当定义一个类时,显示或隐式地指定了在该类型的对象拷贝、移动、赋值和销毁时要做什么。一个类通过定义五种特殊成员函数来控制这些操作,包括:
- 拷贝构造函数 copy constructor
- 移动构造函数 move constructor
- 拷贝赋值运算符 copy-assigned operator
- 移动赋值运算符 move-assigned operator
- 析构函数 destructor
拷贝和移动构造函数定义了当用同类型的另一个对象初始化本对象时做什么;
拷贝和移动赋值运算符定义了将一个对象赋予同类型的另一个对象时作什么;
析构函数定义了当该类型的对象销毁时做什么。
这些操作统称为拷贝控制操作(copy control),对任何C++类都是必要的。若一个类没有定义,则由编译器自动定义缺失操作,但是常常导致灾难。
拷贝、赋值、销毁
拷贝构造函数
如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则该构造函数是拷贝构造函数。
class Foo{
public:
Foo(); //默认构造函数
Foo(const Foo&); //拷贝构造函数
}
拷贝构造函数的首参数必须是自身的引用类型。const可选,但通常都是。因此拷贝构造函数通常不应该是explicit的。
合成拷贝构造函数
如果没有为一个类定义拷贝构造函数,即使定义了其他构造函数,编译器也会提供一个合成拷贝构造函数。
合成拷贝构造函数(Synthesized copy constructor)有时用来阻止拷贝该类类型的对象;但通常会将其参数对象的各个成员逐个拷贝到正在创建的对象中。编译器从给定对象将每个非static对象拷贝到正在创建的对象中。
拷贝初始化
直接初始化和拷贝初始化之间的差异:
- 直接初始化时,实际上要求编译器使用普通的函数匹配方式选择与所提供参数最匹配的构造函数;
- 而 拷贝初始化(copy initialization) 要求编译器将右侧运算对象拷贝到正在创建的对象中,可能存在类型转换。
如果类由一个移动构造函数,则拷贝初始化有时会使用移动构造函数而不是拷贝构造函数。
拷贝初始化会发生在下列情况下:
- 使用
operator=
定义变量 - 将一个对象作为实参传递给一个非引用类型的形参;
- 从一个返回类型为非引用类型的函数返回一个对象;
- 使用初始化列表(花括号)初始化一个数组中的元素或聚合类中的成员。
- 某些类还会对其所分配的对象使用拷贝初始化,例如:初始化标准库容器或是调用其insert和push成员,容器会对其元素进行拷贝初始化。而emplace创建的元素都是直接初始化。
参数和返回值
函数调用过程中具有非引用类型的参数要进行拷贝初始化。
类似地,当一个函数具有非引用的返回类型时,返回值会被用来初始化调用方的结果。
拷贝构造函数被用来初始化非引用类类型参数,因此拷贝构造函数自身的参数必须是引用类型,否则会发生死循环。
拷贝初始化的限制
如果使用初始化值要求通过一个explicit的构造函数进行类型转换,使用拷贝初始化或直接初始化就有了区别:
vector<int> v1(10); //直接初始化,可行
vector<int> v2 = 10; //错误,接受大小参数的构造函数是explicit的
void f(vector<int>); //参数使用了拷贝初始化
f(vector<int>(10)); //正确的构造方式
f(10); //错误
如上所述,接受一个容器大小参数的构造函数是explicit的,因此不能拷贝初始化。同样,传递一个实参或者返回函数值时,也不能隐式使用explicit的构造方式。必须进行显式使用,如f(vector<int>(10))
编译器可以绕过拷贝构造函数
拷贝初始化中编译器有可能跳过拷贝/移动构造函数而直接创建对象。但是即使略过,拷贝/移动构造函数也必须存在且可访问 (如非private)的。
拷贝赋值运算符
如果类未定义自己的拷贝赋值运算符,编译器会为其合成一个。
重载赋值运算符
重载运算符本质上是函数,名字由operator接要定义的运算符组成,也有一个返回类型和一个参数列表。
重载运算符的参数表示运算符的运算对象。某些运算符如赋值运算符必须定义为成员函数。如果一个运算符是一个成员函数,其左侧运算对象就绑定到隐式的this参数。对于一个二元运算符,右侧运算对象作为显式参数传递。
拷贝赋值运算符接受一个与其所在类相同类型的参数。为了与内置类型的赋值保持一致,赋值运算符通常返回一个指向其左侧运算对象的引用。
另外,标准库通常要求保存在容器中的类型具有赋值运算符,且返回值是左侧运算对象的引用。
合成拷贝赋值运算符
synthesized copy-assignment operator
类似拷贝构造函数,对于某些类,合成拷贝赋值运算符用来禁止该类对象的赋值操作。
除了该目的,还用来将右侧运算对象的每个非static成员赋予左侧运算对象的对应成员。这个工作是通过成员类型的拷贝赋值运算符来完成的。对于数组类型的成员,逐个复制数组元素。
合成拷贝赋值运算符返回一个指向其左侧运算对象的引用。
析构函数
构造函数初始化其对象的非static数据成员,析构函数相反,释放对象使用的资源并销毁该对象的非static数据成员。
析构函数是类的一个成员函数,名字由波浪号接类名构成。没有返回值且不接受参数。
class Foo{
~Foo();
};
析构函数的工作
析构函数有一个函数体和一个析构部分。在一个构造函数中,成员的初始化在函数体之前执行并且是按照在类中出现的顺序完成初始化的。而析构函数中首先执行函数体,再销毁成员,按照类中出现的逆序进行销毁。
通常,析构函数释放对象在生存期分配的所有资源。
在析构函数中,不存在类似构造函数中初始化列表的方式来控制销毁。析构部分是隐式的。成员销毁时发生什么完全依赖于成员的类型。
销毁类类型的成员需要执行其自己的析构函数。内置类型没有析构函数。因此销毁时不需要其他操作。智能指针是类类型,因此具有析构函数。与普通指针不同,智能指针成员在析构阶段被自动销毁。
调用析构函数的时机
无论何时一个对象被销毁,自动调用其析构函数:
- 变量离开其作用域
- 对象被销毁,成员随即被销毁
- 容器(标准库&内置是数组)被销毁,其中元素被销毁
- 动态分配的对象在应用delete时
- 创建临时对象的表达式结束后,临时对象被销毁
合成析构函数
synthesized destructor
对于某些类,合成析构函数用来阻止该类型的对象被销毁。否则函数体为空,执行默认操作。
析构函数并不直接销毁成员。成员是在执行析构函数之后隐含的析构阶段被销毁的。析构函数体是作为成员的销毁步骤之外的部分进行的。
三/五法则
对于
控制类拷贝操作的:拷贝构造函数、拷贝赋值运算符、析构函数
以及新标准中的:移动构造函数,移动赋值运算符
可以只定义其中一个或两个而不必定义所有。但是这些操作通常应该被看作一个整体。只需要一个而不需要其他的情况很少见。
需要析构函数的类也需要拷贝和赋值操作
决定一个类是否要定义拷贝控制成员的原则:
确定这个类是否需要一个析构函数。若是,则其同样必须有一个拷贝构造函数和拷贝赋值运算符。
通常,对析构函数的需求比其他更为明显。
需要拷贝操作的类也需要赋值操作,反之亦然
存在不需要析构函数的类。
例如:在一个类中为每个对象分配一个独有、唯一的序号。该类需要拷贝构造函数为每个新的对象生成新的唯一的序号。此外,该拷贝构造函数从给定对象拷贝所有其他数据成员。这个类还需要自定义拷贝运算符避免将序号赋予目的对象。但是不需要析构函数
因此,决定是否要定义拷贝控制成员的第二原则:
如果一个类需要一个拷贝构造函数,几乎可以肯定其需要一个拷贝赋值运算符,反之亦然。
但未必需要析构函数。
使用`=default
可以通过将拷贝控制成员定义为=default
显式要求编译器生成合成版本:
class Foo{
public:
Foo() = default;
Foo(const Foo&) = default;
Foo& operator=(const Foo&);
~Foo() = default;
};
在类内用=default
修饰成员声明时,合成的函数将隐式被声明为内联的(就像在其他类内声明的成员函数)。若不希望合成的成员是内联函数,则需要在类外定义时使用=default
。
只能对具有合成版本的成员函数使用=default
,即默认拷贝构造函数、拷贝控制成员。
阻止拷贝
对于某些即使拷贝也没有意义的类,应当阻止其拷贝。例如iostream阻止拷贝来避免多个对象写入或读取相同的IO缓冲。
为了阻止拷贝,不定义拷贝控制成员是无效的(会合成一个)
定义删除的函数
可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数(deleted function)来阻止拷贝。
删除的函数:虽然被声明,但是不能以任何方式使用。在参数列表之后添加=delete
指出将其定义为删除的:
struct Foo{
Foo() = default; //合成默认拷贝构造函数
Foo(const Foo&) = delete; //阻止拷贝
Foo& operator=(const Foo&) = delete; //阻止赋值
~Foo() = default; //合成析构函数
}
与=default
不同,=delete
必须出现在函数第一次声明的时候:一个默认的成员只影响为这个成员而生的代码,因此=default
直到编译器生成代码时才需要。另一方面,编译器需要提前知道一个函数是删除的来试图禁止使用。
另一个不同点在于,=default
只能用在拷贝控制成员和拷贝构造函数,但可以为任意函数使用=delete
。
析构函数不能delete
若一个析构函数定义为delete的类,编译器不允许定义该类型的变量或创建该类的临时对象。而且若一个类的某个成员的类删除了析构函数,也不能定义该类型的变量或临时对象。但是可以用new进行动态分配却不能delete。
因为有成员无法销毁则对象整体无法销毁。
合成的拷贝控制成员可能是deleted
编译器会将这些合成的成员定义为deleted:
- 合成析构函数:如果类的某个成员的析构函数是删除的或不可访问的,则其合成析构函数被定义为删除的
- 合成拷贝构造函数:如果类的某个成员的拷贝构造函数是删除的或不可访问的,则该类的合成拷贝构造函数是删除的;如果类的某个成员的析构函数是删除的或不可访问的,则该类的合成拷贝构造函数也是删除的。
- 合成拷贝赋值运算符:如果类的某个成员的拷贝赋值运算符是删除的或不可访问的,或是类有一个const的或引用的成员,则该类的合成拷贝赋值运算符被定义为删除的。
- 默认构造函数:如果类的某个成员的析构函数是删除或不可访问的,或是类有一个引用成员没有类内初始化器,或是类有一个const成员没有类内初始化器且其类型未显式定义默认构造函数,则该类的默认构造函数被定义为删除的。
本质上:如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的成员函数将被定义为删除的。
析构函数影响最多:合成拷贝构造函数、合成析构函数、默认构造函数。原因是没有这些规则,可能创建出无法销毁的对象。
具有引用成员或无法默认构造的const成员的类,编译器不会为其合成默认构造函数。
对于合成拷贝赋值运算符:
类内有一个const成员则不能使用合成的拷贝赋值运算符,原因在于该运算符会尝试赋值所有非static成员,但不能给const赋值。
虽然可以为引用赋值,但其结果是改变引用对象的值而不是其本身的值。如果为存在这样成员的类合成拷贝赋值运算符,则赋值后左侧运算对象依然指向赋值前的对象而不会和右侧指向相同的对象,会存在问题。因此对于有引用成员的类,合成拷贝运算符同样是删除的。
private拷贝控制
在delete的新标准发布前是通过将相应操作声明为private来阻止拷贝的。
由于其中析构函数应该是public的,用户可以定义该类型的对象但不能使用声明为private的拷贝赋值运算符和拷贝构造函数。但是友元和成员函数依然可以拷贝对象,为了阻止这一点,应该将拷贝控制成员声明为private但不定义他们。
声明但不定义一个成员函数除了一个例外情况,均是合法的。因此通过声明这些拷贝控制成员为private的而不定义:
- 试图拷贝的代码会在编译期间报错
- 试图访问的友元和成员函数中的拷贝操作会发生链接时错误
总之在新版本中,应该尽量使用=delete
。