30 Familiarize yourself with perfect forwarding failure cases
这一小节会讨论几种完美转发失败的情况。
在开始之前,我们首先讨论下完美转发的含义。转发的意思是说将传入一个函数的参数传入——转发——给另一个函数,目的是第二个函数收到与第一个函数收到的对象相同。这不适用于按值传递,因为对象是拷贝进来的。我们希望转发到的函数基于原始传入的对象执行。这也不适用于指针类型,因为我们不想强迫调用者一定要传递指针。那么通常情况下转发指的是转发引用参数。
完美转发意味着我们不仅转发对象,也转发它们的特征:它们的类型,是左值还是右值,是 const
或是 volatile
。结合我们需要转发引用,那么需要使用通用引用,因为只有通用引用才能在传递参数时确定是左值还是右值。
假定我们有一个函数 f
,我们下面实现一个模板函数转发参数给它。
template <typename T>
void fwd(T &¶m) // accept any argument
{
f(std::forward<T>(param)); // forward it to f
}
template <typename... Ts>
void fwd(Ts &&...params) // accept any arguments
{
f(std::forward<Ts>(params)...); // forward them to f
}
emplace
函数(见 Item 42)和智能指针的 std::make_shared
和 std::make_unique
。
在给定 f
和转发函数 fwd
的前提下,完美转发失败指的是给定参数 f
所作的事情与 fwd
转发相同参数时做的事情不同。
f(expression); // if this does one thing,
fwd(expression); // but this does something else, fwd fails
// to perfectly forward expression to f
Braced initializers
假定 f
声明如下
fdw
就无法编译。
这就是大括号初始化导致完美转发失败的例子。
所有这样的失败的原因都是一样的。当直接调用 f
的时候,比如 f({1, 2, 3})
,编译器会查看函数声明的参数类型和传递的参数类型,判断是否匹配,必要时会进行隐式转换。{1, 2, 3}
生成了一个临时的 std::vector<int>
使得 f
的参数 v
能够绑定到 std::vector<int>
对象上。
当通过 fwd
间接调用 f
的时候,编译器不再检查传递的参数与 f
声明类型的匹配度。而是推导传入 fwd
的参数类型,拿推导的参数类型与 f
声明的参数类型比较。当以下情况发生的时候,完美转发就会失败。
- 编译器无法推导出
fwd
的一个或多个参数类型。这种情况下会编译失败。 - 编译器推导错了
fwd
的一个或多个参数类型。错误意味着无法使用推导出来的类型编译fwd
,也可能意味着使用fwd
的推导类型调用f
,会导致与直接使用相同参数调用f
的行为不一致。这可能是由于f
有多个重载,而推导类型调用的与直接调用的f
函数不同。
上面的例子中,错误的原因是将大括号初始化传递给了一个没有声明 std::initializer_list
为参数的模板函数。这是一个非推导上下文。也就是说,由于模板函数的参数不是 std::initializer_list
,那么编译器被禁止从 {1, 2, 3}
推导类型。由于被阻止推导类型,那么编译器只能报错处理了。
Item 2 告诉我们能够正确推导使用大括号初始化的 auto
变量。这个变量被视为 std::initializer_list
对象,而我们转发就是需要这么一个 std::initializer_list
类型。因此一个简单的变通方式就是先使用 auto
声明一个变量,然后调用 fwd
函数。
auto il = {1, 2, 3}; // il's type deduced to be std::initializer_list<int>
fwd(il); // fine, perfect-forwards il to f
0 or NULL as null pointers
Item 8 告诉我们如果使用 0 或者 NULL
作为空指针传入模板函数,会导致推导出错误的类型,期望是指针类型,但是实际是整数类型。这种情况下,不能期待会完美转发一个空指针。解决方案也很简单,传入 nullptr
。
Declaration-only integral static const data members
一般而言,没有必要定义 static const
的成员变量,因为编译器会进行常量传播(const propagation
),消除了需要内存存放的需求。
class Widget
{
public:
static const std::size_t MinVals = 28; // MinVals' declaration no defn. for MinVals
};
std::vector<int> widgetData;
widgetData.reserve(Widget::MinVals); // use of MinVals
Widget::MinVals
没有定义,但仍可以使用它来初始化 widgetData
的容量。编译器会使用 28 替换所有使用 Widget::MinVals
的地方。所以没有内存存储这个值也是可行的。但是如果需要取地址,比如有某个指针指向这个变量,那么上述代码虽然能编译通过,但是链接的时候会报错。
考虑之前的 f
与 fwd
函数,假定 f
的声明如下
f
是合法的
但是无法通过 fwd
调用 f
链接的时候会出错,原因与之前解释一样。
尽管这里并没有取 MinVals
的地址,但是 fwd
的参数是通用引用,而引用在编译器看来和指针一样。在某种程度上,引用就是自动解引用的指针。传递 MinVals
的引用与传递指针一样高效,但是也要求有某个内存地址,使得指针能够指向这个位置。通过引用传递 static const
的成员变量,一般也就要求其有定义,否则会导致完美转发失败。
根据标准,传递 static const
整数的引用,要求有定义。但是并不是所有的实现都强制要求这一点。所以,具体问题可能依赖于编译器和链接器。你或许发现没有定义也能完美转发 static const
的整型成员变量,但是这并不是能这么做的理由,因为这涉及可移植性。为了提高可移植性,应该给 static const
变量一个定义。对于 MinVals
,定义如下
Overloaded function names and template names
假定函数 f
接受一个函数作为参数,以自定义行为。假定这个函数参数与返回值类型都是 int
,那么 f
的声明如下
processVal
函数
我们可以将 processVal
传递给 f
函数
编译器知道需要哪一个 processVal
,即匹配 f
参数的那一个。于是乎,将接受一个 int
参数的 processVal
函数地址传递给 f
。
但是 fwd
是一个模板函数,并不包含任何关于需要什么类型的参数的信息,那么编译器就无法知道该使用哪一个重载。
processVal
是没有类型的,那么无法进行类型推导,完美转发失败。
如果我们使用模板函数而不是重载,也会有同样的问题。一个模板函数不是一个函数,而是需要函数
template <typename T>
T workOnVal(T param) // template for processing values
{
}
fwd(workOnVal); // error! which workOnVal instantiation?
f
参数相同的函数指针类型,使用 processVal
或者 workOnVal
初始化,这就选择了合适的重载或模板实例,然后将指针传递给 fwd
using ProcessFuncType = int (*)(int); // make typedef; see Item 9
// specify needed signature for processVal
ProcessFuncType processValPtr = processVal;
fwd(processValPtr); // fine
fwd(static_cast<ProcessFuncType>(workOnVal)); // also fine
Bitfields
最后一种无法完美转发的情况是使用了位域作为函数的参数。假定 IPv4 头的定义如下
假定f
的参数是 std::size_t
,我们传入 IPv4Header
的 totalLength
来调用 f
函数。
但是无法以同样的方式调用 fwd
函数
原因是 fwd
的参数是引用,而 h.totalLength
是非 const
位域。C++ 标准禁止这么做:非 const
引用不能绑定到位域。原因很简单,因为位域可以从任意比特开始,而指针或引用不能指向任意比特,C++ 寻址最小单元是 char
,也就是一个字节而不是比特。
如果意识到接受位域作为参数的函数,函数收到的一定是拷贝的话,那么这么问题就很容易解决了。如前所述,没有指针或引用能够指向位域,那么函数不能是指针参数或者引用参数。所以结果只能是按值传递,或者是 const
引用。按值传递的话函数收到的是位域内容的拷贝,如果参数是 const
引用,标准是要求引用绑定到存放位域的的整数类型(比如 int
)的拷贝对象上。因此,并不是绑定到位域而是包含位域的对象的拷贝。
解决这个问题的关键就是函数接受的是拷贝。在完美转发之前,我们可以先行拷贝一遍。在当前 IPv4Header
例子中,代码如下
// copy bitfield value; see Item 6 for info on init. form
auto length = static_cast<std::uint16_t>(h.totalLength);
fwd(length); // forward the copy
Upshot
大部分时候,完美转发都工作的很好,偶尔会出错。可能是编译错误,更严重的参数是能编译但是不能按照预期行为工作。知道完美转发不完美的情况是很重要的,还需要知道如何绕过这些问题。
Things to Remember
- Perfect forwarding fails when template type deduction fails or when it deduces the wrong type.
- The kinds of arguments that lead to perfect forwarding failure are braced initializers, null pointers expressed as
0
orNULL
, declaration-only integralconst static
data members, template and overloaded function names, and bitfields.