C++20标准下的左值与右值

一、什么是左值与右值

  • 左值:左值可以出现在赋值语句的左边或者右边
  • 右值:右值只能出现在赋值语句的右边,不能出现在赋值语句的左边

变量是左值,因此可以出现在赋值语句的左边。数字字面值是右值,因此不能被赋值。

有些操作符,比如赋值,要求其中的一个操作数必须是左值。结果,可以使用左值的上下文比右值更广。左值出现的上下文决定了左值是如何被使用的,例如:

int a = 0;
a = a + 1;

其中 a 变量被用作两种不同操作符的操作数。+ 操作符仅关心其操作数的值。变量的值是当前存储在和该变量相关联的内存中的值。加法操作符的作用是取得变量的值并加1.

变量a 也被用作 = 操作符的左操作数。= 操作符读取右操作数并写到左操作数。在这个表达式中,加法运算的结果被保存到与a相关联的存储单元中,而 a 之前的值则被覆盖。

在C++中所谓的左值一般是指一个指向特定内存的具有名称的值(具名对象),它有一个相对稳定的内存地址,并且有一段较长的生命周期。而右值则是不指向稳定内存地址的匿名值(不具名对象),它的生命周期很短,通常是暂时性的。基于这一特征,我们可以用取地址符&来判断左值和右值,能取到内存地址的值为左值,否则为右值。

二、左值引用

左值引用是编程过程中的常用特性之一,它的出现让C++编程在一定程度上脱离了危险的指针。当我们需要将一个对象作为参数传递给子函数的时候,往往会使用左值引用,因为这样可以免去创建临时对象的操作。非常量左值的引用对象很单纯,它们必须是一个左值。对于这一点,常量左值引用的特性显得更加有趣,它除了能引用左值,还能够引用右值,比如:

int& x = 8;         // 报错 initial value of reference to non - const must be an lvalue
const int& x = 9;   // 编译成功

返回左值引用的函数,连同赋值、下标、解引用和前置的 ++/-- 运算符,都是返回左值表达式的例子。我们可以将一个左值引用绑定到这类表达式的结果上。

返回非引用类型的函数,连同算数、关系、位运算符以及后置的 ++/-- 运算符,都生成右值。我们不能将一个左值引用绑定到这类表达式上,但我们可以将一个 const 的左值引用或者一个右值引用绑定到这类表达式上。

虽然常量左值引用可以引用右值的这个特性在赋值表达式中看不出什么实用价值,但是在函数形参列表中却有着巨大的作用。一个典型的例子就是复制构造函数和复制赋值运算符函数,通常情况下我们实现的这两个函数的形参都是一个常量左值引用,例如:

class X {
public:
    X() {}
    X(const X&) {}
    X& operator = (const X&) { return *this; }
};
X make_x()
{
    return X();
}
int main()
{
    X x1;
    X x2(x1);
    X x3(make_x());
    x3 = make_x();

    return 0;
}

如果把类X中const删除,则会编译失败,因为非常量左值引用无法绑定到make_x()产生的右值。常量左值引用可以绑定右值是一条非常棒的特性,但是它也存在一个很大的缺点——常量性。一旦使用了常量左值引用,就表示我们无法在函数内修改该对象的内容(强制类型转换除外)。所以需要另外一个特性来帮助我们完成这项工作,它就是右值引用。

三、右值引用

所谓右值引用(rvalue reference),就是必须绑定到右值的引用。我们通过 && 而不是 & 来获得右值引用。右值引用有一个重要的性质,就是它只能绑定到一个简要销毁的对象。因此,我们可以自由地将一个右值引用的资源“移动”到另一个对象中。

类似任何引用,一个右值引用也不过是某个对象的另一个名字而已。对于左值引用,我们不能将其绑定到要求转换的表达式、字面常量或是返回右值的表达式。而右值引用有着完全相反的绑定特性:我们可以将一个右值引用绑定到这类表达式上,但不能将一个右值引用直接绑定到一个左值上。

int i = 43;
int &r = i;             // 正确,r引用i
int &&r = i;            // 错误,不能将右值引用绑定到一个左值上
int &r2 = i * 2;        // 错误,不能将左值引用绑定到一个右值表达式上
const int &r3 = i * 2;  // 正确,可以将const引用绑定到右值上
int &&r4 = i * 2;       // 正确,将右值引用r4绑定到右值表达式上

左值持久,而右值短暂。左值一般有持久的状态,而右值要么是字面常量,要么是在表达式求值过程中创建的临时对象。

由于右值引用只能绑定到临时对象,我们得知:

  • 所引用的对象将要被销毁
  • 该对象没有其他用户

这两个特性意味着:使用右值引用的代码可以自由地接管所引用的对象的资源。

四、值类别

在进一步探讨右值引用之前,我们必须先了解一下这个概念:值类别。

值类别是C++11标准中新引入的概念,具体来说它是表达式的一种属性,该属性将表达式分为3个类别,它们分别是左值(lvalue)、纯右值(prvalue)和将亡值(xvalue)。

由于C++11中右值引用的出现,值类别被赋予了全新的含义。可惜的是,在C++11标准中并没能够清晰地定义它们,比如在C++11的标准文档中,左值的概念只有一句话:“指定一个函数或一个对象”,这样的描述显然是不清晰的。这种糟糕的情况一直延续到C++17标准的推出才得到解决。

oGHko0.png

  • 所谓泛左值是指一个通过评估能够确定对象、位域或函数的标识的表达式,简单来说,它确定了对象或者函数的标识(具名对象)
  • 而纯右值是指一个通过评估能够用于初始化对象和位域,或者能够计算运算符操作数的值的表达式。
  • 将亡值属于泛左值的一种,它表示资源可以被重用的对象和位域,通常这是因为它们接近其生命周期的末尾,另外也可能是经过右值引用转换产生的。

从本质上说产生将亡值的途径有两种,第一种是使用类型转换static_cast将泛左值转换为该类型的右值引用。

第二种是在C++17 中引入的,称它为临时量实质化,指的是纯右值转换到临时对象的过程。每当纯右值出现在一个需要泛左值的地方时,临时量实质化都会发生,也就是说都会创建一个临时对象并且使用纯右值对其进行初始化,这里的临时对象就是一个将亡值。

struct X
{
    int a;
};

int main()
{
    int b = X().a;
    return 0;
}

上述代码中,X()是一个纯右值,访问其成员变量a却需要一个泛左值,所以这里会发生一次临时量实质化,将X()转换为将亡值,最后再访问其成员变量a。

五、标准库 move 函数

5.1 用 static_cast将左值转换为右值

通常情况下,static_cast 只能用于其他合法的类型转换,但是,这里又有一条针对右值引用的特许规定:虽然不能隐式地将一个左值转换为右值引用,但我们可以用static_cast 显式地将一个左值转换为一个右值引用。

int i = 0;
int &&k = static_cast<int&&>(i);

虽然我们可以直接编写这种类型转换的代码,但是使用标准库 move 函数是容易得多的方式。而且,统一使用 std::move 使得我们在程序中查找潜在的截断左值的代码变得很容易。

5.2 使用 std::move 将左值转换为右值

虽然不能将一个右值引用直接绑定到一个左值上,但我们可以显式地将一个左值转换为对应的右值引用类型。我们还可以通过调用一个名为 move 的新标准库函数来获得绑定到左值上的右值引用,此函数定义在头文件 utility 中。

int a = 43;
int &&b = std::move(a);

std::move 告诉编译器:我们有一个左值,但我们希望像一个右值一样处理它。

我们必须认识到,调用 move 就意味着承诺: 除了对 a 赋值或销毁它之外,我们将不再使用它。在调用 move 之后,我们不能对移动后的源对象的值做任何假定。也就是说我们可以销毁一个移动后的源对象,也可以对它重新赋值,但不能使用一个移动后的源对象的值。

与大多数的标准库名字使用不同,对 move 我们不提供 using 声明,而是直接调用 std::move,而不是 move。这样做可以避免潜在的名字冲突。因为 move(以及forward 函数)的名字冲突要比其他标准库函数的冲突频繁的多,且多数情况下这种冲突并不是有意的。


参考文献:

  1. 《C++ Primer 第四版》
  2. 《C++ Primer 第五版》
  3. 《现代C++语言核心特性解析》

猜你喜欢

转载自blog.csdn.net/hubing_hust/article/details/128645974