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

ZaynPei Lv6

“完美转发”是 C++11 中一个极其强大的特性,但它并非在所有情况下都“完美”。该条款的核心是揭示几种完美转发会失败或产生非预期行为的边界情况,并提供相应的解决方案。

什么是完美转发失败

完美转发的理想目标是:一个转发函数 fwd 在接收某个表达式 expr 时,其行为应与将 expr 直接传递给目标函数 f 的行为完全相同。

1
2
f(expression);      // 直接调用
fwd(expression); // 通过转发函数间接调用
当直接调用成功,而间接调用却失败(例如,无法通过编译,或调用了错误的重载版本)时,就称完美转发失败。

失败的根本原因在于,编译器在处理fwd(expression)时,必须先推导fwd的模板参数类型,而这个推导过程不考虑最终目标函数f的形参类型。而在直接调用f(expression)时,编译器会同时看到实参和形参,并可以进行必要的隐式类型转换。当类型推导失败或推导出“错误”的类型时,完美转发就会失败。

五种导致完美转发失败的实参

  1. 大括号初始化物 (Braced Initializers): 直接将 {} 初始化列表传递给目标函数通常是合法的,但通过转发函数传递则会失败。
    1
    2
    3
    4
    5
    6
    void f(const std::vector<int>& v);
    f({1, 2, 3}); // 成功:"{1,2,3}"被隐式转换为 std::vector<int>

    template<typename T>
    void fwd(T&& param) { f(std::forward<T>(param)); }
    fwd({1, 2, 3}); // 错误!
    原因在于, 将 {…} 传递给一个未声明为 std::initializer_list 的函数模板参数,属于C++标准中的非推导语境 (non-deduced context)。编译器被禁止从 {1, 2, 3} 中为 fwd 的模板参数 T 推导出类型。由于类型推导失败,代码无法通过编译。

解决方案也很简单, 先使用 auto 将大括号初始化物创建为一个 std::initializer_list 对象,再将该对象传递给转发函数。

1
2
auto il = {1, 2, 3};
fwd(il); // 成功:il 被推导为 std::initializer_list<int>

  1. 以 0 或 NULL 表示的空指针 直接调用时,0 或 NULL 可能会被正确解释为空指针,但转发时则不行。

原因是, 如条款8所述,0 和 NULL 的真实类型是整型(int 或 long)。模板类型推导会忠实地将其推导为整型,而不是指针类型。当这个错误的整型被转发给期望指针的目标函数时,就会发生类型不匹配的错误。

解决方案是使用 nullptr 代替 0 或 NULL。nullptr 的类型是 std::nullptr_t,它可以被正确地推导并转发。

  1. 仅有声明的整型 static const 成员变量 在类中声明但未在实现文件中定义的整型 static const 成员,可以直接作为值使用,但不能通过完美转发传递。
    1
    2
    3
    4
    5
    6
    class Widget {
    public:
    static const std::size_t MinVals = 28; // 仅有声明
    };
    f(Widget::MinVals); // 成功:编译器直接替换为值 28
    fwd(Widget::MinVals); // 错误:通常会导致链接失败
    完美转发是通过引用(万能引用 T&&)来接受参数的。传递引用在底层实现上通常等同于传递指针,这意味着被引用的对象必须有明确的内存地址。仅有声明的 static const 成员变量,编译器通常会通过“常数传播”优化直接使用其值,而不会为其分配内存地址。因此,当 fwd 尝试获取其引用时,链接器会因为找不到该变量的定义而报错。

解决方案是在实现文件中为该成员变量提供定义即可

1
2
// 在 Widget 的 .cpp 文件中
const std::size_t Widget::MinVals; // 无需再次指定值

  1. 重载的函数名字和模板名字 直接将一个重载函数的名字传递给目标函数是合法的,但通过转发函数传递则会失败。
    1
    2
    3
    4
    5
    6
    void f(int (*pf)(int)); // f 接受一个函数指针
    int processVal(int);
    int processVal(int, int);

    f(processVal); // 成功:编译器根据f的签名选择了 int(int) 版本
    fwd(processVal); // 错误!
    原因是, processVal 这个名字本身代表一个函数重载集,它没有一个确定的类型。在直接调用 f 时,编译器可以根据 f 的参数类型(int (*)(int))来确定应该选用哪个重载版本。但在调用 fwd 时,编译器只看到了实参 processVal,由于其类型不确定,模板类型推导 T 失败。函数模板的名字也存在同样的问题。

解决方案:手动指定需要转发的是哪一个重载版本或模板实例(确定类型),例如通过创建一个函数指针来消除歧义。

1
2
3
using ProcessFuncType = int (*)(int);
ProcessFuncType processValPtr = processVal;
fwd(processValPtr); // 成功

  1. 位域 (Bitfields) 可以直接将位域作为值传递给函数,但不能通过完美转发传递。 > 什么是位域: 位域是 C/C++ 中的一种数据结构特性,它允许我们在一个结构体(struct)或联合体(union)中,为一个成员变量指定其占用的二进制位数(bit)。它是一种空间优化技术,主要用于将多个小的、通常是标志位或状态值的变量打包到单个机器字(如一个字节或一个整数)中,从而极大地节省内存空间。
    1
    2
    3
    4
    5
    6
    struct IPv4Header { std::uint32_t totalLength:16; };
    void f(std::size_t s);

    IPv4Header h;
    f(h.totalLength); // 成功
    fwd(h.totalLength); // 错误!
    原因:C++标准禁止将非 const 引用绑定到位域。因为位域可能只占一个字节中的几个比特,它没有自己独立的内存地址,而引用在底层通常是作为指针实现的。既然无法获取一个比特位的地址,自然也无法将其绑定到非 const 引用上。完美转发函数 fwd 的参数 T&& 是一个引用,因此该调用失败。

解决方案:传递位域值的副本。任何接受位域的函数实际上接收的都是其值的副本,因此可以手动创建这个副本再传递。

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

On this page
条款 30 - 熟悉完美转发的失败情形