第二十四章 C++11特性之右值引用

右值引用,是 C++11 语言核心中最为重要的改进之一。右值引用给 C++ 带来了“Move语义”(“转移语义”),同时解决了模板编程中完美转发的问题(Perfect forwarding)。右值引用使 C++ 对象有能力甄别什么是(可以看作)临时对象,对于临时对象的拷贝可以做某种特别的处理,一般来说主要是直接传递资源的所有权而不是像一般地进行拷贝,这就是所谓的 move 语义了。完美转发则是指在模板编程的时候,各层级函数参数传递时不会丢失参数的“属性”(lvalue/rvalue, const?之类的)。这两个问题都对提高 C++ 程序的效率很有好处,让 C++ 的程序可以更加精确地控制资源和对象的行为,(我觉得,同时在一定程度上也提高的 C++ 程序形式上的“自由度”)。

只要英文基本上过得去,建议阅读下面几个网页的文章,他们都是世界级的专家,对 C++11 的理解比我准确得多:

1. 比如清楚地说明了"why",但是没有说明"how",在Google排好前啊,Scott Meyers也推荐了(下面)

C++ Rvalue References Explained 
http://thbecker.net/articles/rvalue_references/section_01.html

2. Effective C++ 的作者写的

Universal References in C++11—Scott Meyers 
http://isocpp.org/blog/2012/11/universal-references-in-c11-scott-meyers

3. VC 的库函数开发组的老师写的,这个我觉得技术细节上说得很清楚。但是很长。把“how”说得很清楚。

Rvalue References: C++0x Features in VC10, Part 2 
http://blogs.msdn.com/b/vcblog/archive/2009/02/03/rvalue-references-c-0x-features-in-vc10-part-2.aspx

如果想看简化的中文版,就继续往下,这篇基本上是上面资料3的笔记

一、如何区分左右值

要理解右值引用(还有我们一直在用的“引用”,以后叫左值引用),就要先搞清楚“左值、右值”是什么。首先,左值右值是针对“表达式”而言的,如果一个表达式所表示的内容,在这个表达式语句完了之后还能访问到(也就是有一个实实在在的名称代表它),那么这个表达式就是左值,否则就是右值。我的理解就是,“需要编译器为程序生成临时“匿名”变量来表示的表达式的值,就是右值”。但是人类又不是编译器,用这个方法来判断左右值并不方便。最简单的判断一个表达式是左值还是右值的方法是,“能不能对这个表达式取地址”。比如下面几个:

 

  1. int x = 0;
  2. x + 1;           // rvalue
  3. x;               // lvalue
  4. ++x;             // lvalue
  5. x++;             // rvalue
  6. int y[10];
  7. y[0];            // lvalue
  8. "literal string" // rvalue

之所以“能不能对这个表达式取地址”可以作为表达式是左值还右值的判断方法呢,因为 C++ 标准说取地址符只能应用在左值上。((C++03 5.3.1/2).)(有例外,参考资料3说 VC 对 C++ 可以开扩展选项,但是一般正直的程序员不会用它!:))。因为右值被看成是“匿名”变量(或者说,是编译器为我们加的变量),是程序中的幽灵变量,程序员不需要知道它的存在,更不应该去处理它,所以如果能对右值取地址,容易出现很多对程序造成危险的行为。

再举个比较常见的例子:

  1. x = a + b + c + d;

其实编译器会生成类似下面伪代码的“东西”

  1. temp1 = a + b;
  2. temp2 = temp1 + c;
  3. x = temp2 + d;

 

这个例子中的 temp?,就是右值(temp这个名字只有编译器知道,程序是不知道的)。

(原因我猜是两个,一是因为大部分CUP所支持的机器指令都是两操作符的(大多数的运算符也是两个操作数的),另一个是把复杂的式子用类似的方法化简成简单的式子(二叉树形式语法树)并最终翻译成汇编是编译器的成熟算法,词法分析基本上都是在干这个活。(纯猜测,非科班,没学过))

我们之所以会关注右值,主要的原因是,右值有时候会带来不必要的性能开销。还是举类似的例子,如果 x, y, z 是某个类(A)的对象,A 在构造的时候动态地申请资源(比如一块大的内存),在拷贝构造的函数中也一样,先申请一块同样大小的内存,然后把拷贝元对象的内容复制一份到刚申请到的内存中。那么,当我们写下 z = x + y 的时候,重载的操作符函数 + 产生的临时变量 temp,把 x + y 的值计算好放在temp中,(这个temp是不可避免的,因为+不应该改变操作数的值),然后 z 再调用拷贝构造函数,申请一大块内存,把 temp 的值拷贝过来,最后,temp再调用析构函数,把自己的内存释放掉。 其实如果斤斤计较下,就会发现其实 temp 只是一个临时的过渡,反正 z = x + y 这个语句结束之后 temp 就没有意义(也被析构了),那么何不把 temp 的内存换给 z 呢,这样 z 不需要申请请的内存,也不需要拷贝它的值,temp也不需要释放内存了,省下不少开销啊。

是的,我们当然想这么干,不过在 C++11 之前,程序没有办法分辨拷贝的对象是不是一个临时变量(可变的右值),是不是可以安全地从这个对象中把资源偷过来,我们只能写一种“拷贝构造函数”,是的,就是我们一直在写的那种“拷贝用”的构造函数。现在你明白了为什么 C++ 中要增加右值引用了,为的就是让程序可以分辨出一个对象是不是“幽灵对象”,然后再有区别地“对待”传入函数的对象。不过先别急,先看看引用的类型。

 

二、引用的类型

现在,C++11里的引用类型分为下面几种了:

  • 可变左值引用 :type &
  • 左值引用:type const &
  • 可变右值引用:type &&
  • 常右值引用:type const &&

这几种引用在初始化的时候,能绑定到什么“值”上呢?遵从两个规则:

  1. 要遵照“常量正确”的原则,也就是非常量的引用(type & 和 type &&)不能绑定到常量上(常量左值,常量右值)
  2. 防止意外修改“幽灵变量”,所以可变左值引用(type &)不能绑定到右值上

总结一下就是下面这个绑定的关系图:

技术分享

在C++03的时候,我们就已经知道type &和type const & 这两种类型的参数是可以参与函数重载的,现在 C++11 加入了两个新的引用类型,也是可以参与重载的,重载的规则如下:

  1. 不能违反初始化绑定规则
  2. 左值倾向于选择(常)左值引用,右值倾向于选择(常)右值引用(强)
  3. 非常量值倾向于选择非常量引用(弱)

还是有例子会比较容易理解:

引用重载例1
  1. #include <iostream>
  2. #include <string>
  3. #include <iomanip>
  4.  
  5. using namespace std;
  6.  
  7. void reference_overload(string & str) {
  8.     cout<<setw(15)<<str<<" => "<<"type &        "<<endl;
  9. }
  10.  
  11. void reference_overload(string && str) {
  12.     cout<<setw(15)<<str<<" => "<<"type &&       "<<endl;
  13. }
  14.  
  15. void reference_overload(string const & str) {
  16.     cout<<setw(15)<<str<<" => "<<"type const &  "<<endl;
  17. }
  18.  
  19. void reference_overload(string const && str) {
  20.     cout<<setw(15)<<str<<" => "<<"type cosnt && "<<endl;
  21. }
  22.  
  23. string const GetConstString() {
  24.     return string("const_rvalue");
  25. }
  26.  
  27. int main() {
  28.     string lvalue("lvalue");
  29.     string const const_lvalue("const_lvalue");
  30.     reference_overload(lvalue);
  31.     reference_overload(const_lvalue);
  32.     reference_overload(string("rvalues"));
  33.     reference_overload(GetConstString());
  34. }

运行的结果如下:

技术分享

符合我们预期的想像。但在实际中,一般不需要重载这四种,而只需要对:

  • type const &
  • type &&

两种引用类型进行重载就很有用了,只有这两种类型的重载时,会有什么样的匹配发生?

引用重载例2
  1. #include <iostream>
  2. #include <string>
  3. #include <iomanip>
  4.  
  5. using namespace std;
  6.  
  7. void reference_overload(string && str) {
  8.     cout<<setw(15)<<str<<" => "<<"type &&       "<<endl;
  9. }
  10.  
  11. void reference_overload(string const & str) {
  12.     cout<<setw(15)<<str<<" => "<<"type const &  "<<endl;
  13. }
  14.  
  15. string const GetConstString() {
  16.     return string("const_rvalue");
  17. }
  18.  
  19. int main() {
  20.     string lvalue("lvalue");
  21.     string const const_lvalue("const_lvalue");
  22.     reference_overload(lvalue);
  23.     reference_overload(const_lvalue);
  24.     reference_overload(string("rvalues"));
  25.     reference_overload(GetConstString());
  26. }

运行结果如下:

技术分享

从上面的结果看到,C++11 的引用重载规则基础上,实现对 type const & 和 type && 的重载,程序就可以“区分”出,什么变量的值是可以“神不知鬼不觉地偷走的”,而什么变量的值是不可以动的。有了这个办法,类的设计者就可以设计出“move 语义”的拷贝构造函数了。

三、move !

其实后面比较轻松,没有多少要记的东西了,举个简单的例子

  1. A(const A& _right) {
  2.     this->p = new int[100];
  3.     memcpy(this->p, _right.p, 100);
  4. }
  5. A(A && _right) {
  6.     this->p = _right.p;
  7.     _right.p = nullptr;
  8. }

1到4行是拷贝构造函数,而5到8行是转移构造函数(move)。拷贝构造函数中,我们从一个(常)左值,或是常右值作为拷贝源来构造一个新对象,原对象是不可以做修改的,所以只能重新 new 一块新的区域,并把数据拷贝一份。但如果拷贝源对象是非常量右值,说明这是一个“无人关心的”变量,我们可以通过指针交换,获取变量所拥有的资源,而“源对象”的针对则指向空,反正这个构造一结束,这个无人关心的变量就析构了。

从上面的例子可以很容易看出,在合适的地方使用这种转义构造,可以很大的提高程序性能。

当然这些是不够的,还有很多地方,我们也想用转移构造,却可能出问题,比如说下面这个例子:

  1. A GetA() {
  2.     A temp;
  3.     // do sth.  
  4.     return temp;
  5. }
  6. A x = GetA()

作为程序员,我知道在 return temp 这个语句后,这个temp变量就没有用了,因此我很想能把 temp 这个变量的内容,用转移拷贝构造函数转移给变量x,但是,temp是个左值!转移拷贝构造函数不会被选中!没有办法了吗?如果我们可以把左值“转换”成右值,让编译器调用转移构造函数的话,那就好了。这是一非常“普遍的需求”,除了上面这个例子之外,我们还会遇到很多类似的情况,比如,我想用“赋值运算符”来实现拷贝构造函数的时候:

  1. A(const A& _right) {
  2.     this->p = new int[100];
  3.     memcpy(this->p, _right.p, 100);
  4. }
  5. A(A && _right) {
  6.     this->p = nullptr
  7.     *this = _right; // Unexpected thing happens
  8. }
  9. A& operator = (A && _right) {
  10.     this->p = _right.p;
  11.     _right.p = nullptr;
  12.  
  13.     return *this;
  14. }
  15. A& operator = (A const & _right) {
  16.     this->p = new int[100];
  17.     memcpy(this->p, _right.p, 100);
  18.  
  19.     return *this;
  20. }

第7行,我们期待它能调用第9行的“转移赋值运算符”,但实际上会调用第15行的“普通赋值运算符”,

因为在C++中

  1. 具名的左值是左值
  2. 不具名的左值引用是左值
  3. 具名的右值引用是左值
  4. 不具名的右值引用是右值

所以上面这个例子,最终并不能实现我们想要的效果。

怎么办呢?C++11提供了这样的办法:std::move,现在程序会是这样:

  1. A GetA() {
  2.     A temp;
  3.     // do sth.  
  4.     return std::move(temp);
  5. }
  6. A x = GetA()

好了,现在转移构造函数被调用了,像魔法对吧? std::move 这个名字取得不好,其实它本身没有“move”任何东西,它只是把“不是“非常量右值”转换成“非常量右值””。但这是怎么做到的呢?其实并不难:

  1. template <typename T> struct RemoveReference {
  2.     typedef T type;
  3. };
  4.  
  5. template <typename T> struct RemoveReference<T&> {
  6.     typedef T type;
  7. };
  8.  
  9. template <typename T> struct RemoveReference<T&&> {
  10.     typedef T type;
  11. };
  12.  
  13. template <typename T> typename RemoveReference<T>::type&& move(T&& t) {
  14.     return t;
  15. }

有了这个 move 之后,就有办法把左值,右值,左值引用,右值引用都转换成右值引用了。

好了,到这里为止,基本上对于左值右值,以及它们的引用,还有转移语义,都应该清楚了。

move 语义和右值引用,确实使 C++ 更加“复杂”了,付出这个代价当然会有回报,STL 的性能得到了很大的提升,想想 vector<string>  v; 这么一个对象,每当 v 的内存区域需要扩大或是缩小的时候,在没有 move 的时候,每个一存储在 v 中的 string 都需要重新拷贝一次,但现在不用了,它们只需要交换指针。而这一切你只需要换一个编译器就可以得到,完全不需要修改以前的程序。 当然,以后当我们再写会动态申请资源的类的时候(需要深拷贝的类(实现 the rule three)),如果实现上这个类的转移拷贝构造和赋值的函数的话,我们也可以在使用 STL 和其它一些函数的时候得这种性能上的好处。而且,它也使得“传值”在C++中变得不那么可怕了。你可以不再为了“性能”而不得不对程序的形式作出妥协,比如使用引用参数而不是返回值来得到函数的 output 等等。

作为一个“不怎么写模板和库”的程序员,我觉得理解了到这里为止的内容,就已经够了。而且这些内容并不容易消化。

完美转发,就留给下次有余力的时候再学习了~

郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。