C++ 值类别探究

C++ 在演进过程中逐渐增强和拓展了对类型的处理能力。

由于表达式产生的中间结果会产生导致多余的拷贝,因而在 C++11 中引入了移动语义来解决这个问题,同时对值类别的左值、右值进行重新定义。需要注意的是,值类别指的是表达式结果的类别,并不是值对象、变量或者类型的类别。

对值类别的准确理解,是掌握移动语义的关键,能够帮我我们写出更优雅高效的代码,那么今天让我们一起来探究一下值类别。

1. 引用

引用是 C 语言所没有的概念。而这个概念,比它表面看起来要复杂一些。

1.1 值与对象

为了理解引用,我们需要首先搞清楚什么叫左值右值

简而言之,左值是一种对象,而不是。其与右值的关键区别在于,是否明确在内存中有其可访问的位置。即,其是否存在一个可访问的地址。如果有,那么它就是一个对象,也就是一个左值 ,否则,它就只是一个,即右值

比如:你不可能对整数 10 取地址,因而这个表达式是一个右值。但是如果你定义了一个变量:

int a = 10;

变量 a 则代表一个对象,即左值。如果我们再进一步,表达式 a + 1 则是一个右值表达式,因为你无法对这个表达式取地址。

任何可以取地址的表达式,背后都必然存在一个对象,因而也必然属于左值。而如果我们把对象地址看作其身份证(Identifier), 那么我们也可以换句话说:任何有身份证的表达式,都属于左值;否则,肯定属于右值

1.2 别名

引用对象别名

所谓别名,是指你没有创建任何新事物,而只是对已存在事物赋予了另外一个名字。比如:

using Int = int;

你并没有创建一个叫做 Int 的新类型,而只是对已存在类型 int 赋予了另外一个名字。再比如:

1
2
template <typename T>
using ArrayType = Array<T, 10>;

你并没有创建一个签名为 ArrayType<T> 的新模板,而只是对已存在模板 Array<T, N> 进行部分实例化后得到的模板,赋予了一个新名字。

因而,引用作为对象别名,并没有创建任何新对象(包括引用自身),而仅仅是给已存在对象赋予了一个新名字。

1.3 空间大小

正是因为其别名的含义,C++ 没有规定引用的尺寸(事实上,从别名语义的角度,它本身不需要内存,因而也就没有尺寸而言)。

因而,如果你试图通过 sizeof 去获取一个引用的大小,是不可能的。你只能得到它所引用的对象的大小(由于别名语义)。

1
2
3
4
5
6
7
8
9
struct Foo {
std::size_t a;
std::size_t b;
};

Foo foo;
Foo& ref = foo;

static_assert(sizeof(ref) == sizeof(Foo));

也正是由于其别名语义,当你试图对一个引用取地址时,你得到的是对象的地址。比如,在上面的例子中,&ref&foo 得到的结果是一样的。

因而,当你定义一个指针时,指针自身就是一个对象(左值);它本身有自己明确的存储,并可以取自己的地址,可以通过 sizeof 获取自己的尺寸。

但是引用,本身不是一个像指针那样的额外对象,而是一个对象的别名,你对引用进行的任何操作,都是其所绑定对象的操作

在上面的例子中,reffoo 没有任何差别,都是对象的一个名字而已。它们本身都代表一个对象,都是一个左值表达式。

因而,在不必要时,编译器完全不需要为引用分配任何内存。

但是,当你需要在一个数据结构中保存一个引用,或者需要传递一个引用时,你事实上是在存储或传递对象的身份(即地址)。

虽然这并不意味着 sizeof(T&) 就是引用的大小(从语义上,引用自身非对象,因而无大小,sizeof(T&) == sizeof(T)),但对象的地址的确需要对应的空间来存储。

1
2
3
4
5
6
7
8
9
10
11
12
struct Bar {
Foo& foo;
};

// still, reference keeps its semantics.
static_assert(sizeof(Bar::foo) == sizeof(Foo));

// but its storage size is identical to a pointer
static_assert(sizeof(Bar) == sizeof(void*));

// interesting!!!
static_assert(sizeof(Bar) < sizeof(Bar::foo));

1.4 受限的指针

在传递或需要存储时,一个引用的事实空间开销与指针无异。因而,在这些场景下,它经常被看作一个受限的指针:

  1. 一个引用必须初始化。这是因为其对象别名语义,因而没有绑定到任何对象的引用,从语义上就不成立。

  2. 由于必须通过初始化将引用绑定到某一个对象,因而从语义上,不存在空引用的概念。这样的语义,对于我们的接口设计,有着很好的帮助: 如果一个参数,从约束上就不可能是空,那么就不要使用指针,而使用引用。这不仅可以让被调用方避免不必要的空指针判断;更重要的是准确的约束表达。

    不过,需要特别注意的是:虽然空引用从概念上是不存在的,但从事实上是可构造的。比如:T& ref = *(T*)nullptr

    因而,在项目中,任何时候,需要从指针转为引用时,都需要确保指针的非空性。

    另外,空引用本身这个概念就是不符合语义的,因为引用只是一个对象的别名。上面的表达式,事实上站在对象角度同样可以构造:T obj = *(T*)nullptr 。正如我们将指针所指向的对象赋值(或者初始化)给另一个对象一样,我们都必须确保指针的非空性。

  3. 像所有的左值一样,引用可以绑定到一个抽象类型,或者不完备类型(而右值是不可能的)。从这一点上,指针和引用具有相同的性质。因而,在传递参数时,决定使用指针,还是引用,仅仅受是否允许为空的设计约束。

  4. 一个引用不可能从一个对象,绑定到另外一个对象。原因很简单,依然由于其对象别名语义。它本身就代表它所绑定的对象,重新绑定另外一个对象,从概念上不通。

而引用的不可更换性,导致任何存在引用类型非静态成员的对象,都不可能直接实现拷贝/移动赋值函数。因而,标准库中,需要存储数据的,比如容器tuplepairoptional 等等结构,都不允许存储引用

这就会导致,当一个对象需要选择是通过指针还是引用来作为数据成员时,除了非空性之外,相对于参数传递,还多了一个约束:可修改性。而这两个约束并不必然是一致的,甚至可以是冲突的。

比如,一个类的设计约束是,它必须引用另外一个对象(非空性),但是随后可以修改为引用另外一个对象。这种情况下, 使用指针就是唯一的选择。但代价是,必须通过其它手段来保证非空性约束。

1.5 左值

任何一个引用类型的变量,都必然是其所绑定对象别名,因而都必然是左值。无论这个引用类型是左值引用,还是右值引用。关于这个话题,我们会在后续章节继续讨论。

重要

  1. 引用是对象的别名,对于引用的一切操作都是对对象的操作;
  2. 引用自身从概念上没有大小(或者就是对象的大小);但引用在传递或需要存储时,其传递或存储的大小为地址的大小。
  3. 引用必须初始化;
  4. 引用不可能重新绑定;
  5. 将指针所指向的对象绑定到一个引用时,需要确保指针非空。
  6. 任何引用类型的变量,都是左值。

2. 右值引用

2.1 缺失的拼图

在 C++11 之前,表达式分类为左值表达式右值表达式,简称左值右值左值都对应着一个明确的对象;从而也都必然可以通过 & 进行取地址操作。而右值表达式虽然肯定都不能进行取地址操作,但在有些场景下,也会隐含着创建一个临时对象的语义。

比如 Foo(10),在 C++98 的年代,其语义是:以 10 来构造一个 Foo 类型的临时对象。而这个表达式属于右值

而引用,从常量性的角度,可以分为:非常引用常引用

因而,常量引用对象类别组合在一起,一共能产生四种类型的引用:

  1. 常左值引用
  2. 非常左值引用
  3. 常右值引用
  4. 非常右值引用

在 C++11 之前,通过符合 &const 的两种组合,可以覆盖三种场景:

  1. Foo&

    • 非常左值引用
      比如:Foo foo(10); Foo& ref = foo;
  2. const Foo&

    • 常左值引用
      比如:Foo foo(10); const Foo& ref = foo;
    • 常右值引用
      比如:const Foo& ref = Foo(10);

但对于非常右值引用无法表达。

好在那时候并没有移动语义的支持,因而对于非常右值引用的需求也并不强烈。

2.2 移动语义

C++11 之前,只有复制语义,这对于极度关注性能的语言而言是一个重大的缺失。那时候程序员为了避免性能损失,只好采取规避的方式。比如:

1
2
std::string str = s1;
s += s2;

这种写法就可以规避不必要的拷贝。而更加直观的写法:

std::string str = s1 + s2;

则必须忍受一个 s1 + s2 所导致的中间临时对象str 的拷贝开销。 即便那个中间临时对象随着表达式的结束,会被销毁(更糟的是,销毁所伴随的资源释放,也是一种性能开销)。

对于移动语义的急迫需求,到了 C++11 终于被引入。其直接的驱动力很简单:在构造或者赋值时,如果等号右侧是一个中间临时对象,应直接将其占用的资源直接移动过来(对方就没有了)。

但问题是,如何让一个构造函数,或者赋值操作重载函数能够识别出来这是一个临时变量?

在 C++11 之前,拷贝构造和赋值重载的原型如下:

1
2
3
4
struct Foo {
Foo(const Foo&);
Foo& operator=(const Foo&);
};

参数类型都是 const &,它可以匹配到三种情况:

  1. 非常左值引用
  2. 常左值引用
  3. 常右值引用

对于非常右值引用是无能为力的。另外,即便是能捕捉非常右值引用,比如:foo = Foo(10); ,但其 const 修饰也保证了其资源不可能被移动走。

因而,能够被移动走资源的,恰恰是之前缺失的那种引用类型:非常右值引用

这时候,就需要有一种表示法,明确识别出那是一种非常右值引用,最后定下来的表示法是 T&& 。这样,就可以这样来定义不同方式的构造和赋值操作:

1
2
3
4
5
6
struct Foo {
Foo(const Foo&); // 拷贝构造
Foo(Foo&&); // 移动构造
Foo& operator=(const Foo&); // 拷贝赋值
Foo& operator=(Foo&&); // 移动赋值
};

通过这样的方式,让 Foo foo = Foo(10)foo = Foo(10) 这样的表达式,都可以匹配到移动语义的版本。与此同时,让 Foo foo = foo1foo = foo1 这样的表达式,依然使用复制语义的版本。

2.3 右值引用变量

引入了右值引用之后,就有一系列的问题需要明确。

首先,在不存在重载的情况下:

  1. 左值是否可以匹配到右值引用类型参数? 比如:
1
2
3
struct non_copyable {
non_copyable(non_copyable&&);
};

答案显然是 NO,否则,一个左值就会被移动构造函数将其资源偷走,而这很明显不是我们所期望的;

  1. 右值是否可以匹配到左值引用类型参数?比如:
1
2
3
4
5
6
7
struct non_movable {
non_movable(const non_movable&);
};

struct non_movable2 {
non_movable2(non_movable&);
};

答案是看情况。

  • 至少在 C++11 之前,一个右值,就可以被类型为 const T& 类型的参数匹配;
  • 但一个右值,不能被 T& 类型的参数匹配;毕竟这种可以修改的承诺。而修改一个调用后即消失的临时对象上,没有任何意义,反而会导致程序员犯下潜在的错误,因而还是禁止了最好。

这就遗留下来一种情况:

  1. 一个非常右值引用 类型的变量,是否允许匹配非常左值引用类型的参数?

    比如:

1
2
3
4
void f(Foo& foo) { foo.a *= 10; }
Foo&& ref = Foo{10};
f(ref); // 是否允许
int b = ref.a + 10;

没有任何理由不允许这样的匹配。毕竟,自从变量 ref 被初始化后,其性质上和左值引用一样,都是引用了一个已经存在的对象。 例子中,经过 f(ref)ref 所引用的对象内容进行修改之后,还会基于其内容进行进一步的处理。这都是非常合理的需求。 并且,ref 所引用的对象的生命周期,和 ref 一样长,不用担心在使用 ref 期间,对象已经不存在的问题。

这就导致了一个看起来很矛盾的现象:

1
2
3
4
5
6
void f(Foo& foo) { foo.a *= 10; }

Foo&& ref = Foo{10};
f(ref); // OK

f(Foo{10}); // 不允许

先将一个临时对象初始化给一个右值引用,再传递给函数 f ,与直接构造一个临时对象传递给 f ,一个是允许的,一个是禁止的。

这背后的差异究竟意味这什么?

一个类型为右值引用的变量,一旦被初始化之后,临时对象的生命将被扩展,会在其被创建的作用域内始终有效。因而,Foo&& foo = Foo{10},从语义上相当于:

1
2
3
4
5
6
{
Foo __temp_obj{10};
Foo& ref = __temp_obj;
// 各种对 ref 的操作
}
// 离开作用域,__temp_obj 被销毁

因而,看似 foo 被定义的类型为右值引用,但这仅仅约束它的初始化:只能从一个右值进行初始化。但一旦初始化完成,它就和一个左值引用再也没有任何差别:都是一个已存在对象的标识

函数参数也没有任何特别之处,它就是一个普通的变量。无非是其可访问范围被限定在函数内部。调用一个函数时,传递实参的过程,就是一个对参数(变量)进行初始化的过程,而初始化的细节与一个普通变量没有任何差别。

1
2
3
4
5
void stupid(Foo&& foo) {
foo.a += 10; // 在函数体内,foo的性质与一个左值引用毫无差别
// blah ...
}
stupid(Foo{10}); // 在执行函数体之前,进行参数初始化: Foo&& foo = Foo{10}

而临时对象 Foo{10} 的生命周期,会比参数变量 foo 更长。所以将 foo 看作左值引用随意访问,是没有任何风险的。

所以,任何一个类型为右值引用的变量,一旦初始化完成,性质上就变成和一个左值引用毫无差别。这样的语义,对于程序员的使用是最为合理的。

我们再看下面的例子:

1
2
3
std::string&& ref = std::string("abc");
std::string obj = ref; // 移动? 还是 拷贝?
std::string s = ref + "cde"; // 是否可以接着假设 ref 所引用的对象是合法的?

既然在完成初始化之后,一个右值引用类型的变量,就变成了左值引用,按照这个语义,当然就只能选择拷贝构造。这样的选择,也让后面对于 ref 的继续使用是安全合理的, 这其实也在帮助程序员编写安全的代码。

毕竟,只有在调用移动构造函数那一刻,传入的是真正的临时变量,也就是说移动构造函数调用结束后,临时变量也就不再存在,无从访问的情况下,自动选择移动构造函数才是确定安全的。

经过之前讨论,我们知道这样的设计决策是最合理的,但矛盾和张力依然存在:毕竟,变量 ref 的类型是右值引用,而移动构造函数的参数类型也是右值引用,为什么它们不是最匹配的,反而是匹配了拷贝构造函数?另外,移动构造函数自动匹配真正的临时对象,毫无疑问是合理的(也是我们的初衷),但我们如何区分一个临时对象和一个类型为右值引用的变量?

这个并不难。因为 C++ 早就规定了,产生临时变量的表达式是右值,而任何变量都是一个对象的标识,因而都是左值,哪怕变量类型是右值引用

因而,右值选择移动构造函数,左值选择拷贝构造函数。

更准确的说,所谓选择移动构造函数,其实是因为右值匹配的是移动构造函数参数,其类型是一个右值引用。我们知道,函数参数也是变量,而一个类型为右值引用的变量,只能由右值来初始化:

1
2
3
4
5
Foo   foo {10};
Foo&& ref = foo; // 不合法,右值引用只能由右值初始化

Foo&& ref1 = Foo{10};
Foo&& ref2 = ref1; // 不合法,ref1是个左值

因而,作为类型为右值引用的函数参数,唯一能匹配的就是右值。这也是移动构造函数能精确识别临时变量的原因。

重要

  1. 对于任何类型为右值引用的变量(当然也包括函数参数),只能由右值来初始化;
  2. 一旦初始化完成,右值引用类型的变量,其性质与一个左值引用再也没有任何差别。

2.4 将亡值

我们现在已经明确了,只有右值临时对象可以初始化右值引用变量,从而也只有右值临时变量能够匹配参数类型为右值引用的函数,包括移动构造函数。

这中间依然有一个重要的缺口:如果程序员就是想把一个左值移动给另外一个对象,该怎么办?

最简单的选择是通过 static_cast 进行类型转换:

1
2
3
4
Foo   foo {10};
Foo&& ref = Foo{10};
Foo obj1 = static_cast<Foo&&>(foo); // 移动构造
Foo obj2 = static_cast<Foo&&>(ref); // 移动构造

我们之前说过,只有右值,才可以用来初始化一个右值引用类型的变量,因而也只有右值才能匹配移动构造。所以,static_cast<Foo&&>(foo)表达式,肯定是一个右值

但同时,它返回的类型又非常明确的是一个引用,而这一点又不符合右值的定义。因为,所有的右值,都必须是一个具体类型,不能是不完备类型,也不能是抽象类型,但引用,无论左值引用,还是右值引用,都可以是不完备类型的引用或抽象类型的引用。这是左值才有的特征。

对于这种既有左值特征,又和右值临时对象一样,可以用来初始化右值引用类型的变量的表达式,只能将其归为新的类别。C++11 给这个新类别命名为将亡值(eXpiring value,简称 xvalue)。 而将原来的右值,重新命名为纯右值。而将亡值纯右值合在一起,称为右值,其代表的含义是,所有可以直接用来初始化右值引用类型变量的表达式。

同时,由于将亡值又具备左值特征:可以是不完备类型,可以是抽象类型,可以进行运行时多态。所以,将亡值又和左值一起被归类为泛左值(generalized lvalue, 简称 glvalue)。

除了 static_cast<T&&>(expr) 这样的表达式之外,任何返回值为右值引用类型的函数调用表达式也属于将亡值。 从而让用户可以实现任意复杂的逻辑,然后通过返回值为右值引用的方式,直接初始化一个右值引用类型的变量。 以此来达到匹配移动构造,移动赋值函数,以及任何其它参数类型为右值引用的函数的目的。

C++ 标准对其的定义为:

xvalue:
an xvalue (an “eXpiring” value) is a glvalue that denotes an object or bit-field whose resources can be reused.

意思就是,这类表达式表明了自己可以被赋值给一个类型为右值引用的变量,当然自然也就可以被移动构造和移动赋值操作自然匹配,从而返回的引用所引用的对象可以通过移动而被重用。

所以,将亡值未必真的会将亡(expiring),它只是能用来初始化右值引用类型的变量而已。只有用到移动场景下,它才会真的导致所引用对象的失效。

最后,将亡表达式存在着一个异常场景,那就是函数类型的右值引用。因为函数地址被移动本身毫无意义。所以,对于返回值为函数类型右值引用的函数调用,或者 static_cast<FunctionType&&>(expr) 的表达式,其类别为左值,而不是将亡值

重要

  • 类型为右值引用的变量,只能由右值表达式初始化;
  • 右值包括纯右值将亡值,其中将亡值的类型是右值引用
  • 类型为右值引用的变量,是一个左值,因而不能赋值给其它类型为右值引用的变量, 当然也不能匹配参数类型为右值引用的函数。

3. 值与对象

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

3.1 值

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

那么 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);,这样的表达式被称做弃值表达式。从语义上,它们仍然会初始化一个临时对象,而临时对象也是泛左值。后面我们会进行解释。

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

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

3.5 泛左值

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

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

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

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

3.5.1 左值

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

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

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

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

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

3.5.2 将亡值

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

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

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

3.5.3 对象?值?

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

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

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

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

比如:Foo foo = Foo{10} 的语义是:构造一个临时对象,然后拷贝/移动给左边的对象 foo

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

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

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

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

1
2
3
4
5
6
    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() 表示式里,拷贝/移动给等号左侧的对象 Foo。虽然 C/C++ 编译器很早就有 RVO/NRVO 优化技术;但同样,那是优化阶段的事,而不是语义分析阶段如何理解这个表达式语义的问题。

3.5.4 临时量实质化

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

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

临时量实质化的过程还发生在其它场景。

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

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

1
2
3
4
using Array = char [10];

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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,从匹配行为上没有变化,但语义上却有了变化。最终导致匹配右值引用版本的不是纯右值类别,而是将亡值。因为纯右值会首先进行实质化,得到一个将亡值。最终是用将亡值初始化了函数的对应参数。

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

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

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

注意

  • 所有的表达式都可以归类为纯右值泛左值
  • 所有的纯右值都是的概念;所有的泛左值都是对象的概念;
  • 左值可以求地址,将亡值不可以求地址;
  • 纯右值在需要临时对象存在的场景下,会通过实质化,转化成将亡值
  • 泛左值可以是抽象类型和不完备类型,可以进行多态调用;纯右值只能是具体类型,无法进行多态调用;
  • 纯右值构造一个左值对象时,是直接构造语义;用将亡值构造一个左值对象时,是拷贝/移动构造语义。

4. decltype 类型推导

decltype 是 C++11 加入的一个重要特性。它允许求一切合法表达式的类型。从而,让从类型到值,从值到类型形成了一个闭环,极大的扩展了泛型编程的能力。

C++ 规范中,对于 decltype 类型推导规则的定义如下:

  1. 若实参为无括号的标识表达式或无括号的类成员访问表达式,则 decltype 产生以此表达式命名的实体的类型。 若无这种实体,或该实参指名某个重载函数,则程序非良构。
  2. 若实参是其他类型为 T 的任何表达式,且
    a. 若表达式的值类别为将亡值,则 decltype 产生 T&&
    b. 若表达式的值类别为左值,则 decltype 产生 T&
    c. 若表达式的值类别为纯右值,则 decltype 产生 T

若表达式是纯右值,则不从该纯右值实质化临时对象:这种纯右值无结果对象。

注意如果对象的名字带有括号,则它被当做通常的左值表达式,从而 decltype(x)decltype((x)) 通常是不同的类型。

这些规则,初看起来,有些让人困惑。但如果真的理解了背后的机制,其实非常容易理解。

decltype 有两种表达方法:

  1. 有括号:decltype((expr))
  2. 无括号:decltype(expr)

4.1 有括号语义

有括号的表达方法,语义是简单而统一的:它站在表达式类别的角度求类型。

  1. 如果表达式属于纯右值,结果必然是非引用类型;
  2. 如果表达式属于泛左值,结果必然是引用类型;
    • 如果表达式属于左值,结果必然是左值引用
    • 如果表达式属于将亡值,结果必然是右值引用
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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
struct Foo { int a; };

using Func = Foo&();
using Array = char[2];

enum class E { OK, FAIL };

const Foo f_v();
Foo& f_ref();
Foo&& f_r();

int a = 0;
const int b = 1;
const Foo foo = {10};
Foo&& rref = Foo{1};
const Foo& ref = foo;
char c[2] = {1, 2};
int* p = &a;
const Foo* pFoo = &foo;

// 左值
decltype((a)) v1; // int&
decltype((foo)) v2; // const Foo&
decltype((foo.a)) v3; // const int&
decltype((f_ref())) v4; // Foo&
decltype((f_r)) v5; // Foo&& (&)()
decltype((c)) v6; // char (&)[2]
decltype((a += 10)) v7; // int&
decltype((++a)) v8; // int&
decltype((c[1])) v9; // char&
decltype((*p)) v10; // int&
decltype((p)) v11; // int*&
decltype((pFoo)) v12; // const Foo*&
decltype((pFoo->a)) v13; // const int&
decltype((Foo::a)) v14; // int&
decltype((rref)) v15; // Foo&
decltype((ref)) v16; // const Foo&
decltype((a > 0 ? a : b)) v; // int&
decltype((static_cast<Func&&>(f_ref))) f; // Foo& (&)()

// 纯右值
decltype((1+2)) v1; // int
decltype((Foo{10})) v2; // Foo
decltype((f_v())) v3; // const Foo
decltype((Array{0, 1})) v4; // char[2]
decltype((a++)) v5; // int
decltype((&b)) v6; // const int*
decltype((OK)) v7; // E
decltype((a > 0 ? 10 : Foo{0}.a)) v; // int

// 将亡值
decltype((Foo{10}.a)) v1; // int&&
decltype((f_r())) v2; // Foo&&
decltype((Array{}[0])) v3; // char&&
decltype((std::move(a))) v4; // int&&
decltype((a > 0 ? Foo{1}.a : Foo{0}.a)) v; // int&&

这其中,最有趣的是 decltype((rref))rref 本身的类型是一个右值引用 Foo&&,但做为左值表达式,它的类型却是 Foo&,而这一点,请参见右值引用变量

4.2 无括号语义

无括号的情况下,除了一种例外,其它情况下,都与有括号场景一致。

这个例外就是对于变量(包括常量)名字的直接求类型。这种情况,会返回变量被定义时的类型。

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
27
28
29
30
31
32
struct Foo { int a; };

using Func = Foo&();
using Array = char[2];

const Foo f_v();
Foo& f_ref();
Foo&& f_r();

int a = 0;
const int b = 1;
const Foo foo = {10};
Foo&& rref = Foo{1};
const Foo& ref = foo;
char c[2] = {1, 2};
int* p = &a;
const Foo* pFoo = &foo;

decltype(a) v1; // int
decltype(b) v2; // const int
decltype(foo) v3; // const Foo
decltype(ref) v4; // const Foo&
decltype(rref) v5; // Foo&&
decltype(c) v6; // char[2]
decltype(p) v7; // int*
decltype(foo.a) v8; // int
decltype(ref.a) v9; // int
decltype(rref.a) v10; // int
decltype(pFoo) v11; // const Foo*
decltype(pFoo->a) v12; // int
decltype(Foo{1}.a) v13; // int
decltype(Foo::a) v14; // int

从例子中不难看出,对于所有的变量访问,无论直接还是间接,由于每个变量在定义时都有自己的类型,因而求类型的结果就是这些变量被定义时的类型。

所以,之所以会出现有括号,无括号两种用法,正是因为每一个被定义的变量,都面临着两种需求:

  1. 它们被定义时的类型
  2. 整体做为一个表达式的类型(一定是泛左值)

前者是不关心表达式的,比如 decltype(Foo{1}.a),它只关心 a 被定义时的类型:int; 而不关心整个表达式本身是一个将亡值,因而表达式必然应该是一种右值引用类型:int&&

正是对于变量有这两种需求的存在,而其它表达式没有这样的问题,所以,才专门为变量定义了两种求类型的方法。而对于其它表达式则两种方式无差别。

5. auto 类型推导

auto 类型推导脱胎于模版函数的类型推导,它们的能力几乎等价(除了初始化列表的情况)。 这也就意味着,其实在 C++11 之前,C++ 早就具备了 auto 的能力,只是没有从语法上允许而已。

5.1 auto 的语义

和直觉不同的是,对于任意表达式:auto v = exprv 的类型并不总是和 expr 所返回的类型一致。

首先,auto 不可能是一个引用,无论是左值引用,还是右值引用,所以,如果 expr 返回类型里包含任何引用,都会被舍弃。比如:

1
2
3
4
5
6
7
8
9
10
11
Foo   foo {1};
Foo& ref = foo;
Foo&& rref = Foo{2};

Foo& getRef();
Foo&& getRref();

auto v1 = ref; // v1 type: Foo
auto v2 = rref; // v2 type: Foo
auto v3 = getRef(); // v3 type: Foo
auto v4 = getRref(); // v4 type: Foo

其次,所有对值所修饰的 const 都会被丢弃。比如:

1
2
3
4
5
6
7
8
9
const Foo   foo {1};
const Foo& ref = foo;
const Foo&& rref = Foo{2};
const Foo* const p = &foo;

auto v1 = foo; // Foo
auto v2 = ref; // Foo
auto v3 = rref; // Foo
auto v4 = p; // const Foo*

究其原因,是因为这种直接给出 auto 的写法,是一种拷贝/移动语义。因而,等号右边的表达式本身类型是引用,并不影响等号左侧对象本身不是引用;同样的,等号右边表达式本身的常量性,拷贝/移动后,并不会影响新定义变量的常量性 。

其推演语义,完全等价于:

1
2
template <typename T>
void f(T value);

其中 T 就是 autovalue 就是你用 auto 所定义的变量。

注意,到了 C++17 之后,并非所有的场景下,都是拷贝/移动语义,比如 auto v = Foo{1},其行为完全等价于:Foo v{1} 。具体可参见对象?值?

因而,更准确的说,这不是拷贝/移动语义,而属于构造初始化语义。

5.2 引用及 const

因而,如果你希望让新定义的变量属于引用类型,或具备 const,则需要明确指定。比如:

1
2
3
      auto   foo  = Foo{1};
const auto& ref = foo;
auto&& rref = Foo{2};

而指针的情况则稍有特殊。

5.3 指针

当你不指定指针的情况下,如果等号右侧的表达式是一个指针类型,那么左侧的变量类型当然也是一个指针。

当你明确指定指针的情况下,则是要求右侧表达式必须是一个指针类型。

1
2
3
4
5
6
7
Foo  foo {1};
Foo* pFoo = &foo;

auto v1 = foo; // v1 type: Foo
auto p1 = pFoo; // p1 type: Foo*
auto* p2 = pFoo; // p2 type: Foo*
auto* p3 = foo; // Error: foo is not a pointer

5.4 万能引用

更为特殊的是 auto&& v = expr 的表达式。这并不必然导致 v 是一个右值引用。而是取决于 expr 的类别。

如果 expr 是一个左值表达式,那么 v 将是左值引用类型;

如果 expr 是一个右值表达式(参见对象?值?),那么 v 将会是右值引用类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Foo   foo {1};
Foo& ref = foo;
Foo&& rref = Foo{2};
Foo&& getRref();
Foo& getRef();
Foo getFoo();

auto&& v1 = foo; // v1 type: Foo&
auto&& v2 = Foo{2}; // v2 type: Foo&&
auto&& v3 = getRref(); // v3 type: Foo&&
auto&& v4 = getRef(); // v4 type: Foo&
auto&& v5 = getFoo(); // v5 type: Foo&&
auto&& v6 = ref; // v6 type: Foo&
auto&& v7 = rref; // v7 type: Foo&

正是因为这样的写法,允许等号右侧是任意合法的表达式,而等号左侧总是可以根据表达式类别,推演出合适的引用类型。所以这种写法被称做万能引用

其中,我们可以清晰的看出,虽然 refrref 分别被定义为左值引用右值引用,但它们做为左值来讲,是等价的。都是左值引用。具体可参考右值引用变量

5.5 初始化列表

由于初始化列表不是一个表达式,因而类型也就无从谈起。所以 C++14 对其做了特殊的规定:

如果使用直接初始化(不用等号)的方式,比如:auto i{1} ,则初始化列表只允许有一个元素,其等价于 auto i = 1; 如果初始化列表超过一个元素,比如 auto j{1,2} ,则编译失败。

如果使用拷贝初始化(用等号)的方式,比如:auto v = {1, 2},则初始化列表允许有多个同一类型的元素。其等价于 std::initializer_list<int> v = {1, 2} 。而 auto v = {1} 则等价于 std::initializer_list<int> v = {1}

5.6 decltype(auto)

由于 auto 推演总是会丢弃引用const 信息,明确给出引用又总是得到一个引用。明确给出 const,则总是得到一个 const 类型。这对于想精确遵从等号后面类型的情况非常不便,尤其在进行泛型编程时,很难通过 auto 符合通用的情况。

decltype 恰恰相反,它总是能准确捕捉右侧表达式的类型(参见 decltype )。因而,我们可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
      Foo   foo {1};
const Foo& ref = foo;
Foo&& rref = Foo{2};
int a = 0;

decltype(foo) v1 = foo; // Foo
decltype((foo)) v2 = foo; // Foo&
decltype(ref) v3 = ref; // const Foo&
decltype(rref) v4 = rref; // Foo&&
decltype((rref)) v5 = rref; // Foo&
decltype(1+2) v6 = 1 + 2; // int

decltype((a > 0 ? Foo{0}.a : Foo{1}.a)) v7 = \
a > 0 ? Foo{0}.a : Foo{1}.a; // int&&

但这样的写法,总是要把右边的表达式在 decltype 里重复写一遍,才能做到。到了 C++14 ,推出了一种新的写法:decltype(auto),其中 auto 是一个自动占位符,代表等号右侧的表达式,这就大大简化了程序员的工作:

1
2
3
decltype(auto) v1 = foo;    // Foo
decltype(auto) v2 = (foo); // Foo&
decltype(auto) v7 = (a > 0 ? Foo{0}.a : Foo{1}.a); // int&&

5.7 函数返回值类型的自动推演

到了 C++14 之后,对于普通函数的返回值自动推演,可以通过 auto 来完成,比如:

auto f() { return Foo{1}.a; } // 返回值类型为int

当然,如果希望返回值类型运用 decltype 规则,则可以用 decltype(auto) 。比如:

1
2
3
auto f() -> decltype(auto) { // 返回值为 int&&
return (Foo{1}.a);
}

5.8 非类型模版参数

1
2
3
4
5
6
7
8
template <auto V>
struct C {
// ....
};

C<10> a; // C<int>
C<'c'> b; // C<char>
C<true> c; // C<bool>

5.9 函数模版的便捷写法

1
2
3
4
template <typename T1, typename T2>
auto add(T1 lhs, T2 rhs) {
return lhs + rhs;
}

到了 C++20 ,允许让普通函数可以有更加便捷的写法:

1
2
3
auto add(auto lhs, auto rhs) {
return lhs + rhs;
}

当然,如果你想指明两个参数属于同一种类型,但另外的参数没有这样的约束,则仍然需要写模版头:

1
2
template <typename T>
auto f(T a, auto b, T c, auto d); // a, c 必须同一类型,b, d 各自有各自类型

其等价于:

1
2
template <typename T, typename T1, typename T2>
auto f(T a, T1 b, T c, T2 d);

本文摘自博客:https://modern-cpp.readthedocs.io/zh_CN/latest/index.html


C++ 值类别探究
http://example.com/2023/08/25/cpp-value-categories/
作者
QiDianMaker
发布于
2023年8月24日
更新于
2023年8月27日
许可协议