熟悉完美转发失败的情形

本文最后更新于:April 20, 2022 am

本篇为介绍右值引用、移动语义和完美转发系列博客的第八篇,也是最后一篇,讨论完美转发失败的情形

右值引用,移动语义、完美转发系列目录:右值、移动语义和完美转发

在详细地讨论之前, 我们再来温习一下完美转发到底是什么。“转发”意味着——一个函数,将它的形参作为另一个函数的实参进行传递;“完美”则代表——让第二个函数接收到实参同第一个函数接收到的实参一致

值传递的函数自然不在讨论范围之内,因为两个函数的参数是两个不同的对象;因为我们不希望强迫外部使用指针,所以用指针传参的函数也同样不加以讨论。当讨论普遍意义上的转发时,我们实际上在讨论按照引用传参的函数

“完美转发”意味着,我们不仅仅在转发对象(传参),还附带了对象的显著特征——即它们是左值还是右值,以及它们是否带有const或者volatile修饰符

由前面几篇博客的讨论,我们知道只有当使用万能引用作为形参时,才有可能将之用作完美转发的源

当我们希望从函数fwd转发一个参数到f时,多半会这样写:

1
2
3
4
template<typename T>
void fwd(T&& param){
f(std::foward<T>(param));
}

自然延伸一下,当需要转发任意多个任意类型的参数时,该怎么写呢?

这时,需要推展函数模板成可变参数模板

1
2
3
4
template<typename... Ts>
void fwd(Ts&&... params){
f(std::forward<Ts>(params)...);
}

在其它标准库中,我们经常会看到它的影子:所有标准容器的emplacement函数,还有智能指针的工厂函数,比如std::make_shared, std::make_unique(参见Item21:优先使用std::make_unique和std::make_shared

好了,最后我们再来明确一下,到底什么叫做“完美转发的失败”?在上述代码的基础上,我们规定:当用某一个参数X调用函数f后,f函数的行为,同用此参数X调用fwd后函数f的行为不一致时,完美转发失败。

大括号初始化物

假设函数f的签名如下:

1
void f(const std::vector<int>& v)

我们可以这样来调用之f({1,2,3}),编译器会将大括号隐式转换成std::vector<int>

但是如果向fwd函数中传入相同的内容,则会导致编译不通过fwd({1,2,3})

这就是我们讨论的第一个完美转发失败的情形,实际上,所有的失败情形的原因都是一致的。直接调用函数f时,编译器检查调用处的实参和函数签名中的形参,发现两者并不相匹配,于是将前者隐式转换成后者。在上述的例子中,编译器就将实参{1,2,3}隐式转换成了一个std::vector<int>类的临时对象,并让形参绑定到它

当通过调用fwd函数来间接调用函数f时,编译器不会再检查fwd函数实参的类型和fwd函数的形参类型是否匹配,因为此函数的形参是万能引用;编译器会先推导出fwd函数的万能引用的类型参数T,然后检查类型参数T和函数f的形参类型,然后问题就发生了——编译器不知道应该将类型参数T推导成什么

完美转发失败只会由以下两种原因之一导致:

  • 编译器无法推导出某个类型参数

    此例便是如此

  • 编译器将某个参数的类型推导“错误”

    这里的错误不单单指fwd函数实例化后,使用推导出的类型无法编译,还包含另一种情况,即后续调用函数f时,f的表现和我们所期待的不一致。

    比如,函数f有多个重载版本,随后编译器推导出的类型参数导致错误地调用了另一个版本的f

回顾上述的错误,调用fwd({1,2,3})时,问题出在这里:我们向函数模板传入大括号初始化物,而函数模板的形参没有被声明为std::initializer_list类型,这种情况按照标准的话来说,就是叫“非推导语境”。简单来说就是,由于fwd函数的形参不是std::initializer_list类型,编译器被禁止从大括号初始化物推导其类型参数,最终导致编译错误。

如果我们在外部初始化此变量,则后序的执行就可以正常进行

1
2
3
4
auto il = {1,2,3};
// il 肯定会被推导成std::initializer_list类型
fwd(il);
// 正常编译

用0/NULL指代空指针

当向函数模板中传递0或者NULL来表明传递空指针时,编译器通常会错误地将类型参数推导为int,而非某个类型的指针,进而导致转发整形参数到子函数。

解决方案很简单,使用nullptr即可。

类中只声明的整形静态常量

通常情况下,如果某个类中有静态的常量( static const )变量,我们不需要单独对它们进行定义,声明就已经足够。这是因为编译器会对上述类型的变量进行常数传播( const propagation ),随后避免为它分配内存,举例而言

1
2
3
4
5
6
7
8
class Widget{
public:
static const std::size_t MinVals = 28;
...
};

std::vector<int> widgetData;
widgetData.reserve(Widget::MinVals);

即使我们没有定义Widget::MinVals,这段代码也能编译。编译器将所有出现Widget::MinVals的地方都用它的值28来代替,如此一来不为它分配内存就是可行的。但如果代码中有地方试图去对Widget::MinVals进行取地址,编译器就必须为它分配地址了,上述代码会在链接期间报错,因为变量Widget::MinVals缺少定义

好,知道上述内容之后,我们再假设函数f的签名为void f(std::size_t val);

如果我们直接调用ff(Widget::MinVals);,没有任何问题

但如果通过函数fwd来间接地调用ffwd(Widget::MinVals);,代码会在链接时报错,并且提示Widget::MinVals没有定义!

结合之前做的铺垫,是有什么东西取了它的地址吗?是的,尽管在源代码里没有出现类似&Widget::MinVals的内容。

函数fwd的参数是万能引用,而所有的引用,在编译器生成的目标代码中,都会被当做指针对待;而在二进制码中,以及在硬件层面,引用和指针没有任何区别。

在这一层面,正如那句老话所说,引用只不过是自动解引用(*操作符)指针罢了。也正因如此,通过引用传递Widget::MinVals和通过指针传递Widget::MinVals都是一样的效率

所以说,要创建指向Widget::MinVals的前提是,此变量必须有地址,底层的指针才能指向它

有一个问题,代码中的注释写的是“不应该”,而不是“不能”。这样说的原因是,根据标准,将Widget::MinVals按引用传递就是必须要有它的定义,但是有一些实现没有强行要求这一点。根据编译器和链接器的不同,这份代码可能最终能够正常编译链接,但是毫无可移植性。

解决的方法很简单,在Widget.cpp文件中加上定义即可

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

假设函数f的参数是一个函数指针,并且我们希望f利用这个函数做一些特定的工作,设f的签名如下

1
2
void f(int (*pf)(int));
// void f(int pf(int));// 这样也可以

现在,假设我们要传入的函数是processVal,签名如下:

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

于是,我们可以这样调用f

1
f(processVal);

即使此处的processVal并不是个函数指针,而是两个重载函数的函数名。但是编译器知道通过f的函数签名,来传入正确的processVal地址

如果试图通过fwd来间接调用函数f,就会出现编译错误。fwd作为函数模板,它的函数签名没有声明关于传入类型的任何信息,编译器无法通过fwd的函数签名来决定应该传入哪一个processVal函数的地址,于是编译错误!

1
fwd(processVal);     // error! which processVal?

如果将processVal从重载函数换成函数模板,那么结局也会一样

1
2
3
4
template<typename T>
T workOnVal(T param){ ...... }

fwd(workOnVal); // error! which instantiation?

这是因为,函数模板不代表某一个函数,它代表了许多函数

问题很明确,既然编译器不知道应该选择哪一个函数,那我们就可以通过手动指定的方式让编译器明白。如果我们希望传入参数是intprocessVal的指针,我们就在外部声明它的函数指针,然后把函数指针传入函数fwd

1
2
3
4
5
6
7
using ProcessFuncType = int(*)(int);

ProcessFuncType processValPtr= processVal;

fwd(processValPtr);// ok!

fwd(static_cast<ProcessFuncType>(workOnVal)); // ok!

当然,这要求我们提前知道fwd内部调用的函数所需要的函数指针的类型。对于一个用于完美转发的函数模板,它的文档没有理由不记录这一点,毕竟这个函数模板可以接收任何类型的参数。

位域

最后一种失败案例和位域有关,当位域被用作函数参数时,完美转发失败。我们通过实际的例子来说明,观察下图的IPv4包头[1]

1
2
3
4
5
6
7
8
struct IPv4Header{
std::uint32_t version:4,
IHL:4,
DSCP:6,
ECN:2,
totalLength:16;
...
};

假设函数f的签名如下

1
void f(std::size_t sz);

如果我们使用位域totalLength来调用f,没有问题:

1
2
3
IPv4Header h;
......
f(h.totalLength); //ok!

但如果使用fwd函数进行转发,则会导致编译错误。

1
fwd(h.totalLength);  // error!

fwd的参数是引用,而且它是个non-const的引用。

听起来问题好像不大,但C++标准明确规定了:禁止将non-const的引用绑定到位域上

这样规定是有理由的,位域有可能由若干条二进制代码的碎片组成(比如,某个int的3-5位),但是我们无法直接地去访问这些位。早些时候,我们知道了指针和引用在硬件层面是一回事,正如我们无法创建指向位域的指针一样,我们也无法创建指向位域的引用(C++规定,开发者能操作的最小的内存存取单位是char

既然我们无法创建指向位域的指针或者引用,能传值的方式就是有两种了:值传递和const的引用传递。

对于前者,实参被拷贝之后,位域其实是被存储到一个“对象”中的(标准规定,任意整形类型即可);对于后者,为什么能够创建位域的引用呢?其实答案已经呼之欲出了——编译器也是通过在函数中构造了一份实参的拷贝,然后让引用绑定到拷贝上。

既然如此,我们只需要在fwd函数外侧构建自己的拷贝,然后转发之

1
2
auto length= static_cast<std::uint16_t>(h.totalLength);
fwd(length);

总结

在绝大多数情况下,完美转发就如同它的名字一般,完美地执行它的使命,我们也不需要对它做过多的关心。但当它不按套路出牌,使得看起来很正常的代码抛出了一堆堆编译错误——我们就很需要清楚它的瑕疵,以及如何应对相应情景。通常情况下,解决方案都是很直接的。

当类型推导失败,或者推导出了错误的类型时,完美转发失效

大括号初始化列表,用0或者NULL传参的空指针,仅有声明的static const成员变量,模板或重载函数的名字,以及位域


  1. 这里假设系统是小端。标准没有规定该使用大端还是小端,但编译器通常提供一种管理的方案供开发者选择

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!