"复制一个对象"听起来很简单,但当对象内部有指针时就会踩坑——两个对象共用同一块内存,一个析构了,另一个就崩掉了。本节彻底讲清楚浅拷贝与深拷贝的区别。
在 C++ 里,以下三种情况都会触发对象的复制,此时拷贝构造函数会被自动调用:
Student b = a; 或 Student b(a);,创建 b 时以 a 为蓝本复制一份。
void Foo(Student s) 调用时,实参会被复制一份传给形参 s。
return s; 时,s 会被复制一份作为返回值(现代编译器通常会优化掉这次复制)。
拷贝构造函数是一种特殊的构造函数,它接受同类型对象的常量引用作为参数。如果你不写,C++ 会自动生成一个——逐字段复制所有成员变量,这就是"浅拷贝"。
| 1 | class Student { |
| 2 | public: |
| 3 | string name; |
| 4 | int score; |
| 5 | |
| 6 | // 普通构造函数 |
| 7 | Student(string n, int s) : name(n), score(s) {} |
| 8 | |
| 9 | // 拷贝构造函数:参数是 const 同类型引用 |
| 10 | Student(const Student& other) : name(other.name), score(other.score) { |
| 11 | cout << "拷贝了 " << other.name << endl; |
| 12 | } |
| 13 | }; |
| 14 | |
| 15 | int main() { |
| 16 | Student a("Alice", 95); |
| 17 | Student b = a; // 触发拷贝构造 → 输出:拷贝了 Alice |
| 18 | Student c(a); // 同上,两种写法等价 |
| 19 | } |
const Student&),不能写成值传递(Student other)。如果写成值传递,调用拷贝构造时又需要复制参数,又会调用拷贝构造……无限递归,直接崩溃。对于只有普通成员变量(int、string 等)的类,C++ 自动生成的浅拷贝完全够用。问题出在类里有指针成员的时候。
浅拷贝只复制指针的值(地址),不复制指针指向的内容——两个对象的指针最终指向同一块内存。
b.data 和 a.data 指向同一块堆内存。b 析构时 delete 了这块内存,a 析构时再次 delete 同一地址,程序崩溃。b 单独分配一块新内存,复制内容。两者互相独立,各自析构互不影响。
看一个完整的会崩溃的例子,理解浅拷贝的危险:
| 1 | class Buffer { |
| 2 | public: |
| 3 | int* data; // 指针成员,指向堆上的数组 |
| 4 | int size; |
| 5 | |
| 6 | Buffer(int n) : size(n) { |
| 7 | data = new int[n]; // 在堆上分配内存 |
| 8 | } |
| 9 | ~Buffer() { delete[] data; } // 析构时释放 |
| 10 | // ⚠️ 没有自定义拷贝构造函数,使用编译器默认的浅拷贝 |
| 11 | }; |
| 12 | |
| 13 | int main() { |
| 14 | Buffer a(10); |
| 15 | Buffer b = a; // 浅拷贝:b.data 和 a.data 指向同一块内存 |
| 16 | } // b 先析构:delete[] data(0x1A2B) |
| 17 | // a 再析构:delete[] data(0x1A2B)← 💥 double-free,程序崩溃! |
解决办法是自己写拷贝构造函数,在里面为新对象单独申请一块内存,再把内容复制过去,而不是直接复制指针地址。
| 1 | class Buffer { |
| 2 | public: |
| 3 | int* data; |
| 4 | int size; |
| 5 | |
| 6 | Buffer(int n) : size(n) { |
| 7 | data = new int[n](); |
| 8 | } |
| 9 | |
| 10 | // 深拷贝:重新申请内存,复制内容 |
| 11 | Buffer(const Buffer& other) : size(other.size) { |
| 12 | data = new int[size]; // ① 新开一块内存 |
| 13 | copy(other.data, other.data + size, data); // ② 复制内容 |
| 14 | } |
| 15 | |
| 16 | ~Buffer() { delete[] data; } |
| 17 | }; |
| 18 | |
| 19 | int main() { |
| 20 | Buffer a(10); |
| 21 | a.data[0] = 42; |
| 22 | Buffer b = a; // 深拷贝:b 有独立的内存 |
| 23 | b.data[0] = 99; // 修改 b 不影响 a |
| 24 | cout << a.data[0]; // 输出:42(a 不受影响) |
| 25 | } // 析构时两者各自释放自己的内存,安全 ✅ |
C++ 有一条经验法则叫"三法则(Rule of Three)":
如果你的类需要手动管理资源(比如内部有 new 出来的指针),那么以下三个函数必须同时自定义,缺一不可:
| 1 | class Buffer { |
| 2 | public: |
| 3 | int* data; int size; |
| 4 | |
| 5 | ~Buffer() { delete[] data; } // ① 析构函数:释放内存 |
| 6 | |
| 7 | Buffer(const Buffer& other) { // ② 拷贝构造函数 |
| 8 | size = other.size; |
| 9 | data = new int[size]; // 新开内存,深拷贝 |
| 10 | copy(other.data, other.data+size, data); |
| 11 | } |
| 12 | |
| 13 | Buffer& operator=(const Buffer& other) { // ③ 拷贝赋值运算符 |
| 14 | if (this == &other) return *this; // 防止自赋值 a = a |
| 15 | delete[] data; // 先释放旧内存 |
| 16 | size = other.size; |
| 17 | data = new int[size]; // 新开内存,深拷贝 |
| 18 | copy(other.data, other.data+size, data); |
| 19 | return *this; |
| 20 | } |
| 21 | }; |
| 22 | |
| 23 | int main() { |
| 24 | Buffer a(10), b(5); |
| 25 | Buffer c = a; // 触发 ② 拷贝构造 |
| 26 | b = a; // 触发 ③ 拷贝赋值(b 已存在,不是初始化) |
| 27 | } |
Buffer c = a; — c 是新建的,调用拷贝构造函数(对象从无到有)b = a; — b 已经存在,调用拷贝赋值运算符(对象已有,覆盖掉旧值)delete 掉旧内存,再深拷贝新内容。
道理很简单:写了析构函数说明类里有手动管理的资源,拷贝时和赋值时同样需要深度处理——三件事是配套的,缺任何一个都会埋下隐患。
C++11 之后还引入了移动语义,扩展为"五法则(Rule of Five)",在三法则基础上再加上移动构造函数和移动赋值运算符。竞赛阶段了解三法则即可,五法则属于更高阶的工程实践。
vector、string 等 STL 容器替代手动 new,它们自己实现了深拷贝,完全不需要你操心三法则,代码更简洁也更安全。