浅谈值类别及其历史

constructor

2020-06-25 00:14:10

Personal

先思考如下几个问题: 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++显然不成立,反例有如下几种: ```cpp 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) ```cpp [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 ^~ ``` 事实上,以下的几种是左值表达式: - 变量名表达式(id-expr) 注意右值引用的变量名表达式当然也是左值,尽管它并不是一个对象 ```cpp [cling]$ int&& r = 5 (int) 5 [cling]$ &r (int *) 0x7f3d20ea0010 ``` - 对返回值为左值引用的函数调用表达式 见引言问题1,正确答案应该是int类型的左值。 - 赋值表达式和组合赋值表达式(`+=`, `%=`, etc. compound assignment expression) - 内建的间接访问表达式(`*`, indirection expression, a.k.a. dereference expression, 解引用表达式) - 内建的下标表达式(`[]`, subscript expression),请注意,内建的下标表达式只是对间接访问运算符的替换,请尝试`2["Luogu"]` - **内建前置自增表达式(pre-increment and pre-decrement expression)** - 成员访问运算符(`.`, the member of object expression,但是这个运算符一般叫做member access operator) - **字符串字面量** *这不是全部,部分本文读者较为不熟悉的内容以及一些过于简单的内容略去* ### 左值的性质 - 可以被取地址 - 可以转换到纯右值 - 可以拥有不完全类型 比如,extern数组前向声明: ```cpp extern int a[]; ``` ### 可修改左值的性质 - 可以作为赋值的目标对象 - 可以被非到const的左值引用绑定 可以注意到,CPL时期的左值指的都是可修改的左值 ## 纯右值 - 返回值非引用类型的函数调用表达式 - **后自增减表达式(post-increment and post-decrement expression)** - 内建的算数表达式、逻辑表达式和比较表达式(arithmetic expression, logical expression, and comparison expression) - 取地址表达式 - `this` - 除了字符串字面量外的字面量 一个比较有意思的一点是,自定义字面量也都是纯右值。 ```cpp //-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 ^~~~~~ ``` - 枚举量(enumerator),我觉得这也算字面量的一种 - C++11起的lambda expression *这不是全部,部分本文读者较为不熟悉的内容以及一些过于简单的内容略去* ### 纯右值性质 - 不能被取地址 - 不能是多态的(指继承多态,纯右值的动态类型永远与静态类型一致) - 不能拥有除void之外的不完全类型(之所以有这一条,我认为是因为C++17开始规定void表达式拥有无结果对象(no result object)) - 非类类型的纯右值不能进行cv限定。(反过来,类类型的纯右值可以进行cv限定这件事情其实挺坑的,比如要考虑`const std::string`和`std::string`的不同) ```cpp 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`做的,其实这个函数和移动没有半点关系,只是改变了值类别让一个对象可以进行移动。 - 转换到右值引用的转换表达式(`std::move`里面干的就是这个) *这不是全部,部分本文读者较为不熟悉的内容以及一些过于简单的内容略去* ### 亡值的性质 除以下几点外,具有左值和纯右值的性质: - 不能被取地址 - 不可能位于赋值运算符左侧 - 不能被非到const的左值引用绑定 ## 数组名 数组名是指向首地址的指针这种谬论大概是从谭浩强教授开始的。本来这句话听着就不对劲,但是被人传久了好像大家都认为这句话没什么问题了。 看几个例子: ```cpp [cling]$ int a[50]; [cling]$ sizeof a == sizeof(int*) (bool) false ``` ```cpp int a[50]; int (&b) [50] = a; ``` 很好的证明了数组名并不是指针,而就指代数组本身。 然而,之所以存在这一误区,就是因为C++的退化机制(decay)。在不需要数组的任何语境,数组名隐式转换为指向首元素的纯右值指针表达式。函数也拥有同样的退化性质,但是函数名也好,数组名也罢,他们都是左值。 ## 移动语义(move semantics) C++11引入了移动语义,目的是节省复制的开销。 ```cpp //-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`去掉,写成这样: ```cpp 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