完美转发含义
“转发”的含义不过是一个函数把自己的形参传递(转发)给另一个函数而已。其目的是为了让第二个函数(转发目的函数)接受第一个函数(转发发起函数)所接受的同一对象。
这就排除了按值传递形参,因为它们只是原始调用者所传递之物的副本。
我们想要转发目的函数能够处理原始传入对象。指针形参也只能出局,因为我们不想强迫调用者传递指针。
所以我们讨论一般意义上的转发时,都是在处理形参为引用类型的情形。
完美转发的含义是我们不仅转发对象,还转发显著特征,例如类型、左右值、以及是否带有const
或volatile
饰词等。我们会运用万能引用,因为只有万能引用形参才会将传入的实参是左值还是右值这一信息加以编码。
假设有某函数f
,然后我们打算撰写一个函数(其实是函数模板)将f
作为转发目标。欲达成此目标,我们需要核心代码:
template<typename T>
void fwd(T&& param) //接受任意实参
{
f(std::forward<T>(param)); //转发该实参到f
}
转发函数,天然就应该是泛型的。fwd
函数就是个例子,它接受任意类型的实参,然后无论接受了什么都加以转发。对于这样的泛型,一种符合逻辑的拓展就是使得转发函数不只是模板,而且是可变长形参模板,从而能够接受任意数量的实参。
可变长形参的fwd
长这样。
template<typename... Ts>
void fwd(Ts&&... params) //接受任意实参
{
f(std::forwar<Ts>(params)...); //转发所有实参到f
}
给定目标函数f
和转发函数fwd
,当以某特定实参调用f
会执行某操作,而用同一实参调用fwd
时会执行不同的操作,则称完美转发失败。
f(expression); //如果本语句执行了某操作
fwd(expression); //而本语句执行了不同的操作,则称fwd完美转发expression到f失败
有若干种实参会导致该失败。重要之处在于知道这几种实参是什么,以及如何绕过它们。
大括号初始化物
假设f
()函数的声明如下:
void f(const std::vector<int>& v);
在此情况下,以大括号初始化物调用f
可以通过编译:
f({ 1,2,3 }); // {1,2,3}会隐式转换为std::vector<int>
但如果把同一大括号初始化物传递给fwd
则无法通过编译。
fwd({ 1,2,3 }); //错误,无法通过编译
大括号初始化物的使用,就是一种完美转发失败的情形。
凡是归类于此的失败,原因都一模一样。在对f
的直接调用中(如f({1,2,3})
),编译器先领受了调用段的实参类型,又领受了f
所声明的形参类型。编译器会比较这两个类型来确定它们是否兼容,而后,如有必要,会实施隐式类型转换来使得调用得以成功。在上面的例子中,编译器从{1,2,3}
出发生成了一个临时的std::vector<int>
类型对象,从而f
的形参就有了一个std::vector<int>
对象得以绑定。
而经由转发函数模板fwd
来对f
实施间接调用时,编译器就不再会比较fwd
的调用处传入的实参和f
所声明的形参了。取而代之的是,编译器会采用推导的手法来取得传递给fwd
实参的类型结果,然后它会比较推导类型结果和f
声明的形参类型。
完美转发会在下面两个条件中的任何一个成立时失败。
- 编译器无法为一个或多个
fwd
的形参推导出类型结果。在此情况下,代码无法编译通过 - 编译器为一个或多个
fwd
的形参推导出了“错误的”类型结果。这里所谓“错误的”,可以既指fwd
根据类型推导结果的实例化无法通过编译,也可以指以fwd
推导而得的类型调用f
与直接以传递给fwd
的实参调用f
行为不一致,根据“不正确”的推导类型,fwd
里调用到的f
重载版本,就与直接调用f
的版本不一样。
在上述fwd({1,2,3})
这句调用中,问题在于向未声明为std::initializer_list
类型的函数模板形参传递了大括号初始化物。由于fwd
的形参未声明为std::initializer_list
,编译器就会被禁止在fwd
的调用过程中从表达式{1,2,3}
出发来推导类型,而既然从fwd
的形参出发进行推导是被阻止的行为,所以,编译器拒绝这个调用也合情合理。
有意思的事情来了,auto
变量在以大括号初始化物完成初始化时,类型推导可以成功。这样的变量会被视为std::initializer_list
类型对象,这么一来,如果转发函数的形参的推导类型结果应为std::initializer_list
的话,就有了一个简单易行的绕行手法——先用auto
声明一个局部变量,然后将该局部变量传递给转发函数:
auto il = { 1,2,3 }; //il的类型推导结果为
//std::initializer_list<int>
fwd(il); //没问题,将il完美转发给f
0和NULL用作空指针
若把0和NULL以空指针之名传递给模板,类型推导就会发生行为扭曲,推导结果会是整型(一般情况下会是int
)而非所传递实参的指针类型。结论就是:0和NULL都不能用作空指针以进行完美转发。
修正方案也很简单:传递nullptr
,而非0或NULL。
仅有声明的整型static const成员变量
不需要给出类中的整型static const
成员变量的定义,而仅仅是声明它,因为编译器会根据这些成员的值实施常数传播,从而就不必再为它们保留内存。
举个例子,考虑下面这段代码
class Widget{
public:
static const std::size_t MinVals = 28; // 给出了MinVals的声明
...
};
... // 未给出MinVals定义
std::vector<int> widgetData;
widgetData.reserve(Widget::MinVals); // 此处用到了MinVals
在这里,尽管Widget::MinVals
并无定义,我们还是利用了MinVals
来指定widgetData
的初始容量。编译器绕过了MinVals
缺少定义的实施,手法是把值28塞到所有提及MinVals
之处。
未为MinVals
的值保留存储这一事实并不会带来问题。但如果要使用MinVals
的地址(例如,有人创建了MinVals
的指针),则MinVals
需要存储,尽管上面的代码仍然可以编译,但是链接时就会报错,直到为MinVals
提供定义。
按照这个思路,想象下f
(转发参数给fwd
的函数)这样声明:
void f(std::size_t val);
使用MinVals
调用f
是可以的,因为编译器直接将值28代替MinVals
;
f(Widget::Minals); //没问题
同样的,如果尝试通过fwd
来调用f
fwd(Widget::MinVals); //错误,无法链接
代码可以编译,但是不能链接。就像使用MinVals
地址表现一样。
尽管源代码看上去并没有对MinVals
实施取地址,但注意到fwd
的形参是个万能引用,而引用这个东西,在编译器生成的机器代码中,通常是当作指针处理的。程序的二进制代码中(从硬件视角来看),指针和引用在本质上是同一事务。在此层次,引用只不过是会解引用的指针罢了。既然如此,通过引用传递MinVals
实际上与通过指针传递MinVals
是一样的,因此,必须有内存使得指针可以指向。通过引用传递整型static const
数据成员,必须定义它们,这个要求可能会造成完美转发失败,未使用完美转发的代码却能成功。
重载的函数名字和模板名字
假定f
(通过fwd
完美转发参数给f
)可以通过向其传递执行某些功能的函数来定义其行为。假设这个函数参数和返回值都是整数,f
声明就像这样。
void f(int (*pf)(int));
值得注意的是,也可以使用更简单的非指针语法声明。这种声明就像这样,含义与上面是一样的。
void f(int pf(int));
无论哪种写法,我们都有一个重载函数,processVal
int processVal(int value);
int processVal(int value, int priority);
我们可以传递processVal
给f
但是有一点要注意,f
要求一个函数指针,但是processVal
不是一个函数指针或者一个函数,它是两个同名的函数。但是,编译器可以知道它需要哪个:通过参数类型和数量来匹配。因此选择了一个int
参数的processVal
地址传递给f
。
工作的基本机制是让f
的声明式使得编译器弄清楚了哪个版本的processVal
是所要求的。fwd
就不行了,因为作为一个函数模板,它没有任何关于类型需求的信息,这也使得编译器不可能决议应该传递哪个函数重载版本。
fwd(processVal); //错误,哪个processVal重载版本
光秃秃的processVal
没有类型,类型推导无从谈起,完美转发失败。
同样的问题会发生在如果我们试图使用函数模板代替重载的函数名。一个函数模板是未实例化的函数,表示一个函数族。
template<typename T>
T workOnVal(T param) { ... } //处理值的模板
fwd(workOnVal); //错误!workOnVal的哪个实例。
欲让像fwd
这种实施完美转发的函数接受重载函数名字或者模板名字,只有手动指定需要转发的那个重载版本或者实例。例如,可以创建一个与f
的形参同一类型的函数指针,然后用processVal
和workOnVal
初始化那个指针(这可以使得适当的processVal
重载版本得以选择或适当的workOnVal
得以生成)。再然后将指针传递给fwd
:
using ProcessFuncType = int (*)(int);
ProcessFuncType processValPtr = processVal;
fwd(processValPtr); //没问题
fwd(static_cast<ProcessFuncType>(workOnVal)); //没问题
位域
最后一种完美转发失败情形,是位域被用作函数实参。
为考察在实践中如何表现,观察如下这个可以表示IPV4头部的模型
struct IPv4Header{
std:;uint32_t version:4,
IHL:4,
DSCP:6,
ECN:2,
totalLength:16
...
};
如果函数f
的声明式中接受std::size_t
类型的形参,然后用IPv4Header
对象的totalLength
字段进行调用,这样没有问题
void f(std::size_t sz);
IPv4Header h;
...
f(h.totalLength); //没问题
但是如果经由fwd
把h.totalLength
转发给f
,就是另一回事了。
fwd(h.totalLength); //错误
问题在于fwd
的参数是引用,而h.totalLength
是非常量位域。听起来并不是那么糟糕。但是C++标准库谴责这种组合:非const
引用不得绑定到位域。这条禁令有及其充分的理由,位域是由机器字的若干任意部分组成的,但是这样的实体是不可能有办法对其直接取地址的。在硬件层次,引用和指针是同一事物。这么一来,既然没有办法创建指向任意比特的指针,那自然也就没有办法把引用绑定到任意比特了。
要将完美转发位域的不可能变成可能,也简单不过。一旦你意识到接受位域实参的任何函数都实际上只会接收到位域值的副本。毕竟,没有函数可以把位域绑定到引用,也不可能有函数接受指向位域的指针,因为根本不存在指向位域的指针。可以传递位域的仅有的形参种类就是按值传递以及常量引用。
在按值传递的形参这种情况下,被调用的函数显然收到的是位域内的值的副本,而在常量引用新参这种情况下,标准要求这时引用实际绑定到存储在某种标准整形中的位域值的副本。常量引用不可能绑定到位域,它们绑定到的是”常规“对象,其中复制了位域的值。
这么一来,把位域传递给完美转发函数的关键,就是利用转发目的函数接受的总是位域值的副本这一事实。你可以自己制作一个副本,并以该副本调用转发函数。
auto length = static_cast<std::uint_16_t>(h.totalLength);
fwd(length); //转发该副本