ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++完美转发详解:右值引用与std::forward底层原理及实践

C++完美转发详解:右值引用与std::forward底层原理及实践 如果你写过包装函数、工厂函数或者任何一层“中间层”大概率经历过这样一个场景明明调用方传进来的是一个临时对象你只是把它转交给下层构造函数结果中间却多出来一两次深拷贝。C11 里引入的完美转发perfect forwarding就是专门解决这个问题的一套机制——它的目标很朴素参数进来时什么样转交出去就什么样左值还是左值右值还是右值const 就还是 const。这篇文章我会从最原始的问题开始把右值引用、引用折叠、万能引用这三者的关系彻底拆开再把 std::forward 的源码和正确用法过一遍最后整理这些年我在实际项目中踩过的坑和排查方法。适合已经会写模板函数、但还没完全想明白 forward 背后逻辑的 C 开发者。1. 完美转发到底在解决什么问题1.1 一个让你想拆键盘的经典场景很多人的模板之路是从写工厂函数开始的。假设有一个对象 Widget内部保存一个 std::string构造函数分别提供左值版本和右值版本#include iostream #include string #include utility class Widget { public: explicit Widget(const std::string name) : name_(name) { std::cout Widget(const std::string) std::endl; } explicit Widget(std::string name) : name_(std::move(name)) { std::cout Widget(std::string) std::endl; } private: std::string name_; };现在想写一个工厂函数让调用方传入任意参数工厂内部用它构造一个对象。如果按 C11 以前的习惯写template typename T T make_widget(const std::string name) { return T(name); }调用auto w make_widgetWidget(std::string(hello world))时发生的过程是这样的std::string(hello world)是一个右值临时量绑定到const std::string上之后它本身还有一个名字叫name。在函数体里执行T(name)时name是左值所以调用的一定是Widget(const std::string)这个拷贝版本哪怕调用方明确传了一个马上要被销毁的临时量也只能老老实实做一次深拷贝把字符串内容完整复制一遍。想要避免这次拷贝就得给函数模板写两个重载一个处理左值、一个处理右值template typename T T make_widget(const std::string name) { return T(name); } template typename T T make_widget(std::string name) { return T(std::move(name)); }两个参数还算能忍如果工厂函数要接收三个、四个、八个参数呢要组合的重载数量会爆炸。而且这还没考虑 const 版本光是处理左值、右值的排列组合就够写一整天了。模板本来是为了避免重复代码结果因为“参数从外面传进来中间又转手了一次”这个动作把代码又活生生撑大了。1.2 被逐层剥掉的信息真正的问题不在于多写几个重载而在于信息丢失。一个对象从调用点到最终构造函数中间每经过一层函数都至少携带三类信息类型、cv 限定符const/volatile、值类别左值还是右值。按值传递会把类型信息剥掉一层数组会退化成指针const 也会丢失按const T传递虽然保住了 const但把值类别信息彻底抹掉了因为形参名在函数体内永远是左值直接按T传递更麻烦根本接不住右值实参。C11 之前移动语义还不存在大家不太在意“少一次拷贝”这件事反正复制也要不了命。但 C11 引入了右值引用和移动构造函数之后丢失右值信息就等于丧失了一次移动优化的机会这个损失就变得非常痛了。这里有一个新手最容易卡住的概念形参的类型和形参作为表达式时的值类别是两回事。函数体内任何有名字的形参都是左值哪怕它的类型是std::string。所以把一个绑定到右值的形参继续往下传时如果不做任何处理下一层拿到的仍然是左值。这就是为什么“转发”这个动作必须靠额外的机制来完成。1.3 完美转发的验收标准完美转发要达成的效果可以这样描述写一个模板函数形如buildT(args...)让它在语义上等价于直接在函数调用点写出T(args...)。无论调用方传的是左值、右值、const 左值、const 右值还是数组这个模板都要“原封不动”地把参数继续传递下去让下一层函数看到的样子和调用方传进来的样子完全一致。拆开来看就是四条标准左值实参最终绑定到左值引用右值实参最终绑定到右值引用并保留移动机会const 实参最终选择 const 版本的重载数组参数保留完整的数组类型而不退化成指针。只要满足这四条就可以认为这个转发是“完美”的。C11 给出的答案就是三个东西的配合万能引用 引用折叠 std::forward。2. 三个前置概念右值引用、引用折叠、万能引用2.1 右值引用与“可移动”的语义右值引用是 C11 新引入的引用类型用T表示它能绑定到临时对象或者通过std::move转换过来的右值。右值引用存在的意义不是让你“读”临时量而是给你一个机会把这个临时量持有的资源“偷”走。比如移动构造函数里直接把对方的字符串缓冲区指针拿过来再把对方置空整个过程不分配新内存、不复制字节只是交换几个指针。在理解完美转发之前必须接受一个观点右值引用本身不是目的移动语义才是目的右值引用只是把“这个对象可以安全被移走”的信息从调用点传递到函数内部的载体。而完美转发要做的就是不让这条信息在层层转发过程中熄灭。2.2 引用折叠规则一张表记住引用折叠是 C11 为了支撑万能引用而引入的编译规则。编译器在模板推导过程中可能会产生“引用的引用”这种中间类型C 语法不允许直接写出这种类型但编译器内部可以先产生、再按规则折叠成合法类型。规则一共四条折叠前类型折叠后类型T TT TT TT T记忆方法很粗暴只要参与折叠的类型中出现了任何一个左值引用结果就是左值引用只有两侧全是右值引用时结果才是右值引用。用生活一点的话说左值引用的“气场”太强一旦出现就压过右值引用结果永远是左值引用。这个规则在 C11 之前并不存在因为那时模板推导不会产生引用的引用。引入右值引用之后为了让一个函数形参既能复用左值实参、又能接住右值实参编译器必须处理“引用折叠”这个中间过程。把它背下来后面看 std::forward 的实现就会非常顺。2.3 万能引用的判定条件万能引用的学名是转发引用Scott Meyers 在《Effective Modern C》里叫它 universal reference。它长得很简单就是出现在模板函数里的Ttemplate typename T void f(T t); // 万能引用但并非所有T都是万能引用。判定条件有三条形参必须是T这种形式T 必须是函数模板自己推导的类型且形参不能带 const 或 volatile。遇到这三种情况就不是万能引用template typename T void g(const T t); // 不是万能引用只是 const 右值引用 template typename T void h(std::vectorT t); // 不是万能引用vector 是具体模板 void k(int t); // 不是万能引用没有模板推导重点解释一下std::vectorT为什么不是这个形参的类型外壳已经固定死了T 只是 vector 内部元素类型右值引用这个属性是明确的它只能接右值。而T中的 T 是整个类型本身编译器在推导时才会决定把右值引用折叠成什么。万能引用绑定左值实参时T 会被推导为左值引用类型绑定右值实参时T 被推导为普通类型。比如调用实参推导出的 T折叠后的形参类型std::string 左值std::stringstd::stringconst std::string 左值const std::stringconst std::stringstd::string 右值std::stringstd::stringconst std::string 右值const std::stringconst std::string注意第二列推导出的 T 本身也可能带引用修饰符这就是后面 std::forward 能实现“有条件转换”的关键信息源。调用方传入的左值/右值信息其实就隐藏在 T 的类型里而不是在函数形参里。3. std::forward 的实现原理20行代码的“有条件”转换3.1 源码拆解std::forward 在 C11/14 标准库里的实现非常短本质就两个重载template typename T T forward(typename std::remove_referenceT::type param) noexcept { return static_castT(param); } template typename T T forward(typename std::remove_referenceT::type param) noexcept { static_assert(!std::is_lvalue_referenceT::value, can not forward an rvalue as an lvalue); return static_castT(param); }逐行拆开看。第一步typename std::remove_referenceT::type这个形参类型很关键。如果 T 是int那么remove_referenceint::type就是int形参类型就是int如果 T 是int形参类型同样是int。这样做是为了让函数可以接收左值同时避免写出T在 T 本身带引用时形成引用的引用。第二步static_castT(param)是整段代码的核心。编译器在这里做引用折叠T 是int时T折叠成int返回值类型是左值引用转发结果是把参数当左值继续传T 是int时T就是int返回值类型是右值引用转发结果是把参数当右值继续传。这一进一出就实现了“原封不动”的转发。最关键的一点是std::forward 自己不会推导模板参数调用时必须显式写出 T也就是std::forwardT(t)。因为 forward 需要外部告诉它“当初推导出来的 T 是什么”它才能根据 T 是不是引用类型来决定返回左值引用还是右值引用。这也解释了为什么很多新手直接写std::forward(t)会编译失败——模板参数无法推导。3.2 为什么不用 std::move 代替std::move 和 std::forward 是两回事前者无条件把参数变成右值引用后者根据模板参数 T 是有条件地转换。操作作用适用场景std::move(t)无条件把 t 转换为右值引用明确不再使用 t希望触发移动语义std::forward (t)T 是左值引用时返回左值引用否则返回右值引用在模板中把参数继续转发给下一层如果在万能引用函数里用 std::move 代替 std::forward会出大事。调用方传一个左值进来你用了 std::move 强行转成右值下一层函数就会对这个左值做移动操作把调用方的对象掏空调用方后续再使用这个对象时只能拿到一个被“搬空”的壳。反过来如果只传入参数而什么都不做右值实参在函数体内会被当成左值移动构造函数永远不被调用白白丢失移动优化机会。我自己的习惯是想一句话std::move 是“我已经决定放弃这个对象了”std::forward 是“我不知道调用方当初是怎么传的但我必须如实告诉下一层”。这两句话的语义差别就是用去半年踩坑换来的。3.3 可变参数模板与标准写法完美转发在单参数函数上只能算热身真正体现价值的是多参数场景。最典型的例子是自己实现一个 make_uniqueC14 之前标准库里没有#include memory #include string #include utility template typename T, typename... Args std::unique_ptrT my_make_unique(Args... args) { return std::unique_ptrT(new T(std::forwardArgs(args)...)); }这里的写法是固定的必须这么写形参用Args... args转发用std::forwardArgs(args)...。参数包展开时两个包Args和args是按位置一一对应的Args 携带每个参数的推导类型信息args 携带每个参数的实际值forward 逐个处理。记住一个判别口诀我能确认是否写对了调用点传进来的是什么类别forward 之后的表达式就是什么类别。如果调用方传了一个左值std::forwardArgs(args)展开后是左值如果调用方传了右值展开后是右值。在调用点写出my_make_uniqueWidget(std::string(abc))它的语义就等同于在调用点直接写new Widget(std::string(abc))。4. 实操从工厂函数到一个完整示例4.1 一个能打印调用版本的完整 Demo只看原理容易飘必须跑一个能输出构造版本的完整程序才能验证 forward 到底有没有起作用。下面这个例子我建议你直接抄去编译运行#include iostream #include string #include utility class Widget { public: explicit Widget(const std::string name) : name_(name) { std::cout Widget(const std::string) std::endl; } explicit Widget(std::string name) : name_(std::move(name)) { std::cout Widget(std::string) std::endl; } private: std::string name_; }; template typename T, typename Arg void construct_widget(Arg arg) { T w(std::forwardArg(arg)); } int main() { std::string name hello; std::cout --- 传左值 --- std::endl; construct_widgetWidget(name); std::cout --- 传右值 --- std::endl; construct_widgetWidget(std::string(world)); }输出结果是--- 传左值 --- Widget(const std::string) --- 传右值 --- Widget(std::string)如果把std::forwardArg(arg)改成直接写arg也就是T w(arg);第二次输出会变成Widget(const std::string)移动构造函数永远不被调用。这个对比非常直观没有 forward右值被函数形参名字“左值化”了使用了 forward右值信息被恢复移动优化生效。4.2 参数数量扩展与可变参数模板单参数验证通过后扩展成多参数版本并不复杂。下面这个例子模拟一个日志包装器需要把任意参数转发给真正负责格式化输出的函数#include iostream #include string #include utility void do_log(const std::string msg, int level) { std::cout [level level ] msg std::endl; } template typename... Args void log_wrapper(Args... args) { do_log(std::forwardArgs(args)...); } int main() { std::string msg user login; log_wrapper(msg, 1); // 支持左值 log_wrapper(std::string(timeout), 2); // 支持右值 }在实际项目里这种包裹函数的参数可能混着 std::string、整型、枚举、自定义对象逐个手动重载根本不可能。可变参数模板加完美转发一套代码通吃所有组合。但这里必须注意一件事std::forwardArgs(args)...包展开时不能因为某个参数是整型这类平凡类型就不 forward所有参数必须统一走 forward否则参数序号一旦错位代码会变得非常难排查。4.3 实操心得不要无脑转发完美转发很强大但不是所有模板都必须用它。我见过不少同事把所有模板形参一律写成T再从头到尾 forward 一遍结果代码读起来很费劲还容易搬空自己的对象。我的判断标准很简单这个函数是否只是参数的“转交者”。如果函数把参数处理后继续传给下一层做真正的工作那就值得用完美转发如果函数要在内部消费参数、遍历参数或者把参数保存到多位位置就要想清楚每个消费点对参数的所有权要求。还有一个必须牢记的规则被 forward 的参数转交之后就不要轻易再使用。因为如果调用方传入的是右值接收方很可能已经把资源移走了你再拿这个参数去做日志、做计算拿到的可能是空壳。如果确实需要在转发后继续使用建议先把参数拷贝一份到本地再转发原参数代价是一次拷贝但能保证逻辑正确。5. 高频踩坑与排查技巧5.1 编译期错误速查完美转发的报错经常让人一脸懵因为报错箭头指向的是标准库头文件内部而不是你自己的代码。把常见问题归类整理成速查表排查起来会快很多错误场景典型报错原因与修法在非模板函数中使用 std::forward‘std::forward’ used without this templateforward 需要显式指定 T但普通函数没有推导信息改用 std::move形参写成 const T编译不报错但无法接左值实参const T 不是万能引用去掉 const让 T 自己推导忘记显式指定模板参数cannot deduce template argument for T必须写 std::forward (t)不能省略把已有对象再次转发给多个消费点编译通过运行时出现空字符串第一次转发可能已经移动资源保证消费顺序或提前拷贝传入位域成员cannot bind bit-field to ...位域不能绑定到非常量引用先拷贝到局部变量再传给函数排查模板推导问题有一个很试出的老技巧故意实例化一个不完整类型让编译器报错从而把 T 的真实类型“逼”出来。template typename T struct TypeDisplayer; // 只声明不定义 template typename T void debug_type(T) { TypeDisplayerT t; // 在这里故意触发编译错误编译器会显示出 T 是什么 }把需要排查的函数体里加上这一句编译错误信息里会直接写出 T 的完整类型一秒定位是推导出了问题还是折叠出了问题。5.2 连续转发的“搬空”事故这是我实际踩过最深的一个坑。写了一段看起来非常合理的代码template typename T void handle(T val) { sink_a(std::forwardT(val)); sink_b(std::forwardT(val)); }如果sink_a的形参是std::string并且内部做了移动操作那么第二次sink_b收到的val已经不是调用方传入的原始对象了它可能是一个被移走的、内部指针已经置空的字符串。调用方以为自己的数据被完整处理了实际第一道工序已经把数据“偷走”了。修复思路要按场景分。如果两个消费点只是读取参数内容完全不修改资源那就不应该用 forward直接用const T或者把参数复制一份。如果你确实需要第一个消费点做移动操作、第二个消费点使用被移走后的剩余状态就要在代码里用注释明确写清楚这个约定否则同事维护时一定会踩雷。循环里的原则也一样不要在循环内部对同一个参数反复 forward第一次迭代完成之后后续迭代拿到的都是被搬空的状态。5.3 完美转发失效的经典场景即使把 forward 写对了有些场景下模板也无法推导或无法转发。最典型的是花括号初始化列表直接传给模板会失败template typename T void f(T t); f({1, 2, 3}); // 编译失败花括号列表无法被推导为 T需要先用 auto 显式声明类型或者显式指定模板参数为std::initializer_listint才能继续转发。另一个经典问题是把 0 或 NULL 当作空指针实参。模板推导会把 0 推导成 intNULL 在某些实现里也是 int传给一个需要指针的下一层函数时就会出现类型不匹配。修复方法只有一句C11 之后统一使用 nullptr不要再写 0 或 NULL 表示空指针。位域成员没办法绑定到非常量引用也就无法直接进入万能引用形参。C20 虽然允许以 const 引用绑定位域但在 C11/14 项目里标准做法是先拷贝到位域外的临时变量再把这个变量传下去。还有一类场景是传函数名本身。当函数名有多个重载时模板无法推导出到底该用哪个函数地址比如void foo(int); void foo(double); template typename F void call(F f); call(foo); // 编译失败不知道选择哪个重载修复方式是显式指定函数指针类型call(static_castvoid(*)(int)(foo));把歧义消除在调用点。6. 从C11到C20完美转发的演进与我的选择6.1 C14/17 让写法更顺滑C14 把 std::make_unique 正式收入标准库这意味着大多数情况下你不需要自己写带完美转发的工厂模板直接用标准库的实现即可。同时C14 的泛型 lambda 允许用auto作为参数lambda 内部可以这样写auto wrapper [](auto... args) { return do_something(std::forwarddecltype(args)(args)...); };C17 引入了折叠表达式配合参数包转发时语法更简洁也让std::invoke这类统一调用语法成为标配完美转发在函数对象包装场景中的应用变得非常顺手。6.2 C20 的约束让意图更清晰完美转发最大的缺点是报错信息不忍直视。一旦调用参数类型不满足下一层函数的要求整个错误信息会从标准库深处喷出一大段模板展开记录。C20 的 concept 可以提前在入口处拦截这种错误#include concepts #include memory #include utility template typename T, typename... Args requires std::constructible_fromT, Args... std::unique_ptrT make_widget(Args... args) { return std::make_uniqueT(std::forwardArgs(args)...); }加上requires约束后调用方传入了无法构造 T 的参数报错信息会明确指到函数签名这一行而不是深渊般的模板堆栈。如果你维护的工具库被很多新人使用这条约束非常值得加。6.3 我的实际项目经验我最早接触完美转发是为了给项目写线程池的任务包装器。std::thread 的构造函数内部已经使用了完美转发但那时我还不懂原理只是照猫画虎地写std::thread(func, std::forwardArgs(args)...)。后来一个函数需要把 std::string 参数转发两次因为不懂搬空问题出现了随机性的空数据排查了整整两天。那以后我给自己定了几条规矩凡是模板转发的参数默认视为不可重复消费凡是需要在函数体内多处使用的参数一律先明确归属权再决定是否 forward凡是拿不准推导类型的场景就用 TypeDisplayer 把类型打出来看。如果你想深入掌握完美转发我的建议是不要一上来就背源码先找一个两参数的工厂函数把带打印输出的构造函数跑通再故意去掉 forward 对比输出最后再引入参数包。把这个过程完整走一遍右值引用、引用折叠、万能引用这三个概念自然就串起来了。等你看一眼形参Args...就能在脑子里模拟出调用现场的类型变化时你的模板功力就已经超过绝大多数只写过业务代码的开发者了。
返回列表