一道我答错的题#
最近做了一个 Modern C++ 水平测试,碰到这样一道题:
1#include <utility>
2
3struct Widget {
4 Widget() = default;
5 Widget(const Widget&) = delete;
6 Widget(Widget&&) = delete;
7};
8
9Widget createA() { return Widget{}; }
10
11Widget createB() {
12 Widget w;
13 return w;
14}
15
16Widget createC() {
17 Widget w;
18 return std::move(w);
19}问:在 C++17 下,哪些函数能通过编译?
我当时的判断是:createA 能编译(RVO),createB 也能编译(NRVO),createC 不能编译(std::move 阻止了 RVO,而 Move Constructor 被删除)。
实际结果:只有 createA 合法。createB 和 createC 都编译失败。
createC 失败容易理解。但 createB 呢?编译器明明可以做 NRVO,为什么有 NRVO 也救不了它?
回头想,我有两个误区。
误区一:以为 C++17 Guaranteed Copy Elision 就是"编译器必须做 NRVO"。 实际上这两件事在语言标准里的机制完全不同。
误区二:以为编译器能够省略 Move Constructor 的调用,就意味着程序不需要有可用的 Move Constructor。 实际上 NRVO 是优化,不是豁免——标准仍然要求存在一条合法的构造路径。
这篇把从 C++98 到 C++17 的返回值优化演进理一遍,搞清楚这两个误区背后的东西。
C++98/03:Copy Elision 的起点#
按值返回一个对象,在概念上可能涉及多次拷贝。以这段代码为例:
1std::vector<int> createVector() {
2 std::vector<int> v(1000000);
3 return v;
4}
5
6auto result = createVector();如果严格按照 C++98 的抽象语义模型,v 先在函数内部构造,return v 时拷贝到函数的返回值对象,然后返回值对象再拷贝给 result。对于一个包含一百万个元素的 vector,两次深拷贝的代价是显而易见的。
但实际上,几乎所有主流编译器在 C++98 时代就已经能省略这些拷贝——这就是 Copy Elision。
术语上需要区分几个概念:
- Copy Elision 是标准允许的一类优化的统称,指编译器可以省略某些 Copy(或 Move)Constructor 的调用。
- RVO(Return Value Optimization) 在历史上有时泛指所有返回值相关的 Copy Elision,有时特指返回临时对象(unnamed)的情况。
- NRVO(Named Return Value Optimization) 特指返回具名局部变量的情况,如上面的
return v。
关键点:Copy Elision 从 C++98 就存在,不是 C++11 才引入的。 但在 C++17 之前,它始终是编译器可以做但不是必须做的优化。标准要求即使编译器执行了 Copy Elision,程序仍然必须有一个合法的 Copy Constructor(C++11 之后是 Copy 或 Move Constructor)。
C++11:Move Semantics 带来了什么#
C++11 引入 Move Semantics 后,Copy Elision 的 fallback 路径变了。
之前写过 move semantics 的笔记:std::move 本身不移动任何东西,只是把参数转换成 rvalue reference;真正的资源转移发生在 Move Constructor 里。这里不重复这些基础内容。
C++11 对返回值的关键改进是:当编译器无法执行 NRVO 时,对于满足条件的局部对象,return w 会自动尝试隐式移动(implicit move)——编译器会优先尝试把 w 当 rvalue 来做重载决议,选择 Move Constructor。
1Widget f() {
2 Widget w;
3 return w; // 编译器先尝试 NRVO;不行则隐式移动;再不行才拷贝
4}这里有两个容易混淆的机制需要分清:
NRVO 和隐式移动是两件不同的事。 NRVO 是直接把局部变量构造在调用方的目标位置上,连 Move Constructor 都不调用。隐式移动是在 NRVO 不可行时,把 return w 视为 return std::move(w) 来做重载决议——它仍然会调用 Move Constructor,只是编译器自动帮你做了 std::move。
另一个值得注意的点:函数参数不能做 NRVO,但符合条件时可以隐式移动。
1Widget f(Widget w) {
2 return w; // 不能 NRVO,但可以隐式移动
3}原因和 ABI 有关——函数参数的存储位置通常由调用约定决定,编译器很难把它直接"安排"在返回值的位置上。
C++17 对 prvalue 做了什么#
这是全文核心。
先回到开头的例子:
1struct Widget {
2 Widget() = default;
3 Widget(const Widget&) = delete;
4 Widget(Widget&&) = delete;
5};
6
7Widget f() { return Widget{}; }在 C++14 下,这段代码编译失败。在 C++17 下,合法。
我最初以为 C++17 只是规定"编译器必须执行 RVO"。但如果是这样,逻辑上应该是:标准仍然要求存在 Copy/Move Constructor,只是编译器保证不调用它。而实际情况是——连 Copy/Move Constructor 的存在都不需要了。
这迫使我重新想:C++17 到底改了什么?
答案是:C++17 改变了 prvalue 的语义。
在 C++11/14 的概念模型里,Widget{} 会先创建一个临时对象,然后这个临时对象通过 Copy 或 Move 初始化返回值。Copy Elision 是编译器"偷懒"省掉了这次 Copy/Move——但从语言规则上说,这次 Copy/Move 是"存在"的,只是被优化掉了。所以即使实际不调用,程序也必须有一个可用的 Copy 或 Move Constructor。
C++17 换了一个思路。它规定:prvalue 不再是"先创建临时对象再拷贝/移动",而是"用于初始化某个目标对象的一个待执行的初始化操作"。 prvalue 携带的是初始化信息,只有在需要绑定到 reference 或实际需要一个对象存在时(materialization),才会真正创建对象。
回到 return Widget{}:Widget{} 是一个 prvalue。在 C++17 下,这个 prvalue 直接初始化函数的返回值对象——中间没有临时对象,也就没有 Copy/Move 的概念。标准里有时把这种机制叫做 unmaterialized value passing 或 deferred temporary materialization。
需要说明的是,这个保证成立有条件。最基本的要求是 return 语句的表达式类型和函数返回类型相同(不考虑 cv 限定),且返回类型的析构函数可访问且未被删除。它不是对所有返回值场景都成立的万能规则。
为什么 NRVO 不是 Guaranteed#
现在可以回头解释 createB 的问题了:
1Widget createB() {
2 Widget w;
3 return w;
4}w 是一个具名局部变量,是 lvalue,不是 prvalue(关于值类别的区分,之前在值类别的笔记里写过)。
NRVO 允许编译器把 w 直接构造在返回值的位置上,从而省略 Move/Copy。但 C++17 没有规定编译器必须做 NRVO——NRVO 仍然是可选的 Copy Elision。
既然不保证,标准就要求必须存在一条"不做 NRVO 也能走通"的合法构造路径。对于 return w,如果 NRVO 不发生,编译器会尝试隐式移动——但 Move Constructor 被删除了。接着 fallback 到 Copy Constructor——也被删除了。没有合法路径,编译失败。
对比一下两种写法在 C++17 下的区别:
| 写法 | 表达式类别 | 机制 | 需要 Copy/Move Constructor? |
|---|---|---|---|
return Widget{} | prvalue | 直接初始化(Guaranteed) | 不需要 |
return w | lvalue | NRVO(可选)+ 隐式移动 fallback | 需要 |
这两种写法在 C++17 中并不等价。 return Widget{} 合法是因为语言语义上就不存在中间构造,而 return w 需要 NRVO 这个优化来避免移动——但优化不是保证。
再看函数参数的情况:
1Widget f(Widget w) {
2 return w; // 不能 NRVO,但可以隐式移动
3}这里 w 是函数参数,不满足 NRVO 的条件,但如果 Widget 有可用的 Move Constructor,编译器可以隐式移动。如果没有可用的 Move/Copy Constructor,同样编译失败。
Copy Elision 与 as-if rule#
这里有一个我之前没注意到的细节。
C++ 有一个通用原则叫 as-if rule:编译器可以任意变换程序的实现,只要可观察行为不变。但 Copy Elision 是这个规则的一个例外。
考虑一个带日志的 Copy Constructor:
1Widget(const Widget& w) {
2 std::cout << "Copy\n";
3}如果编译器执行了 Copy Elision,"Copy" 不会被打印——这改变了可观察行为。按 as-if rule,这本来是不允许的。但标准明确给了 Copy Elision 一个特许:即使省略 Copy/Move Constructor 会改变程序的可观察行为,只要满足标准规定的条件,仍然允许省略。
而 C++17 的 Guaranteed Copy Elision 更进一步。对于 return Widget{} 这种情况,语言语义上就没有 Copy/Move 发生——不是"允许省略",而是"语义上本来就不需要"。这根本不是 as-if rule 的问题了。
传统 Copy Elision:允许省略一次本来可能执行的 Copy/Move。
C++17 prvalue 直接构造:语言定义上就不存在需要省略的 Copy/Move。
编译实验#
以下结果使用 g++ 16.2.1 和 clang++ 22.1.8 实际验证,两者行为一致。
带日志版本(Copy/Move Constructor 可用)#
完整测试代码
1#include <iostream>
2
3struct Widget {
4 int id;
5 Widget() : id(0) { std::cout << "Default ctor\n"; }
6 Widget(const Widget& w) : id(w.id) { std::cout << "Copy ctor\n"; }
7 Widget(Widget&& w) noexcept : id(w.id) { std::cout << "Move ctor\n"; }
8 ~Widget() { std::cout << "Dtor\n"; }
9};
10
11Widget createA() { return Widget{}; }
12Widget createB() { Widget w; return w; }
13Widget createC() { Widget w; return std::move(w); }
14
15int main() {
16 std::cout << "=== createA ===\n";
17 { auto a = createA(); }
18 std::cout << "\n=== createB ===\n";
19 { auto b = createB(); }
20 std::cout << "\n=== createC ===\n";
21 { auto c = createC(); }
22}| 编译选项 | createA | createB | createC |
|---|---|---|---|
-std=c++14 -O0 | Default ctor → Dtor | Default ctor → Dtor | Default ctor → Move ctor → Dtor → Dtor |
-std=c++17 -O0 | Default ctor → Dtor | Default ctor → Dtor | Default ctor → Move ctor → Dtor → Dtor |
-std=c++17 -O0 -fno-elide-constructors | Default ctor → Dtor | Default ctor → Move ctor → Dtor → Dtor | Default ctor → Move ctor → Dtor → Dtor |
几个观察:
-O0 并不等于关闭 Copy Elision。在 C++14 和 C++17 下,createA 和 createB 默认都只输出一次 Default ctor——编译器即使在 -O0 下仍然执行了 RVO 和 NRVO。
-fno-elide-constructors 可以关闭传统的 Copy Elision。加了这个 flag 后,createB(NRVO)被禁止了,出现了 Move ctor。但 createA 仍然只有 Default ctor——因为 C++17 的 Guaranteed Copy Elision 不是编译器优化,而是语言语义,-fno-elide-constructors 关不掉它。
createC 在所有情况下都产生 Move ctor 调用。std::move(w) 使 w 变成 xvalue,阻止了 NRVO,编译器无法省略这次移动。
删除版本(Copy/Move Constructor = delete)#
| 函数 | -std=c++14 | -std=c++17 |
|---|---|---|
createA(return Widget{}) | 编译失败 | 编译通过 |
createB(return w) | 编译失败 | 编译失败 |
createC(return std::move(w)) | 编译失败 | 编译失败 |
C++14 下三个都失败,因为即使编译器能做 RVO/NRVO,标准要求必须有可用的 Copy 或 Move Constructor。
C++17 下只有 createA 通过,因为 Widget{} 是 prvalue,直接初始化目标对象,不需要 Copy/Move Constructor。createB 和 createC 仍然失败——w 是 lvalue,标准不保证省略。
工程实践#
几条真正有用的经验:
return Widget{}(返回临时对象)在 C++17 的相应条件下保证直接构造,是最安全的返回方式。
return w(返回具名局部变量)通常是返回局部对象的最佳写法。编译器几乎总会做 NRVO,即使不做也会隐式移动。不要因为 NRVO 不是 guaranteed 就刻意改成 return Widget{}——大多数场景下局部变量需要在函数体内使用,直接 return w 即可。
return std::move(w) 在 w 是满足 NRVO 条件的局部变量时,通常是多余的甚至有害的——它会阻止 NRVO,迫使编译器执行一次 Move。之前在 move semantics 笔记里写过"无论如何都不要把 std::move 用在函数返回值上",这个结论在满足 NRVO 条件的典型场景下是对的。但这个说法有些绝对——如果返回的对象不满足 NRVO 条件(比如函数参数、或者不同分支返回不同的局部变量),显式 std::move 可能是合理的。
最后,语言合法性和最终机器代码的效率是两个不同的问题。 createB 在 C++17 下因为没有 Move Constructor 而编译失败,但如果 Move Constructor 存在,编译器几乎总会通过 NRVO 省略它。“标准不保证"约束的是程序的合法性,不是运行时的实际行为。