文章

C++完美转发

完美转发保留实参的值类别,使函数模板能正确传递左值或右值,通常结合 T&& 和 std::forward 使用。

C++完美转发

C++ 完美转发

完美转发 是指:函数模板接收到的参数在传递给其他函数时,保持其原本的“值类别”(即左值或右值)

  • 常用于 泛型函数模板 中,用于“转发参数给其他函数”时,避免复制或不必要的拷贝构造
  • C++11 引入 std::forward&& 引用折叠来实现完美转发。

完美转发的关键

  • 使用 万能引用(Forwarding Reference)
1
2
template<typename T>
void func(T&& arg);  // T&& 是万能引用
  • 搭配 std::forward<T>(arg) 保留参数的值类别

    1
    
    std::forward<T>(arg);
    

    根据 T 的类型判断是否将 arg 保持为右值

    • 如果 Tint&,则结果为左值
    • 如果 Tint&&,则结果为右值

    即它使得模板参数保留了传入的原始“引用性质”。

右值引用对比万能引用

右值引用(Rvalue Reference)

  • 当类型是明确指定时,int&& 就是右值引用。
  • 只能绑定到右值(临时对象或 std::move 后的对象)。
1
2
int&& r = 5;      // 合法,5 是右值
int&& r2 = x;     // 错误,x 是左值

万能引用(Universal / Forwarding Reference)

  • 只有在模板类型推导时,形如 T&& 的参数被称为万能引用(Scott Meyers 在《Effective Modern C++》中提出的名词)。

  • 其行为如下:

传入参数的值类别推导出的 T 类型形参类型 T&& 实际类型
传入左值T 被推导为 int&变成 int& &&,折叠为 int&(左值引用)
传入右值T 被推导为 int仍是 int&&(右值引用)

简言之:万能引用会根据实参的值类别推导为左值引用或右值引用,达到“完美转发”的效果。

引用折叠规则(Reference Collapsing)

形式折叠结果
T& &T&
T& &&T&
T&& &T&
T&& &&T&&

所以当 T 推导为左值引用时,T&& 实际变成左值引用。

示例对比:普通转发 vs 完美转发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <iostream>
#include <utility>

// 有两个版本的 print,分别接受 左值引用 和 右值引用,用来区分传入的参数到底是左值还是右值
void print(int& x)  { std::cout << "左值引用\n"; }
void print(int&& x) { std::cout << "右值引用\n"; }

// 普通传递(失去值类别信息)
template<typename T>
void pass(T arg) {
    // 参数 arg 是传值方式,即函数内部的 arg 是局部变量,无论传入左值还是右值,函数体内的 arg 都是左值(因为它有名字且可寻址)
    // 传递给 print(arg) 时,arg 是左值,所以总是调用 print(int&)
    print(arg); 
}

// 完美转发
template<typename T>
// T&& 是万能引用,arg 可以绑定左值或右值
void perfectForward(T&& arg) {
    // 关键是 std::forward<T>(arg),它能根据 T 的类型推导完美地转发参数的值类别
    // 如果传入左值,T 推导为 int&,std::forward<int&>(arg) 返回 int&,调用 print(int&)
    // 如果传入右值,T 推导为 int,std::forward<int>(arg) 返回 int&&,调用 print(int&&)
    print(std::forward<T>(arg));
}

int main() {
    int x = 10;

    pass(x);           // 传入左值 x
    // arg 是传值,传入左值,arg 为左值
    // 调用 print(int&),输出 "左值引用"

    pass(20);          // 传入右值 20
    // arg 是传值,虽然传入右值,arg 是局部变量左值
    // 调用 print(int&),输出 "左值引用"

    perfectForward(x); // 传入左值 x
    // T 推导为 int&,std::forward 返回左值引用
    // 调用 print(int&),输出 "左值引用"

    perfectForward(20); // 传入右值 20
    // T 推导为 int,std::forward 返回右值引用
    // 调用 print(int&&),输出 "右值引用"

    return 0;
}

输出:

1
2
3
4
左值引用
左值引用
左值引用
右值引用

重载解析和引用绑定规则

如果传的是一个左值,比如变量 x。调用 print(x),有三个版本:

1
2
3
void print(int& x);   // 接收左值引用
void print(int&& x);  // 接收右值引用
void print(int x);    // 按值传递(拷贝)
  • 左值只能绑定到左值引用(int&)或者按值传递(int x),不能绑定到右值引用(int&&)。
  • 所以编译器排除 print(int&&)
  • 这时编译器会在剩下的两个中选择最佳匹配:
    • int&:直接引用,无需拷贝
    • int:按值拷贝,调用成本更高

所以选择 print(int& x)

  • 左值绑定规则:左值可绑定到左值引用或按值参数,不能绑定到右值引用。

  • 右值绑定规则:右值可绑定到右值引用或按值参数,不能绑定到左值引用。

重载解析会选最佳匹配,避免不必要拷贝。

关键点

模板形参写成 T&&,但这不是普通的右值引用,它是万能引用(转发引用)

  • 传入 左值 时,模板推导时 T 会变成左值引用类型,比如 int&

  • 传入 右值 时,模板推导时 T 会变成普通类型,比如 int

为什么传左值,T 变成 int&
  • 形参写的是 T&&,如果 T 直接是 int,那么形参是 int&&(右值引用)。但这时传进来了左值 x,右值引用是不能直接绑定左值的,编译器不能直接让 int&& 绑定左值。所以编译器让 T 推导为 int&,于是形参类型是 int& &&

  • 这里的 int& && 是引用的引用,看着奇怪,但 C++ 有引用折叠规则& && 折叠成 &,所以 int& && 等同于 int&。这样形参类型就变成了 int&,成功绑定左值。

  • 换句话说:

    • 左值传给T&&时,编译器会“升级”Tint&,保证T&&最终变成int&,能接受左值。

    • 这样模板就能接受左值,参数仍保持左值语义。

为什么传右值,T 变成 int
  • 形参写的是 T&&,这是一种万能引用(也叫转发引用),既能接受左值,也能接受右值。当你传入一个右值,比如 20,编译器会推导 T = int,于是形参类型变为 int&&,也就是标准的右值引用
  • 这没问题,因为右值可以直接绑定到右值引用,所以不需要任何折叠或者特殊处理。
  • 换句话说:
    • 右值传给 T&& 时,编译器会推导出 T = int,于是 T&& 就是 int&&,刚好可以接受右值。
    • 所以整个推导链条非常自然:右值 → T = int → 形参是 int&& → 成功绑定右值。

总结图表

函数调用传入参数类别模板参数 T 类型参数 arg 类型std::forward<T>(arg) 类型传给 print 的参数类型输出
pass(x)左值intint(局部变量,左值)—(未用 forward)左值引用左值引用
pass(20)右值intint(局部变量,左值)—(未用 forward)左值引用左值引用
perfectForward(x)左值int&int&(折叠后)int&左值引用左值引用
perfectForward(20)右值intint&&int&&右值引用右值引用
  • 普通传递时,传入的参数无论左值还是右值,都会被拷贝或移动到局部变量,变成左值。
  • 完美转发借助万能引用和 std::forward,可以保持参数的原始值类别,避免值类别丢失。

完美转发的本质

完美转发的关键目标是:

尽可能将调用者的实参值类别(左值或右值)“原样”地传递给被调用者(callee)

这个值类别信息必须通过两次保留和传递,缺一不可:

第一次传递:通过万能引用 T&& arg

1
2
template<typename T>
void func(T&& arg) { ... }
  • 当传入左值时,T 推导为 int&,则函数参数类型为 int& &&,折叠为 int&
  • 当传入右值时,T 推导为 int,则函数参数类型为 int&&
  • 也就是说:
    • T&& arg 作为万能引用,可以保留原始值类别信息在类型 T 中。
    • 然而!arg 作为具名变量,在函数体中始终是左值!

第二次传递:使用 std::forward<T>(arg)

  • 虽然 arg 是左值,但我们可以利用 T 这个保存了“原始值类别”的类型信息,来“恢复”传入的值类别。
  • 这就是 std::forward<T>(arg) 做的事情:
    • 如果 Tint&,则 std::forward<T>(arg) 是左值引用(传左值)。
    • 如果 Tint,则 std::forward<T>(arg) 是右值引用(传右值)。

std::forward 就像是“值类别的还原器”。

对比总结

1
传入实参 --> T&& arg (保存值类别) --> arg 是左值 --> std::forward<T>(arg) 恢复值类别 --> 最终传入目标函数
传入实参T 推导arg 的类型arg 本身std::forward(arg) 的效果传入 print 的版本
x (左值)int&int&左值左值print(int&)
20 (右值)intint&&左值右值print(int&&)

总之一句话,T&& 保存原始值类别,std::forward 恢复原始值类别。

应用场景

  • 泛型函数包装器(如 std::invoke
  • 工厂函数(如 std::make_unique<T>(args...)
  • 类型封装器(如 emplace_back, std::thread 等)
本文由作者按照 CC BY 4.0 进行授权