【c++面向对象编程】第4篇:类与对象(三):拷贝构造函数与深浅拷贝问题
目录
一、一个崩溃的程序
二、拷贝构造函数是什么?
调用时机(三个场景)
三、浅拷贝 vs 深拷贝
浅拷贝(默认行为)
深拷贝(正确的做法)
四、什么时候必须自己写拷贝构造函数?
一个反面例子:vector的浅拷贝问题
五、完整的例子:安全的动态数组
六、三个常见的坑
1. 拷贝构造函数参数不用引用 → 无限递归
2. 忘了const导致无法拷贝const对象
3. 浅拷贝发生在你没想到的地方
七、这一篇的收获
一、一个崩溃的程序
先看这段代码,你觉得它会崩溃吗?
cpp
class StringWrapper { private: char* data; public: StringWrapper(const char* str) { data = new char[strlen(str) + 1]; strcpy(data, str); } ~StringWrapper() { delete[] data; } void print() { cout << data << endl; } }; int main() { StringWrapper s1("Hello"); StringWrapper s2 = s1; // 用s1初始化s2 s1.print(); s2.print(); return 0; } // 程序在这里崩溃!运行结果:可能正常输出,也可能输出乱码,最后大概率崩溃。
原因很简单:s1和s2里面的data指针指向了同一块内存。当程序结束,s2先析构,delete[]了那块内存;然后s1析构,再次delete[]同一块内存——重复释放,程序崩溃。
这就是浅拷贝带来的灾难。
二、拷贝构造函数是什么?
拷贝构造函数是一种特殊的构造函数:
参数是本类对象的const引用
用已有的对象去创建新的对象时自动调用
如果你不写,编译器会生成一个默认的(逐成员复制)
语法长这样:
cpp
class MyClass { public: // 拷贝构造函数 MyClass(const MyClass& other) { // 拷贝逻辑 } };调用时机(三个场景)
cpp
class Demo { public: Demo() { cout << "普通构造" << endl; } Demo(const Demo& other) { cout << "拷贝构造" << endl; } ~Demo() { cout << "析构" << endl; } }; Demo makeDemo() { Demo d; return d; // 场景3:返回值 } int main() { Demo a; // 普通构造 Demo b = a; // 场景1:用a初始化b → 拷贝构造 Demo c(a); // 场景2:直接传参 → 拷贝构造 Demo d = makeDemo(); // 场景3:返回值(可能被优化掉,不一定调用) }关键点:“=”在这里不是赋值,是初始化。赋值是后面讲的重载operator=。
三、浅拷贝 vs 深拷贝
浅拷贝(默认行为)
编译器生成的默认拷贝构造函数做的事很简单:把每个成员变量的值原样复制。
cpp
// 编译器生成的默认版本(概念上) StringWrapper(const StringWrapper& other) : data(other.data) // 只复制指针的值,不复制指针指向的内容 {}对于int、double这种值类型,浅拷贝没问题。但对于指针,复制的是地址,不是地址里的内容。
浅拷贝的问题:
两个对象指向同一块内存
一个修改,另一个也跟着变(可能不是你想要的效果)
一个释放,另一个变成悬空指针
重复释放导致崩溃
深拷贝(正确的做法)
深拷贝的做法:不复制指针的值,而是复制指针指向的内容。
cpp
class StringWrapper { private: char* data; public: // 普通构造函数 StringWrapper(const char* str) { data = new char[strlen(str) + 1]; strcpy(data, str); } // 拷贝构造函数(深拷贝) StringWrapper(const StringWrapper& other) { // 1. 分配新内存 data = new char[strlen(other.data) + 1]; // 2. 复制内容 strcpy(data, other.data); cout << "深拷贝:" << data << endl; } // 析构函数 ~StringWrapper() { delete[] data; cout << "释放:" << data << endl; } void print() { cout << data << endl; } // 后面会讲赋值运算符重载 };现在运行之前会崩溃的例子:
cpp
int main() { StringWrapper s1("Hello"); StringWrapper s2 = s1; // 深拷贝:s2有自己独立的内存 s1.print(); // Hello s2.print(); // Hello return 0; // 分别释放两块内存,不冲突 }内存布局对比:
text
浅拷贝: s1.data ──→ [H][e][l][l][o][\0] s2.data ──→ ↑ (指向同一块) 深拷贝: s1.data ──→ [H][e][l][l][o][\0] s2.data ──→ [H][e][l][l][o][\0] (另一块内存)
四、什么时候必须自己写拷贝构造函数?
三法则(Rule of Three):如果类需要自定义析构函数,那么它几乎一定也需要自定义拷贝构造函数和拷贝赋值运算符。
具体来说,以下情况必须写拷贝构造函数:
类里有指针成员,并且构造函数里用
new分配了内存类里有文件句柄、数据库连接等需要“独占”的资源
类里有互斥锁(mutex)(两个对象拥有同一个锁会导致死锁)
一句话:默认的逐成员复制对你的资源管理方式不适用时。
一个反面例子:vector的浅拷贝问题
cpp
class IntVector { private: int* arr; int size; public: IntVector(int n) : size(n) { arr = new int[n]; for(int i=0; i<n; i++) arr[i] = i; } ~IntVector() { delete[] arr; } // 没有写拷贝构造函数 → 浅拷贝! void set(int idx, int val) { arr[idx] = val; } int get(int idx) { return arr[idx]; } }; int main() { IntVector v1(5); IntVector v2 = v1; // 浅拷贝,v2.arr指向v1.arr同一块内存 v2.set(0, 999); // 修改v2 cout << v1.get(0); // 输出999!v1被意外修改了 // 程序结束,两次delete[]同一块内存 → 崩溃 }这就是所谓的“意外的共享状态”。
五、完整的例子:安全的动态数组
cpp
#include <iostream> #include <cstring> using namespace std; class SafeArray { private: int* data; int size; public: // 普通构造函数 SafeArray(int n) : size(n) { data = new int[n]; for (int i = 0; i < n; i++) { data[i] = 0; } cout << "构造:分配了" << n << "个int" << 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() { 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 = a; // 拷贝构造 b.set(0, 999); cout << "a: "; a.print(); // [0, 10, 20, 30, 40] ← 没被影响 cout << "b: "; b.print(); // [999, 10, 20, 30, 40] ← 独立修改 return 0; }输出:
text
构造:分配了5个int [0, 10, 20, 30, 40] 拷贝构造:深拷贝了5个int a: [0, 10, 20, 30, 40] b: [999, 10, 20, 30, 40] 析构:释放了5个int 析构:释放了5个int
完美!两个对象互不干扰,各释放各的内存。
六、三个常见的坑
1. 拷贝构造函数参数不用引用 → 无限递归
cpp
class Bad { public: Bad(Bad other) { // ❌ 传值,会再次调用拷贝构造,无限递归 // ... } };参数必须用引用,通常是const引用:
cpp
Bad(const Bad& other) { } // ✅2. 忘了const导致无法拷贝const对象
cpp
class Demo { public: Demo(Demo& other) { } // 参数不是const }; const Demo d1; Demo d2 = d1; // ❌ 错误!不能将const转为非const引用3. 浅拷贝发生在你没想到的地方
函数传参也会调用拷贝构造函数:
cpp
void func(SafeArray arr) { // 传值,会调用拷贝构造 // ... } SafeArray a(10); func(a); // 这里发生了一次深拷贝如果数组很大,深拷贝的开销不小。想避免拷贝?用引用传参:
cpp
void func(const SafeArray& arr) { // 不拷贝 // ... }七、这一篇的收获
你现在应该明白:
拷贝构造函数:用已有对象创建新对象时调用
浅拷贝:默认行为,只复制指针的值,导致两个对象共享内存
深拷贝:自己实现,分配新内存并复制内容,对象各自独立
三法则:需要析构函数 → 就需要拷贝构造和拷贝赋值
💡 小作业:修改上面的
SafeArray,故意去掉拷贝构造函数,观察程序会出什么问题。然后加上拷贝构造函数,验证深拷贝解决了问题。
下一篇预告:第5篇《类与对象(四):赋值运算符重载》——=不只是初始化,还有赋值。拷贝构造和赋值运算符有什么区别?什么时候调用哪个?为什么赋值要返回引用?下篇揭晓。
