ARTICLE DETAIL

资讯详情

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

移动语义与完美转发实战:std::move、std::forward和noexcept避坑指南

移动语义与完美转发实战:std::move、std::forward和noexcept避坑指南 前阵子帮同事做 code review看到一段代码往vectorshared_ptrLargeObj里塞元素循环体里先构建一个临时对象再push_back。我问他为什么不用emplace_back他说反正 C11 有移动语义了临时对象扔进去不也是移动嘛。我让他把shared_ptr换成自定义类型试一下结果当场就翻车了——没有移动构造函数又没写拷贝构造临时对象直接被当成 const 左值拷贝整个指针共享到了被释放的栈上内存里。这其实不是个例。很多人能把“移动语义”和“完美转发”这两个词背得滚瓜烂熟面试的时候也能说出std::move是转成右值引用、std::forward是按参数原样转发但一落到真实的代码里该用forward的地方用了move该加noexcept的地方忘了加vector扩容该走移动构造却走了拷贝构造最后性能指标不达标还得靠火焰图一点点查。所以我打算用一篇完整篇幅把这俩概念彻底拆开揉碎。你不需要提前掌握很深的东西只要知道T是左值引用、函数模板的基本写法就行。我会讲清楚三件事移动语义到底解决什么问题、完美转发到底在“完美”什么、以及真实工程里最容易踩的坑长什么样全部配可编译的示例代码。如果你是 C11 之后才入行或者写了好几年 C 但一直靠“复制粘贴再改改”混日子的这篇应该能帮你把这块拼图补上。1. 先从两个痛点说起为什么需要移动语义又为什么需要完美转发1.1 拷贝开销是怎么悄悄吃掉你程序的性能的在 C11 之前函数传参、返回对象、往容器里塞东西统统只有一条路拷贝。这里的“拷贝”指的是调用拷贝构造函数或者拷贝赋值运算符。如果类里只有一个int那无所谓但如果是字符串、动态数组、map、vector这种管理堆内存的类型一次拷贝就得重新分配内存再把数据逐字节搬过去。我举个例子。你写了一个解析配置文件的功能函数签名是Config parse_config(const std::string path)内部读文件、逐行解析最终构建一个很大的Config对象返回。在 C11 之前编译器可能给你生成两次拷贝一次是函数内部把结果拷给返回值临时变量另一次是调用方把临时变量拷给外面的变量。两个大对象来回倒腾光内存分配和释放就有好几回。那时候的通用优化手段是什么靠 RVO/NRVO返回值优化/具名返回值优化让编译器尽可能在调用方的栈上直接构造跳过中间拷贝。再不行就搞引用参数函数改成void parse_config(const std::string path, Config out)通过输出参数绕开返回值拷贝。这些方法都有用但写起来别扭而且一旦函数里面有分支逻辑RVO 经常失效。移动语义就是冲着这个问题来的与其把一块堆内存拷贝一份不如直接把指针所有权“交接”过去。原来对象手里的那块内存新对象直接拿过来用原对象被置成空。整个过程就是几次指针赋值和置空操作不发生堆内存分配开销从 O(n) 降到 O(1)。1.2 包装层碰到的麻烦参数怎么原样传到底层函数移动语义解决的是资源转移问题完美转发解决的是另外一个让人头疼的问题——当你写一个包装函数、工厂函数、或者代理类时你希望把外部传进来的参数“原封不动”转交给另一个函数。但这个“原封不动”非常难办因为调用方可能传左值、右值、const左值、const右值你可能还需要在转发的同时保持参数的 const 属性和值类别让底层函数能正常地区分“可以移动”和“只能拷贝”。最早那种T const参数的方案什么都能接但是一接住就把右值变成了左值——对方的移动语义全被废掉了。比如你写一个工厂函数make_foo本来想return Foo(std::forwardArgs(args)...)高效地把参数转给Foo的构造函数但如果参数被const“粘住”了底层就只能拷贝不能移动。这在 Lua binding、Python binding、C 事件系统、智能指针实现里都是致命的性能问题。完美转发就是用来解决这件事的。你可以把它理解成“参数拾音器”不改变参数的值类别原来是右值给你转成右值原来是左值就保持左值底层函数接到的参数和调用方传入时的“味道”完全一致这才叫“完美”。2. 移动语义的根右值引用、std::move 和移动构造函数2.1 右值和左值到底是什么别再用“等号左边”来定义了如果你还停留在“左值就是能取地址的右值就是临时值”这种程度其实也够用但要想理解移动语义最好再往深走一步。C11 的标准里把表达式分成了几个值类别核心区别是谁“拥有”那个对象。左值lvalue有名字、能取地址、持续存在的对象。比如变量a、*p、arr[0]。纯右值prvalue纯临时量比如std::string(hello)、1 2这种没有名字的中间结果。将亡值xvalue即将被销毁、但资源还能被“抢救”的对象。比如std::move(obj)的结果、obj.member当obj是右值引用的时候。泛左值glvalue是左值加将亡值的合集右值rvalue是纯右值加将亡值的合集。为什么这么分因为移动语义的本质就是从“将亡值”手里接管资源。一个对象如果还能活很久你贸然把它的内部指针搬走把它置空那是对它的“抢劫”但如果它马上就要销毁了你只是把它手里那块内存拿过来自己用这是“资源回收”谁都不亏。代码上最直观的判别decltype((expr))如果是T或const T那这个表达式是左值范畴如果是T那就是右值范畴。不过日常开发真不用时刻去判别你只需要记住几个关键结论有名字的一定是左值哪怕它的类型是右值引用。这句话特别容易绕下面会重点讲。纯右值优先绑定右值引用也能绑定 const 左值引用。右值引用本身是一个变量可以被取地址所以它是一个左值。2.2 std::move 没有移动任何东西它只是一个授权标记很多教程把std::move描述成“把对象移动出去”这个说法很有误导性。我见过不少人以为std::move做了指针搬运的工作结果一看性能没变化跑来问我为什么。真相是std::move做的事情极其简单它只是把你的对象转换成一个右值引用类型相当于给编译器发了一个“你可以抢它的资源”的许可。具体实现对应到标准库核心就是一条静态转换template typename T constexpr typename std::remove_referenceT::type move(T t) noexcept { return static_casttypename std::remove_referenceT::type(t); }它先把模板参数T的引用剥掉再把实参转换成对应的右值引用。拿std::move(some_string)来说不管some_string本身是不是左值这一通操作之后得到的表达式类型就是std::string属于右值于是后面的重载匹配才会走到移动构造函数上。所以std::move本身不搬数据真正干活的永远是移动构造函数或者移动赋值运算符。如果一个类型压根没有移动构造std::move也不会帮你创造奇迹——它会老老实实退化成拷贝因为右值也能绑定const T这也是很多“我明明 move 了为什么还是拷贝”的答案。2.3 手写一个带移动语义的动态数组类纸上谈兵没意思我直接写一个简单的Buffer类里面有一块堆内存和长度重点看移动构造和移动赋值怎么写。#include cstddef #include algorithm #include utility #include iostream class Buffer { public: explicit Buffer(std::size_t size) : size_(size), data_(size 0 ? new char[size] : nullptr) {} ~Buffer() { delete[] data_; } // 拷贝构造深拷贝 Buffer(const Buffer other) : size_(other.size_), data_(size_ 0 ? new char[size_] : nullptr) { if (data_ other.data_) { std::copy(other.data_, other.data_ size_, data_); } } // 拷贝赋值需要先释放自己的再拷贝对方的 Buffer operator(const Buffer other) { if (this ! other) { Buffer temp(other); swap(temp); } return *this; } // 移动构造直接接管对方资源并把对方置空 Buffer(Buffer other) noexcept : size_(other.size_), data_(other.data_) { other.size_ 0; other.data_ nullptr; } // 移动赋值先释放自己的旧资源再接管对方资源 Buffer operator(Buffer other) noexcept { if (this ! other) { delete[] data_; size_ other.size_; data_ other.data_; other.size_ 0; other.data_ nullptr; } return *this; } void swap(Buffer other) noexcept { using std::swap; swap(size_, other.size_); swap(data_, other.data_); } char* data() const { return data_; } std::size_t size() const { return size_; } private: std::size_t size_; char* data_; };这个类表面上只有一个new[]和delete[]但把它看懂移动语义的要点就全有了。移动构造的关键是资源交接三件事把对方的指针塞给自己、把对方的长度拿走、把对方清空。有人会问为什么一定要把对方置空两个原因。第一源对象马上要析构如果它的data_还指向我们正在用的内存析构时会delete[]两次直接崩掉。第二标准库容器比如std::vector要求移动后的对象处于“合法但未指定”的状态它可能会后续调用析构、拷贝赋值等操作把源对象置空是保证不崩溃的基本手段。移动赋值比移动构造多一步先释放自己的旧资源。因为你给一个已经持有内存的Buffer赋新值时旧内存必须还回去否则就内存泄漏了。另外注意移动赋值要处理自赋值的情况b std::move(b)如果不判断this ! other先把delete[] data_执行了再想把other.data_接管过来那other.data_已经被你删了这就是把两个指针指向同一块内存然后 double delete 的灾难现场。2.4 移动构造函数为什么一定要标注 noexcept我的示例里移动构造、移动赋值都带了noexcept这不是随手写的是标准库里一个重要约定。std::vector扩容的时候如果元素类型提供了移动构造但不保证异常安全它会选择用拷贝构造而不是移动构造。为什么因为std::vector的扩容需要提供强异常保证如果扩容途中抛异常容器应该保持原状不能丢数据。移动构造如果半路抛异常那个对象已经被撕了一半数据已经乱了没法回滚拷贝构造至少可以保证“要么全拷贝成功要么不拷贝原数据还在”。所以标准库内部有std::move_if_noexcept这种东西专门在移动可能抛异常时退化回拷贝。你如果自定义了一个移动构造却没有标noexcept再把这个类型塞进std::vector发现扩容性能怎么都上不去十有八九就是这里的问题。有个很反直觉的现象移动构造大部分实现只是指针交换和置空根本不会抛异常但因为编译器不会替你做异常安全性证明没有显式noexcept它就默认为可能抛异常于是标准库就绕着走。所以规则很简单你手写的移动构造和移动赋值只要内部没有可能失败的操作一律加noexcept。2.5 移动过后对象还能用吗标准只保证“合法但未指定”这是很多新手的困惑alb std::move(bob)之后bob到底还能不能继续用标准库容器给的说法是“合法但未指定”valid but unspecified state。意思是你可以安全地给bob重新赋值、调用不依赖具体内容的方法、或者让bob析构但不能假设它里面还有原来的数据。比如std::unique_ptr移走之后源指针变成nullptrstd::string移走之后标准没说它变成空字符串但我们手写实验的时候绝不应该依赖它为空。所以当你写自定义类型时移动构造把源对象置空是成本最低也最安全的通用方案。我在工程里的一条经验法则是移动构造写完顺手加一句“源对象处于空状态除析构和赋值外勿再访问”写注释比让后人猜要好得多。3. 完美转发万能引用、引用折叠和 std::forward3.1 为什么需要完美转发Wrapper 的尴尬假设你在写一个线程池线程函数统一包装用户传入的可调用对象和参数。你写一个模板template typename F, typename... Args auto schedule(F f, Args... args) { // 想转给真正干活的地方 }这里Args...是个什么它的类型可能五花八门调用方可能传一个左值std::string也可能传一个右值std::string那你转发给内部函数的时候就得保持同样的值类别。否则的话传了个临时std::string进来你在包装层内部把它接住它就变成左值了再传给底层函数的构造函数时底层只能拷贝移动被白白浪费。像这种“参数经过中间层再传给底层”的场景在事件系统、bind 包装、工厂函数、容器emplace实现里遍地都是。emplace_back干的就是这种事外部给的参数必须原样转发到元素的构造函数里去。如果没有完美转发vector就没法阻止临时对象反复拷贝。3.2 万能引用和普通右值引用的区别在深入编码之前必须得把T的两种身份分清楚。普通状态下T是右值引用只能绑定右值void foo(std::string s); // 右值引用只能接右值但在模板推导里如果T是一个未推导类型且不带有 const/volatile 修饰那么T被称为万能引用也叫转发引用Universal Reference / Forwarding Reference。它既能绑定左值也能绑定右值。这背后的魔法就是引用折叠。先看折叠规则你会发现非常机械模板参数推导出的类型实际使用的引用折叠结果TTTTTTTT一句话总结只要实参是左值折叠结果就是左值引用只有实参是右值折叠结果才是右值引用。所以一个万能引用形参并不知道自己到底“是左值还是右值”它要等调用方传入参数后依据推导和折叠规则来决定。这里联想起我说的“右值引用本身是左值”就能理解为什么万能引用内部不能直接把参数转给底层函数一旦进入函数体形参有了名字它就成了左值你必须再用std::forwardT把它“还原”成调用方的值类别。3.3 std::forward 的原理它到底转了个什么std::forward的标准实现大概是这个样子template typename T constexpr T forward(typename std::remove_referenceT::type t) noexcept { return static_castT(t); }注意这个函数模板推导的时候非常讲究当作为左值调用std::forwardT(arg)时显式给出的T如果是std::string那么返回值就是std::string如果T是std::string返回值就是std::string。所以你看到std::forward的调用总是要求带显式模板参数——不显式指定T它没法知道你要转成哪种引用。在实际写代码的过程中std::forward通常和万能引用形参配套出现所以你会经常看到这样的固定组合template typename T void foo(T param) { bar(std::forwardT(param)); }param乖乖躺在foo里它现在是一个左值。std::forwardT根据T的推导结果把它“解开”回原来的值类别如果外部传右值T会被推导成非引用类型std::forwardT结果就是右值引用如果外部传左值T是Tstd::forwardT结果就是左值引用。3.4 std::move 和 std::forward 的对比什么时候用哪个很多人问std::move和std::forward是不是差不多完全不是它们的目标不同但底层机制其实很近都是基于static_cast做类型转换。std::move无条件把参数变成右值引用用于“我明确知道这个对象不再需要了你可以抢它的资源”。std::forward是条件转发根据模板参数T的折叠结果决定是左值引用还是右值引用用于“在包装层里还原调用方的原始值类别”。用法上最直白的一句话在万能引用模板里转发参数给下一个函数用std::forwardT在一个普通左值对象“生命已经走到终点”时用std::move。如果在一个万能引用形参上用了std::move你等于无视了调用方到底是左值还是右值无条件把人家绑到右值上。假如调用方传了一个左值给你你自己觉得它“反正没用了”就直接 move 掉这类代码非常危险相当于你把一个调用方可能还要继续使用的资源给掏空了。我给一个对照表场景使用结果明确要转移一个左值对象比如unique_ptrstd::move(ptr)得到右值移动到新对象在模板里把参数原样传递给底层函数std::forwardT(arg)保持调用方值类别在模板里强制转移参数不考虑调用方std::move(arg)极大概率误伤左值返回局部对象直接return obj利用 RVO/NRVO不需要 move3.5 一个完整的转发包装器实例把参数精确传给底层函数我现在写一个真实点的例子。假设我有一个函数make_string它能接收各种参数并构造一个std::stringvoid show(const std::string s) { std::cout left or const ref: s std::endl; } void show(std::string s) { std::cout right ref: s std::endl; } template typename S void wrapper(S s) { show(std::forwardS(s)); } int main() { std::string a hello; const std::string b world; wrapper(a); // 左值 - show(const std::string) wrapper(b); // const 左值 - show(const std::string) wrapper(std::string(!)); // 右值 - show(std::string) wrapper(std::move(a)); // 将亡值 - show(std::string) }这个例子能直接解释“完美”在哪个地方如果我在wrapper里不写std::forwardS(s)而是直接show(s)那么s作为一个形参永远是左值最后调用的永远是show(const std::string)右值版本永远不会被命中移动彻底失败。改写成static_castS(s)其实和std::forwardS(s)是一回事后者只是把这个常见操作封装成更易读的形式。再复杂一点的场景转发到构造函数或者可变参数模板。比如给unique_ptr做一个简单工厂template typename T, typename... Args std::unique_ptrT make_u(Args... args) { return std::unique_ptrT(new T(std::forwardArgs(args)...)); }这里std::forwardArgs(args)...会依次展开每个参数包里的元素并分别保持各自的左右值属性。如果此前你把forward误写成move那么非临时对象传进构造函数时也会被当成右值编译器可能直接报错也可能偷偷调用移动构造把外部对象掏空属于非常难排查的隐蔽 bug。4. 实操避坑工程里那些让人摸不着头脑的现象4.1 返回局部对象时别多此一举加 std::move有的开发者从 Java 转过来习惯了显式提示垃圾回收器到了 C 这边也喜欢在返回语句里写return std::move(obj);。在绝大多数情况下这是反优化。C17 开始纯右值复制省略已经是标准强制行为。局部对象返回时编译器直接在调用方的内存位置构造不发生拷贝也不发生移动。你写上std::move之后反而把这个强制省略给破坏掉因为返回类型可能变成了引用或者需要经过右值引用转换导致退出 RVO 路径。看下面的对比std::string good() { std::string s hello; return s; // RVO/NRVO直接构造到调用方 } std::string bad() { std::string s hello; return std::move(s); // 克制了 NRVO必须移动 }有人会说“强制 RVO 不是 C17 吗我还在 C14 呢”。即使 C14 里没有强制标准也鼓励编译器做 RVO你写上std::move反而是负优化。唯一例外的场景是条件分支返回值比如return cond ? std::move(a) : std::move(b);这时候因为三元运算符没法同时拷贝两个具名对象显式std::move才合理。4.2 vector 扩容为什么不走移动先去查 noexcept一提起“容器性能上不去”很多人第一反应是“扩容拷贝太频繁”。但如果你明明写了移动构造vector仍然不肯用最可疑的就是缺noexcept。我之前踩过一次很深的坑写了一个工具类内部持有一堆内存指针移动构造写得没问题但忘了加noexcept。std::vector扩容时检测到这个移动构造“可能抛异常”为了满足强异常保证它选择调用拷贝构造。而我的拷贝构造恰好写得比较重量级结果一个简单的push_back循环直接变成 O(n) 内存拷贝定位了半天才发现罪魁祸首就是那个可能抛异常的移动构造。所以排查这类问题有个经验顺序确认类型有移动构造和移动赋值且没有const成员、没有引用成员、没有复制删除等抑制隐式生成的障碍。确认移动构造和移动赋值都声明了noexcept。确认类型没有被用户声明的析构函数、拷贝构造、拷贝赋值所抑制。用std::is_nothrow_move_constructibleT::value在编译期验证。4.3 为什么移动构造被悄悄 delete 了的排查思路这套机制容易让人掉进另一个陷阱你以为编译器会给你生成移动构造结果它根本不生成代码里明明std::vectorMyType都编译过了运行时却走了拷贝。原因通常是“特殊成员函数的生成规则”起了作用。规则大致是如果你显式声明了拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符、析构函数中的任何一个编译器生成其余的函数时就会受影响。最常见的是你写了一个析构函数比如为了释放某个资源于是编译器不再隐式生成移动构造函数而MyType(const MyType)仍会被隐式生成。这时候std::move(my_obj)匹配到的恰好就是const MyType这个拷贝构造。这个问题有一个很好用的约定俗成需要自定义析构函数时顺手把拷贝操作和移动操作一并考虑要么显式定义要么 default别留半自动状态。否则代码编译能过但性能跟你预期的差了十万八千里。4.4 常见问题速查表平时被问得比较多的问题我汇总成一张表方便对照排查现象可能原因解决方向std::move后仍然走拷贝目标类型没有移动构造函数或移动构造被 suppress自定义并 default或显式实现移动函数std::vectorT扩容性能差T 的移动构造没有noexcept给移动构造函数加noexceptwrapper里传参总丢右值语义忘了std::forwardT直接裸传形参改成std::forwardT(param)对const T使用std::move无用const对象无法真正转移到非const指针不要对const对象使用move或先去掉 constreturn std::move(x)反而慢破坏了 RVO/NRVO直接return x成员变量是引用类型写移动构造报错引用成员没有拷贝/移动语义不能被“移动”改成指针成员后重新设计所有权自定义析构后没有移动构造特殊成员函数生成规则抑制显式 default移动构造移动后源对象还能用吗标准只保证合法未指定不是行为契约把源对象重置为空是最安全的选择4.5 编译期验证小技巧用 type_traits 帮你看穿转发类型如果你想验证某个模板里参数到底被折叠成了什么类型typeid在模板推导时往往丢信息我建议直接用std::is_same和std::is_rvalue_reference来做静态断言快速判断推导结果。template typename T void forward_dump(T param) { using naked std::remove_reference_tT; std::cout T 有无引用: std::is_referenceT::value \n; std::cout param 本身: std::is_rvalue_referencedecltype(param)::value \n; std::cout forward 后: std::is_rvalue_referencedecltype(std::forwardT(param))::value \n; }当你传左值int x进forward_dump(x)T会被推导成intparam类型是int它的std::is_rvalue_reference是 0传右值forward_dump(5)时T是intparam类型是intis_rvalue_reference就变成 1。实际排查万能引用相关代码时这个办法能稳准狠地把“到底转没转对”暴露出来。5. 一段经验沉淀移动语义和完美转发的边界感写了这么多年 C我最大的体会是这两样东西本质上都是“规则”而不是“魔法”。代码能编译不代表语义正确语义正确也不代表性能符合预期。移动语义要求你在设计类的时候就想清楚资源所有权完美转发要求你在写每一层模板的时候都想清楚参数到底“从哪来、要到哪去”。我自己现在写项目有几个自我检查的清单每次写完都会过一遍所有管理内容的类型都要有明确的移动构造和移动赋值能给noexcept就给模板包装层的所有参数透传一律std::forwardArgs(args)绝不用std::move代替能直接返回具名局部对象就绝不写std::move写线程池、事件总线这类中间层代码的时候先用static_assert把值类别验证清楚再放开手脚写业务逻辑。另外一个心得是以后看到网上那些“一行代码优化性能”、“用移动语义让大对象不再拷贝”的标题党文章别太当真。移动语义不是银弹它只对持有堆内存、文件句柄等重量级资源的类型有意义。如果你的类只有一个int移动它和拷贝它没有任何区别多写一堆移动构造函数反而是负收益。真正决定代码质量的还是你对自己类型资源生命周期有多清楚。这个分寸感是光看书看不出来的得多写、多踩坑、多读反例。如果这篇文章能帮你少走一点弯路那我就挺满足了。动手写代码的时候记得把noexcept补上把forward用对地方——这些细碎的坚持量变到最后就是质变。
返回列表