本节摘要:中转函数(工厂、包装器、emplace 类接口)要把参数原样转交终点:调用方给的是右值,终点就该收到右值走移动;给的是左值,就该收到左值走拷贝。万能引用 T&& 加 std::forward 实现这种"原样转交";转发丢一次值类别,3.3 的过户优化就断在中转站。
上一节过户的前提是"右值属性活着抵达终点"。可现实代码里对象往往要经过几层中转:工厂函数包一层、日志切面包一层、泛型容器再包一层。每一层转发如果用普通参数接(按值或按具名引用),右值会在中转站被"换票"——具名参数永远是左值——终点收到的就成了左值,移动退化成拷贝。本节解决这最后一环,也是本章账目流转的收束站。
写同一个工厂函数的三个版本,流水直接暴露差异:
#include <cstdio> #include <memory> #include <utility> struct Job { explicit Job(int id) : id_(id) { std::printf(" 构造 Job\n"); } Job(const Job& other) : id_(other.id_) { std::printf(" 拷贝构造 Job(抄账)\n"); } Job(Job&& other) noexcept : id_(other.id_) { std::printf(" 移动构造 Job(过户)\n"); } int id_; }; // 版本一:按值转发——多一次拷贝或移动,账目不保真 template <typename T> std::unique_ptr<T> make_by_value(T v) { return std::make_unique<T>(v); // v 是具名左值,永远走拷贝 } // 版本二:万能引用加 forward——原样转交 template <typename T, typename Arg> std::unique_ptr<T> make_perfect(Arg&& arg) { return std::make_unique<T>(std::forward<Arg>(arg)); } int main() { std::printf("版本一:传右值给按值参数\n"); auto j1 = make_by_value<Job>(Job(1)); // 中转站内 v 是左值:终点收到左值 std::printf("版本二:传右值给万能引用\n"); auto j2 = make_perfect<Job>(Job(2)); // forward 保真:终点收到右值 std::printf("版本二:传左值给万能引用\n"); Job keep(3); auto j3 = make_perfect<Job>(keep); // 左值仍按左值转发:走拷贝,正确 return 0; }
输出确定:
版本一:传右值给按值参数 构造 Job 拷贝构造 Job(抄账) 版本二:传右值给万能引用 构造 Job 移动构造 Job(过户) 版本二:传左值给万能引用 构造 Job 拷贝构造 Job(抄账)
版本一的三行流水暴露了两次账目损失:构造出的临时 Job 先被按值参数抄进中转站,然后中转站里的 v 又以左值身份被终点再抄一次——过户机会全灭。版本二只有终点一次构造:右值进来走移动,左值进来走拷贝,两种情形都符合"原样"的承诺。
这依赖两个机制。其一,万能引用:模板里写成 T&&(T 是待推导的模板参数)的形式很特殊——传左值时 T 推导为左值引用,传右值时推导为普通类型,引用折叠规则让同一个形参两种形态都能接。其二,std::forward(arg):按推导结果恢复值类别——右值情形把 arg 恢复成右值,左值情形保持左值。记法:万能引用负责"两种票都能接",forward 负责"什么票转什么票"。
万能引用不是处处生效,三个现场要认清:
现场一:具名即左值。 任何具名变量都是左值,哪怕它类型是右值引用。void f(Widget&& w) 里的 w 是左值——想在 f 内部把它继续传走,仍要 std::move(w)。这解释了很多"我明明传的右值为什么走拷贝"的疑惑。
现场二:万能引用必须出现在可推导的模板参数上。 std::vector<T>&& 不是万能引用(T 已在外层确定),成员函数里的 T&&(T 来自类而不是该函数的推导)也不是。类型不用推导,就没有折叠戏法,就是普通的右值引用。
现场三:重载会截胡。 一组重载里既有 f(const std::string&) 又有万能引用版 template <typename T> f(T&&),传非 const 字符串字面量时万能引用常常精确匹配胜出——转发进终点后行为可能出乎所料。泛型中转层避免与具体类型重载共存,是省心写法。
标准库的 emplace 接口是完美转发的范本——对照 push_back 看账目:
#include <cstdio> #include <string> #include <vector> struct Entry { explicit Entry(std::string tag) : tag_(std::move(tag)) { std::printf(" 就地构造\n"); } Entry(const Entry&) = delete; // 禁拷贝:逼出原地构造 Entry(Entry&&) noexcept = delete; std::string tag_; }; int main() { std::vector<Entry> v; v.reserve(4); // 预留,避免中途搬家 v.emplace_back("audit-01"); // 字符串字面量直达构造函数 std::printf("容器内条目:%s\n", v[0].tag_.c_str()); return 0; }
输出:
就地构造 容器内条目:audit-01
emplace_back 把参数完美转发给元素的构造函数,对象直接在容器账页上就地产出——push_back(Entry("audit-01")) 则要先在站外构造再过户进来,多一道手续。Entry 禁了拷贝与移动,push_back 的写法直接编译不过,emplace 是唯一通路,这张对比让人过目不忘。
背景:主线四站流水要加耗时打点,方案是写一个通用的中转包装:进站打点、调用内层函数、出站打点。实现者第一版用按值参数接函数对象,压测发现中转层多出一堆不必要的拷贝。
操作:把包装改成万能引用加 forward 的标准形态——内层调用写作 inner(std::forward<Args>(args)...),参数包把每个参数的值类别逐个保真。移动与拷贝的选择权交还给终点函数。
结果:中转层账目归零:打点之外零开销,压测数据与不包日志时持平。
解读:中转层是账目流转里最容易"顺手抄账"的地方——写按值参数图省事,每一层就多抄一笔。完美转发的意义不是炫技,而是让中转层在账本上隐身:它只该经手票据,不该动账。判断标准很简单:中转函数不拥有参数,就永远不该为它掏拷贝的钱。
变式:完美转发有个著名盲区——传大对象成员的位域或重载名时转发会失败(需要显式类型处理),实际工程遇到概率低,知道有这回事即可。另一个实用变体是"转发加 move 收尾":中转函数最后一次使用参数后 move 进终点,前文 emplace 与按值加 move 惯用法都是这个模式的化身。
⚠️ 常见坑:对万能引用参数无脑 std::move。万能引用即接左值也接右值,move 会把调用方的左值参数悄悄过户走——调用方可能还要继续用。转发场景用 forward(保真),确定接管场景才用 move(过户),两者写法相近、语义迥异。
至此账目流转走完:票据选型、只读冻结、过户、保真转发。下一章账本升维到编译期——一份泛型代码如何为多种类型各开一张票、实例化的成本记在哪、Concepts 怎么把不合格的账户拦在票面之外。