ARTICLE DETAIL

资讯详情

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

C++返回值优化与移动语义:RVO/NRVO原理与工程实践

C++返回值优化与移动语义:RVO/NRVO原理与工程实践 C开发者大概都遇到过这种场景明明函数写得挺干净可一跑性能分析发现按值返回大对象时拷贝成本高得离谱。搜来搜去最后都会落到一个词上——返回值优化RVO。这个优化在C11之前就已经存在但C11加入移动语义之后很多人误以为RVO已经没用了实际根本不是一回事。RVO负责“零成本返回”移动语义负责“没被优化时也能兜底”两者是配合关系不是替代关系。我最初做网络服务时为了把一个内部缓冲区从解析函数里带出来先后试过出参、指针、shared_ptr绕了一大圈最后才发现按值返回加RVO才是最简单也最快的写法。这篇文章就把RVO的原理、验证方法、失效场景、工程实践一条龙讲清楚适合正在写C以及对代码性能敏感的开发者。1. 返回值优化到底在优化什么1.1 按值返回的三次“隐藏”构造先看一个C03时代的经典场景Heavy makeHeavy() { Heavy h; // 对 h 做一系列操作 return h; }在编译器没有做任何优化的情况下按值返回这个h会发生什么第一次是Heavy h在函数栈上正常构造第二次是return h时把局部对象拷贝到返回值临时对象第三次是回到调用方后从临时对象再拷贝到目标变量。三次构造其中两次是完整拷贝如果Heavy内部有一块512MB的缓冲区这个代价是很疼的。老一代C程序员为了避开这个开销常用的办法是改成出参void makeHeavy(Heavy out) { Heavy h; // ... out h; }出参确实避免了返回值临时对象的拷贝但本质上还是有一次拷贝或赋值。而且语义上更难读调用方得先默认构造一个对象传进去等于把性能问题转移到了调用方。C11之后这种写法基本没有必要了因为RVO加上移动语义可以做得更好。1.2 RVO 与 NRVO一个省略两种操作返回值优化其实分两类。一类叫RVOReturn Value Optimization针对返回没有名字的临时对象Heavy makeTemporary() { return Heavy(); // 返回匿名临时对象 }另一类叫NRVONamed Return Value Optimization针对返回有名字的局部对象Heavy makeNamed() { Heavy h; // ... return h; // 返回具名局部对象 }不管是哪一种核心思想都一样编译器把“函数内部构造对象”和“调用方接收对象”这两个步骤合并让对象直接构造在调用方预期的那块内存上。函数内部不再有独立对象返回时也不用拷贝自然就省下了那两次拷贝。但要注意RVO和NRVO在C11时代都只是允许优化不是强制优化。标准委员会没有规定编译器必须做所以不同编译器、不同优化级别下的行为会有差异。理解了这一点后面验证RVO是否生效的思路就顺了既然它不是必然发生的我们就需要手段去确认它在自己的构建系统里确实发生了。2. C11 移动语义与 RVO 的互补关系2.1 C11 的隐式移动省略不了也不亏C11引入右值引用后标准对return语句加了一条规则返回局部对象时会优先将其当作右值来处理也就是说编译器会尝试调用移动构造函数而不是拷贝构造函数。这是个隐式行为不需要你写std::move。举个最常见的例子std::unique_ptrConfig loadConfig() { auto config std::make_uniqueConfig(); // 读取配置填充 config return config; }std::unique_ptr是只可移动、不可拷贝的类型。在C11之前这种代码根本没法按值返回只能返回裸指针或者用出参。C11之后return config会自动走移动构造把裸指针从局部变量转交给调用方。即使编译器不做NRVO代价也只是一次指针交接而已。这就是为什么说移动语义改变了“按值返回等于性能灾难”的认知。它保证了一个下限即使RVO失效至少还有一次轻量级的移动在兜底。但移动替代不了RVO——很多类型移动起来并不廉价比如std::array这种纯聚合类型移动本质还是逐个拷贝元素。这时候RVO的意义就体现出来了。2.2 从拷贝到移动再到省略三层性能阶梯实际工程里按值返回大对象的性能表现可以分三个层级层级发生条件代价第一层RVO/NRVO成功零转移对象直接在目标位置构造第二层省略失败但移动可用一次移动通常是指针或资源交接第三层移动不可用或移动不noexcept一次甚至多次完整拷贝写代码的时候我们的期望值是先尝试第一层编译器能省略就省略省略不了退到第二层靠移动构造最怕的是直接掉到第三层。理解这个阶梯之后看网上那些关于“按值返回还是用出参”的争论就知道答案了优先按值返回让编译器去决定走哪一层绝大多数时候表现都不差。也正因为这个原因我在项目里立了一条规矩新代码默认按值返回。只有性能测试证明某个函数返回值成为瓶颈时才会去分析RVO有没有生效再考虑换成出参或其他方案。2.3 移动后的对象状态一个容易忽略的标准细节移动语义虽然好用但自己写移动构造函数时必须注意一个问题被移动过的源对象必须仍然处于“合法但未指定”的状态。这句话的意思是源对象析构必须是安全的也允许重新赋值但你别指望它还是原来的内容。比如std::string被移动之后绝大多数实现里会变成空串但标准只要求它处于合法状态不保证一定是空串。你写自定义类型的移动构造函数时务必要把源对象内部的指针置空否则源对象析构时会对同一块内存二次释放直接崩溃。这个细节和RVO的关系在于RVO生效时移动构造函数压根不会被调用所以没问题但RVO失效时移动构造函数就会被调用这时候如果移动构造写得不对反而会引入比拷贝更隐蔽的bug。所以移动构造的安全性是RVO兜底方案的保障。3. 实操确认你的返回代码到底被优化没有3.1 最直接的黑盒验证构造日志法判断RVO是否生效最简单的办法就是让构造函数、拷贝构造函数、移动构造函数都打印日志然后看输出结果。写一个测试类#include iostream #include utility class Heavy { public: Heavy() { std::cout default ctor\n; } Heavy(const Heavy) { std::cout copy ctor\n; } Heavy(Heavy) noexcept { std::cout move ctor\n; } ~Heavy() { std::cout dtor\n; } }; Heavy makeTemporary() { return Heavy(); } Heavy makeNamed() { Heavy h; return h; } Heavy makeMoved() { Heavy h; return std::move(h); } int main() { std::cout --- RVO ---\n; Heavy h1 makeTemporary(); std::cout --- NRVO ---\n; Heavy h2 makeNamed(); std::cout --- move ---\n; Heavy h3 makeMoved(); }用g -stdc11 -O2编译并运行我机器上的输出大致是这样--- RVO --- default ctor --- NRVO --- default ctor --- move --- default ctor move ctor看到关键差异没有RVO和NRVO场景下整个程序只有一次默认构造连析构都不需要为临时对象额外调用。而return std::move(h)那个函数硬生生多出来一次移动构造。这说明前两个函数确实被优化了第三个则没有。这个例子也顺带把4.2节要讲的“return std::move是反优化”给预演了一遍。如果你不给编译器开优化或者加上-fno-elide-constructors强制关闭复制省略输出里会多出若干次移动甚至拷贝调用。看到那些多出来的构造你就能直观理解RVO的价值了。3.2 看汇编对比 -fno-elide-constructors打印日志之后想进一步确认可以看汇编。命令是g -stdc11 -O2 -S test.cpp -o test.s打开test.s找到main函数里调用makeTemporary和makeNamed的位置。如果RVO生效你会看到编译器在目标地址上直接调用Heavy::Heavy()也就是默认构造函数附近没有拷贝构造函数或移动构造函数的call指令。更直接的对比方法是把优化关闭前后两份汇编摆在一起g -stdc11 -O2 -fno-elide-constructors -S test.cpp -o test_noelide.s这份汇编里返回路径上就会出现Heavy::Heavy(Heavy)或者Heavy::Heavy(const Heavy)的调用。两份汇编一对比优化效果一目了然。这个手段最适合在团队内部做代码审查时用某段代码到底有没有被优化不用争论汇编是最终事实。3.3 粗粒度性能对比的写法如果不想看汇编直接用时间说话也可以但有几个坑要注意。先给一个粗略的测试框架#include chrono #include iostream #include string static std::string makeString(size_t n) { std::string s; s.reserve(n); for (size_t i 0; i n; i) { s.push_back(x); } return s; } int main() { volatile size_t sink 0; auto start std::chrono::steady_clock::now(); for (int i 0; i 1000000; i) { std::string s makeString(8192); sink s.size(); } auto end std::chrono::steady_clock::now(); std::cout std::chrono::durationdouble, std::milli(end - start).count() ms\n; return static_castint(sink); }注意两点。第一用volatile size_t sink把结果累加出去否则整个循环可能被编译器判定为无副作用直接删掉。第二std::string的移动操作非常轻量RVO和移动之间的性能差不会特别明显所以这个测试更适合验证“编译器确实没做什么危险的事情”而不是用来量化RVO收益。要想量化收益就得用一个移动也贵的类型比如std::arrayint, 10000或者自己定义一个内部含大数组的类。对这类对象RVO生效和只靠移动语义的差距会拉开数倍甚至更多。微基准测试容易骗人我的习惯是只看相对差异不看绝对毫秒数。4. 哪些写法会让返回值优化直接失效4.1 多分支返回不同对象、返回成员和形参RVO和NRVO不是万能的有几类写法几乎注定优化失败。最容易遇到的是函数里有多个分支每个分支返回不同的具名对象Heavy makeFromFlag(bool flag) { Heavy a; Heavy b; if (flag) { return a; } else { return b; } }这种情况下编译器在进入函数时不知道最终要返回的是a还是b没法提前把某个局部对象安排在调用方的内存位置NRVO基本失效。但不用太担心C11之后编译器会尝试对这两个返回分支分别做移动至少不会退化成拷贝。代价是比单路径返回多一次移动和一次析构性能能接受但不够极致。同样会失效的还有两种情况。一种是返回成员变量Heavy getMember() { return this-member_; }member_不是局部对象它在当前对象内部早已构造完毕省略不了必须生成一份副本只能靠移动兜底。另一种是返回全局或静态对象道理一样对象早已存在返回值必须是一份独立的新对象。还有一种比较特殊的是返回函数形参Heavy passThrough(Heavy h) { return h; }C11/14标准下函数形参被视为左值即使它实际上是按值传进来的返回时也更倾向于拷贝而不是移动。这个问题到C20才被修正详见5.2节。4.2 return std::move(obj) 是典型的反优化我在不少代码评审里见过这样的写法std::string makeString() { std::string s; // ... return std::move(s); }写这行代码的同事通常很自豪觉得“我用了移动语义性能最优化了”。但实际上这是一个反优化。原因在于return s本来就有两个可能如果编译器做NRVO就是零成本如果编译器不做NRVOC11标准也会自动把s当右值处理走移动构造。你手动写std::move(s)等于强行把s标记成右值编译器就没法把它当成“具名局部对象”来做NRVO了只能被迫走移动构造。用3.1节的测试代码已经验证过了同样的对象return s能触发NRVO而return std::move(s)会多一次移动构造。也就是说你辛辛苦苦加的std::move反而亲手断送了编译器帮你做零拷贝优化的机会。这里有个例外要说清楚当返回类型和局部对象类型不完全一致时情况会不一样。比如局部变量是Derived函数返回类型是Base这时候直接return obj可能无法触发NRVO加std::move(obj)也许能让转换更顺畅。但最常见的“返回局部变量类型和返回类型完全一致”场景永远应该直接return obj。4.3 移动构造不 noexcept 的连锁反应移动构造函数写不写noexcept看起来是个小细节实际影响面很大。最经典的场景是std::vector扩容如果元素的移动构造函数不保证不抛异常vector扩容时为了安全会退化成拷贝构造。这个逻辑和返回值优化也有联动。在纯返回场景中移动构造是否noexcept不影响编译器的省略决策但如果移动构造真的抛了异常程序会从返回路径上抛出异常这时候RVO不RVO已经不重要了正确性才是第一位。而且很多泛型包装类型比如std::function、std::future在内部传递或者存放大对象时是否会选择移动语义也跟noexcept相关。我的建议很简单自定义类型的移动构造函数和移动赋值运算符一律写成noexcept。这不花什么成本但能保证你的类型在容器、泛型库、异步任务等场景里始终走高效的移动路径。它和RVO是一套组合拳RVO管编译器层面的省略noexcept管运行时的兜底路径。5. C17 之后新标准和老代码的兼容5.1 guaranteed copy elision 带来了什么变化虽然项目标题是C11但写代码的人总得往前看。C17引入了一个重要变化纯右值初始化场景下的复制省略变成了语言强制行为也就是所谓的guaranteed copy elision。什么叫“强制”看这个例子struct NonCopyable { NonCopyable() default; NonCopyable(const NonCopyable) delete; NonCopyable(NonCopyable) delete; }; NonCopyable make() { return NonCopyable(); } int main() { NonCopyable n make(); }在C11或C14下这段代码编译不通过因为编译器要求返回值类型必须可拷贝或可移动哪怕它实际上做了省略也必须在语法层面允许。C17之后return NonCopyable()这个场景就是强制省略不再要求拷贝或移动构造函数可用所以代码能直接编译通过。这等于把“返回临时对象”这一类场景从“优化”升级成了“语言保证”。但要注意guaranteed copy elision只适用于返回纯右值临时对象的场景NRVO返回具名局部对象依然没有被强制编译器仍然可以自行决定是否省略。所以C17之后写return Heavy{}比Heavy h; return h;能获得更多语言层面的保证。5.2 C20 对返回形参的隐式移动修正C20通过P1825提案解决了一个C11时代遗留的坑返回按值传入的形参时不再视为左值而是允许隐式移动。看这个函数std::unique_ptrSomeType wrap(std::unique_ptrSomeType p) { return p; }在C11/14/17下return p编译不通过因为p是左值无法直接构造std::unique_ptr返回值你需要写return std::move(p)。但C20之后标准允许这种场景隐式移动return p可以直接通过。这个修正让“按值传参再按值返回”的模式变得更加顺手也意味着如果团队在升级C标准一些为了绕过旧标准限制而写的std::move可以逐步清掉。不过如果你还在维护C11/14的代码库上面这种形参返回场景该写std::move还是得写别和4.2节说的“局部对象返回别用move”搞混了。6. 工程实践返回值优化友好代码怎么写6.1 按值返回的五条经验结合前几章的踩坑经历我整理了五条在实际项目里很好用的经验。第一返回局部对象时直接写return obj不要加std::move。编译器会自动选择移动或不移动你加move只会画蛇添足。第二移动构造函数和移动赋值运算符尽量标noexcept。这条规则对自定义类型几乎无脑适用收益远大于成本。第三尽量保持返回路径简单。如果函数里多个分支返回不同具名对象NRVO基本没戏如果能把逻辑合并成单路径返回或者直接return Heavy{...}构造临时对象省略概率会高很多。第四优先按值返回而不是用出参。C11之后std::string f()这种签名通常比void f(std::string out)更高效因为前者能用RVO和移动后者强制要求调用方先构造一个对象然后在函数里再赋值或拷贝一次。很多老项目习惯用出参其实可以慢慢改过来。第五用C17及以上编译代码时返回临时对象的场景可以得到language-level guarantee能写成return Heavy{}就别写成Heavy h; return h;。6.2 什么时候不应该按值返回按值返回虽好但不是所有场景都适用。比如说你要返回容器内部某个元素应该用const T或者迭代器按值返回会无谓地拷贝一份返回成员变量的引用时如果成员生命周期能保证比引用使用时间长用const T更合理但这主要看对象是否依旧存活别返回悬垂引用。多态对象更是别按值返回。把派生类对象按值赋给基类对象会发生对象切片丢失派生类部分的数据和行为这种情况下要返回std::unique_ptrBase或std::shared_ptrBase让动态类型安全传递。还有一种情况如果对象的构造和析构都非常轻量而移动构造并不比拷贝快按值返回的收益其实没有那么明显这时候可以靠Profile结果来决定要不要继续用。6.3 多线程唤醒场景里的返回值优化既然这次的热搜词里有“C11多线程唤醒的用法”我多聊两句和RVO的关联。先说结论返回值优化和多线程唤醒在语法层面没有直接关系唤醒线程靠的是std::condition_variable、std::mutex和std::atomic这些同步机制RVO起不到唤醒的作用。但工程上它们经常一起出现。以典型的生产者-消费者模式为例一个worker线程执行完任务后把结果放进一个共享容器然后notify_one()唤醒消费者线程。如果结果是一个大对象比如一个包含大量数据的std::vector那么从生成结果到通知消费者中间最关键的性能点就是结果对象的传递路径。如果这个结果是在某个函数内部生成的并且通过按值返回传递RVO就能让它在调用方的目标位置直接构造省掉一次大对象拷贝。具体来说和传统写法相比// 不推荐先构造再拷贝/移动进共享容器 void produce(std::vectorData shared, std::mutex mtx) { std::vectorData result; // 填充 result { std::lock_guard lock(mtx); shared std::move(result); } cond.notify_one(); } // 更推荐按值返回让编译器在目标位置构造 std::vectorData produce() { std::vectorData result; // 填充 result return result; // NRVO或自动移动 }第二种写法在调用端可以直接配合移动操作把结果送进队列RVO负责省掉函数返回时的拷贝移动操作负责把对象送进共享容器锁内的操作被压到最小唤醒后的消费者读取数据也会更快。换句话说RVO虽然不会帮你唤醒线程但它能让唤醒之后的数据交接变得更轻。这就是我在实际项目里把这两个话题放一起看的原因。我个人折腾返回值优化的体会是先默认按值返回再验证最后才考虑优化。验证手段很简单构造日志、汇编、性能对比都可以对绝大多数代码来说return obj加一个noexcept移动构造就是黄金组合别随便加std::move去画蛇添足。真遇到多分支返回或者形参返回这种复杂场景先想清楚RVO大概率会失效再决定要不要调整写法。踩过几次坑之后现在我写函数签名默认就是值类型返回加依赖编译器优化真的省心性能也不差。
返回列表