最近看到的部分代码中,赋值运算符重载总是忘记自赋值的处理,但是自赋值的处理又非常重要,所以我决定写这篇文章。
1. 重要前提
并不是所有的场景下自我赋值都会导致问题。自赋值风险只针对手动资源管理的类/结构体,满足如下条件的类/结构体,自我赋值完全安全:
- 类成员全部为基础栈类型:
int、char、double等; - 使用标准库容器:
std::string、std::vector(内部已做安全处理); - 使用编译器默认生成的赋值运算符(仅逐成员浅拷贝,无手动释放逻辑)。
虽然这种场景下,自赋值只会自己覆盖自己,数据不变、无崩溃、无资源泄漏。但是也不推荐,毕竟这些成员之间的赋值也是有CPU开销的。
2. 风险原因
前文提到,自赋值风险只针对手动资源管理的类/结构体 。这主要是因为,手动资源管理的类/结构体,其赋值运算符重载的常规逻辑是:先释放旧资源 → 再拷贝新资源,形如如下的代码:
cpp
#include <cstring>
#include <iostream>
class MyStr {
public:
// 构造函数与析构函数省略
// 传统赋值:先释放旧资源,再拷贝新资源(自赋值时致命)
MyStr& operator=(const MyStr& rhs) {
delete[] data_; // 1. 先释放自身资源
data_ = new char[std::strlen(rhs.data_) + 1]; // 2. 再分配新资源
std::strcpy(data_, rhs.data_); // 3. 拷贝数据(此时 rhs.data_ 已被释放,野指针)
return *this;
}
const char* c_str() const { return data_; }
private:
char* data_; // 管理堆内存
};
正是这样的代码,如果存在MyStr类型的对象发生了自赋值时,赋值函数内 this == &rhs,两个指针指向同一个对象。程序会先执行「释放自身堆内存」的逻辑,提前销毁了自己的数据;后续再读取源对象数据进行拷贝时,原内存已被释放,变成野指针,直接触发程序崩溃。 如上只是以管理内存为例,持有文件句柄、网络套接字等资源的类,也会面临类似问题。
3. 解决方案
3.1 区分自我
通常,最常用的解决方案,是在赋值函数开头增加自我赋值的判断,不允许自我赋值发生。
cpp
MyStr& operator=(const MyStr& rhs) {
if (this == &rhs) { // 自赋值拦截
return *this;
}
delete[] data_;
data_ = new char[std::strlen(rhs.data_) + 1];
std::strcpy(data_, rhs.data_);
return *this;
}
3.2 Copy-and-Swap 机制
前文提到自赋值风险是因为先释放旧资源,再拷贝新资源 导致的,只要打破这个顺序,就可以避免风险。Copy-and-Swap 机制,就是利用了这个原理,它先拷贝、再交换、最后释放旧资源 。它从底层架构上天然规避自赋值风险,自带异常安全特性,还无需人工判断。 所谓的先拷贝、再交换、最后释放旧资源,借助临时对象,将所有风险操作(内存分配、数据拷贝)转移到临时对象,原对象全程保持安全,不会被篡改、不会提前释放资源。实现写法如下:
cpp
#include <utility>
#include <cstring>
class MyStr {
public:
MyStr(const char* s = "") {
data_ = new char[std::strlen(s) + 1];
std::strcpy(data_, s);
}
// 拷贝构造:用于生成临时对象
MyStr(const MyStr& other) {
data_ = new char[std::strlen(other.data_) + 1];
std::strcpy(data_, other.data_);
}
~MyStr() { delete[] data_; }
// 交换内部资源,noexcept 保证不抛异常
void swap(MyStr& other) noexcept {
std::swap(data_, other.data_);
}
// 范式一:const 引用传参 + 手动临时对象
MyStr& operator=(const MyStr& rhs) {
MyStr(rhs).swap(*this); // 拷贝出临时对象,交换后旧资源随临时对象析构
return *this;
}
// 范式二:值传递自动生成临时对象(与范式一等价,二选一即可)
// MyStr& operator=(MyStr rhs) noexcept {
// swap(rhs);
// return *this;
// }
private:
char* data_; // 管理堆内存
};
如上代码中,范式一主动构建临时对象,范式二借助值传递自动生成临时对象。二者等价,二选一即可。
4. 总结
自我赋值的风险不在于语法违规,而在于传统赋值「先释放、后拷贝」的资源逻辑,会导致自赋值时数据提前销毁、程序崩溃。简单栈对象的自赋值是安全的,但手动管理堆资源的自定义类必须严防自赋值。传统的 if 判断只能解决自赋值崩溃,无法解决异常安全问题,工程中不推荐;Copy-and-Swap 是工业级标准解法,通过临时对象隔离风险,同时实现自赋值安全与异常安全,是资源类赋值运算符的推荐实现。