21. 什么是Rule of Five?它与Rule of Three有何关系?
好,咱们来聊聊C++里一个绕不开的话题——Rule of Five。说白了,它就是Rule of Three在C++11之后的升级版。
我记得刚学C++那会儿,老前辈们总念叨「三法则」。那时候资源管理全靠手工,写个拷贝构造函数、拷贝赋值运算符、析构函数,基本就能应付大部分场景。但C++11带来了移动语义和右值引用,局面一下子就变了。
先回顾一下Rule of Three
Rule of Three说的是:如果你需要显式定义以下三个函数中的任何一个,那么你很可能需要定义全部三个:
- 析构函数(destructor)
- 拷贝构造函数(copy constructor)
- 拷贝赋值运算符(copy assignment operator)
为什么?因为这三个函数通常一起处理同一类资源——比如动态内存、文件句柄、网络连接。你写了析构函数来释放资源,那拷贝构造和拷贝赋值也得配套实现,否则就会出现双重释放或者内存泄漏。
核心思想:如果你需要手动管理资源,编译器生成的默认版本大概率不靠谱。
Rule of Five 来了
C++11引入了移动语义,于是多了两个新成员:
- 移动构造函数(move constructor)
- 移动赋值运算符(move assignment operator)
Rule of Five就是说:如果你需要显式定义析构函数、拷贝构造、拷贝赋值中的任何一个,那么你最好把移动构造和移动赋值也一并定义了。一共五个函数,一个都不能少。
嗯,这里要注意——不是说你非得写五个。而是说,只要你手动定义了其中一个,编译器就会把移动操作标记为删除(= delete)。你想想看,这多坑人?
常见陷阱:很多人只写了拷贝构造和析构,忘了移动操作。结果代码里明明有std::move,却还是调了拷贝构造,性能直接打回原形。
它们之间的关系
我画了张图,帮你理清这五个函数的关系:
从图上能看出来,Rule of Five并不是推翻了Rule of Three,而是在它的基础上做了扩展。原来的三个函数依然重要,新增的两个移动操作让资源管理更高效。
一个实际例子
我在项目中遇到过这样一个场景:写一个简单的String类,管理动态分配的字符数组。如果只实现拷贝构造和析构,忘了移动构造,那返回临时字符串时就会多一次深拷贝。性能损失在大量字符串拼接时特别明显。
来看看完整的Rule of Five实现:
class String {
private:
char* data_;
size_t size_;
public:
// 构造函数
String(const char* str = "")
: size_(strlen(str)), data_(new char[size_ + 1]) {
memcpy(data_, str, size_ + 1);
}
// 析构函数
~String() {
delete[] data_;
}
// 拷贝构造函数
String(const String& other)
: size_(other.size_), data_(new char[size_ + 1]) {
memcpy(data_, other.data_, size_ + 1);
}
// 拷贝赋值运算符
String& operator=(const String& other) {
if (this != &other) {
delete[] data_;
size_ = other.size_;
data_ = new char[size_ + 1];
memcpy(data_, other.data_, size_ + 1);
}
return *this;
}
// 移动构造函数
String(String&& other) noexcept
: data_(other.data_), size_(other.size_) {
other.data_ = nullptr;
other.size_ = 0;
}
// 移动赋值运算符
String& operator=(String&& other) noexcept {
if (this != &other) {
delete[] data_;
data_ = other.data_;
size_ = other.size_;
other.data_ = nullptr;
other.size_ = 0;
}
return *this;
}
};
个人习惯:移动操作我一般都会加上noexcept。为什么?因为标准库容器(比如std::vector)在重新分配内存时,如果移动构造是noexcept的,它会优先使用移动而不是拷贝。否则,为了保证异常安全,它只能退回到拷贝。这个细节我曾经踩过坑,后来就养成了习惯。
什么时候可以不用Rule of Five?
其实也不是所有类都需要手动实现这五个函数。我总结了几种情况:
| 场景 | 建议 | 原因 |
|---|---|---|
| 类成员都是内置类型(int、double等) | 不用管,编译器生成的就行 | 没有资源需要管理 |
| 类成员是STL容器(vector、string等) | 不用管,它们自己会处理好 | STL容器已经实现了Rule of Five |
| 类管理了动态内存、文件句柄等资源 | 必须实现Rule of Five | 否则会出现资源泄漏或双重释放 |
| 类设计为不可拷贝(如unique_ptr) | 删除拷贝操作,实现移动操作 | 明确语义,禁止拷贝 |
避坑指南
我曾经在一个项目里看到这样的代码:类里定义了析构函数,但没定义移动操作。结果std::vector<MyClass>在扩容时,明明可以移动,却调了拷贝构造。排查了半天才发现是移动操作被隐式删除了。
所以我的建议是:
- 如果你手动定义了析构、拷贝构造、拷贝赋值中的任何一个,请把剩下的四个也一并定义
- 移动操作记得加noexcept,否则标准库可能不买账
- 如果不想支持移动,可以用= delete显式删除,避免意外
一句话总结:Rule of Five = Rule of Three + 移动构造 + 移动赋值。它们不是对立的,而是继承与扩展的关系。C++11之后,资源管理类的标配就是这五个函数。