【c++面向对象编程】第5篇:类与对象(四):赋值运算符重载
目录
一、一个容易混淆的问题
二、赋值运算符的基本写法
为什么返回值是SafeArray&(引用)?
为什么参数是const引用?
三、自赋值检查:为什么重要?
四、完整例子:带赋值运算符的SafeArray
五、拷贝构造 vs 赋值运算符:对比总结
六、另一种实现技巧:copy-and-swap
七、三法则升级为五法则(C++11)
八、三个必踩的坑
1. 忘了写赋值运算符,用了默认的浅拷贝
2. 赋值运算符没有返回引用
3. 自赋值检查写错了
九、这一篇的收获
一、一个容易混淆的问题
看下面这段代码,猜猜哪行调用了拷贝构造,哪行调用了赋值?
cpp
SafeArray a(5); SafeArray b(3); // b已经存在 SafeArray c = a; // 第1行 b = a; // 第2行
第1行:
c正在被创建,用a初始化它 →拷贝构造函数第2行:
b已经存在,把a的值赋给它 →赋值运算符
区别很明确:
左边对象还没出生→ 拷贝构造
左边对象已经活着→ 赋值运算符
这就是为什么两个都要实现——它们服务于不同的场景。
二、赋值运算符的基本写法
赋值运算符的函数名是operator=,它的基本框架如下:
cpp
class SafeArray { public: // 赋值运算符重载 SafeArray& operator=(const SafeArray& other) { // 1. 自赋值检查 if (this == &other) { return *this; } // 2. 释放当前资源 delete[] data; // 3. 分配新资源并复制内容 size = other.size; data = new int[size]; for (int i = 0; i < size; i++) { data[i] = other.data[i]; } // 4. 返回自身引用 return *this; } private: int* data; int size; };为什么返回值是SafeArray&(引用)?
因为C++支持连续赋值:
cpp
a = b = c; // 等价于 a.operator=(b.operator=(c))
b = c应该返回b本身,然后作为参数传给a的赋值运算符。如果返回的不是引用,而是一个副本,就会多一次不必要的拷贝,而且行为不符合预期。
cpp
// 错误的写法 SafeArray operator=(const SafeArray& other); // 返回值不是引用 a = b = c; // 会有额外拷贝,效率低且可能出错
为什么参数是const引用?
const:我们不应该修改右边的对象引用:避免拷贝(否则会递归调用拷贝构造)
三、自赋值检查:为什么重要?
先看一个没有自赋值检查的例子:
cpp
SafeArray& operator=(const SafeArray& other) { delete[] data; // 释放自己 data = new int[other.size]; // 重新分配 // ... }如果写成a = a(自己给自己赋值):
delete[] data— 释放了a的内存然后
new int[other.size]— 但other就是a本身,它的data已经被释放了访问
other.data→未定义行为(大概率崩溃)
加上自赋值检查后:
cpp
if (this == &other) { return *this; // 啥也不干,直接返回 }注意:比较的是地址,不是内容。this是当前对象的地址,&other是参数对象的地址。指向同一个对象时,地址相同。
四、完整例子:带赋值运算符的SafeArray
cpp
#include <iostream> #include <cstring> using namespace std; class SafeArray { private: int* data; int size; public: // 普通构造函数 SafeArray(int n = 0) : size(n) { data = (n > 0) ? new int[n] : nullptr; for (int i = 0; i < size; i++) { data[i] = 0; } cout << "构造:size=" << size << endl; } // 拷贝构造函数 SafeArray(const SafeArray& other) : size(other.size) { data = new int[size]; for (int i = 0; i < size; i++) { data[i] = other.data[i]; } cout << "拷贝构造:深拷贝了" << size << "个int" << endl; } // 赋值运算符重载 SafeArray& operator=(const SafeArray& other) { cout << "赋值运算符被调用" << endl; // 1. 自赋值检查 if (this == &other) { cout << "自赋值检查:啥也不干" << endl; return *this; } // 2. 释放当前资源 delete[] data; // 3. 分配新资源并复制 size = other.size; if (size > 0) { data = new int[size]; for (int i = 0; i < size; i++) { data[i] = other.data[i]; } } else { data = nullptr; } return *this; } // 析构函数 ~SafeArray() { delete[] data; cout << "析构:释放了" << size << "个int" << endl; } void set(int idx, int val) { if (idx >= 0 && idx < size) data[idx] = val; } int get(int idx) const { if (idx >= 0 && idx < size) return data[idx]; return -1; } void print() const { cout << "["; for (int i = 0; i < size; i++) { cout << data[i] << (i < size-1 ? ", " : ""); } cout << "]" << endl; } }; int main() { SafeArray a(5); for (int i = 0; i < 5; i++) a.set(i, i * 10); a.print(); // [0, 10, 20, 30, 40] SafeArray b(3); b.set(0, 100); b.print(); // [100, 0, 0] cout << "\n--- 执行 b = a ---" << endl; b = a; // 赋值运算符 b.print(); // [0, 10, 20, 30, 40] cout << "\n--- 执行 a = a (自赋值) ---" << endl; a = a; // 自赋值,应该安全地什么都不做 cout << "\n--- 程序结束 ---" << endl; return 0; }运行结果:
text
构造:size=5 [0, 10, 20, 30, 40] 构造:size=3 [100, 0, 0] --- 执行 b = a --- 赋值运算符被调用 [0, 10, 20, 30, 40] --- 执行 a = a (自赋值) --- 赋值运算符被调用 自赋值检查:啥也不干 --- 程序结束 --- 析构:释放了5个int 析构:释放了5个int
五、拷贝构造 vs 赋值运算符:对比总结
| 对比维度 | 拷贝构造函数 | 赋值运算符 |
|---|---|---|
| 函数名 | 类名(const 类名&) | operator=(const 类名&) |
| 调用时机 | 创建新对象时 | 对象已存在,重新赋值 |
| 释放旧资源 | 不需要(对象是全新的) | 必须释放当前资源 |
| 自赋值风险 | 没有风险(新对象无旧资源) | 有风险,必须检查 |
| 返回值 | 无返回值 | 返回自身引用 |
一个记忆技巧:
拷贝构造:建新屋,搬家具(以前没有房子)
赋值:换家具,先清空(房子已经有了)
六、另一种实现技巧:copy-and-swap
上面的实现有个潜在问题:如果new int[size]失败(内存不足抛异常),对象已经delete[] data了,数据丢失,陷入无效状态。
更健壮的写法是“先构造副本,再交换”:
cpp
SafeArray& operator=(const SafeArray& other) { if (this != &other) { SafeArray temp(other); // 先拷贝一份(调用拷贝构造) swap(temp); // 交换资源 } return *this; } void swap(SafeArray& other) { std::swap(data, other.data); std::swap(size, other.size); }这种写法的好处:
强异常安全:如果分配内存失败,原对象保持不变
代码复用:拷贝逻辑直接用拷贝构造
自动处理自赋值:自赋值时
temp是自身的副本,交换后等于没变
七、三法则升级为五法则(C++11)
之前提到三法则(析构、拷贝构造、拷贝赋值)。C++11引入了移动语义,扩展成了五法则:
| 函数 | 作用 |
|---|---|
| 析构函数 | 释放资源 |
| 拷贝构造函数 | 深拷贝 |
| 拷贝赋值运算符 | 深赋值 |
| 移动构造函数 | 转移资源(下一篇讲) |
| 移动赋值运算符 | 转移资源(下一篇讲) |
对于管理资源的类,这五个函数通常需要一起考虑。
八、三个必踩的坑
1. 忘了写赋值运算符,用了默认的浅拷贝
cpp
class Bad { int* p; public: Bad() { p = new int(5); } ~Bad() { delete p; } // 没有写赋值运算符 → 浅拷贝 }; Bad a, b; b = a; // b.p = a.p → 两个指针指向同一块内存 // 析构时重复释放 → 崩溃2. 赋值运算符没有返回引用
cpp
void operator=(const SafeArray& other) // ❌ 返回值void
后果:
连续赋值
a = b = c编译失败语义不符合C++惯例
3. 自赋值检查写错了
cpp
if (*this == other) // ❌ 比较内容,不是地址
如果两个对象内容相同但地址不同,会错误地跳过赋值,导致该复制的内容没复制。
正确写法:
cpp
if (this == &other) // ✅ 比较地址
九、这一篇的收获
你现在应该能够:
区分拷贝构造和赋值运算符的调用时机
写出标准形式的
operator=理解为什么需要自赋值检查
知道赋值运算符必须返回
*this的引用了解copy-and-swap技术能提供异常安全
💡 小作业:为上一讲的
StringWrapper类添加赋值运算符。注意处理自赋值,确保深拷贝正确。然后测试这三种情况:
s1 = s2(正常赋值)
s1 = s1(自赋值)
s1 = s2 = s3(连续赋值)
下一篇预告:第6篇《this指针:对象如何知道自己在调用谁?》——成员函数里访问成员变量时,编译器怎么知道是哪个对象的变量?this指针就是那个“暗号”,它指向当前对象自己。下篇揭秘。
