浅谈值类别及其历史

constructor

2020-06-25 00:14:10

科技·工程

先思考如下几个问题:

  1. 现在有 int& fun(void){...}, 请问fun()这个表达式的类型是什么
  2. "AK IOI"是左值还是右值
  3. 数组名是什么

对一下答案吧:

  1. int&
  2. 右值
  3. 指向首地址的指针

如果你的作答和上面的答案一样,那么恭喜你。

全错

来吧,一起看下面值类别的内容,然后再回过头来考虑上面的问题。

值类别的分类

在C++11以前,值类别被分为左值(lvalue)和右值(rvalue),11之后,右值被进一步细化,分为亡值(xvalue, expiring value)和纯右值(prvalue, pure right value)。(注意,bitfield只能归类于泛左值(glvalue, general left value),因为无法确定其是左值还是亡值,但是本文章不会引入位域,请忽略)

为什么有这样的名字

这边要引入一个典型的误区,认为能放在赋值运算符(assignment operator)的左侧的表达式为左值,其余的为右值(note: 一个更错误的想法是放左边的是左值,放右边的是右值)。

正常人都会见到这个名字后作出如上的猜测,事实上,在历史上,这个说法一度是正确的,编程语言CPL第一次引入了值类别的概念,并且规定能放置于赋值运算符左侧的表达式为左值表达式,否则为右值表达式,但这样的规则在C++显然不成立,反例有如下几种:

const int a = 0; // a这个id-expr是左值表达式,但a不能被放在赋值运算符左侧
"Luogu" // 字符串常量是左值表达式,但是常量表达式显然不能放在运算符左侧
int arr[3];
arr // arr作为id-expr时是左值表达式,但是不能被放在赋值运算符左侧

表达式

每个C++表达式都由两部分构成,一个是类型,一个是值类别。而且,这个类型绝对不可能是引用类型(http://eel.is/c++draft/expr.type#1 ),因为根本不存在引用类型的对象(因为引用类型不是对象类型,http://eel.is/c++draft/basic.types#8 )。所谓的引用类型的对象这种说法都是错误的,也正是基于这个理由,才需要std::reference_wrapper这种设施。

左值

在C++98中,并没有左值和右值的明确定义,只能按照性质予以区分。

最不会出错的分辨方法是左值可以被应用于取地址运算符(&, address-of operator)

[cling]$ const int a = 0;
[cling]$ &a
(const int *) 0x7f24112dc000
[cling]$ &"Luogu"
(const char (*)[6]) 0x7f241129a000
[cling]$ int arr[10]{};
[cling]$ &arr
(int (*)[10]) 0x7f24112de010
[cling]$ &2
input_line_9:2:2: error: cannot take the address of an rvalue of type 'int'
 &2
 ^~

事实上,以下的几种是左值表达式:

注意右值引用的变量名表达式当然也是左值,尽管它并不是一个对象

[cling]$ int&& r = 5
(int) 5
[cling]$ &r
(int *) 0x7f3d20ea0010

见引言问题1,正确答案应该是int类型的左值。

这不是全部,部分本文读者较为不熟悉的内容以及一些过于简单的内容略去

左值的性质

比如,extern数组前向声明:

extern int a[];

可修改左值的性质

可以注意到,CPL时期的左值指的都是可修改的左值

纯右值

一个比较有意思的一点是,自定义字面量也都是纯右值。

//-std=c++14
[cling]$ #include <string>
[cling]$ using namespace std::literals;
[cling]$ & "ss"
(const char (*)[3]) 0x7fc4ffb8e000
[cling]$ &"ss"s
input_line_6:2:2: error: taking the address of a temporary object of type 'basic_string<char>'
      [-Waddress-of-temporary]
 &"ss"s
 ^~~~~~

这不是全部,部分本文读者较为不熟悉的内容以及一些过于简单的内容略去

纯右值性质

int fun()
const int fun() // the same
std::string foo()
const std::string foo() // not the same

亡值

亡值从C++11开始引入,是为了完善移动语义(move semantics)

最典型的应该就是std::move, 请注意std:::forward不一定是,请不要混淆forwarding reference和rvalue reference,限于篇幅,请自己查找有关资料。std::move并不是一个很玄学的东西,只是一个强制转换而已。移动语义的功劳经常被抢,很多人以为移动是std::move做的,其实这个函数和移动没有半点关系,只是改变了值类别让一个对象可以进行移动。

这不是全部,部分本文读者较为不熟悉的内容以及一些过于简单的内容略去

亡值的性质

除以下几点外,具有左值和纯右值的性质:

数组名

数组名是指向首地址的指针这种谬论大概是从谭浩强教授开始的。本来这句话听着就不对劲,但是被人传久了好像大家都认为这句话没什么问题了。

看几个例子:

[cling]$ int a[50];
[cling]$ sizeof a == sizeof(int*)
(bool) false
int a[50];
int (&b) [50] = a;

很好的证明了数组名并不是指针,而就指代数组本身。

然而,之所以存在这一误区,就是因为C++的退化机制(decay)。在不需要数组的任何语境,数组名隐式转换为指向首元素的纯右值指针表达式。函数也拥有同样的退化性质,但是函数名也好,数组名也罢,他们都是左值。

移动语义(move semantics)

C++11引入了移动语义,目的是节省复制的开销。

//-std=c++11
std::vector<int> source = {2, 2, 1, 2, 3};

//after this, vector destination takes the memory managed by source, accesses to source become Undefined Behaviors.
auto destination = std::move(source);

为了解释这段代码,我将std::move去掉,写成这样:

auto d = static_cast<decltype(s) &&>(s)

左值到右值引用强制转换表达式是亡值,这使得本来不可被移动的左值s可以被移动,由于转换后的亡值与原有的左值是同一实体,他移交的内存也是原有对象管理的内存。这个亡值参与的初始化语句是移动语境:重载决议将选定vector<int>(vector<int>&&)这个移动构造函数。典型的vector移动实现是直接移交源对象的指针给目标对象,这避免了元素的复制开销。

相比而言,如果不进行一次强制转换,选定的构造函数将会是vector<int>(const vector<int>&)这个移动构造函数,从上面可以发现,当参数是右值时,函数右值引用的版本在重载决议中高于到const引用的版本,尽管后者也是可以绑定到右值的,另外,C++17中纯右值保证不调用构造函数,连移动的开销一起省了(甚至移动复制构造函数全=delete也能过编译,这件事经常迷惑一批萌新),所以注意构造和析构函数中的副作用(side-effect)是不能被依赖的。

所以如果并没有给定移动构造函数,上述的代码不会报错,而是会调用复制构造,但不影响C++17开始的强制复制消除。

说道移动语义就一定要安利C++14的std::exchange,简直是算法题目中能清理大量代码的福音:https://en.cppreference.com/w/cpp/utility/exchange

值类别的历史

之前已经提到了值类别的来源,CPL。在这之后,C基本沿袭了CPL的术语,但是废除了“右值”这一名词,将值类别分为左值和非左值,C++98恢复了右值这一术语,并将C中的一些非左值变为左值。

C++11开始,随着移动语义的引入,右值被分开,并出现了较为严格的值类别定义:

其中,同一性指的是有办法判断两个表达式指代的是否是同一实体。

至于移动语义请自己去查找。

C++17开始,C++11的分类法被完全推翻。

C++17开始纯右值不可被移动,并且引入了强制复制消除的要求(mandatory copy elision),达到了史无前例的值类别最复杂的阶段,另外,void表达式开始指代一个无结果的对象,也成为了不可被移动且不具有同一性的纯右值。

本文章是参考资料加上个人见解写成,其中不免可能出现错误,欢迎大家指出。

参考:http://eel.is/c++draft/basic.lval