三法则

C++11前,没有移动语义。考虑下面三个函数,

  • 析构函数
  • 拷贝构造函数
  • 拷贝复制运算符

三法则就是说:如果这三个中你需要定义一个,那么另外两个你(几乎)也得定义。

什么意思?对于普通不涉及资源管理的类,如果你不写这三个函数,编译器会自动生成默认版本,其行为通常是对成员变量逐一拷贝或析构(调用成员变量的operator=或析构函数)。这在可平凡拷贝和平凡析构的类型中没有问题。但是如果成员变量管理了某个资源(如动态内存,文件句柄,网络连接等),只是浅拷贝的话肯定会出问题。

struct A {
    A() : data(new int[5]) {}
    int* data;
};

对于上面这个类,成员变量dataint类型的指针,它管理了动态内存。如果不手动写析构函数,就会内存泄漏。进一步的,如果只写了析构函数,那么当对象发生拷贝时(平凡拷贝仅复制指针变量的取值),就会有两个对象持有同一块动态内存,析构时就会double free。因此,你还得手写拷贝构造和拷贝赋值。

struct A {
    A() : data(new int[5]) {}
    ~A() { delete[] data; }
    A(const A& other) : data(new int[5]) {
        memcpy(data, other.data, 5);
    }
    A& operator=(const A& rhs) {
        if (this != &rhs) {
            memcpy(data, other.data, 5);
        }
        return *this;
    }
    int* data;
};

五法则

C++11后,增加了移动语义。上面三个函数扩充为五个,

  • 析构函数
  • 拷贝构造函数
  • 拷贝赋值运算符
  • 移动构造函数
  • 移动赋值运算符

五法则就是说,如果这五个函数你需要定义其中一个,那么其他四个你(几乎)也需要定义出来。

同样的道理,对于涉及资源管理的类型。为了资源正确管理/释放/转移,你往往需要定义这五个当中的一个,但如果其他四个你不写,编译器生成的默认版本可能并不会正确地管理资源,造成隐晦的bug。

注意

C++11起, = default, = delete 也算手动定义。

因为用户定义(包括 = default 或 = delete)的析构函数、复制构造函数或复制赋值运算符,会阻止隐式定义移动构造函数移动赋值运算符,所以任何想要移动语义的类必须声明全部五个特殊成员函数。

注意:

  • 在需要调用移动版本的构造和赋值时,如果类型没有定义移动构造/赋值,重载决议可以选择接受 const T& 的拷贝构造/赋值1
  • 如果没有定义拷贝构造/赋值,且代码中的确用到了,编译器会隐式定义2
  • 用户声明拷贝构造/拷贝赋值/析构函数,都会阻止编译器隐式声明移动构造/赋值。要移动语义,需要自己声明,例如= default
  • 如果你定义了移动构造/赋值,拷贝构造/赋值会被隐式delete

可以发现,定义析构/拷贝构造/拷贝赋值,就不会隐式定义移动构造/赋值;定义移动构造/赋值,拷贝构造直接隐式删除。从这个行为来看,好像拷贝构造更重要,确实如此。因为以前没有移动构造/赋值,如果后者改为不隐式定义拷贝构造,那么传 const T& 时,也会因为找不到匹配的调用而编译失败。因为 T&& 不能接收 const T&。还不如一开始就隐式删除,明确告诉你。而且如果隐式生成拷贝构造/赋值(通常是按照成员逐一浅拷贝),可能会引发资源泄漏,所以也不能生成。

对于一个类T,先去分两种不同的事:

  1. 编译器是否声明某个特殊成员函数
  2. 该函数即使被声明后,是否最终可用,还是被定义为deleted

特殊成员函数包括,

  • 默认构造函数
  • 析构函数
  • 拷贝构造函数
  • 拷贝赋值运算符
  • 移动构造函数
  • 移动赋值运算符

默认构造函数的隐式定义

这里插播一下什么时候会生成默认构造函数。首先明确默认构造函数不只是没有任何参数的构造函数,它其实是说在调用时可以不传任何参数的构造函数。例如,

struct A {
    A(); // 是默认构造函数
};
 
struct B {
    B(int x = 0); // 是默认构造函数
};
 
struct C {
    C(int x, int y = 0); // 不是默认构造函数,因为至少传一个实参才能调用
};
 
struct D {
    D(int x = 1, char y = '2'); // 是默认构造函数
};
 
struct E {
    explicit E(int x, char y = '2'); // 可被当作单参构造函数,进而用explicit修饰
};

类中没有任何用户声明的构造函数(包括构造函数模板)时,编译器自动声明一个无参、public的默认构造函数。只要声明了任意构造函数,编译器就不再自动声明无参默认构造函数。这里的“任意”包括普通构造、拷贝构造、移动构造,以及构造函数模板。

可见struct E双参构造函数亦可被explicit修饰。

零法则

如果类型不管理资源,那就不要定义上面这些特殊成员。

References

  1. https://www.cnblogs.com/codingmengmeng/p/9110608.html
  2. https://zh.cppreference.com/cpp/language/rule_of_three

Footnotes

  1. 因为 const T& 可以接收 T&& 右值引用。

  2. 如果类型中含有不可复制的成员或者基类,那么拷贝构造/赋值会被隐式delete.