22. 在什么情况下,编译器会隐式删除移动构造函数?
移动构造函数这东西,说起来挺有意思。它不像拷贝构造函数那样「老实巴交」——你只要不声明,编译器就乖乖给你生成一个。移动构造函数的生成规则,说白了就是「疑罪从无」:只要编译器觉得你可能有别的想法,它就直接把移动构造函数给删了。
我在项目中遇到过好几次这样的坑:明明写了 std::move(),结果对象还是被拷贝了。查了半天,发现是移动构造函数被隐式删除了。嗯,今天我们就来彻底搞清楚,编译器在什么情况下会「狠心」删掉移动构造函数。
核心规则:五法则的连锁反应
先记住一句话:移动构造函数的隐式声明,比拷贝构造函数更脆弱。你想想看,拷贝构造函数只要你不声明拷贝赋值、析构函数,它大概率还能生成。但移动构造函数?稍微有点风吹草动,它就没了。
具体来说,编译器会在以下情况隐式删除移动构造函数:
隐式删除移动构造函数的条件(满足任一即可):
- 用户声明了析构函数
- 用户声明了拷贝构造函数(无论是否
= delete) - 用户声明了拷贝赋值运算符
- 用户声明了移动赋值运算符
- 基类或成员变量的移动构造函数被删除或不可访问
- 基类或成员变量的析构函数被删除或不可访问
说白了,只要你碰了「五法则」中的任何一个成员(析构、拷贝构造、拷贝赋值、移动赋值),编译器就会认为你打算自己管理资源,于是它就不敢自动生成移动构造函数了。
逐条拆解:为什么这些规则存在?
1. 用户声明了析构函数
这是最常见的坑。我记得有一次,一个同事写了个简单的资源管理类,只写了个析构函数来释放内存,没写移动构造函数。结果在 std::vector 里插入元素时,性能惨不忍睹——每次扩容都在拷贝,而不是移动。
为什么会这样?因为如果你自己写了析构函数,编译器会想:「嗯,这家伙肯定在管理某种资源。那我不能随便生成移动构造函数,万一他把资源移动走了,析构函数又去释放,不就 double free 了吗?」
class Buffer {
int* data;
size_t size;
public:
~Buffer() { delete[] data; } // 声明了析构函数
// 移动构造函数被隐式删除!
};
void test() {
Buffer b1;
Buffer b2 = std::move(b1); // 编译错误:移动构造函数被删除
// 实际上会尝试调用拷贝构造函数(如果存在),否则编译失败
}
我的建议:如果你写了析构函数,请同时考虑移动构造函数和移动赋值运算符。要么显式 = default,要么自己实现。别指望编译器帮你生成。
2. 用户声明了拷贝构造函数或拷贝赋值运算符
这个规则其实很直观。你想想看,如果你自己定义了拷贝行为,说明你对「如何复制这个对象」有特殊要求。那编译器怎么知道你移动的时候该怎么处理?它干脆就不生成了。
class Widget {
public:
Widget(const Widget& other) { /* 自定义拷贝 */ }
// 移动构造函数被隐式删除!
};
void test() {
Widget w1;
Widget w2 = std::move(w1); // 调用拷贝构造函数,而不是移动
}
这里有个细节要注意:即使你把拷贝构造函数声明为 = delete,移动构造函数同样会被删除。编译器只看「你声明了没」,不管你是允许还是禁止。
3. 用户声明了移动赋值运算符
这个规则可能有点反直觉。为什么声明了移动赋值,移动构造就被删了?
我个人理解是:C++ 标准委员会觉得,如果你手动实现了移动赋值,说明你对移动语义有特殊的管理需求。那移动构造和移动赋值在语义上应该是「对称」的——你既然自己管了赋值,构造也应该自己管。所以编译器干脆把移动构造删了,逼你显式处理。
class Gadget {
public:
Gadget& operator=(Gadget&&) noexcept { /* 自定义移动赋值 */ }
// 移动构造函数被隐式删除!
};
4. 基类或成员变量的移动构造函数被删除
这个规则其实很合理。如果一个类包含了一个「不能移动」的成员,那整个类自然也不能移动。比如:
struct NonMovable {
NonMovable(NonMovable&&) = delete;
};
struct Container {
NonMovable member; // 成员不可移动
// Container 的移动构造函数被隐式删除!
};
同样,如果基类的移动构造函数被删除,派生类的移动构造函数也会被删除。
一个完整的例子:看看编译器怎么「变脸」
下面这个例子,我故意加了很多注释,方便你对照着看:
#include <type_traits>
#include <iostream>
class Example {
public:
// 情况A:什么都不声明
// 编译器会生成移动构造函数(如果所有成员都可移动)
// 情况B:声明析构函数
// ~Example() = default; // 即使 = default,也算「用户声明」
// 移动构造函数被删除!
// 情况C:声明拷贝构造
// Example(const Example&) = default;
// 移动构造函数被删除!
// 情况D:声明拷贝赋值
// Example& operator=(const Example&) = default;
// 移动构造函数被删除!
// 情况E:声明移动赋值
// Example& operator=(Example&&) = default;
// 移动构造函数被删除!
};
int main() {
std::cout << std::is_move_constructible_v<Example> << std::endl;
// 根据上面的注释,取消注释不同情况,观察输出
}
避坑指南:我踩过的那些坑
我曾经犯过的错误:
- 在基类中只写了虚析构函数,忘了移动构造。结果派生类在
std::vector里扩容时,全部走拷贝,性能直接崩了。 - 为了「简洁」,在类里只声明了拷贝构造和析构,以为编译器会「智能地」生成移动构造。结果被坑了整整一个下午。
- 把拷贝构造函数声明为
= delete,以为这样就能强制使用移动构造。结果移动构造也被删了,类直接变成不可复制也不可移动。
如何检查移动构造函数是否被删除?
在实际项目中,我建议你养成两个习惯:
- 使用
static_assert检查:
static_assert(std::is_move_constructible_v<MyClass>,
"MyClass must be move constructible!");
- 使用
= default显式声明:如果你希望编译器生成移动构造函数,就显式写出来:
class MyClass {
public:
MyClass(MyClass&&) = default; // 显式要求编译器生成
~MyClass() = default; // 即使有析构函数,移动构造依然存在
};
但要注意:即使你写了 = default,如果基类或成员的移动构造被删除,编译器仍然会报错。所以 = default 只是「请求」,不是「命令」。
知识体系图:移动构造函数的隐式删除规则
总结:记住这三句话
好了,关于隐式删除移动构造函数,你只需要记住:
- 五法则中的任何一个「用户声明」都会杀死移动构造函数——析构、拷贝构造、拷贝赋值、移动赋值,碰一个就没了。
- 基类和成员「拖后腿」也会导致删除——哪怕你自己什么都没声明,只要有个成员不能移动,整个类就不能移动。
- 显式
= default是好朋友——如果你确定需要移动语义,就大大方方写出来,别指望编译器猜你的心思。
我在代码审查中经常看到这样的问题:一个类写了析构函数,然后整个项目里到处是拷贝,性能差得离谱。其实只要加一行 MyClass(MyClass&&) = default;,问题就解决了。嗯,有时候就是这么简单。