免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

C++返回值优化RVO与NRVO:从原理到实战,彻底理解拷贝省略

C++返回值优化RVO与NRVO:从原理到实战,彻底理解拷贝省略 先说个我自己的例子。早几年我在做通信协议解析模块里面有个函数要从缓冲区里构造并返回一个较大的结构体对象。当时项目整体性能一直上不去我用perf跑了一圈发现瓶颈不在协议解析本身而在返回值那段——每次返回都涉及一次深拷贝构造和析构的调用次数比预期多得多。后来我坐下来认真跟了编译器生成的汇编才意识到自己压根没有利用好C的返回值优化RVO和命名返回值优化NRVO。从那之后凡是在项目里写返回大对象的函数我都会先想想这一步编译器会不会帮我省下拷贝今天这篇就把RVO和NRVO从定义到原理再到实战一次性讲透适合刚学C的初学者、准备面试的求职者以及写业务代码但没时间抠性能的开发者。有一点先说明RVO和NRVO不是程序员手写出来的特效这俩是C标准允许、编译器去实现的一套“拷贝省略”规则。你理解了标准在什么场景下允许编译器把拷贝/移动构造全部省掉很多看起来玄乎的代码行为就都能解释了。比如为什么return str;比return std::move(str);在某些场景下更快为什么C17以后临时对象那部分从“可能优化”变成了“必须省略”这些都是本文要展开的。1. 先给RVO和NRVO一个清楚的定义1.1 从观察拷贝构造函数调用次数开始在搞清楚什么是RVO和NRVO之前最好的方式是自己动手测一下。下面这个示例我建议你也跑一遍没有任何依赖几秒钟就能看到结果。#include iostream #include string class String { public: String(const char* text) { size_t len std::char_traitschar::length(text); data_ new char[len 1]; std::char_traitschar::copy(data_, text, len 1); std::cout constructor: data_ \n; } String(const String other) { size_t len std::char_traitschar::length(other.data_); data_ new char[len 1]; std::char_traitschar::copy(data_, other.data_, len 1); std::cout copy constructor: other.data_ \n; } String(String other) noexcept { data_ other.data_; other.data_ nullptr; std::cout move constructor\n; } ~String() { delete[] data_; } friend String makeString(const char* text); private: char* data_; }; String makeString(const char* text) { String s(text); // 局部命名对象 return s; // 返回命名对象 } String makeStringTemp(const char* text) { return String(text); // 返回临时对象纯右值 } int main() { std::cout --- NRVO ---\n; String a makeString(hello); std::cout --- RVO ---\n; String b makeStringTemp(hello); }在默认开启优化的编译器比如g -O2下你会发现绝大多数输出只有构造函数不会出现copy constructor和move constructor。这个现象背后的功臣就是RVOReturn Value Optimization返回值优化编译器把函数内部构造的临时对象直接放到调用方准备接收结果的内存位置上从而省略一次拷贝/移动。NRVONamed Return Value Optimization命名返回值优化编译器把函数内部那个带名字的局部对象直接构造到调用方栈帧中省略返回时的那一次拷贝/移动。两者的区别说白了RVO针对的是返回临时对象纯右值NRVO针对的是返回具名局部对象。二者合称“拷贝省略copy elision”。1.2 NRVO和RVO的具体差别有人会觉得反正都是省拷贝区分“临时”和“命名”有意义吗有意义。标准对两类的保证强度完全不同。RVO在C17之后被写成了强制规则只要符合纯右值返回条件编译器不允许再额外拷贝NRVO则始终是“允许但非强制”的优化也就是说编译器可以不做而且做不做得看编译器和优化选项代码里不能依赖它。为了更直观我把关键点拉成一张表对比项RVONRVO返回对象类型临时对象纯右值/临时物化前函数内具名局部对象C17前允许省略非强制允许省略非强制C17后强制省略仍然非强制影响拷贝构造调用的确定性很高仍依赖编译器和代码形态典型写法return String(...)String s(...); return s;顺带说一句C17以前面试中常考“RVO和NRVO哪个更稳定”这类题标准答案就是RVO相对更稳因为临时对象生命周期和返回位置天然匹配。C17以后RVO直接变成语言规则题目的考点就转向NRVO了。1.3 标准的动机为什么不直接规定只能返回简单类型有人可能会想既然拷贝开销大为什么标准不干脆规定所有返回值都必须省略拷贝一步到位这里有一个重要的语言设计原因拷贝/移动构造本身可能有副作用。比如上面的String类中我在拷贝构造函数里打日志如果标准强制在任何情况下都省略拷贝那么这类有副作用的构造函数在某些代码里就永远不会执行这会破坏程序员对可观察行为observable behavior的预期。因此标准化的做法是把“可省略场景”分成两层。第一层纯右值返回临时对象这类场景语义上就没有“先构造临时对象再拷贝到目标”的步骤可以定义为“它在目标位置直接构造”于是C17起成为强制的底层行为。第二层返回具名局部对象这个对象真的有一个名字生命周期和语义都要求它先被构造出来再考虑转移。标准鼓励编译器省略这个转移但不强制。理解了这层设计你就明白NRVO为什么“不稳定”——因为它是优化而非语义保证。这一点在实际工程里非常重要本文后面还会反复提到。2. 编译器是如何做到RVO/NRVO的2.1 拷贝省略在哪些环节出现很多人以为拷贝省略只发生在函数返回时其实它的应用范围更广。常见场景有至少四类函数返回纯右值对象RVO。函数返回具名局部对象NRVO。以纯右值为实参做拷贝初始化比如T t T(...)。抛出和捕获异常时异常对象的复制也可能被省略。第3类在C17前后也有变化。例如T t T(42)在C17之前允许编译器把临时构造和拷贝合并但允许拷贝构造函数仍然可访问C17之后直接从语言层面规定这里的T(42)就是t的初始化器没有额外临时对象。这也是为什么你在看一些老C代码时会看到作者刻意把拷贝构造函数写成private来阻止复制但到C17下却发现有些之前被禁的写法又能编译了。不过实践中大家最关心的还是前两类也就是RVO和NRVO。这两种优化的本质是编译器在生成函数调用约定时提前在调用方的栈空间里开好一个“返回对象槽位”然后把返回对象的构造地址直接指向槽位。函数里的局部对象不再单独建一个帧内对象最后再复制出来而是出生就在目标位置。打个比方平时你购物回家需要先把快递放到驿站再从驿站提回家省略优化后快递员直接按你家门铃省掉了驿站这一段。2.2 C17标志性的变化强制省略咱们单独把C17这个变化拿出来讲因为它影响太广。C17修改了“纯右值prvalue”的语义。在C17以前纯右值代表一个临时对象在C17以后纯右值不再必然表示一个实物对象更像是“用来初始化某个对象的说明书”。只有当它被用于需要实物对象的场景比如绑定到引用、访问成员变量时才会“物化materialization”成一个临时对象。直接看例子。下面这段代码在C17下可以编译在C14下即使允许也会要求拷贝构造可用struct MoveOnly { MoveOnly() default; MoveOnly(const MoveOnly) delete; MoveOnly(MoveOnly) default; }; MoveOnly make() { return MoveOnly{}; } int main() { MoveOnly m make(); }因为make()返回的是纯右值MoveOnly{}C17强制这个纯右值直接在m的存储位置完成构造所以根本不要求移动构造可访问更不要说拷贝构造。如果你在求职或面试中聊到“C17的返回值优化”上面这个MoveOnly的例子是最有说服力的正面证据。很多老文章和旧书在讲“移动语义比拷贝省略更可靠”时其实有明显的历史局限性——在C17的RVO场景下移动构造都不需要存在。但要再次强调强制省略只对“纯右值返回”生效。下面的NRVO例子C17标准依然不保证省略MoveOnly makeN() { MoveOnly m; return m; // NRVO编译器可以省也可以不省 }2.3 为什么return std::move(local)有时是负优化C社区一直流传一句经验不要在返回局部对象时写std::move。这话不是玄学背后原因就是NRVO。看下面两种写法std::string foo() { std::string s hello; return s; // 写法ANRVO候选 } std::string bar() { std::string s hello; return std::move(s); // 写法B移动构造候选 }写法A中编译器看到你返回具名局部对象s可以应用NRVO直接把s构造到返回值槽位最终没有任何拷贝、没有移动。写法B中你对s调用了std::move返回值类型是std::string。编译器默认你是想把s“移交”给返回值于是它不再认为这是一个NRVO机会在生成代码时通常会在return处调用一次移动构造函数。虽然移动比拷贝便宜但移动构造仍要完成指针搬移和原对象置空等操作和多花一次指令相比肯定不如NRVO完全省略来得快。更关键的是写法B在C17下依然不会触发强制的RVO因为它返回的是一个左值表达式经过std::move转成的xvalue不是纯右值。我实际测试过用g 12开-O2反复调用一百万次写法A生成的代码量比写法B更少函数调用边界处的汇编码更干净。虽然现代编译器偶尔也能在写法B下做进一步优化但没有任何标准保证。职业习惯建议统一使用写法A。一句话总结std::move用于把对象移交到另一个变量或容器时很合适但不要用在返回局部对象的return表达式里除非你有特别明确的理由。3. 实操中怎么写出容易被优化的代码3.1 五种稳定利用RVO/NRVO的写法光知道概念不够落到代码里要养成条件反射。我把平时写代码时推荐的写法整理成五种按推荐程度从高到低直接返回纯右值构造式return Widget(args...);返回列表初始化器return {member1, member2};C11起普遍可用局部具名对象直接返回Widget w(...); return w;使用独立工厂函数时让工厂函数内部直接构造目标对象避免在返回前把对象“塞进”智能指针等包装再解包装第2种值得单独提一下。当你写一个返回pairvectorint, string的函数时可以直接std::pairstd::vectorint, std::string getData() { return { std::vectorint{1,2,3}, hello }; }这里std::vectorint{1,2,3}是纯右值hello会隐式构造为std::string整个返回表达式在C17下同样是强制省略候选不会产生多余的临时pair。反过来不少新手习惯先定义一个pair变量再返回这个变量std::pairstd::vectorint, std::string getData() { std::pairstd::vectorint, std::string p; p.first ...; p.second ...; return p; }这种写法依然存在NRVO机会不是说不可以只是相比直接返回纯右值NRVO是否发生更依赖编译器。如果你想让行为更确定尽量把构造推到return表达式里。3.2 哪些场景可能打断优化就算你写了上面推荐的写法也要知道一些场景下NRVO会被“劝退”。最典型的是同一个函数里有多个返回点返回不同对象。std::string resolve(bool flag) { std::string a aaa; std::string b bbb; if (flag) { return a; } else { return b; } }这段代码里编译器的NRVO落到哪个对象它没法保证a和b都恰好与返回值槽位重合因为流程上两个对象都可能在退出函数时存活。这类代码编译器通常选择直接调用移动构造函数。好消息是移动构造通常很便宜实践中大多数项目不会因为这种代码变成性能瓶颈但如果这是热路径中的高频函数就要重新设计逻辑把多个分支的结果统一到一个局部对象再返回。还有一个常见场景是返回成员变量。比如class DataHolder { public: std::string getValue() const { return value_; } private: std::string value_; };value_不是函数内局部对象不满足NRVO条件返回它一般会触发一次拷贝或移动。如果getValue()被高频调用可能需要考虑返回const std::string而不是值或者引入std::shared_ptr之类的共享所有权设计。这取决于你是否需要值语义不能一概而论。另外lambda捕获外面的对象后返回该对象本质上也不属于NRVO场景因为它不是函数内创建的具名局部对象。3.3 到底有多大收益才值得手动改造讲了半天优化最后还是要回到工程判断。RVO/NRVO省掉的是一次拷贝或移动操作但拷贝/移动的成本可大可小。一个int的拷贝几乎为0一个容器的拷贝可能涉及堆分配和深拷贝一个std::arrayint, 4096的拷贝则要搬4KB内存。所以在决定是否刻意调整返回结构前先问三个问题这个对象是不是大对象拷贝成本是否显著这个函数是不是热路径调用频率高不高这个函数当前的返回是否已经触发了RVO/NRVO举个例子我之前在项目里把一个返回大型协议结构体的函数从“先局部构造再返回”改成“直接返回纯右值”结果是得益于RVO函数的栈空间分配和拷贝全部被省略耗时下降约15%。但如果我改的是一个返回int的工具函数这点优化收益完全测不出来纯属浪费阅读成本。因此我的建议是写代码时默认采用有利于RVO/NRVO的写法这不花额外成本但在项目层面不要在没有profile数据的情况下为了“可能的优化”大动干戈。4. 常见问题与排查技巧实录4.1 如何在自己的工程里确认有没有发生拷贝省略光看代码很难百分之百确定编译器做了什么尤其是NRVO这种非强制优化。我推荐两个实用手段一是使用编译器选项查看中间代码。GCC/Clang下可以加-fdump-tree-optimized查看优化后的GIMPLE搜一下返回处有没有出现memcpy、CPLUSPLUS相关复制调用。MSVC下可以开启/d1reportAllClassLayout做参考但信息不如GCC直观。二是写一个带计数器或日志的探针类直接统计构造、拷贝、移动次数。实战中我最常用的是这种带静态计数器的工具类#include iostream struct Probe { static inline int copies 0; static inline int moves 0; static inline int constructions 0; Probe() { constructions; } Probe(const Probe) { copies; } Probe(Probe) noexcept { moves; } static void report() { std::cout copies copies moves moves constructions constructions \n; } }; Probe makeProbe() { Probe p; return p; } Probe makeProbeTemp() { return Probe{}; } int main() { Probe a makeProbe(); Probe b makeProbeTemp(); Probe::report(); }你把这段放进测试工程不同编译选项下跑一遍立刻能判断编译器是否执行了RVO/NRVO。我自己给团队做C培训时也用这个例子直观且有说服力。4.2 关闭优化看差异-fno-elide-constructorsGCC和Clang都提供了-fno-elide-constructors选项专门用来关闭拷贝省略。日常开发不建议加但调试和理解机制时非常有用。接上面的Probe例子分别跑g -stdc17 -O2 probe_test.cpp -o with_opt g -stdc17 -O2 -fno-elide-constructors probe_test.cpp -o no_opt ./with_opt ./no_opt对比两份输出你会清楚看到关闭拷贝省略后makeProbe()会调用一次拷贝或移动构造函数makeProbeTemp()也会多一次移动/拷贝。这就证明了默认开启优化时编译器确实在做RVO/NRVO。这个选项也是排查“诡异性能问题”的利器。如果你怀疑某段代码在别的编译器、别的优化选项下突然变慢可以先看看是不是该编译器没做RVO/NRVO。4.3 关于移动语义与RVO/NRVO的三条实操经验这里我把这些年实际踩过的坑浓缩成三条经验。第一条移动语义是优秀的工具但是“移动”不是“没有成本”。std::string移动虽然不深拷贝字符数据但需要把一个指针搬过去可能还要改原对象的堆分配状态。NRVO连这一步都省了。第二条不要在返回局部对象时加std::move但不要因此否定移动语义。移动语义在容器扩容、成员转移、std::swap实现里仍然是核心价值。误用和不用是两码事。第三条如果你的代码必须跨老编译器编译不要依赖C17的强制RVO。老标准下的RVO和NRVO都只是“优化机会”有些老版本编译器的实现质量参差不齐热点路径最好自己留一条可观测的性能兜底比如在单元测试里加一个“拷贝次数不得超过N”的断言。这样做之后即使换了一套工具链测试失败了也能第一时间知道优化预期被打破。4.4 有关RVO/NRVO的常见误区速查表我在社区里看过很多讨论也见过不少面试者踩同样的坑。整理成表格说法真相return std::move(s)一定比return s快不一定常常更慢因为它阻止NRVORVO在所有C标准下都是强制的错只有C17起对纯右值场景强制NRVO在C17下也是强制的错NRVO仍然允许但不强制移动构造比RVO/NRVO更高效错RVO/NRVO连移动构造都省掉只要写了return 临时对象就一定没有拷贝基本正确C17后该场景是强制省略拷贝构造函数被delete函数就无法返回对象分场景C17下返回纯右值仍可以返回具名对象则仍可能不行最后一条在现实中比较容易被忽略建议你多动手验证。就我个人的开发习惯来说写完一个返回对象的函数后我会习惯性地瞄一眼它是不是在C17下被强制RVO还是仅依赖NRVO。如果是NRVO代码里又正好在热路径我就会加一个探针测试或者至少留一条注释告诉后来的维护者这里编译器的优化预期是什么。这样做的成本很低但能避免很多隐晦的性能回归。说到底C的很多优化并不靠玄学把标准边界和编译器行为都摸清楚代码的质量和可维护性都会上一个台阶。
返回列表