跳转至

RVO 与 NRVO:C++ 返回值优化

我相信绝大多数人在写C和老C++的时候,不会喜欢返回大结构体,因为这一定会触发一次对象拷贝,对不对?但是从C++11开始,有一种看不见却又非常实在的性能魔法——返回值优化(RVO, Return Value Optimization)和命名返回值优化(NRVO, Named RVO)悄然登场。它们悄无声息地消灭了很多本可以发生的拷贝(甚至移动),让你写“自然、直观”的代码而不用为性能担忧。今天把这事讲清楚:为什么会发生,什么时候发生,哪些写法能触发(或阻止)它,最后给出实用建议,能直接贴上博客去。


试一试比较流行的TL;DR

  • RVO/NRVO 能避免拷贝/移动构造,直接在调用者的空间构造返回对象,因此性能好。
  • C++17 对某类返回做了“保证消除”(即在某些返回情形下拷贝/移动被标准强制省略)。
  • 不要为了“显式移动”而写 return std::move(x); —— 这通常会阻止 NRVO,反而更慢。
  • 想得到最稳妥的性能:写自然、直观的返回本地对象的代码,信任编译器/标准。需要针对性优化时再测和调。

RVO、NRVO 究竟是什么

想象函数要返回一个大对象:如果按字面实现,函数会在内部构造一个临时对象,然后拷贝(或移动)到调用点;拷贝很贵。RVO/NRVO 的核心思路是,把“要返回的对象”直接在调用者提供的内存里构造,函数体内构造的就是最终对象——没有中间拷贝,也没有额外分配。RVO(匿名返回值优化)指的是直接返回一个临时表达式;NRVO(命名返回值优化)指的是返回函数内部的命名局部变量时进行的优化。

举个直观的对比:

// 假设 MyBigType 的拷贝/移动很贵
MyBigType make1() {
    MyBigType tmp(...); // 命名局部变量 tmp
    // 若触发 NRVO,tmp 会在调用者的空间中直接构造
    return tmp;         // NRVO 有机会发生
}

MyBigType make2() {
    return MyBigType(...); // 直接返回临时,RVO 有机会发生
}

在没有任何优化时,make1 可能会发生一次拷贝(或移动),而有了 NRVO/RVO,拷贝/移动被省了。


C++17 后的变化

在 C++17 之前,RVO/NRVO 是一种“允许但非必需”的优化:编译器通常会做,但标准并不强制。从 C++17 起,标准在若干情况下要求进行拷贝消除(例如返回 prvalue 时会被保证省略拷贝/移动)。这意味着,有些写法不再依赖编译器幸运与否:符合标准保证消除(guaranteed elision)的情形,编译器必须直接在目标处构造对象。

所以:在现代 C++(>= C++17)中,你能更放心地写 return MyType(...); 而不担心隐藏的拷贝成本。


哪些情形会阻止 NRVO / RVO?——常见踩坑

  1. 多处 return 返回不同的局部变量:如果函数有多个分支,每个分支返回不同的局部命名对象,编译器可能无法把它们都放在同一个目标空间,从而无法做 NRVO。
  2. 返回函数参数或引用:NRVO 只针对函数内部要返回的对象,不会把函数参数“移动”成返回对象的目标。
  3. 对返回的局部变量做 std::movereturn std::move(x); 会把 x 视为右值,从而抑制 NRVO(因为你显式表明想移动),编译器会选择移动而不是消除拷贝;通常这是退步。
  4. 异常控制流和复杂语义:在某些复杂控制流或异常相关的语义下,编译器可能无法模板化地保证消除(但在 C++17 的一些常见场景,这点已被标准覆盖)。
  5. 编译器被禁用消除的标志:很多编译器有关闭拷贝消除的开关(如 GCC 的 -fno-elide-constructors),用于测试行为;不要在发布构建中打开这个。

举个阻止 NRVO 的小例子:

MyBigType bad(bool flag) {
    MyBigType a(...);
    MyBigType b(...);
    if (flag) return a; // 可能无法 NRVO,因为另一分支返回不同命名对象
    else     return b;
}

相比之下,下面更有利于 NRVO(或至少更简单):

MyBigType good(bool flag) {
    if (flag) return MyBigType(...); // RVO 或者 C++17 保证消除
    else     return MyBigType(...);
}

std::move 的误用

很多人习惯在返回局部变量时写 return std::move(x);,自以为可以强制移动从而更快。实际上:

  • return x; —— 编译器可能做 NRVO(直接消除),也可能做移动构造(如果 NRVO 不可行)。
  • return std::move(x); —— 明确禁用了 NRVO 的机会(把 x 当作右值),编译器不得不执行移动构造。移动也有代价(尤其是当移动也要释放老资源、执行内存操作时)。因此,不要用 std::move 优化 return 本地变量,除非你对编译器行为和性能有充分测量和特殊原因。

简言之:return std::move(x); 往往是个性能反例而不是优化。


实用建议(写代码的姿势)

  • 写直观的代码:把函数实现写成自然的形式:构造局部对象并 return 它,或者直接 return Type(args...)。现代编译器和标准会替你把代价抹掉。
  • 别写 return std::move(local); 来“强制”移动——这是反优化。
  • 在性能敏感处用基准(benchmark)说话:不同平台、不同编译器、不同类型(移动开销 vs 拷贝开销)差别很大,遇到疑问就测。
  • 若必须避免拷贝,考虑传出参数或 emplace 风格:比如 void fill_into(MyBigType &out); 或者 MyBigType::create(..., std::in_place_args),但这应作为必要时的方案,不是常态。
  • 注意异常安全与语义清晰:为了讨好 RVO 而写晦涩代码不可取,优先保证正确与易读。性能问题可在热点处剖析。

小结

RVO 和 NRVO 是 C++ 给我们的一份免费礼物:在不牺牲可读性的前提下,编译器能把许多看似昂贵的返回开销抹掉。自 C++17 起,某些返回情形的消除变成了语言保证,这让我们写返回值风格的接口更加安心。日常开发里,遵循“写出自然、清晰的返回语义,信任编译器”的原则;当性能成问题时,用测量代替猜测,再针对性地调整实现。