C++ 三之法则、五之法则和零之法则

如果你刚开始接触 C++,可能会被“三之法则”、“五之法则”和“零之法则”这些术语搞得一头雾水。这些其实是 C++ 中关于资源管理(比如内存、文件句柄、网络连接等)的核心设计原则。简单来说,它们告诉我们:当类需要管理资源时,哪些特殊成员函数必须被正确定义,以避免内存泄漏、重复释放等问题。本文会用通俗的语言和代码示例,带你彻底搞懂这些法则。## 为什么需要这些法则?C++ 允许你自定义类,而类的对象可以通过拷贝、赋值、销毁等方式被操作。如果你没有手动管理资源,C++ 会为你生成默认的拷贝构造函数、赋值运算符和析构函数。这些默认行为只是简单地“浅拷贝”——直接复制成员变量的值。但对于动态分配的内存(比如 new 出来的指针),浅拷贝会导致多个对象共享同一块内存,一旦其中一个对象被销毁,其他对象的指针就成了“悬空指针”,程序就会崩溃。三之法则、五之法则和零之法则就是为了解决这个问题而生的。它们指导我们:什么时候需要自定义这些特殊函数,以及如何正确地实现它们。## 三之法则(Rule of Three)三之法则适用于 C++98/03 时代。它指出:如果一个类需要显式定义以下三个函数中的任何一个,那么它通常需要显式定义所有三个:1. 析构函数~ClassName()):释放资源。2. 拷贝构造函数ClassName(const ClassName& other)):通过已有对象创建新对象。3. 拷贝赋值运算符ClassName& operator=(const ClassName& other)):将已有对象赋值给另一个已存在的对象。为什么?因为如果你需要手动释放资源(比如 delete 指针),说明你的类管理着资源。默认的拷贝构造函数和赋值运算符只会浅拷贝指针,导致两个对象指向同一块内存。当一个对象被销毁时,另一个对象的指针就无效了。所以你需要自己实现深拷贝。### 代码示例 1:违反三之法则的后果cpp#include <iostream>#include <cstring>class StringBad {private: char* data; // 动态分配的字符数组public: // 构造函数:分配内存并复制字符串 StringBad(const char* str = "") { data = new char[strlen(str) + 1]; strcpy(data, str); std::cout << "构造: " << data << "\n"; } // 析构函数:释放内存 ~StringBad() { std::cout << "析构: " << data << "\n"; delete[] data; } // 注意:没有定义拷贝构造函数和拷贝赋值运算符! void print() const { std::cout << data << "\n"; }};int main() { StringBad s1("Hello"); StringBad s2(s1); // 使用默认拷贝构造函数(浅拷贝) s2.print(); // 输出 "Hello",但 s2.data 和 s1.data 指向同一地址 // 程序结束时,s1 和 s2 都会调用析构函数,导致同一块内存被释放两次! return 0; // 输出:析构: Hello (s2 先析构,释放内存) // 析构: Hello (s1 析构,但内存已被释放,崩溃或未定义行为)}运行结果:程序可能会崩溃或输出乱码,因为 s2 被销毁后,s1data 指针变成了悬空指针。### 代码示例 2:正确实现三之法则cpp#include <iostream>#include <cstring>class StringGood {private: char* data;public: // 构造函数 StringGood(const char* str = "") { data = new char[strlen(str) + 1]; strcpy(data, str); std::cout << "构造: " << data << "\n"; } // 析构函数 ~StringGood() { std::cout << "析构: " << data << "\n"; delete[] data; } // 拷贝构造函数:深拷贝 StringGood(const StringGood& other) { data = new char[strlen(other.data) + 1]; strcpy(data, other.data); std::cout << "拷贝构造: " << data << "\n"; } // 拷贝赋值运算符:深拷贝,并处理自赋值 StringGood& operator=(const StringGood& other) { if (this == &other) return *this; // 自赋值检查 delete[] data; // 释放原有资源 data = new char[strlen(other.data) + 1]; strcpy(data, other.data); std::cout << "拷贝赋值: " << data << "\n"; return *this; } void print() const { std::cout << data << "\n"; }};int main() { StringGood s1("Hello"); StringGood s2(s1); // 调用拷贝构造函数 s2.print(); StringGood s3("World"); s3 = s1; // 调用拷贝赋值运算符 s3.print(); return 0; // 所有析构函数正常释放各自的内存}输出构造: Hello拷贝构造: HelloHello构造: World拷贝赋值: HelloHello析构: Hello析构: Hello析构: Hello每个对象都有自己的独立内存,安全可靠。## 五之法则(Rule of Five)C++11 引入了移动语义(std::move)和右值引用(&&),允许我们“偷”资源而非拷贝。因此,三之法则扩展成了五之法则,增加了两个函数:4. 移动构造函数ClassName(ClassName&& other)):将资源从右值对象“转移”到新对象。5. 移动赋值运算符ClassName& operator=(ClassName&& other)):将资源从右值对象转移到已存在的对象。移动语义可以显著提高性能:对于临时对象,我们不需要深拷贝,只需将指针所有权转移,然后清空源对象。### 代码示例 3:五之法则的完整实现cpp#include <iostream>#include <cstring>#include <utility> // for std::moveclass StringMove {private: char* data;public: // 构造函数 StringMove(const char* str = "") { data = new char[strlen(str) + 1]; strcpy(data, str); std::cout << "构造: " << data << "\n"; } // 析构函数 ~StringMove() { if (data) { std::cout << "析构: " << data << "\n"; delete[] data; } } // 拷贝构造函数 StringMove(const StringMove& other) { data = new char[strlen(other.data) + 1]; strcpy(data, other.data); std::cout << "拷贝构造: " << data << "\n"; } // 拷贝赋值运算符 StringMove& operator=(const StringMove& other) { if (this == &other) return *this; delete[] data; data = new char[strlen(other.data) + 1]; strcpy(data, other.data); std::cout << "拷贝赋值: " << data << "\n"; return *this; } // 移动构造函数:转移资源,并置空源对象 StringMove(StringMove&& other) noexcept : data(other.data) { other.data = nullptr; // 防止源对象析构时释放资源 std::cout << "移动构造\n"; } // 移动赋值运算符:释放自身资源,转移对方资源 StringMove& operator=(StringMove&& other) noexcept { if (this == &other) return *this; delete[] data; // 释放当前资源 data = other.data; // 转移指针 other.data = nullptr; // 置空源对象 std::cout << "移动赋值\n"; return *this; } void print() const { if (data) std::cout << data << "\n"; else std::cout << "(空)\n"; }};int main() { StringMove s1("Hello"); StringMove s2(std::move(s1)); // 移动构造 s2.print(); // 输出 "Hello" s1.print(); // 输出 "(空)",资源已被转移 StringMove s3("World"); s3 = std::move(s2); // 移动赋值 s3.print(); // 输出 "Hello" s2.print(); // 输出 "(空)" return 0;}输出构造: Hello移动构造Hello(空)构造: World移动赋值Hello(空)析构: World析构: Hello析构: (空) // s1 和 s2 析构时 data 为 nullptr,安全移动操作避免了深拷贝,仅转移指针,效率更高。## 零之法则(Rule of Zero)既然手动管理资源这么麻烦,有没有更简单的方法?有!零之法则主张:尽量使用 RAII(资源获取即初始化)类来管理资源,让你的类不需要定义任何特殊成员函数。换句话说,让编译器生成的默认函数就能正常工作。例如,用 std::string 代替 char*,用 std::vector 代替动态数组,用 std::unique_ptrstd::shared_ptr 代替裸指针。这些标准库组件已经正确实现了资源管理(包括移动语义),你的类只需组合它们即可。### 代码示例 4:零之法则的应用cpp#include <iostream>#include <string> // 使用 std::string 自动管理字符串内存#include <vector> // 使用 std::vector 自动管理数组内存class Student {private: std::string name; // RAII 类,自动管理内存 std::vector<int> scores; // RAII 类,自动管理内存public: // 构造函数:只需初始化成员,无需手动 new/delete Student(const std::string& n, std::initializer_list<int> s) : name(n), scores(s) {} void print() const { std::cout << name << ": "; for (int score : scores) std::cout << score << " "; std::cout << "\n"; } // 无需定义析构函数、拷贝/移动构造函数、赋值运算符! // 编译器生成的默认版本会调用 std::string 和 std::vector 的对应函数};int main() { Student s1("Alice", {90, 85, 92}); Student s2(s1); // 默认拷贝构造,自动深拷贝 s2.print(); Student s3("Bob", {70, 80}); s3 = std::move(s1); // 默认移动赋值,自动转移资源 s3.print(); return 0;}输出Alice: 90 85 92 Bob: 90 85 92零之法则让代码更简洁、更安全,推荐优先使用。## 总结- 三之法则:如果类需要自定义析构函数,通常也需要自定义拷贝构造函数和拷贝赋值运算符(C++98/03 时代)。- 五之法则:在 C++11 及以后,增加移动构造函数和移动赋值运算符,以支持移动语义,提升性能。- 零之法则:优先使用 RAII 类(如 std::stringstd::vector、智能指针)组合成你的类,避免手动管理资源,让编译器生成的默认函数正常工作。在实际开发中,零之法则是最佳实践:尽量让资源管理交给标准库。只有当你的类封装了非标准资源(如操作系统句柄、自定义内存池)时,才需要考虑五之法则或三之法则。记住这些法则,能让你写出更健壮、更高效的 C++ 代码。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐