C++ 中的深拷贝(Deep Copy)和浅拷贝(Shallow Copy)是一个 C++ 实际开发中至关重要的核心概念,因为它直接关系到程序的正确性和内存安全。


1. 问题的根源:指针和动态内存

要理解深拷贝和浅拷贝,首先必须明白问题的根源在哪里。如果一个类只包含基本数据类型(如 int, double)或者不持有动态资源的对象(如 std::string,它自己内部处理好了深拷贝),那么我们通常不需要关心这个问题。

问题出现在当一个类的成员变量是指针,并且这个指针指向了在堆(Heap)上动态分配的内存时。

来看一个简单的例子,一个自定义的字符串包装类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class MyString {
private:
char* _data;
size_t _len;

public:
MyString(const char* s = "") {
_len = strlen(s);
_data = new char[_len + 1]; // 在堆上分配内存
strcpy(_data, s);
}

~MyString() {
delete[] _data; // 在析构时释放内存
_data = nullptr;
}
// ... 其他成员函数 ...
};

这个 MyString 类自己管理了一块动态内存(_data)。当我们复制这个类的对象时,问题就来了。


2. 浅拷贝 (Shallow Copy):简单的成员复制

什么是浅拷贝?

浅拷贝,也称为“成员逐一复制”或“位拷贝”,是仅仅复制对象的所有成员变量的值

  • 对于基本数据类型,复制的是值本身。
  • 对于指针类型,复制的是 指针的地址值,而不是指针所指向的内存内容。

编译器何时会生成浅拷贝?

当你没有自定义拷贝构造函数和拷贝赋值运算符时,编译器会自动为你生成它们。而这些自动生成的版本执行的就是浅拷贝。

浅拷贝带来的致命问题

让我们看看使用默认的浅拷贝会发生什么:

1
2
3
4
5
6
7
8
9
#include <iostream>
#include <cstring>

// (接上文的 MyString 类定义)

void cause_problem() {
MyString str1("hello");
MyString str2 = str1; // 调用了编译器生成的拷贝构造函数 -> 浅拷贝
} // 函数结束,str1 和 str2 的析构函数被调用

发生了什么?

  1. MyString str1("hello");

    • str1._data 指向堆上的一块内存,内容为 “hello\0”。
    • str1._len 为 5。
  2. MyString str2 = str1; (浅拷贝)

    • str2._len 被赋值为 str1._len (值为 5)。
    • str2._data 被赋值为 str1._data (同一个内存地址!)。

现在,str1str2 内部的 _data 指针指向了 同一块 堆内存。

问题一:悬挂指针 (Dangling Pointer)

如果 str2 在某个时刻被修改,str1 也会被“意外”地修改,因为它们共享数据。更严重的是,如果其中一个对象被销毁…

问题二:重复释放 (Double Free)

cause_problem 函数结束时:

  1. str2 的析构函数被调用。它执行 delete[] _data;,成功释放了内存。
  2. str1 的析构函数被调用。它执行 delete[] _data;,试图 再次释放同一块已经被释放的内存

这会导致程序崩溃! 这就是所谓的“重复释放”错误,是 C++ 中非常严重的内存错误。


3. 深拷贝 (Deep Copy):复制指针指向的内容

什么是深拷贝?

深拷贝是在进行对象复制时,如果遇到指针类型的成员变量,它不会只复制指针的地址,而是会重新分配一块新的内存空间,然后将原始指针所指向的内容复制到这块新内存中

这样,两个对象就各自拥有了独立的内存资源,互不影响。

如何实现深拷贝?

要实现深拷贝,你必须手动为你的类提供:

  1. 拷贝构造函数 (Copy Constructor)
  2. 拷贝赋值运算符 (Copy Assignment Operator)

4. C++ 中的实现:拷贝构造函数与拷贝赋值运算符

让我们为 MyString 类添加深拷贝的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
#include <iostream>
#include <cstring>

class MyString {
private:
char* _data;
size_t _len;

public:
// 默认构造函数
MyString(const char* s = "") {
std::cout << "Constructor called" << std::endl;
_len = strlen(s);
_data = new char[_len + 1];
strcpy(_data, s);
}

// 析构函数
~MyString() {
std::cout << "Destructor called for " << (_data ? _data : "null") << std::endl;
delete[] _data;
_data = nullptr;
}

// 1. 拷贝构造函数 (实现深拷贝)
MyString(const MyString& other) {
std::cout << "Copy Constructor called" << std::endl;
_len = other._len;
_data = new char[_len + 1]; // 分配新的内存
strcpy(_data, other._data); // 拷贝内容
}

// 2. 拷贝赋值运算符 (实现深拷贝)
MyString& operator=(const MyString& other) {
std::cout << "Copy Assignment Operator called" << std::endl;

// 关键步骤1:检查自赋值
if (this == &other) {
return *this;
}

// 关键步骤2:释放当前对象已有的资源
delete[] _data;

// 关键步骤3:分配新资源并拷贝内容
_len = other._len;
_data = new char[_len + 1];
strcpy(_data, other._data);

// 关键步骤4:返回*this以支持链式赋值 (a = b = c)
return *this;
}

void print() const {
if (_data) {
std::cout << _data << std::endl;
}
}
};

int main() {
MyString str1("hello"); // Constructor

MyString str2 = str1; // 调用拷贝构造函数
// 等价于 MyString str2(str1);

MyString str3; // Constructor
str3 = str1; // 调用拷贝赋值运算符

std::cout << "--- Exiting main ---" << std::endl;
return 0; // str3, str2, str1 的析构函数依次被调用
}

输出:

1
2
3
4
5
6
7
8
Constructor called
Copy Constructor called
Constructor called
Copy Assignment Operator called
--- Exiting main ---
Destructor called for hello
Destructor called for hello
Destructor called for hello

现在,每个对象都有自己独立的内存,析构时各自释放自己的内存,程序运行正常,没有任何内存错误。


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
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <string>
#include <vector>
#include <memory>

class MyModernClass {
private:
std::string _name; // std::string 内部实现了深拷贝
std::vector<int> _items; // std::vector 内部实现了深拷贝
std::unique_ptr<SomeResource> _resource; // unique_ptr 管理资源,但不可拷贝

public:
// 不需要手动写析构、拷贝构造、拷贝赋值、移动构造、移动赋值!
// 编译器自动生成的版本会正确地调用每个成员的对应函数。
};

当拷贝 MyModernClass 的对象时,编译器生成的拷贝构造函数会自动调用 std::stringstd::vector 的拷贝构造函数,它们会执行正确的深拷贝。你一行代码都不用多写,这就是零之法则。


6. 总结对比表

特性 浅拷贝 (Shallow Copy) 深拷贝 (Deep Copy)
定义 只复制成员变量的值,如果是指针,则复制地址。 复制成员变量的值,如果是指针,则为指针所指内容分配新内存并复制。
实现方式 编译器默认生成。 需要用户自己实现拷贝构造函数和拷贝赋值运算符。
资源管理 多个对象共享同一份外部资源。 每个对象拥有自己独立的外部资源副本。
优点 速度快,开销小。 安全,对象间互不影响,避免内存错误。
缺点 极易导致悬挂指针和重复释放等内存错误,非常危险。 实现复杂,有额外的内存分配和数据复制开销,性能较低。
适用场景 仅当类中没有指针成员或不涉及动态资源管理时才安全。 只要类中包含指向动态分配资源的指针,就必须使用深拷贝。
现代实践 尽量避免需要手动区分的场景,使用 RAII 类(std::string, std::vector, 智能指针)来自动处理,遵循“零之法则”。