3. 值与对象

在理解 Modern C++ 的各种令人眼花缭乱的特性之前,必须先搞清楚两个基本概念:对象object )和 value )。 这是理解很多特性的基础。

3.1.

简单说, 是一个纯粹的数学抽象概念,比如数字 10 ,或者字符 'a' , 或者布尔值 false ,等等。它们完全不需要依赖于计算机或者内存而存在,就只是一个纯粹的值:不需要存储到内存,当然也就不可修改。注意,这与存储在内存中,但 immutable 完全不是一个语意。

那么 1+2 呢?这是一个表达式,但这个表达式的求值结果也是一个 。因而,这是一个值类别的表达式 。 而数字 10 同样是一个表达式,其求值的结果毫无疑问也是一个 ——它自身。 因而,在这个角度, 1+2 和数字 10 ,从性质上没有任何区别,都是 类别的表达式。

3.2. 对象

对象 是一个在内存中占据了一定空间的有类型的东西。因而,它必然是与计算机内存这个物理上具体存在的设备关联在一起的一个物质。

因而,每一个对象都必然有一个 标识Identifier ),从而你可以知道这个对象在内存中唯一的起始位置。否则,对象是一个与内存关联在一起的物质就无从谈起。

所以 int i 就定义了一个对象,系统必然会在内存中为其分配一段 sizeof(int) 大小的空间,而 i 就是这个对象的标识。

既然对象与内存有关联,并且有自己区别于其它对象的唯一起始内存地址,那么任何对象都必然可以被引用。引用做为一个对象的别名,当然也是对象的一种 标识

所以,区分 对象 的方法非常简单:是否有 标识 ,或可否被 引用 (毕竟引用就是一种标识)。只有做为具体内存物质的对象才可能被引用;而值,做为一种抽象概念, 引用无从谈起。

3.3. 值与对象的关系

那么 对象 之间是什么关系?

很简单, 用来初始化 对象 。比如: bool b = true , 其语意是:用值 true 初始化对象 b ;类似的,int i = 1 + 2 表示用值 1+2 的计算结果值, 初始化对象 i对象 表示内存中的一段有类型的空间, 这则是个空间里的内容。 用 来初始化 对象 的过程, 是一个将值加载到空间的隐喻。

3.4. 纯右值

所有的 语意的表达式,都归类为 纯右值pure right value ,简称 prvalue )。在 C++11 之前,它们被称做 右值

规范对于纯右值的定义如下:

A prvalue is an expression whose evaluation initializes an object or a bit-field, or computes the value of an operand of an operator, as specified by the context in which it appears, or an expression that has type cv void.

其存在的唯一的目的,是为了初始化 对象 。单独写一个 纯右值 表达式的语句,比如: 1+2; ,或者 true && (1 == 2); ,这样的 表达式被称做 弃值表达式 。从语意上,它们仍然会初始化一个临时对象,而临时对象也是泛左值。后面我们会进行解释。

而既然是一个 ,就必须是某种具体类型的值,而不可能是某种 不完备类型 。当然也不可能是一个 抽象类型 (包含有纯虚函数的类)的值,即便其基类是某种抽象类型,但它自身必然是一个具体类型,因而对其任何 virtual 函数的调用,都是对其具体类型所对应的函数实现的调用。

同时,你不可能对一个值进行取地址操作(语意上就不通),也不可能引用它。

3.5. 泛左值

纯右值 对应的是 泛左值glvalue )。整个表达式的世界被分为这两大类别。前者全部是 语意,后者全部是 对象 语意。

规范对于泛左值的定义如下:

A glvalue is an expression whose evaluation determines the identity of an object, bit-field, or function.

从这个定义我们可以看出,泛左值表达式的求值结果是一个对象的标识。

_images/obj-value.png

3.5.1. 左值

左值很容易辨别:任何可以对其通过符号 & 取地址的表达式,都属于 左值 。因而,任何变量(包括常量),无论是全局的,还是类成员的,还是函数参数,还是函数名字,都肯定属于左值。

另外,所有返回值是左值引用的函数调用表达式(包括用户自定义的重载操作符),以及 static_cast<T&>(expr) 都必然也属于左值。毕竟,没有内存中的对象,哪里来的引用?而引用无非是对象的一个别名标识罢了。

剩下的就是系统的一些 builtin 操作符的定义,比如对一个指针求引用操作: *p ,或者 ++ii++ 却是一个 右值 )。

其中,最为特殊的是字符串字面常量,比如: "abcd" ,这是一个左值对象。这有点违背直觉,但由于 C/C++ 中字符串并不是一个 builtin 基本类型。这些字符串字面常量都会在内存中得以存储。

需要注意的是,这两种情况下,无论是变量 i ,还是函数参数 r ,它们都是一个 左值 ,虽然它们的类型是 右值引用 。我们之前谈到过,任何变量,无论其属于什么类型,都必然是一个左值。变量的名字,就是对应对象的标识。

3.5.2. 速亡值

速亡值是所有返回类型为 右值引用 的非左值表达式。 这包括返回值类型为 右值引用 的函数调用表达式,static_cast<T&&>(expr) 表达式。

其所引用的对象,从理论上同样也是可以取其地址的。其目的是为了初始化类型为 右值引用 类型的变量。借此,也可以匹配参数类型为右值引用的函数。 一旦允许取其地址,程序的其它部分将无从判断,一个地址来自于速亡值对象,还是来自于左值对象,从而让速亡值的存在失去了本来的意义。 因而,对其取地址操作被强行禁止。

与右值引用和速亡值有关的详细讨论,请参考: 右值引用

3.5.3. 对象?值?

上面给的那些与值有关的例子,简单而直观,不难理解它们是数学意义上的值。 我们来看一个不那么直观的例子:在 Foo 是一个 class 的情况下, Foo{10} 是一个对象还是一个值?

C++17 之前,这个表达式的语意是一个 临时对象

非常有说服力的例子是: Foo&& foo = Foo{10} 或者 const Foo& foo = Foo{10} 。这这两个初始化表达式里,毫无疑问 Foo{10} 是一个对象,因为它可以被引用,无论是一个右值引用 Foo&& ,还是一个左值引用 const Foo&,能被引用的必然是 对象

但后来人们发现,将其定义为对象语意,在一些场景下会带来不必要的麻烦:

比如: Foo foo = Foo{10} 的语意是:构造一个临时对象,然后 copy/move 给左边的对象 foo

注意,只要 Foo{10} 被定义为 对象 ,那么 copy/move 语意也就变得不可避免,这就要求 class Foo 必须要隐式或显式的提供 public copy/move constructor 。即便编译器肯定会将对 copy/move constructor 的调用给优化掉,但这是到优化阶段的事,而语意检查发生在优化之前。如果 class Foo 没有 public copy/move constructor ,语意检查阶段就会失败。

这就给一些设计带来了麻烦,比如,程序员不希望 class Foo 可以被 copy/move ,所有 Foo 实例的创建都必须通过一个工厂函数,比如: Foo makeFoo() 来创建;并且程序员也知道 copy/move constructor 的调用必然会被任何像样的编译器给优化掉,但就是过不了那该死的对实际运行毫无影响的语意检查那一关。

于是,到了 C++17 ,对于类似于 Foo{10} 表达式的语意进行了重新定义, 它们不再是一个 对象 语意,而只是一个 。即 Foo{10} 与内存临时对象再无任何关系,它就是一个 : 其估值结果,是对构造函数 Foo(int) 进行调用所产生的 。而这个 ,通过等号表达式,赋值给左边的 对象 , 正如 int i = 10 所做的那样。从语意上,不再有对象间的 copy/move , 而是直接将构造函数调用表达式作用于等号左边的 对象 , 从而完成用 初始化 对象 的过程。因而, Foo foo = Foo{10} ,与 Foo foo{10} , 在 C++17 之后,从语意上(而不是编译器优化上)完全等价。

一旦将其当作 语意,很多表达式的理解上也不再一样。 比如: Foo foo = Foo{Foo{Foo{10}}} ,如果 Foo foo = Foo{10}Foo foo{10} 完全等价,那么就可以进行下列等价转换:

    Foo foo = Foo{Foo{Foo{10}}}
<=> Foo foo{Foo{Foo{10}}
<=> Foo foo = Foo{Foo{10}}
<=> Foo Foo{Foo{10}}
<=> Foo foo = Foo{10}
<=> Foo foo{10}

注意,这是一个自然的语意推论,而不是编译器优化的结果。

自然,对于 Foo makeFoo() 这样的函数,其调用表达式 makeFoo() ,在 C++17 下也是 。 而不像之前定义的那样:返回一个临时对象,然后在 Foo foo = makeFoo() 表示式里, copy/move 给等号左侧的对象 Foo 。 虽然 C/C++ 编译器很早就有 RVO/NRVO 优化技术;但同样,那是优化阶段的事,而不是语意分析阶段如何理解这个表达式语意的问题。

3.5.4. 纯右值物质化

我们再回到前面的问题: Foo&& foo = Foo{10} 表达了什么语意?毕竟,按照我们之前的讨论,等号右边是一个 , 而左边是一个对于对象的 引用 。而 引用 只能引用一个对象,引用一个 是逻辑上是讲不通的。

这中间隐含着一个过程: 纯右值物质化 。即将一个 纯右值 , 赋值给一个 临时对象 ,其标识是一个无名字的 右值引用 ,即 速亡值 。然后再将等号左边的 引用 绑定到这个 速亡值 对象上。

纯右值物质化 的过程还发生在其它场景。

比如, Foo{10} 是一个 纯右值 , 但如果我们试图访问其非静态成员,比如: Foo{10}.m ,此时就必需要将这个纯右值物质化, 转化成 速亡值 。毕竟,对于任何非静态成员的访问,都需要对象的 地址 ,与成员变量所代表的 偏移 两部分配合。 没有对象的存在,仅靠偏移量访问其成员,根本不可能。

还有数组的订阅场景。比如:

using Array = char [10];

Array{};    // 纯右值
Array{}[0]; // 速亡值

另外, static_cast<T>(expr) 是一个 直接初始化 表达式, 即,中间存在一个隐含的 T 类型的未命名临时变量,通过 expr 进行初始化。如果 expr 是一个 纯右值 , 而 T 是一个 右值引用 类型,则这个过程也是一个纯右值 物质化 的过程。

而之前提到的 弃值表达式 ,也会有一个 纯右值物质化 的过程。这样的表达式的存在主要是为了利用其副作用。 如果编译器发现其并不存在副作用,往往会将其优化掉。但这是优化阶段的职责。在语意分析阶段,统统是 纯右值物质化 语意。

C++17 之前的规范定义中,将 纯右值速亡值 合在一起,称为 右值 。 代表它们可以被一个 右值引用类型的变量 绑定(即初始化一个右值引用类型的变量)。 因而,在进行重载匹配时, 右值 会优先匹配 右值引用类型的参数 。比如:

void func(Foo&&);       // #1
void func(const Foo&);  // #2

Foo&& f();


func(Foo{10}); // #1
func(f());     // #1

Foo foo{10};
func(foo);     // #2

Foo&& foo1 = Foo{10};
func(foo1);    // #2

到了 C++17 ,从匹配行为上没有变化,但语意上却有了变化。 最终导致匹配右值引用版本的不是 纯右值 类别,而是 速亡值 。 因为 纯右值 会首先进行 物质化 ,得到一个 速亡值 。最终是用 速亡值 初始化了函数的对应参数。

一个 纯右值 ,永远也无法匹配到 move 构造函数。 因为 Foo foo = Foo{10}Foo foo{10} 等价。 这不需要将 纯右值 进行 物质化 ,得到一个 速亡值 ,然后匹配到 move 构造函数的过程。

只有 速亡值 ,才能匹配到 move 构造。比如: Foo foo = std::move(Foo{10}) 将会导致 move 构造的调用。

另外,一个表达式是 速亡值 ,并不代表其所引用的对象一定是一个从 纯右值 物质化 得到的临时对象。 而是两种可能都存在。比如,如果 foo 是一个 左值std::move(foo) 这个 速亡值 所引用的对象就是一个 左值 ; 而 std::move(Foo{10}) 则毫无疑问引用的是一个 物质化 后的到的临时对象。

_images/obj-value-2.png

注意

  • 所有的表达式都可以归类为 纯右值泛左值

  • 所有的 纯右值 都是 的概念;所有的 泛左值 都是 对象 的概念;

  • 左值 可以求地址,速亡值 不可以求地址;

  • 纯右值 在需要临时对象存在的场景下,会通过 物质化 ,转化成 速亡值

  • 泛左值 可以是抽象类型和不完备类型,可以进行多态调用;纯右值 只能是具体类型,无法进行多态调用。

  • 纯右值 构造一个 左值 对象时,是 直接构造 语意; 用 速亡值 构造一个 左值 对象时,是 拷贝/移动构造 语意。