三-五-零法则

ZaynPei Lv6

这个法则是关于如何正确管理类中的资源(尤其是动态分配的内存),避免内存泄漏和悬挂指针等问题的指导方针。它们是C++语言演化过程中逐步形成的三个阶段性法则。

法则一:三法则 (The Rule of Three) - C++98/03时代

核心思想:如果一个类需要程序员显式地定义析构函数、拷贝构造函数或拷贝赋值运算符 中的任意一个,那么它极有可能也需要定义所有这三个

  • 析构函数 (~MyClass()):释放类所拥有的资源。

  • 拷贝构造函数 (MyClass(const MyClass& other)):用一个已存在的对象来创建一个新对象。

  • 拷贝赋值运算符 (MyClass& operator=(const MyClass& other)):将一个已存在对象的值赋给另一个已存在的对象。

这个法则的根源在于手动资源管理。通常,你需要自定义这三个函数的唯一原因,就是你的类通过裸指针管理着动态分配的内存或其他资源(如文件句柄、网络连接等)。

如果你需要 析构函数 -> 说明你需要在对象销毁时释放资源(例如 delete ptr;)。

既然你需要释放资源,那么编译器自动生成的 拷贝构造函数(它只会进行浅拷贝,即直接复制指针地址)就是错误的。因为它会导致两个对象指向同一块内存,造成重复释放。所以,你必须自己写一个深拷贝的版本。

同理,编译器自动生成的 拷贝赋值运算符也是浅拷贝,同样是错误的。它不仅会造成重复释放,还会导致内存泄漏(赋值前没有释放自己原有的内存)。所以,你也必须自己实现它。

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
#include <cstring>
#include <iostream>

class String {
public:
// 构造函数
String(const char* s = "") {
std::cout << "构造函数\n";
m_data = new char[strlen(s) + 1];
strcpy(m_data, s);
}

// 1. 析构函数 (触发三法则)
~String() {
std::cout << "析构函数\n";
delete[] m_data;
}

// 2. 拷贝构造函数 (必须提供,否则会重复释放)
String(const String& other) {
std::cout << "拷贝构造函数\n";
m_data = new char[strlen(other.m_data) + 1];
strcpy(m_data, other.m_data);
}

// 3. 拷贝赋值运算符 (必须提供,否则会内存泄漏+重复释放)
String& operator=(const String& other) {
std::cout << "拷贝赋值运算符\n";
if (this == &other) { // 1. 检查自赋值
return *this;
}
delete[] m_data; // 2. 释放旧资源
m_data = new char[strlen(other.m_data) + 1]; // 3. 分配新资源
strcpy(m_data, other.m_data); // 4. 复制数据
return *this;
}

private:
char* m_data;
};

法则二:五法则 (The Rule of Five) - C++11及以后

由于C++11引入了移动语义 (Move Semantics),三法则扩展为了五法则。如果一个类定义了五个特殊成员函数中的任何一个,它应该把它们全部定义(或 =delete 掉)。

新增的两个特殊成员函数:

  • 移动构造函数 (MyClass(MyClass&& other) noexcept):用一个将要被销毁的临时对象(右值)来创建新对象,通过“窃取”其资源来避免昂贵的拷贝。

  • 移动赋值运算符 (MyClass& operator=(MyClass&& other) noexcept):从一个临时对象(右值)“窃取”资源。

移动语义是C++11的重大性能提升。如果你的类管理着资源,你不仅要告诉编译器如何拷贝它,还应该告诉编译器如何移动它。因为移动操作(只是交换指针和设置nullptr)远比深拷贝(分配内存 + 复制数据)要快得多。

如果你手动定义了拷贝操作(拷贝构造/拷贝赋值)或析构函数,编译器通常不会再自动为你生成移动操作。这会导致在需要移动的场合(例如从函数返回临时对象)被迫降级去执行昂贵的拷贝操作,从而丧失性能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ... 在上面的 String 类中增加 ...

// 4. 移动构造函数 (高效的资源“窃取”)
String(String&& other) noexcept {
std::cout << "移动构造函数\n";
m_data = other.m_data; // 1. 直接拿走资源
other.m_data = nullptr; // 2. 将源对象置为空,防止它析构时释放资源
}

// 5. 移动赋值运算符, 设置 noexcept 表示这个函数不会抛异常
String& operator=(String&& other) noexcept {
std::cout << "移动赋值运算符\n";
if (this == &other) { // 1. 检查自赋值
return *this;
}
delete[] m_data; // 2. 释放自己的旧资源
m_data = other.m_data; // 3. 拿走对方的资源
other.m_data = nullptr; // 4. 将源对象置为空
return *this;
}

法则三:零法则 (The Rule of Zero) - 现代C++最佳实践

应该优先设计一个类,它不需要程序员编写任何自定义的析构函数、拷贝/移动构造函数或赋值运算符。

因为手动编写这五个函数非常繁琐且极易出错(例如忘记检查自赋值、忘记处理异常安全等)。现代C++提倡将资源管理交给专门的类去做

具体实现方法, 可以通过使用C++标准库提供的RAII (Resource Acquisition Is Initialization) 容器智能指针来管理资源。

  • 用 std::string 代替 char* 来管理字符串。
  • 用 std::vector 代替裸数组。
  • 用 std::unique_ptr 或 std::shared_ptr 代替裸指针来管理动态分配的对象。

这些标准库组件本身已经完美地实现了五法则。你的类只需要包含它们作为成员,编译器自动生成的特殊成员函数就会正确地调用这些成员的对应函数

例如, 之前的 String 类可以简化为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <string>
#include <iostream>

class String {
public:
String(const std::string& s = "") : m_data(s) {
std::cout << "构造函数\n";
}
// ... 其他成员函数 ...

// 不需要写析构函数
// 不需要写拷贝构造函数
// 不需要写拷贝赋值运算符
// 不需要写移动构造函数
// 不需要写移动赋值运算符
// 编译器会为我们自动生成所有这些,并且它们都是正确的!

private:
std::string m_data; // 使用 std::string 来管理资源
};

通过遵循零法则,你的类会变得更简单、更安全、更易维护,同时也能享受到现代C++的强大功能。

总结

法则 | 何时应用 | 核心内容 | 目标 |

三法则 (遗留代码/C++98) 类中直接用裸指针管理资源。 如果定义了 析构、拷贝构造、拷贝赋值 之一,就要定义全部三个,以实现深拷贝。 避免浅拷贝导致的内存错误。 五法则 (C++11及以后) 类中直接用裸指针管理资源,且需要高性能。 在三法则基础上,增加 移动构造 和 移动赋值,以利用移动语义提升性能。 在安全的基础上,提供更高的性能。 零法则 (现代C++最佳实践) 设计新类时。 不要用裸指针管理资源。使用 std::string, std::vector, std::unique_ptr 等RAII工具,从而让编译器自动生成所有正确的特殊函数。 让代码更简洁、更安全、更易维护,避免重复造轮子。