目录
[五、拷贝构造 vs 赋值运算符:对比总结](#五、拷贝构造 vs 赋值运算符:对比总结)
[1. 忘了写赋值运算符,用了默认的浅拷贝](#1. 忘了写赋值运算符,用了默认的浅拷贝)
[2. 赋值运算符没有返回引用](#2. 赋值运算符没有返回引用)
[3. 自赋值检查写错了](#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指针就是那个"暗号",它指向当前对象自己。下篇揭秘。