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(C++98) ~MyClass() 析构函数 MyClass(const MyClass&) 拷贝构造 MyClass& operator=(const MyClass&) 拷贝赋值 Rule of Five(C++11 新增) MyClass(MyClass&&) 移动构造 MyClass& operator=(MyClass&&) 移动赋值 C++11 扩展 定义其中任何一个 → 建议全部五个都定义

从图上能看出来,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之后,资源管理类的标配就是这五个函数。

专注资料整理