CPP学习笔记—深拷贝与浅拷贝
C++ 中的深拷贝(Deep Copy)和浅拷贝(Shallow Copy)是一个 C++ 实际开发中至关重要的核心概念,因为它直接关系到程序的正确性和内存安全。
1. 问题的根源:指针和动态内存
要理解深拷贝和浅拷贝,首先必须明白问题的根源在哪里。如果一个类只包含基本数据类型(如 int, double)或者不持有动态资源的对象(如 std::string,它自己内部处理好了深拷贝),那么我们通常不需要关心这个问题。
问题出现在当一个类的成员变量是指针,并且这个指针指向了在堆(Heap)上动态分配的内存时。
来看一个简单的例子,一个自定义的字符串包装类:
1 | class MyString { |
这个 MyString 类自己管理了一块动态内存(_data)。当我们复制这个类的对象时,问题就来了。
2. 浅拷贝 (Shallow Copy):简单的成员复制
什么是浅拷贝?
浅拷贝,也称为“成员逐一复制”或“位拷贝”,是仅仅复制对象的所有成员变量的值。
- 对于基本数据类型,复制的是值本身。
- 对于指针类型,复制的是 指针的地址值,而不是指针所指向的内存内容。
编译器何时会生成浅拷贝?
当你没有自定义拷贝构造函数和拷贝赋值运算符时,编译器会自动为你生成它们。而这些自动生成的版本执行的就是浅拷贝。
浅拷贝带来的致命问题
让我们看看使用默认的浅拷贝会发生什么:
1 |
|
发生了什么?
MyString str1("hello");str1._data指向堆上的一块内存,内容为 “hello\0”。str1._len为 5。
MyString str2 = str1;(浅拷贝)str2._len被赋值为str1._len(值为 5)。str2._data被赋值为str1._data(同一个内存地址!)。
现在,str1 和 str2 内部的 _data 指针指向了 同一块 堆内存。
问题一:悬挂指针 (Dangling Pointer)
如果 str2 在某个时刻被修改,str1 也会被“意外”地修改,因为它们共享数据。更严重的是,如果其中一个对象被销毁…
问题二:重复释放 (Double Free)
当 cause_problem 函数结束时:
str2的析构函数被调用。它执行delete[] _data;,成功释放了内存。str1的析构函数被调用。它执行delete[] _data;,试图 再次释放同一块已经被释放的内存。
这会导致程序崩溃! 这就是所谓的“重复释放”错误,是 C++ 中非常严重的内存错误。
3. 深拷贝 (Deep Copy):复制指针指向的内容
什么是深拷贝?
深拷贝是在进行对象复制时,如果遇到指针类型的成员变量,它不会只复制指针的地址,而是会重新分配一块新的内存空间,然后将原始指针所指向的内容复制到这块新内存中。
这样,两个对象就各自拥有了独立的内存资源,互不影响。
如何实现深拷贝?
要实现深拷贝,你必须手动为你的类提供:
- 拷贝构造函数 (Copy Constructor)
- 拷贝赋值运算符 (Copy Assignment Operator)
4. C++ 中的实现:拷贝构造函数与拷贝赋值运算符
让我们为 MyString 类添加深拷贝的实现。
1 |
|
输出:
1 | Constructor called |
现在,每个对象都有自己独立的内存,析构时各自释放自己的内存,程序运行正常,没有任何内存错误。
5. “三/五/零之法则” (The Rule of Three/Five/Zero)
这是一个关于何时需要手写特殊成员函数(析构、拷贝、移动)的指导方针。
三之法则 (Rule of Three) - 经典 C++
如果你需要显式地声明析构函数、拷贝构造函数、或拷贝赋值运算符中的任何一个,那么你很可能需要把这三个都声明。
- 原因:手动管理资源(如
new/delete)通常是同时需要这三者的根本原因。- 需要析构函数来
delete资源。 - 需要拷贝构造函数和拷贝赋值运算符来实现深拷贝,以避免浅拷贝带来的问题。
- 需要析构函数来
我们的 MyString 例子完美地诠释了三之法则。
五之法则 (Rule of Five) - C++11
随着 C++11 引入了移动语义 (Move Semantics),这个法则扩展了。
如果你需要手动实现析构、拷贝构造、拷贝赋值、移动构造、或移动赋值中的任何一个,那么你可能需要实现所有五个。
- 移动构造函数
MyString(MyString&& other) - 移动赋值运算符
MyString& operator=(MyString&& other)
移动操作用于从临时对象(右值)“窃取”资源,而不是昂贵的拷贝,极大地提高了性能。
零之法则 (Rule of Zero) - 现代 C++ 最佳实践
一个类应该专注于一项任务。如果这个任务是业务逻辑,那么它就不应该操心资源管理。
核心思想:不要自己手动管理资源! 使用 C++ 标准库提供的 RAII (Resource Acquisition Is Initialization) 容器和智能指针。
- 用
std::string代替char*。 - 用
std::vector<T>代替T*动态数组。 - 用
std::unique_ptr<T>或std::shared_ptr<T>来管理单个动态对象的生命周期。
如果你遵循零之法则,你的类可能看起来像这样:
1 |
|
当拷贝 MyModernClass 的对象时,编译器生成的拷贝构造函数会自动调用 std::string 和 std::vector 的拷贝构造函数,它们会执行正确的深拷贝。你一行代码都不用多写,这就是零之法则。
6. 总结对比表
| 特性 | 浅拷贝 (Shallow Copy) | 深拷贝 (Deep Copy) |
|---|---|---|
| 定义 | 只复制成员变量的值,如果是指针,则复制地址。 | 复制成员变量的值,如果是指针,则为指针所指内容分配新内存并复制。 |
| 实现方式 | 编译器默认生成。 | 需要用户自己实现拷贝构造函数和拷贝赋值运算符。 |
| 资源管理 | 多个对象共享同一份外部资源。 | 每个对象拥有自己独立的外部资源副本。 |
| 优点 | 速度快,开销小。 | 安全,对象间互不影响,避免内存错误。 |
| 缺点 | 极易导致悬挂指针和重复释放等内存错误,非常危险。 | 实现复杂,有额外的内存分配和数据复制开销,性能较低。 |
| 适用场景 | 仅当类中没有指针成员或不涉及动态资源管理时才安全。 | 只要类中包含指向动态分配资源的指针,就必须使用深拷贝。 |
| 现代实践 | 尽量避免需要手动区分的场景,使用 RAII 类(std::string, std::vector, 智能指针)来自动处理,遵循“零之法则”。 |









