完美转发能够优化函数调用过程中参数传递的效率。本文一部分翻译这篇文章和这篇文章,略加重组并加上个人理解;另一部分介绍了emplace如何实现容器内对象的原地构造。
问题 (1)
我们想写这样一个工厂模板函数factory,使用参数Arg类型的参数arg,构造一个T的对象,并返回它的shared_ptr。我们的理想是**factory就像不存在一样**:
- 把
arg传给factory函数时,没有引入额外的拷贝;就像调用者直接使用arg构造T的对象一样; - 把
arg传给T的构造函数时,要保留arg的左值右值特征;也就是说,假如arg是右值并且T定义了移动构造函数,则构造T对象时要匹配移动构造。当然,如果arg是左值,或者T没有定义移动构造,可能还是需要拷贝。但这和直接构造T对象的行为是一样的,我们已经做到了factory就像不存在一样。
这也是完美的含义。
尝试1 (1.1)
1 | template<typename T, typename Arg> |
显然,这是一个失败的尝试:
- 把
arg传给factory函数时引入了额外的拷贝:调用factory的时候,arg是值传递的,需要实参到形参的拷贝; - 若
T的构造函数的参数是左值引用类型,就更糟糕:它引用了一个实参(factory函数结束后,实参就消亡了);
尝试2 (1.2)
1 | template<typename T, typename Arg> |
factory的参数是个引用,因此额外的拷贝是没有了。但是arg是一个左值引用,它不能匹配右值,这样的调用无法通过编译:
1 | factory<X>(hoo()); // 若hoo返回的不是引用而是值,则找不到匹配的函数,出错 |
尝试3 (1.3)
同时提供factory(Arg&)和factory(Arg const &):
1 | template<typename T, typename Arg> |
这样,实参若是非const的左值则匹配前者;若是const的左值或者右值则匹配后者;额外拷贝的完全避免了。但是,还有两个问题:
- 即使实参是右值,匹配后者,
arg引用的是右值,new T(arg)也无法匹配T的移动构造函数; - 若factory有n个参数,每个参数都有const引用和非const引用两种类型,组合起来就需要2^n个重载;
解决方案 (2)
回顾一下我们的理想:这层工厂函数就像不存在一样:
- 没有额外的拷贝;就像调用者直接使用
arg构造T的对象一样; - 若
arg是右值,则需要保留其右值的特征(匹配T的移动构造函数————若有);
这要求我们:
- 必须按引用传递;且左值和右值必须都能引用;
- 若引用右值,必须保留右值的特征;
第1.3节中使用的const的左值引用(Arg const&或与之等价的const Arg&)可以满足第1点。但如前所述,无法满足第2点。容易想出,通用引用(见C++11中的通用引用)能够同时满足这两点。
首先这个模板函数声明应该是这样的:
1 | template<typename T, typename Arg> |
因为arg是一个通用引用,匹配左值时它是左值引用,匹配右值时它是右值引用。乍一看,认为这样可以了:
1 | template<typename T, typename Arg> |
其实,这不行。看过C++11中的右值引用和C++11中的通用引用就会知道,无论右值引用类型的变量还是通用引用类型的变量,都是左值,因为它们有名字。这里名字为arg的变量(函数参数),当然是左值。那么new T(arg)无论如何也不能匹配移动构造函数。我们想要的是:
- 当左值
arg引用的是左值时,把被引用的左值传给new T(); - 当左值
arg引用的是右值时,把被引用的右值传给new T();由于arg本身是左值,我们需要使用static_cast把它强制为右值;
C++11提供的std::forward(),使用一行代码,就给我们实现了。它的玄妙之处在于类型推导和引用折叠(见C++11中的通用引用)。
std::forward (2.1)
1 | template<class S> |
还是通过factory例子来看它是如何处理左值和右值的吧。有了它,我们的factory函数可以这样写:
1 | template<typename T, typename Arg> |
** forwad左值:**
1 | X x; |
根据类型推导规则(见C++11中的通用引用),函数模板factory的类型参数Arg的推导结果是X&,那么factory展开为:
1 | shared_ptr<A> factory(X& && arg) |
用Arg(X&)替换std::forward类型参数S:
1 | X& && forward(remove_reference<X&>::type& a) noexcept |
经过remove_reference和引用折叠(见C++11中的通用引用),最终结果是:
1 | shared_ptr<A> factory(X& arg) |
可以看出:当左值arg引用的是左值时,传递给new T()的是被引用的左值(被引用的左值再经过static_cast<X&>得到的还是左值引用)。
** forwad右值:**
1 | X foo(); |
根据类型推导规则(见C++11中的通用引用),函数模板factory的类型参数Arg的推导结果是X,那么factory展开为:
1 | shared_ptr<A> factory(X&& arg) |
这种情况下,factory不需要引用折叠。然后,用Arg(X)替换std::forward类型参数S:
1 | X&& forward(typename remove_reference<X>::type& a) noexcept |
经过remove_reference,forward简化成这样:
1 | X&& forward(X& a) noexcept |
可以看出:当左值arg引用的是右值时,传递给new T()的是被引用的右值(static_cast<X&&>把arg强制为右值,因为没有名字)。
完美转发的两步:
可见,完美转发中,有两个关键步骤:
- 使用通用引用来接收参数;这样,左值右值都能接收;
- 使用std::forward()转发给被调函数;这样,左值作为左值传递,右值作为右值传递;
std::forward和std::move比较 (2.2)
对比C++11中的通用引用中std::move的工作原理一节,我们发现,std::forward和std::move还是有一些相似之处的:
- 它们都使用static_cast()来完成任务;
- 它们都使用通用引用(及背后的类型推导和引用折叠机制);
所不同的是:
std::forward参数是左值引用,返回的是通用引用;std::move参数是通用引用,返回的是右值引用;
这很好理解:
std::forward拿到的是一个左值(所以参数是左值引用);然后看这个左值是左值引用类型还是右值引用类型,若为前者则返回左值,若为后者则返回右值(所以返回值是通用引用);std::move拿到的可能是左值也可能是右值(所以参数是通用引用),但一定返回右值(所以返回值是右值引用);
emplace (3)
push_back的右值引用版本 (3.1)
把一个对象加入容器(以vector为例),我们常用push_back。在C++11以前,只有这样一个版本:
1 | void push_back (const value_type& val); |
从C++11开始,有了右值引用,又增加了一个版本:
1 | void push_back (value_type&& val); |
代码:
1 | std::vecotr<Foo> v; |
- C++11之前,需要一次完整拷贝:把
push_back的参数拷贝到vector内部。注意,临时对象Foo("123")到push_back的参数val是引用传递的(const value_type&可以引用右值),不需拷贝。 - C++11开始,需要一次移动拷贝:把
push_back的参数移动拷贝到vector内部。
看来问题有了改善,因为移动构拷贝比完整拷贝代价要小。但,emplace效果更好。
emplace实现原地构造 (3.2)
从功能上看,emplace和我们前文factory工厂函数十分相似:给它参数,它给你构造对象,利用完美转发,中间不增加额外的参数的拷贝,就像你直接构造对象一样。但更重要的一点是,它在容器内部原地构造。总结来说:
- 利用完美转发,没有任何参数的拷贝;
- 在容器内部原地构造,也不会有对象的拷贝;
以std::vector的emplace_back为例,它的声明如下:
1 | template< class... Args > |
和前文factory不同的是,emplace_back的参数个数是变化的,不过没关系,把它理解成N个通用引用就行了。
1 | class Bar |
这段代码只有3个构造:X(),Y()和Bar(X&&, Y&&)。临时对象X()和Y()被完美转发到Bar的构造函数,而Bar的构造函数在vector内原地构造。
问:emplace_back的N个参数是什么?
答:是vector::value_type的构造函数的参数列表,在上例中,就是Bar::Bar()的参数列表。注意,你不要去调vector::value_type的构造函数,而只需要传入参数列表即可,emplace_back会帮我们调用。见下文emplace的错误用法。
emplace的错误用法 (3.3)
如前所述,我们应该把vector::value_type的构造函数的参数列表传给emplace_back,让它帮我们调用vector::value_type的构造函数。一个常见的错误是,传入一个vector::value_type类的对象。更糟糕的是,你若这么干了,并不会引起编译错误。因为,你传入vector::value_type类的对象,emplace_back就拿着这个对象去调用构造函数————当然,是拷贝构造函数。你看,这就引入了一次额外的拷贝,没有达到原地构造的效果。例如:
1 | vector<Bar> v; |
这段代码中,除了原有的3个构造(X(),Y()和Bar(X&&, Y&&)),还多了一个拷贝:Bar(Bar&&)。当然,我们传入emplace_back的是vector::value_type(本例中是Bar)的临时对象(右值),多出的这个拷贝是移动拷贝构造。若传入的是Bar类型的左值,多出拷贝将是完整拷贝。
小结 (4)
完美转发一方面通过通用引用接收参数,另一方面使用std::forward转发给后续函数,并保留左值/右值特征,就像没有本层传递一样。利用完美转发,emplace可以实现容器内对象的原地构造。