首页 > 代码库 > Chapter13:拷贝控制
Chapter13:拷贝控制
拷贝控制操作:拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符、析构函数。
实现拷贝控制操作的最困难的地方是首先认识到什么时候需要定义这些操作。
- 拷贝构造函数:
如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数时拷贝构造函数。
参数是引用:为了避免陷入递归当中。
拷贝构造函数在几种情况下会被隐式地使用,因此,拷贝构造函数通常不应该是explicit的。
合成的拷贝构造函数:逐个拷贝其数据成员;对类类型的成员,会使用其拷贝构造函数来拷贝,内置类型直接拷贝;主元素拷贝一个数组类型的成员。
直接初始化VS拷贝初始化
当使用直接初始化时,我们实际上是要求编译器使用普通的函数匹配来选择与我们提供的参数最匹配的构造函数;
当使用拷贝初始化时,我们要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要则进行类型转换;
拷贝初始化通常由拷贝构造函数或者移动构造函数来完成。
拷贝初始化发生情况:
1.使用=定义变量;
2.将一个对象作为实参传递给一个非引用类型形参;--->拷贝构造函数的参数是引用;
3.从一个返回类型为非引用类型的函数返回一个对象;
4.用花括号列表初始化一个数组中的元素或一个聚合类中的成员。
编译器可以绕过拷贝构造函数:
1 string null_book = "9-999-99999-9";//拷贝初始化2 //编译器被允许将下面的代码3 string null_book("9-999-99999-9");//编译器略过了拷贝构造函数
拷贝赋值运算符:返回一个指向其左侧运算对象的引用
析构函数
析构函数也有一个函数体和一个析构部分。在一个析构函数里,首先执行函数体,然后销毁成员,成员按初始化顺序的逆序销毁。
认识到析构函数体本身并不直接销毁成员是非常重要的。成员是在析构函数体之后隐含的析构阶段中被销毁的。
三/五法则
拷贝控制通常应该看成一个整体。
一个基本原则是首先确定这个类是否需要一个析构函数。如果这个类需要一个析构函数,我们几乎可以肯定它也需要一个拷贝构造函数和一个拷贝赋值函数。
阻止拷贝:定义删除的函数
与=default不同,=delete必须出现在函数第一次声明的时候,这个差异与这些声明的含义在逻辑上是吻合的。一个默认的成员只影响为这个成员而生成的代码,因此=default直到编译器生成代码时才需要。而另一方面,编译器需要知道一个函数是删除的,以便禁止试图使用它的操作。
我们可以对任意函数指定=delete。
析构函数被定义为删除的,则不能定义该类型的变量。
合成的拷贝控制函数可能是删除的
如果一个类有数据成员不能默认构造、拷贝、复制和销毁,则对应的成员函数将被定义为删除的。规则引申如下:
如果类的某个成员的析构函数时删除的或不可访问的,则类的合成析构函数被定义为删除的;同时类的默认构造函数是删除的;同时类的合成拷贝构造函数也被定义为删除的。(析构函数被定义为删除的,则不能定义该类型的变量。)
如果类的某个成员的拷贝构造函数是删除的或不可访问的,则类的合成拷贝构造函数被定义为删除的;
如果类的某个成员的拷贝赋值运算符是删除的或不可访问的,或类有一个const的或引用成员,则类的合成拷贝赋值运算符被定义为删除的;
如果类有一个引用成员,它没有类内初始化器,或是类有一个const成员,没有类内初始化器且其类型未显式定义默认构造函数,则该类的默认构造函数是删除的。
ps:解释为什么有引用成员,类的合成拷贝赋值运算符就是删除的?因为虽然可以给引用赋予新值,但这样做改变的是引用指向对象的值,而不是引用本身。如果我们合成了合成拷贝赋值运算符,则赋值后,左侧运算对象仍然指向与赋值前一样的对象,则不会与右侧运算对象指向相同的对象。由于这种行为不是我们所期望的,所以合成拷贝赋值运算符被定义为删除的。
声明但不定义一个成员函数时合法的(对此有一个例外),试图访问一个未定义成员将导致一个链接时错误。
- 拷贝控制与资源管理的两种方法:行为像值或行为像指针
行为像值:
赋值运算符通常组合了析构函数和构造函数的操作。类似析构函数,赋值操作会销毁左侧运算对象的资源;类似拷贝构造函数,赋值操作会从右侧运算对象拷贝数据。重点在于:当异常发生时,能将左侧运算对象置于一个有意义的状态。
好的模式是:先将右侧运算对象拷贝到一个局部临时对象中;当拷贝完成后,销毁左侧对象的现有成员就安全了(处理了自赋值的情况)。一旦左侧对象资源被销毁,就只剩下将临时对象拷贝到左侧运算对象中了。
行为像指针:
最好是使用shared_ptr来管理类中的资源。
有时候我们希望直接管理资源,这时候,使用引用计数。
- 交换操作:swap
如果一个类定义了自己的swap,那么算法将使用类自定义版本。否则,算法使用标准库定义的swap。
由于swap的存在就是为了优化代码,我们将其声明为inline。
与拷贝控制成员不同,swap并不是必要的。但是,对于分配了资源的类,定义swap可能是一种很重要的优化手段。
//swap的正确使用方式using std::swap;swap(r,h)
定义了swap的类,通常使用swap来定义它们的赋值运算符。这些运算符使用了一种名为拷贝并交换的技术。
1 //注意rhs是按值传递2 HasPtr& HasPtr::operator=(HasPtr rhs)3 {4 swap(*this, rhs);//交换左侧运算对象和局部变量rhs的内容5 return *this;//rhs被销毁6 }
这个技术自动处理了自赋值情况且天然是异常安全的。
- 右值与移动
新标准的一个最主要的特性是可以移动而非拷贝对象的能力。在某些情况下,对象拷贝之后就立即被销毁了。在这些情况下,移动而非拷贝会大幅度提升性能。
在新容器中,我们可以用容器来保存不可拷贝的对象,只要它们能被移动即可。
我们可以看出,主要是移动那些“用完立刻销毁的对象”,我们需要识别出这些对象。为了支持移动操作,新标准引入了右值引用。所谓的右值引用就是必须绑定到右值的引用,右值引用的一个重要特性是——只能绑定到一个将要被销毁的对象。因此,我们可以自由地将一个右值引用的资源“移动”到另一个对象。
右值引用有着与左值引用相反的绑定特性:我们可以将一个右值引用绑定到要求转换的表达式、字面常量、或返回右值的表达式,但不能将一个右值引用直接绑定到一个左值上。
返回左值引用的函数、连同赋值、下标、解引用和前置递增/递减运算符,都是返回左值表达式的例子;
返回非引用类型的函数、连同算术、关系、位以及后置递增/递减运算符,都生成右值。我们可以将一个const左值引用或者一个右值引用绑定到这类表达式。
所以左值与右值的区别是:左值持久,右值短暂:要么是字面常量,要么是在表达式求值过程中创建的临时对象。
由于右值引用只能绑定到临时对象,我们得知1)所引用的对象将要被销毁;2)该对象没有其他用户。这两个特性意味着:使用右值引用的代码可以自由的接管所引用的对象的资源。
变量是左值:我们不能将一个右值引用绑定到一个左值引用类型的变量上。
很多时候,我们使用过程中,需要将右值引用绑定到左值上,为此,标准库提供move,它告诉编译器,我们有一个左值,但是希望像右值一样处理它。这就意味着承诺:除了对此对象赋值或销毁外,我们不再使用它。调用std::move之后,我们不能对移后源对象的值做任何假设。也即:我们可以销毁一个移后源对象,也可以赋予它新值,但是不能使用一个移后源对象的值。
- 移动构造函数&移动赋值函数
有了移动的对象,那么,如何移动呢?
除了完成资源的移动,移动构造函数还必须确保移后源对象处于这样一个状态——销毁它是无害的。特别是,一旦资源完成移动,源对象必须不再指向被移动的资源——这些资源的所有权已经归属新创建的对象。
移动、标准库容器和异常
由于移动操作“窃取”资源,它通常不分配资源。因此,移动操作不会抛出任何异常。我们应该通知标准库,否则会为了处理这种可能性而做一些额外的工作。
所以:不抛出异常的移动构造函数和移动赋值运算符必须标记为noexcept。
为什么呢?(关于标准库与我们自定义类的交互)
虽然移动操作通常不抛出异常,但抛出异常也是允许的;其次,标准库容器能对异常发生时其自身的行为提供保障。
例如,vector保证,当vector调用push_back时,发生异常时,vector本身不发生改变。如何做到这一点?
当push_back要求重新分配空间,将旧空间移动到新内存时。移动一个对象通常会改变它的值。如果重新分配过程使用了移动构造函数,且在移动了部分而不是全部元素之后,抛出一个异常,就会产生问题,旧空间中的移后源元素已经改变了,新空间未构造元素尚不存在。
为了解决这种潜在问题,除非vector知道元素类型的移动构造函数不会抛出异常,否则在重新分配内存的过程中,它就必须使用拷贝构造函数而不是移动构造函数。
合成的移动操作
如果我们不声明自己的拷贝构造函数或拷贝赋值函数,编译器总会为我们合成这些操作。
与拷贝操作不同,编译器根本不会为某些类合成移动操作。特别是,如果一个类定义了自己的拷贝构造函数、拷贝赋值运算符或者析构函数,编译器就不会为它合成移动构造函数和移动赋值运算符了。
只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非static数据成员都可以移动时,编译器才会为它合成移动操作。
合成的移动操作是删除的情况:
类成员未定义移动操作或编译器不能为其合成移动操作的情况下,合成的移动操作是删除的;
有类成员的移动操作是删除的或者不可访问的;(移动操作永远不能隐式定义为删除的函数,但是如果显示定义为default,却不能移动所有成员,则定义为删除的函数)
类的析构函数是删除的或不可访问的;
有类成员是const的或是引用,则移动赋值运算符是删除的;
在同时具有移动构造函数和拷贝构造函数的情况下:
1 Foo(const Foo&);2 Foo(Foo&&);
当传递右值的时候,使用移动构造函数;当传递左值的时候,使用拷贝构造函数;
但是如果没有移动构造函数的话,右值也可以被绑定到 const左值引用上,此时调用拷贝构造函数。
不要随意的使用移动操作
通过对类代码小心的使用move,可以大幅度提升性能。而如果随意在普通用户代码中使用移动操作,很可能导致莫名其妙的、难以查找的错误,难以提升应用程序的性能。
- 成员函数与右值引用
有时候,如果一个成员函数同时提供拷贝和移动版本,它也能从中受益。这种成员函数通常使用与拷贝/移动相同的参数模式:一个版本接受const 左值引用;一个版本接受非const右值引用。
我们不需要定义接受一个const X&&或X&参数的版本,当我们窃取数据时,必然改变移后源;当拷贝时,必然不应该改变对象。
1 void X::push_back(const string& s)2 {3 alloc.construct(first_free++, s);4 }5 void X::push_back(string&& s)6 {7 alloc.construct(first_free++, std::move(s));8 }
通常情况下,我们在一个对象上调用成员函数,而不管该对象是左值还是右值,所以无法阻止这种方式:
s1+s2="wow!";
但是现在,我们可以像const一样,强制左侧运算对象为左值。即:在参数列表后放置一个引用限定符。
当const和引用限定同时使用时,引用限定符要放在const之后。
当我们定义const成员函数时,可以定义两个版本,其差别仅仅是const的有无;而引用限定则不同,必须都加上引用限定符或者都不加。
Chapter13:拷贝控制