Effective Modern C++ Item 30 熟悉完美转发的失败情形

在C++11的中最引人瞩目的语言特性之一,就是完美转发。完美转发,可是完美的哟!不过,解开这完美的外表,你才会发现理想和现实有差距。C++11的完美转发相当不错,但如果一定要说达到了完美,那还是有一定差距的。本节主要介绍这些差距的地方。

转发的使用背景

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

转发想要达到的效果

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

假设某个函数f,然后我们打算写一个函数(其实是函数模板)将f作为转发目标。欲达此目的,我们需要核心代码如下:

template<typename T>
void fwd(T&& param)             //接受任意实参
{
    f(std::forward<T>(param));  //转发该实参到f
}

转发函数,天然就应该是泛型的。fwd模板就是个例子。它接受任意型别的实参,然后无论接受了什么都要加以转发。对于这样的泛型,一种符合逻辑的拓展就是,使得转发函数不只是模板,而且是可变长形参模板,从而能够接受任意数量的实参,可变长形参形式的fwd长成这样:

template<typename ...Ts>
void fwd(Ts&&... param)             //接受任意实参
{
    f(std::forward<Ts>(param)...);  //转发所有实参到f
}

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

什么叫完美转发失败

给定目标函数f和转发函数fwd,当以某特定实参调用f会执行操作,而用同一实参调用fwd会执行不同的操作,则称完美转发失败:

f(expression);      //如果本语句执行了某操作
fwd(expression);    //而本语句执行了不同的操作,
                    //则称fwd完美转发express到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::initalizer_list型别的函数模板形参传递了大括号初始化物,因为这样的语境按规定,用标准委员会的行话说,叫做“非推导语境”。通俗的说,这个词的意思是,由于fwd的形参未声明为std::initalizer_list,编译器就会被禁止在fwd的调用过程中从表达式{1, 2, 3}出发来推型别。而既然从fwd的形参出发进行推导是被阻止的行为,所以编译器拒绝这个调用也是合情合理的。

大括号初始物的绕行方法

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

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

0 和 NULL用做空指针

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

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

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初始化那个指针(这可以使得适当的prcessVal重载版本的以选择或适当的workOnValue实例得以生成),再后将指针传递给fwd:

using PrcessFuncType = int (*)(int);            //相当于创建一个typedef

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);               //没问题

但是,入股欧式经由fwdh.totalLength转发给f,就是另一回事了:

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

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

绕行方法

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

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

//复制位域值,初始化形式参见Item 6
auto length = static_cast<std::uint16_t>(h.totalLength)fwd(length);                //转发该副本

结语

在绝大多数情况下,完美转发就如规定所言的方式运作。你很少需要特别留意什么。但当它无法运作时,也就是当一些看上去合理的代码编译失败,或者更讨厌的情况,可以通过编译,行为却表现的和预料不同。重要的是,要了解完美转发的不完美之所在,同样重要的是知道如何规避他们。在绝大多数情况下,这些规避手法都是直截了当的。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值