- 右值引用解决的问题
- C++11之前与Java的对比
- C++11右值引用
- std::move(val) 和 std::forward
(T&& val) 的作用 - 右值引用使用注意
- 何时发生移动,何时发生复制
- 感谢
右值引用解决的问题
右值引用是 C++11 中最重要的新特性之一,它解决了C++中大量的历史遗留问题,使C++标准库的实现在多种场景下消除了不必要的额外开销(如std::vector
, std::string
),也使得另外一些标准库成为可以实现的可能。在 C++11 之前,移动语义的确实是C++饱受诟病的问题之一。
使用右值引用特性,可以实现:移动语义(move) 和 完美转发(perfect forwarding)。
C++11之前与Java的对比
右值引用与对象的资源所有权的交换复制有关。
在之前初学Java时,我们就要明确两个概念:深复制 和 浅复制 。
深复制:被复制对象的所有变量都含有与原来的对象相同的值,除去那些引用其他对象的变量。那些引用其他对象的变量将指向被复制过的新对象,而不再是原有的那些被引用的对象。换言之,深复制把要复制的对象所引用的对象都复制了一遍。
浅复制:被复制对象的所有变量都含有与原来的对象相同的值,而所有的对其他对象的引用仍然指向原来的对象。换言之,浅复制仅仅复制所考虑的对象,而不复制它所引用的对象。
在Java当中,所有的参数与赋值符号,实现的所有权转移都是使用传递引用,这里的引用与C中的引用概念类似,但C中的引用在使用过程中并不能改变引用的对象。Java 示例代码:
在上述代码中,s2改变则s1也会随之改变,很多情况下,当把s1放到一个函数参数位置上当作参数,参数获取的仍然也是s1的引用。但有一些函数提供复制的语义,如字符串的 “+” 操作符,它会将两个字符串拼成一个,但却是在内存中重新分配了空间。
浅复制 是移动语义的实现方法,速度快,同时能够保证不同作用域可以操作同一个对象。而当我们需要使用复制语义存储某一对象的副本时,例如我们存储一个Student
对象的副本到ArrayList中,当我们改变了Student,ArrayList中的也随之改变,这并不是我们想要的结果,因此,需要使用 深复制。
|
|
上述代码使用输入输出流将对象 串行化 之后进行了复制,返回了复制的对象的引用,调用这个函数可以获得一个Studnet
的实例的副本。
在C++中,浅复制代码可以写成下面这样:
而在C++的表现却是s1与s2并不是指向同一个对象,s1的改变并不会影响到s2中的值。 赋值符号”=”在这里会调用s2的复制构造函数,将s1当做复制构造函数的参数进行调用。如果不使用指针,无法实现Java中的浅复制。
C++之所以与Java在内存管理方面存在上面所述的差异,原因是他们两种语言的内存回收方式的不同。C++(RAII机制)在栈上创建的对象(如局部变量)在离开作用于就会被析构,在堆上分配内存的对象只有在调用 delete
之后或者程序结束之后才会释放,而Java采用垃圾回收算法来判断是否回收创建的对象。
C++的指针设计简单,应用灵活,但却很容易造成内存泄漏(即便采用 谁开发谁治理 谁负责申请内存谁就负责销毁的方法,在一些过程函数中难以定义“谁”,且在多人协作容易出现问题),Java虽然采用垃圾回收机制却仍然无法避免使用不当产生的内存泄漏。
C++11右值引用
右值引用是C++11引入的与Lambda表达式齐名的重要特征之一。
右值引用了解之前需要明确这几个定义:左值、右值,C++11标准中将右值划分出的纯右值、将亡值。
左值(lvalue, left value),顾名思义就是赋值符号左边的值。准确来说,左值是表达式(不一定是赋值表达式)后依然存在的持久对象。函数名和变量名(实际上是函数指针③和具名变量,具名变量如std::cin、std::endl等)、返回左值引用的函数调用、前置自增/自减运算符连接的表达式++i/–i、由赋值运算符或复合赋值运算符连接的表达式(a=b、a+=b、a%=b)、解引用表达式*p、字符串字面值”abc”等。
右值(rvalue, right value),右边的值,是指表达式结束后就不再存在的临时对象。
纯右值(prvalue, pure rvalue),纯粹的右值,要么是纯粹的字面量,例如 10, true;要么是求值结果相当于字面量或匿名(不具名)临时对象,例如 1+2。非引用返回的临时变量、运算表达式产生的临时变量、原始字面量、Lambda 表达式都属于纯右值。
将亡值(xvalue, expiring value),是 C++11 为了引入右值引用而提出的概念(因此在传统 C++中,纯右值和右值是统一个概念),也就是即将被销毁、却能够(或者说要使用移动)被移动的值。
将亡值可能稍有些难以理解,我们来看这样的代码:
在这样的代码中,函数 createAVector
的返回值 res
在内部创建然后被赋值给 v
,然而 v
获得这个对象时,会将整个 res
拷贝一份,然后把 res
销毁,如果这个 res
非常大,这将造成大量额外的开销(这也就是传统 C++ 一直被诟病的问题)。在最后一行中,v
是左值、createAVector()
返回的值就是右值(也是纯右值)。
但是,v
可以被别的变量捕获到,而 createAVector()
产生的那个返回值作为一个临时值,一旦被 v
复制后,将立即被销毁,无法获取、也不能修改。
将亡值就定义了这样一种行为:临时的值能够被识别、同时又能够被移动。
需要拿到一个将亡值,就需要用到右值引用的申明:T &&
,其中 T
是类型。右值引用的声明让这个临时值的生命周期得以延长、只要变量还活着,那么将亡值将继续存活。
C++11 提供了 std::move
这个方法将左值参数无条件的转换为右值,有了它我们就能够方便的获得一个右值临时对象,例如:
注意:rv2 虽然引用了一个右值,但由于它是个具名变量可以在后面的代码中使用,所以 rv2 是一个左值。
在程序设计中,我们重点的是如何使用右值引用才能使程序效率提高,而不是区分哪些是左值,哪些是右值。
事实上,将亡值不过是C++11提出的一块晦涩的语法糖。它与纯右值在功能上及其相似,如都不能做操作符的左操作数,都可以使用移动构造函数和移动赋值运算符。当一个纯右值来完成移动构造或移动赋值任务时,其实它也具有“将亡”的特点。一般我们不必刻意区分一个右值到底是纯右值还是将亡值。
std::move(val) 和 std::forward(T&& val) 的作用
std::move(val)
会将val强制转换成右值并返回该右值,代码如下(MSVC):
std::forward<T>(T&& val)
会将val转换成与其传入时相同的类型并将转换后的进行返回,即如果传入时是左值则返回左值,如果传入的是右值则返回右值,实现代码(MSVC)如下:
|
|
使用方法如下:
完美转发涉及到了引用折叠规则(reference collapsing rule), 以及一个特殊原则,在 2.两个原则 中有讲到,这两个原则应当归属在 模板 的新增特性里。
注意:为了避免这两个原则对我们造成思维混乱,将其归为 完美转发 ,并使用完美转发来解决根据输入的左值或右值调用参数为左值或右值的重载函数。 若要使用上述原则,则需要进行针对性的测试,并能根据实际情况测试出能得到自己想要的结果的使用方法才可以使用。
右值引用使用注意
对于将亡值,如果对象能够调用 移动构造函数 和 移动赋值函数,则会自动使用 移动语义, 因此,对于右值引用在函数返回值方面,最佳使用方法是:
1234567std::vector<int> return_vector(void){std::vector<int> tmp {1,2,3,4,5};return tmp;}std::vector<int> rval_ref = return_vector();与我们之前写的函数并无其他特殊之处。
无论是左值引用
&
还是右值引用&&
,本质上都是 引用类型(是一种基本类型),以前使用&
会导致的错误的地方,把&
替换成&&
也会出现错误。错误使用
&&
和&
的方法如下,该方法返回的对象临时的,在返回完成之后会被析构,导致引用指向错误,运行错误:1234567std::vector<int>&& return_vector(void){std::vector<int> tmp {1,2,3,4,5};return std::move(tmp);}std::vector<int> &&rval_ref = return_vector();1234567std::vector<int>& return_vector(void){std::vector<int> tmp {1,2,3,4,5};return tmp;}std::vector<int> &rval_ref = return_vector();如何使用右值引用,与上面同理,右值引用也是一种 引用类型,以前为了传参数避免参数调用复制构造而使用
&
传引用的地方,或者返回一个非函数内创建的变量的引用而是用&
的地方,都是可以使用右值引用&&
的地方。&&
与&
的声明方法相同,只能在声明右值引用的同时必须指定一个引用对象。示例如下:123456MyString& reference(MyString& v) { // & 引用在参数、返回值上的应用std::cout << "左值引用" << std::endl;return v;}MyString c;MyString& d = reference(c);123456int&& reference(MyString&& v) { // && 引用在参数、返回值上的应用std::cout << "左值引用" << std::endl;return std::move(v); // 注意:这里需要将左值v转换成右值}MyString c;MyString&& d = reference(std::move(c)); // 注意:这里需要将左值c转换成右值上述函数返回引用类型的用法很少用到,如需用到需要参考 输入输出流 的
>>
或<<
运算符的写法。我们经常用到的是在函数参数上声明引用类型,如reference(MyString& v)
, 如果我们要使用 移动语义 对某一对象要将其移动函数内进行操作,则可有如下写法:123456int&& reference(MyString&& v) { // && 引用在参数、返回值上的应用MyString&& tmp = std::move(v);... // op for tmp...return std::move(tmp); // 注意:这里需要将左值v转换成右值}
而实际上,我认为在使用移动语义的时候配合 完美转发(Perfect Forwarding) 则能够使函数提供更多的用法,典型用法可参考 vector
的 void push_back(value_type&& _Val)
。可我怎么觉得我很少能用到
何时发生移动,何时发生复制
在实际程序设计时,我们需要知道何时是调用 复制构造函数 与 复制赋值函数,何时调用 移动构造函数 与 移动赋值函数。下面是一个我抄的(出处在最后的链接上)一个类的写法,上面粗体的四个函数都有实现:
接下来我们我们进行试验,来了解 移动 和 复制 的调用情况,先给出试验用的测试:
在1, 2, 3, 4都定义的情况下,我们会得到下面的结果:
12345678910Copy Assignment is called! source: a1Move Assignment is called! source: a2Move Assignment is called! source: a4Move Assignment is called! source: 1000Move Constructor is called! source: a5Move Constructor is called! source: a5Copy Constructor is called! source: 1000Move Constructor is called! source: a5Move Constructor is called! source: 1000Move Constructor is called! source: 1000在1,2,3都定义的情况下,我们会得到下面的结果:
12345678910Copy Assignment is called! source: a1Copy Assignment is called! source: a2Copy Assignment is called! source: a4Copy Assignment is called! source: 1000Move Constructor is called! source: a5Move Constructor is called! source: a5Copy Constructor is called! source: 1000Move Constructor is called! source: a5Move Constructor is called! source: 1000Move Constructor is called! source: 1000在1,2都定义的情况,我们会得到下面的结果:
12345678910Copy Assignment is called! source: a1Copy Assignment is called! source: a2Copy Assignment is called! source: a4Copy Assignment is called! source: 1000Copy Constructor is called! source: a5Copy Constructor is called! source: a5Copy Constructor is called! source: 1000Copy Constructor is called! source: a5Copy Constructor is called! source: 1000Copy Constructor is called! source: 1000在只定义1的情况下,我们得到了一个运行错误的结果.在Debug的过程中,
a0 = MyString("a4");
在运行时并未调用标号1函数,那没有调用 复制构造函数 则调用是使用了移动的 移动赋值函数 。在我们定义了1,3,4的情况下,我们得到如下结果:
12345678910Copy Assignment is called! source: a1Move Assignment is called! source: a2Move Assignment is called! source: a4Move Assignment is called! source: 1000Copy Constructor is called! source: a5Copy Constructor is called! source: a5Copy Constructor is called! source: 1000Copy Constructor is called! source: a5Copy Constructor is called! source: 1000Copy Constructor is called! source: 1000本次的输出,告诉我们第三句调用的是 移动赋值函数。
- 在1,2,3,4都未定义的情况下,我们在析构函数的时候,得到了一个运行错误,而析构函数做的是
delete
操作。
上述输出结果告诉我们,要想像 main()
函数中那样调用,并且想得到自己最理想的调用结果,就需要自己设计能用的复制构造函数 与 复制赋值函数,以及 移动构造函数 与 移动赋值函数 或者 遵照C++新标准的推荐,避免使用C风格的指针 。
上述代码可能是使用了,本句存疑,容我之后好好想想。C
风格的指针,导致在移动赋值函数调用之后,出现问题
在这里解释一下为什么7行代码却输出了10行输出,原因在于
vector
身上,众所周知,vector
是一个动态数组,其内部的数据是连续存放的,因此,当vector
每增加一个元素就需要判断一下vector的大小是否已经达到目前分配的最大数量,如果达到了最大数量则需要先申请一个更大的数组,然后将现在的元素 移动 或者 复制 到新的数组中进行存储,然后将新的元素追加到当前数组的最后位置。注意:一个类型如果明确它是可以移动的,则一定要显式定义它的 移动构造函数 与 移动赋值函数,若是明确它是可以拷贝的,则一定要显式定义它的 复制构造函数 与 复制赋值函数, 如果明确它不可以移动或者拷贝,则将其 私有化 即可封闭相应的函数调用。
注意:涉及到新特性,需要进行针对特性的测试。
感谢
- C++11 标准新特性: 右值引用与转移语义 - ibm
- c++11 中的 move 与 forward - twoon
- 左值右值的一点总结 - twoon
- C++11:完美转发的使用 - 一如当初
- 话说C++中的左值、纯右值、将亡值 - 同勉共进
- C++ 11/14 高速上手教程 - 语言运行期的强化 - 实验楼
- C++11 rvalues and move semantics confusion (return statement) - stackoverflow
- Value categories - cppreference.com
- std::forward
本文抄袭了他们的一些原文,如果作者禁止使用,则请告知,我会第一时间修改。