DongGu
万能引用与完美转发

万能引用与完美转发

万能引用与引用折叠

万能引用(universal reference),在现代 C++ 中使用十分频繁,它几乎可以绑定任何类型的对象。万能引用和右值引用语法一样,都用 T&& 表示,但是只有在特定条件下,才会表现出万能引用的性质。

构成万能引用 / 转发引用的必要条件

1. 必须存在类型推导

万能引用会出现在两种上下文中,最普遍的是作为函数模板参数类型:

1
2
template<typename T>
void f(T&& param); // param 是万能引用

或者是出现在 auto 声明中:

1
auto&& var2 = var1;         // var2 是万能引用

这两种情况都会发生类型推导,分别是对 paramvar2 的类型进行推导。

如果 T&& 没有伴随着类型推导,那么此时它就是右值引用:

1
2
3
void f(Widget&& param);     // 无类型推导,param 是右值引用

Widget&& var1 = Widget(); // 无类型推导,var1 是右值引用

有时候,虽然 T&& 出现在模板中,但并不意味着一定会发生类型推导。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template<class T, class Allocator = allocator<T>>
class vector {
public:
void push_back(T&& x);
...
};

std::vector<Widget> v;
// vector<Widget> 实例化后等价于:
class vector<Widget, allocator<Widget>> {
public:
void push_back(Widget&& x); // 右值引用
...
};

push_back 无法在 vector 实例化之前调用,但是实例化后的 vector 已经不会进行类型推导,push_back 的参数类型 T&& 已经成为了具体类型的右值引用。

2. 类型必须是严格的 T&& 形式

T&& 如果被其他关键字修饰,此时便成为了右值引用。比如:

1
2
3
4
5
template<typename T>
void f(std::vector<T>&& param); // param 是右值引用

template<typename T>
void f(const T&& param); // param 是右值引用

以上两种情况,T&& 配合 std::vector 使用或添加 const 修饰都导致 T&& 成为了右值引用,只能接收右值。

当然,只要是 T&& 形式,类型名没有关系:

1
2
template<typename MyTemplateType>
void someFunc(MyTemplateType&& param); // 万能引用

万能引用的原理

万能引用的底层原理是一套编译期类型系统规则————引用折叠(reference collapsing)。

在 C++ 中,你无法显式声明”引用的引用”,比如:

1
2
3
int x;

auto& & rx = x; // 错误!不能声明引用的引用

但在编译期,模板类型推导、auto 推导、decltypetypedef/using 别名展开时系统可能会隐式地拼接出”引用的引用”。

比如 template 类型推导:

1
2
3
4
template<typename T>
void func(T&& param);

func(w); // 以左值调用 func,T 推导为 Widget&

ParamType 为万能引用时,根据类型推导规则,当参数为左值时,得到左值引用。此时就会出现:

1
void func(Widget& && param);

我们知道最终的推导结果是:

1
void func(Widget& param);

这种转变的关键就是引用折叠。

引用折叠的规则很简单:只要存在一个左值引用时折叠结果就为左值引用,只有两个引用都为右值引用时折叠结果才为右值引用。

原类型组合 折叠后的最终类型 记忆口诀
& + & &(左值引用) 左 + 左 = 左
& + && &(左值引用) 左 + 右 = 左
&& + & &(左值引用) 右 + 左 = 左
&& + && &&(右值引用) 右 + 右 = 右

可见,万能引用并不是一种新的引用类型,它本质上是右值引用语法 T&& 在特定的类型推导环境下的具体体现。

完美转发

完美转发(perfect forwarding)是 C++11 引入的核心特性之一,用于解决在模板包装函数中向另一个函数传递参数时,保留参数的原始类型属性(左值/右值、const/volatile 限定符)的问题,且不产生任何不必要的拷贝开销。

在 C++ 中,只要一个变量有名字,在函数内部它就是一个左值。比如:

1
2
3
4
template <typename T>
void wrapper(T&& arg) {
process(arg);
}

无论传入的参数是什么类型,在 wrapper 函数内部它具有名字 arg,因此 process 传入的参数永远是一个左值,只会匹配 process 参数类型为左值的重载。

如果不使用完美转发,最常见的方法是重载模板:

1
2
3
4
5
6
7
8
9
template <typename T>
void wrapper(T& arg) {
process(arg);
}

template <typename T>
void wrapper(const T& arg) {
process(arg);
}

这样的方法在参数较多的时候需要大量重载,同时 const T& 完全破坏了右值的移动语义,只能触发拷贝构造。

但是如果使用了完美转发,代码就会变得异常简单且高效。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void process(const Widget& lvalArg);    // 处理左值
void process(Widget&& rvalArg); // 处理右值

template<typename T> // 将 param 转发给 process 的模板
void logAndProcess(T&& param)
{
auto now = std::chrono::system_clock::now(); // 获取当前时间
makeLogEntry("Calling 'process'", now);
process(std::forward<T>(param));
}

Widget w;
logAndProcess(w); // 以左值调用
logAndProcess(std::move(w)); // 以右值调用

在这种写法下,传入左值与右值后,分别会正确调用 process 的左值和右值重载。

我们知道当 logAndProcess 分别传入左值和右值时,根据 template 类型推导与引用折叠规则,param 的类型 T&& 分别会成为左值引用和右值引用。但是右值引用本身却是一个左值(有名字),只有通过 std::forward 才能将其”还原”为右值。

std::forward 的实现

std::forwardstd::move 一样,它既不转发任何东西,也不会生成任何运行时代码,它本质上也是类型转换,只不过相比 std::movestd::forward 根据模板参数 T 决定是否将参数转换为右值。

std::forward 的具体实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
namespace std {

// 1. 处理左值/右值引用的重载
template <typename T>
constexpr T&& forward(typename std::remove_reference<T>::type& param) noexcept {
return static_cast<T&&>(param);
}

// 2. 阻止对临时右值误用 forward 的重载(安全防护机制)
template <typename T>
constexpr T&& forward(typename std::remove_reference<T>::type&& param) noexcept {
static_assert(!std::is_lvalue_reference<T>::value,
"std::forward cannot convert an rvalue to an lvalue reference");
return static_cast<T&&>(param);
}

} // namespace std

其中 constexpr 告诉编译器这个类型转换可以在编译期执行,零运行时开销;typename 用以消除歧义,让编译器清楚后面是一个类型而不是类成员;noexcept 承诺这个强制转换永远不会抛出异常,让编译器放心优化。

std::forward 在使用时,需要显式传入 T 的类型,std::forward 模板内的 std::remove_reference<T> 会进行类型萃取并拼接 &&&,分别形成与左值和右值匹配的重载类型。

下面我们分析完美转发过程中 std::forward 发挥的作用:

1. 外层模板传入左值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template <typename T>
void wrapper(T&& arg) {
process(std::forward<T>(arg));
}

int x = 0;
wrapper(x); // 传入左值

// wrapper 内部,T 推导为 int&,实例化后等价于:
// void wrapper(int& arg) {
// process(std::forward<int&>(arg)); // 显式传入类型 int&
// }

// std::forward 内部,替换模板参数后等价于:
// constexpr int& forward(int& param) noexcept {
// return static_cast<int&>(param); // 发生引用折叠:int& && → int&
// }

当传入类型为左值时,外层 T 的推导结果为左值引用,显式传入 std::forward 后发生引用折叠,返回值类型还是左值引用,结果相当于原样返回。

2. 外层模板传入右值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template <typename T>
void wrapper(T&& arg) {
process(std::forward<T>(arg));
}

wrapper(0); // 传入右值

// wrapper 内部,T 推导为 int,实例化后等价于:
// void wrapper(int&& arg) {
// process(std::forward<int>(arg)); // 显式传入类型 int
// }

// std::forward 内部,替换模板参数后等价于:
// constexpr int&& forward(int& param) noexcept {
// return static_cast<int&&>(param);
// }

当传入类型为右值时,外层 T 的推导结果为非引用的原始类型,显式传入 std::forward 后与 && 拼接,返回值类型成为了右值引用,发生了左值到右值引用的类型转换。

std::forward 的第二个重载是为了处理特殊情况:

1
2
3
4
5
6
7
8
9
// 特殊情况:std::forward 模板类型 T 传入左值引用,但是参数类型为临时右值
std::forward<int&>(0);

// std::forward 内部,替换模板参数后等价于:
// constexpr int& forward(int&& param) noexcept { // 传入参数为右值,匹配第二个重载
// static_assert(!std::is_lvalue_reference<int&>::value,
// "std::forward cannot convert an rvalue to an lvalue reference"); // T 为左值引用,触发断言
// return static_cast<int&>(param);
// }

std::forward 传入右值时,会触发第二个重载,此时如果传入的 T 类型为左值引用,会触发断言导致编译失败。

std::movestd::forward 的使用

一般来说,我们会对右值引用使用 std::move

1
2
3
4
5
6
7
8
9
10
11
12
class Widget {
public:
Widget(Widget&& rhs) // rhs 是右值引用
: name(std::move(rhs.name)),
p(std::move(rhs.p))
{ ... }
...

private:
std::string name;
std::shared_ptr<SomeDataStructure> p;
};

对万能引用使用 std::forward

1
2
3
4
5
6
7
class Widget {
public:
template<typename T>
void setName(T&& newName) // newName 是万能引用
{ name = std::forward<T>(newName); }
...
};

因为右值引用总是绑定右值,我们可以直接对其使用 std::move;由于万能引用既可以绑定左值也可以绑定右值,如果我们对其使用 std::move 可能会引发一些问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Widget {
public:
template<typename T>
void setName(T&& newName) // 万能引用,但内部错误使用了 std::move
{ name = std::move(newName); }
...

private:
std::string name;
std::shared_ptr<SomeDataStructure> p;
};

std::string getWidgetName(); // 工厂函数
Widget w;
auto n = getWidgetName(); // n 是局部变量
w.setName(n); // n 的值被移走了!

局部变量 n 被传递给了 w.setName,调用者很容易误以为这是对 n 的只读操作。但由于 setName 内部使用了 std::move 无条件地将其引用参数强制转换为右值,n 的值将被移入 w.name,导致 nsetName 调用返回时带有一个未指定的值。

还需要注意的是,只有对于不会再被使用的变量,我们才对其使用 std::movestd::forward

返回值优化

对于函数返回值,使用 std::movestd::forward 可以使程序更高效:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Matrix                              // 按值返回
operator+(Matrix&& lhs, const Matrix& rhs)
{
lhs += rhs;
return std::move(lhs); // 将 lhs 移动到返回值
}

template<typename T>
Fraction // 按值返回
reduceAndCopy(T&& frac) // 万能引用参数
{
frac.reduce();
return std::forward<T>(frac); // 右值则移动到返回值,左值则拷贝
}

对于右值引用类型的左值,使用 std::movestd::forward 可以返回右值临时变量,触发移动构造或移动赋值运算符;即使该类型不支持移动相关操作,编译器也会匹配拷贝操作。

但是需要说明的是,由于编译器会对程序进行优化,有些时候人为的过度优化反而破坏了编译器优化。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
Widget makeWidget()                 // 拷贝版本
{
Widget w; // 局部变量
...
return w; // 编译器会应用 RVO/NRVO
}

Widget makeWidget() // 移动版本(不推荐!)
{
Widget w;
...
return std::move(w); // 破坏了 RVO/NRVO 优化
}

你可能会认为使用 std::move 返回局部变量更加高效,但是编译器会进行 RVO/NRVO(Return Value Optimization / Named Return Value Optimization,返回值优化 / 具名返回值优化),让返回对象直接在最终位置构造,避免创建中间对象。

需要满足的条件有:

  1. w 是局部自动变量
  2. 类型和函数返回类型相同
  3. 直接返回这个变量

即使没有触发 RVO,对于局部变量这个将亡值(xvalue),编译器也会将其看作右值类型,自动应用移动语义。


这次的插图来自画师 TheProtagonists

图片地址:https://www.pixiv.net/artworks/147570230

文章内容参考:Effective Modern C++ 42 Specific Ways to Improve Your Use of C++11 and C++14 (Scott Meyers) Item23、Item25、Item28

本文作者:DongGu
本文链接:https://donggu.xyz/2026/08/07/现代C++学习/万能引用与完美转发/
版权声明:本文采用 CC BY-NC-SA 3.0 CN 协议进行许可