4. 重载赋值运算符:深拷贝、浅拷贝与自赋值
好,咱们今天聊一个非常实在的话题——赋值运算符重载。说实话,这个运算符是C++里最容易出坑的地方之一。我见过不少项目,代码跑着跑着突然崩了,查到最后,十有八九是赋值运算符没写好。
为什么?因为编译器会偷偷给你生成一个默认的赋值运算符。它看起来挺聪明,实际上就是个“浅拷贝”的搬运工。你想想看,如果类里有个指针成员,它直接把指针值复制过去——两个对象指向同一块内存。然后呢?析构的时候double free,程序直接挂掉。
浅拷贝 vs 深拷贝
先看一个典型的“踩坑”例子:
class String {
public:
String(const char* str = "") {
m_data = new char[strlen(str) + 1];
strcpy(m_data, str);
}
~String() { delete[] m_data; }
// 编译器生成的默认赋值运算符(浅拷贝)
// String& operator=(const String& other) {
// m_data = other.m_data; // 危险!两个对象共享同一块内存
// return *this;
// }
private:
char* m_data;
};
int main() {
String s1("Hello");
String s2("World");
s2 = s1; // 浅拷贝:s2.m_data 和 s1.m_data 指向同一地址
// 析构时:s1 释放内存,s2 再次释放 → 崩溃
}
为什么会这样?因为默认赋值运算符只是把成员变量逐个复制。对于指针,它复制的是地址,而不是地址指向的内容。这就是浅拷贝。
深拷贝就不一样了——它要复制指针指向的整个数据块。说白了,就是“你有一份,我也有一份,互不干扰”。
核心区别:
- 浅拷贝:复制指针值,两个对象共享同一块内存
- 深拷贝:复制指针指向的内容,每个对象拥有独立的内存
重载赋值运算符的正确姿势
好,那怎么写一个靠谱的赋值运算符?我个人习惯用“拷贝并交换”这个惯用法,它既简洁又安全。不过咱们先看一个更直观的写法:
class String {
public:
String& operator=(const String& other) {
// 1. 处理自赋值
if (this == &other) {
return *this;
}
// 2. 释放当前资源
delete[] m_data;
// 3. 分配新内存并复制数据
m_data = new char[strlen(other.m_data) + 1];
strcpy(m_data, other.m_data);
return *this;
}
private:
char* m_data;
};
嗯,这里要注意几个关键点:
- 自赋值检查:
if (this == &other)这一步不能省。我曾经在一个项目里没写这个检查,结果用户代码里写了obj = obj,直接把自己释放了——那场面,惨不忍睹。 - 先释放,再分配:顺序很重要。如果先分配内存失败(比如内存不足),原来的资源已经被释放了,对象就处于一个“半死不活”的状态。更好的做法是用临时变量做拷贝,然后交换。
- 返回引用:返回
*this是为了支持链式赋值,比如a = b = c。
避坑指南: 我曾经在代码里忘了处理自赋值,结果线上服务偶尔崩溃,查了两天才定位到。自赋值看起来是“不可能发生”的事,但在容器、算法里其实很常见。别偷懒,一定要写检查。
拷贝并交换惯用法
如果你觉得上面的写法有点啰嗦,我推荐一个更优雅的方式——拷贝并交换(Copy-and-Swap)。它利用拷贝构造函数和swap函数,把赋值操作变得异常安全:
class String {
public:
// 拷贝构造函数(深拷贝)
String(const String& other)
: m_data(new char[strlen(other.m_data) + 1]) {
strcpy(m_data, other.m_data);
}
// swap 函数
void swap(String& other) noexcept {
std::swap(m_data, other.m_data);
}
// 赋值运算符(拷贝并交换)
String& operator=(String other) { // 注意:传值,不是传引用
swap(other); // 交换当前对象和临时对象
return *this;
}
private:
char* m_data;
};
这个写法妙在哪里?
operator=的参数是传值,所以会调用拷贝构造函数创建一个临时对象。如果拷贝失败(比如内存不足),异常会抛出,当前对象不会被修改——这就是异常安全。swap只是交换指针,不会抛异常(noexcept)。- 临时对象在函数结束时自动析构,释放原来的资源。
说白了,就是把“释放旧资源”和“复制新资源”这两个操作拆开了,让拷贝构造函数去负责复制,让析构函数去负责释放。赋值运算符只做一件事——交换指针。干净利落。
个人经验: 我建议所有需要深拷贝的类都优先考虑拷贝并交换。它写起来简单,而且天然支持异常安全。不过要注意,如果类里有大量资源需要复制,传值可能会带来额外的拷贝开销。这时候可以用传统的写法,但一定要小心异常安全。
自赋值的处理
自赋值的问题,说白了就是 obj = obj 这种写法。虽然程序员一般不会这么写,但在某些场景下它确实会发生:
- 容器操作:比如
v[i] = v[j],如果 i == j,就是自赋值。 - 算法实现:某些排序、去重算法可能会产生自赋值。
- 模板代码:泛型代码里,类型不确定,自赋值是可能发生的。
在拷贝并交换的写法里,自赋值是安全的。因为传值会调用拷贝构造函数,创建一个临时对象,然后交换指针。即使 this == &other,临时对象也是独立的拷贝,交换后当前对象拿到的是自己的拷贝,临时对象析构时释放的是原来的资源——没问题。
但在传统写法里,自赋值检查是必须的。否则 delete[] m_data 会释放自己的资源,然后 strcpy 从已经释放的内存里读数据——未定义行为,程序随时可能崩溃。
知识结构图
下面这张图总结了赋值运算符重载的核心逻辑:
总结
赋值运算符重载,说白了就三件事:
- 深拷贝:复制指针指向的内容,而不是指针本身。
- 自赋值检查:防止自己释放自己。
- 异常安全:要么成功,要么保持原状。
我个人更推荐拷贝并交换的写法,它把这三个问题一次性解决了。当然,如果你对性能有极致要求,传统写法也可以,但一定要把异常安全考虑清楚。
嗯,赋值运算符就聊到这里。记住一句话:如果类里有指针成员,一定要自己写赋值运算符。编译器生成的默认版本,真的靠不住。
公众号:蓝海资料掘金营,微信deep3321