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,以为这样就能强制使用移动构造。结果移动构造也被删了,类直接变成不可复制也不可移动。

如何检查移动构造函数是否被删除?

在实际项目中,我建议你养成两个习惯:

  1. 使用 static_assert 检查:
static_assert(std::is_move_constructible_v<MyClass>,
              "MyClass must be move constructible!");
  1. 使用 = default 显式声明:如果你希望编译器生成移动构造函数,就显式写出来:
class MyClass {
public:
    MyClass(MyClass&&) = default;  // 显式要求编译器生成
    ~MyClass() = default;           // 即使有析构函数,移动构造依然存在
};

但要注意:即使你写了 = default,如果基类或成员的移动构造被删除,编译器仍然会报错。所以 = default 只是「请求」,不是「命令」。

知识体系图:移动构造函数的隐式删除规则

移动构造函数 用户声明了析构函数 移动构造函数被删除 用户声明了拷贝构造函数 移动构造函数被删除 用户声明了拷贝赋值运算符 移动构造函数被删除 用户声明了移动赋值运算符 移动构造函数被删除 基类或成员变量的移动构造被删除/不可访问

总结:记住这三句话

好了,关于隐式删除移动构造函数,你只需要记住:

  • 五法则中的任何一个「用户声明」都会杀死移动构造函数——析构、拷贝构造、拷贝赋值、移动赋值,碰一个就没了。
  • 基类和成员「拖后腿」也会导致删除——哪怕你自己什么都没声明,只要有个成员不能移动,整个类就不能移动。
  • 显式 = default 是好朋友——如果你确定需要移动语义,就大大方方写出来,别指望编译器猜你的心思。

我在代码审查中经常看到这样的问题:一个类写了析构函数,然后整个项目里到处是拷贝,性能差得离谱。其实只要加一行 MyClass(MyClass&&) = default;,问题就解决了。嗯,有时候就是这么简单。

专注资料整理