C++的杂七杂八:我家的返回值才不可能这么傲娇(右值引用和移动语义)

/ 0评 / 0

Published by orzz.org(). (http://orzz.org/cxx_rvalue_reference/)

大凡编程语言,都会有“函数”这个概念。而对于外部而言,一个函数最重要的部分就是它的返回值了。
说到这里,返回值其实应该是一个很简单的话题。当需要通过函数传递一个值出去的时候,使用返回值不是理所当然的嘛,比如说,像下面这样:

直接通过返回值将函数的计算结果返回出去,不论是函数还是调用者都会身心愉悦,因为这是最自然的使用方法了。但是在C++里,通过函数返回值返回处理结果往往是一种奢侈的行为。

一、使用返回值的问题

比如说,这种写法:

我相信有经验的C++程序员看到了都会皱眉头,良好的做法应当是使用+=来代替之:

原因很简单,+和+=的操作符重载的实现一般而言是像这样的:

注意到上面代码的第14行,由于+操作符不能修改任何一个参数,所以必须构建一个临时变量Str(x),并且Str(x)在把值传递出去之后,自身立马销毁了。外面负责接收的变量只能得到并复制一遍Str(x),于是一个简单的返回值就造成了两次x的拷贝。当像上文“ss = s1 + s2 + s3 + s4”这样连加的时候,拷贝就会像击鼓传花一样,在每一次+调用处发生。

我们不可能把+的返回值像+=一样用引用或指针来代替,因为Str(x)在出了operator+的作用域后自然就被销毁了,外部得到的引用将是一个悬空引用,无法通过它拿到处理后的数据。

为了说明问题,我们可以写一个简单的例子来看看这样赋值到底会有多大的损耗:

这是一个简单的Str类,包装了一个char*,并限制字符串长度为1024。

程序运行之后,我们得到如下打印信息:

Str copy constructor Hello
Str copy constructor Hello-
Str destructor Hello-
Str copy constructor Hello-
Str copy constructor Hello-World
Str destructor Hello-World
Str copy constructor Hello-World
Str copy constructor Hello-World!
Str destructor Hello-World!
Str destructor Hello-World
Str destructor Hello-

这个。。太漂亮了。。连续6次拷贝构造,并且最终这些临时生成的字符串统统炸鞭炮一样噼里啪啦被销毁掉了。
一次拷贝的工作是new一个1024的大内存块,再来一次strcpy。连续的构造-拷贝-析构,对性能会有相当大的影响。
所以尽量选择+=其实是不得已而为之的事情。。

同样的道理,我们也很少写 Str intToStr(int i) ,取而代之是 void intToStr(int i, Str& s)。
为了避免返回值的性能问题,C++er们不得不牺牲掉代码的优雅,用蹩脚的参数来解决。

二、一些解决方案

因噎废食不是办法,作为一个程序员(不仅仅是C++程序员),追求效率是无可厚非的,但是不能容忍语言限制我们的生产力,禁锢我们的思想。

一个解决方法是使用cow(copy-on-write)。我们可以不需要每次都拷贝数据,只有当数据发生改写的时候,才会出现拷贝操作。
体现在代码上,上文中Str的拷贝构造函数内,就不再直接使用strcpy了,取而代之的是一行短短的指针复制(注意不是内容复制)的浅拷贝。直到+=这种会改变自身内容的操作时,Str内部才会再创建一份内存,并把现有的数据拷贝一次。

这样做的代价是:

得到的好处是:

听起来似乎挺好,如果使用cow能够解决所有问题的话,就算实现起来麻烦点也是可以接受的。

只可惜对于 Str intToStr(int i) 的情况,cow确实有足够高的性能,但是我们一开始提出来的“ss = s1 + s2 + s3 + s4”,cow却帮不了太多忙:虽然Str(x)时,生成临时对象的拷贝不存在了,但马上执行的赋值改写操作不得不让内存被复制一遍,传递到下一层情况也不会有任何改变,连续赋值导致连续复制,在这种情况下原先的性能问题依然存在。

问题的本质其实不在于怎样复制,而在于不要复制。实际上对于返回值这种情况,返回的变量是一个临时变量,它马上就面临被销毁的命运,因此根本不需要把它的内容拷贝一遍,直接拿过来用不就好了。假如能这样的话,函数就能把自己的处理结果直接交给外面,而不是用一个坑爹的临时变量中转一道。

拿来用的过程其实很简单,一个swap操作就ok了。这样的话,只要能在Str里增加一个“移动构造函数”,这个世界就会变得很美好:

写到这里,真正头疼的地方来了:代码里...的位置应该填什么?

在C++中,一个表达式有左值(lvalue)和右值(rvalue)之分,即是否可寻址之分。
一般的变量都属于左值,而像(1 + 2)这种表达式的结果,则是不可寻址的,属于右值。
可改变的左值,被 & 绑定;不可寻址的值自然无法改变(或者说,不应该被改变),则会被 const & 绑定。

函数的返回值是一个什么值?从上面来看,它应该是一个右值。那么是否定义 const & 就可以了呢?

在C++03里:
当一个变量为一般左值时,它会走到 & ;
所有其它情况(const左值、右值)都会被绑定到 const & 。
这个结果非常不好,我们无法准确控制const左值是拷贝还是移动。

Andrei Alexandrescu,《Modern C++ Design》的作者,2003年曾在Generic上发过一篇著名的文章《Move Constructors》,里面给出了他对于这个问题的解决方案:Mojo (Move of Joint Objects),核心在于从语义上区分出“临时值”的概念来。

Mojo利用 & 绑定所有的左值;不定义 const &(因为它会把const左值和右值都吃掉),而是定义了一个constant(其内部存储了const的对象指针)作为传递const左值的手段;最后,他定义了temporary,来处理并传递右值。

这两个定义看起来像这样:

使用Mojo的类(比如Str)需要定义operator constant() const、operator temporary();
然后,对于需要使用Mojo传递参数的函数定义三个版本的重载:func(Str&)、func(constant&)、func(temporary&),分别用于接收左值、常量左值和右值。

有了这些之后,Mojo就可以完美的判断一般参数的左值/右值了。但仅仅这样,还无法正确处理返回值。因为想要返回一个对象,就需要把Mojo应用到拷贝构造函数上。这时事情变得复杂起来,因为拷贝构造函数要求使用 const & 。

Mojo的对策是再建立一个新的类型:fnresult,来处理返回值:

有了这个以后,对于刚刚定义的Str类来说,需要做的工作是:

事情完了么?恩,其实还有不少细节要处理。。
但不管怎么说,通过Mojo,我们确实可以顺利的区分出返回值变量,并把它绑定到“移动构造函数”上。

什么?你说它太麻烦?考虑到Mojo能够带来的性能提升,我不会说这是一个。。
好吧,这确实是一个侵入式且使用不大方便的方案。。。

三、其实事情可以很简单

不要再去想特殊处理 const & 了!我们拧不过C++的默认绑定机制,就像胳膊拧不过大腿。。
把事情回归到原点,其实不就是由于无法区分返回值的右值特征嘛,我们直接给所有的右值打个标记不就完了?

于是,尝试定义下面这样一个模板:

这是一个很简单的模板,唯一有用的就是拷贝构造函数,它接收一个 const rvalue& ,然后直接捅给类型T的构造函数。

这样做的话,若T没有定义“移动构造函数”,rvalue就会被默认转型为 const T& ,并进入T的拷贝构造函数;
若T定义了“移动构造函数”,比如对于Str,像这样:

那么rvalue就会乖乖的被绑定到“移动构造函数”里,并执行我们想要的工作。

为了避免使用者不得不利用 const_cast 把 const 去掉才能完成swap,可以在std下定义swap函数的重载版本:

除了 swap 外,move 用来把一个普通左值变为 const rvalue& ,unmove 则把一个 const rvalue& 变为普通左值。

现在是不是简单多了?梳理一下看看Str需要做哪些事:

好了,就这些。

有了上面这些以后,我们可以开始写新的operator+了:

在新的 operator+ 里,Str(x)会被转换为 rvalue ,并被 Str(const rvalue& rhs) 接收,完成移动语义。
对于一般的拷贝构造,则由原来的拷贝构造完成。
而用于接收 const rvalue& 的 operator+ ,则利用 unmove 将 rvalue 转换为可以改变的左值,并在完成 += 操作后还原回 const rvalue& 。

现在再来看看“ss = s1 + s2 + s3 + s4”的输出吧:

Str copy constructor Hello
Str move constructor Hello-
Str move constructor Hello-World
Str move constructor Hello-World!
Str move constructor Hello-World!

没有任何临时变量的损耗,连续 + 的运算结果直接被“move”到了 ss 里。

四、其实事情可以更简单

是的,还可以更简单。

上面给出的方案,不论哪一种都不是语言本身的特征。最后一种方案虽然简单,却还是要做无谓的 const 转换,仍然显得很繁琐。
其实我们需要的只是C++能够自动区别出返回值的右值特征而已。

于是,C++11里终于有了能够辨别右值的犀利武器:右值引用。

现在,我们有了语言原生的移动构造函数:

Str&& 就是 Str 的右值引用,它只能用来绑定右值。
那么 operator+ 应该怎么写呢?

由于右值引用的出现,C++的表达式不再非左即右了,一个右值引用的结果,有可能也是可以寻址的,因此:右值引用不一定能绑定一个右值引用。

在C++11里,表达式可以被分为3类:左值、右值、xvalue。其中 xvalue 是右值引用表达式的值,它可左可右。
把复杂的概念抛开,xvalue 可以被看做一个临时值,意思是马上就会被销毁的值。

那怎么区分右值引用表达式到底是左值还是右值呢?
简单来说,如果右值引用是不具名的(如 Str&& func(void) 这个函数的返回值,就是一个不具名的右值引用),那么它就是一个右值。
而具名的右值引用,如上面的 Str&& rhs ,则是一个左值。

因此像这样写代码:

sr 会被绑定到 ss 的拷贝构造,而不是移动构造函数上。

让我们梳理一下:

好了,还差什么?

想想看 operator+ 的写法,Str(x) += y 是一个左值还是右值?
好吧,它是左值。因为 += 返回的是一个普通引用。因此最开始的写法:

会导致返回的时候被调用一次拷贝构造函数,因为 operator+ 的返回值是一个对象。
那么返回 Str&& 行不行呢?当然不行,上面梳理的第1点已经指出一个左值不可能绑定到一个右值引用。

为此,标准库提供了一个 std::move 函数,用来把一个左值转换为一个右值,现在我们可以这样写:

这个 operator+ 的写法是不是和我们前面用 rvalue 时的写法很像?

其实一个右值引用,就相当于我们前面定义的 const rvalue& ;一个普通的右值,比如 Str operator+() 里的返回值 Str ,则相当于 rvalue;而 std::move,就如同前面那个 move 一样,可以把一个左值变成右值。

你说等等,那 unmove 相当于什么?
答案是在这里我们不再需要 unmove 了。因为根据我们梳理的第2点,一个具名的右值引用就是一个左值,自然不再需要 unmove 来转换一道了,语言内建的功能就是如此方便。

好了,来完成剩下的移动语义 operator+ 吧:

它和前面的第二个 operator+ 写法也是一致的,唯一的区别是不再需要 unmove 了。

运行一下,看看效果:

Str copy constructor Hello
Str move constructor Hello-
Str move constructor Hello-World
Str move constructor Hello-World!

可以看到,对比起前面的写法,编译器在使用右值引用时优化掉了一次move constructor的调用。

结个尾吧

关于返回值的故事到这里就可以结尾了。虽然还有很多使用上的小细节,但是那些都已经不再是大问题了。

虽然在前面我们自己定义的 rvalue 可以基本达到和C++11里右值引用一样的效果,但它毕竟不是语言内建的支持,用起来没有右值引用顺溜。
而且,一个很重要的,在C++11里的右值引用不仅仅实现了 move 语义和返回值优化而已。由于是语言内建的功能,自然可以很方便的调整原本不完美的转换规则,用来实现另外一个同样重要且难以搞定的“完美转发(Perfect Forwarding)”。

不过那就是另外一个故事了。


参考文章:

Published by orzz.org(). (http://orzz.org/cxx_rvalue_reference/)

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据