C++ RVO 返回值优化:C++17 强制拷贝省略与 NRVO 实践指南
1. 从一次诡异的对象拷贝说起前阵子帮一个做音视频处理的朋友排查性能问题代码逻辑很简单一个函数返回一个封装了缓冲区的大对象调用方直接拿返回值初始化新对象。按教科书上的说法这里至少会发生一次拷贝构造运气不好还得加上一次临时对象的构造和析构。但实际跑下来用性能分析工具一看拷贝构造函数压根没被调用对象地址从头到尾就是同一个。朋友一脸懵地问我编译器是不是偷偷优化掉了什么这就是C RVO全称Return Value Optimization返回值优化。它不是什么新标准里的黑魔法而是编译器在特定条件下对函数返回对象时的一种优化行为允许编译器直接在调用方的存储空间里构造被返回的对象从而省掉一次甚至多次拷贝。这个机制从 C 早期就存在C17 之后更是把其中一部分场景从“允许优化”升级成了“强制要求”。这篇文章想聊清楚几件事RVO 到底在什么条件下触发编译器为什么能这么干C17 前后有什么本质区别以及在实际写代码时怎么利用它、又容易在哪些地方踩坑。内容会涉及对象生命周期、拷贝省略、具名返回值优化NRVO、移动语义的配合以及一些实测的编译器行为差异。适合已经写过一段时间 C、对对象模型和性能有一定敏感度的开发者也适合刚接触移动语义、想搞清楚“为什么有时候移动构造也没被调用”的朋友。我个人的习惯是遇到这类“编译器悄悄做了事”的机制一定要把它背后的规则和边界摸清楚否则写出来的代码在换编译器、换优化等级、甚至换标准版本时行为可能完全不一样。RVO 就是典型例子。2. RVO 到底是什么为什么值得单独拿出来讲2.1 一次函数返回理论上要发生多少次构造和析构先看一段最朴素的代码假设我们有一个管理动态数组的类class Buffer { public: Buffer() { /* 分配内存 */ } Buffer(const Buffer other) { /* 深拷贝 */ } Buffer(Buffer other) noexcept { /* 转移所有权 */ } ~Buffer() { /* 释放内存 */ } }; Buffer makeBuffer() { Buffer buf; // 填充数据 return buf; } int main() { Buffer b makeBuffer(); }如果不做任何优化按照 C 的对象模型makeBuffer内部先构造局部对象buf然后return buf时用buf拷贝构造一个临时对象作为返回值函数结束后buf析构回到main里再用这个临时对象拷贝构造b临时对象析构。整个过程是1 次默认构造 2 次拷贝构造 2 次析构。对于管理大块内存的类来说这个开销非常可观。RVO 要解决的就是这个问题。它允许编译器把buf直接构造在b所在的内存位置上return语句不产生任何拷贝buf的析构也推迟到b的生命周期结束时才发生。最终只剩 1 次默认构造和 1 次析构。2.2 两种形式URVO 和 NRVORVO 在实际讨论中通常分成两类URVOUnnamed Return Value Optimization返回的是临时对象也就是直接写return Buffer();这种形式。C17 起这种场景的拷贝省略是强制的编译器必须直接在调用方空间构造连拷贝构造和移动构造都不需要存在。NRVONamed Return Value Optimization返回的是具名局部变量也就是上面例子里的return buf;。这种优化是允许但非强制的编译器可以选择做也可以选择不做标准不要求。这个区别非常关键。很多人以为 C17 之后所有 RVO 都强制了其实不是强制的是 URVONRVO 依然依赖编译器实现。我在实际项目里就遇到过同一段代码在某个编译器上零拷贝、换一个编译器就多了一次移动构造的情况。2.3 为什么编译器能“凭空”省掉拷贝核心在于 C 标准里的copy elision拷贝省略规则。标准明确允许编译器在某些情况下省略拷贝/移动构造即使这些构造函数有副作用比如打印日志编译器也可以不调用它们。这听起来有点违反直觉因为通常我们认为“有副作用的函数一定会被调用”但拷贝省略是一个特例。背后的逻辑是拷贝构造的语义是“用已有对象创建一个等价的新对象”如果编译器能证明直接在目标位置构造出这个对象且行为等价那么跳过中间步骤并不改变程序的可观察行为除了构造函数里的副作用。标准把这个优化权限交给了编译器C17 进一步把 URVO 场景下的这个权限变成了义务。注意拷贝省略跳过的是拷贝/移动构造函数的调用但对象的析构依然会在正确的时间点发生。不要以为构造函数被跳过了对象的生命周期管理就可以乱来。3. C17 前后规则到底变了什么3.1 C14 及之前全靠编译器自觉在 C14 时代URVO 和 NRVO 都只是“允许的优化”。标准里用的是“may”编译器可以选择做或不做。绝大多数主流编译器在开启优化哪怕是-O0之外的默认级别时都会做 URVONRVO 在-O2下也基本都会做。但这意味着如果你依赖 RVO 来保证性能代码在不同编译器、不同优化等级下表现可能不一致。拷贝构造函数和移动构造函数必须存在且可访问否则代码无法编译哪怕实际不会被调用。我见过有同事写了一个只允许移动、禁止拷贝的类然后在return一个具名局部变量时因为编译器没做 NRVO 而报错折腾了半天才发现是拷贝构造被删除了导致编译失败。这在 C14 下是真实存在的坑。3.2 C17 起URVO 变成强制NRVO 依然看心情C17 引入了一个新概念叫guaranteed copy elision。对于返回纯右值prvalue的场景比如Buffer makeBuffer() { return Buffer(); }标准要求编译器必须直接在调用方的存储位置构造这个Buffer对象不产生临时对象也不调用拷贝或移动构造。这意味着即使你把拷贝构造和移动构造都删掉这段代码依然能编译通过。但 NRVO 没有享受到这个待遇。return buf;里的buf是一个具名变量属于左值标准依然只允许编译器做优化不强制。所以场景C14C17 及以后返回临时对象return T();允许优化拷贝/移动构造需存在强制省略拷贝/移动构造可不存在返回具名局部变量return x;允许优化拷贝/移动构造需存在允许优化拷贝/移动构造需存在返回函数参数允许优化允许优化返回全局变量/静态变量不适用不适用这个表格是我自己在整理标准条款时做的实际写代码时记住一点就够了返回临时对象是零成本的返回具名局部变量则要看编译器脸色。3.3 一个容易混淆的点移动构造什么时候登场很多人学移动语义时被告知“返回局部变量会自动移动”这话对但不完整。准确地说如果编译器做了 NRVO那么既没有拷贝也没有移动对象直接在目标位置构造。如果编译器没做 NRVO那么return buf;里的buf会被当作右值触发移动构造前提是移动构造可用。如果移动构造不可用但拷贝构造可用则触发拷贝构造。如果两者都不可用编译失败。所以移动构造其实是 NRVO 没生效时的“保底方案”。C11 引入移动语义后即使 NRVO 没做返回大对象的开销也从深拷贝降级成了所有权转移这已经是一个巨大的进步。但 NRVO 生效时连移动都省了这才是最优解。4. 实测不同编译器和优化等级下的行为差异4.1 测试代码设计为了直观看到 RVO 的触发情况我写了一个带日志输出的类在每个特殊成员函数里打印信息#include iostream class Tracker { public: Tracker() { std::cout default ctor\n; } Tracker(const Tracker) { std::cout copy ctor\n; } Tracker(Tracker) noexcept { std::cout move ctor\n; } ~Tracker() { std::cout dtor\n; } }; Tracker makeUnnamed() { return Tracker(); } Tracker makeNamed() { Tracker t; return t; } int main() { std::cout --- unnamed ---\n; Tracker a makeUnnamed(); std::cout --- named ---\n; Tracker b makeNamed(); }4.2 实测结果对比我在几台不同环境上跑了这段代码结果如下只列出关键行环境makeUnnamed输出makeNamed输出某编译器 A-O0C14default ctor dtordefault ctor move ctor dtor dtor某编译器 A-O2C14default ctor dtordefault ctor dtor某编译器 A-O0C17default ctor dtordefault ctor move ctor dtor dtor某编译器 A-O2C17default ctor dtordefault ctor dtor某编译器 B-O0C17default ctor dtordefault ctor dtor可以看到几个规律URVO 在 C17 下即使-O0也是零拷贝符合标准强制要求。NRVO 在-O0下不一定生效某编译器 A 在-O0下就没做某编译器 B 做了。开启-O2后NRVO 基本都会生效。C14 下 URVO 在-O0时也可能不生效因为那时还不是强制的。这个实测结果说明如果你的代码性能依赖 NRVO最好不要假设它在所有构建配置下都生效。尤其是调试版本NRVO 可能被关闭这时候移动构造的性能就体现出来了。4.3 为什么-O0下 NRVO 可能不生效这跟编译器的实现策略有关。NRVO 需要在函数调用约定层面做调整把返回对象的地址作为隐藏参数传给被调函数让被调函数直接在这个地址上构造局部变量。这个变换在优化阶段做起来更自然-O0下编译器倾向于保持代码结构简单方便调试所以可能不做这个变换。URVO 在 C17 下是语言规则要求的编译器必须在语义分析阶段就处理好跟优化等级无关。实操心得如果你在调试一个性能问题发现-O0下拷贝构造被频繁调用先别急着改代码把优化等级调到-O2再看。很多“性能问题”在优化版本里根本不存在。5. 怎么写代码才能最大化利用 RVO5.1 优先返回临时对象而不是具名变量既然 URVO 是强制的NRVO 不是那么在性能敏感的路径上能返回临时对象就返回临时对象。比如工厂函数// 推荐URVO 强制生效 Buffer makeBuffer(int size) { return Buffer(size); } // 不推荐依赖 NRVO Buffer makeBuffer(int size) { Buffer buf(size); // 一些初始化操作 return buf; }但现实是很多场景下你需要在返回前对对象做一系列操作没法直接return Buffer(...)。这时候 NRVO 就是你的朋友只要编译器做了优化效果和 URVO 一样。我的建议是不要为了追求 URVO 而把代码写得别扭NRVO 在主流编译器的发布构建里基本都会生效可读性更重要。5.2 不要画蛇添足地return std::move(x)这是我最想强调的一个坑。很多人学了移动语义后觉得在return语句里显式加std::move能“帮助”编译器移动于是写成Buffer makeBuffer() { Buffer buf; return std::move(buf); // 错误做法 }这实际上会阻止 NRVO。因为std::move(buf)是一个右值表达式返回的不再是具名变量本身编译器无法在这个表达式上做 NRVO。结果就是NRVO 失效但移动构造依然会被调用因为返回的是右值所以性能上比 NRVO 差但比拷贝好。更糟糕的是如果这个对象后续还要用std::move可能让它处于被移空的状态。正确的做法就是直接return buf;。标准规定在return语句中如果返回的是局部变量编译器会优先尝试 NRVO如果 NRVO 不生效这个局部变量会被自动当作右值处理触发移动构造。你不需要手动std::move。5.3 返回成员变量和函数参数时的行为RVO 的适用范围仅限于局部自动变量。如果你返回的是类的成员变量、函数参数、全局变量或静态变量NRVO 不适用。比如class Container { Buffer buf_; public: Buffer getBuffer() { return buf_; // 返回成员变量NRVO 不适用会拷贝 } };这种情况下如果确实需要返回成员变量可以考虑返回引用或指针或者用std::move显式转移所有权但要清楚这会改变成员的状态。函数参数同理返回参数不会触发 NRVO因为参数的生命周期不由被调函数管理。5.4 移动构造一定要标记noexcept这是一个容易被忽略但影响很大的细节。当 NRVO 不生效时编译器会尝试用移动构造来返回对象。但如果移动构造没有标记noexcept在某些场景下比如std::vector扩容时编译器为了异常安全可能会选择拷贝构造而不是移动构造。虽然函数返回场景下这个规则不完全一样但养成给移动构造加noexcept的习惯总是没错的。Buffer(Buffer other) noexcept { /* ... */ }我见过一个项目里移动构造忘了加noexcept结果在某些标准库实现下返回大对象时性能比预期差了一截排查了很久才发现是这个原因。6. 常见问题与排查技巧实录6.1 为什么我的拷贝构造被调用了这是最常见的问题。排查思路可以按下面的顺序来确认返回的是不是局部自动变量。返回成员变量、参数、全局变量都不会触发 NRVO。检查有没有手动std::move。return std::move(x);会阻止 NRVO。确认优化等级。-O0下 NRVO 可能不生效试试-O2。确认标准版本。C17 下 URVO 强制C14 下不是。检查是否有多个返回路径。如果函数有多个return语句返回不同的局部变量NRVO 可能无法应用因为编译器需要在编译期确定返回对象的构造位置。第 5 点特别隐蔽。比如Buffer makeBuffer(bool flag) { Buffer a, b; if (flag) return a; else return b; }这种情况下两个不同的局部变量都可能被返回编译器很难对两者都做 NRVO通常会退化成移动构造。如果性能敏感可以考虑合并成一个变量或者用其他设计。6.2 拷贝构造被删除了为什么代码编译不过在 C14 下即使实际不会调用拷贝构造只要代码里存在可能触发拷贝的返回语句编译器就要求拷贝构造可访问。C17 的 URVO 场景下这个要求被取消了但 NRVO 场景依然要求拷贝或移动构造存在。如果你写了一个只允许移动的类在 C14 下返回具名局部变量时可能编译失败。解决办法有两个升级到 C17 并改用返回临时对象或者确保移动构造可用NRVO 不生效时会走移动。6.3 常见问题速查表现象可能原因排查方向拷贝构造被调用返回的不是局部变量检查返回对象类型拷贝构造被调用手动加了std::move去掉std::move拷贝构造被调用-O0下 NRVO 未生效提高优化等级移动构造被调用NRVO 未生效但移动可用正常现象可接受编译失败拷贝构造被删除C14 下 NRVO 场景要求拷贝/移动可用升级标准或改用 URVO多个返回路径导致性能下降NRVO 无法应用合并返回变量6.4 一个容易被忽略的坑RVO 与对象身份RVO 生效时返回的对象和调用方接收的对象是同一个对象地址相同。这意味着如果你在构造函数里记录了this指针或者在对象里保存了指向自身的指针RVO 不会破坏这个关系。但如果你依赖“返回时会创建一个新对象”这个假设来做某些事情比如在拷贝构造里做引用计数RVO 会让这些逻辑不被执行。注意不要在拷贝构造或移动构造里放必须执行的业务逻辑。拷贝省略允许编译器跳过这些构造函数任何依赖它们被调用的副作用都是不可靠的。7. 把 RVO 放进更大的性能优化图景里RVO 只是 C 对象返回优化的一环。把它和移动语义、emplace_back、std::optional等机制放在一起看才能形成完整的性能优化思路。比如当你需要返回一个可能不存在的对象时std::optionalT配合 RVO 可以做到零拷贝std::optionalBuffer makeBuffer(bool flag) { if (!flag) return std::nullopt; return Buffer(); }这里return Buffer();是 URVOstd::optional的构造会直接在内部存储上完成没有额外拷贝。类似地std::variant、std::tuple等类型在返回时也能受益于 RVO。另一个相关场景是容器操作。std::vector::emplace_back允许直接在容器内存上构造对象避免临时对象的拷贝。这和 RVO 的思路是一致的能就地构造就不要先构造再搬移。理解了这个核心思想很多 C 的性能优化手段就串起来了。我在实际项目里总结出一条经验凡是涉及大对象返回的地方先确认 RVO 是否生效再考虑移动语义最后才考虑指针或引用等间接手段。RVO 和移动语义能解决绝大多数场景只有在对象生命周期确实无法用值语义表达时才需要引入指针。最后分享一个小技巧如果你想确认某个返回语句是否触发了 RVO可以在拷贝构造和移动构造里打日志然后在发布构建下跑一遍。如果两者都没输出说明 RVO 生效了。这个方法比看汇编简单得多适合日常快速验证。