[c++]string赋值运算符重载:从“砸碗“到优雅的“换碗仪式“
一、为什么需要赋值运算符重载?
使用编译器默认生成的赋值运算符重载,而这个对象有申请动态分配的资源时,使用编译器默认生成的赋值运算符重载会带来两个问题。
二、浅拷贝的两个致命问题:
1.1、同一个空间析构释放两次
赋值运算符重载中深拷贝用来解决浅拷贝带来的危害的,如果对象有在堆区申请内存,那么赋值运算符重载浅拷贝会造成同一块内存会被释放两次。
1.2、内存泄漏
内存泄漏,被赋值的那个对象中的_str指向了赋值对象_str指向的空间,而被赋值的对象_str原先的空间没有指针指向了,造成内存泄漏,因此赋值运算符重载对于有动态分配资源的对象进行赋值操作时,需要深拷贝来解决问题。
形象比喻:在C++中,当类包含动态分配的资源时,默认的赋值操作(浅拷贝)会带来严重问题。想象一下两个人共用一个碗吃饭,当其中一个人决定换碗时,如果处理不当,可能会把另一个人的饭碗也砸了。这就是我们需要自定义赋值运算符重载的原因。
三、赋值运算符重载传统写法:粗暴的"砸碗"操作
传统写法是自己完成开空间,拷贝内容到新空间,释放旧空间,然后_str指向新空间,并且更新成员变量,也就是_size和_capacity。
1、"砸碗"比喻解析
场景:A = B(赋值操作)
买新碗:new char[]创建新内存盛饭:memcpy复制B的数据到新内存砸旧碗:delete[] _str释放A的旧内存用新碗:_str = temp让A指向新内存
自赋值的危险:A = A
A和B其实是同一个人,共用同一个碗买新碗、盛饭都正常砸旧碗时把唯一的碗砸了!后续操作无法进行
string& operator=(const string& s) { if (this != &s) { // 自赋值检查:确保不是自己给自己赋值 char* temp = new char[s._capacity + 1]; // 1. 买新碗 memcpy(temp, s._str, s._size + 1); // 2. 把旧碗的饭盛到新碗 delete[] _str; // 3. 砸旧碗 _str = temp; // 4. 用新碗 _size = s._size; _capacity = s._capacity; } return *this; }2、赋值运算符重载传统写法-自赋值问题
我们来看传统写法中自赋值的问题。假设我们有一个字符串对象,我们将其赋值给自己,即 str = str;。在传统写法中,如果没有自赋值检查,代码会这样执行:
分配新内存:char* temp = new char[s._capacity + 1];复制数据:memcpy(temp, s._str, s._size + 1);释放旧内存:delete[] _str;更新指针:_str = temp;
现在,如果发生自赋值,那么 s 和 *this 是同一个对象。因此,在第3步释放 _str 时,实际上也释放了 s._str,因为它们指向同一块内存。然后,在第2步中,我们试图从已经被释放的内存中复制数据(s._str 现在是一个悬空指针),这会导致未定义行为(通常程序崩溃)。
所以,自赋值检查 if (this != &s) 是为了避免在自赋值时出现先释放再使用的问题。
string& operator=(const string& s) { // 假设没有 if (this != &s) 检查 // 1. 分配新内存 char* temp = new char[s._capacity + 1]; // 分配新空间 // 2. 复制数据:memcpy(temp, s._str, s._size + 1); // 此时 s._str 和 this->_str 指向同一块内存 // 3. 释放旧内存:delete[] _str; // 这里释放了 _str,但 s._str 也指向同一块内存! // 现在 s._str 变成了悬空指针 // 4. 更新指针:_str = temp; return *this; }四、赋值运算符重载传统写法:优雅的"换碗仪式"
现代写法是把自己完成开空间,拷贝内容到新空间,释放旧空间,然后_str指向新空间,并且更新_size和_capacity,这些操作交给拷贝构造去做,拷贝构造出一个临时对象,然后再与这个临时对象交换内容,并且让临时对象生命周期结束的时候调用析构函数来清理自己原本的空间,而自己完成了赋值的深拷贝

场景:A = B(赋值操作)
准备阶段(传值参数):厨师用B的配方重新做一份菜(深拷贝临时对象)新菜和原菜食材独立,味道相同交换阶段(swap操作):A说:"我把我的剩菜给你,你把新菜给我"双方优雅交换碗,不破坏任何食物清理阶段(析构临时对象):服务员收走A的剩菜剩饭(临时对象析构)新菜完好留在A的桌上
自赋值安全:A = A
厨师用A的配方做新菜(创建副本)A与新菜交换服务员收走A的旧菜结果:A的菜品保持不变
阶段一:引入拷贝构造辅助
string& operator=(const string& s) { if (this != &s) { string temp(s); // 拷贝构造创建临时对象 std::swap(_str, temp._str); // 交换资源 std::swap(_size, temp._size); std::swap(_capacity, temp._capacity); } return *this; }阶段二:封装Swap操作
void swap(string& temp) { std::swap(_str, temp._str); std::swap(_size, temp._size); std::swap(_capacity, temp._capacity); } string& operator=(const string& s) { if (this != &s) { string temp(s);// 厨师做新菜 swap(temp);// 优雅换碗 } return *this; }阶段三:终极版Copy-and-Swap
在这个版本中,我们利用了一个事实:参数s是一个临时对象,它是传入对象的副本。我们直接交换当前对象和s,这样当前对象就拥有了传入对象的副本,而s则拥有了当前对象原来的资源。当函数返回时,s超出作用域,析构函数被调用,释放掉原对象资源。
自赋值的情况:当发生自赋值时,比如 str = str,传值会调用拷贝构造函数,创建一个与str相同的临时对象,然后交换,临时对象被销毁,最终str还是原来的值。虽然效率低一些,但是正确。最终版本中不需要显式检查自赋值,因为自赋值的情况也能正确工作。
总结:最终版本的赋值运算符重载代码简洁、安全(异常安全、自赋值安全),是推荐的写法。需要注意的是,这种写法依赖于拷贝构造函数的正确实现和析构函数的正确实现。
void swap(string& s) { std::swap(_str, s._str); std::swap(_size, s._size); std::swap(_capacity, s._capacity); } string& operator=(string s)// 传值参数:自动创建副本 { swap(s);// 交换资源 return *this;// 临时对象析构清理旧资源 }