↓ 跳过正文
  1. Posts/

从 RVO 到 Guaranteed Copy Elision:C++ 返回值优化的演进

·4193 字·9 分钟
77 - 这篇文章属于一个选集。

一道我答错的题
#

最近做了一个 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++98/03 → C++11 → C++17 返回值优化演进

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。

C++11/14 vs C++17 对象构造路径对比

需要说明的是,这个保证成立有条件。最基本的要求是 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 wlvalueNRVO(可选)+ 隐式移动 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}
编译选项createAcreateBcreateC
-std=c++14 -O0Default ctor → DtorDefault ctor → DtorDefault ctor → Move ctor → Dtor → Dtor
-std=c++17 -O0Default ctor → DtorDefault ctor → DtorDefault ctor → Move ctor → Dtor → Dtor
-std=c++17 -O0 -fno-elide-constructorsDefault ctor → DtorDefault ctor → Move ctor → Dtor → DtorDefault 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 方式的机制对比

几条真正有用的经验:

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 省略它。“标准不保证"约束的是程序的合法性,不是运行时的实际行为。

参考资料
#

77 - 这篇文章属于一个选集。

相关文章