C++17 详解 6

本文为 《C++17 in detail》 一书的中文渣中渣译文,不足之处还望指正。

2.2 有保证的复制消除

复制消除是一种流行的优化手段,它可以避免创建不必要的临时对象。

比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Chapter Clarification/copy_elision.cpp
#include <iostream>

struct Test
{
Test() { std::cout << "Test::Test\n"; }
Test(const Test&) { std::cout << "Test(const Test&)\n"; }
Test(Test&&) { std::cout << "Test(Test&&)\n"; }
~Test() { std::cout << "~Test\n"; }
};

Test Create()
{
return Test();
}

int main()
{
auto n = Create();
}

上边的调用中,为了存储 Create 的返回值,你可能会假设用到了一次临时的复制。C++14 里,大多数编译器都能注意到这个临时对象很容易被优化掉,n 可以从 Create() 中“直接”创建。你很可能会得到如下输出:

1
2
Test::Test // 创建 n
~Test // main 结束时销毁 n

这种复制消除的优化行为,依照其基本形式,被叫做返回值优化(Return Value Optimisation——RVO)。

作为实验,你可以在 GCC 里添加编译选项 -fno-elide-constructors-std=c++14(或者其它更早的语言标准)。这时你会看到不同的输出结果:

1
2
3
4
5
6
7
// compiled as "g++ CopyElision.cpp -std=c++14 -fno-elide-constructors"
Test::Test
Test(Test&&)
~Test
Test(Test&&)
~Test
~Test

这种情况下,编译器用了 2 次额外的复制把返回值传递到 n

编译器甚至能更聪明,它可以在你返回一个命名对象的情况下进行消除——即所谓的命名返回值优化(Named Return Value Optimisation——NRVO):

1
2
3
4
5
6
7
8
Test Create()
{
Test t;
// 这里对 t 进行初始化
return t;
}

auto n = Create(); // 临时对象通常能被消除(译注:t 怎么就变成临时对象了呢?)

当前(译注:指 C++17 之前),标准允许以下情况下的消除:

  • 临时对象被用于初始化另一个对象(包括函数返回的对象、通过 throw 表达式创建的异常对象)时
  • 当一个即将超出范围的变量被返回或抛出时
  • 异常被按值捕获时

但是,消除与否取决于编译器实现。现实是所有的构造函数(译注:即构造、拷贝构造、移动构造)都是必要的。有时候消除只会在 release 构建(优化过的)时发生,debug 构建(没有任何优化选项)不会消除任何东西。

C++17 里,对何时应进行消除有了清楚的规则,甚至构造函数也可能被完全省略。

这样有什么作用呢?

  • 允许返回不可移动/不可复制的对象——因为现在可以跳过拷贝/移动构造
  • 提升代码可移植性——因为每一个合规的编译器都支持同样的规则
  • 支持按值返回样式而不是使用输出参数
  • 提升性能

下边是一个不可移动/不可复制的类型的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Chapter Clarification/copy_elision_non_moveable.cpp
#include <array>

// based on P0135R0
struct NonMoveable
{
NonMoveable(int x) : v(x) { }
NonMoveable(const NonMoveable&) = delete;
NonMoveable(NonMoveable&&) = delete;
std::array<int, 1024> arr;
int v;
};

NonMoveable make(int val)
{
if (val > 0)
return NonMoveable(val);

return NonMoveable(-val);
}

int main()
{
auto largeNonMoveableObj = make(90); // construct the object
return largeNonMoveableObj.v;
}

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
2
3
4
class X { int a; };
X{10} // 表达式为 prvalue
X x; // x 是 lvalue
x.a // 表达式为 lvalue (location)

简言之:prvalue 执行初始化,glvalue 描述位置。

C++17 规定,当你从某个类或数组的 prvalue 对象进行初始化时,不需要创建临时对象。没有任何移动或拷贝牵涉其中(所以也就不需要必须有拷贝或移动构造函数了);编译器可以安全地进行消除。

它会发生在如下情况:

  • 从一个 prvalue 类别的对象初始化:Type t = T()
  • 一个返回 prvalue 的函数的调用时——跟上边几个例子一样。

有几个例外情况,临时对象仍然是必需的:

  • prvalue 被绑定到某个引用
  • 在一个 prvalue 类别的类对象上执行成员访问
  • 在一个 prvalue 类别的数组上执行下标操作
  • 一个 prvalue 类别的数组被退化到指针
  • 在一个 prvalue 类别的类对象上进行子类到基类的类型转换
  • prvalue 被用作舍弃的值表达式

扩展:本修改提案:P0135R0 (论证)和 P0135R1(措辞)。

译注:

翻译这节要了老命了!

又一标准委员会过于学院气的铁证。为了支撑新的语法、新的特性,常常无端构造出一些形而上学的概念,从最初的左值、右值的推出即有此意。

到 C++11,已经彻底不说人话了,用词晦涩难懂,又无法通过 traits 验证结果(仅有 is_lvalue_referenceis_rvalue_reference 几个相关的 traits)。

C++17 更是变本加厉。可以看上述两个提案,简单地说,C++17 通过对五种类别的定义措辞做了所谓的“微调”(tweak),支撑了 Copy Elision 的标准化。这不像是语言设计,更像是某种不可言传、神乎其神的内功心法。

徒耗标准委员会的时间外,这种毫无表征的东西的存在,势必会导致语言理解的复杂度。我们常说要少写注释,让代码清晰到可以自我解释。语言的设计更应该如此,任何复杂特性的用法,都应该通过若干简单、清晰、可具体实施的语法组合实现,绝非通过这种架空的、哲学态的、虚无的定义实现。

回到本节内容本身。值类别前的一小节应该算是易懂的,不做赘述。值类别的更详细、更准确的表述和翻译,可以直接参考这里这里 。请恕我才疏学浅。

评论