完美转发能够优化函数调用过程中参数传递的效率。本文一部分翻译这篇文章和这篇文章,略加重组并加上个人理解;另一部分介绍了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可以实现容器内对象的原地构造。