简而言之,C++三准则(The rule of three)指的是在一个类中,如果用户自定义了析构函数(Destructor),复制构造函数(copy constructor)或者复制赋值运算符(copy assignment operator)其中的任意一个,那么用户需要将这三个全部自定义。

引言

我们首先来看一下析构函数,复制构造函数和复制赋值运算符是什么。用一个stackoverflow上的例子:

class person
{
    std::string name;
    int age;
 
public:
 
    person(const std::string& name, int age) : name(name), age(age)
    {
    }
};
 
int main()
{
    person a("Bjarne Stroustrup", 60);
    person b(a);   // 1.这啥?
    b = a;         // 2.这又是啥?
}

C++是如何进行一个person的对象的复制的呢?有两种方式,一个是通过复制构造函数,如b(a),是通过复制已有对象的所有状态来构造一个新的对象;二是通过复制赋值运算符,如b=a,由于对象b已经存在并且初始化完成,此时通过赋值运算改变其状态需要进行较多的操作,如内存管理等。在上述例子中,我们并没有自定义任何的析构函数,复制构造函数和复制赋值运算符,它们其实是类的特殊成员函数,在缺省的情况下,会被隐式定义为如下:

// 1. 复制构造函数
person(const person& that) : name(that.name), age(that.age)
{
}
 
// 2. 复制赋值运算符
person& operator=(const person& that)
{
    name = that.name;
    age = that.age;
    return *this;
}
 
// 3. 析构函数
~person()
{
}

其中复制构造函数默认会逐个复制旧对象的成员;复制赋值运算符会逐个复制赋值旧对象的成员;析构函数一般为空。

资源管理

那么问题来了,既然类都帮我们缺省定义了这三个函数,而且举的例子没有自定义也完全能work,我们还需要管这么多吗?Just let it go?回答这个问题前,我们首先得明确,为什么会有这三个特殊函数。可以发现,这三个函数其实负责的是同一件事情,就是管理对象的资源。当我们构造一个对象的时候,我们需要在构造函数中给这个对象分配资源;当析构一个对象的时候,我们也要在析构函数中释放资源;当复制赋值一个旧对象的时候,我们也要在复制赋值运算符中管理资源的复制。所以要不要自定义这三个函数,其实和我们要处理的资源有关系。在大部分情况下,我们不需要自定义。但是当管理的资源涉及到裸指针,文件描述符和互斥锁等,需要用户自定义,因为隐形定义的这三个函数会直接复制指针等,这是一项非常危险的操作。我们把上例中的string换成char array:

class person
{
    char* name;
    int age;
 
public:
 
    // 构造函数通过new[] 分配资源
    person(const char* the_name, int the_age)
    {
        name = new char[strlen(the_name) + 1];
        strcpy(name, the_name);
        age = the_age;
    }
 
    // 析构函数通过delete[] 释放资源
    ~person()
    {
        delete[] name;
    }
};

如果用户使用缺省的三个特殊函数,那么两个对象ab会共享同一个char指针,也就是aname的改变在b中也能观察到,且如果b被销毁,那么a.name就变成了野指针,此时销毁a就会产生undefined behavior. 所以此时隐式定义不能满足我们的需求,我们需要自定义这三个特殊函数:

// 1. 复制构造函数
person(const person& that)
{
    name = new char[strlen(that.name) + 1];
    strcpy(name, that.name);
    age = that.age;
}
 
// 2. 复制赋值运算符
person& operator=(const person& that)
{
    char* local_name = new char[strlen(that.name) + 1];
    // If the above statement throws,
    // the object is still in the same state as before.
    // None of the following statements will throw an exception :)
    strcpy(local_name, that.name);
    delete[] name;
    name = local_name;
    age = that.age;
    return *this;
}
 
// 3. 析构函数
~person()
{
    delete[] name;
}

当需要管理的资源不可复制时,如文件描述符和互斥锁,我们需要将复制构造函数和复制赋值运算符设为private且不提供定义:

private:
    person(const person& that);
    person& operator=(const person& that);

或者将他们声明为deleted (C++11):

person(const person& that) = delete;
person& operator=(const person& that) = delete;

三/五/零准则

C++11中一个对象另外加入了两个特殊成员函数:移动构造函数移动赋值运算符。五准则指的是如果定义了其中一个特殊成员函数,那么5个都需要用户自定义,缺一不可。Cppreference的一个例子如下:

class rule_of_five
{
    char* cstring; // 用作到动态分配内存的柄的裸指针
    rule_of_five(const char* s, std::size_t n) // 避免计数二次
    : cstring(new char[n]) // 分配器
    {
        std::memcpy(cstring, s, n); // 填充
    }
 public:
    rule_of_five(const char* s = "")
    : rule_of_five(s, std::strlen(s) + 1)
    {}
    // 1. 析构函数
    ~rule_of_five()
    {
        delete[] cstring;  // 解分配
    }
    // 2. 复制构造函数
    rule_of_five(const rule_of_five& other) 
    : rule_of_five(other.cstring)
    {}
    // 3. 移动构造函数
    rule_of_five(rule_of_five&& other) noexcept 
    : cstring(std::exchange(other.cstring, nullptr))
    {}
    // 4.复制赋值运算符
    rule_of_five& operator=(const rule_of_five& other) 
    {
         return *this = rule_of_five(other);
    }
    // 5. 移动赋值运算符
    rule_of_five& operator=(rule_of_five&& other) noexcept 
    {
        std::swap(cstring, other.cstring);
        return *this;
    }
// 另外可以用下列内容替换两个赋值运算符
//  rule_of_five& operator=(rule_of_five other) noexcept
//  {
//      std::swap(cstring, other.cstring);
//      return *this;
//  }
};

零准则指的是用户在创建自定义类的时候可以不定义任何一个上述特殊成员函数。

应用

在实际coding的时候,我们基本不会考虑到三/五/零准则,尤其到了C++11,裸指针可以由智能指针替代。只有在性能优化时,会考虑实现高效的自定义赋值运算符。