4767 字
24 分钟

C++完美转发

完美转发(perfect forwarding)解决的是一个很具体的问题:写一个中间函数,把收到的参数继续交给下一层函数时,不要擅自改变调用者原本传来的方式。

在 C 里,多包一层函数通常只是多一次调用:

void target(int* p);
void wrapper(int* p) {
target(p);
}

wrapper 收到一个指针,再把这个指针传给 target,事情很直接。C++ 里麻烦一些,因为一个参数不只有“值”和“地址”两种形态。它可能是左值,也可能是右值;可能是 const,也可能不是;传到下一层时可能触发拷贝,也可能触发移动。

完美转发要保留的就是这件事:调用者传来的是左值,下一层也看到左值;调用者传来的是右值,下一层也看到右值。外层函数只负责“代传”,不替调用者决定该拷贝还是该移动。

最小模板知识#

完美转发需要一点函数模板知识,但不需要先学完整的模板元编程。后面只会用到三件事:模板参数推导、引用折叠、std::remove_reference_t

函数模板里的 T#

函数模板可以把某个类型先写成一个占位符:

template<class T>
void print_value(T x) {
// x 的类型由调用时传入的实参决定
}
print_value(1); // T 推导为 int
print_value(3.14); // T 推导为 double
print_value(std::string{}); // T 推导为 std::string

T 不是运行时变量,而是编译期推导出来的类型。编译器看到不同调用,会生成不同版本的函数。

引用也可以参与模板推导:

template<class T>
void take_ref(T& x) {
}
int n = 1;
take_ref(n); // OK:T 推导为 int,形参是 int&
// take_ref(1); // 错误:非常量左值引用不能绑定右值

这里的 T& 只能接左值。1 是右值,绑不上 T&

引用折叠#

C++ 源码里不能直接写出“引用的引用”:

// 不能这样直接写:
// int& & bad;

但模板和类型别名会在“替换类型”的过程中制造出类似 U& && 这样的中间形式。编译器不能真的保留一个“引用的引用”,所以会把它折叠成一层普通引用。这套规则叫引用折叠(reference collapsing)。

先不用管完美转发,单看类型别名就能看到这件事:

#include <type_traits>
using LRef = int&;
using RRef = int&&;
static_assert(std::is_same_v<LRef&, int&>); // int& + & -> int&
static_assert(std::is_same_v<LRef&&, int&>); // int& + && -> int&
static_assert(std::is_same_v<RRef&, int&>); // int&& + & -> int&
static_assert(std::is_same_v<RRef&&, int&&>); // int&& + && -> int&&

LRef 本来就是 int&。当你再写 LRef&& 时,直觉上像是 int& &&,但最终类型不是右值引用,而是 int&。只要里面已经有一个左值引用 &,它就会把整个结果“拉回”左值引用。

四条规则写成表就是:

U& & -> U&
U& && -> U&
U&& & -> U&
U&& && -> U&&

最通俗的记法是:左值引用更强势。只要参与折叠的两边有一个是 &,结果就是 &;只有两边都是 &&,结果才是 &&

这条规则是完美转发的基础。因为转发引用写成 T&&,如果某次调用把 T 推导成了 U&,那么形参并不会变成右值引用,而会经历这一步:

T = U&
T&& = U& && // 替换 T 之后得到的中间形式
= U& // 引用折叠后的最终形参类型

也就是说,&& 不是“无论如何都变成右值引用”。在模板里,它要先等 T 推导完成,再和 T 里可能已经带着的引用一起折叠。

remove_reference_t 只是剥掉引用#

std::movestd::forward 的简化实现时,经常会看到 std::remove_reference_t<T>。它属于 type traits,但这里不需要把它当成复杂模板元编程理解。它做的事很朴素:把类型里的引用去掉。

using A = std::remove_reference_t<int>; // int
using B = std::remove_reference_t<int&>; // int
using C = std::remove_reference_t<int&&>; // int

后面看到它时,只要把它读成“去掉 &&& 之后的原始类型”就够了。

转发到底会丢什么#

先看一个目标函数。它有两个重载:一个用 const std::string& 只读观察参数,一个用 std::string&& 接收可移动参数。

#include <iostream>
#include <string>
#include <utility>
void target(const std::string& s) {
std::cout << "target(const string&): " << s << '\n';
}
void target(std::string&& s) {
std::cout << "target(string&&): " << s << '\n';
}

这里要先把匹配顺序说清楚:const std::string& 不是只能接左值,它也能接右值。右值实参同时可以绑定到 const std::string&std::string&&,只是 std::string&& 对右值是更精确的匹配,所以重载决议会优先选择 std::string&&

直接调用时,重载决议的结果是:

std::string name = "Alice";
target(name); // name 是左值:std::string&& 不可行,只能选 const std::string&
target(std::string("Bob")); // 右值:两个重载都可行,std::string&& 更优
target(std::move(name)); // xvalue:两个重载都可行,std::string&& 更优

name 是有名字的对象,作为表达式是左值。std::string("Bob") 是临时对象,是右值。std::move(name)name 这个左值显式转成右值。

问题出在多包一层函数之后:

void wrapper_bad(std::string&& s) {
target(s);
}
wrapper_bad(std::string("Bob")); // 传进来的是右值

wrapper_bad 的形参类型是 std::string&&,它确实只能接右值,所以 wrapper_bad(std::string("Bob")) 这一步没问题。但进入函数体之后,s 有了名字。只要一个表达式有名字、能取地址,它就是左值。

于是 target(s) 里的实参不是“传进 wrapper_bad 时的那个右值”,而是函数体里的左值表达式 s。对这个左值来说,std::string&& 重载已经不可行,只剩 const std::string& 可以绑定,所以最后选择 const std::string&。这不是因为 const std::string&std::string&& 更适合右值,而是因为右值属性在进入 wrapper 后已经丢了。

这就是上一篇左值 / 右值文章里最容易踩的坑:类型是右值引用,不代表这个表达式就是右值。具名右值引用作为表达式仍然是左值。

std::move 不是通用答案#

既然 s 在函数体里变成了左值,那可以用 std::move 把它转回右值:

void wrapper_move(std::string&& s) {
target(std::move(s));
}

这对 std::string&& 这种普通右值引用形参没问题。wrapper_move 的签名已经说明了它只接右值,函数体里继续移动是合理的。

但通用 wrapper 不只想接右值,也想接左值:

template<class T>
void wrapper_move(T&& arg) {
target(std::move(arg));
}
std::string name = "Alice";
wrapper_move(name); // name 是左值,却被转成右值传给 target
wrapper_move(std::string("Bob")); // 右值也被转成右值传给 target

这个版本太激进了。它不管调用者传来的是什么,一律把 arg 转成右值。如果下一层函数真的会移动资源,wrapper_move(name) 就可能把调用者还想继续使用的 name 搬空。

std::move 表达的是:“我不再需要这个对象的值了,你可以移动它。”
通用 wrapper 想表达的是:“调用者当初怎么给我,我就怎么交给下一层。”

把几种写法放在一起,对比会更清楚:

写法传入 name(左值)传入 std::string("Bob")(右值)问题
直接调用 target(实参)std::string&& 不可行,走 const std::string&两者都可行,std::string&& 更优没有中间层,值类别保持正确
普通 wrapper:target(arg)arg 是左值,走 const std::string&arg 仍是左值,走 const std::string&右值进入 wrapper 后变成具名左值表达式
std::move wrapper:target(std::move(arg))std::string&&std::string&&左值也被强行右值化
std::forward wrapper:target(std::forward<T>(arg))const std::string&std::string&&左值仍是左值,右值仍是右值

完美转发的目标就是最后一行:左值仍按左值传,右值仍按右值传。

转发引用:能同时接住左值和右值#

写成 T&& 的形参有两种完全不同的情况。

普通函数里的具体类型 && 是右值引用:

void f(std::string&& s); // 普通右值引用,只接右值
std::string name = "Alice";
// f(name); // 错误:左值绑不上 std::string&&
f(std::string("Bob")); // OK

函数模板里的 T&& 不一样:

template<class T>
void f(T&& x);

这里的 T 要从实参推导出来,所以 T&&转发引用(forwarding reference)。它既能接左值,也能接右值。

std::string name = "Alice";
f(name); // OK:左值也能接
f(std::string("Bob")); // OK:右值也能接

原因是模板推导会把左值和右值推导成不同的 T

调用方式T 推导结果形参 T&& 折叠后
传入 U 类型左值U&U&
传入 U 类型右值UU&&

左值调用时,T 不是简单的 U,而是 U&。于是 T&& 变成 U& &&,根据引用折叠规则折叠成 U&。右值调用时,TUT&& 就是 U&&

U 换成具体的 std::string,过程会更直观。

左值调用:

std::string name = "Alice";
f(name);
// name 是 std::string 类型的左值
// T -> std::string&
// T&& -> std::string& &&
// 折叠后 -> std::string&

所以这次实例化出来的函数更像:

void f(std::string& x);

右值调用:

f(std::string("Bob"));
// std::string("Bob") 是右值
// T -> std::string
// T&& -> std::string&&
// 折叠后 -> std::string&&

所以这次实例化出来的函数更像:

void f(std::string&& x);

T&#x26;&#x26; 的模板推导与引用折叠

这个机制很关键:T 里保留下了调用者原本传来的是左值还是右值。std::forward<T> 正是靠这个信息工作。

下面这个小程序可以验证三件事:

  • 左值调用时,T 会推导成左值引用。
  • 右值调用时,T 不带引用,形参 T&& 才是右值引用。
  • 不管形参类型最终是 U& 还是 U&&,函数体里的变量名 x 作为表达式仍然是左值。
#include <iostream>
#include <string>
#include <type_traits>
template<class T>
void probe(T&& x) {
if constexpr (std::is_lvalue_reference_v<T>) {
std::cout << "T: left reference\n";
} else {
std::cout << "T: non-reference type\n";
}
if constexpr (std::is_lvalue_reference_v<decltype(x)>) {
std::cout << "parameter type: left reference\n";
} else if constexpr (std::is_rvalue_reference_v<decltype(x)>) {
std::cout << "parameter type: right reference\n";
}
if constexpr (std::is_lvalue_reference_v<decltype((x))>) {
std::cout << "expression x: lvalue\n";
}
std::cout << "---\n";
}
int main() {
std::string name = "Alice";
probe(name);
probe(std::string("Bob"));
}

输出大致是:

T: left reference
parameter type: left reference
expression x: lvalue
---
T: non-reference type
parameter type: right reference
expression x: lvalue
---

这里的 decltype(x)decltype((x)) 故意都写出来了。decltype(x) 看的是变量 x 的声明类型,所以第二次调用能看到 x 的形参类型是 std::string&&decltype((x)) 看的是表达式 x 的值类别,而 x 有名字,所以表达式永远是左值。这正是后面还需要 std::forward<T>(x) 的原因。

并不是所有 T&& 都是转发引用。只有“待推导的模板参数 T 直接写成 T&&”才是。

void a(std::string&& s); // 不是转发引用:具体类型
template<class T>
void b(T&& x); // 是转发引用:T 要推导,且直接写成 T&&
template<class T>
void c(const T&& x); // 不是转发引用:多了 const
template<class T>
void d(std::vector<T>&& v); // 不是转发引用:不是直接的 T&&
template<class T>
struct Box {
void set(T&& x); // 通常不是转发引用:T 属于类模板,调用 set 时已经确定
};

最后一个例子尤其容易误会。Box<std::string> 里的 set 实际上就是:

void set(std::string&& x);

它只接右值,不会因为写着 T&& 就自动变成“万能形参”。

std::forward<T> 做了什么#

std::forward<T> 可以理解成“有条件的 std::move”。它根据 T 的推导结果决定把参数转成左值还是右值。

简化后的实现大致长这样:

template<class T>
constexpr T&& forward(std::remove_reference_t<T>& t) noexcept {
return static_cast<T&&>(t);
}

标准库里的真实实现还有另一个重载,用来处理右值输入和防止错误用法。理解完美转发时,先抓住这一句 static_cast<T&&>(t) 就够了。

看两种调用:

template<class T>
void wrapper(T&& arg) {
target(std::forward<T>(arg));
}

如果调用者传入左值:

std::string name = "Alice";
wrapper(name);

推导过程是:

T = std::string&
std::forward<T>(arg)
=> std::forward<std::string&>(arg)
=> static_cast<std::string& &&>(arg)
=> static_cast<std::string&>(arg) // 结果仍是左值

如果调用者传入右值:

wrapper(std::string("Bob"));

推导过程是:

T = std::string
std::forward<T>(arg)
=> std::forward<std::string>(arg)
=> static_cast<std::string&&>(arg) // 结果是右值

arg 本身在函数体里永远是左值,因为它有名字。std::forward<T>(arg) 的作用就是利用 T 中保存的信息,把它恢复成调用者传入时的样子。

std::move 与 std::forward 的分工

一个透明的 wrapper#

把前面的目标函数和三个 wrapper 放在一起,差别很清楚:

void target(const std::string& s) {
std::cout << "copy/read: " << s << '\n';
}
void target(std::string&& s) {
std::cout << "move/sink: " << s << '\n';
}
template<class T>
void wrapper_bad(T&& arg) {
target(arg); // arg 有名字,永远按左值传
}
template<class T>
void wrapper_move(T&& arg) {
target(std::move(arg)); // 永远按右值传,可能误伤左值
}
template<class T>
void wrapper(T&& arg) {
target(std::forward<T>(arg)); // 左值仍是左值,右值仍是右值
}

调用结果可以按值类别理解:

std::string name = "Alice";
wrapper_bad(name); // target(const string&)
wrapper_bad(std::string("Bob")); // target(const string&):右值丢失了
wrapper_move(name); // target(string&&):左值被强行移动
wrapper_move(std::string("Bob")); // target(string&&)
wrapper(name); // target(const string&):保持左值
wrapper(std::string("Bob")); // target(string&&):保持右值

这就是“完美”的含义:外层函数不增加额外拷贝,也不把左值擅自变成右值;它尽量把实参原样交给下一层。

多个参数:Args&&...forward<Args>(args)...#

实际代码里,完美转发经常和变参模板一起出现。比如一个简化版的 make_unique

#include <memory>
#include <utility>
template<class T, class... Args>
std::unique_ptr<T> make_object(Args&&... args) {
return std::unique_ptr<T>(
new T(std::forward<Args>(args)...)
);
}

这里有两个 ...

Args&&... args

表示可以接任意多个参数。每个参数都是一个转发引用:

std::forward<Args>(args)...

表示把每个参数分别按原来的左值 / 右值属性转发给 T 的构造函数。

看一个类:

struct User {
std::string name;
int age;
User(const std::string& n, int a)
: name(n), age(a) {
std::cout << "copy name\n";
}
User(std::string&& n, int a)
: name(std::move(n)), age(a) {
std::cout << "move name\n";
}
};

make_object 构造它:

std::string n = "Alice";
auto u1 = make_object<User>(n, 20);
// n 是左值,转发后仍是左值
// 选择 User(const std::string&, int)
auto u2 = make_object<User>(std::string("Bob"), 21);
// 临时 string 是右值,转发后仍是右值
// 选择 User(std::string&&, int)

如果 make_object 里写成 new T(args...),所有 args 都是有名字的函数形参,都会按左值传给构造函数。右值信息就丢了。

如果写成 new T(std::move(args)...),所有参数都会按右值传。调用者传进来的左值也可能被搬空。

std::forward<Args>(args)... 的意义就是逐个参数恢复原样。

emplace_back 为什么离不开完美转发#

std::vector::push_back 接收一个已经构造好的对象,再把它拷贝或移动进容器:

std::vector<User> users;
std::string first = "Alice";
User u(first, 20);
users.push_back(u); // 拷贝已有对象
users.push_back(User(std::string("Bob"), 21)); // 移动临时对象

emplace_back 则是把构造参数直接传进容器内部,在那块内存上原地构造对象:

std::vector<User> users;
std::string name = "Alice";
users.emplace_back(name, 20); // name 是左值,按左值转发
users.emplace_back(std::string("Bob"), 21); // 临时 string 是右值,按右值转发

它内部大致需要做这样的事:

template<class... Args>
void emplace_back(Args&&... args) {
// 在 vector 的未初始化内存上构造一个 User
construct_at(storage, std::forward<Args>(args)...);
}

如果不用完美转发,emplace_back(name, 20)emplace_back(std::string("Bob"), 21) 在内部都会变成左值参数,临时对象的移动机会就没了。emplace 系列函数之所以能把“构造参数”原样送到元素构造函数,靠的就是 Args&&...std::forward<Args>(args)...

通用包装器#

日志、计时、重试、延迟执行这类工具函数也常常需要完美转发。外层函数只想加一点额外行为,不应该改变内层函数看到的参数。

template<class F, class... Args>
void log_call(F&& f, Args&&... args) {
std::cout << "calling...\n";
std::forward<F>(f)(std::forward<Args>(args)...);
}

使用时:

void save(const std::string& s) {
std::cout << "copy/read save: " << s << '\n';
}
void save(std::string&& s) {
std::cout << "move save: " << s << '\n';
}
std::string name = "Alice";
log_call(
[](auto&& x) {
save(std::forward<decltype(x)>(x));
},
name
); // name 仍按左值进入 save
log_call(
[](auto&& x) {
save(std::forward<decltype(x)>(x));
},
std::string("Bob")
); // 临时对象仍按右值进入 save

这个例子里 lambda 的 auto&& x 也是转发引用。因为 lambda 没有显式写出模板参数名,所以常用 decltype(x) 作为 std::forward 的模板参数。普通函数模板里优先写原始模板参数 TArgs,更直观:

template<class T>
void wrapper(T&& arg) {
target(std::forward<T>(arg)); // 首选这种写法
}

std::move 的分工#

std::movestd::forward 都不真的移动对象。它们都只是类型转换,真正的移动发生在后续的移动构造、移动赋值或接收右值引用的函数里。

区别在语义上:

  • std::move(x):我明确不再需要 x 的当前值,可以把它当右值使用。
  • std::forward<T>(x):我在模板转发函数里,调用者怎么传进来,我就怎么传下去。

需要把参数存到对象内部时,std::move 很合适:

class Person {
std::string name_;
public:
void set_name(std::string name) {
name_ = std::move(name);
}
};

这里的 name 是函数自己的局部对象。调用方传左值时,先拷贝出这个局部对象;调用方传右值时,先移动出这个局部对象。到了函数体里,set_name 的目的就是把这个局部对象存进成员,后面也不再需要它,所以 std::move(name) 正合适。

只负责代传时,std::forward 才合适:

template<class T>
void pass_to_target(T&& arg) {
target(std::forward<T>(arg));
}

这两种函数的语义不同:

  • sink 函数:我要吃掉 / 存下这个对象,用 std::move
  • forwarding wrapper:我只是替你传给下一层,用 std::forward

常见误区#

T&& 不一定是转发引用#

只有待推导的模板参数直接写成 T&& 时,才是转发引用。具体类型的 std::string&&const T&&std::vector<T>&&、类模板里已经确定的 T&&,都不是这个规则。

std::forward 不是高级版 std::move#

std::move 不需要模板参数,因为它无条件转成右值。

std::move(x);

std::forward 必须知道 T,因为它要根据 T 判断恢复成左值还是右值。

std::forward<T>(x);

随便写一个类型进去通常是错的:

template<class T>
void wrapper(T&& arg) {
target(std::forward<std::string>(arg)); // 错:强行按 std::string&& 转发
}

这里应该写 std::forward<T>(arg)T 才保存了调用者传入时的值类别。

forward 之后不要假设参数还保持原值#

如果调用者传进来的是右值,std::forward<T>(arg) 会把它作为右值交给下一层。下一层可能真的把它移动走。

template<class T>
void wrapper(T&& arg) {
target(std::forward<T>(arg));
// 如果 target 移动了 arg,这里就不应该继续依赖 arg 的具体值
}

转发之后,最好只做收尾工作,不要再读取参数并假设它还和之前一样。

const 右值通常也移动不了#

std::forward 会保留 const。如果调用者传进来的是 const std::string,即使它被转发成右值,类型也会是 const std::string&&。大多数移动构造函数接收的是非 const 的 std::string&&,所以最后仍然可能退回拷贝。

const std::string name = "Alice";
wrapper(std::move(name)); // 转发后是 const std::string&&,通常不能真正移动

这和 std::move(const T) 的问题一样:右值引用只说明“可以绑定右值”,不代表一定能修改源对象并偷走资源。

经验法则#

普通代码里,只在确定不再需要某个对象的当前值时用 std::move

std::string a = "hello";
std::string b = std::move(a); // 之后不要再依赖 a 的具体内容

模板转发代码里,形参写成 T&&Args&&...,往下一层传时用对应的 std::forward<T>std::forward<Args>

template<class T>
void wrapper(T&& arg) {
target(std::forward<T>(arg));
}
template<class... Args>
void wrapper_many(Args&&... args) {
target(std::forward<Args>(args)...);
}

如果暂时不写模板库,只需要先认识完美转发出现的位置:emplace_backmake_unique、通用 wrapper、任务调度器、日志 / 计时包装器。这些函数本身不该决定参数要不要被移动,它们只是把调用者的意图原样交给下一层。

C++完美转发
https://blog.gzher.com/posts/cpp-perfect-forwarding/
作者
中会 / ChatGPT 5.5
发布于
2026-07-08
许可协议
CC BY-NC-SA 4.0