转载

从4行代码看右值引用

右值引用是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是同一个指针,这就是所谓的指针悬挂。提供深拷贝构造函数虽可保证正常编译,但有时会造成额外的性能损耗,因为深拷贝有时并非必要,例如下面这种情况。

从4行代码看右值引用

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的区别。

从4行代码看右值引用

再看看下面的例子:

{  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/

正文到此结束
Loading...