std::forward<T>(arg)转发的既不是对象本身,也不是引用本身,而是实参原本的值类别(value category)——传进来是左值,它还你一个左值;传进来是右值,它还你一个右值。听上去很虚,但一旦漏写,代价是实打实的:本该移动的变成拷贝,本该只读的变成把调用方的对象搬空。这篇把「万能引用 → 类型推导 → 引用折叠 →forward」这条链路完整推一遍,手写一个my_forward验证原理,再把四类转发失败逐个跑给你看。
官方文档:std::forward (cppreference)
1. 引子:多出来的一次拷贝
先看一个最常见的转发中继:relay接住参数,再交给sink处理。
template<typenameT>voidrelay(T&&x){sink(x);// 问题出在这一行}形参写成T&&,看起来左值右值都能接,很多人以为「原样传下去」是自动的。实际上不是:x是有名字的变量,任何具名变量作为表达式出现时,值类别一律是左值。所以哪怕调用方送来的是一个临时对象(右值),sink(x)送出去的也是左值——如果sink有「const T&拷贝」和「T&&移动」两个重载,被选中的永远是前者,移动语义在这里断掉了。
要修好它,得先搞清楚x的类型到底是什么。
2. 核心:forward 恢复的是值类别
T&&只有出现在类型推导上下文(模板参数T&&、auto&&)里才是万能引用(forwarding reference);一旦T是确定的具体类型,它就是一个普通的右值引用。万能引用接住实参时,T的推导结果取决于实参的值类别:
| 实参的值类别 | T推导为 | 形参T&&折叠后 | std::forward<T>(x)的结果 |
|---|---|---|---|
左值(int a) | int& | int& &&→int& | 左值(static_cast<int&>) |
右值(42) | int | int &&→int&& | 右值(static_cast<int&&>) |
两条规则拼出了整件事:引用折叠(reference collapsing)里只有&& &&折叠成&&,其余三种都折叠成&;而forward的全部实现就是一句static_cast<T&&>(arg)。把这条链路画出来:
实参是左值 a 实参是右值 42 │ │ ▼ ▼ T 推导为 int&(带引用) T 推导为 int(不带引用) │ │ ▼ ▼ 形参声明 T&& → int& && 形参声明 T&& → int && │ │ ▼ 引用折叠②:& + && = & ▼ 引用折叠④:&& + && = && x 的类型是 int& x 的类型是 int&& │ │ └───────────────┬──────────────────────┘ ▼ 具名变量 x 作为表达式,值类别一律是「左值」 ▼ std::forward<T>(x) 展开成 static_cast<T&&>(x) │ ┌───────────────┴───────────────┐ ▼ ▼ static_cast<int&>(x) → 左值 static_cast<int&&>(x) → 右值 下一层选拷贝 / 只读重载 下一层选移动重载一句话:T里存着「实参原本是左值还是右值」这个信息,forward的作用就是把这份信息还原成表达式的值类别。所以std::forward只在万能引用语境下有意义——如果你手里是int&&这样的普通右值引用,forward和move没有区别,写了只是自欺欺人。
官方文档:Template argument deduction (cppreference)
3. 手写一个 my_forward
标准库的std::forward有两个重载,核心那个可以简化成这样:把T&&通过引用折叠还原,static_cast出去即可。
// demo.cpp — 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo#include<iostream>#include<type_traits>#include<utility>// 手写版 forward:一句 static_cast<T&&>,靠引用折叠恢复值类别template<typenameT>constexprT&&my_forward(std::remove_reference_t<T>&arg)noexcept{returnstatic_cast<T&&>(arg);}voidsink(int&){std::cout<<"sink(int&) 收到左值\n";}voidsink(int&&){std::cout<<"sink(int&&) 收到右值\n";}template<typenameT>voidrelay(T&&x){sink(my_forward<T>(x));// 转发时还原 x 原本的值类别}intmain(){inta=1;relay(a);// 左值进去 → 左值出来relay(42);// 右值进去 → 右值出来// 编译期核对:my_forward 的返回类型与 std::forward 完全一致static_assert(std::is_same<decltype(my_forward<int&>(a)),int&>::value,"");static_assert(std::is_same<decltype(my_forward<int>(a)),int&&>::value,"");std::cout<<"my_forward 的返回类型与 std::forward 一致\n";return0;}sink(int&) 收到左值 sink(int&&) 收到右值 my_forward 的返回类型与 std::forward 一致relay(a):T推成int&,T&&折叠成int&,static_cast<int&>给出左值。relay(42):T推成int,T&&就是int&&,static_cast<int&&>给出右值。这里std::remove_reference_t<T>&是必须的——形参不能直接写T&&,否则左值实参会推导不出T,forward也没法显式指定模板参数。
官方文档:C++ Core Guidelines · F.19: “Use forward for forwarding arguments”
4. 转发失败①:忘记 forward,右值退化成拷贝
最容易犯也最隐蔽的一类。用一个带拷贝/移动计数的类型来量化损失:
// demo.cpp — 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo#include<iostream>#include<utility>structTrace{staticintcopies;staticintmoves;intid;explicitTrace(inti):id{i}{}Trace(constTrace&other):id{other.id}{++copies;}Trace(Trace&&other)noexcept:id{other.id}{++moves;}};intTrace::copies=0;intTrace::moves=0;// 忘记 forward:x 具名 → 左值 → 走拷贝构造template<typenameT>voidrelay_bad(T&&x){Trace t{x};(void)t;}// 正确:forward 还原值类别 → 走移动构造template<typenameT>voidrelay_ok(T&&x){Trace t{std::forward<T>(x)};(void)t;}voidreport(constchar*tag){std::cout<<tag<<" 拷贝="<<Trace::copies<<" 移动="<<Trace::moves<<'\n';}intmain(){Trace::copies=0;Trace::moves=0;relay_bad(Trace{1});report("忘记 forward:");Trace::copies=0;Trace::moves=0;relay_ok(Trace{1});report("使用 forward:");// 补充:左值实参时,忘写 forward 不会更差(左值本来就是左值)Trace::copies=0;Trace::moves=0;Trace owned{9};relay_bad(owned);report("忘写 forward + 左值实参:");return0;}忘记 forward: 拷贝=1 移动=0 使用 forward: 拷贝=0 移动=1 忘写 forward + 左值实参: 拷贝=1 移动=0对比很直观:
| 写法 | 右值实参的开销 | 左值实参的开销 | 为什么 |
|---|---|---|---|
sink(x)(漏写) | 1 次拷贝 | 1 次拷贝 | x具名是左值,永远走拷贝重载 |
sink(std::forward<T>(x)) | 1 次移动 | 1 次拷贝 | 右值还原成右值,走移动重载 |
注意第三行:漏写forward只在右值实参上有损失。左值实参本来就该拷贝,漏写只是「碰巧没写错」。这个特性让 bug 极难在测试里暴露——只有当调用方传临时对象、而sink又是按值收或走移动重载时,性能损失才出现。所以规则是:只要形参是万能引用、又要往下一层传,就无条件写forward,别靠推理。
5. 转发失败②:用 std::move 顶替 forward
另一种常见错法:知道要「脱掉具名变量的左值身份」,于是随手写std::move。std::move的语义是无条件转成右值,它不看实参原本是什么——左值传进来也会被当成右值转走,结果是把调用方的对象搬空。
// demo.cpp — 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo#include<iostream>#include<string>#include<utility>structPayload{std::string name;boolmoved_from=false;explicitPayload(std::string n):name{std::move(n)}{}Payload(constPayload&other):name{other.name}{std::cout<<" [拷贝构造被调用]\n";}Payload(Payload&&other)noexcept:name{std::move(other.name)}{other.moved_from=true;// 标记:源对象已被搬走std::cout<<" [移动构造被调用]\n";}};voidsink(Payload p){std::cout<<"sink 收到 "<<p.name<<'\n';}template<typenameT>voiduse_forward(T&&x){sink(std::forward<T>(x));}// 保留值类别template<typenameT>voiduse_move(T&&x){sink(std::move(x));}// 无条件转右值intmain(){Payload a{"甲"};std::cout<<"use_forward(a):\n";use_forward(a);std::cout<<"调用方 a 被搬空了吗: "<<(a.moved_from?"是":"否")<<'\n';Payload b{"乙"};std::cout<<"use_move(b):\n";use_move(b);std::cout<<"调用方 b 被搬空了吗: "<<(b.moved_from?"是":"否")<<'\n';return0;}use_forward(a): [拷贝构造被调用] sink 收到 甲 调用方 a 被搬空了吗: 否 use_move(b): [移动构造被调用] sink 收到 乙 调用方 b 被搬空了吗: 是forward版本把左值如实转成左值,sink按值收参数时拷贝一份,调用方的a完好;move版本把左值强行当右值送走,b的name被移动构造吃掉,moved_from被置位。std::move是承诺「我不要它了」,std::forward只是转达「调用方原本是什么」,两者不能互换。顺带一提,如果sink的参数是const Payload&,move版本连编译错误都不会报——移动根本没发生,但你已经写下了一句语义错误的代码,之后任何一次签名调整都可能引爆它。
6. 转发失败③④:推导不出来的实参
万能引用靠模板实参推导工作,所以凡是推导不出T的实参,转发就失败。两个经典场景:
// demo.cpp — 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo#include<iostream>#include<utility>#include<vector>voidtake_concrete(conststd::vector<int>&v){std::cout<<"take_concrete 收到 "<<v.size()<<" 个元素\n";}// ③ 万能引用接不住大括号初始化列表:T 无法推导template<typenameT>voidrelay(T&&x){take_concrete(std::forward<T>(x));}voidtarget(int){std::cout<<"target(int)\n";}voidtarget(double){std::cout<<"target(double)\n";}// ③' 重载函数名同理:一个名字对应多个类型,T 无法推导template<typenameF>voidcall_with_int(F&&f){std::forward<F>(f)(3);}intmain(){take_concrete({1,2,3});// OK:形参是确定的 vector<int>,能吃 braced-init-list// relay({1, 2, 3}); // 反例,不要这么写:编译错误,T 推导失败relay(std::vector<int>{4,5});// OK:先造出实实在在的 vector 再转发// call_with_int(target); // 反例,不要这么写:重载函数名推导不出 Fcall_with_int(static_cast<void(*)(int)>(target));// OK:先消歧成具体函数指针return0;}take_concrete 收到 3 个元素 take_concrete 收到 2 个元素 target(int)③ 的根因是标准的推导规则:只有当形参是std::initializer_list<T>(或它的引用),{1, 2, 3}才能参与推导;T&&形参面对 braced-init-list 属于「非推导上下文」,直接失败。④ 的根因是重载集不是一个类型,编译期没法确定F取哪个重载。两者的修法思路一样:先把实参「实体化」成一个有确定类型的对象,再交给万能引用——写成std::vector<int>{4, 5}或static_cast<void (*)(int)>(target)。顺带说一句,这也是std::make_unique<T>(args...)这类工厂函数的局限:它转发不了{...},标准库为此专门补了std::initializer_list重载(如std::vector::emplace_back(initializer_list))。
7. 完整示例:零拷贝的转发中继
把前面几节的要点合起来。这个程序让dispatch用重载区分「只读处理」和「接管处理」,转发中继负责把值类别如实传下去:
// demo.cpp — 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo#include<iostream>#include<string>#include<utility>structPayload{staticintcopies;staticintmoves;std::string name;boolmoved_from=false;explicitPayload(std::string n):name{std::move(n)}{}Payload(constPayload&other):name{other.name}{++copies;}Payload(Payload&&other)noexcept:name{std::move(other.name)}{other.moved_from=true;++moves;}};intPayload::copies=0;intPayload::moves=0;voiddispatch(constPayload&p){std::cout<<"只读处理: "<<p.name<<'\n';}voiddispatch(Payload&&p){std::cout<<"接管处理: "<<p.name<<'\n';}// 转发中继:参数原样交给下一层,自己不产生任何拷贝template<typenameT>voidrelay(T&&x){dispatch(std::forward<T>(x));}intmain(){Payload owned{"配置文件"};std::cout<<"-- 传左值 --\n";relay(owned);std::cout<<"-- 传右值 --\n";relay(Payload{"临时句柄"});std::cout<<"拷贝次数="<<Payload::copies<<" 移动次数="<<Payload::moves<<'\n';std::cout<<"owned 被搬空了吗: "<<(owned.moved_from?"是":"否")<<'\n';return0;}-- 传左值 -- 只读处理: 配置文件 -- 传右值 -- 接管处理: 临时句柄 拷贝次数=0 移动次数=0 owned 被搬空了吗: 否两个关键结论:重载决议在转发后依然正确(左值落到const Payload&,右值落到Payload&&),而且转发本身不拷贝任何东西——relay传的是引用,计数全是 0。这不是省了一次拷贝的问题,而是让「按值收参数 + 内部移动」这种现代写法在中间加一层包装后依然不被劣化。Core Guidelines 的 F.19 给出的规则很短:把参数继续往下传时,用std::forward,并且只传一次(转发后就不要再用这个参数了)。
8. 延伸阅读
- std::forward (cppreference):两个重载的签名与返回值,
<utility>里最短的实现之一。 - Template argument deduction (cppreference):为什么
{1,2,3}和重载函数名推导不出T,规则原文在这里。 - Reference declaration · Reference collapsing (cppreference):四条折叠规则的正式表述,
& &&与&& &都折成&。 - C++ Core Guidelines · F.19 (isocpp.github.io):
forward的用法约定,附带「只转发一次」的提醒。 - Compiler Explorer (godbolt.org):把
sink(x)和sink(std::forward<T>(x))的汇编贴出来对比,能直接看到多出来的那次拷贝。
9. 一句话总结
std::forward<T>(arg)只是static_cast<T&&>(arg),它借助「模板推导把左值记成T&、右值记成T」和「引用折叠只有&& &&留&&」这两条规则,把实参原本的值类别还原出来;漏写它就会在右值实参上悄悄退化成拷贝,用std::move顶替它则会把调用方的对象搬空,而{1,2,3}和重载函数名这类推导不出来的实参必须先实体化再转发。