3、移动赋值运算符:移动赋值运算符的定义、与拷贝赋值运算符的区别、自赋值检测与资源处理

好,咱们接着聊移动语义。上一节我们讲了移动构造函数,那移动赋值运算符自然也不能缺席。说实话,在项目里,移动赋值运算符的使用频率甚至比移动构造函数还高——毕竟谁还没写过几个 a = std::move(b) 呢?

3.1 移动赋值运算符的定义

移动赋值运算符,说白了就是重载 operator=,只不过参数是右值引用。它的核心任务是:把源对象的资源“偷”过来,同时释放自己原有的资源

来看一个典型的实现:

class Buffer {
public:
    // 移动赋值运算符
    Buffer& operator=(Buffer&& other) noexcept {
        if (this != &other) {          // 自赋值检测
            delete[] data_;            // 释放当前资源
            data_ = other.data_;       // 接管源对象的资源
            size_ = other.size_;
            other.data_ = nullptr;     // 源对象置空
            other.size_ = 0;
        }
        return *this;
    }

private:
    char* data_ = nullptr;
    size_t size_ = 0;
};

嗯,这里有几个关键点:

  • 返回类型是 Buffer&——支持链式赋值,比如 a = b = c
  • 参数是 Buffer&&——只接受右值
  • 标记 noexcept——移动操作不应该抛异常,这是约定俗成的
  • 源对象要置空——否则析构时会把资源释放掉,你就白忙活了
我的习惯:移动赋值运算符和移动构造函数,我通常会一起实现。因为它们的逻辑高度相似,漏掉一个就容易出问题。

3.2 与拷贝赋值运算符的区别

这两个家伙看起来很像,但本质完全不同。我画了个对比图,你一看就明白:

拷贝赋值运算符 源对象 data: 0x1000 目标对象 data: 0x2000 复制数据 内存块 A 内存块 B 两块内存独立存在 源对象不受影响 移动赋值运算符 源对象 data: 0x1000 目标对象 data: 0x2000 转移所有权 内存块 A 已释放 目标对象接管内存块 A 源对象被置空

从图上能清楚看到:拷贝赋值是“复制一份”,移动赋值是“直接拿过来”。

我把它们的区别整理成了一张表:

对比维度 拷贝赋值运算符 移动赋值运算符
参数类型 const T& T&&
资源处理 深拷贝(分配新内存) 浅拷贝 + 源对象置空
性能开销 O(n),可能很大 O(1),常数时间
异常安全 通常强异常安全保证 通常不抛异常(noexcept)
源对象状态 不变 变为空/有效但未指定状态
核心区别一句话:拷贝赋值是“我复制一份,你的还是你的”;移动赋值是“你的就是我的,你的我不管了”。

3.3 自赋值检测与资源处理

自赋值检测,就是检查 this != &other。你可能会想:谁会写 a = std::move(a) 这种代码?

嗯,确实很少直接这么写。但我在项目中遇到过这种情况:

  • 容器算法里,元素自我交换时可能触发
  • 模板代码中,类型推导导致左右值混淆
  • 某些 STL 算法的内部实现
我曾经踩过的坑:有一次没写自赋值检测,结果 a = std::move(a) 直接把 a 的资源释放了,然后又把 nullptr 赋给自己。程序跑起来就崩,查了半天才发现是自赋值的问题。

那资源处理要注意什么?我总结了三条铁律:

  1. 先释放,后接管——顺序不能乱。先释放自己的资源,再接管别人的
  2. 源对象必须置空——否则析构时会把资源释放掉,造成 double free
  3. 保持 noexcept——移动操作不应该失败,这是 C++ 标准库的约定

来看一个更完整的实现,加入了异常安全考虑:

class Buffer {
public:
    // 移动赋值运算符(推荐写法)
    Buffer& operator=(Buffer&& other) noexcept {
        if (this != &other) {
            // 先释放当前资源
            delete[] data_;
            
            // 接管源对象的资源
            data_ = other.data_;
            size_ = other.size_;
            
            // 源对象置空
            other.data_ = nullptr;
            other.size_ = 0;
        }
        return *this;
    }

    // 另一种写法:先交换再释放(copy-and-swap 变体)
    Buffer& operator=(Buffer&& other) noexcept {
        if (this != &other) {
            Buffer temp(std::move(other));
            swap(temp);
        }
        return *this;
    }

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

    char* data_ = nullptr;
    size_t size_ = 0;
};
我个人偏好:第二种写法(交换法)更安全,因为即使移动构造抛异常,当前对象的状态也不会被破坏。不过既然移动构造是 noexcept 的,两种写法其实都可以。

最后说一个细节:移动赋值运算符和析构函数的关系。如果你定义了移动赋值运算符,通常也需要定义析构函数来释放资源。反过来,如果你定义了析构函数,编译器就不会自动生成移动赋值运算符了。这个规则叫“五法则”(Rule of Five),咱们后面会专门讲。

好了,移动赋值运算符就聊到这里。记住三个关键词:自赋值检测、资源转移、源对象置空。写代码的时候多留个心眼,别让自赋值把你坑了。