当定义一个类时,我们显式地或隐式地指定了此类型的对象在拷贝、赋值和销毁时做什么。一个类通过定义三种特殊的成员函数来控制这些操作,分别是拷贝构造函数、赋值运算符和析构函数。
拷贝构造函数定义了当用同类型的另一个对象初始化新对象时做什么,赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么,析构函数定义了此类型的对象销毁时做什么。我们将这些操作称为拷贝控制操作。
由于拷贝控制操作是由三个特殊的成员函数来完成的,所以我们称此为“C++三法则”。在较新的 C++11 标准中,为了支持移动语义,又增加了移动构造函数和移动赋值运算符,这样共有五个特殊的成员函数,所以又称为“C++五法则”。也就是说,“三法则”是针对较旧的 C++89 标准说的,“五法则”是针对较新的 C++11 标准说的。为了统一称呼,后来人们干把它叫做“C++ 三/五法则”。
如果一个类没有定义所有这些拷贝控制成员,编译器会自动为它定义默认的操作,因此很多类会忽略这些拷贝控制操作。但是,对于一些持有其他资源(例如动态分配的内存、打开的文件、指向其他数据的指针、网络连接等)的类来说,依赖这些默认的操作会导致灾难,我们必须显式的定义这些操作。
C++ 并不要求我们定义所有的这些操作,你可以只定义其中的一个或两个。但是,这些操作通常应该被看做一个整体,只需要定义其中一个操作,而不需要定义其他操作的情况很少见。
当我们决定是否要为一个类显式地定义拷贝构造函数和赋值运算符时,一个基本原则是首先确定这个类是否需要一个析构函数。通常,对析构函数的需求要比拷贝构造函数和赋值运算符的需求更加明显。如果一个类需要定义析构函数,那么几乎可以肯定这个类也需要一个拷贝构造函数和一个赋值运算符。
我们在前面几节中使用过的 Array 类就是一个典型的例子。这个类在构造函数中动态地分配了一块内存,并用一个成员变量(指针变量)指向它,默认的析构函数不会释放这块内存,所以我们需要显式地定义一个析构函数来释放内存。
「应该怎么做」可能还是有点不清晰,但基本原则告诉我们,Array 类也需要一个拷贝构造函数和一个赋值运算符。
如果我们为 Array 定义了一个析构函数,但却使用默认的拷贝构造函数和赋值运算符,那么将导致不同对象之间相互干扰,修改一个对象的数据会影响另外的对象。此外还可能会导致内存操作错误,请看下面的代码:
Array func(Array arr){ //按值传递,将发生拷贝
Array ret = arr; //发生拷贝
return ret; //ret和arr将被销毁
}
当 func() 返回时,arr 和 ret 都会被销毁,在两个对象上都会调用析构函数,此析构函数会 free() 掉 m_p 成员所指向的动态内存。但是,这两个对象的 m_p 成员指向的是同一块内存,所以该内存会被 free() 两次,这显然是一个错误,将要发生什么是未知的。
此外,func() 的调用者还会继续使用传递给 func() 的对象:
Array arr1(10);
func(arr1); //当 func() 调用结束时,arr1.m_p 指向的内存被释放
Array arr2 = arr1; //现在 arr2 和 arr1 都指向无效内存
arr2(以及 arr1)指向的内存不再有效,在 arr(以及 ret)被销毁时系统已经归还给操作系统了。
总之,如果一个类需要定义析构函数,那么几乎可以肯定它也需要定义拷贝构造函数和赋值运算符。
虽然很多类需要定义所有(或是不需要定义任何)拷贝控制成员,但某些类所要完成的工作,只需要拷贝或者赋值操作,不需要析构操作。
作为一个例子,考虑一个类为每个对象分配一个独有的、唯一的编号。这个类除了需要一个拷贝构造函数为每个新创建的对象生成一个新的编号,还需要一个赋值运算符来避免将一个对象的编号赋值给另外一个对象。但是,这个类并不需要析构函数。
这个例子引出了第二个基本原则:如果一个类需要一个拷贝构造函数,几乎可以肯定它也需要一个赋值运算符;反之亦然。然而,无论需要拷贝构造函数还是需要复制运算符,都不必然意味着也需要析构函数。