【c++面向对象编程】第5篇:类与对象(四):赋值运算符重载

📅 2026/7/9 13:33:45 👁️ 阅读次数 📝 编程学习
【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(自己给自己赋值):

  1. delete[] data— 释放了a的内存

  2. 然后new int[other.size]— 但other就是a本身,它的data已经被释放了

  3. 访问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类添加赋值运算符。注意处理自赋值,确保深拷贝正确。然后测试这三种情况:

  1. s1 = s2(正常赋值)

  2. s1 = s1(自赋值)

  3. s1 = s2 = s3(连续赋值)


下一篇预告:第6篇《this指针:对象如何知道自己在调用谁?》——成员函数里访问成员变量时,编译器怎么知道是哪个对象的变量?this指针就是那个“暗号”,它指向当前对象自己。下篇揭秘。