12-2 值类别(左值和右值)

在我们讨论第一个复合类型(左值引用)之前,我们先稍微绕一下,谈谈什么lvalue是左值引用。

在第1.10 课——表达式简介中,我们将表达式定义为“字面量、变量、运算符和函数调用的组合,可以执行以产生一个单一值”。

例如:

#include <iostream>

int main()
{
    std::cout << 2 + 3 << '\n'; // The expression 2 + 3 produces the value 5

    return 0;
}

在上面的程序中,表达式2 + 3被求值,产生值 5,然后该值被打印到控制台。

在第 6.4 课——递增/递减运算符和副作用中,我们也注意到表达式可能会产生比表达式本身持续时间更长的副作用:

#include <iostream>

int main()
{
    int x { 5 };
    ++x; // This expression statement has the side-effect of incrementing x
    std::cout << x << '\n'; // prints 6

    return 0;
}

在上面的程序中,表达式++x会递增 x 的值,即使表达式求值完毕,该值也会保持不变。

除了产生值和副作用之外,表达式还可以做一件事:它们可以求值为对象或函数。我们稍后会进一步探讨这一点。

表达式的性质

为了帮助确定表达式的求值方式以及它们可以在何处使用,C++ 中的所有表达式都具有两个属性:类型和值类别。

表达式的类型

表达式的类型等同于表达式求值后得到的值、对象或函数的类型。例如:

int main()
{
    auto v1 { 12 / 4 }; // int / int => int
    auto v2 { 12.0 / 4 }; // double / int => double

    return 0;
}

对于v1,编译器会在编译时确定,两个int操作数的除法运算会产生一个int结果,因此int为该表达式的类型 。通过类型推断,int将被用作v1的类型。

对于v2,编译器会在编译时确定一个double操作数和一个int操作数的除法运算会产生double结果。请记住,算术运算符的操作数必须类型匹配,因此在这种情况下,int操作数会被转换为 double,并执行浮点除法。所以double为该表达式的类型 。

编译器可以利用表达式的类型来判断表达式在给定上下文中是否有效。例如:

#include <iostream>

void print(int x)
{
    std::cout << x << '\n';
}

int main()
{
    print("foo"); // error: print() was expecting an int argument, we tried to pass in a string literal

    return 0;
}

在上述程序中,该print(int)函数需要一个int参数。然而,我们传入的表达式(字符串字面量)的类型"foo"不匹配,找不到合适的类型转换。因此,会产生编译错误。

请注意,表达式的类型必须在编译时可确定(否则类型检查和类型推断将无法工作)——但是,表达式的值可以在编译时(如果表达式是 constexpr)或运行时(如果表达式不是 constexpr)确定。

表达式的值类别

现在考虑以下程序:

int main()
{
    int x{};

    x = 5; // valid: we can assign 5 to x
    5 = x; // error: can not assign value of x to literal value 5

    return 0;
}

这两个赋值语句中,一个有效(将值5赋给变量x),另一个无效(将值x赋给字面值5是什么意思?)。那么,编译器如何知道赋值语句两侧可以合法出现哪些表达式呢?

答案在于表达式的第二个属性:值类别value category。表达式(或子表达式)的值类别指示表达式是解析为值、函数还是某种对象。

在 C++11 之前,只有两种可能的值类别:lvalue和rvalue。

在 C++11 中,为了支持名为 移动语义(move semantics)的新特性,添加了三个额外的值类别( glvalue、prvalue和xvalue) 。

作者注:
在本课中,我们将继续使用 C++11 之前的版本来介绍值范畴,这样可以更轻松地引入值范畴的概念(而且目前也足够了)。我们将在后续章节中讲解移动语义(以及其他三个值范畴)。

左值和右值表达式

左值(读作“ell-value”,是“left value”或“locator value”的缩写,有时也写作“l-value”)是一个表达式,其值等于可识别的对象或函数(或位域)。

C++ 标准中使用了“标识(identity)”一词,但其定义并不明确。具有标识的实体(例如对象或函数)可以与其他类似实体区分开来(通常是通过比较实体的地址)。

具有标识的实体可以通过标识符、引用或指针访问,并且其生命周期通常比单个表达式或语句更长。

int main()
{
    int x { 5 };
    int y { x }; // x is an lvalue expression

    return 0;
}

在上面的程序中,该表达式x是一个左值表达式,因为它计算出的值为一个变量x(该变量具有标识符)。

自从语言中引入常量以来,左值分为两种子类型:可修改左值是指其值可以修改的左值。不可修改左值是指其值不能修改的左值(因为该左值是 const 或 constexpr)。

int main()
{
    int x{};
    const double d{};

    int y { x }; // x is a modifiable lvalue expression
    const double e { d }; // d is a non-modifiable lvalue expression

    return 0;
}

右值(读作“arr-value”,是“right value”的缩写,有时也写作“ r-valuervalue”)是一种非左值的表达式。右值表达式的求值结果为一个值。常见的右值包括字面量(C 风格的字符串字面量除外,它们是左值)以及按值返回的函数和运算符的返回值。右值不可识别(这意味着它们必须立即使用),并且仅存在于它们所在的表达式的作用域内。

int return5()
{
    return 5;
}

int main()
{
    int x{ 5 }; // 5 is an rvalue expression
    const double d{ 1.2 }; // 1.2 is an rvalue expression

    int y { x }; // x is a modifiable lvalue expression
    const double e { d }; // d is a non-modifiable lvalue expression
    int z { return5() }; // return5() is an rvalue expression (since the result is returned by value)

    int w { x + 1 }; // x + 1 is an rvalue expression
    int q { static_cast<int>(d) }; // the result of static casting d to an int is an rvalue expression

    return 0;
}

你可能想知道为什么return5()、x + 1和static_cast(d)是右值:答案是,因为这些表达式会产生临时值,这些值不是可识别的对象。

关键见解:
左值表达式求值为一个可识别的对象。
右值表达式求值为一个值。

值类别和运算符

除非另有规定,运算符期望其操作数为右值。例如,binary 运算operator+符期望其操作数为右值:

#include <iostream>

int main()
{
    std::cout << 1 + 2; // 1 and 2 are rvalues, operator+ returns an rvalue

    return 0;
}

字面量1和2都是右值表达式。operator+将很乐意使用它们来返回右值表达式3。

现在我们可以回答为什么 x = 5 有效而 5 = x 无效的问题了:赋值运算要求其左操作数是可修改的左值表达式。后一个赋值( 5 = x)失败,是因为其左操作数表达式5是右值,而不是可修改的左值。

左值到右值的转换

由于赋值运算要求右操作数是右值表达式,你可能会想知道为什么以下代码可以运行:

int main()
{
    int x{ 1 };
    int y{ 2 };

    x = y; // y is not an rvalue, but this is legal

    return 0;
}

当需要右值但实际提供的是左值时,该左值会进行左值到右值的转换,以便在需要时使用。这实际上意味着对左值进行求值,得到其对应的右值。

在上面的例子中,左值表达式y经历了左值到右值的转换,计算结果y生成一个右值(2),然后将其赋值给x。

关键见解:
左值会隐式转换为右值。这意味着左值可以用于任何需要右值的地方。
相反,右值不会隐式转换为左值。

现在考虑以下示例:

int main()
{
    int x { 2 };

    x = x + 1;

    return 0;
}

在此语句中,变量x在两个不同的上下文中被使用。在赋值运算符的左侧(需要左值表达式),x是一个计算结果为变量x的左值表达式。在赋值运算符的右侧,x变量经历了左值到右值的转换,然后被求值,使其结果(2)可以用作赋值运算符operator的右操作数。operator+返回右值表达式3,然后将其用作赋值运算符的右操作数。

如何区分左值和右值

你可能仍然对哪些表达式属于左值、哪些属于右值感到困惑。例如,以下哪个表达式operator++的结果属于左值还是右值?我们将在此介绍几种可以用来判断左值和右值的方法。

小技巧:
识别左值表达式和右值表达式的一个经验法则:

  • 左值表达式是指那些求值为函数或可识别对象(包括变量)的表达式,这些对象在表达式结束后仍然存在。
  • 右值表达式是指那些求值为值的表达式,包括字面值和临时对象,这些对象在表达式结束后不会继续存在。

有关左值和右值表达式的更完整列表,您可以查阅技术文档。

小技巧:
左值和右值表达式的完整列表可以在这里找到。在 C++11 中,右值分为两种子类型:prvalues 和 xvalues,因此我们这里讨论的右值是这两类值的总和。

最后,我们可以编写程序,让编译器告诉我们某个表达式是什么类型。以下代码演示了一种判断表达式是左值还是右值的方法:

#include <iostream>
#include <string>

// T& is an lvalue reference, so this overload will be preferred for lvalues
template <typename T>
constexpr bool is_lvalue(T&)
{
    return true;
}

// T&& is an rvalue reference, so this overload will be preferred for rvalues
template <typename T>
constexpr bool is_lvalue(T&&)
{
    return false;
}

// A helper macro (#expr prints whatever is passed in for expr as text)
#define PRINTVCAT(expr) { std::cout << #expr << " is an " << (is_lvalue(expr) ? "lvalue\n" : "rvalue\n"); }

int getint() { return 5; }

int main()
{
    PRINTVCAT(5);        // rvalue
    PRINTVCAT(getint()); // rvalue
    int x { 5 };
    PRINTVCAT(x);        // lvalue
    PRINTVCAT(std::string {"Hello"}); // rvalue
    PRINTVCAT("Hello");  // lvalue
    PRINTVCAT(++x);      // lvalue
    PRINTVCAT(x++);      // rvalue
}

打印出来的内容:

img

这种方法依赖于两个重载函数:一个带有左值引用参数,另一个带有右值引用参数。对于左值参数,优先使用左值引用版本;对于右值参数,优先使用右值引用版本。因此,我们可以根据选择哪个函数来判断参数是左值还是右值。

所以正如你所看到的,operator++结果是左值还是右值取决于它是用作前缀运算符(返回左值)还是后缀运算符(返回右值)!

适合高级读者:
与其他字面量(右值)不同,C 风格的字符串字面量是左值,因为 C 风格的字符串(实际上是 C 风格的数组)会退化为指针。这种退化过程只有在数组是左值(因此具有可以存储在指针中的地址)时才有效。C++ 为了向后兼容继承了这一特性。
我们将在第 17.8 课中介绍数组衰减——C 风格的数组衰减。

现在我们已经了解了左值,接下来可以学习第一个复合类型:lvalue reference。

posted @ 2025-12-08 23:47  游翔  阅读(14)  评论(0)    收藏  举报