16. 三/五法则(Rule of Three/Five):什么是三/五法则?为什么需要遵循?如何实现?
好,咱们今天聊一个C++里特别实在的话题——三/五法则。
我记得刚入行那会儿,写类的时候只管往里塞数据成员,析构函数?不写。拷贝构造?不写。赋值运算符?更不写。编译器帮我生成不就行了嘛。结果呢?有一次线上服务莫名其妙地double free,查了一整天才发现——一个类里有个裸指针,浅拷贝之后两个对象指向同一块内存,析构时各自释放了一次。嗯,从那以后,我再也不敢无视三/五法则了。
什么是三/五法则?
说白了,这是一条C++资源管理的黄金准则。
三法则(Rule of Three) 说的是:如果你需要显式定义以下三个函数中的任何一个,那么你很可能需要把三个都定义了:
- 析构函数(Destructor)
- 拷贝构造函数(Copy Constructor)
- 拷贝赋值运算符(Copy Assignment Operator)
五法则(Rule of Five) 是C++11引入移动语义后的扩展版,在三个的基础上再加两个:
- 移动构造函数(Move Constructor)
- 移动赋值运算符(Move Assignment Operator)
核心思想: 如果你的类直接管理某种资源(堆内存、文件句柄、网络连接等),编译器自动生成的拷贝/移动/析构行为大概率是错的。
为什么需要遵循?
你想想看,编译器默认生成的拷贝构造函数做的是什么事?——逐成员浅拷贝。对于int、double这种值类型,浅拷贝没问题。但对于指针呢?两个对象的指针成员指向同一块内存,谁负责释放?谁先释放?释放后另一个怎么办?
我在项目中遇到过这样一个场景:一个日志缓冲区类,内部维护了一个char*指针指向堆上分配的缓冲区。团队里有人忘了写拷贝构造函数,结果两个Logger对象共享同一块缓冲区。一个对象写日志时把内容改了,另一个对象读到的全是乱码。更糟的是,析构时double free,程序直接崩溃。
这就是为什么需要三/五法则——为了正确管理资源,避免悬空指针、内存泄漏、重复释放。
注意: 如果你没有手动管理资源(比如类里全是vector、string、unique_ptr这些RAII封装好的类型),那就不需要自己写三/五函数。编译器生成的版本完全够用。这叫「零法则(Rule of Zero)」——尽量让编译器帮你干活。
如何实现?
咱们直接看代码。假设有一个管理动态数组的类:
class IntArray {
public:
// 构造函数:分配资源
IntArray(size_t size)
: m_size(size), m_data(new int[size]) {
std::fill(m_data, m_data + m_size, 0);
}
// 析构函数:释放资源
~IntArray() {
delete[] m_data;
m_data = nullptr; // 我个人习惯置空,防野指针
}
// 拷贝构造函数:深拷贝
IntArray(const IntArray& other)
: m_size(other.m_size), m_data(new int[other.m_size]) {
std::copy(other.m_data, other.m_data + m_size, m_data);
}
// 拷贝赋值运算符:深拷贝 + 自赋值检查
IntArray& operator=(const IntArray& other) {
if (this != &other) { // 自赋值检查,我曾经吃过这个亏
delete[] m_data; // 释放旧资源
m_size = other.m_size;
m_data = new int[m_size];
std::copy(other.m_data, other.m_data + m_size, m_data);
}
return *this;
}
// 移动构造函数:窃取资源
IntArray(IntArray&& other) noexcept
: m_size(other.m_size), m_data(other.m_data) {
other.m_size = 0;
other.m_data = nullptr; // 把源对象置为空
}
// 移动赋值运算符
IntArray& operator=(IntArray&& other) noexcept {
if (this != &other) {
delete[] m_data; // 释放自己的旧资源
m_size = other.m_size;
m_data = other.m_data;
other.m_size = 0;
other.m_data = nullptr;
}
return *this;
}
private:
size_t m_size;
int* m_data;
};
小技巧: 拷贝赋值运算符可以用「拷贝并交换(Copy-and-Swap)」惯用法来写,代码更简洁、异常安全。但那是另一个话题了,咱们今天先掌握基础版。
三/五法则的核心逻辑
下面这张图帮你理清思路:
常见误区与避坑指南
我曾经犯过一个错:写了析构函数和拷贝构造函数,但忘了写拷贝赋值运算符。结果代码在大部分场景下跑得挺好,唯独在obj1 = obj2这种赋值场景下,资源管理就出问题了。所以记住——要么三个都写,要么都不写。
另一个常见误区是移动构造函数不标记noexcept。为什么重要?因为标准库容器(比如std::vector)在扩容时,如果移动构造函数是noexcept的,它会优先使用移动操作;否则退化为拷贝操作,性能差很多。我个人习惯只要移动操作里不抛异常,一律加上noexcept。
总结一下:
- 类里有裸指针、文件句柄等资源 → 实现五法则
- 类里全是RAII封装好的类型 → 零法则,啥也不写
- 写了析构函数 → 大概率需要写拷贝构造和拷贝赋值
- 移动操作不抛异常 → 记得加
noexcept
三/五法则不是什么高深理论,它就是C++程序员在资源管理这件事上总结出来的实战经验。你只要记住一句话:资源谁管理,谁负责拷贝、移动和释放。做到这一点,你的类就安全了。
公众号:蓝海资料掘金营,微信 deep3321