4. 移动构造函数和移动赋值运算符如何编写?

好,咱们直接进入正题。移动构造函数和移动赋值运算符,说白了就是C++11给类增加的“搬家”能力。你想想看,以前拷贝一个对象,吭哧吭哧把数据全复制一遍,遇到大块内存资源,那叫一个心疼。现在有了移动语义,我们可以把资源“偷”过来,让原对象变成空壳子。

我个人习惯把这两个函数称为“五法则”里的新成员。嗯,这里要注意,它们和拷贝构造、拷贝赋值、析构函数一起,构成了现代C++类的资源管理核心。

4.1 基本写法模板

先看一个最典型的例子。假设我们有一个管理动态数组的类 MyVector

class MyVector {
public:
    // 默认构造函数
    MyVector() : data_(nullptr), size_(0) {}

    // 移动构造函数
    MyVector(MyVector&& other) noexcept
        : data_(other.data_)
        , size_(other.size_) {
        other.data_ = nullptr;
        other.size_ = 0;
    }

    // 移动赋值运算符
    MyVector& operator=(MyVector&& other) noexcept {
        if (this != &other) {
            delete[] data_;          // 释放当前资源
            data_ = other.data_;     // 偷走对方的资源
            size_ = other.size_;
            other.data_ = nullptr;   // 对方置空
            other.size_ = 0;
        }
        return *this;
    }

    ~MyVector() { delete[] data_; }

private:
    int* data_;
    size_t size_;
};

看到没?移动构造其实就是“指针交换”加“原指针置空”。移动赋值多了一步:先释放自己原有的资源。我在项目中遇到过有人忘了释放,结果内存泄漏,排查了半天。

4.2 为什么必须加 noexcept?

你可能会问,为什么移动操作要标记 noexcept

原因很实在:标准库容器(比如 std::vector)在重新分配内存时,会优先选择移动操作。但如果移动操作可能抛异常,它就只能退而求其次用拷贝。你想啊,如果移动一半抛异常了,原对象已经被修改,数据就丢了。所以标准库要求:移动操作必须保证不抛异常,它才敢放心用。

我曾经在一个项目中,因为忘了加 noexcept,导致 std::vector 插入大量元素时性能骤降。排查了半天才发现是移动操作没标记异常安全。从那以后,我写移动构造函数和移动赋值运算符的第一件事就是加上 noexcept

核心原则:移动操作应该是不抛异常的。如果实在无法保证,那就别写移动语义,老老实实用拷贝。

4.3 自赋值检查有必要吗?

移动赋值运算符里,我写了 if (this != &other) 这个检查。有人觉得多余,因为谁会对自己移动自己呢?

嗯,理论上确实很少见。但我在代码审查时见过这样的写法:

v = std::move(v);  // 对自己移动赋值

虽然看起来蠢,但某些泛型代码里可能无意中触发。如果不做自赋值检查,delete[] data_ 会把 other.data_ 也删掉,然后 other.data_ 变成悬空指针,程序直接崩溃。

所以我的建议是:加上自赋值检查,成本极低,收益很高。别省这一行代码。

4.4 移动后的对象应该处于什么状态?

C++标准只说“移动后的对象处于有效但未指定的状态”。说白了,就是你可以对它重新赋值,也可以让它析构,但不能假设它还有原来的值。

我个人习惯把移动后的对象置为“空状态”:指针置 nullptr,大小置 0。这样最安全,也最容易调试。你想想看,如果移动后还留着一些数据,万一有人误用了,bug 会非常隐蔽。

小技巧:可以写一个 swap 函数来辅助实现移动赋值,代码会更简洁:

void swap(MyVector& other) noexcept {
    using std::swap;
    swap(data_, other.data_);
    swap(size_, other.size_);
}

MyVector& operator=(MyVector&& other) noexcept {
    swap(other);  // 把自己和对方的资源交换
    return *this;
}

这样写的好处是:异常安全,而且代码一目了然。

4.5 什么时候编译器会生成默认的移动操作?

如果你没有声明任何拷贝构造、拷贝赋值、析构函数,编译器可能会自动生成移动构造函数和移动赋值运算符。但一旦你声明了其中任何一个,编译器就不会再自动生成了。

我见过不少新手踩这个坑:写了一个析构函数释放资源,以为编译器会自动生成移动操作,结果性能惨不忍睹。记住:写了析构函数,就要手动考虑移动操作

4.6 移动操作与资源管理的完整流程

下面这张图展示了移动构造函数和移动赋值运算符在整个资源生命周期中的位置:

移动操作在资源管理中的位置 资源创建 拷贝构造/赋值 移动构造/赋值 其他成员函数 资源使用 析构函数释放资源 深拷贝,开销大 浅拷贝+置空,高效 正常使用资源

从图中可以看出,移动操作和拷贝操作是并行的两条路径。移动操作通过“偷取”资源来避免深拷贝,但最终资源还是要由析构函数来释放。移动后的原对象处于空状态,它的析构函数会安全地释放空指针。

4.7 实战中的常见陷阱

最后,我总结几个实际项目中容易踩的坑:

  • 忘记释放当前资源:移动赋值里,如果忘了 delete[] data_,每次移动都会泄漏一份资源。我曾经在代码审查时发现一个类泄漏了上百MB内存,就是这个问题。
  • 移动后原对象还能用吗?:不能假设它还有值。但你可以重新给它赋值,或者让它析构。仅此而已。
  • 不要手动调用 std::move 除非必要:让编译器自动选择移动还是拷贝。你只需要保证移动操作正确且高效。
  • 继承体系中的移动:如果基类有移动操作,派生类也要正确调用基类的移动构造/赋值,否则基类部分会被拷贝。

警告:移动操作不是万能的。对于小型对象(比如 std::pair<int, int>),拷贝可能比移动更快。不要盲目追求移动语义,该拷贝时就拷贝。

好了,关于移动构造函数和移动赋值运算符的写法,就聊到这里。记住核心三要素:偷资源、置空原对象、标记 noexcept。把这三点做到位,你的移动操作就基本合格了。


公众号:蓝海资料掘金营,微信deep3321