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 窃取资源与源对象置空
窃取资源就是把源对象的指针、大小等成员直接复制过来。然后一定要把源对象置为“空”状态——通常是 nullptr 和 0。为什么?因为源对象的析构函数还会被调用,如果它指向有效内存,析构时就会 delete 掉,那我们就白忙活了。
data_,就一定要窃取 other.data_;释放了 size_ 相关的资源(如果有),就窃取 other.size_。
5.3 noexcept 说明符
你注意到我上面写了 noexcept 吗?这不是随便加的。移动赋值运算符通常应该标记为 noexcept,原因有二:
- 性能优化——标准库容器(如
std::vector)在重新分配内存时,如果移动操作是noexcept的,就会优先使用移动而不是拷贝。否则,为了异常安全,它只能选择拷贝。 - 语义正确——移动操作本质上就是“偷资源”,不应该抛出异常。如果可能抛出异常,那说明你的设计有问题。
我记得有一次,一个同事问我:“为什么我的 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 知识体系图
下面这张图总结了移动赋值运算符的核心逻辑和实现路径:
5.6 常见陷阱与避坑指南
最后,我总结几个实际项目中容易踩的坑:
- 忘记释放旧资源——移动构造不需要释放,但移动赋值必须释放。我见过有人直接把移动构造的代码复制过来改个名,结果内存泄漏了。
- 自赋值检查写错——有人写成
if (this != &other)但忘了取地址,变成了比较指针值。嗯,这种bug很难查。 - 没有标记 noexcept——标准库容器会因此放弃使用移动操作,性能大打折扣。
- 源对象置空不彻底——只置空了指针,忘了重置大小或其他成员。析构时虽然不会崩,但逻辑上可能有问题。
好了,移动赋值运算符就讲到这里。记住它的核心:释放旧资源,窃取新资源,处理自赋值。下一章我们聊聊如何让编译器自动生成这些移动操作——也就是“特殊成员函数的生成规则”。