C++17 详解 6
本文为 《C++17 in detail》 一书的中文渣中渣译文,不足之处还望指正。
2.2 有保证的复制消除
复制消除是一种流行的优化手段,它可以避免创建不必要的临时对象。
比如:
1 | // Chapter Clarification/copy_elision.cpp |
上边的调用中,为了存储 Create
的返回值,你可能会假设用到了一次临时的复制。C++14 里,大多数编译器都能注意到这个临时对象很容易被优化掉,n
可以从 Create()
中“直接”创建。你很可能会得到如下输出:
1 | Test::Test // 创建 n |
这种复制消除的优化行为,依照其基本形式,被叫做返回值优化(Return Value Optimisation——RVO)。
作为实验,你可以在 GCC 里添加编译选项 -fno-elide-constructors
和 -std=c++14
(或者其它更早的语言标准)。这时你会看到不同的输出结果:
1 | // compiled as "g++ CopyElision.cpp -std=c++14 -fno-elide-constructors" |
这种情况下,编译器用了 2 次额外的复制把返回值传递到 n
。
编译器甚至能更聪明,它可以在你返回一个命名对象的情况下进行消除——即所谓的命名返回值优化(Named Return Value Optimisation——NRVO):
1 | Test Create() |
当前(译注:指 C++17 之前),标准允许以下情况下的消除:
- 临时对象被用于初始化另一个对象(包括函数返回的对象、通过
throw
表达式创建的异常对象)时 - 当一个即将超出范围的变量被返回或抛出时
- 异常被按值捕获时
但是,消除与否取决于编译器实现。现实是所有的构造函数(译注:即构造、拷贝构造、移动构造)都是必要的。有时候消除只会在 release 构建(优化过的)时发生,debug 构建(没有任何优化选项)不会消除任何东西。
C++17 里,对何时应进行消除有了清楚的规则,甚至构造函数也可能被完全省略。
这样有什么作用呢?
- 允许返回不可移动/不可复制的对象——因为现在可以跳过拷贝/移动构造
- 提升代码可移植性——因为每一个合规的编译器都支持同样的规则
- 支持按值返回样式而不是使用输出参数
- 提升性能
下边是一个不可移动/不可复制的类型的例子:
1 | // Chapter Clarification/copy_elision_non_moveable.cpp |
C++14 里上边的代码会编译失败,因为它缺少拷贝、移动构造函数。但是在 C++17 里这些构造函数不再是必要的了——因为对象 largeNonMoveableObj
会被原地构造。
注意,你可以在函数内使用多个返回语句,复制消除一样可以生效。
此外,重要的是要记住,C++17 的复制消除只对临时对象起作用,对 NRVO 无效。
这种强制性的复制消除在标准里是怎么定义的呢?此功能基于值类别(Value Categories),请继续阅读下一节内容以理解其工作原理。
2.2.1 更新后的值类别
C++98/03 里只有两种基本的表达式类别:
- lvalue
- rvalue
译注:即左值和右值。
C++11 起这种分类被扩展了(因为有了移动语义),现在有五种类别:
- lvalue
- glvalue(泛左值)
- xvalue(亡值、将亡值)
- rvalue
- prvalue(纯右值)
这里有个图表,可以更好地一览所有分类:
请记住,我们有三种核心类别(下边是口语化的“定义”):
- lvalue ——有标识符的表达式,且可以取地址
- xvalue ——“即将过期(eXpiring)的 lvalue”——可以移动、可以重复使用的对象,通常其生命周期马上结束
- prvalue ——(pure rvalue)——没有名字、不能被取地址、可以移动的表达式
译注:C++17 中 prvalue 已经是不可移动了。
为了支持标准化的复制消除,提案作者建议简化 glvalue 和 prvalue 的定义:
- glvalue ——泛化的 lvalue —— glvalue 是其求值确定一个对象、位域或函数的位置的表达式
译注:原文:“glvalue - “generalised” lvalue - A glvalue is an expression whose evaluation computes the
location of an object, bit-field, or function” - prvalue ——纯 rvalue —— prvalue 是其求值初始化某个对象或位域,或计算某个运算符的操作数的值(依它所出现的上下文而定。译注:即虽然看起来同样的表达式,其类别也不同,比如
a[n]
、a.m
)的表达式译注:原文:“prvalue - “pure” rvalue - A prvalue is an expression whose evaluation initialises an object,
bit-field, or operand of an operator, as specified by the context in which it appears”
比如:
1 | class X { int a; }; |
简言之:prvalue 执行初始化,glvalue 描述位置。
C++17 规定,当你从某个类或数组的 prvalue 对象进行初始化时,不需要创建临时对象。没有任何移动或拷贝牵涉其中(所以也就不需要必须有拷贝或移动构造函数了);编译器可以安全地进行消除。
它会发生在如下情况:
- 从一个 prvalue 类别的对象初始化:
Type t = T()
- 一个返回 prvalue 的函数的调用时——跟上边几个例子一样。
有几个例外情况,临时对象仍然是必需的:
- prvalue 被绑定到某个引用
- 在一个 prvalue 类别的类对象上执行成员访问
- 在一个 prvalue 类别的数组上执行下标操作
- 一个 prvalue 类别的数组被退化到指针
- 在一个 prvalue 类别的类对象上进行子类到基类的类型转换
- prvalue 被用作舍弃的值表达式
译注:
翻译这节要了老命了!
又一标准委员会过于学院气的铁证。为了支撑新的语法、新的特性,常常无端构造出一些形而上学的概念,从最初的左值、右值的推出即有此意。
到 C++11,已经彻底不说人话了,用词晦涩难懂,又无法通过 traits 验证结果(仅有
is_lvalue_reference
、is_rvalue_reference
几个相关的 traits)。C++17 更是变本加厉。可以看上述两个提案,简单地说,C++17 通过对五种类别的定义措辞做了所谓的“微调”(tweak),支撑了 Copy Elision 的标准化。这不像是语言设计,更像是某种不可言传、神乎其神的内功心法。
徒耗标准委员会的时间外,这种毫无表征的东西的存在,势必会导致语言理解的复杂度。我们常说要少写注释,让代码清晰到可以自我解释。语言的设计更应该如此,任何复杂特性的用法,都应该通过若干简单、清晰、可具体实施的语法组合实现,绝非通过这种架空的、哲学态的、虚无的定义实现。
回到本节内容本身。值类别前的一小节应该算是易懂的,不做赘述。值类别的更详细、更准确的表述和翻译,可以直接参考这里 和这里 。请恕我才疏学浅。