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

在C++11的宝箱上最引人注目的语言特性纹章之一,就是完美转发。完美转发,可是完美的哦!不过,揭开宝箱的表面,你才会发现会有这样的“完美”(理想版)和那样的“完美”(现实版)。C++11的完美转发相当不错,但如果一定要说达到了真正的完美之境,那就不能拘泥于若干“小节”。该条款就致力让你熟悉这些“小节”。

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

完美转发的含义是我们不仅转发对象,还转发其显著特征:型别、是左值还是右值,以及是否带有const 或 volation饰词等。结合前面的观察分析,即我们一般是会和引用形参打交道,这就是说,我们会运用万能引用,因为只有万能引用形参才会将传入的实参是左值还是右值这一信息加以编码。

假设有某函数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::forward<Ts>(params)...);  //转发所有实参到f
}

这种形式你可以在很多地方见到,包括标准容器的置入函数(参见条款42),以及智能指针的工厂函数std::make_shared和std::make_unique(参见条款21)。

给定目标函数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的形参出发进行推导时被阻止的行为,所以编译器拒绝这个调用也是合情合理的。

有意思的事情来了,条款2曾经说明过,auto变量在以大括号初始化物完成初始化时,型别推导可以成功,这样的变量会被视为std::initializer_list型别对象,这么一来,如果转发函数的形参的推导型别结果应为std::initializer_list的话,就有了一个简单易行的绕行手法——先用auto声明一个局部变量,然后将该局部变量传递给转发函数:

auto il = {1, 2, 3};  //il的型别推导结果为std::initializer_list<int>
fwd(il);  //没问题,将il完美转发给f

0和NULL用作空指针

条款8曾经说明过,若尝试把0和NULL以空指针之名传递给模板,型别推导就会发生行为扭曲,推导结果会是整型(一般情况下会是int)而非所传递实参的指针型别。结论就是,0和NULL都不能用作空指针以进行完美转发。不过,修正方案也颇为简单:传递nullptr,而非0或NULL。欲知详情,请参阅条款8。

仅有声明的整型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)并无定义,我们还是利用了MinVals来指定widgetData的初始容量。编译器绕过了MinVals缺少定义的事实(编译器的行为是这样规定的),手法是把值28塞到所有提及Minvals之处。未为MinVals的值保留存储这一事实并不会带来问题。如果产生了对Minvals实施取值的需求(例如,有人创建了一个指涉到MinVals的指针),MinVals就得要求存储方可(因此指针才能够指涉到它),然后上面这段代码虽然仍能够编译通过,但是如果不为MinVals提供定义,它在链接器就会遭遇失败。

记住了上述预备知识,然后想象f(fwd转发实参的目的函数)声明如下:

void f(std::size_t val);

以MinVals直接调用f没问题,因为编译器会用Minvals的值来代替它自己:

f(Widget::Minvals); //没问题,当“f(28)”处理

哎呀,如果想经由fwd来调用f,便会碰壁了:

fwd(Widget::MinVals);  //错误!应该无法链接

上面的代码能够通过编译,却不能完成链接。如果这能提醒你想起在对MinVals实施取地址所发生过的失败,这就对了,因为其底层原理相同。

尽管源代码看上去并没有对MinVals实施取址,但注意到fwd的形参是个万能引用,而引用这东西,在编译器生成的机器代码中,通常是当指针处理的。程序的二进制代码中(从硬件视角来看),指针和引用在本质上是同一事物。在此层次,有一句老话说得对:引用不过是会提领的指针罢了。既然如此,MinVals按引用传递和按指针传递结果也就没有什么区别了。基于同样的理由,也得准备某块内存以供指针去指涉。按引用传递整型static const成员变量通常要求其加以定义,而这个需求就会导致代码完美转发失败而等价的、未使用完美转发的代码却能成功。

你可能已经注意到,在先前的讨论中我在有些地方闪烁其词。代码"应该无法"链接,引用“通常”是当指针处理的,按引用传递整型static const成员变量通常要求其加以定义,仿佛我知道一些事情,但是不是很想一吐为快。

好吧,确有其事,依据标准,按引用传递MinVals时要求Minvals有定义,但并不是所有实现都服从了这个需求。因此,你可能会发现有时是能够完美转发未加定义的static const成员变量的,这取决于具体的编译器和链接器。如果真是这样,恭喜,不过没有理由期望这样的代码能够移植。若想添加可移植性,只需static const成员变量提供定义即可。对于MinVals,定义如下:

const std::size_t Widget::Minvals;   //在widget的.cpp文件中

注意,定义语句没有重复指定初始化物(对于本例中的Minvals,就是值28),不过,该细节不用死记硬背。如果忘记了这一点,在两处都提供了初始化物,你的编译器肯定会发出控诉,从而提醒只在一处指定即可。

重载的函数名字和模板名字

假设f(我们一直变着法子经由fwd转发各种东西的目标函数)想通过传入一个执行部分操作函数来自定义其行为。假定该函数接受并返回的型别int,那么f可以声明如下:

void f(int (*pf)(int));  //pf是"processing function"的简称

值得一提的是,f也可以使用平凡的非指针语法来声明。这样的声明长成下面这样,尽管它与上面的声明含义相同:

void f(int pf(int));    //声明与上面含义相同的f

无论哪种方式声明都可以吧,再假设又有重载函数processVal:

int processVal(int value);
int processVal(int value, int priority);

processVal就可以传递给f,

f(processVal);   //没问题

这样居然没问题,有点意外吧。f要求的实参是个指涉到函数的指针,可是processVal既非函数指针,甚至连函数都不是,它是两个不同函数的名字。无论如何,编译器还是知道它们需要的是哪个processVal:匹配f形参型别的那个。总之,编译器会选择接受一个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重载版本得以选择或适当的workOnValue实例得以生成),再后将指针传递给fwd:

using ProcessFuncType = int (*)(int);  //相当于创建一个typede:参见条款9
ProcessFuncType processValPtr = processVal;  //指定了需要的processVal签名
fwd(processValPtr);   //没问题
fwd(static_cast<ProcessFuncType>(workOnVal)); //也没问题

当然,这要求你知道fwd转发的函数指针型别到底应该是什么。完美转发函数一般来说不会在文档中写明这个信息。毕竟,完美转发函数是被设计用来接受任何型别的,但这么一来,没有文档告知你要传递的型别,那你又如何知道呢?

位域

最后一种完美转发失败情形,是位域被用作函数实参。为考察在实践中如何表现,观察如下这个可以表示IPv4头部的模型:

struct IPv4Header
{
    std::uint32_t version:4,
                  IHL:4,
                  DSCP:6,
                  ECN:2,
                  totalLength:16;
    ...
};

如果我们被虐了千百遍的函数f(转发函数fwd万年不变的目标)的声明式中接受std::size_t型别的形参,然后用IPv4Header对象的,比如说,totalLength字段来调用f吧,编译器会怪怪房型:

void f(std::size_t sz);    //待调用的函数
IPv4Header h;
...
f(h.totalLength);   //没问题

但是,如果是经由fwd把h.totalLength转发给f,就是另一回事了:

fwd(h.totalLength);   //错误!

问题在于fwd的形参是个引用,而h.totalLength是个非const的位域。乍听之下,这也没什么,但是C++标准却对于这么个组合以异乎寻常的口吻严加禁止:“非const引用不得绑定到位域”,该条禁令倒是有机器充分的理由。位域是机器字的若干任意部分组成的(例如,32位int的第3到第5个比特),但是这样的实体是不可能有办法对其直接取址的。我前面曾经提及,在硬件层次,引用和指针本是同一事物。这么一来,既然没有办法创建指涉到任意比特的指针(C++硬性规定,可能指涉的最小实体是单个char),那自然也就没办法把引用绑定到任意比特了。

要将完美转发位域的不可能化为可能,也简单不过。一旦你意识到接受位域实参的任何函数都实际上只会收到位域值的副本。毕竟,没有函数可以把位域绑定到引用,也不可能有函数接受指涉到位域的指针,因为根本不存在指涉到位域的指针。可以传递位域的仅有的形参种类就只有按值传递,以及,有点匪夷所思的常量引用。在按值传递的形参这种情况下,被调用的函数显然收到的是位域内的值的副本。常量引用不可能绑定到位域,它们绑定到的是“常规”对象,其中复制了位域的值。

这么一来,把位域传递给完美转发函数的关键,就是利用转发目的函数接受的总是位域值副本的这一事实。你可以自己制作一个副本,并以该副本调用转发函数,例如,在IPv4Header一例中,下述代码即演示了该技巧:

//复制位域值,初始化形式参见条款6
auto length = static_cast<std::uint16_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、付费专栏及课程。

余额充值