C++ 值类别(value category)循序渐进(一)值类别是什么

目录

  • 一、值类别的定义和分类关系
    • 1.1 基础值类别定义
    • 2.1 混合值类别定义
  • 二、基本值类别包含的表达式种类和属性
    • 2.1 lvalue(左值)
      • 2.1.1 包含种类
      • 2.1.2 属性
    • 2.2 prvalue(纯右值)
      • 2.2.1 包含种类
      • 2.2.2 属性
    • 2.3 xvalue(将亡值)
      • 2.3.1 包含种类
      • 2.3.2 属性
  • 三、混合值类别属性
    • 3.1 glvalue(泛左值)
    • 3.1 rvalue(右值)
  • 四、左值引用和右值引用
  • 五、一些特殊类别
    • 5.1 未决成员函数调用(Pending member function call)
    • 5.2 Void表达式
    • 5.3 位域(Bit fields)
  • 六、区分值类别的作用
  • 七、编程语言值类别发展历史
    • 7.1 CPL
    • 7.2 C
    • 7.3 C++98
    • 7.4 C++11
    • 7.5 C++17

学习C++比较深入点的朋友们对下面这些名词肯定不陌生:左值右值、左值引用、右值引用、移动语义、完美转发、std::forward、std::move,每一个都算不上浅显易懂,但无论是平常工作还是面试都可能遇到,这也是C++进阶必须要掌握的内容,这些其实都是一类问题或者说知识点,也就是值类别(value category),有章法地整体学习这一块内容比每个概念单个看有效得多,这里就开一个新系列来聊聊C++的值类别。整个内容主要以C++ 11之后的modern C++为准,这一块在C++的历史上变化还挺大的。

一、值类别的定义和分类关系

首先我们要明确一点,值类别是针对表达式的,所以我们先来看看C++的表达式的定义:由各种运算对象(operands)和运算符(operators )组成的表明一个计算的式子,比如a + b或a.method(1) + b这种,但这里想要额外强调的是,即便没有额外的运算符,"hello word"这种字面量以及单个变量名也属于表达式。

其实每一个C++表达式都有两个重要的特征:类型(type)和值类别(value category),前者我们都很熟悉,就是int、float、vector这种类型,而后者很多人都不太了解。C++ 11之后基本的值类别大体上可以分为我们常说的左值和右值两种,也就是比较宽泛的左值右值分类,我们应该在很多地方见到过一个简略的定义:能取地址的就是左值,否则就是右值,虽然不是很严谨,但也充分表明了二者最大的区别:左值是有身份的,而右值没有。左值右值这个名称的来源就是因为他们在赋值表达式位置,左值可以在左边,右值只能在右边。在标准的定义里我们所说的左值右值对应的是glvalue(“generalized” lvalue,泛左值)和rvalue(右值,从定义上来看其实也就是泛右值,不知道为啥不叫grvalue),之所以叫泛是因为包含了不同的种类,是广义上的混合值类型,而它们所包含的基础值类型有三类:prvalue(纯右值)、xvalue(eXpiring value,将亡值)和lvaue(左值)。整体上来看glvalue包含lvalue和xvalue,rvalue包含prvalue和xvalue。因此xvalue从广义上讲既是左值也是右值。

1.1 基础值类别定义

我们先来看下三种基础值类型的具体定义:

  • lvalue(左值):有身份的值, 之所以叫左值是因为历史原因,这种值可以在赋值表达式的左侧。
  • prvalue(纯右值) 指的是求值(evaluation)满足以下二者之一的表达式:
    (1)用于计算内部运算符的运算对象的表达式,此类prvalue没有结果对象。
    (2)用于初始化一个对象,此类prvalue被称为是有结果对象,结果对象可以是一个变量、一个new表达式创建的对象、由临时量实质化(temporary materialization )创建的临时对象,或者它们的成员。注意非void的discarded expressions(接过被丢弃的表达式)是有结果对象(临时量实质化创建的materialized temporary)的,并且每个类类型和数组类型的prvalue只要不是被用在decltype都会有结果对象。
    prvalue之所以叫“纯”右值,就是因为这些表达式是真的没有身份,不能取地址,是实实在在的右值。
  • xvalue(将亡值,“eXpiring” value):和字面意思一样,快要消亡的值,它和lvalue的区别就在于即将消亡,所以它也是有身份的,其实就是即将消亡的lvalue,表明这类对象的资源是可以被复用的。

以上定义为了便于理解有简化的成分,建议理解得差不多后仔细揣摩下cppreference上的详细说明。也可以先单独看下C++11版本的定义,比较明确,在下文的之类型发展历史里有。

2.1 混合值类别定义

除了基本分类以外,还有2个混合概念,一是包含lvalue和xvalue的glvalue(generalized lvalue,广义左值),二是包括xvalue和prvalue的rvalue(右值,个人认为也可以叫泛右值,也就是广义右值),表达式的各种值类型的关系如下图所示:
C++ 值类别(value category)循序渐进(一)值类别是什么_第1张图片

二、基本值类别包含的表达式种类和属性

下面我们来看下Mordern C++的标准里所详细定义的各种表达式所属的基本值类别以及各个值类别的属性,详细的定义特别的多,如果是刚开始接触大概扫一遍有个概念就行。

2.1 lvalue(左值)

2.1.1 包含种类

  • 一个变量、函数、模板参数对象(C++20才开始有)或者数据成员的名字,而无论它们是什么类型, 有一点特别需要注意,即便变量的类型是右值引用,由它名字组成的表达式仍然是左值表达式
  • 返回左值引用的函数调用或者重载运算符,比如std::getline(std::cin, str), std::cout << 1, str1 = str2, ++it;
  • 赋值和复合赋值运算符表达式,比如a = b, a += b, a %= b
  • 前置增减运算符表达式,比如++a 和 --a
  • 间接取值(指针取值)表达式,比如*p
  • 下标取值表达式,并且其中一个操作数是左值数组时,比如a是左值数组,a[n] (C++11开始)
  • 对象成员表达式a.m, m 是成员枚举项或非静态成员函数、a是rvalue并且是对象类型的非静态数据成员这两种情况除外
  • 指针成员表达式p->m, m 是成员枚举项或非静态成员函数的情况除外,注意和上面一个的区别,因为是地址访问,p所访问的对象自然不会是rvalue
  • 对象的成员指针(pointer-to-member)表达式a.*mp,其中 a是lvalue并且mp 是数据成员指针,成员指针用得比较少,有兴趣的可以看看这个帖子了解下
  • 指针的成员指针表达式p->*mp, mp 是数据成员指针
  • 逗号表达式(comma expression),a, b , 在b是lvalue的情况下
  • b和c满足特定类型的,三目运算符a ? b : c, 比如bc是同一类型的lvalue,具体参照三目运算符规则
  • 字符串字面量,比如 “Hello, world!”
  • 往左值引用转的类型转换表达式,比如static_cast(x)
  • 返回类型是到函数的右值引用的函数调用表达式或重载的运算符表达式(C++11起)
  • 转换到函数的右值引用类型的转型表达式,如 static_cast(x)(C++11起)

最后两条看起来是不是有点奇怪,为什么函数的右值引用会是lvalue,可以看看stackoverflow上的这个讨论。

2.1.2 属性

  • glvalue有的公共属性(参照下面glvalue部分,这里不单独介绍)。
  • lvalue的地址可以通过取址运算符取到,比如&++i[1] 、&std::endl都是合法表达式。
  • 可修改的左值可作为赋值和复合赋值运算符的左操作数。
  • 左值可以用来初始化左值引用,这也就是我们常用的定义引用,也就是将一个新名字关联给该表达式所标识的对象,比如 int a = 0; int& b = a;。

2.2 prvalue(纯右值)

2.2.1 包含种类

  • 除字符串字面量以外的字面量,比如 42,true,nullptr
  • 返回类型是非引用的函数或者重载运算符的调用a比如str.substr(1, 2), str1 + str2, or it++
  • 后置自增减运算符表达式,比如a++和a–
  • 算数表达式,比如a + b, a % b, a & b, a << b
  • 逻辑表达式,比如a && b, a || b, !a
  • 比较表达式,比如a < b, a == b, a >= b
  • 取地址表达式,比如&a
  • 对象成员表达式,a.m, 当m是一个枚举值成员变量或者是一个非静态成员函数,或者a是一个rvalue并且m是一个非引用类型的非静态数据成员(截止到C++11)
  • 内建指针访问成员表达式p->m, 当m是一个枚举值成员变量或者是一个非静态成员函数
  • 对象的成员指针a.*mp, 当mp是指向成员函数的指针, 或者a是rvalue并且mp是指向数据成员的指针 (截止到C++11)
  • 指针的成员指针p->*mp, mp是指向成员函数的指针
  • 逗号表达式a, b, 在b是rvalue的情况下
  • 三目运算表达式a ? b : c,在特定的 b和c的值类型下属于prvalue,具体的还是残障上面提到的三目运算符规则
  • 转换到非引用类型的表达式,比如static_cast(x), std::string{}, (int)42
  • this指针
  • 枚举项
  • 非类型模板形参,除非它的类型是类或者 (C++20开始) 左值引用
  • lamda表达式(C++11起)
  • requires 表达式(C++20起)
  • concept的特化(C++20起)

2.2.2 属性

  • rvalue有的公共属性(参照下面rvalue部分,这里不单独介绍)。
  • prvalue不具有多态:它所标识的对象的动态类型始终是该表达式的类型。
  • 非类非数组的prvalue不能被 const、volatile 限定,除非它被实质化以绑定到 cv 修饰类型的引用 (C++17 起)。(注意:函数调用或类型转换表达式可能生成非类的const、volatile限定类型的prvalue,但它的 cv 限定符通常被立即去除。)
  • 纯右值不能具有不完整类型(除了类型 void(见下文),或在 decltype 说明符中使用之外)
  • 纯右值不能具有抽象类类型或它的数组类型。

2.3 xvalue(将亡值)

2.3.1 包含种类

  • 返回类型是对象的右值引用的函数或者重载运算符的调用,比如最典型的:return std::move(x)
  • 下标运算符a[n], a是rvalue数组的情况下
  • a.m, 对象成员表达式, a是 rvalue 并且m是非引用类型的非静态数据成员
  • a.*mp, 对象的成员指针表达式,a是rvalue,mp是指向数据函数的指针
  • 三目运算表达式a ? b : c,在特定的 b和c的值类型下是xvalue
  • 往右值引用类型转的类型转换表达式,比如static_cast(x)

2.3.2 属性

  • rvalue有的公共属性(参照下面rvalue部分,这里不单独介绍)
  • glvalue有的公共属性(参照下面rvalue部分,这里不单独介绍)
    这里强调一下,与所有的rvalue类似,xvalue可以绑定到右值引用上,而且与所有的glvalue类似,xvalue可以是多态的,而且非类的亡值可以有const、volatile限定。

三、混合值类别属性

前面说到了混合类别是由基本类别构成的,他们包含的具体的表达式就是基础类别的合集,因此这里只列举两种混合类别的属性。

3.1 glvalue(泛左值)

  • glvalue可以通过lvalue到rvalue、数组到指针或函数到指针的隐式转换转换成prvalue。
  • glvalue可以是多态的:它标识的对象的动态类型不必是该表达式的静态类型。
  • glvalue可以具有不完整类型,只要表达式允许。

3.1 rvalue(右值)

  • rvalue不能由取址运算符来取地址:比如&int()、&i++[3]、&42 及 &std::move(x) 都是是非法的。
  • rvalue不能作为赋值运算符及复合赋值运算符的左操作数。
  • rvalue可以用来初始化 const 左值引用,这种情况下该rvalue所标识的对象的生存期被延长到该引用的作用域末尾。
  • rvalue可以用来初始化右值引用,这种情况下该右值所标识的对象的生存期被延长到该引用的作用域结尾。(C++11起)
  • 当rvalue被用作函数实参且该函数有两种重载,其中一个的形参是右值引用,而另一个的形参是 const 的左值引用,右值将被绑定到右值引用的重载版本上(因此,当复制与移动构造函数均可用时,用rvalue调用到的会是移动构造函数而不是复制构造函数,复制和移动赋值运算符类似)。(C++11起)

四、左值引用和右值引用

上面的定义里提到了左值引用和右值引用,这里就来简单介绍下。如果只是说引用,用C++的程序员肯定不陌生,但大部分人对引用的认识应该停留在是变量的别名,类似指针,传引用属于传地址有利于提高性能等,并不会太深究。

首先来看下引用声明的的定义:声明一个具名变量作为引用,也就是已经存在的对象或者函数的别名,也就是用一个引用变量指代已有的东西。引用在声明的时候就必须绑定到对象,既然值类别有左值右值之分,也就是已经存在的对象有左右值之分,理所应当的,引用也应该有,于是在C++11之后引用也分为了左值引用和右值引用,关于引用声明和初始化的细节可以参照reference和reference_initialization。

这里想说的是,左值引用和右值引用是两种不同的引用类型,他们可以绑定到的具体对象是由对象的值类型决定的,按照直觉左值引用应该可以绑定到glvalue,右值引用可以绑定到rvalue,比如:

int a = 0;
int& b = a;
int&& c = 27;

从定义上来看我们也能看到整体上来说是这样的,但是也有些例外,可以为我们的编程带来便利,比如上面的关于左右值的属性就讲到了,rvalue可以用来初始化const 左值引用。

五、一些特殊类别

上面的定义覆盖了绝大部分的表达式,但还有一些边角情况需要单独定义,如下:

5.1 未决成员函数调用(Pending member function call)

对于表达式 a.mf 和 p->mf,其中 mf 是非静态成员函数,以及表达式 a.*pmf 和 p->*pmf,其中 pmf 是成员函数指针,被归类为prvalue表达式,但它们除了作为函数调用运算符的左操作数(比如 (p->*pmf)(args))以外,不能用来初始化引用、作为函数实参等其他任何目的。

5.2 Void表达式

返回 void 的函数调用表达式,cast到 void 的类型转换表达式,以及抛异常表达式(throw-expressions),被归类为纯右值表达式,但它们不能用来初始化引用或者作为函数实参。它们可以用在舍弃值的语境(discarded-value contexts,例如自成一行,作为逗号运算符的左操作数等)和返回 void 的函数中的 return 语句中。另外,throw 表达式可用作条件运算符 ?: 的第二个和第三个操作数。
另外,从C++17起,void 表达式没有结果对象。

5.3 位域(Bit fields)

位域可以通俗地理解为可以缩减位数整型类数据成员,比如:

#include 
 
struct S
{
    // three-bit unsigned field, allowed values are 0...7
    unsigned int b : 3;
};
 
int main()
{
    S s = {6};
 
    ++s.b; // store the value 7 in the bit-field
    std::cout << s.b << '\n';
 
    ++s.b; // the value 8 does not fit in this bit-field
    std::cout << s.b << '\n'; // formally implementation-defined, typically 0
}

代表某个位域的表达式(例如 a.m,其中 a 是类型 struct A { int m: 3; } 的左值)是glvalue表达式:它可用作赋值运算符的左操作数,但它不能被取地址,并且非 const 的左值引用不能绑定于它。const 左值引用或右值引用可以从位域泛左值初始化,但这会创建位域的一个临时副本,而不会直接绑定到位域。

六、区分值类别的作用

各种类型的值类别我们已经搞清楚了,那么C++定义这些值类别有什么意义呢?或者说能给我们的编码提供什么样的能力、我们在写程序的过程中会用到这些特性来做什么呢?答案很明确,那就是C++11之后引入的移动语义(Move semantics)。
利用移动语义,我们可以方便的进行资源的转移,从而节省拷贝开销、实现智能指针所有权的转移等。虽然左右值的概念并不是C++11才提出的,但是有了正式的移动语义的定义后,左右值在我们平时的编码中才真正有了用武之地,这个系列接下来我们会详细聊聊std::forward、std::move、移动构造移动赋值等,这里先不展开。我们再来回顾下左右值的定义,对于右值,包括rvalue和xvalue,大体上要么是用过了不需要再用的变量,要么是临时定义的字面量之类的,这些值都是可以复用的,比如我要把一个临时的vector放到另一个结构里去,大可不必要把内容复制一遍,只需要把底层的指针还过去就好,在对象很大的时候能极大提高性能表现,这也就涉及到了移动拷贝和移动构造。

七、编程语言值类别发展历史

前面说了并不是C++ 11引入的值类别,甚至都不是C++首次提出的,下面我们就来看下值类别的发展史。

7.1 CPL

CPL语言首次为表达式引入了值类别:所有 CPL 表达式都能以“右侧模式 (right-hand mode)”求值,但只有某些类型的表达式在“左侧模式 (left-hand mode)”有意义。在右侧模式中求值时,表达式被当做一条进行值的计算(右侧值,或右值)的规则。在左侧模式中求值时,表达式的效果是给出一个地址(左侧值,或左值)。“左”和“右”代表“赋值之左”和“赋值之右”。

7.2 C

C 语言遵循相似的分类法,但赋值的作用不再重要:C语言的表达式被分为“左值 (lvalue) 表达式”和其他(函数和非对象值),其中“左值 (lvalue)”的含义为标识一个对象的表达式,即“定位器值 (locator value)”。

7.3 C++98

2011 年前的 C++ 遵循 C 模型,但恢复了对非左值表达式的“右值 (rvalue)”称呼,把函数归类为左值,并添加了引用能绑定到左值但只有 const 的引用能绑定到右值的规则。几种非左值的 C 表达式在 C++ 中成为了左值表达式。

7.4 C++11

随着移动语义引入到 C++11 之中,值类别被重新进行了定义,以区别以下两种表达式的互相独立的性质:

  • 拥有身份 (identity):可以确定表达式是否与另一表达式指代同一实体,例如通过比较它们所标识的对象或函数的(直接或间接获得的)地址;
  • 可被移动:移动构造函数、移动赋值运算符或实现了移动语义的其他函数重载能够绑定到这个表达式。

C++11 中:

  • 拥有身份且不可被移动的表达式被称作左值 (lvalue)表达式;
  • 拥有身份且可被移动的表达式被称作将亡值 (xvalue)表达式;
  • 不拥有身份且可被移动的表达式被称作纯右值 (prvalue)表达式;
  • 不拥有身份且不可被移动的表达式未被使用,这是因为这类其实也就是const prvalue 和const xvalue,虽然可以绑定到const T&&,但不能修改在实际使用中没有意义。
  • 拥有身份的表达式被称作“泛左值 (glvalue) 表达式”。左值和亡值都是泛左值表达式。
  • 可被移动的表达式被称作“右值 (rvalue) 表达式”。纯右值(prvalue)和将亡值(xvalue)都是右值表达式。

7.5 C++17

C++17 中,某些场合强制要求进行复制消除,而这要求将纯右值表达式从被它们所初始化的临时对象中分离出来,这就是我们现有的体系。需要注意的是,与相比较于C++11 的方案,prvalue不再可被移动。

参考:
https://en.cppreference.com/w/cpp/language/value_category

你可能感兴趣的