Published by orzz.org(). (http://orzz.org/cxx-perfect-forwarding/)
拖了又拖,终于痛下决心写这篇文了……因为自己太懒,博客里的内容一直捉襟见肘,实在是说不过去啊。
闲话少叙,还是赶快进入正题吧。
话说在C++里一直有一个比较头疼的问题,那就是在函数调用函数的时候,如何正确的传递参数。
考虑下面这个泛型算法:
1 2 3 4 5 6 7 8 |
template <typename T, typename U, typename CompT> void foo(T a, U b, CompT do_compare) { if (do_compare(a, b)) { // ... } } |
如果参数都是int啊long这种值,CompT的类型也只是一个函数指针的话,do_compare(a, b)时是没有任何成本的。
可是现实却往往不是这么单纯,foo在实际使用中,我们当然希望a和b有可能是任何类型,而CompT只要是一个可以接受a、b的可调用体(比如仿函数)就好了。
说到这里,其实我们可以把问题再弄单纯点:怎样构建一个泛型函数forwardValue
1 2 3 4 5 |
template <typename T> void forwardValue(... /* what's here? */ val) { processValue(val); } |
可以让它能够准确(不改变参数类型或cv属性)而高效的(不会引发深拷贝)将val转发到processValue里去?
一、参数前面填什么?
考虑到参数有可能是一个类或结构体,首先的想法是使用引用:
1 2 3 4 5 |
template <typename T> void forwardValue(T& val) { processValue(val); } |
但是这样写,右值是无法绑定的。
同样的,使用const引用:
1 2 3 4 5 |
template <typename T> void forwardValue(const T& val) { processValue(val); } |
虽然能绑定任何属性的实参,但会导致所有类型都被变成常量。
结合上面两种情况,C++98/03里靠谱的写法只有这样了:
1 2 3 4 5 6 7 8 9 10 11 |
template <typename T> void forwardValue(T& val) { processValue(val); } template <typename T> void forwardValue(const T& val) { processValue(val); } |
二、不完美的解决方案
如上写法,是C++98/03里的完美转发。这种方式有两个问题。
问题一,非常量右值的const属性被改变了。写一组测试函数可以看得很清楚:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
#include <iostream> class Foo {}; void processValue(Foo&) { std::cout << "左值" << std::endl; } void processValue(const Foo&) { std::cout << "常左值" << std::endl; } void processValue(Foo&&) { std::cout << "右值" << std::endl; } void processValue(const Foo&&) { std::cout << "常右值" << std::endl; } Foo& lvalue(void) { static Foo xx; return xx; } const Foo& clvalue(void) { static const Foo xx; return xx; } Foo rvalue(void) { return Foo(); } const Foo crvalue(void) { return Foo(); } template <typename T> void forwardValue(T& val) { processValue(val); } template <typename T> void forwardValue(const T& val) { processValue(val); } int main(void) { forwardValue(lvalue()); forwardValue(clvalue()); forwardValue(rvalue()); forwardValue(crvalue()); return 0; } |
看看输出结果:
1 2 3 4 |
左值 常左值 常左值 常左值 |
这是因为在C++98/03里,我们只能通过const引用来绑定右值,尽管右值对象可能不是一个常量。
问题二,代码冗余。
上面给的例子是一个参数的情况。那么两个参数呢?稍微想想就知道,两个参数需要4个同样的函数才能转发所有情况。那么三个参数呢……
这个方案最不可取的地方就在这里了:需要重写的转发函数个数是2的n次方,其中n等于参数个数。
一个解决方法是通过一层reference wrapper,在参数传递之前把它的属性(左右值、常量)包裹起来,直到传递完毕再打开这层wrapper,比如说,写一个简单的wrapper如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
template <typename T> struct fvalue { typedef T type_t; type_t content_; fvalue(type_t r) : content_(r) {} operator type_t () const { return content_; } }; template <typename T> struct fvalue<T&&> { typedef T&& type_t; type_t content_; fvalue(type_t r) : content_(std::move(r)) {} operator type_t () const { return std::move(content_); } }; |
然后再给它加一些辅助函数:
1 2 3 4 5 6 7 |
template <typename T> inline fvalue<T&> pass(T& r) { return fvalue<T&> (r); } template <typename T> inline fvalue<const T&> pass(const T& r) { return fvalue<const T&> (r); } template <typename T> inline fvalue<T&&> pass(T&& r) { return fvalue<T&&> (std::move(r)); } template <typename T> inline fvalue<const T&&> pass(const T&& r) { return fvalue<const T&&>(std::move(r)); } template <typename T> inline T forward(fvalue<T>& r) { return r; } template <typename T> inline T& forward(T& r) { return r; } |
最后在需要转发的时候,这样使用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
template <typename T> void forwardValue(T val) { processValue(forward(val)); } int main(void) { forwardValue(pass(lvalue())); forwardValue(pass(clvalue())); forwardValue(pass(rvalue())); forwardValue(pass(crvalue())); return 0; } |
输出结果:
1 2 3 4 |
左值 常左值 右值 常右值 |
为了能够存储参数的左右值属性,上面的方案里使用了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的引用折叠规则:
1 2 3 4 |
T& + & -> T& (左值 + & -> 左值) T&& + & -> T& (右值 + & -> 左值) T& + && -> T& (左值 + && -> 左值) T&& + && -> T&&(右值 + && -> 右值) |
上面这个表,加号前面的T&、T&&代表实参的左右值属性,加号后面的&、&&代表形参的类型。
比如,第一行就表示,当实参为一个左值(T&),在传递给参数为T&类型的形参的时候,泛型函数里的T&将被推导为T&(左值)。
那么上面的表里,前两行正好对应了现有T&的推导方式。而后两行,则是T&&的推导方式。从表里可以看出来,不论实参的左右值属性是什么类型(T&、T&&),经过引用折叠规则的推导,T&&形参类型都将正确的保持住原先的属性。
但是T&&形参类型保持住了这些属性,并不代表形参本身也会保持住这些属性。
让我们在前面测试代码的基础上写一个例子来看一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
template <typename T> struct type_check; template <typename T> struct type_check<T&> { type_check() { std::cout << "左值 -> "; } }; template <typename T> struct type_check<const T&> { type_check() { std::cout << "常左值 -> "; } }; template <typename T> struct type_check<T&&> { type_check() { std::cout << "右值 -> "; } }; template <typename T> struct type_check<const T&&> { type_check() { std::cout << "常右值 -> "; } }; template <typename T> void forwardValue(T&& val) { type_check<T&&>(); processValue(val); } int main(void) { forwardValue(lvalue()); forwardValue(clvalue()); forwardValue(rvalue()); forwardValue(crvalue()); return 0; } |
输出结果:
1 2 3 4 |
左值 -> 左值 常左值 -> 常左值 右值 -> 左值 常右值 -> 常左值 |
由于具名的右值引用其实是一个左值,因此T&&类型的形参本身,将会丢失左值属性。为了能够还原它原本的属性,我们需要做一下类型转换:
1 2 3 4 5 6 |
template <typename T> void forwardValue(T&& val) { type_check<T&&>(); processValue(static_cast<T&&>(val)); } |
这样就能够得到正确的结果:
1 2 3 4 |
左值 -> 左值 常左值 -> 常左值 右值 -> 右值 常右值 -> 常右值 |
在C++11中,std命名空间里提供了一个forward函数用来实现最后一步的属性还原:
1 2 3 4 5 |
template <typename T> void forwardValue(T&& val) { processValue(std::forward<T>(val)); } |
我们可以稍微思考一下它是如何实现的。
首先,参数val的类型肯定是一个T&(因为具名右值引用是一个左值),因此forward的参数可以是T&。
然后,结合上面的引用折叠推导规则,只有T&&类型才能保持住所有的属性,因此forward的返回值是T&&。
这样可以很快的写出一个简单的forward函数:
1 2 3 4 5 |
template<typename T> T&& forward(T& a) { return static_cast<T&&>(a); } |
那么这样使用是否正确呢?
1 2 3 4 5 6 |
template <typename T> void forwardValue(T&& val) { type_check<T&&>(); processValue(forward(val)); } |
看看结果:
1 2 3 4 |
左值 -> 右值 常左值 -> 常右值 右值 -> 右值 常右值 -> 常右值 |
很明显,错了。原因在于val始终是一个左值,因此forward自动推导出来的T将是非引用类型,T&&将始终都是一个右值引用。
所以,正确的使用方法是这样:
1 2 3 4 5 6 |
template <typename T> void forwardValue(T&& val) { type_check<T&&>(); processValue(forward<T>(val)); } |
因为若T&&保存了实参的左右值属性,那么根据引用折叠规则倒推(根据“左值 + && -> 左值”可以得到“左值 - && -> 左值”),T&&为左值引用的时候,T将也是一个左值引用。而当T&&为右值引用的时候,T将是非引用类型。所以forward
为了禁止forward的自动推导,可以写成这样:
1 2 3 4 5 |
template<typename T> T&& forward(typename std::identity<T>::type& a) { return static_cast<T&&>(a); } |
当然了,这只是简单的forward实现方式。实际中还要考虑很多其他情况,因此std的forward实现会比这个要复杂。
参考文章:
Published by orzz.org(). (http://orzz.org/cxx-perfect-forwarding/)