拷贝控制
拷贝、赋值与销毁
拷贝构造函数
如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数。
1 | class Foo { |
第一个参数必须是引用,且通常都是const的,拷贝构造通常是隐式使用,不应该是explicit的。
合成的拷贝构造
无论我们有没有定义其他拷贝构造,编译器都会自动和合成一个拷贝构造函数。合成的拷贝构造函数会从给定对象中依次将每个非static成员拷贝到正在创建的对象中。
每个成员的类型决定了它如何拷贝:对类类型的成员,会使用其拷贝构造函数来拷贝;内置类型的成员则直接拷贝。虽然我们不能直接拷贝一个数组,但合成拷贝构造函数会逐元素地拷贝一个数组类型的成员。如果数组元素是类类型,则使用元素的拷贝构造函数来进行拷贝。
拷贝初始化
1 | string dots (10, '.');//直接初始化 |
如果类中有一个移动构造函数,则拷贝初始化有时会使用移动构造而非拷贝构造,所谓移动构造就是指的就是以移动而非深拷贝的方式初始化含有指针成员的类对象。简单的理解,移动语义指的就是将其他对象(通常是临时对象)拥有的内存资源“移为已用”。所以我们应了解何时发生拷贝构造:
拷贝初始化不仅在我们用=定义变量时会发生,在下列情况下也会发生·
将一个对象作为实参传递给一个非引用类型的形参
从一个返回类型为非引用类型的函数返回一个对象
用花括号列表初始化一个数组中的元素或一个聚合类中的成员
此外当初始化标准容器或调用insert或push时,会使用拷贝初始化,而emplace成员创建的元素都是直接初始化。
参数和返回值
函数调用过程中,具有非引用类型的参数都要进行拷贝初始化,函数返回值为非引用时,返回值也会被用来做为接受对象拷贝初始化的参数。
所以拷贝初始化的参数必须是引用类型,不然就一直调用也不会成功。
拷贝初始化的限制
值初始化和拷贝初始化不是一模一样的,如果使用explicit构造函数,我们就不能隐式的调用它:
1 | vector<int> v1(10);//正确:直接初始化 |
必须显示的调用explicit函数。
编译器可以绕过拷贝构造函数
拷贝/移动构造可以被忽略,直接创建对象:
1 | string null_book = "9-999-99999-9"; //拷贝初始化 |
可以跳过拷贝/移动构造,但必须有且可访问。
拷贝赋值运算符
重载赋值运算符
重载运算符本质上是函数,其名字由 operator 关键字后接表示要定义的运算符的符号组成。因此,赋值运算符就是一个名为operator=的函数。类似于任何其他函数,运算符函数也有一个返回类型和一个参数列表。
重载运算符的参数表示运算符的运算对象。某些运算符,包括赋值运算符,必须定义为成员函数。如果一个运算符是一个成员函数,其左侧运算对象就绑定到隐式的this参数(参见7.1.2节,第231页)。对于一个二元运算符,例如赋值运算符,其右侧运算对象作为显式参数传递。
1 | class Foo { |
赋值运算符应该返回一个引用!
合成的拷贝赋值运算符
和前几个构造函数一样,如果类内未定义,就会自动生成。作为一个例子,下面的代码等价于sales_data的合成拷贝赋值运算符:
1 | sales_data& Sales_data::operator= (const Sales_data &rhs) { |
那么它和拷贝构造函数的区别是,拷贝构造是从无到有,而拷贝赋值时本来就有,只是值发生改变。
析构函数
它与构造函数相反,构造函数初始化非static数据成员,还有其他工作,析构函数释放对象使用资源,销毁对象非static数据成员。它没有返回值,也不接受参数:
1 | class Foo { |
由于没有参数,所以不能被重载。
函数任务
它所有顺序都与构造函数相反,先执行函数体,然后销毁成员,且按出现次序逆序销毁。且析构部分时隐式的,销毁完全取决与类型。
隐式销毁内置指针类型的成员不会delete指向的对象
何时调用析构
无论何时一个对象被销毁,就会自动调用其析构函数:
- 变量在离开其作用域时被销毁。
- 当一个对象被销毁时,其成员被销毁。
- 容器(无论是标准库容器还是数组)被销毁时,其元素被销毁。
- 对于动态分配的对象,当对指向它的指针应用delete运算符时被销毁。
- 对于临时对象,当创建它的完整表达式结束时被销毁。
1 | {//新作用域 |
当指向一个对象的引用或指针离开作用域时,析构函数不会执行。
合成的析构函数
概念如前,下面等同合成析构函数:
1 | class sales_data { |
三/五法则
需要析构函数的类也需要拷贝和拷贝赋值
如果一个类需要析构函数,那么肯定也需要一个拷贝函数和一个拷贝赋值运算符。例子:
1 | class HasPtr { |
如果使用合成拷贝,则会简单的拷贝指针成员,则有可能多个对象指向相同内存。
需要拷贝操作的类也需要赋值,反之亦然
作为一个例子,考虑一个类为每个对象分配一个独有的、唯一的序号。这个类需要一个铂贝构造函数为每个新创建的对象生成一个新的、独一无二的序号。除此之外,这个拷贝构造函数从给定对象拷贝所有其他数据成员。这个类还需要自定义拷贝赋值运算符来避免将序号赋予目的对象。但是,这个类不需要自定义析构函数。
使用=defult
这段代码可以显示的要求编译生成合成版本:
1 | class Sales_data { |
使用此语句,合成函数将隐式声明为内联,如果不希望是内联的,应该对类外使用它(如上面的拷贝赋值)。
阻止拷贝
对于某些类来说,拷贝和赋值时没有意义的,如iostream,所以组织拷贝,以避免多个对象的写入过读取相同的IO缓冲。
定义删除的函数
通过将拷贝和拷贝赋值函数定义为删除的函数来组织拷贝,这是一种我们虽然声明,但不能使用的函数:
1 | struct NoCopy { |
与=default不同:
- =delete必须在函数第一次声明的时候出现,而=default知道编译器生成代码时才需要,可以出现在定义处。
- 另一个是可以对任意函数使用(虽然主要是阻止拷贝),但=default只可以使用在有合成版本的函数。
析构函数不能删除
对于删除了析构函数的类型,虽然我们不能定义这种类型的变量或成员,但可以动态分配这种类型的对象。但是,不能释放这些对象。所以不可删除。
合成的拷贝控制成员可能是删除的
本质上,这些规则的含义是:如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的成员函数将被定义为删除的。
一个成员有删除的或不可访问的析构函数会导致合成的默认和拷贝构造函数被定义为删除的,这看起来可能有些奇怪。其原因是,如果没有这条规则,我们可能会创建出无法销毁的对象。
private拷贝控制
新标准以前,组织是通过将函数放在private里的,但现在应该使用=delete。
拷贝控制和资源管理
类行对象有两种拷贝语意,一种像值:拷贝像值对象,副本和源对象完全独立,改变副本不会对源对象有影响,如string。一种像指针:拷贝这种对象,共同使用底层数据,改变自己也会改变源对象,如shared_ptr。
行为像值的类
像值的行为,每个对象应该拥有一份自己的拷贝。HasPtr
- 定义一个拷贝构造函数,完成string的拷贝,而不是拷贝指针
- 定义一个析构函数来释放string
- 定义一个拷贝赋值运算符来释放对象当前的 string,并从右侧运算对象拷贝string
1 | class HasPtr{ |
类值拷贝赋值运算符
赋值类运算符通常是组合了析构和构造函数,赋值的操作其实会销毁左侧运算对象的资源,其次需要保证再异常发发生时代码也是安全的:
1 | HasPtr& HasPtr::operator= (const HasPtr &rhs){ |
编写赋值运算符时,有两点需要记住:
- 如果将一个对象赋予它自身,赋值运算符必须能正确工作。
- 大多数赋值运算符组合了析构函数和铂贝构造函数的工作。
当你编写一个赋值运算符时,一个好的模式是先将右侧运算对象拷贝到一个局部临时对象中。当拷贝完成后,销毁左侧运算对象的现有成员就是安全的了。一旦左侧运算对象的资源被销毁,就只剩下将数据从临时对象拷贝到左侧运算对象的成员中了。
如果直接删除自身数据,然后将指针指向赋予对象的数据,那么在将自身赋予自身时就会出现访问无效内存的异常。
定义行为像指针
这个类拷贝指针成员本身不是它指向的string,我们的类拷贝时拷贝的是指针而不是指向的对象。同时在析构时也需要在最后一个指向对象的HasPtr销毁时,销毁对象。
这时就需要一个类似引用计数的东西,类似shared_ptr。
引用计数
它的工作方式:
- 除了初始化对象外,每个构造函数(拷贝构造函数除外)还要创建一个引用计数,用来记录有多少对象与正在创建的对象共享状态。当我们创建一个对象时,只有一个对象共享状态,因此将计数器初始化为1。
- 拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成员,包括计数器。拷贝构造函数递增共享的计数器,指出给定对象的状态又被一个新用户所共享。
- 析构函数递减计数器,指出共享状态的用户少了一个。如果计数器变为0,则析构函数释放状态。
- 拷贝赋值运算符递增右侧运算对象的计数器,递减左侧运算对象的计数器。如果左侧运算对象的计数器变为0,意味着它的共享状态没有用户了,拷贝赋值运算符就必须销毁状态。
计时器不可以放在类中,否则无法正确更新它,最好的办法就是保存在动态内存中,把它当作底层数据,多个对象共享,同样在最后一个指向它的对象销毁时销毁。
定义使用引用计数的类
1 | class HasPtr { |
拷贝与析构
当拷贝时,应该复制指针本身,并且递增关联的计数器。析构不能无脑delete,必须注意计数器数量,到0才可以delete。
1 | HasPtr::~HasPtr(){ |
交换操作
通常的资源管理类都会有swap函数。如果类定义了自己的swap,算法将使用自定义版本,否则会使用标准库的swap,一次交换操作实际上包含了一次拷贝和两次赋值。如:
1 | HasPtr temp = v1; //创建v1的值的一个临时副本 |
但理论上可以省取这些内存分配的过程,直接交换指针:
1 | string *temp = v1.ps; //为v1.ps中的指针创建一个副本 |
编写swap函数
1 | class HasPtr { |
首先将swap定义为friend,一遍能够访问HasPtr的数据成员。swap不是必要的,但是重要的优化手段。
与std::swap不同
使用时不应该加上std::
1 | void swap (Foo &lhs, Foo &rhs) |
在赋值运算中使用swap
定义swap后会用来用它定义赋值运算符。是将左侧对象与右侧对象的副本进行交换:
1 | //注意rhs是按值传递的,意味着HasPtr的拷贝构造函数 |
此版本参数不是引用,因此右侧传递进来的是一个副本,所以不需要额外的拷贝操作,它保证异常安全的同时也与原来的赋值运算实现一样。
拷贝控制示例(单独成章)
动态内存管理类(单独成章)
对象移动
新标准中有可以移动而非拷贝的能力。很多时候对象拷贝完立刻被销毁了,移动可以大大提升性能。移动的另一个原因是源于IO类或unique_ptr这样的类包含不能被共享的资源,所以可以移动不能拷贝。
标准库容器、string和shared ptr类既支持移动也支持拷贝。IO类和unique ptr类可以移动但不能拷贝。
右值引用
符号为&&,它必须绑定到右值且只能绑定到一个将要销毁的对象,所以可以自由的移动到另一个对象中。
回忆左值和右值:一般而言,一个左值表达式表示的是一个对象的身份,而一个右值表达式表示的是对象的值。
右值引用也不过是对象的另一个名字,对于常规引用,我们可以称之为左值引用。
区别:我们不能将其绑定到要求转换的表达式、字面常量或是返回右值的表达式。右值引用有着完全相反的绑定特性:我们可以将一个右值引用绑定到这类表达式上,但不能将一个右值引用直接绑定到一个左值上:
1 | int i =42;int &r n i; //正确:r引用i |
- 返回左值引用的函数,连同赋值、下标、解引用和前置递增/递减运算符,都是返回左值的表达式的例子。
- 我们可以将一个左值引用绑定到这类表达式的结果上。
- 返回非引用类型的函数,连同算术、关系、位以及后置递增/递减运算符,都生成右值。
- 我们不能将一个左值引用绑定到这类表达式上,但我们可以将一个 const的左值引用或者一个右值引用绑定到这类表达式上。
左值持久:右值短暂
考察左值和右值表达式的列表,两者相互区别之处就很明显了:左值有持久的状态,而右值要么是字面常量,要么是在表达式求值过程中创建的临时对象。所以
- 所引用的对象将要被销毁
- 该对象没有其他用户
这两个特性意味着:使用右值引用的代码可以自由地接管所引用的对象的资源。
变量是左值
变量可以看作只有一个运算对象而没有运算符的表达式,虽然我们很少这样看待变量。类似其他任何表达式,变量表达式也有左值/右值属性。变量表达式都是左值。带来的结果就是,我们不能将一个右值引用绑定到一个右值引用类型的变量上,这有些令人惊讶:
1 | int &&rr1 = 42;//正确:字面常量是右值 |
其实有了右值表示临时对象这一观察结果,变量是左值这一特性并不令人惊讶。毕竟,变量是持久的,直至离开作用域时才被销毁。
标准库move函数
虽然不能将右值引用绑定到左值,但可以显示将左值转换为对应右值引用类型,我们可以调用move来获得绑定到左值上的右值引用,在头文件utility中。
1 | int &&rr3 = std::move (rr1); l l ok |
move对左值使用之后,可以像右值一样处理,但之后除了赋值或者销毁它外,但不能使用该对象的值。且应该直接使用std::move。
移动构造和移动赋值函数
我们可以为自己的类定义移动操作,他们就是从给定对象窃取而不是拷贝资源。除了完成资源移动,移动构造函数还必须确保移后源对象处于这样一个状态—–销毁它是无害的。特别是,一旦资源完成移动,源对象必须不再指向被移动的资源—–这些资源的所有权已经归属新创建的对象。
1 | strVec::strVec (strVec &&s) noexcept//移动操作不应抛出任何异常 |
与拷贝构造函数不同,移动构造函数不分配任何新内存;它接管给定的Strvec中的内存。在接管内存之后,它将给定对象中的指针都置为nullptr。这样就完成了从给定对象的移动操作,此对象将继续存在。最终,移后源对象会被销毁,意味着将在其上运行析构函数。strVec的析构函数在first_free 上调用deallocate。如果我们忘记了改变s.first free,则销毁移后源对象就会释放掉我们刚刚移动的内存。
移动操作与异常
由于移动操作不分配任何资源,所以不会抛出任何异常,我们应该将此事通知给标准库,提升一些性能消耗。
方法就是在小括号之后冒号之前加上noexcept
1 | class strvec { |
移动赋值运算符
它与移动构造函数一个,应该标记为noexcept:
1 | strVec &StrVec::operator=(StrVec &&rhs) noexcept |
这里多了一步操作就是检测this与rhs地址是否相同,也就是是否是同一个对象(这也是赋值运算需要重点考虑的:将自身赋予自身时能否不出错)。如果相同什么都不用做。
移后源可以析构
编写移动操作必须保证移后对象可析构,在strVec中,将移后源对象的指针成员设置为nullptr来实现。
合成的移动操作
编译器不会为某些类生成合成的移动函数,如果没有移动函数,类会使用对应的拷贝操作来代替移动。
只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非static数据成员都可以移动时,编译器才会为它合成移动构造函数或移动赋值运算符。编译器可以移动内置类型的成员。如果一个成员是类类型,且该类有对应的移动操作,编译器也能移动这个成员:
1 | //编译器会为X和hasx合成移动操作 |
移动操作只有当我们显示要求编译器生成=default的移动操作而却不是所有成员都可以移动时才会将移动操作定义为删除的函数。
移动右值,拷贝左值
如果一共类既有移动函数也有拷贝构造函数,那么会根据匹配规则使用,如在strvec类中,拷贝构造函数接受一个 const strvec的引用。因此,它可以用于任何可以转换为strVec的类型。而移动构造函数接受一个strVec&&,因此只能用于实参是(非static)右值的情形:
1 | StrVec v1, v2 ; |
如果没有移动构造,会调用拷贝
由于不会默认合成移动构造,所以用拷贝代替,且是绝对安全的
拷贝赋值和移动赋值合并
如果为类添加一个移动构造函数,实际上也会获得移动赋值运算符:
1 | class HasPtr { |
此运算符有一个非引用参数,这意味着此参数要进行拷贝初始化。依赖于实参的类型,拷贝初始化要么使用拷贝构造函数,要么使用移动构造函数——左值被拷贝,右值被移动。因此,单一的赋值运算符就实现了拷贝赋值运算符和移动赋值运算符两种功能。
例如,假定hp和 hp2都是HasPtr对象:
1 | hp = hp2; // hp2是一个左值;hp2通过拷贝构造函数来拷贝 |
建议:更新三/五法则
所有五个拷贝控制成员应该看作一个整体:一般来说,如果一个类定义了任何一个拷贝操作,它就应该定义所有五个操作。如前所述,某些类必须定义持贝构造函数、拷贝赋值运算符和析构函数才能正确工作。一般来说拷贝一个资源会导致一些额外开销。在这种拷贝并非必要的情况下,定义了移动构造函数和移动赋值运算符的类就可以避免此问题。