拷贝控制
拷贝:在使用一个已有的对象初始化一个新的对象,在初始化,参数传递,返回与列表初始化时会被使用,当且仅当右值的类型与左值相同时或者可以隐式转换时。
赋值:在使用一个已有的对象赋值一个已有的对象。
这两种操作分开的原因可能是因为拷贝会申请内存?
当拷贝控制操作没有定义时,编译器会自动合成。
拷贝构造函数
当一个构造函数的形参时自身类类型的引用时,且额外参数有默认值,则是拷贝构造函数。
由于常常被隐式调用并且(会进行隐式类型转换),因此通常不应是explicit的。
与默认构造函数不同,即便声明了其他的构造函数,也会进行合成。
合成拷贝构造函数会将所有非static成员拷贝,拷贝时要么调用类的拷贝构造函数或直接拷贝,对于数组会逐一拷贝。
若是其不是引用类型,那么传入的过程也需要拷贝初始化,就套娃了,因此必须是引用。
拷贝初始化对右值要求隐式转换,而直接初始化是显式转换。
在编译过程中,编译器可能换将拷贝初始化改为直接初始化,但即便如此,拷贝/移动构造函数也必须存在且可访问。
拷贝赋值运算符
与拷贝构造函数不同,拷贝赋值运算符是对运算符=的重载,一般返回一个左值引用。
但默认拷贝赋值运算符的内部与默认拷贝构造函数差不多。
折构函数
销毁对象的非static成员。
折构函数包含两部分,函数体与折构部分(销毁元素的部分),其顺序与构造函数完全相反,先执行函数体,再按照声明顺序逆序销毁对象。但折构部分是隐式的。其成员到底如何折构,取决于其类型。如内置类型没有折构函数,因此无需折构。
当一个引用或指针被销毁时,其对象不会被折构。但是智能指针不同。
合成的折构函数的函数体实际上就是空的。
相互的关系
需要自定义折构函数的肯定需要两个拷贝函数。
需要自定义两个拷贝函数中的任何一个都一定会需要自定义另一个,但不一定需要自定义折构函数。
default
通过=default可以显式指定编译器合成函数,当在类内使用时,内联,因此一般在类外使用。
只有有合成版本的函数能使用default。
阻止拷贝
由于有一些类中拷贝控制操作没有意义,为了保证不被使用,就需要阻止它。但是当不写的时候,会自动合成,因此必须显式阻止。
直接给函数加上=delete即显式指定了删除。可以被使用于任何函数。
折构函数不能被删除,若是删除,该类型将不允许定义,但仍然可以动态分配该类型对象,但不能delete,主要针对private声明,const和引用。
虽然编译器会自动合成默认构造函数和拷贝控制函数,但是若是其数据成员不能默认构造,拷贝,复制或销毁,则合成的函数将被声明为delete。
在老标准下,通过将拷贝控制函数声明为private并且不进行定义作为删除。
拷贝控制函数的实现
拷贝总共有两种语义:
若是数据成员用指针来存储指向,即使用动态内存的情况下
-
行为像值,如vector,string等,不共享,各自为独立的个体
当拷贝时,先保存右值的临时值,再释放左值内存,后将右值的临时值拷贝给左值,这样保证即便是将其赋给自身也不会出现异常,另外初始化时申请新的内存空间。
另外,需要保证异常安全。
-
行为像指针,如shared_ptr,即共享
可以使用shared_ptr之类的,或者自己实现引用计数。拷贝时递增右值计数,递减本对象计数,后赋值。并且在任何减计数的情况下,判断是否等于0,若等于0则折构。
swap
swap函数虽然不是必要的,但是却很常用。对于我们自己的类,可以使用std::swap来交换,其相当于是进行了3次拷贝。但是有时拷贝很费时间,因此我们可以自己编写swap交换指针来避免不必要的拷贝。
当存在类型匹配的swap时,调用swap会调用专用的swap而非标准库的。
swap可以用于赋值运算符。
在swap后,rhs指向原本的this指向的内存,而this指向rhs指向的内存,由于是值传递,因此在结束作用域后rhs将被销毁,其内存被释放。非常完美的执行了赋值操作。
而且这种方式自动就是异常安全的,且能正确处理自赋值。
拷贝控制的使用场景
- 分配资源的类需要拷贝控制。
- 拷贝控制操作中不止要进行拷贝。
拷贝赋值运算符执行的动作常常在拷贝构造与折构函数中出现,这些公共的部分应该在private中模块化。
对象移动
如在vector中,我们时常会将一块内存拷贝到另一块内存中并销毁原内存,这非常的效率低下,因此引入移动。
此外,由于如IO类与unique_ptr这种存在不能被共享的资源,不允许被拷贝,但是却允许移动(移动并没有创建拷贝。
右值引用
右值引用就是对右值的引用,使用&&来表示。
对于一个右值,要么用一个const左值引用要么用一个右值引用来绑定。
绑定的对象即将要被销毁且没有其他对象,因此可以只有接管其引用对象的资源。
即便是一个右值引用,也是一个变量,是左值,故不能将右值引用变量绑定到右值变量上。
move
返回给定左值对象的右值引用。
对于原本的左值对象,其原本的值已经被“舍弃”了,要么销毁它,要么给它赋一个新值。
move在utility中,虽然是标准库的函数,但一般不使用using,而是直接使用std::move来避免潜在的命名冲突。
类似const使用的对函数的关键字noexcept,表示该函数不应该抛出异常。
移动构造函数
移动构造函数的形参是一个右值引用。必须保证这个右值引用在函数结束时是无害的,不再指向源对象的。
不抛出异常的移动构造函数与移动赋值运算符必须标记为noexcept。
由于移动构造函数若是在中途出现异常,其不能回滚(原有的内存已经被新的值填入了),因此类就会去采用拷贝构造函数(因为原有的元素仍然在内存空间之中)。因此必须使用noexcept来表明其可以安全使用,而不会出现这种问题。
移动赋值运算符
与拷贝赋值运算符一样,但是参数是右值引用,需要注意的是,与左值引用是const不同,右值引用不使用const。
必须保证自赋值,释放原有元素与右值引用的无害。
自赋值时需要特判,因为若是此时释放掉原有的空间,其值就不见了。
移动操作
移动操作必须保证源对象可析构且有效。
- 可析构需要源对象不再指向拷贝完的内存。
- 有效需要源对象可以被安全的赋予新值,不依赖于源数据。
当且仅当类没有定义任何拷贝控制成员时,且所有非static成员都可移动,才会自动合成移动操作。
只有移动操作被显式要求合成时,其才有可能出现delete的情况。
一种是拷贝函数被定义了而移动函数仅被声明了,第二种是拷贝函数没有被定义但是移动函数不能被生成,即其有成员不能被移动。
若是移动函数被定义了,那么合成的拷贝函数会被自动delete。
拷贝与移动
具体是调用拷贝还是移动函数会根据传入参数时左值还是右值而定。
若是一个类没有定义移动函数,那么即便是右值引用也会被转换为左值引用并调用拷贝函数。
若是使用前面的拷贝并交换赋值运算符,在传入时使用拷贝初始化,此时就已经决定了移动或是拷贝,因此该赋值运算符可以既看做拷贝也可以看做是移动。
三/五法则
一般来说,要么析构函数与拷贝函数都定义,要么再多定义移动函数。
移动迭代器
在大部分情况下,与算法配合使用迭代器都会使用拷贝的方法,因为解引用迭代器会获得一个左值。但是移动迭代器解引用将会生成一个右值引用,此时就会使用移动的方法。
但由于移动操作会使源对象的情况未定义,因此要谨慎使用move。虽然移动能大幅提升性能。
移动与拷贝重载
除去使用在拷贝控制操作上,也可以在普通的函数使用这两种版本的重载,通过传入的参数来决定。
限定函数的使用
有的时候对于成员函数,其this与所希望的不符,如赋值时this是右值等,此时可以如const一般在函数后增加&或者&&来限定使用者是左值还是右值,当与const同时出现时,放在const后。
对于重载函数,若是其中有一个限定了,那么其他的所有的重载版本都要限定。
通过限定,我们可以通过this的不同而使用不同的函数,使其更加适合。