5. 移动赋值运算符:定义与实现,处理自赋值和资源释放

好,咱们接着聊移动语义。上一章我们搞定了移动构造函数,那移动赋值运算符呢?说白了,它就是给已经存在的对象“搬家”用的。你想想看,一个对象已经活在某个地址上了,现在要把另一个对象的资源“偷”过来,同时还得把自己原来的破烂清理干净——这就是移动赋值干的事。

5.1 为什么需要移动赋值运算符

我记得刚开始写C++11代码时,总觉得有移动构造函数就够了。直到有一次我在项目中写了个大矩阵运算的循环:

Matrix a, b;
// ... 初始化 a 和 b ...
a = std::move(b);  // 没有移动赋值,就会走拷贝赋值

结果性能直接崩了。为什么?因为没有定义移动赋值运算符,编译器老老实实调了拷贝赋值,把b的数据完整复制了一份。嗯,这显然不是我们想要的。

移动赋值运算符的核心价值在于:

  • 避免不必要的深拷贝——直接把源对象的资源指针“偷”过来
  • 处理已持有资源的对象——赋值前需要释放旧资源
  • 支持自赋值安全——万一有人写 a = std::move(a) 呢?

5.2 基本定义与实现

先看一个典型的实现模板。我个人习惯把移动赋值运算符写成这样:

class Buffer {
private:
    int* data_;
    size_t size_;
    
public:
    // 移动赋值运算符
    Buffer& operator=(Buffer&& other) noexcept {
        // 1. 自赋值检查
        if (this != &other) {
            // 2. 释放当前资源
            delete[] data_;
            
            // 3. 窃取资源
            data_ = other.data_;
            size_ = other.size_;
            
            // 4. 源对象置为可析构状态
            other.data_ = nullptr;
            other.size_ = 0;
        }
        return *this;
    }
};

这里有几个关键点,我一个个说。

5.2.1 自赋值检查

if (this != &other) 这行代码,看着简单,但曾经坑过我一次。有一次我在一个多线程场景下,不小心让同一个对象自己移动给了自己:

Buffer buf;
buf = std::move(buf);  // 自赋值!

如果没有自赋值检查,会发生什么?先 delete[] data_ 把自己资源释放了,然后 other.data_ 指向的也是同一块内存——已经释放了!接着把 nullptr 赋给自己,数据全丢了。所以这个检查不是可有可无的,是保命的。

注意:自赋值检查虽然重要,但不要滥用。如果你能通过其他方式保证不会自赋值(比如只在明确不同的对象间移动),可以省略这个检查以换取一点性能。但我建议——除非你非常确定,否则还是写上。

5.2.2 资源释放

移动赋值和移动构造最大的区别就在这里。移动构造时,目标对象是“新生的”,没有资源需要释放。但移动赋值时,目标对象可能已经持有资源,必须先释放掉。

我曾经在项目中见过一个bug:有人写移动赋值时忘了释放旧资源,结果内存泄漏了。嗯,这种错误很隐蔽,因为程序跑起来好像没问题,但内存占用会慢慢涨上去。

5.2.3 窃取资源与源对象置空

窃取资源就是把源对象的指针、大小等成员直接复制过来。然后一定要把源对象置为“空”状态——通常是 nullptr0。为什么?因为源对象的析构函数还会被调用,如果它指向有效内存,析构时就会 delete 掉,那我们就白忙活了。

小技巧:我习惯把“释放旧资源”和“窃取新资源”写成对称的操作,这样逻辑清晰,不容易漏掉某个成员。比如释放了 data_,就一定要窃取 other.data_;释放了 size_ 相关的资源(如果有),就窃取 other.size_

5.3 noexcept 说明符

你注意到我上面写了 noexcept 吗?这不是随便加的。移动赋值运算符通常应该标记为 noexcept,原因有二:

  1. 性能优化——标准库容器(如 std::vector)在重新分配内存时,如果移动操作是 noexcept 的,就会优先使用移动而不是拷贝。否则,为了异常安全,它只能选择拷贝。
  2. 语义正确——移动操作本质上就是“偷资源”,不应该抛出异常。如果可能抛出异常,那说明你的设计有问题。

我记得有一次,一个同事问我:“为什么我的 std::vector 扩容时那么慢?”我一看,他的移动赋值运算符没加 noexcept。加上之后,性能直接翻倍。就是这么立竿见影。

5.4 更健壮的实现:copy-and-swap 风格

上面那种实现方式很直接,但有一个潜在问题:如果资源释放失败(比如 delete[] 虽然不会抛异常,但其他资源类型可能),或者代码写复杂了容易出错。我个人其实更推荐另一种风格——copy-and-swap:

class Buffer {
public:
    // 移动赋值运算符(copy-and-swap 风格)
    Buffer& operator=(Buffer other) noexcept {
        swap(*this, other);
        return *this;
    }
    
    friend void swap(Buffer& a, Buffer& b) noexcept {
        using std::swap;
        swap(a.data_, b.data_);
        swap(a.size_, b.size_);
    }
};

等等,这里有个细节:参数是 Buffer other,按值传递。这意味着:

  • 如果传入的是左值,会调用拷贝构造(创建副本)
  • 如果传入的是右值,会调用移动构造(高效转移)

然后我们只需要交换当前对象和 other 的资源。函数结束时,other 被析构,原来的旧资源就被自动释放了。

这种实现的好处是:

  • 自赋值安全——自赋值时,other 是当前对象的副本,交换后还是自己,没问题
  • 异常安全——要么完全成功,要么什么都不变
  • 代码简洁——不需要手动释放资源,交给析构函数处理
核心思想:移动赋值运算符的本质就是“释放旧资源 + 窃取新资源”。无论你选择哪种实现方式,只要把握住这个核心,就不会出错。

5.5 知识体系图

下面这张图总结了移动赋值运算符的核心逻辑和实现路径:

移动赋值运算符核心逻辑 operator=(T&& other) this != &other? 释放旧资源 窃取资源 源对象置空 return *this 核心原则:释放旧资源 → 窃取新资源 → 源对象置空 → 返回自身

5.6 常见陷阱与避坑指南

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

  • 忘记释放旧资源——移动构造不需要释放,但移动赋值必须释放。我见过有人直接把移动构造的代码复制过来改个名,结果内存泄漏了。
  • 自赋值检查写错——有人写成 if (this != &other) 但忘了取地址,变成了比较指针值。嗯,这种bug很难查。
  • 没有标记 noexcept——标准库容器会因此放弃使用移动操作,性能大打折扣。
  • 源对象置空不彻底——只置空了指针,忘了重置大小或其他成员。析构时虽然不会崩,但逻辑上可能有问题。
我曾经踩过的坑:有一次我写了一个带自定义删除器的智能指针类,移动赋值时忘了把源对象的删除器也转移过来。结果源对象析构时,删除器还在,但指针已经空了,导致未定义行为。所以记住——移动赋值要转移所有“资源相关”的成员,不只是指针。

好了,移动赋值运算符就讲到这里。记住它的核心:释放旧资源,窃取新资源,处理自赋值。下一章我们聊聊如何让编译器自动生成这些移动操作——也就是“特殊成员函数的生成规则”。


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