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)」惯用法来写,代码更简洁、异常安全。但那是另一个话题了,咱们今天先掌握基础版。

三/五法则的核心逻辑

下面这张图帮你理清思路:

三/五法则决策流程图 你的类是否直接管理资源? 零法则 使用RAII封装类 五法则 实现全部五个函数 资源管理正确 无需手动管理

常见误区与避坑指南

我曾经犯过一个错:写了析构函数和拷贝构造函数,但忘了写拷贝赋值运算符。结果代码在大部分场景下跑得挺好,唯独在obj1 = obj2这种赋值场景下,资源管理就出问题了。所以记住——要么三个都写,要么都不写

另一个常见误区是移动构造函数不标记noexcept。为什么重要?因为标准库容器(比如std::vector)在扩容时,如果移动构造函数是noexcept的,它会优先使用移动操作;否则退化为拷贝操作,性能差很多。我个人习惯只要移动操作里不抛异常,一律加上noexcept

总结一下:

  • 类里有裸指针、文件句柄等资源 → 实现五法则
  • 类里全是RAII封装好的类型 → 零法则,啥也不写
  • 写了析构函数 → 大概率需要写拷贝构造和拷贝赋值
  • 移动操作不抛异常 → 记得加noexcept

三/五法则不是什么高深理论,它就是C++程序员在资源管理这件事上总结出来的实战经验。你只要记住一句话:资源谁管理,谁负责拷贝、移动和释放。做到这一点,你的类就安全了。


公众号:蓝海资料掘金营,微信 deep3321