当定义一个类时,我们显式地或隐式地指定在此类型的对象拷贝、移动、赋值和销毁时做什么。一个类通过定义五种特殊的成员函数来控制这些操作,包括:拷贝构造函数(copy constructor)、拷贝赋值运算符(copy-assignment operator)、移动构造函数(move constructor)、移动赋值运算符(move-assignment operator)和析构函数(destructor)。我们称这些操作为拷贝控制操作(copy control)。
一、拷贝、赋值与销毁
1. 拷贝构造函数
如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数。
拷贝构造函数的第一个参数必须是一个引用类型。虽然我们可以定义一个接受非const引用的拷贝构造函数,但此参数几乎总是一个const的引用。
(如果其参数不是引用类型,则调用永远也不会成功——为了调用拷贝构造函数,我们必须拷贝它的实参,但为了拷贝实参,我们有需要调用拷贝构造函数,如此无限循环。)
合成拷贝构造函数
编译器会合成一个拷贝构造函数。合成的拷贝构造函数会将其参数的成员逐个拷贝到正在创建的对象中。编译器从给定对象中依次将每个非static成员拷贝到正在创建的对象中。
每个成员的类型决定了它如何拷贝:对类类型的成员,会使用其拷贝的构造函数来拷贝;内置类型的成员则直接拷贝。虽然我们不能直接拷贝一个数组,但合成拷贝构造函数会逐元素地拷贝一个数组类型的成员。
拷贝初始化
拷贝初始化通常使用拷贝构造函数来完成。如果一个类有一个移动构造函数,则拷贝初始化有时会使用移动构造函数而非拷贝构造函数来完成。
编译器可以绕过拷贝构造函数
2. 拷贝赋值运算符
重载赋值运算符
重载运算符本质上是函数,其名字由operator关键字后接要定义的运算符的符号组成。因此,赋值运算符就是一个名为operator=的函数。运算符函数也有一个返回类型和一个参数列表。
拷贝赋值运算符本身是一个重载的赋值运算符,定义为类的成员函数,左侧运算对象绑定到蕴含的this参数,而右侧的运算对象是所属类类型的,作为函数的参数。函数返回指向其左侧运算对象的引用。
当对类对象进行赋值时,会使用拷贝赋值运算符。
如果类未定义自己的拷贝赋值运算符,编译器会为它合成一个。通常情况下,合成的拷贝赋值运算符会将右侧对象的非static成员逐个赋予左侧对象的对应成员,这些赋值操作是由类型的拷贝赋值运算符完成的。
3. 析构函数
析构函数释放对象使用的资源,并销毁对象的非static数据成员。
析构函数是类的一个成员函数,名字由波浪号接类名构成。它没有返回值,也不接受参数,因此也不能被重载。对于一个给定类,只会有唯一一个析构函数。
在一个构造函数中,成员的初始化是在函数体执行之前完成的,且按照它们在类中出现的顺序进行初始化。在一个析构函数中,首先执行函数体,然后销毁成员。成员按初始化顺序的逆序销毁。
隐式合成的析构函数体为空,但这并不意味着它什么也不做,当空函数体执行完后,非静态数据成员会被逐个销毁。成员是在析构函数体之外隐含的析构阶段中被销毁的。析构部分是隐式的。销毁类类型的成员需要执行成员自己的析构函数。内置类型没有析构函数,因此销毁内置类型成员什么也不需要做。
销毁一个内置指针类型的成员不会delete它所指向的对象。与普通指针不同,智能指针是类类型,所以具有析构函数,智能指针成员在析构阶段会被自动销毁。
4. 三/五法则
有三个基本操作可以控制类的拷贝操作:拷贝构造函数、拷贝赋值运算符和析构函数。在新标准下,一个类还可以定义一个移动构造函数和一个移动赋值运算符。
需要析构函数的类也需要拷贝和赋值操作
需要拷贝操作的类也需要赋值操作,反之亦然
5. 使用=default
我们可以通过将拷贝控制成员定义为=default来显式地要求编译器生成合成的版本。
6. 阻止拷贝
定义删除的函数
在新标准下,我们可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数(deleted function)来阻止拷贝。删除的函数是这样一种函数:我们虽然声明了它们,但不能以任何方式使用它们。在函数的参数列表后面加上=deleted来指出我们希望将它定义为删除的。
=delete通知编译器(以及我们代码的读者),我们不希望定义这些成员。
与=default不同,=delete必须出现在函数第一次声明的时候。我们可以对任何函数指定=delete(我们只能对编译器可以合成的默认构造函数或拷贝控制成员使用=default)。
析构函数不能是删除的成员
合成的拷贝控制成员可能是删除的
对某些类来说,编译器将这些合成的成员定义为删除的函数:
二、 拷贝控制和资源管理
通常,管理类外资源的类必须定义拷贝控制成员。这种类需要通过析构函数来释放对象所分配的资源。一旦一个类需要析构函数,那么它几乎肯定也需要一个拷贝构造函数和一个拷贝赋值运算符。
1. 行为像值的类
为了提供类值的行为,对于类管理的资源,每个对象都应该拥有一份自己的拷贝。
赋值运算符通常祝贺了析构函数和构造函数的操作。
2. 定义行为像指针的类
对于行为类似指针的类,我们需要为其定义拷贝构造函数和拷贝赋值运算符,来拷贝指针成员本身而不是它指向的string。我们的类仍需要自己的析构函数来释放接受string参数的构造函数分配的内存。在本例中,只有当最后一个指向string的HasPtr销毁时,析构函数才可以释放string。
引用计数
定义一个使用引用计数的类
三、 动态内存管理类
移动构造函数和std::move
一些标准库类,包括string,都定义了所谓的“移动构造函数”。移动构造函数通常是将资源从给定对象“移动”到正在创建的对象。标准库保证“移后源(moved-from)”string仍然保持一个有效的、可析构的状态。
move标准库函数定义在utility头文件中。①当realloctae在新内存中构造string时,它必须调用move来表示希望使用string的移动构造函数,如果它漏掉了move调用,将会使用string的拷贝构造函数。②我们通常不为move提供一个using声明。当我们使用move时,直接调用std::move而不是move。
四、对象移动
1. 右值引用
所谓右值引用就是必须绑定到右值的引用。我们通过&&而不是&来获得右值引用。
右值引用只能绑定到一个将要销毁的对象。因此,我们可以自由地将一个右值引用地资源“移动”到另一个对象中。
一般而言,一个左值表达式表示地是一个对象的身份,而一个右值表达式表示的是对象的值。
左值持久;右值短暂
左值有持久的状态,而右值要么是字面常量。要么是在表达式求值过程中创建的临时对象。
变量是左值
标准库move函数
move函数返回给定对象的右值引用。
调用move意味着:除了对rr1赋值或销毁它外,我们将不再使用它。
返回左值的表达式包括:返回左值引用的函数及赋值、下标、解引用和前置递增/递减运算符;
返回右值的包括:返回非引用类型的函数及算数、关系、位以及后置递增/递减运算符。(我们可以将一个const的左值引用或者一个右值引用绑定到这类表达式上。)
2. 移动构造函数和移动赋值运算符
移动构造函数的第一个参数是该类类型的一个右值引用,任何额外的参数都必须有默认实参。
除了完成资源移动,移动构造函数还必须确保移后源对象处于这样一个状态——销毁它是无害的。特别是,一旦资源完成移动,源对象必须不再指向被移动的资源——这些资源的所有权已经归属新创建的对象。
与拷贝构造函数不同,移动构造函数不分配任何新内存:它接管给定的StrVec中的内存。最终,移后源对象会被销毁,意味着将在其上运行析构函数。
移动操作通常不会抛出任何异常。除非标准库知道我们的移动构造函数不会抛出异常,否则它会认为移动我们的类对象时可能会抛出异常,并且为了处理这种可能性而做一些额外的工作。
一种通知标准库的方法是在我们的构造函数中指明noexpect。我们在一个函数的参数列表后指定noexpect。
我们必须在类头文件的声明中和定义中(如果定义在类外的话)都指定noexpect。
移动赋值运算符
移动赋值运算符执行与析构函数和移动构造函数相同的工作。与移动构造函数一样,如果我们的移动赋值运算符不抛出任何异常,我们就应该将它标记为noexpect。
移后源对象必须可析构
从一个对象移动数据并不会销毁此对象,但有时在移动操作完成后,源对象会被销毁。因此,当我们编写一个移动操作时,必须确保移后源对象进入一个可析构的状态(析构安全的状态)。
合成的移动操作
只有当一个类没有定于任何自己版本的拷贝控制成员(拷贝构造函数、拷贝赋值运算符或析构函数),且类的每个非static数据成员都可以移动时,编译器才会为它合成移动构造函数或移动赋值运算符。编译器可以移动内置类型的成员。如果一个成员时类类型。且该类有对应的移动操作,编译器也能移动这个成员。
移动右值,拷贝左值
但如果没有移动构造函数,右值也被拷贝。
移动迭代器
移动迭代器的解引用运算符生成一个右值引用。
我们通过调用标准库的make_move_iterator函数将一个普通迭代器转换为一个移动迭代器。此函数接受一个迭代器参数,返回一个移动迭代器。
移动迭代器支持正常的迭代器操作。
3. 右值引用和成员函数
右值和左值引用成员函数
重载和引用函数
引用限定符也可以区分重载版本。