条款30.熟悉完美转发的失败情形

熟悉完美转发的失败情形

完美转发的确切含义:“转发”的含义不过是一个函数把自己的形参传递(转发)给另一个函数而已。其目的是为了让第二个函数(转发目的函数)接受第一个函数(转发发起函数)所接受的同一对象。这就排除了按值传递形参,因为它们只是原始调用者所传递之物的副本,我们想要转发目的函数能够处理原始传入对象。指针形参也只能出局,因为我们不想强迫调用者传递指针。论及一般意义上的转发时,都是在处理形参为引用类型的情形

完美转发的含义是我们不仅转发对象,还转发显著特征:类型,是左值还有右值,以及是否带有constvolatile饰词等。我们会运用万能引用,因为只有万能引用形参才会将传入的实参是左值还是右值这一信息加以编码

假设有某函数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行为不一致。这种分裂行为的源泉之一,可能在于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);

我们可以传递processValf

但是有一点要注意,f要求一个函数指针,但是processVal不是一个函数指针或者一个函数,它是两个同名的函数。但是,编译器可以知道它需要哪个:通过参数类型和数量来匹配。因此选择了一个int参数的processVal地址传递给f

工作的基本机制是让f的声明式使得编译器弄清楚了哪个版本的processVal是所要求的。fwd就不行了,因为作为一个函数模板,它没有任何关于类型需求的信息,这也使得编译器不可能决议应该传递哪个函数重载版本

fwd(processVal);		//错误,哪个processVal重载版本

光秃秃的processVal没有类型,类型推导无从谈起,完美转发失败。

同样的问题会发生在如果我们试图使用函数模板代替重载的函数名。一个函数模板是未实例化的函数,表示一个函数族。

template<typename T>
T workOnVal(T param) { ... }	//处理值的模板

fwd(workOnVal);		//错误!workOnVal的哪个实例。

欲让像fwd这种实施完美转发的函数接受重载函数名字或者模板名字,只有手动指定需要转发的那个重载版本或者实例。例如,可以创建一个与f的形参同一类型的函数指针,然后用processValworkOnVal初始化那个指针(这可以使得适当的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);	//没问题

但是如果经由fwdh.totalLength转发给f,就是另一回事了。

fwd(h.totalLength);	//错误

问题在于fwd的参数是引用,而h.totalLength是非常量位域。听起来并不是那么糟糕。但是C++标准库谴责这种组合:const引用不得绑定到位域。这条禁令有及其充分的理由,位域是由机器字的若干任意部分组成的,但是这样的实体是不可能有办法对其直接取地址的。在硬件层次,引用和指针是同一事物。这么一来,既然没有办法创建指向任意比特的指针,那自然也就没有办法把引用绑定到任意比特了。

要将完美转发位域的不可能变成可能,也简单不过。一旦你意识到接受位域实参的任何函数都实际上只会接收到位域值的副本。毕竟,没有函数可以把位域绑定到引用,也不可能有函数接受指向位域的指针,因为根本不存在指向位域的指针。可以传递位域的仅有的形参种类就是按值传递以及常量引用

在按值传递的形参这种情况下,被调用的函数显然收到的是位域内的值的副本,而在常量引用新参这种情况下,标准要求这时引用实际绑定到存储在某种标准整形中的位域值的副本。常量引用不可能绑定到位域,它们绑定到的是”常规“对象,其中复制了位域的值。

这么一来,把位域传递给完美转发函数的关键,就是利用转发目的函数接受的总是位域值的副本这一事实。你可以自己制作一个副本,并以该副本调用转发函数。

auto length = static_cast<std::uint_16_t>(h.totalLength);
fwd(length);		//转发该副本

要点速记

  • 完美转发的失败情形,是源于模板类型推导失败,或推导结果是错误的类型
  • 会导致完美转发失败的实参有大括号初始化物,以值0或NULL表达的空指针,仅有声明的整型static const成员变量,模板或重载的函数名字,以及位域
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值