完美转发
需要预先了解:
1. 转发的问题
写一个包装函数(wrapper)时,你常常希望它把参数"原样"转交给另一个函数——参数的类型、值类别(左值/右值)、const 属性都保持不变。这件事比听起来难,先看直接的做法有什么问题。
void target(int& x) { /* 左值版本 */ }
void target(int&& x) { /* 右值版本 */ }
template <typename T>
void wrapper(T arg) // 按值传递
{
target(arg);
}
按值传递有两个问题:
- 参数被拷贝了一份(右值时走移动构造,但左值时一定是深拷贝),大对象开销明显
- 无论调用者传的是左值还是右值,
arg在函数内部都是一个有名字的左值——target(arg)永远调用左值版本,值类别信息丢失
改用引用传递呢?T& 只能绑定左值,const T& 虽然能绑定右值,但把 const 也带进去了,而且函数内部拿到的仍然是左值。普通的引用写法无法同时保留"类型"和"值类别"。
2. 万能引用
模板参数写成 T&& 时,情况完全不同:
template <typename T>
void wrapper(T&& arg);
这里的 T&& 不是普通的右值引用,而是万能引用(universal reference,标准术语叫 forwarding reference,转发引用)。它的推导规则取决于实参的值类别:
| 实参 | T 推导为 | arg 的实际类型 |
|---|---|---|
左值(如 int x) |
int& |
int&(折叠结果) |
右值(如 10) |
int |
int&& |
int x = 10;
wrapper(x); // T = int&,arg 是 int&,绑定左值
wrapper(10); // T = int,arg 是 int&&,绑定右值
关键在于:同一个模板可以同时接受左值和右值,并且把实参的值类别"编码"进了 T 里——左值对应 T = 类型&,右值对应 T = 类型。
注:判断是不是万能引用,看
T&&中的 T 是否由推导决定。template <typename T> void f(T&&)是万能引用;void f(int&&)、template <typename T> void f(const T&&)、std::vector<int>&&都只是普通的右值引用。auto&&也是万能引用(C++14 泛型 lambda 就靠它)。
2.1 引用折叠
为什么 T = int& 时 arg 能是 int&?因为 T&& 替换为 int& && 后,按引用折叠(reference collapsing)规则化简:
| 原始组合 | 折叠结果 |
|---|---|
T& & |
T& |
T& && |
T& |
T&& & |
T& |
T&& && |
T&& |
规则一句话:只要有一个左值引用参与,结果就是左值引用;两个都是右值引用,结果才是右值引用。
3. std::forward
3.1 命名后的参数是左值
万能引用解决了"接收"的问题,但还没解决"转交"的问题。函数体内的 arg 有名字,有名字的表达式是左值——即使它绑定的是一个右值:
template <typename T>
void wrapper(T&& arg)
{
target(arg); // 无论 arg 绑定什么,arg 本身是左值,永远调用左值版本
}
实测对比(consume 区分左值/右值重载):
wrapper_fwd(Probe()); // 传右值 + std::forward -> "接收右值"
wrapper_plain(Probe()); // 传右值但不 forward -> "接收左值"(值类别丢失)
所以转交时必须手动"恢复"值类别,这就是 std::forward 的工作。
3.2 std::forward 的实现
std::forward 的原型(C++14 起)是:
template <typename T>
constexpr T&& forward(std::remove_reference_t<T>& t) noexcept
{
return static_cast<T&&>(t);
}
它本身不做任何操作,只做一次类型转换,效果取决于模板参数 T:
T = int&时,T&&折叠为int&—— 返回左值引用,恢复左值性T = int时,T&&就是int&&—— 返回右值引用,恢复右值性
还记得万能引用的推导吗?wrapper 里的 T 恰好编码了实参的值类别,把它原样传给 std::forward<T>,值类别就恢复得刚刚好:
template <typename T>
void wrapper(T&& arg)
{
target(std::forward<T>(arg)); // T 是 int& -> 传左值;T 是 int -> 传右值
}
为什么必须显式写 <T>? forward 的参数是 remove_reference_t<T>&——如果让编译器自己推导,T 永远被推导为左值引用类型,forward 就退化成"永远返回左值引用",失去意义。显式指定是它正常工作的前提。
3.3 完整验证
#include <cstdio>
#include <utility>
struct Probe
{
Probe() { printf(" 默认构造\n"); }
Probe(const Probe&) { printf(" 拷贝构造\n"); }
Probe(Probe&&) noexcept { printf(" 移动构造\n"); }
};
void consume(Probe& p) { printf(" 接收左值\n"); }
void consume(Probe&& p) { printf(" 接收右值\n"); }
// 用 std::forward 完美转发
template <typename T>
void wrapper_fwd(T&& arg)
{
consume(std::forward<T>(arg));
}
// 不用 forward,直接传
template <typename T>
void wrapper_plain(T&& arg)
{
consume(arg);
}
int main()
{
Probe p;
printf("wrapper_fwd(p): 传入左值 ->\n"); wrapper_fwd(p);
printf("wrapper_fwd(Probe()): 传入右值 ->\n"); wrapper_fwd(Probe());
printf("wrapper_plain(p): 直接传左值 ->\n"); wrapper_plain(p);
printf("wrapper_plain(Probe()): 直接传右值 ->\n"); wrapper_plain(Probe());
return 0;
}
编译并运行代码后,看到这样的输出:
默认构造
wrapper_fwd(p): 传入左值 ->
接收左值
wrapper_fwd(Probe()): 传入右值 ->
默认构造
接收右值
wrapper_plain(p): 直接传左值 ->
接收左值
wrapper_plain(Probe()): 直接传右值 ->
默认构造
接收左值
wrapper_fwd 完整保留了调用者的意图:左值传左值、右值传右值。wrapper_plain 中右值退化为左值——如果 consume 内部用参数拷贝构造一个大对象,就会白白多一次深拷贝。
注:"完美"指的是值类别的保持:
std::forward不会把左值变成右值,也不会把右值变成左值,它只是把T里已经记录的信息翻译回引用类型。
4. std::forward 与 std::move
两个函数长得几乎一样(都是 static_cast<T&&>),但用途完全不同:
std::move |
std::forward<T> |
|
|---|---|---|
| 行为 | 无条件转成右值引用 | 按 T 恢复值类别 |
| 模板参数 | 自动推导,无需指定 | 必须显式指定 |
| 使用场景 | 明确表示"这个对象我不要了",触发移动 | 泛型代码中转发参数,保持调用者的意图 |
std::string s = "hello";
consume(std::move(s)); // 无论 s 原本是什么,这里强制按右值处理
consume(std::forward<std::string&>(s)); // 明确按左值转发(等价于直接传 s)
一个简单的判断方法:在模板里转发参数用 forward,在具体类型上转移所有权用 move。把 std::forward 用在非模板代码里没有意义——那时你已经知道参数是左值还是右值了。
5. 应用:工厂函数
完美转发最常见的应用是工厂函数——接收任意参数、构造目标对象。手写一个 std::make_unique 的简化版:
#include <memory>
#include <utility>
template <typename T, typename... Args>
std::unique_ptr<T> make_unique(Args&&... args)
{
return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}
Args&&... 是万能引用参数包,std::forward<Args>(args)... 把每个参数按原来的值类别展开转交。效果:
struct Widget
{
explicit Widget(std::string name) : m_name(std::move(name)) {}
std::string m_name;
};
std::string s = "hello";
auto w1 = make_unique<Widget>(s); // 左值 -> Widget 拷贝构造
auto w2 = make_unique<Widget>(std::move(s)); // 右值 -> Widget 移动构造
auto w3 = make_unique<Widget>("hello"); // 字面量 -> 直接构造
如果把 std::forward 换成直接传参,w2 和 w3 也会走拷贝路径,传入右值时白白多一次深拷贝。std::vector 的 emplace_back、std::thread 的构造函数等,内部都是同一个模式。
注意:完美转发不完美的地方——位域不能作为转发实参(无法绑定非 const 引用)、花括号初始化列表无法推导
T(要用auto转换或显式构造)。这些属于边角情况,遇到时知道原因即可。


