值类别和对这些类别的引用

本主题介绍并介绍 C++ 中存在的各种值类别(以及对值的引用):

  • glvalue
  • lvalue
  • xlvalue
  • prvalue
  • rvalue

毫无疑问,你听说过 左值右值。 但是,你可能不会用本主题介绍的术语来看待它们。

C++ 中的每个表达式都会生成一个属于上面列出的五个类别之一的值。 C++ 语言(其设施和规则)有一些方面需要正确理解这些值类别,以及对这些类别的引用。 这些方面包括获取值的地址、复制值、移动值以及将值转发到另一个函数。 本主题不会深入介绍所有这些方面,但它为深入了解它们提供了基础信息。

本主题中的信息是按照 Stroustrup 基于标识性和可移动性这两个相互独立属性对值类别所作的分析来表述的 [Stroustrup, 2013]。

左值具有标识性

一个值具有标识意味着什么? 如果你拥有某个值的内存地址(或能够获取它),并且可以安全地使用这个地址,那么该值就具有标识性。 这样,你可以做的不仅仅是比较值的内容,你可以按标识来比较或区分它们。

左值具有标识性。 如今,“左值”中的“l”是“left”的缩写(即赋值语句左侧的值),这件事已经只具有历史意义了。 在 C++ 中,左值可以出现在赋值语句的左侧 or 右侧。 那么,“lvalue”中的“l”实际上并不帮助你理解或定义它们是什么。 你只需要明白,我们所说的“左值”是指具有身份的值。

属于左值的表达式示例包括:命名变量或常量,以及返回引用的函数。 左值表达式的示例包括:临时表达式;或按值返回的函数。

int& get_by_ref() { ... }
int get_by_val() { ... }

int main()
{
    std::vector<byte> vec{ 99, 98, 97 };
    std::vector<byte>* addr1{ &vec }; // ok: vec is an lvalue.
    int* addr2{ &get_by_ref() }; // ok: get_by_ref() is an lvalue.

    int* addr3{ &(get_by_ref() + 1) }; // Error: get_by_ref() + 1 is not an lvalue.
    int* addr4{ &get_by_val() }; // Error: get_by_val() is not an lvalue.
}

现在,虽然说左值具有身份这句话没错,但 xvalue 也同样具有身份。 我们将在本主题的后面详细说明 xvalue 到底是什么。 现在,你只要知道有一种称为 glvalue 的值类别(即“广义左值”)。 glvalue 集合是左值(也称为 经典左值)和 xvalue 的超集。 因此,虽然“左值具有标识”这一说法是正确的,但所有具有标识的对象构成的完整集合是 glvalue 的集合,如下图所示。

左值具有标识

右值是可移动的;左值不是

但有些值不是 glvalue。 换句话说,有一些值 无法 获取内存地址(或者不能依赖它有效)。 我们在上面的代码示例中看到了一些此类值。

没有可靠的内存地址听起来像是缺点。 但事实上,这样的值的优点是,你可以 移动 它(这通常是便宜的),而不是复制它(这通常是昂贵的)。 移动一个值意味着它不再位于原来的位置。 因此,应避免尝试在它之前所在的位置访问它。 讨论 何时以及如何移动 值已脱离本主题的范围。 对于这个主题,我们只需要知道,可移动的值被称为右值(或经典右值)。

“右值”中的“r”是“right”的缩写(例如赋值语句的右侧)。 但是,可以在赋值之外使用右值和对右值的引用。 因此,“rvalue”中的“r”并不是需要关注的重点。 你只需要理解:我们所说的右值,就是可移动的值。

相反,左值不可移动,如下图所示。 如果左值可以被移动,那么这就会与 左值的定义本身相矛盾。 对于那些原本完全有理由认为自己还能继续访问该左值的代码来说,这将是一个意想不到的问题。

右值是可移动的;左值不是

所以,左值不能被移动。 但是,确实有一种 glvalue(具有标识的一类东西)可以被移动——前提是你知道自己在做什么(包括注意不要在移动后再访问它)——而这就是 xvalue。 当我们查看价值类别的完整图片时,我们将再次回顾本主题中的这一想法。

Rvalue 引用和引用绑定规则

本部分介绍对右值引用的语法。 我们得等到后面的另一个主题,再对移动和转发展开深入讨论;不过这里只需知道,右值引用是解决这些问题不可或缺的一部分。 不过,在讨论右值引用之前,我们首先需要更清楚地理解 T&——也就是我们之前一直简称为“引用”的东西。 它实际上是“左值(非常量)引用”,指向一个可由该引用的使用者写入的值。

template<typename T> T& get_by_lvalue_ref() { ... } // Get by lvalue (non-const) reference.
template<typename T> void set_by_lvalue_ref(T&) { ... } // Set by lvalue (non-const) reference.

左值引用可以绑定到左值,但不能绑定到右值。

然后有左值常量引用(T const&),它引用引用的用户 无法 写入的对象(例如,常量)。

template<typename T> T const& get_by_lvalue_cref() { ... } // Get by lvalue const reference.
template<typename T> void set_by_lvalue_cref(T const&) { ... } // Set by lvalue const reference.

左值常值引用可以绑定到左值或右值。

类型为 T 的右值引用的语法写作 T&&。 右值引用指向可移动的值——也就是在使用后无需保留其内容的值(例如临时值)。 由于其根本目的就是从绑定到右值引用的值中移动(从而修改该值),因此 constvolatile 限定符(也称为 cv 限定符)不适用于右值引用。

template<typename T> T&& get_by_rvalue_ref() { ... } // Get by rvalue reference.
struct A { A(A&& other) { ... } }; // A move constructor takes an rvalue reference.

右值引用绑定到一个右值。 事实上,在重载解析方面,右值 更倾向于 绑定到右值引用而不是左值常量引用。 但是,右值引用不能绑定到左值上,因为正如我们所说,右值引用所引用的是这样一种值:其内容被认为是我们不需要保留的,比如移动构造函数的参数。

你还可以在需要按值传递参数的地方传递右值:通过复制构造,或者如果该右值是 xvalue,则通过移动构造。

glvalue 具有标识;prvalue 不具有标识

在这一阶段,我们已经知道哪些对象具有身份标识。 我们知道什么是可移动的,什么不是。 但是,我们尚未命名没有标识的值集。 该集合称为 prvalue,或 纯右值

int& get_by_ref() { ... }
int get_by_val() { ... }

int main()
{
    int* addr3{ &(get_by_ref() + 1) }; // Error: get_by_ref() + 1 is a prvalue.
    int* addr4{ &get_by_val() }; // Error: get_by_val() is a prvalue.
}

glvalue 具有标识性;prvalue 则不具有标识性

价值类别的全貌

现在只需将上述信息和插图整合成一幅完整的整体图景。

价值类别全貌

glvalue (i)

glvalue(广义左值)具有标识性。 我们将用“i”来简写“具有标识”。

lvalue (i&!m)

左值(一种 glvalue)具有标识性,但不能被移动。 这些值通常是通过引用或 const 引用传递的读写值,或者复制成本便宜时按值传递。 左值无法绑定到右值引用。

xvalue (i&m)

xvalue(一种 glvalue,但也是一种右值)具有身份,并且也是可移动的。 这可能是一个原本的左值,你决定对它进行移动,因为复制的代价很高,而且之后你会小心不再访问它。 以下介绍如何将左值转换为 x值。

struct A { ... };
A a; // a is an lvalue...
static_cast<A&&>(a); // ...but this expression is an xvalue.

在上面的代码示例中,我们尚未移动任何内容。 我们只是通过将左值转换为未命名的右值引用来创建 xvalue。 它仍然可以通过其左值名称来识别;但作为 xvalue,它现在可以被移动。 迁移它的原因,以及迁移实际意味着什么,都要留待另一个话题再谈。 不过,如果这样有助于理解,你可以把“xvalue”中的“x”理解为“仅限专家使用”。 通过将左值强制转换为 xvalue(一种右值,请记住),该值随后能够绑定到右值引用。

下面是 xvalue 的另外两个示例:调用返回一个未命名右值引用的函数,以及访问 xvalue 的成员。

struct A { int m; };
A&& f();
f(); // This expression is an xvalue...
f().m; // ...and so is this.

prvalue (!i&m)

prvalue(纯右值;一种右值)没有身份,但可移动。 这些通常是临时对象,或者是调用按值返回函数所得到的结果,或者是对任何其他非 glvalue 表达式求值的结果。

右值 (m)

右值是可移动的。 我们将使用“m”作为“可移动”的简写。

右值 引用 始终引用一个右值(假定它的内容不需要保留的值)。

但是,右值引用本身是否为右值? 未命名的右值引用(例如上面 xvalue 代码示例中所示的那些)是 xvalue,所以,没错,它是右值。 它更倾向于绑定到右值引用类型的函数参数,例如移动构造函数的参数。 相反地(而且这也许有悖直觉),如果一个右值引用有名称,那么仅由该名称构成的表达式就是左值。 因此,它不能绑定到右值引用参数。 但这样做很容易,只需再次将其转换为未命名的右值引用(xvalue)。

void foo(A&) { ... }
void foo(A&&) { ... }
void bar(A&& a) // a is a named rvalue reference; so it's an lvalue.
{
    foo(a); // Calls foo(A&).
    foo(static_cast<A&&>(a)); // Calls foo(A&&).
}
A&& get_by_rvalue_ref() { ... } // This unnamed rvalue reference is an xvalue.

!i&!m

没有标识且不可移动的值类型是我们尚未讨论的一种组合。 但是我们可以忽略它,因为该类别不是 C++ 语言的有用想法。

引用折叠规则

在一个表达式中,多个同类引用(对左值引用的左值引用,或对右值引用的右值引用)会相互抵消。

  • A& & 折叠为 A&
  • A&& && 折叠为 A&&

表达式中的多个不同类型的引用会折叠成左值引用。

  • A& && 折叠为 A&
  • A&& & 折叠为 A&

转发引用

最后一部分将我们已经讨论过的右值引用与另一种不同的概念——转发引用——进行对比。 在“转发引用”这一术语被提出之前,有些人使用“通用引用”这个术语。

void foo(A&& a) { ... }
  • A&& 是一个右值引用,正如我们所看到的。 const 和 volatile 不适用于右值引用。
  • foo 仅接受 A 类型的右值。
  • 右值引用(如 A&&)之所以存在,是为了让你可以编写针对传入临时对象(或其他右值)这种情况进行优化的重载版本。
template <typename _Ty> void bar(_Ty&& ty) { ... }
  • _Ty&&转发引用。 根据传递给 bar的内容,类型 _Ty 可以是常量/非常量,独立于易失性/非易失性。
  • bar 接受任何类型为 _Ty 的左值或右值。
  • 传递左值会导致转发引用变为 _Ty& &&,其会折叠为左值引用 _Ty&
  • 传递右值会导致该转发引用变为右值引用 _Ty&&
  • 转发引用(例如 _Ty&&)存在的原因 不是 为了优化,而是为了接收你传给它们的内容,并以透明且高效的方式将其继续转发出去。 只有在编写(或仔细研究)库代码时,你才可能会遇到转发引用——例如,编写一个转发构造函数参数的工厂函数时。

Sources

  • [Stroustrup, 2013] B. Stroustrup: C++ 编程语言,第四版。 艾迪森-韦斯利 2013.