现代C++特性指南(3)RVO 与 NRVO:编译器的返回值优化
现代C特性指南3RVO 与 NRVO编译器的返回值优化仓库已经开源仍然在持续建设中喜欢的话点个⭐相关的链接如下clone me!: git clone https://github.com/Awesome-Embedded-Learning-Studio/Tutorial_AwesomeModernCPP静态网页体验极大改进点击这里直接阅览https://awesome-embedded-learning-studio.github.io/Tutorial_AwesomeModernCPP/我相信各位如果是从写C的特别是写单片机C的在特别的是从RAM贼小的片子来的朋友一定不会在编程的时候返回大结构体我的意思是一定不会写struct X GetSth(...)这样的东西对吧栈一不小心就打爆了这是因为按值返回一个结构体意味着函数里构造一份再把这份拷贝给调用者——对于那些动辄几百字节的结构体来说这个开销在性能敏感的代码里完全不可接受。所以我们当时发明了各种绕法传出指针参数、返回静态局部变量、用 malloc 让调用者自己 free……谁家早年的OpenCVC 有了拷贝构造和移动构造之后按值返回大对象的代价已经大幅降低了——但编译器还能做得更好。它有一个零成本的秘密武器第一个是返回值优化Return Value OptimizationRVO第二个是命名返回值优化Named Return Value OptimizationNRVO。这两者的核心思路是这样的既然最终的对象要放在调用者的栈帧上那何必在函数内部先构造一份再拷贝/移动过去直接在调用者的空间里构造不就完了由此疑惑派生两者。就这个意思。RVO 和 NRVO 到底做了什么假设我们有一个简单的Point类带一个会打印日志的拷贝构造函数#includeiostream// 原谅我还在iostream我知道print很好用但是我们没在讲更现代的CstructPoint{doublex,y;Point(doublex,doubley):x(x),y(y){// 我知道好像在这里塞中文可能会造成问题但是怕啥demo而已std::cout 构造 Point(x, y)\n;}Point(constPointother):x(other.x),y(other.y){std::cout 拷贝 Point(x, y)\n;}Point(Pointother)noexcept:x(other.x),y(other.y){std::cout 移动 Point(x, y)\n;}};然后我们写两个工厂函数一个返回临时对象一个返回命名局部变量// RVO 场景返回 prvalue临时对象Pointmake_point_rvo(doublex,doubley){returnPoint(x,y);// 返回一个临时对象}// NRVO 场景返回命名局部变量Pointmake_point_nrvo(doublex,doubley){Pointp(x,y);// 命名局部变量// ... 可能还有一些对 p 的操作 ...returnp;// 返回命名变量}在没有优化的情况下make_point_rvo会先在函数内部构造Point(x, y)然后拷贝或移动到调用者的空间。make_point_nrvo也是一样构造p然后拷贝/移动p到调用者。但有了 RVO/NRVO 之后编译器直接在调用者的栈帧上分配空间让函数内部的构造操作直接发生在这个空间里——根本不存在中间对象拷贝和移动都无从谈起。我们来验证一下intmain(){std::cout RVO \n;Point amake_point_rvo(1.0,2.0);std::cout\n NRVO \n;Point bmake_point_nrvo(3.0,4.0);return0;}用 GCC 编译默认优化级别g-stdc17-Wall-Wextra-orvo_test rvo_test.cpp ./rvo_test输出 RVO 构造 Point(1, 2) NRVO 构造 Point(3, 4)每个Point只构造了一次——没有拷贝没有移动。这就是 RVO/NRVO 在工作编译器把构造操作直接搬到了调用者的空间里。用编译器开关验证——关闭消除看看会发生什么GCC 和 Clang 提供了一个编译器选项-fno-elide-constructors可以强制关闭拷贝消除。我们来看看关闭之后的行为g-stdc17-Wall-fno-elide-constructors-orvo_no_elide rvo_test.cpp ./rvo_no_elide输出变成了GCC 15,-stdc17 RVO 构造 Point(1, 2) NRVO 构造 Point(3, 4) 移动 Point(3, 4)这里有一个很重要的细节值得注意RVO 部分没有变化——即使加了-fno-elide-constructorsmake_point_rvo仍然只构造了一次没有移动。这是因为 C17 对 prvalue 返回的拷贝消除是语言语义保证不是编译器优化开关能关闭的这一点我们后面详细展开。真正被影响的是 NRVOmake_point_nrvo从零成本退化到了一次移动构造。注意 NRVO 退化后用的是移动而不是拷贝——因为 C11 之后当编译器遇到return local_var;的时候会自动把local_var当作右值来处理隐式移动即使local_var在函数内部是一个左值。这是一个很重要的保证即使拷贝消除没生效你也至少能获得移动语义的性能。如果你想观察全退化行为——也就是连 RVO 也退化成移动——可以用 C14 模式编译g -stdc14 -fno-elide-constructors。在 C14 下-fno-elide-constructors对 RVO 和 NRVO 都生效两个函数都会多出移动操作。C17 的保证消除——从允许到必须在 C17 之前RVO 和 NRVO 都是编译器被允许做但不是必须做的优化。也就是说标准说编译器可以省略这次拷贝/移动但没有说编译器必须省略。在实践中主流编译器在开启优化后基本都会做但严格来说这不是保证。C17 改变了其中一种情况的规则当返回值是 prvalue纯右值时拷贝消除变成了保证。这不是一个可选的优化——它是语言的语义保证。这意味着return Point(x, y);这种写法在 C17 中绝对不会触发拷贝或移动构造函数。这个保证的底层原理是 C17 对 prvalue 语义的重新定义。在 C17 之前prvalue 被理解为临时对象——函数返回Point(x, y)时先创建一个临时Point对象然后把它拷贝/移动到调用者的空间。C17 之后prvalue 被重新定义为初始化的配方——Point(x, y)不再是一个对象而是一组构造指令告诉编译器在这个位置用这些参数构造一个Point。既然 prvalue 不是对象那就不存在拷贝对象这件事拷贝消除自然就是保证的了。// C17 之前Point(x,y) 是一个临时对象// C17 之后Point(x,y) 是一个构造配方Pointmake_point(doublex,doubley){returnPoint(x,y);// C17 保证不触发拷贝/移动}⚠️踩坑预警C17 的保证消除只适用于返回prvalue的场景——也就是return Type(args...);这种直接返回临时对象的写法。返回命名局部变量NRVO的情况仍然是允许但非必需的优化C17 并没有把 NRVO 也变成保证。所以return p;中的p是否被消除仍然取决于编译器的实现。NRVO 什么时候失效NRVO 虽然大部分时候都能生效但有一些代码模式会让它失效。理解这些模式很重要——因为失效意味着你可能从零成本退化到移动成本虽然不致命但在性能敏感的热路径上可能成为瓶颈。最典型的失效场景是多个返回分支返回不同的命名对象。编译器要做 NRVO需要在调用者的空间里预先分配好内存然后让函数内部的命名变量直接构造在这个空间里。但如果有两个不同的命名变量可能被返回编译器就没法把两个变量都放在同一块空间里——它们各有各的地址。Pointbad_nrvo(boolflag){Pointa(1.0,2.0);Pointb(3.0,4.0);if(flag){returna;// 可能阻止 NRVO}returnb;// 返回不同的命名对象}这种情况下编译器没法确定a和b哪个会被返回所以无法提前把其中一个放在调用者的空间里。结果就是先正常构造a和b然后根据条件移动其中一个到返回值。你可以通过修改代码来恢复 NRVO使用同一个命名变量在不同分支里给它不同的值。Pointgood_nrvo(boolflag){Pointresult(0.0,0.0);if(flag){resultPoint(1.0,2.0);}else{resultPoint(3.0,4.0);}returnresult;// NRVO 可以生效}另一个常见的失效场景是返回函数参数。NRVO 只针对函数内部的局部变量函数参数是在调用者的栈帧上已经构造好的对象编译器没法把它搬到返回值的空间里。Pointreturn_param(Point p){// 对 p 做一些操作 ...returnp;// 无法 NRVO但 C11 会隐式移动}这里p是函数参数不是局部变量NRVO 不会生效。但好消息是 C11 的隐式移动规则仍然适用——return p;会把p当作右值调用移动构造函数。所以你不会退化到拷贝只是退化为移动。还有一个虽然不是失效但值得一提的场景返回全局或静态变量。这种情况下根本没有 NRVO 可言——全局/静态变量有固定的存储位置不可能被搬到调用者的空间里。Pointglobal_point(1.0,2.0);Pointreturn_global(){returnglobal_point;// 拷贝构造没有 NRVO也没有隐式移动}注意这里连隐式移动都不会发生——global_point不是局部变量C11 的隐式移动规则不适用于它。所以这里确实是拷贝构造。如果你想要移动得显式写return std::move(global_point);。用汇编看 RVO 的效果理解原理很重要但没有什么比直接看汇编更能说明问题了。我们来写两个函数对比有无 RVO 时的编译输出。// rvo_asm.cpp -- 用 Compiler Explorer 查看汇编// 建议在 https://godbolt.org 上查看完整汇编structHeavy{intdata[256];Heavy(intv){for(autod:data)dv;}Heavy(constHeavyo){for(inti0;i256;i)data[i]o.data[i];}Heavy(Heavyo)noexcept{for(inti0;i256;i)data[i]o.data[i];}};Heavywith_rvo(intv){returnHeavy(v);// C17 保证消除}Heavywithout_rvo(Heavy h){returnh;// 参数返回无法 NRVO}在 x86-64 上用g -stdc17 -O2编译GCC 15with_rvo的汇编如下// GCC 15, -O2 -stdc17 with_rvo(int): movd %esi, %xmm1 ; 参数 v 加载到 SSE 寄存器 movq %rdi, %rax ; rdi 调用者提供的返回值地址 leaq 1024(%rdi), %rdx ; 循环终止地址 起始 1024 pshufd $0, %xmm1, %xmm0 ; 将 v 广播到 xmm0 的全部 4 个 int .L2: movups %xmm0, (%rax) ; 每次写入 16 字节 addq $32, %rax movups %xmm0, -16(%rax) cmpq %rdx, %rax jne .L2 movq %rdi, %rax ret注意几点函数通过隐含的rdi参数调用者提供的空间地址直接在调用者的内存上工作。它把v用pshufd广播到 SSE 寄存器的 4 个 lane然后每次循环写 32 字节两条movups循环 1024/32 32 次填满整个data[256]共 1024 字节。没有memcpy调用没有额外的内存拷贝——构造和返回合二为一。without_rvo的汇编则明显不同// GCC 15, -O2 -stdc17 without_rvo(Heavy): movq (%rsi), %rax movq %rdi, %rdx leaq 8(%rdi), %rdi movq %rax, -8(%rdi) movq 1016(%rsi), %rax movq %rax, 1008(%rdi) andq $-8, %rdi movq %rdx, %rax subq %rdi, %rax leal 1024(%rax), %ecx ; 待复制的字节数1024 subq %rax, %rsi movq %rdx, %rax shrl $3, %ecx ; 1024 / 8 128 个四字qword rep movsq ; 复制 128 * 8 1024 字节 ret这里有rep movsqecx被计算为1024 / 8 128——一次 1024 字节的内存复制操作int data[256]的大小就是 256 * 4 1024 字节。编译器先处理了首尾各 8 字节的对齐问题然后把中间部分用rep movsq一次性搬过去。这就是没有 RVO/NRVO 时的代价对大对象来说这 1024 字节的拷贝可能成为热点路径上的瓶颈。RVO 和移动语义的关系很多人会把 RVO 和移动语义搞混觉得反正有移动了RVO 无所谓。实际上它们是不同层次的优化而且 RVO 优先级更高。RVO/NRVO 是消除——连移动都省了。移动语义是降级——从深拷贝降级为浅层指针转移。两者的关系可以用一个简单的优先级链来表示保证消除C17 prvalue NRVO编译器优化 隐式移动C11 拷贝构造编译器会从左到右尝试——先看能不能消除不行就看能不能 NRVO再不行就隐式移动最后才用拷贝构造。所以你不需要担心RVO 失效了是不是性能就崩了——即使 RVO 失效你还有移动语义兜底比 C03 时代的纯拷贝要好得多。这也引出了一个非常重要的实战规则永远不要写return std::move(local_var);。Heavybad_idea(){Heavyh(42);returnstd::move(h);// 阻止了 NRVO}Heavygood_idea(){Heavyh(42);returnh;// 可能触发 NRVO退一步也是隐式移动}return std::move(h);把h显式转换成右值引用这意味着编译器必须使用移动构造——NRVO 的机会被你亲手掐掉了。而return h;让编译器有最大的自由度它可以做 NRVO直接消除也可以做隐式移动C11 保证无论哪种都比显式std::move好。通用示例——字符串构建工厂让我们把 RVO/NRVO 的知识应用到一个实际的场景中。假设我们在写一个配置文件解析器需要一个工厂函数来构建配置字符串#includeiostream#includestring#includemapusingConfigstd::mapstd::string,std::string;/// brief 将配置映射转换为可读的字符串/// NRVO 场景返回命名局部变量std::stringformat_config_nrvo(constConfigcfg){std::string result;result.reserve(256);// 预分配避免多次扩容for(constauto[key,value]:cfg){resultkey;result ;resultvalue;result\n;}returnresult;// NRVOresult 直接在调用者空间构造}/// brief 构建一条简单的配置行/// RVO 场景返回 prvaluestd::stringmake_config_line(conststd::stringkey,conststd::stringvalue){returnkey value\n;// C17 保证消除}/// brief 条件返回——NRVO 可能失效的例子std::stringformat_with_default(constConfigcfg,conststd::stringkey,conststd::stringdefault_value){autoitcfg.find(key);if(it!cfg.end()){returnit-first it-second\n;// prvalue保证消除}returnkey default_value (default)\n;// prvalue保证消除}intmain(){Config cfg{{host,localhost},{port,8080},{debug,true},};std::string formattedformat_config_nrvo(cfg);std::coutformatted;std::string linemake_config_line(timeout,30);std::coutline;std::string fallbackformat_with_default(cfg,timeout,60);std::coutfallback;return0;}这三个函数分别展示了不同的返回场景。format_config_nrvo返回一个经过复杂构建过程的命名变量NRVO 可以让result直接在调用者的空间里增长——连一次字符串移动都省了。make_config_line返回表达式结果prvalueC17 保证消除。format_with_default虽然有条件分支但每个分支都返回 prvalue所以仍然能享受保证消除。动手实验——rvo_demo.cpp我们来写一个完整的实验程序把 RVO、NRVO、失效场景、以及std::move的误用全部跑一遍。// rvo_demo.cpp -- RVO / NRVO 完整演示// Standard: C17#includeiostream#includestring#includeutilityclassTracker{std::string name_;public:explicitTracker(std::string name):name_(std::move(name)){std::cout [name_] 构造\n;}Tracker(constTrackerother):name_(other.name__copy){std::cout [name_] 拷贝构造\n;}Tracker(Trackerother)noexcept:name_(std::move(other.name_)){other.name_(moved-from);std::cout [name_] 移动构造\n;}~Tracker(){std::cout [name_] 析构\n;}conststd::stringname()const{returnname_;}};/// brief RVO返回 prvalueTrackermake_rvo(conststd::stringname){returnTracker(name_rvo);}/// brief NRVO返回命名局部变量Trackermake_nrvo(conststd::stringname){Trackert(name_nrvo);returnt;}/// brief 失效的 NRVO两个返回分支返回不同命名对象Trackermake_bad_nrvo(conststd::stringname,boolflag){Trackera(name_a);Trackerb(name_b);if(flag){returna;}returnb;}/// brief 错误示范用 std::move 阻止了 NRVOTrackermake_bad_move(conststd::stringname){Trackert(name_badmove);returnstd::move(t);// 显式移动阻止 NRVO}/// brief 返回函数参数——NRVO 不适用但有隐式移动Trackerreturn_param(Tracker t){returnt;}intmain(){std::cout 1. RVO返回 prvalue\n;{autoamake_rvo(A);std::cout 结果: a.name()\n;}std::cout\n;std::cout 2. NRVO返回命名变量\n;{autobmake_nrvo(B);std::cout 结果: b.name()\n;}std::cout\n;std::cout 3. NRVO 失效不同命名对象\n;{autocmake_bad_nrvo(C,true);std::cout 结果: c.name()\n;}std::cout\n;std::cout 4. 错误std::move 阻止 NRVO \n;{autodmake_bad_move(D);std::cout 结果: d.name()\n;}std::cout\n;std::cout 5. 返回参数隐式移动\n;{Trackerparam(E_param);autoereturn_param(std::move(param));std::cout 结果: e.name()\n;}std::cout\n;std::cout 程序结束 \n;return0;}编译运行g-stdc17-Wall-Wextra-O2-orvo_demo rvo_demo.cpp ./rvo_demo实际输出GCC 15,-stdc17 -O2 1. RVO返回 prvalue [A_rvo] 构造 结果: A_rvo [A_rvo] 析构 2. NRVO返回命名变量 [B_nrvo] 构造 结果: B_nrvo [B_nrvo] 析构 3. NRVO 失效不同命名对象 [C_a] 构造 [C_b] 构造 [C_a] 移动构造 [C_b] 析构 [(moved-from)] 析构 结果: C_a [C_a] 析构 4. 错误std::move 阻止 NRVO [D_badmove] 构造 [D_badmove] 移动构造 [(moved-from)] 析构 结果: D_badmove [D_badmove] 析构 5. 返回参数隐式移动 [E_param] 构造 [E_param] 移动构造 [E_param] 移动构造 [(moved-from)] 析构 结果: E_param [E_param] 析构 [(moved-from)] 析构我们来仔细分析这些输出。第 1 和第 2 步是完美的情况——RVO 和 NRVO 都生效了每个对象只构造了一次没有任何拷贝或移动。第 3 步中 NRVO 失效了因为两个分支返回不同的命名对象编译器选择了隐式移动aC_a变成了移动构造b则正常析构。第 4 步展示了return std::move(t)的后果——NRVO 被阻止额外的移动构造发生了。第 5 步比较有意思return_param接收参数时发生了一次移动构造std::move(param)触发返回参数时又发生了一次隐式移动——总共两次移动。注意析构的顺序——param的析构在e之后因为param在外层作用域声明它比e的作用域更晚结束。如果你用-fno-elide-constructors关闭消除重新编译你会发现第 2 步NRVO出现了移动构造但第 1 步RVO不受影响——这就是 C17 保证消除和非保证优化的区别。第 1 步在 C17 下是保证消除的-fno-elide-constructors对它无效因为保证消除是语言语义不是编译器优化开关能控制的。而 NRVO 仍然是允许但非必需的优化所以能被-fno-elide-constructors关闭。实战指导把理论落实到实际编码中这里有几条简单的规则可以帮你最大化 RVO/NRVO 的收益。第一按值返回不要用输出参数。std::string build_message()比void build_message(std::string out)更利于 RVO/NRVO。现代 C 的哲学是写自然的代码让编译器替你优化而按值返回正是最自然的写法。第二不要写return std::move(local);。这条规则已经强调过好几次了因为笔者见过太多好心办坏事的案例。return local;让编译器有最大的优化空间——它可以做 NRVO、也可以做隐式移动。return std::move(local);强制退化到移动构造这是反优化。第三保持返回路径简单。如果你有多个返回分支尽量让它们返回同一个命名变量或者都返回 prvalue。避免不同分支返回不同的命名对象——这会阻止 NRVO。第四性能敏感的代码要测量。RVO/NRVO 是编译器的优化不同编译器、不同版本、不同优化级别的行为可能不同。如果你真的在意某次返回的性能写一个 benchmark 来测量而不是靠猜测。小结RVO 和 NRVO 是现代 C 给我们的免费午餐——在不牺牲代码可读性的前提下编译器把返回值的开销抹掉了。C17 进一步把 prvalue 返回的消除提升为语言保证让我们可以更安心地按值返回大对象。NRVO 虽然不是保证的但主流编译器在开启优化后几乎都会做。记住最关键的一条规则返回局部变量时直接return不要加std::move——让编译器做它最擅长的事。下一篇我们进入移动语义实战看看这些理论知识在 STL 容器和自定义类型中是如何发挥作用的。相关阅读OnceCallback 实战五then 链式组合 - 相似度 41%