C++的杂七杂八:参数的传递接力(右值引用和完美转发)

/ 0评 / 0

Published by orzz.org(). (http://orzz.org/cxx-perfect-forwarding/)

拖了又拖,终于痛下决心写这篇文了……因为自己太懒,博客里的内容一直捉襟见肘,实在是说不过去啊。
闲话少叙,还是赶快进入正题吧。

话说在C++里一直有一个比较头疼的问题,那就是在函数调用函数的时候,如何正确的传递参数。
考虑下面这个泛型算法:

如果参数都是int啊long这种值,CompT的类型也只是一个函数指针的话,do_compare(a, b)时是没有任何成本的。
可是现实却往往不是这么单纯,foo在实际使用中,我们当然希望a和b有可能是任何类型,而CompT只要是一个可以接受a、b的可调用体(比如仿函数)就好了。

说到这里,其实我们可以把问题再弄单纯点:怎样构建一个泛型函数forwardValue

可以让它能够准确(不改变参数类型或cv属性)而高效的(不会引发深拷贝)将val转发到processValue里去?

一、参数前面填什么?

考虑到参数有可能是一个类或结构体,首先的想法是使用引用:

但是这样写,右值是无法绑定的。

同样的,使用const引用:

虽然能绑定任何属性的实参,但会导致所有类型都被变成常量。

结合上面两种情况,C++98/03里靠谱的写法只有这样了:

二、不完美的解决方案

如上写法,是C++98/03里的完美转发。这种方式有两个问题。

问题一,非常量右值的const属性被改变了。写一组测试函数可以看得很清楚:

看看输出结果:

这是因为在C++98/03里,我们只能通过const引用来绑定右值,尽管右值对象可能不是一个常量。

问题二,代码冗余。
上面给的例子是一个参数的情况。那么两个参数呢?稍微想想就知道,两个参数需要4个同样的函数才能转发所有情况。那么三个参数呢……
这个方案最不可取的地方就在这里了:需要重写的转发函数个数是2的n次方,其中n等于参数个数。

一个解决方法是通过一层reference wrapper,在参数传递之前把它的属性(左右值、常量)包裹起来,直到传递完毕再打开这层wrapper,比如说,写一个简单的wrapper如下:

然后再给它加一些辅助函数:

最后在需要转发的时候,这样使用:

输出结果:

为了能够存储参数的左右值属性,上面的方案里使用了C++11的右值引用。若完全不使用C++11,按如上做法则和“T& + const T&”方案的输出结果一致。
这个方案的最大好处在于解决了“2的n次方个转发函数”的问题,不好的地方在于我们还是需要写一些额外的代码噪音来标记转发。

三、C++11的解决方案

上面的方案出现的各种问题,其实根本在于原先的C++在泛型参数推导时无法保持住左右值属性。
比如,参数为T&的时候会丢失掉右值(无法绑定);而参数为const T&的时候,又会丢失掉左值(或者说,非const也会被const所覆盖)。若C++11保持这种做法,在有了右值引用(可以绑定右值)的情况下,想要完美转发一个参数就需要写4个函数,更加不靠谱。

那么,现在语言层面上的解决方法只剩下修改函数模板的参数推导规则了。
在现有的条件下,T&和const T&的推导方式是不能修改的,因为那会导致和老标准的兼容性问题。能够随意修改的,就只有新引入的T&&了。

因此在C++11中,T&&的参数将保持住参数的所有属性(左右值、常量)将外部的参数传递进来。为了实现这个特征,这里需要介绍下C++11的引用折叠规则:

上面这个表,加号前面的T&、T&&代表实参的左右值属性,加号后面的&、&&代表形参的类型。
比如,第一行就表示,当实参为一个左值(T&),在传递给参数为T&类型的形参的时候,泛型函数里的T&将被推导为T&(左值)。
那么上面的表里,前两行正好对应了现有T&的推导方式。而后两行,则是T&&的推导方式。从表里可以看出来,不论实参的左右值属性是什么类型(T&、T&&),经过引用折叠规则的推导,T&&形参类型都将正确的保持住原先的属性。

但是T&&形参类型保持住了这些属性,并不代表形参本身也会保持住这些属性。
让我们在前面测试代码的基础上写一个例子来看一下:

输出结果:

由于具名的右值引用其实是一个左值,因此T&&类型的形参本身,将会丢失左值属性。为了能够还原它原本的属性,我们需要做一下类型转换:

这样就能够得到正确的结果:

在C++11中,std命名空间里提供了一个forward函数用来实现最后一步的属性还原:

我们可以稍微思考一下它是如何实现的。
首先,参数val的类型肯定是一个T&(因为具名右值引用是一个左值),因此forward的参数可以是T&。
然后,结合上面的引用折叠推导规则,只有T&&类型才能保持住所有的属性,因此forward的返回值是T&&。
这样可以很快的写出一个简单的forward函数:

那么这样使用是否正确呢?

看看结果:

很明显,错了。原因在于val始终是一个左值,因此forward自动推导出来的T将是非引用类型,T&&将始终都是一个右值引用。
所以,正确的使用方法是这样:

因为若T&&保存了实参的左右值属性,那么根据引用折叠规则倒推(根据“左值 + && -> 左值”可以得到“左值 - && -> 左值”),T&&为左值引用的时候,T将也是一个左值引用。而当T&&为右值引用的时候,T将是非引用类型。所以forward中的T&&才能正确的还原原本参数的左右值属性。

为了禁止forward的自动推导,可以写成这样:

当然了,这只是简单的forward实现方式。实际中还要考虑很多其他情况,因此std的forward实现会比这个要复杂。


参考文章:

  • 1. [C/C++]关于C++11中的std::move和std::forward
  • 2. 《C++0x漫谈》系列之:右值引用(或“move语意与完美转发”)(下)
  • 3. The Forwarding Problem: Arguments
  • 4. (原创)C++11改进我们的程序之move和完美转发
  • Published by orzz.org(). (http://orzz.org/cxx-perfect-forwarding/)

    发表回复

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

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