RVO、NRVO 与移动语义:noexcept 怎样决定 vector 扩容走拷贝还是移动
编译优化系列的第五篇。上一篇量过错误处理机制的成本,本篇转向另一类隐式动作:对象的拷贝与移动。C++ 函数返回对象时真的会多构造一个临时对象吗?为什么有人强调移动构造一定要写 noexcept?同样往 vector 里 push_back 20 万个对象,仅仅因为移动构造的异常规格不同,扩容代价就能差出近四倍。本篇用一个给每次构造动作计数的对象,把返回值优化(RVO)、具名返回值优化(NRVO)与容器扩容的内部决策全部变成可读数字,再用关闭复制消除的构建反证每个结论。
本文要回答的问题
- 函数按值返回对象时,RVO 和 NRVO 分别省掉了哪次构造,哪个是标准保证、哪个靠编译器?
-fno-elide-constructors之后,为什么纯右值返回仍然零移动,具名返回却多出一次移动?vector扩容搬迁旧元素时,按什么规则在移动构造与拷贝构造之间选择?- 移动构造少一个
noexcept,为什么会让 20 万次扩容慢约 3.8 倍? - 工程上写资源持有类、用容器增长时,有哪些可以照抄的纪律?
一、两次构造的身世:RVO、NRVO 与复制消除
按值返回看起来天然要付出两次构造:函数内构造一个局部对象,返回时再复制一份给调用方。复制消除(copy elision)把这两次合并成一次,它有两种常见形态。RVO 处理纯右值返回,函数直接 return Widget(v),C++17 起这种情况的复制消除是标准强制保证,编译器没有选择权;NRVO 处理具名局部对象,函数先定义 Widget local(v) 再 return local,能不能消除取决于编译器的裁决,标准只允许、不强制。
基准在独立翻译单元里定义两个 noinline 工厂,防止编译器看穿调用点后改写返回路径,再用全局计数器记录接收对象身上实际发生的构造动作。
SafeWidget makeSafeRVO(int v) {
return SafeWidget(v); // 纯右值:C++17 保证消除
}
SafeWidget makeSafeNRVO(int v) {
SafeWidget local(v); // 具名局部:NRVO 是优化不是保证
local.id += 1;
return local;
}默认 -O2 构建下两个场景的计数完全相同:参数构造 1 次,拷贝 0、移动 0。为了确认这不是计数器失灵,再用 -fno-elide-constructors 构建一份反证二进制,这个开关强制编译器不要做可选的复制消除。
| 场景 | 默认构建(参数/拷贝/移动) | 关闭复制消除(参数/拷贝/移动) |
|---|---|---|
| RVO 纯右值返回 | 1 / 0 / 0 | 1 / 0 / 0 |
| NRVO 具名返回 | 1 / 0 / 0 | 1 / 0 / 1 |
反证结果恰好划出标准的边界:RVO 那一行在开关下依然是 1/0/0,因为 C++17 的保证复制消除不依赖优化开关,纯右值从语义上就是「在接收对象的存储上直接构造」,根本不存在一个可供消除的临时对象;NRVO 一行则退回 1 次参数构造加 1 次移动构造,说明默认构建里那次移动确实被优化吃掉了,而不是代码本身没有移动需求。这个差异也解释了为什么工程上返回局部对象可以放心,但返回一个经过复杂分支挑选的具名对象时,最好确认工具链的 NRVO 行为,必要时自己保证移动构造足够便宜。
NRVO 还有几个常见的失效模式值得记住。其一是函数在不同分支返回两个不同名字的局部对象,编译器无法把接收存储同时映射给两个变量,只能放弃消除、走隐式移动;其二是返回函数参数或全局变量,它们的存储不属于本函数栈帧,天然没有消除空间;其三是按引用返回局部对象,这已经不是优化问题而是悬空引用错误。失效本身并不可怕——C++11 之后返回具名局部对象时,编译器会先尝试隐式移动、移动不可用才退回拷贝,真正的风险是类根本没有移动构造,这时每一次返回都 silently 落到深拷贝。用本篇的计数器跑一遍自己的工厂函数,是确认这条退化链有没有被触发的最直接办法。
二、扩容时的抉择:move_if_noexcept 与强异常保证
vector 的存储空间连续,容量耗尽时必须申请一块更大的内存、把旧元素逐个搬到新地址、再释放旧内存。搬迁可以走移动构造(偷指针,常数时间)也可以走拷贝构造(重新分配并复制全部资源),这个选择不是随机的,标准库用 std::move_if_noexcept 做裁决:元素的移动构造承诺不抛异常(noexcept 为真)才搬走它,否则只要类型可拷贝,就退回拷贝构造。
读这张流程卡抓住第二步的问询:标准库宁可牺牲性能也要守住强异常保证——如果搬迁到一半某个移动构造抛了异常,已经被掏空的旧元素无法还原,vector 就会处于半损坏状态;而拷贝从不动旧数组,任何一步抛异常都能回滚。所以「移动构造应当无条件写 noexcept」不是风格偏好,而是让容器敢用你的移动构造的准入条件。
下图把裁决逻辑画成分支,红色是类型特征查询,两条出口分别对应零拷贝的移动路径与保守的拷贝路径。
读图沿两条出口对比:绿色移动路径上每个元素只交换一次指针,橙色拷贝路径上每个元素都要重新分配 256 字节缓冲并逐字节复制。基准用的两个类函数体逐行相同,唯一差异就是移动构造有没有 noexcept,类型特征查询的结果也打印在记录里:is_nothrow_move_constructible 对安全类为 1、对风险类为 0。
三、扩容轨迹:容量翻倍时动作数怎样累积
先跑 20 个元素的小规模轨迹,每次 capacity 变化打印一行,看搬迁动作怎样随容量翻倍累积。
trace safe size= 1 cap= 1 copy=0 move=1
trace safe size= 2 cap= 2 copy=0 move=3
trace safe size= 3 cap= 4 copy=0 move=6
trace safe size= 5 cap= 8 copy=0 move=12
trace safe size= 9 cap= 16 copy=0 move=24
trace safe size= 17 cap= 32 copy=0 move=48
---
trace risky size= 1 cap= 1 copy=1 move=1
trace risky size= 2 cap= 2 copy=1 move=2
trace risky size= 3 cap= 4 copy=3 move=3
trace risky size= 5 cap= 8 copy=7 move=5
trace risky size= 9 cap= 16 copy=15 move=9
trace risky size= 17 cap= 32 copy=31 move=17安全类的拷贝计数始终为 0,所有搬迁都是移动;风险类每次扩容都把旧元素逐个数拷贝一遍(cap 4 时累计 3、cap 8 时累计 7、cap 16 时 15,正好是每次容量减一的等比和),移动只剩新元素经 push_back 参数进容器的那一次。两条轨迹容量曲线完全一致,说明策略差异不影响增长几何,只影响每个旧元素的搬迁单价。理解这条轨迹也就能反推 reserve 的价值:提前一次给到目标容量,整条翻倍搬迁链根本不会发生。
四、20 万次扩容实测:3.8 倍差距与零拷贝对照
把规模拉到 20 万,让单次几十纳秒的差异累积成几十毫秒的墙钟时间。每个对象持有 256 字节堆缓冲,拷贝要重新分配加复制,移动只偷指针,机制差异被充分放大。
左面板是构造动作的真实计数:安全类 462143 次移动、零拷贝,其中约 20 万次来自新元素入容器、262143 次来自历次扩容搬迁(最终容量 262144,减一恰为搬迁总量);风险类正好相反,262143 次扩容搬迁全部变成拷贝,只保留 20 万次参数移动。右面板是同一过程 7 次计时的中位:27.286 ms 对 103.767 ms,差约 3.8 倍。
| 类型(N=200000) | 拷贝构造 | 移动构造 | 耗时中位 (ms) | 相对倍数 |
|---|---|---|---|---|
| SafeWidget(移动 noexcept) | 0 | 462143 | 27.286 | 1.00 |
| RiskyWidget(移动无 noexcept) | 262143 | 200000 | 103.767 | 3.80 |
完整原始记录如下,含类型特征查询、小规模轨迹、大规模计数计时与反证构建四段。

图中两类的参数构造都是 20 万次、最终容量都是 262144,输入与增长几何完全对齐,3.8 倍差距可以干净地归因到异常规格这一处变量,而不是对象大小或元素数量。
五、可照抄的四条工程纪律
第一,资源持有类按五法则配齐特殊成员,移动构造与移动赋值无条件写 noexcept。只要移动的动作只是交换指针、句柄、整数这类不会抛异常的操作,这个承诺就名副其实,标准容器与 std::move_if_noexcept 才敢在扩容、resize、shrink_to_fit 里使用它。
第二,知道元素规模就先 reserve。本篇轨迹显示每次容量翻倍都要搬迁全部旧元素,20 万元素累计搬迁 26 万次;一次到位的预留把这条链整条删掉,比任何事后优化都直接。
第三,返回局部对象放心按值返回,不要为了「避免拷贝」手写输出参数或堆分配。C++17 保证纯右值返回零拷贝,主流工具链的 NRVO 也相当稳定;真遇到复杂分支抑制 NRVO 的场景,先保证移动构造便宜,再考虑调整结构。
第四,性能判断要靠计数而不是猜测。本篇所有结论都来自一个几十行的计数器,同样的手法可以直接搬进任何代码库:给可疑类型的特殊成员加静态计数,跑一遍真实流程,隐式拷贝立刻现形——很多「移动了为什么还慢」的问题,答案就是某个移动构造漏了 noexcept 或根本没被生成。
移动构造「根本没被生成」是比漏写 noexcept 更隐蔽的坑。C++ 的隐式移动生成有明确抑制规则:用户一旦自己声明了拷贝构造、拷贝赋值或析构函数,编译器就不再隐式生成移动操作,所有本应移动的场景静默退回拷贝,代码照常编译、没有任何告警。老代码改造时这个坑尤其常见——给类加了一个析构打日志,移动语义就整条消失。排查有两个机械动作:用 static_assert(std::is_nothrow_move_constructible_v<T>) 在编译期钉住契约,或像本篇一样运行期计数;修复则优先用 = default 显式要回默认移动,再按需补 noexcept,而不是手写一遍易错的成员搬迁。
实验环境
- 机器/系统:Mac mini(x86_64)/ macOS 26
- 工具版本:Apple clang 21.0.0(clang-2100.1.1.101),编译参数
-O2 -std=c++17 -Wall,反证构建叠加-fno-elide-constructors - 日期:2026-11-11
- 完整命令:
make同时构建bin/bench与bin/bench-no-elide→bash run.sh输出 RVO/NRVO 计数、N=20 扩容轨迹、N=20 万扩容 7 次计时中位与反证构建对照
代码获取
完整可复现工程(带全局计数器的两个 Widget 类、独立 TU 工厂、双目标 Makefile、run.sh 编排与实测 results.txt)已公开:
- 仓库地址:https://github.com/derekzhuo/geek-doc.cn/tree/main/experiments/cpp-rvo-nrvo-move-noexcept
- 复现方式:克隆后进入该目录执行
bash run.sh,约一分钟输出全部计数与计时;把RiskyWidget的移动构造补上noexcept再跑一次,可以亲手复现拷贝计数归零、耗时降到与安全类持平的过程。
这一篇在知识体系里的位置
本篇属于 C++ 性能实测系列的值语义分组,与前四篇的构建期优化互为补充:LTO 三档实测、PGO 两阶段实测、虚函数去虚化条件讨论编译器怎样优化调用,异常机制成本讨论错误路径的价格,本篇聚焦对象生命周期上的隐式动作。后续同组会展开智能指针与自定义删除器、小对象优化、内存池与分配器成本的对照实测。
总结
在带动作计数器的同构基准上,三条结论各有证据:RVO 是 C++17 强制的保证复制消除,-fno-elide-constructors 下纯右值返回仍保持参数构造 1 次、移动 0 次,而 NRVO 被开关关闭后退化为 1 次移动,边界清晰;vector 扩容通过 move_if_noexcept 守护强异常保证,移动构造缺少 noexcept 时宁可对 262143 个旧元素逐次拷贝;同样 20 万次 push_back,仅异常规格一处差异就带来 27.3 ms 对 103.8 ms、约 3.8 倍的耗时差距。配套纪律是移动函数无条件 noexcept、已知规模先 reserve、按值返回不必恐慌,以及用计数器而不是猜测定位隐式拷贝。