右值引用是C++11新增的一个重要特性,主要用来解决C++98/03的两个问题:临时对象非必要的昂贵拷贝操作,以及在模板函数中如何按照参数的实际类型进行转发。本文通过4行代码解释右值引用相关的概念,帮助读者透彻掌握这一新特性。
目录描述:
概述
对于右值引用的概念,有些读者可能会感到陌生,其实它与C++98/03中的左值引用类似,例如C++98/03中的左值引用是这样使用的。
int i = 0; int& j = i;
这里的int&是对左值进行绑定(但int&却不能绑定右值),相应的,对右值进行绑定的引用就是右值引用,通过&&可以很方便地绑定右值,例如下面这行代码绑定了一个右值0。
int&& i = 0;
与右值引用相关的概念繁多,例如右值、纯右值、将亡值、universal references、引用折叠、移动语义、move语义和完美转发等。其中有多个新概念,对初学者来说,可能会觉得右值引用过于复杂,概念之间的关系难以厘清。
右值引用实际上并没有那么复杂,通过简单的4行代码我们就能清晰理解右值引用相关的概念了。
四行代码的故事
【第1行代码的故事】
int i = getVar();
上面的这行代码很简单,从getVar()函数获取一个整型值,然而,这行代码会产生几种类型的值呢?答案是两种——左值i,以及函数getVar()返回的临时值。临时值在表达式结束后就销毁了,而左值i在表达式结束后仍然存在,这个临时值就是右值,具体来说是一个纯右值。区分左值和右值的一个简单办法是:看能不能对表达式取地址,如果能,则为左值,否则为右值。
所有的具名变量或对象都是左值,而匿名变量则是右值,例如,在下面这条简单的赋值语句中:
int i = 0;
i是左值,0是字面量,即右值。在代码中,i可被引用,0就不可以了。具体来说,表达式中等号右边的0是纯右值(prvalue),在C++11中所有的值必属于左值、将亡值、纯右值三者之一。例如,非引用返回的临时变量、运算表达式产生的临时变量、原始字面量和lambda表达式等都是纯右值。而将亡值是C++11新增的、与右值引用相关的表达式,例如将要被移动的对象、T&&函数返回值、std::move返回值和转换为T&&的类型转换函数的返回值等。关于将亡值我们会在后面介绍,先看下面的代码:
int j = 5; auto f = []{return 5;};
5是一个原始字面量,[]{return 5;}是个lambda表达式,都属于纯右值,它们的特点是在表达式结束之后就销毁了。
通过第1行代码我们对右值有了一个初步的认识,知道了什么是右值。
【第2行代码的故事】
T&& k = getVar();
它和第1行代码很像,只是多了“&&”,但实际上语义差别很大,这里,getVar()产生的临时值不会像第一行代码那样,在表达式结束之后就销毁了,而是会被“续命”,其生命周期将通过右值引用得以延续,与变量k的生命周期一样长。
【右值引用的第一个特点】
让我们通过一个简单的例子看看右值的生命周期,如代码清单1所示。
代码清单1 #include using namespace std; int g_constructCount=0; int g_copyConstructCount=0; int g_destructCount=0; struct A { A(){ cout<<"construct: "<<++g_constructCount< function path() { var args = arguments, result = [] ; for(var i = 0; i < args.length; i++) result.push(args[i].replace('@', '/cms/js/syntax/scripts/')); return result }; SyntaxHighlighter.autoloader.apply(null, path( 'applescript @shBrushAppleScript.js', 'actionscript3 as3 @shBrushAS3.js', 'bash shell @shBrushBash.js', 'coldfusion cf @shBrushColdFusion.js', 'cpp c @shBrushCpp.js', 'c# c-sharp csharp @shBrushCSharp.js', 'css @shBrushCss.js', 'delphi pascal @shBrushDelphi.js', 'diff patch pas @shBrushDiff.js', 'erl erlang @shBrushErlang.js', 'groovy @shBrushGroovy.js', 'java @shBrushJava.js', 'jfx javafx @shBrushJavaFX.js', 'js jscript javascript @shBrushJScript.js', 'perl pl @shBrushPerl.js', 'php @shBrushPhp.js', 'text plain @shBrushPlain.js', 'py python @shBrushPython.js', 'ruby rails ror rb @shBrushRuby.js', 'sass scss @shBrushSass.js', 'scala @shBrushScala.js', 'sql @shBrushSql.js', 'vb vbnet @shBrushVb.js', 'xml xhtml xslt html @shBrushXml.js' )); SyntaxHighlighter.all();
为了清楚地观察临时值,在编译时设置编译选项-fno-elide-constructors关闭返回值优化效果。
输出结果为:
construct: 1 copy construct: 1 destruct: 1 copy construct: 2 destruct: 2 destruct: 3
从上面的例子可看到,在没有返回值优化的情况下,拷贝构造函数调用了两次,一次是GetA()函数内部创建的对象返回出来构造一个临时对象产生的,另一次是在main函数中构造a对象产生的。第二次的destruct是因为临时对象在构造a对象之后就销毁了。如果开启返回值优化,输出结果将是:
construct: 1 destruct: 1
但这不是C++标准,是各编译器的优化规则。我们回到之前提到的可以通过右值引用来延长临时右值的生命周期,如果上面的代码通过右值引用来绑定函数返回值,结果又会怎样呢?在编译时设置选项-fno-elide-constructors。
int main() { A&& a = GetA(); return 0; }
输出结果为:
construct: 1 copy construct: 1 destruct: 1 destruct: 2
通过右值引用,比之前少了一次拷贝构造和一次析构,原因在于右值引用绑定了右值,让临时右值的生命周期延长了。我们可以利用这个特点做性能优化,即避免临时对象的拷贝构造和析构,事实上,在C++98/03中,常量左值引用也经常用于性能优化。上面的代码改成:
A& a = GetA();
【右值引用的第二个特点】
右值引用独立于左值和右值。意思是右值引用类型的变量可能是左值也可能是右值。例如:
int&& var1 = 1;
var1类型为右值引用,但var1本身是左值,因为具名变量都是左值。
关于右值引用一个有意思的问题是:T&&是什么,一定是右值吗?让我们来看下面的例子:
template< TYPENAME T> void f(T&& t); f(10); //t是右值 int x = 10; f(x); //t是左值
可以看到,T&&表示的值类型不确定,看起来有点奇怪,但这就是右值引用的一个特点。
【右值引用的第三个特点】
T&& t在发生自动类型推断时,是未定的引用类型,如果被左值初始化,就是左值;如果被右值初始化,就是右值。
再看上面的代码,函数templatevoid f(T&& t),当参数为右值10时,根据universal references的特点,t被一个右值初始化,那么t就是右值;当参数为左值x时,t被一个左值引用初始化,它就是一个左值。需要注意的是,仅当发生自动类型推导(如函数模板的类型自动推导,或auto关键字)时,T&&才是universal references。再看下面的例子:
template< TYPENAME T> void f(T&& param); template< TYPENAME T> class Test { Test(Test&& rhs); };
param是universal reference,rhs是Test&&右值引用,因为模版函数f发生了类型推断,而Test&&并未发生类型推导,因为Test&&是确定的类型了。
正是因为右值引用可能是左值也可能是右值,依赖于初始化,我们可利用这一点做很多文章,如后面要介绍的移动语义和完美转发。
这里再提一下引用折叠,正是因为引入了右值引用,所以可能存在左值引用与右值引用和右值引用与右值引用的折叠,C++11确定了引用折叠的规则:
所有右值引用叠加到右值引用上仍是右值引用;
其他引用类型之间的叠加都将变成左值引用。
【第3行代码的故事】
T(T&& a) : m_val(val){ a.m_val=nullptr; }
这行代码实际来自于一个类的构造函数,构造函数的一个参数是右值引用,为什么将右值引用作为构造函数的参数呢?在解答这个问题之前我们先看一个例子,如代码清单2所示。
代码清单2 class A { public: A():m_ptr(new int(0)){cout << "construct" << endl;} A(const A& a):m_ptr(new int(*a.m_ptr)) //深拷贝的拷贝构造函数 { cout << "copy construct" << endl; } ~A(){ delete m_ptr;} private: int* m_ptr; }; int main() { A a = GetA(); return 0; }
输出为:
construct copy construct copy construct
一个带有堆内存的类,必须提供一个深拷贝构造函数,因为默认是浅拷贝,会发生“指针悬挂”问题。如果不提供深拷贝的拷贝构造函数,上面的测试代码将会发生错误(编译选项-fno-elide-constructors),内部的m_ptr将会被删除两次,第一次是临时右值析构时,第二次是外面构造的a对象释放时,而这两个对象的m_ptr是同一个指针,这就是所谓的指针悬挂。提供深拷贝构造函数虽可保证正常编译,但有时会造成额外的性能损耗,因为深拷贝有时并非必要,例如下面这种情况。
GetA函数会返回临时变量,通过它拷贝构造了一个新的对象a,临时变量在拷贝构造完成之后就销毁了,如果堆内存很大,这个拷贝构造的代价会很大。每次都会产生临时变量并造成额外的性能损失,有没有办法避免呢?答案是肯定的,C++11已提供了解决方案,如代码清单3所示。
代码清单3 class A { public: A() :m_ptr(new int(0)){} A(const A& a):m_ptr(new int(*a.m_ptr)) //深拷贝的拷贝构造函数 { cout << "copy construct" << endl; } A(A&& a) :m_ptr(a.m_ptr) { a.m_ptr = nullptr; cout << "move construct" << endl; } ~A(){ delete m_ptr;} private: int* m_ptr; }; int main(){ A a = Get(false); }
输出为:
construct move construct move construct
与清单2相比,只多了一个构造函数,输出结果表明,并没有调用拷贝构造函数,只调用了move construct,让我们来看看这个函数:
A(A&& a) :m_ptr(a.m_ptr) { a.m_ptr = nullptr; cout << "move construct" << endl; }
它并没有做深拷贝,仅将指针的所有者转移到了另外一个对象,同时,将参数对象a的指针置为空,仅做了浅拷贝,因此,构造函数避免了临时变量的深拷贝问题。
这个函数其实就是移动构造函数,它的参数是一个右值引用类型,这里的A&&表示右值。为什么?前面已经提到,这里没有发生类型推断,是确定的右值引用类型。为什么会匹配到这个构造函数?因为它只接受右值参数,而函数返回值是右值。这里的A&&可看作是临时值标识,做浅拷贝即可,从而解决了前面提到的临时变量拷贝构造产生的性能损失问题。这就是所谓的移动语义,右值引用的一个重要作用是来支持移动语义的。
需要注意的一个细节是,我们提供移动构造函数的同时也会提供一个拷贝构造函数,以防止移动不成功时还能拷贝构造,使代码更安全。
我们知道移动语义是通过右值引用来匹配临时值的,那么,普通的左值是否也能借助移动语义来优化性能,该怎么做呢?事实上为了解决这个问题,C++11提供了std::move方法将左值转换为右值,从而方便应用移动语义。move是将对象资源的所有权从一个对象转移到另一个对象,只是转移,没有内存的拷贝,这就是所谓的move语义。图2解释了深拷贝和move的区别。
再看看下面的例子:
{ std::list< std::string > tokens; //省略初始化... std::list< std::string > t = tokens; //这里存在拷贝 } std::list< std::string > tokens; std::list< std::string > t = std::move(tokens); //这里没有拷贝
如果不用std::move,拷贝的代价很大,性能较低。使用move几乎没有任何代价,只是转换了资源的所有权。C++11中所有的容器都实现了移动语义,方便我们做性能优化。
这里也要注意对move语义的误解,它并不能移动任何东西,唯一的功能是将一个左值强制转换为右值引用。如果一些基本类型(int和char[10]定长数组等),使用move仍会发生拷贝(因为没有对应的移动构造函数)。因此,move对于含资源(堆内存或句柄)的对象来说更有意义。
【第4行代码故事】
template < TYPENAME T>void f(T&& val){ foo(std::forward< T >(val)); }
C++11之前调用模板函数时,有个比较头疼的问题——如何正确传递参数。例如下面这段代码不能按照参数的本来类型进行转发。
template < TYPENAME T> void forwardValue(T& val) { processValue(val); //右值参数会变成左值 } template < TYPENAME T> void forwardValue(const T& val) { processValue(val); //参数变成常量左值引用 }
C++11引入了完美转发:在函数模板中,完全依照模板的参数类型(即保持参数的左值、右值特征),将参数传递给函数模板中调用的另外一个函数。C++11中的std::forward正是做这个事情的,按照参数的实际类型进行转发。请看下面的例子:
void processValue(int& a){ cout << "lvalue" << endl ; } void processValue(int&& a){ cout << "rvalue" << endl; } template void forwardValue(T&& val) { processValue(std::forward< T >(val)); //按照参数本来的类型进行转发。 } void Testdelcl() { int i = 0; forwardValue(i); //传入左值 forwardValue(0);//传入右值 }
输出为:
lvaue rvalue
右值引用T&&是一个universal references,可接受左值或右值,正是这个特性让它适合作为一个参数的路由,然后再通过std::forward按照参数的实际类型匹配对应的重载函数,最终实现完美转发。
我们可结合完美转发和移动语义来实现一个泛型的工厂函数,这个工厂函数可创建所有类型的对象。具体实现如下:
template< TYPENAME … Args> T* Instance(Args&&… args) { return new T(std::forward< ARGS >(args)…); }
这个工厂函数的参数是右值引用类型,内部使用std::forward按照参数的实际类型进行转发。
总结
通过4行代码我们知道了什么是右值和右值引用,以及右值引用的一些特点,利用这些特点我们可方便实现移动语义和完美转发。C++11正是通过引入右值引用来优化性能,具体来说是通过移动语义来避免无谓拷贝的问题,通过move语义将临时生成的左值中的资源无代价地转移到另外一个对象中,通过完美转发来解决不能按照参数实际类型来转发的问题,同时,完美转发获得的一个好处是可以实现移动语义。
作者:祁宇
作者简介:金山WPS资深工程师,负责Android服务端开发。爱好开源,主要研究方向为架构设计和业务重构。个人博客 http://www.cnblogs.com/qicosmos/