在复制一个对象时,如果类里保存的只是 int、double 这类普通数值成员,直接把成员的值复制一份给新对象就足够了。但如果对象内部包含指向动态内存的指针成员,只把指针保存的 “内存地址” 复制一份,能算把真正的数据也独立复制了一份吗?
显然不能。只复制指针保存的地址,和重新开辟一块独立内存把数据复制过去,完全是两码事:前者叫做 “浅拷贝”,而后者叫做 “深拷贝”。
什么是浅拷贝与深拷贝?
在 C++ 中,浅拷贝和深拷贝并不是语法关键字,而是用来描述复制对象之后,内部指针成员与底层动态内存之间的对应关系:
- 浅拷贝(Shallow Copy):只复制指针本身保存的内存地址,不重新分配内存。复制完成后,两个对象的指针指向同一块堆内存,修改其中一个对象的数据,另一个对象的数据也会跟着改变。
- 深拷贝(Deep Copy):在复制对象时,先为新对象重新申请一块独立的堆内存,再把原内存里存放的数据复制过去。复制完成后,两个对象的底层内存各自独立,后续修改数据或释放内存互不干扰。
形象来说,浅拷贝就像是 “只多配了一把同一个房间的钥匙”,两个对象共用同一个房间。而深拷贝则是 “重新开了一间新房,并把里面的物品照样布置了一份”,两个房间互不影响。它们在内存中的指向关系如下图所示。

浅拷贝的产生原因与隐患
为什么代码中会出现浅拷贝呢?前面我们学过,如果在类中没有手写拷贝构造函数,编译器会自动生成一个按成员逐个复制的默认版本。
假设 ShallowBox 类里有一个指针成员 int* data,需要配合前面学过的 new 和 delete 来分配和释放堆内存。此时编译器默认生成的拷贝构造函数,效果就相当于下面这段代码:
// 编译器默认生成的浅拷贝构造
ShallowBox(const ShallowBox& other) : data(other.data) {}可以看到,data(other.data) 只是把 other.data 里保存的内存地址原样复制了一份。下面我们通过一个例子把两个对象的指针地址打印出来,看看默认浅拷贝会发生什么现象。
示例 1:默认浅拷贝的现象
#include <iostream>
class ShallowBox {
public:
int* data;
ShallowBox(int val) : data(new int(val)) {}
// 未手写拷贝构造,默认浅拷贝
};
int main() {
ShallowBox box1(10);
ShallowBox box2 = box1; // 触发默认浅拷贝
std::cout << "box1.data 地址:" << box1.data << "\n";
std::cout << "box2.data 地址:" << box2.data << "\n";
*box2.data = 99; // 通过 box2 修改数据
std::cout << "*box1.data = " << *box1.data << "\n";
std::cout << "*box2.data = " << *box2.data << "\n";
delete box1.data; // 释放共享内存
return 0;
}运行结果如下。
box1.data 地址:0x55a8f2b02eb0
box2.data 地址:0x55a8f2b02eb0
*box1.data = 99
*box2.data = 99分析:
从运行结果可以看到,执行 ShallowBox box2 = box1; 之后,box1.data 和 box2.data 打印出来的内存地址完全相同。小伙伴们在本地运行时,分配到的具体十六进制地址可能会不一样,我们重点观察两个地址是否一致即可。
地址相同说明默认浅拷贝并没有开辟第二块整数内存,只是把地址复制了一份给 box2。因此在 main() 末尾手动释放内存时,delete box1.data; 也只能执行一次,不能再对 box2.data 释放第二次。
细心的小伙伴可能已经注意到了:在示例 1 中,我们甚至不敢把 delete data; 写进类的析构函数里让它自动释放。这是因为浅拷贝会带来以下 3 个严重隐患:
- 数据互相篡改:如运行结果所示,我们明明只执行了
*box2.data = 99;,但由于两个对象共用同一块内存,*box1.data读取到的值也跟着变成了 99。 - 重复释放同一块内存:如果在析构函数中写上
delete data;,当两个对象先后销毁时,就会对同一个地址连续执行两次delete,直接导致程序崩溃。 - 产生悬空指针:当其中一个对象先销毁并把共享内存释放掉以后,另一个对象内部的指针虽然还记着原来的地址,但那块内存已经失效,从而变成危险的悬空指针。
比如我们在 ShallowBox 类中补上下面的析构函数:
~ShallowBox() {
delete data;
}局部对象遵循 “先创建、后销毁” 的顺序。当 main() 函数结束时,后创建的 box2 会先调用析构函数,把共享地址处的内存释放掉。紧接着 box1 销毁时,它的析构函数又会拿着同一个地址再执行一次 delete,程序就会直接崩溃。
如何实现深拷贝?
要想解决浅拷贝的问题,我们就不能依赖编译器自动生成的默认拷贝,而需要手动编写拷贝构造函数:先使用 new 为新对象单独开辟一块全新的堆内存,再把原对象内存里保存的数值 *other.data 复制进去。
当类内部直接管理动态内存时,我们可以对比下面 2 种拷贝构造函数的写法:
// 错误方式
NumberBox(const NumberBox& other) : data(other.data) {}
// 正确方式
NumberBox(const NumberBox& other)
: data(new int(*other.data)) {}在正确方式中,内层的 *other.data 先通过解引用取出原对象内存里保存的整数值,外层的 new int(...) 再向堆区申请一块全新的独立内存,并把该整数存进去。这样一来,新对象的 data 指针拿到的就是一个全新的内存地址了。
下面我们把深拷贝构造函数和析构函数 ~NumberBox() 一起放进类中,运行程序看看实际效果。
示例 2:手写深拷贝构造函数
#include <iostream>
class NumberBox {
public:
int* data;
NumberBox(int val) : data(new int(val)) {}
// 深拷贝构造函数
NumberBox(const NumberBox& other)
: data(new int(*other.data)) {
std::cout << "调用深拷贝构造函数\n";
}
// 释放独立内存
~NumberBox() {
delete data;
}
};
int main() {
NumberBox box1(10);
NumberBox box2 = box1; // 调用深拷贝构造函数
std::cout << "box1.data 地址:" << box1.data << "\n";
std::cout << "box2.data 地址:" << box2.data << "\n";
*box2.data = 99;
std::cout << "*box1.data = " << *box1.data << "\n";
std::cout << "*box2.data = " << *box2.data << "\n";
return 0;
}运行结果如下。
调用深拷贝构造函数
box1.data 地址:0x55a8f2b02eb0
box2.data 地址:0x55a8f2b032e0
*box1.data = 10
*box2.data = 99分析:
在这个例子中,box2 通过深拷贝构造函数拿到了一块全新的独立内存,因此打印出来的两个指针地址是不同的。
后面修改 *box2.data 的数值时,*box1.data 依然保持 10 不变。等到 main() 函数结束时,box2 和 box1 各自的析构函数分别释放自己名下的独立内存,不会发生重复释放的问题。
深拷贝赋值与三法则
写好了析构函数和深拷贝构造函数之后,这个 NumberBox 类是不是就彻底安全了呢?
其实还没有。如果我们像 box3 = box1; 这样对两个已经创建好的对象执行赋值操作,此时调用的是上一节学过的拷贝赋值运算符 operator=。
如果没有手写 operator=,编译器默认生成的赋值操作同样是浅拷贝,只会直接执行 data = other.data;。这既会把 box3 原来持有的旧内存弄丢而造成内存泄漏,又会让两个对象的指针再次指向同一块内存。
因此,在 C++ 中有一条经典的 “三法则(Rule of Three)”:如果一个类因为直接管理动态内存,需要显式定义以下 3 个特殊成员函数中的任意一个,那么这 3 个函数通常都应该配套写齐:
- 析构函数:在对象销毁时执行
delete,释放当前对象持有的动态内存。 - 拷贝构造函数:在使用已有对象创建新对象时执行深拷贝,为新对象分配独立内存并复制数据。
- 拷贝赋值运算符:在两个已存在对象互相赋值时执行深拷贝,先释放目标对象原有的旧内存,再分配新内存并复制数据。
在编写深拷贝赋值运算符 operator= 时,小伙伴们千万不要漏掉 if (this != &other) 自赋值检查:
// 错误方式
NumberBox& operator=(const NumberBox& other) {
delete data;
data = new int(*other.data);
return *this;
}
// 正确方式
NumberBox& operator=(const NumberBox& other) {
if (this != &other) {
delete data;
data = new int(*other.data);
}
return *this;
}如果不做 if (this != &other) 检查,一旦遇到 box1 = box1; 自己赋给自己,第一句 delete data; 就会把自己唯一的数据内存释放掉。紧接着下一句再去读取 *other.data 时,访问的就是已经失效的内存,程序会直接崩溃。
下面我们将这 3 个函数完整组合在 NumberBox 类中,看看实际运行效果。
示例 3:完整实现三法则
#include <iostream>
class NumberBox {
public:
int* data;
NumberBox(int val) : data(new int(val)) {}
// 1. 析构函数
~NumberBox() {
delete data;
}
// 2. 深拷贝构造函数
NumberBox(const NumberBox& other)
: data(new int(*other.data)) {
std::cout << "调用深拷贝构造函数\n";
}
// 3. 深拷贝赋值运算符
NumberBox& operator=(const NumberBox& other) {
std::cout << "调用深拷贝赋值运算符\n";
if (this != &other) {
delete data;
data = new int(*other.data);
}
return *this;
}
};
int main() {
NumberBox box1(10);
NumberBox box2 = box1; // 触发深拷贝构造函数
NumberBox box3(50);
box3 = box1; // 触发深拷贝赋值运算符
*box2.data = 20;
*box3.data = 30;
std::cout << "*box1.data = " << *box1.data << "\n";
std::cout << "*box2.data = " << *box2.data << "\n";
std::cout << "*box3.data = " << *box3.data << "\n";
return 0;
}运行结果如下。
调用深拷贝构造函数
调用深拷贝赋值运算符
*box1.data = 10
*box2.data = 20
*box3.data = 30分析:
从运行结果可以看出,NumberBox 类完整实现了三法则要求的 3 个特殊成员函数。无论是通过 NumberBox box2 = box1; 创建新对象,还是通过 box3 = box1; 给已存在的对象赋值,每个对象都拥有各自独立的动态内存。
因此后面修改 *box2.data 和 *box3.data 的值时,*box1.data 始终保持 10 不变,程序结束时 3 个对象也能各自安全完成析构。
常见问题
1. 在现代 C++ 开发中,每次写类都要自己手写裸指针和三法则吗?
不需要。这里使用裸指针演示,主要是为了把浅拷贝与深拷贝的底层内存差异直观展示出来。在实际项目开发中,我们通常会优先使用现成的 RAII 资源管理类型作为类的成员变量,省去手写三法则的麻烦:
优先使用标准库容器:像 std::string 和 std::vector 内部已经封装好了完善的深拷贝与析构释放逻辑,直接依赖编译器生成的默认拷贝就能安全完成深拷贝。
按需选用智能指针:管理动态分配的对象时,独占资源使用 std::unique_ptr,需要多个对象共享同一份资源时使用 std::shared_ptr。
