【C++11】右值引用(概念,使用场景),移动拷贝构造函数,赋值拷贝构造函数。
一. 左值和右值的概念
1. 左值概念
-
左值是一个数据的表达式(如变量名或引用的指针),我们可以获取到它的地址,正常情况下是可以能够对它赋值,定义const修饰后的左值,不能给它赋值,但是可以取出它的地址。
-
左值可以出现在赋值符号( " = " )的左边,也可以出现在赋值符号(" = “ )的右边。
-
左值具有持久的状态。
2. 右值概念
-
右值也是一个数据表达式,右值是字面常量或者是求值过程种创建的临时对象,右值的生命周期是短暂的,如:字面常量,表达式返回值,函数返回值(不是左值引用的返回值),临时变量,匿名对象等等,
-
右值不能出现在赋值符号的左边,右值也
不能取出地址
,更不能对它赋值
。
3. 左值引用
-
左值引用是对左值的一种引用,相当于给左值取别名。
-
普通的左值引用不能引用右值,但是const的左值引用可以引用右值。
-
引用方法: 类型+&,例如: int& pa=a;pa引用a变量。
4.右值引用
-
右值引用是给右值取别名,所有的右值引用是不能引用左值。
-
右值是不能取出地址的,但是当右值去别名后,这个右值会被存到特定的位置,且可以取到该值的地址,也就是说右值引用值是一个左值。
-
右值引用会开辟一块空间去存右值,其中普通的右值引用是可以被修改这块空间的,const的右值引用时不可以被修改的。
-
标准库中的move函数可以将一个左值强制转换为右值
二. 左值引用的使用场景和缺陷
自定义的string类,下面的使用的string类可能会使用我们自定义类,做参考。
1. 左值引用的使用场景
- 左值引用作为函数的参数,能够减少拷贝。
如下:func1函数是传值参数,s1在func1函数内拷贝构造出s;func2函数是传引用参数,s是s1的别名,两个指向的是同一个对象。
左值引用可以引用函数返回值,也可以减少拷贝构造。
当一个函数的返回值出了函数作用域不会被销毁,那么该返回值是引用的方法传出去,不需要拷贝构造出新的对象。
2. 左值引用的缺陷
如果一个函数的返回值出了该函数作用域后就会被销毁掉,这个返回值的生命周期就结束了,因此就不能使用传引用返回,所以只能通过传值返回,而传值返回至少需要进行一次拷贝构造(如果编译器比较旧,就需要进行两次的拷贝构造),在旧的编译器下,如果一个函数是返回值给另一个函数并创建出新对象的时候,中间过程中需要先拷贝构造出一个临时对象,然后再通过这个临时对象在另一个函数内拷贝构造出一个新对象,然后这个临时对象就会被销毁掉。但是有的编译器为了提高拷贝效率,就将调用两次拷贝构造函数
优化只调用一次拷贝构造函数。
C++ RVO(返回值优化机制)
深入理解C++中的RVO
但是如果是将返回值赋值给另一个函数体内已存在值的时候,它会调用一次拷贝构造函数构造出一个临时对象,然后调用赋值重载函数将临时对象赋值给s,由于这两个过程调用的函数是是不一样的,所以编译器就不会发生优化。
虽然编译器会将两次拷贝构造函数优化成一次拷贝构造函数,但是如果是返回对象是一个string等容器类型的对象,那么会发生一次深拷贝的问题。深拷贝的代价是十分大的。如下,str对象拷贝构造出一个s的对象,深拷贝的过程:
拷贝构造出s对象
- 在堆区拷贝构造出字符串2,然后将字符2的地址赋值给s对象中的指针。
- 销毁to_string函数栈帧,释放字符串1和str对象
如上,str拷贝构造对象时,除了拷贝构造出一个string对象外, 还需要在堆区拷贝一个字符串赋值给string对象上的指针,这就是深拷贝问题。
三. 右值引用和移动拷贝构造函数
为了
解决深拷贝的问题
,我们可以在我们自己实现的sjp::string中增加一个移动构造函数
,移动构造函数本质是将参数右值中的资源给窃取过来,占为己有,这样就不用做深拷贝,所以他叫移动构造,就是窃取别人的资源来构造自己,然后右值直接释放掉。这样就不会去在堆区上拷贝构造新的资源。
1. 移动拷贝构造函数
移动拷贝构造函数跟构造函数一样,参数需要是一个本身类型的对象,但移动拷贝构造函数的参数是一个该类型的右值引用
。移动拷贝函数创建出一个新对象,都将新对象中的值都设置为0,接下来与传进来的右值对象进行资源交换
。
- 根据函数匹配规则,如果调用拷贝构造对象的时候传的是左值,编译器会自动匹配到拷贝构造函数,如果传的是右值,那么就会匹配到移动拷贝函数。
- 使用移动拷贝构造函数后,源对象指向资源就被交换出去,这些资源的所有权都归属到了新对象,因此,如果源对象是一个长期存在的对象的时候,需要谨慎使用移动拷贝构造函数。调用移动拷贝构造函数创建出s3,s1的资源被转移到了s3,s1中没有指向任何资源,所以就不能通过s1去寻找之前的资源。
当我们理解了移动拷贝构造函数后,我们再来理解为什么移动拷贝构造函数可以解决传值返回可以解决深拷贝的问题。
当函数的返回值进行返回的时候,它会调用拷贝出一个临时对象,此时返回值就会去拷贝出一个临时对象,由于返回值是一个将亡值,属于一个右值,因此就会调用移动拷贝构造函数拷贝出临时对象,然后临时对象去拷贝出新对象,由于临时对象也属于一个右值,所以回去调用移动拷贝构造函数去拷贝出新对象。由于两个过程都是调用移动拷贝构造函数,则编译器会直接优化成只调用一次移动拷贝构造函数。
- 为什么移动拷贝构造函数可以解决深拷贝的问题呢?
如下,str对象中包含一个指针指向堆上一个字符串,当我们调用移动拷贝构造函数创建出s的时候,可以将str对象中的资源和s的对象资源进行交换,所以指向字符串的指针就给了s,因此s对象就可以通过指针使用该字符串,这就不需要去堆上再创建一块新的字符串给s对象。
2. 移动赋值
如果string类中只有一个移动拷贝构造函数,那么函数返回值构造临新对象的时候,那么只需要调用一次移动拷贝构造函数将资源转移给新对象。如果是将返回值赋值给一个已存在的值时,那么就会调用一次移动拷贝构造函数和一次赋值重载函数,赋值重载函数也是进行深拷贝的。如下:
因此,为了解决赋值重载的深拷贝的问题,我们还需要再实现一个移动赋值重载函数,移动赋值重载函数跟拷贝构造函数一样,都是解决深拷贝的问题,都是进行转移资源。
移动赋值重载函数跟赋值重载函数的定义是类似的,只是移动赋值重载函数的参数是右值引用,是为了让右值能够调用该函数,如下:
当有了移动赋值重载函数后,临时对象就会去调用移动赋值函数,直接转移资源给s。
拷贝构造/赋值函数,移动构造/赋值函数,析构函数之间的关系(包括从父类继承时的情况)
默认构造函数
-
当除了析构函数外没有其他任何默认函数,会自动生成默认构造函数,否则构造函数无法自动生成(即存在除析构函数意以外的任何其他BIG5之一),必须显示声明默认构造函数,即使他什么也不做。
-
即使显示声明的默认构造函数什么也不做,编译器也将其认为是复杂的,其不如自动生成的默认构造函数来的高效,并且也不会被认为是 POD 类型。
默认拷贝构造/赋值函数
1.C++11 标准还规定了如下规则
:
-
如果拷贝构造函数或者析构函数被显示声明,那么拷贝赋值运算符就不会自动生成
-
如果拷贝赋值运算符或者析构函数被显示声明,那么拷贝构造
函数就不会自动生成 -
这两种情况下, Visual Studio 仍然会自动生成必要的函数,并不会产生告警。
2.当显示定义了任意移动构造/移动赋值函数时(包括通过default指定的),编译器不会再自动合成拷贝构造/拷贝赋值函数(即使是default指定的移动构造/移动赋值函数也被认为是显示定义的)
默认移动构造/移动赋值函数
- 只有在没有显示定义任何BIG 5(注意:包括析构函数)之一时,编译器才会自动合成。
使用default指定的默认函数(注意default只能指定默认无参无初始化列表的构造函数和BIG5,其他不适用),与自动合成的默认函数的相同与不同:
-
1.相同:在调用父类相应函数时,与自动合成的默认函数表现出一致特性(功能性)
-
2.不同:即使是default指定的,也被认为是人工显示定义的(存在性)
单继承
核心:在构造子类之前一定要执行父类的一个构造函数。
-
构造函数(不包括复制构造函数)。
顺序:①直接父类;②自己 注意:若直接父类还有父类,那么“直接父类的父类”会在“直接父类” 之前 构造。 可以理解为这是一个递归的过程,知道出现一个没有父类的类才停止。
2.1 如果没有显式定义构造函数,则“合成的默认构造函数”会自动调用直接父类的“默认构造函数”,然后调用编译器为自己自动生成的“合成的默认构造函数”。
2.2 如果显式定义了自己的构造函数
2.2.1 如果没有显式调用直接父类的任意一个构造函数,那么和“合成的默认构造函数”一样,会先自动调用直接父类的 默认构造函数,然后调用自己的构造函数。
2.2.2 如果显式调用了直接父类的任意一个构造函数,那么会先调用直接父类相应的构造函数,然后调用自己的构造函数。 -
拷贝构造函数
顺序:①直接父类;②自己 注意:和构造函数一样,若直接父类还有父类,那么“直接父类的父类”会在“直接父类” 之前 构造。 可以理解为这是一个递归的过程,知道出现一个没有父类的类才停止。
2.1 如果 “没有显式定义拷贝构造函数” 或者 “使用default显式定义拷贝构造函数”时,则“合成的拷贝构造函数”会自动调用直接父类的“拷贝构造函数”,然后调用编译器为自己自动生成的“合成的拷贝构造函数”(注意:不是默认构造函数)
注意:当有任何显示定义的移动类函数时,编译器会删除所有默认的拷贝类函数
2.2 如果显式定义了自己的拷贝构造函数 (和构造函数类似)
2.2.1 如果没有显式调用父类的任意一个构造函数,那么会先调用直接父类的 默认构造函数(注意:不是 拷贝构造函数)。
2.2.2 如果显式调用了直接父类的任意一个构造函数,那么会先调用直接父类相应的构造函数。
-
拷贝赋值函数
3.1 如果 “没有显式定义拷贝赋值函数” 或者 “使用default显式定义拷贝赋值函数”时,会自动调用直接父类的拷贝赋值函数。(注意:不是 默认构造函数)
注意:当有任何显示定义的移动类函数时,编译器会删除所有默认的拷贝类函数
3.2 如果显式定义了,就只执行自己定义的版本,不再自动调用直接父类的任何函数,只执行自己的拷贝赋值函数。
注意:如有需要对父类子部分进行赋值,应该在自己编写的代码中,显式调用父类的赋值操作符。
- 移动构造函数
与2.拷贝构造函数情况类似,注意:只有当没有任何显示定义的BIG5之一时(即使是default指定的big5也被认为是显示定义的),才会自动合成
-
移动赋值函数
与3. 拷贝赋值函数情况类似,注意:只有当没有任何显示定义的BIG5之一时(即使是default指定的big5也被认为是显示定义的),才会自动合成
-
析构函数
与构造函数 顺序相反。
五. 右值引用与STL容器
1. 移动拷贝构造函数和赋值重载函数
c++标准库,很多容器为了解决深拷贝的问题,都会定义一个移动拷贝构造函数和一个赋值重载函数。 如下:
string类的移动拷贝构造函数和赋值重载函数:
list类的移动拷贝构造函数和一个赋值重载函数:
2.push_back和insert
在c++11标准库中,很多容器的push_back
或者insert
接口都至少会重载两个函数,其中一个函数的参数是左值引用,另一个函数的参数是右值引用。如下vector的接口:
在上面种的,参数是const 左值引用的insert,它既可以接收左值,也可以接受右值,那么为什么还需要多定义一个参数是右值引用的push_back函数重载?
如果调用vector中insert时,传的参数是右值,那么就会编译器就会匹配到
右值引用参数的insert的重载函数
,因为insert函数内部会对该参数值进行赋值重载到vector内,如果是右值,那么就会调用移动拷贝构造函数
,所以可以避免深拷贝的出现。
如下:
- 调用inser接口,如果传的是左值,编译器匹配左值引用的赋值重载函数,x赋值给*pos时会调用string类赋值重载函数,进行深拷贝,如下:
- 如果传的是右值,编译器匹配右值引用的赋值重载函数,x赋值给*pos时会调用string类的移动赋值重载函数,进行资源转换。
因此,我们在调用insert
和push_back
时,如果涉及深拷贝的问题,尽量传右值(匿名对象),这样可以减少深拷贝的问题。
3. 完美转发
但是在上面调用参数是右值引用的insert接口中存在一个问题,如果右值引用x去接受一个右值,那么这个x就会退化成一个左值,所以*pos=x;这个过程是调用的时候是会直接调用赋值重载函数,不是移动拷贝构造函数。因此,为了保持x是一个右值,那么我们可以做这样一个动作:
std::forward<T>(x)
:它可以在传参的时候保持x原生属性
,也就是可以保持x的右值属性,因此,这样可以保证*pos=x去调用移动赋值重载函数。
这种调用std::forward(x)保持原生属性的过程就叫做完美转发。