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 与拷贝赋值运算符的区别
这两个家伙看起来很像,但本质完全不同。我画了个对比图,你一看就明白:
从图上能清楚看到:拷贝赋值是“复制一份”,移动赋值是“直接拿过来”。
我把它们的区别整理成了一张表:
| 对比维度 | 拷贝赋值运算符 | 移动赋值运算符 |
|---|---|---|
| 参数类型 | const T& |
T&& |
| 资源处理 | 深拷贝(分配新内存) | 浅拷贝 + 源对象置空 |
| 性能开销 | O(n),可能很大 | O(1),常数时间 |
| 异常安全 | 通常强异常安全保证 | 通常不抛异常(noexcept) |
| 源对象状态 | 不变 | 变为空/有效但未指定状态 |
核心区别一句话:拷贝赋值是“我复制一份,你的还是你的”;移动赋值是“你的就是我的,你的我不管了”。
3.3 自赋值检测与资源处理
自赋值检测,就是检查 this != &other。你可能会想:谁会写 a = std::move(a) 这种代码?
嗯,确实很少直接这么写。但我在项目中遇到过这种情况:
- 容器算法里,元素自我交换时可能触发
- 模板代码中,类型推导导致左右值混淆
- 某些 STL 算法的内部实现
我曾经踩过的坑:有一次没写自赋值检测,结果
a = std::move(a) 直接把 a 的资源释放了,然后又把 nullptr 赋给自己。程序跑起来就崩,查了半天才发现是自赋值的问题。
那资源处理要注意什么?我总结了三条铁律:
- 先释放,后接管——顺序不能乱。先释放自己的资源,再接管别人的
- 源对象必须置空——否则析构时会把资源释放掉,造成 double free
- 保持 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),咱们后面会专门讲。
好了,移动赋值运算符就聊到这里。记住三个关键词:自赋值检测、资源转移、源对象置空。写代码的时候多留个心眼,别让自赋值把你坑了。