1. 从一次诡异的对象拷贝说起
前阵子帮一个做音视频处理的朋友排查性能问题,代码逻辑很简单:一个函数返回一个封装了缓冲区的大对象,调用方直接拿返回值初始化新对象。按教科书上的说法,这里至少会发生一次拷贝构造,运气不好还得加上一次临时对象的构造和析构。但实际跑下来,用性能分析工具一看,拷贝构造函数压根没被调用,对象地址从头到尾就是同一个。朋友一脸懵地问我:编译器是不是偷偷优化掉了什么?
这就是C++ RVO,全称Return Value Optimization,返回值优化。它不是什么新标准里的黑魔法,而是编译器在特定条件下对函数返回对象时的一种优化行为,允许编译器直接在调用方的存储空间里构造被返回的对象,从而省掉一次甚至多次拷贝。这个机制从 C++ 早期就存在,C++17 之后更是把其中一部分场景从“允许优化”升级成了“强制要求”。
这篇文章想聊清楚几件事:RVO 到底在什么条件下触发,编译器为什么能这么干,C++17 前后有什么本质区别,以及在实际写代码时怎么利用它、又容易在哪些地方踩坑。内容会涉及对象生命周期、拷贝省略、具名返回值优化(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 和 NRVO
RVO 在实际讨论中通常分成两类:
- URVO(Unnamed Return Value Optimization):返回的是临时对象,也就是直接写
return Buffer();这种形式。C++17 起,这种场景的拷贝省略是强制的,编译器必须直接在调用方空间构造,连拷贝构造和移动构造都不需要存在。 - NRVO(Named Return Value Optimization):返回的是具名局部变量,也就是上面例子里的
return buf;。这种优化是允许但非强制的,编译器可以选择做,也可以选择不做,标准不要求。
这个区别非常关键。很多人以为 C++17 之后所有 RVO 都强制了,其实不是,强制的是 URVO,NRVO 依然依赖编译器实现。我在实际项目里就遇到过同一段代码在某个编译器上零拷贝、换一个编译器就多了一次移动构造的情况。
2.3 为什么编译器能“凭空”省掉拷贝
核心在于 C++ 标准里的copy elision(拷贝省略)规则。标准明确允许编译器在某些情况下省略拷贝/移动构造,即使这些构造函数有副作用(比如打印日志),编译器也可以不调用它们。这听起来有点违反直觉,因为通常我们认为“有副作用的函数一定会被调用”,但拷贝省略是一个特例。
背后的逻辑是:拷贝构造的语义是“用已有对象创建一个等价的新对象”,如果编译器能证明直接在目标位置构造出这个对象,且行为等价,那么跳过中间步骤并不改变程序的可观察行为(除了构造函数里的副作用)。标准把这个优化权限交给了编译器,C++17 进一步把 URVO 场景下的这个权限变成了义务。
注意:拷贝省略跳过的是拷贝/移动构造函数的调用,但对象的析构依然会在正确的时间点发生。不要以为构造函数被跳过了,对象的生命周期管理就可以乱来。
3. C++17 前后,规则到底变了什么
3.1 C++14 及之前:全靠编译器自觉
在 C++14 时代,URVO 和 NRVO 都只是“允许的优化”。标准里用的是“may”,编译器可以选择做或不做。绝大多数主流编译器在开启优化(哪怕是-O0之外的默认级别)时都会做 URVO,NRVO 在-O2下也基本都会做。但这意味着:
- 如果你依赖 RVO 来保证性能,代码在不同编译器、不同优化等级下表现可能不一致。
- 拷贝构造函数和移动构造函数必须存在且可访问,否则代码无法编译,哪怕实际不会被调用。
我见过有同事写了一个只允许移动、禁止拷贝的类,然后在return一个具名局部变量时,因为编译器没做 NRVO 而报错,折腾了半天才发现是拷贝构造被删除了导致编译失败。这在 C++14 下是真实存在的坑。
3.2 C++17 起:URVO 变成强制,NRVO 依然看心情
C++17 引入了一个新概念叫guaranteed copy elision。对于返回纯右值(prvalue)的场景,比如:
Buffer makeBuffer() { return Buffer(); }标准要求编译器必须直接在调用方的存储位置构造这个Buffer对象,不产生临时对象,也不调用拷贝或移动构造。这意味着即使你把拷贝构造和移动构造都删掉,这段代码依然能编译通过。
但 NRVO 没有享受到这个待遇。return buf;里的buf是一个具名变量,属于左值,标准依然只允许编译器做优化,不强制。所以:
| 场景 | C++14 | C++17 及以后 |
|---|---|---|
返回临时对象return T(); | 允许优化,拷贝/移动构造需存在 | 强制省略,拷贝/移动构造可不存在 |
返回具名局部变量return x; | 允许优化,拷贝/移动构造需存在 | 允许优化,拷贝/移动构造需存在 |
| 返回函数参数 | 允许优化 | 允许优化 |
| 返回全局变量/静态变量 | 不适用 | 不适用 |
这个表格是我自己在整理标准条款时做的,实际写代码时记住一点就够了:返回临时对象是零成本的,返回具名局部变量则要看编译器脸色。
3.3 一个容易混淆的点:移动构造什么时候登场
很多人学移动语义时被告知“返回局部变量会自动移动”,这话对,但不完整。准确地说:
- 如果编译器做了 NRVO,那么既没有拷贝也没有移动,对象直接在目标位置构造。
- 如果编译器没做 NRVO,那么
return buf;里的buf会被当作右值,触发移动构造(前提是移动构造可用)。 - 如果移动构造不可用但拷贝构造可用,则触发拷贝构造。
- 如果两者都不可用,编译失败。
所以移动构造其实是 NRVO 没生效时的“保底方案”。C++11 引入移动语义后,即使 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,-O0,C++14 | default ctor + dtor | default ctor + move ctor + dtor + dtor |
某编译器 A,-O2,C++14 | default ctor + dtor | default ctor + dtor |
某编译器 A,-O0,C++17 | default ctor + dtor | default ctor + move ctor + dtor + dtor |
某编译器 A,-O2,C++17 | default ctor + dtor | default ctor + dtor |
某编译器 B,-O0,C++17 | default ctor + dtor | default ctor + dtor |
可以看到几个规律:
- URVO 在 C++17 下即使
-O0也是零拷贝,符合标准强制要求。 - NRVO 在
-O0下不一定生效,某编译器 A 在-O0下就没做,某编译器 B 做了。 - 开启
-O2后,NRVO 基本都会生效。 - C++14 下 URVO 在
-O0时也可能不生效,因为那时还不是强制的。
这个实测结果说明,如果你的代码性能依赖 NRVO,最好不要假设它在所有构建配置下都生效。尤其是调试版本,NRVO 可能被关闭,这时候移动构造的性能就体现出来了。
4.3 为什么-O0下 NRVO 可能不生效
这跟编译器的实现策略有关。NRVO 需要在函数调用约定层面做调整,把返回对象的地址作为隐藏参数传给被调函数,让被调函数直接在这个地址上构造局部变量。这个变换在优化阶段做起来更自然,-O0下编译器倾向于保持代码结构简单,方便调试,所以可能不做这个变换。URVO 在 C++17 下是语言规则要求的,编译器必须在语义分析阶段就处理好,跟优化等级无关。
实操心得:如果你在调试一个性能问题,发现
-O0下拷贝构造被频繁调用,先别急着改代码,把优化等级调到-O2再看。很多“性能问题”在优化版本里根本不存在。
5. 怎么写代码才能最大化利用 RVO
5.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。 - 确认标准版本。C++17 下 URVO 强制,C++14 下不是。
- 检查是否有多个返回路径。如果函数有多个
return语句返回不同的局部变量,NRVO 可能无法应用,因为编译器需要在编译期确定返回对象的构造位置。
第 5 点特别隐蔽。比如:
Buffer makeBuffer(bool flag) { Buffer a, b; if (flag) return a; else return b; }这种情况下,两个不同的局部变量都可能被返回,编译器很难对两者都做 NRVO,通常会退化成移动构造。如果性能敏感,可以考虑合并成一个变量,或者用其他设计。
6.2 拷贝构造被删除了,为什么代码编译不过
在 C++14 下,即使实际不会调用拷贝构造,只要代码里存在可能触发拷贝的返回语句,编译器就要求拷贝构造可访问。C++17 的 URVO 场景下这个要求被取消了,但 NRVO 场景依然要求拷贝或移动构造存在。
如果你写了一个只允许移动的类,在 C++14 下返回具名局部变量时可能编译失败。解决办法有两个:升级到 C++17 并改用返回临时对象,或者确保移动构造可用(NRVO 不生效时会走移动)。
6.3 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 拷贝构造被调用 | 返回的不是局部变量 | 检查返回对象类型 |
| 拷贝构造被调用 | 手动加了std::move | 去掉std::move |
| 拷贝构造被调用 | -O0下 NRVO 未生效 | 提高优化等级 |
| 移动构造被调用 | NRVO 未生效但移动可用 | 正常现象,可接受 |
| 编译失败,拷贝构造被删除 | C++14 下 NRVO 场景要求拷贝/移动可用 | 升级标准或改用 URVO |
| 多个返回路径导致性能下降 | NRVO 无法应用 | 合并返回变量 |
6.4 一个容易被忽略的坑:RVO 与对象身份
RVO 生效时,返回的对象和调用方接收的对象是同一个对象,地址相同。这意味着如果你在构造函数里记录了this指针,或者在对象里保存了指向自身的指针,RVO 不会破坏这个关系。但如果你依赖“返回时会创建一个新对象”这个假设来做某些事情(比如在拷贝构造里做引用计数),RVO 会让这些逻辑不被执行。
注意:不要在拷贝构造或移动构造里放必须执行的业务逻辑。拷贝省略允许编译器跳过这些构造函数,任何依赖它们被调用的副作用都是不可靠的。
7. 把 RVO 放进更大的性能优化图景里
RVO 只是 C++ 对象返回优化的一环。把它和移动语义、emplace_back、std::optional等机制放在一起看,才能形成完整的性能优化思路。
比如,当你需要返回一个可能不存在的对象时,std::optional<T>配合 RVO 可以做到零拷贝:
std::optional<Buffer> makeBuffer(bool flag) { if (!flag) return std::nullopt; return Buffer(); }这里return Buffer();是 URVO,std::optional的构造会直接在内部存储上完成,没有额外拷贝。类似地,std::variant、std::tuple等类型在返回时也能受益于 RVO。
另一个相关场景是容器操作。std::vector::emplace_back允许直接在容器内存上构造对象,避免临时对象的拷贝。这和 RVO 的思路是一致的:能就地构造,就不要先构造再搬移。理解了这个核心思想,很多 C++ 的性能优化手段就串起来了。
我在实际项目里总结出一条经验:凡是涉及大对象返回的地方,先确认 RVO 是否生效,再考虑移动语义,最后才考虑指针或引用等间接手段。RVO 和移动语义能解决绝大多数场景,只有在对象生命周期确实无法用值语义表达时,才需要引入指针。
最后分享一个小技巧:如果你想确认某个返回语句是否触发了 RVO,可以在拷贝构造和移动构造里打日志,然后在发布构建下跑一遍。如果两者都没输出,说明 RVO 生效了。这个方法比看汇编简单得多,适合日常快速验证。