C++ Template: 值类别 Value Categories

整理 C++ 左值、右值、glvalue、prvalue、xvalue、临时对象具现化和 decltype 值类别判断。

表达式 是 C++ 语言的基石,提供了表达计算的主要机制,每个表达式都有一个 类型 type 和 值类别 Value Categories

类型 描述了其计算产生的 值 的 静态类型,例如,表达式 7 的类型为 int,表达式 5 + 2 的类型也为 int,如果 x 是 int 类型的变量,表达式 x 的类型也为 int

而 值类别 则影响 表达式 的行为方式,尤其是在 对象生命周期、资源管理 和 重载解析 等方面,它用于描述该 表达式 的 值 如何产生,以及它在 表达式 求值中的作用

传统左值和右值 Traditional Lvalues and Rvalues

在 C++11 之前,C++ 只有 两种值类别

  1. 左值 Lvalue, Left value:表示可以被 定位 localizable 的值,通常指向存储在 内存 或 寄存器 中的数据,并且可以被修改(除非 const 修饰)

  2. 右值 Rvalue, Right value:表示纯计算产生的值,不能被修改,仅用于计算目的

传统的左值 Lvalue

传统的 左值 是仅仅是指可以出现在 赋值表达式左侧 的值,通常表示 持久化 的存储位置

int x = 5; // x 是一个左值
x = 7;     // OK,x 仍然可以修改

传统 (C语言中) 的右值 Rvalue

传统的 右值 是仅仅是指可以出现在 赋值表达式右侧 的值,一般是 临时值,用于计算,不能获取地址,也 不能直接被修改

int a = 5 + 2;  // 5+2 计算结果是一个右值
int b = a * 3;  // a * 3 计算结果也是右值

C++ 现代化的改变

在 C 语言标准化(1989 年)后,C++ 进一步拓展了左值和右值的概念

  1. const 修饰的变量仍然是 左值,因为它们有存储位置,但不可修改

    int const x = 5;  // x 是一个不可修改的左值
    x = 7;            // 错误!左侧需要一个可修改的左值

    x 是左值,因为它有确定的内存位置,可以取地址 &x,但由于 const 修饰,x 不能被修改,因此不允许出现在赋值号的左侧

  2. 类 的 右值 可以出现在 赋值 的 左侧,因为 赋值 变成了 成员函数调用
    因为 C++ 相比 C 引入了 类 Class,这改变了传统 赋值 的概念:类 的 右值 也可以出现在 赋值号左侧,因为 类 的 赋值 操作通常调用 赋值运算符重载,这其实是一次 函数调用,遵循的是 函数调用 规则

    struct MyClass {
        MyClass& operator=(const MyClass&) {
            return *this;
        }
    };
     
    MyClass foo() { return MyClass(); }
     
    foo() = MyClass();  // 合法,调用 operator=

    这就是为什么 右值 也能出现在赋值 左侧,因为 类 的赋值不是简单的 修改内存,而是调用类的 成员函数(即 operator=)

左值

由于以上变化,C++ 标准现在将 lvalue 定义为:Localizable Value 可定位的值,即可以 取地址 的值,它们不再仅仅是 变量,还包括

  • 指针解引用
    int y = 10;
    int* p = &y;
    *p = 20;  // *p 是左值
  • 类成员访问
    struct Data { int value; };
    Data d;
    d.value = 30;  // d.value 是左值
  • 返回左值引用的函数
    std::vector<int> v{1, 2, 3};
    v.front() = 100;  // v.front() 是左值
  • 字符串字面量
    const char* str = "hello";
    // "hello" 是一个不可修改的左值

也就是说引用 变量 的表达式并不是唯一一种 左值表达式 了,还有 引用存储在指针的地址中的值 (解指针操作,例如 *P)以及 引用类对象成员的表达式 (例如 p->data), 甚至 调用返回用 & 声明的“传统”左值引用类型的值的函数 也是 左值表达式

右值

右值 自然是 不可定位的值,即 没有持久的存储位置 或者说是 临时值,具有 短生命周期 且 不能被修改 的值,一般用于计算,一旦使用了就不能再引用

例如 纯数学值

  • 字面量
    如 7, 'a', nullptr

  • 计算表达式的结果
    x + 5

  • 所有的临时对象

    int x = 10;
    int y = x + 5;  // x+5 是右值

注意它 不能被修改

(x + 5) = 20;  // 错误!右值不能被修改

函数返回的 非引用值 也是 右值

std::string getName() {
    return "Zeno";
}

左值到右值的转换 Lvalue-to-Rvalue Conversions

在 C++ 中,左值 到 右值 转换是一个 隐式转换,它会将一个 左值 转换为 同类型 的 右值,主要通过读取 左值 所关联的 内存 或 寄存器 来完成,这是 C++ 表达式求值 机制的基础之一

为什么需要左值到右值的转换?

  • 右值 的短暂性决定了它只能出现在赋值运算符的右侧,比如,7 = 8 这样的语句是没有意义的,因为 7 是一个不可修改的 右值常量

  • 左值 通常是变量,可以修改并持久存在,像 x = y 这样的赋值是合法的,即使 x 和 y 都是 左值

int x = 5;     // x 是左值
int y = 10;    // y 是左值
x = y;         // y 发生了左值到右值的转换

在 x = y; 中,y 是左值,但它被隐式地转换成右值,即读取 y 的当前值,然后将这个 右值 赋给 x

所以 左值 到 右值 的转换使 左值 可以在需要 右值 的地方使用

int x = 10;
int y = x;      // x 是左值,转换为右值,赋值给 y
int z = x + 5;  // x 是左值,转换为右值,进行加法运算

y = x; 中,x 是 左值,转换为 右值,实际为 “读取 x 当前的存储值”,而在 z = x + 5; 中,x 作为 左值被 “读取”,然后与 5 进行加法运算,结果是 右值

并且它将告诉 编译器 何时生成 “加载” 指令,编译器 会在需要读取变量值的时候,插入相应的内存读取指令

int a = 10;
int b = a + 1;  // 编译器会在这里插入从 `a` 的内存位置读取值的指令

左值到右值转换的限制

  • 常量左值 可以进行 左值 到 右值 的转换,但不能被修改

    const int a = 5;
    int b = a;    // 可以转换成右值
    a = 10;       // 错误!a 是不可修改的左值
  • 引用类型 不进行 左值 到 右值 转换

    int x = 10;
    int& ref = x;  // ref 是 x 的左值引用
    int y = ref;   // ref 先转换为右值,读取 x 的值

    但 ref 本质上只是 x 的别名,它在需要右值时会读取 x 的值

所以说,左值 到 右值 转换会读取内存,这会生成相应的 “加载” 指令,从变量的存储位置读取数据,在现代编译器的优化下,许多 “加载” 操作会被优化掉,例如寄存器重用,避免多次加载同一变量

C++11 以来的值类别

C++11 引入了 右值引用(&&) 以支持 移动语义,这使得传统的 左值 和 右值 划分不再足够描述所有情况,因此,C++ 标准委员会重新设计了值类别系统,引入了 三种核心类别 和 两种复合类别

三种核心类别

Lvalue(左值)
表示对象或函数的 标识符,即可以被 取地址 的 表达式,例如 变量名、指针解引用、类成员访问、返回左值引用的函数调用

int x = 5;    // x 是左值
int* p = &x;  // *p 是左值

具体而言,具有对象身份的表达式包括

  • 变量名:例如,int x = 5; 中的 x 是一个具有 对象标识符 的 表达式

  • 指针解引用:例如,int* p = &x; *p = 10; 中的 *p 是一个具有 对象标识符 的 表达式,因为它指向一个具体的内存位置

  • 数组元素访问:例如,int arr[3] = {1, 2, 3}; arr[0] = 10; 中的 arr[0] 是一个具有 对象标识符 的 表达式

  • 类成员访问:例如,struct S { int m; }; S s; s.m = 5; 中的 s.m 是一个具有 对象标识符 的 表达式

  • 函数调用返回左值引用:例如,int& func(); func() = 5; 中的 func() 是一个具有 对象标识符 的 表达式


Prvalue(纯右值)
用于 初始化对象 或 计算操作数 的 表达式,通常 没有存储地址 并具有 可移动性,例如 字面量(如 7、'a')、内置运算符(如 x + 5)、返回非引用类型的函数调用、Lambda 表达式

int y = 10 + 5;   // 10+5 是 prvalue
auto f = []() {}; // Lambda 表达式是 prvalue

Xvalue(亡值)
表示即将 “过期” 的对象,其资源可以被 重用,通常用于 移动语义,例如 std::move(x)、返回 && 右值引用的函数

std::string str1 = "hello";
std::string str2 = std::move(str1);  // std::move(str1) 是 xvalue

两种复合类别

Glvalue(泛左值) 包括 Lvalue 和 Xvalue , 表示具有 对象标识符 的 表达式

Rvalue(右值) 包括 Prvalue 和 Xvalue,表示可以被 移动 的 表达式

可以看到,将亡值 既属于 泛左值,也属于 右值,这是因为 将亡值 兼具两者的特性

  • 对象标识符 Identity:将亡值 指向特定的 对象,具有明确的 存储地址,因此属于 泛左值
  • 可移动性 Movability:将亡值 表示即将被 销毁 的 对象,其资源可以被 安全地移动,因此也被视为 右值

这种双重归类有助于编译器和程序员在处理表达式时,准确地应用移动语义和资源管理策略,例如,std::move 函数将一个 左值 转换为 将亡值,指示可以 移动其资源

#include <utility>
#include <string>
 
int main() {
    std::string str = "Hello, World!";
    std::string moved_str = std::move(str); // std::move(str) 生成一个将亡值
    return 0;
}

在上述代码中,std::move(str) 生成一个 将亡值,既具有对象身份(指向 str),又可用于移动操作

C++17

C++17 中,值类别又进行了进一步表述,但是首先我们需要了解什么是 位域 bit‐field

在 C++ 中,位域 指的是 结构体 或 联合体 中声明的 成员,其所占用的 存储空间 精确定义为若干个 位 bit,而不是整个字节或更大的基本类型,例如,可以这样声明一个 位域

struct Example {
    unsigned int flag : 1;  // 只占用 1 个比特
    unsigned int value : 3; // 占用 3 个比特,能表示 0~7 之间的数
};

在这个例子中,flag 和 value 就是 位域,位域常用于内存紧凑的数据表示或者需要精确控制二进制数据布局的场景

然后,我们可以看看 C++17 如何表述不同的值类别

  • glvalue 泛左值:是一个 表达式,其求值决定了 对象,位域 或 函数 (即具有 存储 的实体) 的 标识符

  • prvalue 纯右值:是一个 表达式,其求值 初始化 一个 对象 或一个 位域,或者计算一个 运算符 的操作数的值

  • xvalue 将亡值:是一个 glvalue,但它表示一个 对象 或 位域 的 资源 可以被重用(通常是因为它即将 过期,xvalue 中的 “x” 最初来自 “eXpiring value” 中)

  • lvalue 左值:是一个 不是 xvalue 的 glvalue

  • rvalue 右值:是一个 表达式,它要么是 prvalue 要么是 xvalue

除了位域,glvalues 生成带有地址的实体,该地址可能是更大封闭对象的子对象的地址,对于基类 子对象 来说,泛左值(表达式)的类型称为其 静态类型,而该基类所属的 最派生对象 的类型称为泛左值的动态类型,如果 glvalue 不产生基类子对象,则其 静态 和 动态类型相同(即表达式的类型)

也就是说,glvalue(广义左值)有两个重要特性

  1. 具有 地址 的实体
    除了位域外,所有 glvalue 表达式都对应一个在内存中 有地址 的实体,这个 地址 可能是整个对象的地址,也可能只是这个对象内部某个子对象(例如成员变量)的 地址,也就是说,当你对一个对象的成员取地址时,实际上得到的是那个成员在整体对象内的 地址

  2. 静态类型 与 动态类型 的区分
    如果 glvalue 表达式产生的是一个 基类子对象,那么

    • 静态类型:就是 表达式 本身在代码中声明的类型,即编译器在 编译时 所知道的 类型(基类类型)
    • 动态类型:是这个基类子对象所属的最派生(实际)对象的类型,即运行时 对象真正的 类型

    如果 glvalue 表达式不产生基类子对象(比如直接表达整个对象或非基类成员),那么其静态类型和动态类型是相同的,都等于表达式的类型

这种区分对于理解 多态 和 对象模型 非常重要,例如,当你通过基类引用或指针操作一个派生类对象时,虽然编译器看到的是 基类(静态类型),但实际对象可能是 派生类(动态类型),这影响了运行时的行为(比如虚函数调用)

我们来看这样一个例子

int x = 3; 
int y = x; 

在 int x = 3; 中

  • 右侧的字面量 3 是一个 prvalue,它只代表一个具体的数值,没有地址
  • 这个 prvalue 用来初始化变量 x,此时,x 作为被声明的对象,并不是作为表达式被求值,因此我们说 “x 是一个变量”,而不是说 “x 是一个 lvalue”

在 int y = x; 中

  • 右侧的 x 是一个 lvalue,因为它引用了之前已经存在的对象(变量 x),该对象有一个确定的地址
  • 当这个 lvalue 被求值时,它并不会直接产生数值 3,而是 “指定” 了对象 x
  • 接着,会对这个 lvalue 进行 lvalue-to-rvalue 转换,提取出 x 所存储的值(即 3),这个转换后的结果是一个 prvalue,正好用来初始化 y

临时对象具现化 temporary materialization

在 C++17 中,引入了 临时对象具现化(temporary materialization) 的概念,以优化对象的创建和初始化过程,这项机制旨在推迟临时对象的创建,直到确实需要时才进行,从而提高程序的性能和效率

回想一下,prvalues 纯右值 是用于 初始化 对象的 表达式 类别,所以 当一个 纯右值(prvalue)出现在需要 泛左值(glvalue)的上下文中时,编译器会创建一个 临时对象,并将该 纯右值 用于 初始化 此 临时对象 (这正是本段开头提及的纯右值的主要功能)随后,纯右值 被替换为指向该 临时对象 的 将亡值(xvalue)

这种操作的主要目的是在需要对象身份的上下文中,将 纯右值 转换为具有存储位置的对象(即将 纯右值 “具现化” 为 将亡值),这确保了表达式在需要引用 或 指针 的场景中能够正常工作

例如

int f(int const&);
int r = f(3);

这里 int f(int const&); 是一个需要 引用 作为参数的函数,它期望一个 glvalue 泛左值作为它实际的输入参数,但是表达式中的 3 是 prvalue 纯右值,所以这里 临时对象具现化 发生了,表达式 3 被转换为一个 xvalue 将亡值,也就是指定了一个用值 3 初始化的临时对象

更一般地,在下列情况下,临时变量 将被 具现化

  • 绑定 引用 到 纯右值
    当一个纯右值绑定到引用时,会触发临时对象的创建

    int f(const int&);
    f(3); // 3 是纯右值,被绑定到 f 的参数,触发临时对象具现化
  • 访问类类型纯右值的 非静态成员
    当通过 纯右值 访问类的 非静态成员 时,需要先创建临时对象

    struct S { int m; };
    int i = S().m; // S() 是纯右值,访问其成员 m 时触发临时对象具现化
  • 对 数组纯右值 进行 下标操作 或 数组到指针 的转换
    当对数组纯右值进行下标访问或将其转换为指针时,会触发临时对象的创建

    int arr[3] = {1, 2, 3};
    int* p = arr + 1; // arr 是纯右值,进行指针运算时触发临时对象具现化
  • 纯右值 出现在 大括号初始化列表 中
    当 纯右值 用于初始化 std::initializer_list 时,会触发 临时对象 的创建

    std::initializer_list<int> il = {1, 2, 3}; // 大括号内的纯右值触发临时对象具现化
  • 对纯右值应用 sizeof 或 typeid 操作符
    在这些情况下,需要对纯右值进行求值,从而触发临时对象的创建

    struct S {};
    size_t size = sizeof(S()); // S() 是纯右值,应用 sizeof 时触发临时对象具现化

因此,在 C++17 中,由 prvalue 初始化的对象始终由上下文决定,因此,只有在真正需要时才会创建临时变量,在 C++17 之前,prvalue(尤其是类类型的)始终隐含临时变量,即使最终可能会被优化掉,这导致了不同 C++ 标准对于 复制构造函数 和 移动构造函数 可用性的不同要求

例如下面这个例子

class N {
public:
    N();
    N(N const&) = delete; // 该类既不可拷贝 …
    N(N&&) = delete; // … 也不可移动
};
N make_N() {
    return N{}; // 在 C++17 之前始终创建概念临时对象
} // 在 C++17 中,此时不会创建任何临时对象
 
auto n = make_N(); // 这在 C++17 之前是错误的,因为 prvalue 需要拷贝构造函数,但是这从 C++17 开始是可以的,因为 n 是直接从 prvalue 初始化的。

在 C++17 之前,编译器可以选择性地省略临时对象的拷贝或移动操作(称为“拷贝省略”),但这 并非强制要求,因此,即使编译器在实践中通常会进行此类优化,仍需要确保被复制或移动的对象具备可访问的拷贝或移动构造函数

在我们的例子中,类 N 的 拷贝构造函数 和 移动构造函数 都被显式删除(= delete),使得该类既 不可复制 也 不可移动,在 C++11 和 C++14 标准下,尽管编译器可能会优化掉对临时对象的拷贝或移动,但它们仍需验证这些操作是可行的,由于 N 的拷贝和移动构造函数被删除,编译器会在尝试执行这些验证时产生错误

从 C++17 开始,标准引入了 强制性拷贝省略 Mandatory Copy Elision,在特定情况下(例如函数返回临时对象时),编译器 必须直接在调用者的上下文中构造对象,而无需进行拷贝或移动操作,这意味着,即使类的拷贝和移动构造函数被删除,代码也能正常编译,因为不再需要这些构造函数

也就是说,标准保证了 make_N() 产生的 N{} 临时对象必须在 auto n 的存储中直接构造,而无需复制或移动操作,所以编译器也就无需检查 N 的 拷贝构造函数 和 移动构造函数 是否存在了,它们不被需要

也就是说,从 C++17 开始 右值 不再一定产生临时变量

因此,在 C++17 之前,上面的代码会导致编译错误,因为编译器需要验证拷贝或移动构造函数的可用性,而在 C++17 及之后的版本中,由于强制性拷贝省略的引入,编译器直接在目标位置构造对象,无需调用拷贝或移动构造函数,从而避免了此类错误

我们用一个例子来总结

We conclude with an example that shows a variety of value category situations:
Click here to view code image
class X {};
X v;
X const c;
void f(X const&); // 接受任何值类别的表达式
void f(X&&); // 仅接受 prvalues 和 xvalues,但比前一个声明匹配优先级更高 (匹配 prvalues 和 xvalues 时)
f(v); // 将可修改的左值传递给第一个 f() 声明
f(c); // 将不可修改的左值传递给第一个 f() 声明
f(X()); // 将 prvalue(自 C++17 起 具现化 为 xvalue)传递给第二个 f() 声明
f(std::move(v)); // 将 xvalue 传递给第二个 f() 声明

使用 decltype 检查值类别

使用关键字 decltype (自 C++11 起引入),可以检查任何 C++ 表达式的 值类别

对于任何表达式 x,使用 decltype((x)) 注意使用双圆括号,将会产生

  • type 若 x 是 纯右值 prvalue
  • type& 若 x 是 左值 lvalue
  • type&& 若 x 是 将亡值 xvalue

decltype((x)) 中需要 双括号,以避免在表达式 x 确实命名一个实体的情况下产生命名实体的声明类型(在其他情况下,括号不起作用)

例如,如果表达式 x 只是命名一个变量 v,那么没有括号的构造就变成 decltype(v),它会产生变量 v 的类型,而不是反映引用该变量的表达式 x 的值类别的类型

因此,使用任何表达式 e 的 类型特征 type traits,我们可以按如下方式检查其值类别

if constexpr (std::is_lvalue_reference<decltype((e))>::value) {
    std::cout << "expression is lvalue\n";
}
else if constexpr (std::is_rvalue_reference<decltype((e))>::value) {
    std::cout << "expression is xvalue\n";
}
else {
    std::cout << "expression is prvalue\n";
}

引用类型 Reference Types

C++ 中的 引用类型,例如 int& 与 表达式值类型 以两种重要方式发生交互

  • 引用 限制它可以绑定的 表达式的值类别
    例如,类型为 int& 的非 const 左值引用 只能用类型为 int 的 左值 的表达式来初始化;同样,类型为 int&& 的 右值引用 只能用类型为 int 的 右值 的表达式来初始化

  • 函数的返回类型
    表达式值类别 与 引用 交互的第二种方式是与 函数的返回类型 交互,其中使用 引用 类型作为 返回类型 会影响对该函数的调用的 值类别

    • 调用返回类型为 左值引用 的函数将产生一个左值

    • 调用返回类型为 右值引用 且引用的是 对象类型 的函数将产生一个 xvalue(函数类型的 右值引用 总是产生 左值)

    • 调用返回 非引用类型 的函数会产生一个 纯右值

例如,如果我们有

int& lvalue();
int&& xvalue();
int prvalue();

给定表达式的 值类别 和 类型 都可以通过 decltype 确定,就像前文所说的,它使用 引用类型 来描述 表达式 是 左值 还是 将亡值

std::is_same_v<decltype(lvalue()), int& // 因为结果是左值,所以结果为 true
std::is_same_v<decltype(xvalue()), int&&> // 得出 true 因为结果是 xvalue
std::is_same_v<decltype(prvalue()), int> // 因为结果是纯右值,所以结果为 true

因此,下列是所有可能的调用

int& lref1 = lvalue(); // OK:左值引用可以绑定到左值
int& lref3 = prvalue(); // 错误:左值引用无法绑定到 prvalue
int& lref2 = xvalue(); // 错误:左值引用无法绑定到 xvalue
int&& rref1 = lvalue(); // 错误:右值引用无法绑定到左值
int&& rref2 = prvalue(); // OK:右值引用可以绑定到右值
int&& rref3 = xvalue(); // OK:右值引用可以绑定到 xrvalue