探索C++0x: 3. 右值引用(rvalue reference)

转载请注明来源: http://blog.csdn.net/thesys/archive/2010/06/06/5651713.aspx

简介

C++0x中引入了右值引用(rvalue reference)这个设施,形如T&&,用来实现移动语义(move semantics)和完美转发(perfect forwarding)。此前C++中有一个著名的性能问题——复制临时对象,由于右值引用的引入,该问题将得到极大的改善。

虽然右值引用的引入是一个很了不起的进步,也是一个明智的决定,但它并不那么讨人喜欢,至少我觉得如此。原因有二:首先是其概念本身就不容易理解,增加了一些智力负担;另外如果想享受它带来的性能好处,还必须增加一些编码工作量。

什么是左值和右值

我们首先需要熟悉一下现行C++98/03标准中,左值和右值的概念。网上现在充斥着很多对它们的解释,长篇大论的有,只言片语的也有,而且不一定清晰正确,可谓良莠不齐,这里我希望能够尽量简单准确地进行说明。

这里有两个问题经常令人混淆,必须首先澄清一下:

1. lvalue(left value)和rvalue(right value)的概念经过了长时间的演化,早已名不副实,千万不要把L和R当做真正的左右来理解。最早或许left value意味着在等号左边,right value意味着在等号右边,但对于现代C++这么复杂的语法来说,早就不适用了。
2. 左值还是右值,是针对某个表达式而言的,而不是针对某个具体值或者对象本身而言。C++ 03 标准 3.10/1 节:“每一个表达式要么是一个lvalue,要么就是一个rvalue。”因此不要单纯的考虑数字1是左值还是右值,或者存在地址0x100000上的那个string对象到底是左值还是右值,重要的是看表达式。

左值和右值的定义和判断:

如果一个语句结束的时候,该表达式代表的对象立刻被销毁,则为右值,否则就是左值。也就是说右值代表的是临时对象或者字面值,而左值则不是临时对象。引申出来的另一个判断方法是:具名的表达式意味着是左值,非具名的则为右值(非具名左值引用是个例外,它是左值)。示例代码如下:

现行C++标准对右值的限制:

由于临时对象在语句结束后被立刻销毁,因此在语句结束后还使用它是不安全的。所以现行C++标准中,规定右值是不能被具名引用的,因为一旦被引用了就可能被使用。但令人不爽的是,由于函数在传递参数时,又需要让临时对象可以被作为实参传递,C++标准只好又规定右值可以被具名引用,但只能被常量引用,而不能被被非常量引用,且被常量引用时,如果该常量引用是具名的(也就是左值),则该临时对象的生命周期延长到和该常量引用相同。其实这个规定挺搞笑的,完全可以不用区分是否是常量,同样规定右值也可以被非常量引用,不过标准既然这么说了,那编译器就只好这么办(其实VC的某些版本没这么干)。这里要注意的是,左值一旦被具名引用,则变成了右值。示例代码如下:

为什么要引入右值引用?

了解临时对象造成的性能问题,了解RVO

在现行的C++标准中,如果函数返回一个对象,则该对象是一个临时对象,也就是一个右值。这就带来一个很头痛的性能问题,就是该对象会被白白的拷贝好几遍。这种纯粹浪费性能的行为完全违背了C++的设计哲学,因此现行C++标准中,不惜破坏原来简单的拷贝语义,提出可以在拷贝构造的情况下省略掉多余的拷贝,这就是著名的RVO(Return Value Optimization)。但是很多时候,RVO都不能完全奏效,性能浪费依然不能避免。示例代码如下:

右值引用是救星:

为了解决这个问题,有一个很直观的方案,就是对临时对象的内部的数据(如上例中的m_buffer成员)进行操作,将它们“移动”或者“交换”过来,从而取代拷贝行为,因为反正临时对象马上就要销毁的,移动过来岂不正好?

但问题来了,如果要对临时对象的内部数据进行修改,至少需要具备两个条件:一个是需要引用临时对象,否则怎么有机会修改它呢;另一个是要识别对象是不是一个临时对象,改错了可就完蛋了。

如果简单的规定右值可以被引用,而且可以被非常量的引用,貌似可以解决第一个问题,但第二个问题还是没法解决,因为你不知道一个非常量的引用到底是不是临时对象。

因此C++0x标准中引入了右值引用,用两个连续的“&”符号来表示,和左值引用以示区别。例如int&&就表示一个整型的右值引用,而int&则还是和原来一样,表示整型的左值引用。C++0x标准进一步规定,除了原来规定的右值可以绑定到常量左值引用外,右值还可以绑定到右值引用,当然一旦被具名引用,右值还是会变成左值。而且遇到重载时,优先考虑将右值绑定到右值引用而不是左值引用(那当然,否则这玩意儿就废了)。另外,左值不允许绑定到右值引用,除非强制类型转换,这一点也很重要,以免无意中将非临时对象的内部数据给移走了。示例代码如下:

有了这样一个规定,就简单了,首先右值可以被引用了,其次为了分辨对象是不是临时的,我们可以做两个重载的函数,其中一个的形参是左值引用,另一个的形参是右值引用,那么该函数被调用时,如果参数是左值(非临时对象)或者左值引用,编译器会自动调用前一个重载的函数,如果参数是右值(临时对象)或者右值引用,则会自动调用后一个重载的函数,这样我们就可以准确的对左值和右值分开处理了。根据此原理,我们对前面例子中的int_array类进行修改,增加一个拷贝构造函数重载,和一个赋值操作符重载,用来接受右值引用,并修改使用的地方。示例代码如下:

这个示例代码已经完全消除了不必要的拷贝,之前的性能问题得到了彻底地解决。我们发现代码里面用到了std::move()这个函数,接下来我们仔细讲解这个函数。

了解std::move()

很多时候,我们需要把左值引用转换成右值。原因通常有两个:一个原因是我们明确知道该左值不久后将被销毁,而我们需要转移其中的内部数据到其它对象中,例如上例中new_make_int_array()函数中的用法;另一个原因是由于右值一旦被具名引用,哪怕是具名的右值引用,它也会变成左值,因此需要将它恢复成右值,这个描述很拗口,但的确就是这样。

标准库中提供了std::move()这个模板函数,用来干这个转换的差事。它接收一个引用,并强制类型转换成右值引用,然后返回。由于函数返回值是不具名的右值引用,因此它还是右值。具体的实现代码如下:

std::move()从名字上看,通常会产生错误的理解,以为移动临时对象内部数据的操作是由它完成的。看了代码就知道了,根本不是那么回事,它仅仅完成一个语义上的转换,将左值变成右值而已,而且没有任何性能损失。由于std::move()的语义是转成右值,以便接下来被转移,那么被传入到move函数的参数,在move调用过后,就最好不要再访问了,以免访问到错误的值。

完美转发(perfect forwarding)

最后我们讲一下完美转发,仔细讲起来会比较复杂。简单来说,在编写函数模板的过程中,可能需要把模板实参的左右值特性和常量性完美的保持下来,转发给其它的函数。在C++现行标准中,需要为每一个左右值特性和常量性作一个函数重载,如果函数只有一个形参,需要至少4个重载,如果函数模板有3个参数,则需要4的3次方=64个重载,因此很难做到统一的解决方案。C++0x通过右值引用,加上引用折叠(reference collapsing),以及左值引用经过函数模板推导还是左值引用的特殊规定,较好的实现了完美转发,就是std::forward()这个模板函数。其实现代码如下:

引用折叠(reference collapsing)

补充介绍一下引用折叠,这是C++0x为了实现移动语义(move semantics)和完美转发(perfect forwarding)而增加的规定。简单罗列一下规则,就很清楚了:
A& &等价于A&

A& &&等价于A&

A&& &等价于A&

A&& &&等价于A&&

总结

1. 右值引用的确带来了性能提升的可能,也带来了完美转发。但性能的提升需要额外的编码,用来实现转移和交换行为。

2. 左值和右值的概念,并不是指等号的左边还是右边,而是指该表达式代表的对象是否是持久的,持久的就是左值,反之是右值。我们也可以用是否具名来判断,具名的是左值,反之是右值(非具名左值引用除外)。

3. std::move()函数用于语义上的转移,将左值转成右值,而不是实质上数据的转移,实质性的转移需要每个类单独编码实现。

4. std::forward()函数实现完美转发,可用于模板函数中完美的传递参数。

阅读更多
个人分类: 技术
想对作者说点什么? 我来说一句

没有更多推荐了,返回首页

关闭
关闭
关闭