1. 拷贝、赋值、与销毁
1. 拷贝构造函数
如果构造函数的第一个参数是自身类型的引用,且额外参数都有默认值。则此构造函数是拷贝构造函数。
为什么第一个参数是引用?因为非引用的参数要进行拷贝初始化,调用拷贝构造函数,导致无限循环。
拷贝构造函数不能声明为explicit的,在很多情况下都会隐式使用。
1.1 合成拷贝构造函数
无论定没定义拷贝构造函数,编译器都会定义一个合成拷贝构造函数。
对某些类,合成拷贝构造函数用来防止该类型对象赋值。
一般情况下,合成拷贝构造函数将参数的非static数据成员依次拷贝到正在创建的对象中。具体怎么拷贝取决于参数成员类型,内置类型直接拷贝,数组的话会不能直接拷贝,会逐个拷贝数组成员,类类型成员调用它的拷贝构造函数拷贝。
1.2 拷贝初始化
直接初始化是要求编译器使用普通的函数匹配选择和参数最匹配的构造函数完成对象构造。
拷贝初始化时调用拷贝构造函数,如果类有移动构造函数,有时会调用移动构造函数将右侧对象拷贝到正在创建的对象内。
除了直接使用=
,拷贝初始化还会在以下情况发生:
1.3 拷贝赋值运算符
1.3.1 重载运算符
重载运算符本质是函数,函数名为 operator加要重载的运算符
。
重载运算符的参数表示运算符的运算对象。有些运算符重载必须定义为成员函数的形式,此时左侧运算对象绑定在this上。
标准库要求保存在容器的类型有赋值运算符,且返回值是左侧运算对象的引用。
1.3.2 合成拷贝赋值运算符
对某些类,合成拷贝赋值运算符用来防止该类型对象赋值。
一般情况下,合成拷贝赋值运算符将右侧对象的非static数据成员依次拷贝到正在创建的对象中。具体怎么拷贝取决于参数成员类型,内置类型直接拷贝,数组的话会不能直接拷贝,会逐个拷贝数组成员,类类型成员调用它的拷贝构造函数拷贝。
1.4 析构函数
析构函数做与构造函数完全相反的事。析构函数释放对象使用的资源,并销毁对象的非static成员。
析构函数是类的成员函数,函数名由~加上类名
构成。
析构函数不接受参数,不能重载,每个类只有一个析构函数。
构造函数先按照在类中声明顺序初始化成员,再执行函数体。析构函数先执行函数体,再按初始化顺序逆序销毁成员。“析构”部分是隐式完成的,内置类型什么也不用做,类类型成员调用自己的析构函数。
1.4.1 什么时候调用析构函数
当对象被销毁时,调用析构函数。
当对象的指针或者引用离开作用域后,对对象本身没什么影响。
1.4.2 合成析构函数
当类未定义自己的析构函数时,编译器会为之定义一个合成析构函数。
对于某些类,合成析构函数用来阻止该类型的对象被销毁。一般情况下,合成析构函数函数体为空,只完成对象的销毁和资源的释放。
1.5 三/五法则
1.5.1 需要析构函数的类也需要拷贝和赋值操作
比如对一个HasPtr类,它含有一个普通指针ps
作为成员。定义它的析构函数:
使用delete销毁ps所指对象。
看如下函数:
它的拷贝和赋值运算符都是合成版本,就是简单的拷贝hp
的指针成员到ret
,当函数结束时,hp
和ret
作为HasPtr
对象执行析构函数,也就是试图delete
两次ps
,这显然是错误的。
1.5.2 需要拷贝操作的类也需要赋值操作,反之亦然
1.6 使用=default
可以将拷贝控制成员显式定义为default显式要求编译器生成合成版本。
在类内使用=default
生成的合成版本函数隐式声明为内联的。如果不想合成的成员是内联的,应该只对成员类外定义使用=deault
。
1.7 阻止拷贝
对某些类而言,拷贝没有合理的意义(如iostream类),定义时需要采取某些机制阻止拷贝。
1.7.1 定义删除的函数
删除的函数:虽然声明了它,但是不能以任何方式使用它们。在参数列表后加上=delete
来指出希望将它定义为删除的。
与=deault
不同,=delete
必须出现在第一次声明时。
因为默认成员只会影响它的代码内容,所以=default
可以在编译器生成代码时才需要。而编译器需要第一时间知道成员是删除的,从而禁止任何使用它的操作。
除了这个不同点,=default
使用对象仅限于有合成版本的成员函数,而=delete
可以对任何函数指定。
在新版本发布之前,类是通过将拷贝构造函数和拷贝赋值运算符声明为private来阻止拷贝的。
1.7.2 析构函数不能是删除的成员
如果一个类的析构函数或者它的类类型的成员的析构函数被删除,不允许定义该类型的变量或是创建该类型的临时对象。
虽然不能定义该类型的变量,但是可以动态分配该类型的对象,只是不能释放动态分配对象的指针。
1.7.3 合成的拷贝控制成员可能是删除的
类成员的析构函数是删除的或不可访问的,则:
类的合成析构函数被定义为删除的。
类的合成拷贝构造函被定义为删除的。
类的默认构造函数被定义为删除的。
类的成员的拷贝构造函数/拷贝运算符是删除的或不可访问的,则:
类的合成拷贝构造函数被定义为删除的。
类含有引用或者const成员,则:
类的合成拷贝赋值运算符被定义为删除的。
类含有引用但没有类内初始化器或者含有const成员但没有类内初始化器且类型未显式定义默认构造函数,则:
类的合成默认构造函数被定义为删除的。
解释:
上述规则是为了防止创建出无法销毁的对象。
2. 拷贝控制与资源管理
管理类外资源(如动态分配内存资源)的类必须定义拷贝控制成员,来规定该类的不同对象之间拷贝时,类外资源的拷贝规则。
为了定义拷贝控制成员,必须先确定此类型对象的拷贝语义。即,拷贝操作使类的行为看起来是一个值(有自己状态,拷贝的副本和值相互独立)或是一个指针(共享状态,副本和值具有同样的底层数据,改变副本也会改变对象)。
考虑一个HasPtr类,有两个成员,一个int指针和string指针。对内置类型,直接拷贝,行为像值一样。而对string成员的拷贝,则取决于具体的拷贝语义。
2.1 行为像值的类
需要自定义:析构函数,拷贝构造函数,拷贝赋值运算符。(需要真正拷贝对象,动态分配空间,所以需要析构)
这种方式不是拷贝string指针,而是拷贝string串,然后将指针给ps。这样的类每个对象都有一个string的副本,且副本之间相互独立。
行为像值的类的赋值运算组合了析构与构造函数的操作。析构左边的对象,构造右边的对象。在编写赋值运算符时,先将右侧对象拷贝到临时对象内,然后销毁左侧对象,再将临时对象拷贝到左侧对象。
为什么要先拷贝右侧对象?因为如果是把对象赋值到自身,如果先销毁左侧对象再拷贝右侧对象,在拷贝右侧对象时会拷贝一个不存在的对象。
行为像值的类的拷贝赋值运算符示例:
2.2 行为像指针的类
需自定义:拷贝构造函数,拷贝赋值运算符,析构函数。(虽然对象之间拷贝只需要拷贝指针,但是接受string的构造函数需要动态分配)
行为像指针的类类似于shared_ptr,因为大家都是用指针访问对象,不能某人访问完就给销毁了,需要共享控制的机制。
–引用计数存在动态内存内。
–调用拷贝构造函数递增计数,并把计数器指针拷贝到新的数据成员。
–拷贝赋值运算符递减左侧运算对象的计数器,递增右侧运算对象的计数器。
–析构函数递减计数器。
拷贝构造函数:
析构:
赋值拷贝运算符:
3. 交换操作
交换时,更希望交换指针,而不是真的交换内容。标准库的swap会带来不必要的拷贝。
除非显式指定了std::swap,类型自定义的swap的匹配程度会高于标准库版本的swap。即使在块内声明了using std::swap;
可以用swap定义赋值运算符实现拷贝并交换技术(将左侧运算对象和右侧运算对象的副本交换),称为拷贝并交换赋值运算符,使用交换指针的方式完成赋值是很安全的:
传入的参数不是引用,所以会对参数rhs进行拷贝初始化,调用拷贝构造函数。
4. 动态内存管理
自定义一个vector:
数据成员:
成员函数:
拷贝控制三件套。(拷贝构造函数,拷贝赋值运算符,析构函数)
在reallocate时,不拷贝元素,而是将原来的元素移动过去。
5. 移动元素
拷贝后立刻销毁原元素的操作用移动代替会更好。
还有的情况是有的不能被拷贝的对象(比如unique_ptr、IO类)用移动的方式更好。
5.1 右值引用
右值引用就是只能绑定到右值的引用,用&&
而非&
来获取右值引用。
常规引用(左值引用)不能绑定到要求转换的表达式、字面值常量、返回值右值的表达式。右值引用则可以。
左值持久,右值要么是字面值常量,要么是表达式求值时构建的临时对象。
任何变量都是左值,包括右值引用变量本身。
右值指向的对象将要被销毁,且该对象没有其他用户。所以右值引用能自由接管对象。以=方式接管对象(前提是定义了=)。
5.1.1 move
move位于utility头文件内,接收一个左值作为参数,返回一个右值引用。实际他做的是:告诉编译器,有一个左值,但是希望按照右值方式去使用它。也就是move保证:除了对该左值赋值或者销毁它外,将不再使用它。
move不要提供using声明,直接用域作用符::
。
5.2 移动构造函数和移动赋值运算符
5.2.1 移动构造函数
移动构造函数的第一个参数是一个右值引用。和拷贝构造函数一样,任何额外参数都必须有默认实参。
实际上,移动构造参数和移动赋值运算符和构造的差不多,只不过移动版本的是从给定对象窃取而不是拷贝资源。
在函数的参数列表后加上noexcept来表示不要对这个函数抛出异常。如果是构造函数noexcept出现在参数列表和初始化列表和冒号之间,且声明和定义都必须加上noexcecpt。
为什么移动构造函数不能抛出异常,而拷贝构造函数没有这个要求?
为什么移动构造函数要加上noexcept?
5.2.2 移动赋值运算符
与移动构造函数一样,如果移动赋值运算符不抛出任何异常,应该被标记未noexcept:
移动赋值运算符需要处理自赋值(右侧对象是右值引用,左侧对象是左值,怎么会是自赋值?因为右侧对象可能是对左侧的左值对象调用move来的):
移动构造函数由于是参数传递的,在自动会析构,所以不用自己free,也就没有释放自己这种危险了。
5.2.3 移后源对象必须可析构
前面的例子将移后源对象置为nullptr保证可析构。
移动要保证移后源对象仍然有效(可以安全的赋新值或可以安全使用而不依赖当前值),对移动后移后源留下的值没有要求。
5.2.4 合成的移动操作
合成的移动操作定义为删除的函数的条件:
补充一点:当显式要求编译器生成=default的移动操作,且编译器不能移动所有成员,编译器也会把移动操作定义为删除的函数。
例子:
5.2.5 移动操作和拷贝操作
参数为左值,使用拷贝构造函数。参数为右值,使用移动构造函数。
如果没有定义移动构造函数,那么右值也会使用拷贝构造函数:
5.2.6 拷贝并交换赋值运算符和移动操作
如上的赋值运算符参数不是引用,所以会拷贝初始化rhs,可以用拷贝构造函数和移动构造函数完成拷贝初始化,传左值就是拷贝赋值运算符,传右值就是移动赋值运算符。单一运算符就实现了两个功能。
示例如下:
函数体的交换正常完成功能,如果是拷贝赋值运算符,则和前面分析的拷贝并交换赋值运算符同理。如果是移动赋值运算符,右侧运算对象被移动到rhs,函数结束后被销毁,正好完成移动功能。
5.2.7 移动迭代器
5.2.8 右值引用与成员函数
成员函数也可以定义拷贝版本(指向const的左值引用)和移动版本(指向非const的右值引用)。
拷贝版本可接受任何类型为X的对象,移动版本只可以接收非const的右值。
为什么要可修改(非const)?
成员函数需要从实参窃取数据,即移动,源会被修改。
示例:
移动和拷贝的重载方式如下:
5.2.9 左值和右值引用调用成员函数
在调用成员函数时,不区分左值还是右值,这就导致了右值可能作为赋值运算符左侧对象。
为了兼容旧标准,新标准仍然允许向右值赋值。不过可以在自定义类内制止这种用法:在定义赋值运算符时,在参数列表后加上引用限定符。
引用限定符可以是&
(左侧运算对象为左值)或者&&
(左侧运算对象为右值),限定的是"this"
也就是左侧运算对象的左值/右值属性。
函数可以同时使用const
和&
限定符,但是const
限定符必须在前:
引用限定符也可以区分重载版本。
重载时,如果有的函数加上了引用限定符,所有重载的函数都必须加引用限定符。