对C++左值和右值的理解

对C++左值和右值的理解

左值和右值的定义

C++03标准中的左值和右值

  左值 (lvalue) 和右值 (rvalue) 中的“左”和“右”其实就是左手右手中的“左”和“右”。因为最初产生这个概念的时候,左值总是出现在赋值符号的左边,右值总是出现在赋值符号的右边。在C++03标准中,左值和右值是这样定义的:

1.Every expression is either an lvalue or an rvalue.
2.An lvalue refers to an object or function.
……
5.The result of calling a function that does not return a reference is an rvalue. User defined operators are functions, and whether such operators expect or yield lvalues is determined by their parameter and return types.

  即:对于每一个表达式,它要么是左值要么是右值。左值指向一个对象或函数。函数的返回值如果不是个引用,那么是个右值。用户定义的运算符是函数,此类运算符是期望值还是产生左值取决于其参数和返回类型。

  左值是一个对象,这个对象占据了内存中的空间,并且可以取得地址。而如果一个表达式它不是左值,那么就认为这个表达式是一个右值。

  借助代码也许会更好理解。

1
2
3
4
5
int a;
a = (9 + 1);
int *pa = &a;
(9 + 1) = a; // 这一句代码是错误的,MSVC会提示:“表达式必须是可修改的左值”。
int *pb = &(9 + 1); // 这一句代码是错误的,MSVC会提示:“表达式必须为左值或函数指示符”。

  上面的代码中,变量a显然是一个左值,它可以出现在赋值符号的左边,也可以取得它的地址。而对于表达式 “(9 + 1)” 来说,它是一个临时的计算结果,它完全可能不存在于内存中(比如存在于某个临时寄存器中),也就是没有一个确定的内存地址,因此这个表达式不是一个左值,而是一个右值。只能给左值赋值,因为对一个不存在的内存赋值,是没有意义的。同样的道理,只能对左值取地址,因为对一个不存在地址的表达式取地址,是没有意义的。

  前面提到在以前左值总是出现在赋值符号的左边,右值总是出现在赋值符号的右边。现在这个说法已经不那么准确,接下来谈谈两种特殊的情况。

  左值未必能放在等号左边。考虑下面这段代码:

1
2
const int a = 10;   // a是一个左值
a = 100; // 错误!a不能放在等号左边!

  变量a显然是个左值,但是因为它被 const 修饰,不可被修改,所以变量a不能出现在赋值符号的左边。对于可以修改的左值,一般称为 “可修改左值”

  等号的左边未必是左值。考虑下面这一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class A
{
public:
A() = default;
A& operator=(int)
{
return *this;
}
};
A func1()
{
return A();
}

int func2()
{
return 123;
}

int main()
{
func1() = 5321; // 语句1 可以运行,因为这一句代码相当于 func1().operator=(5321);
func2() = 4321; // 语句2 错误,MSVC会提示:“表达式必须是可修改的左值”
return 0;
}

  从定义来看,函数 func1 和 func2 的返回值不是引用,所以是右值。而对一个右值赋值是无意义的,所以“语句 2”是错误的。但是因为 func1 返回的是类型A,这个类型重载了自己的赋值操作符。因此对它的赋值操作实际上等同于执行了成员函数 “operator=”,所以“语句 1”是可以运行的。

C++11标准中的左值和右值

  C++11标准中的左值和右值明显复杂了许多。在C++11标准中,表达式可以分为广义左值 (glvalue, generalized lvalue)和右值(rvalue)。广义左值又可分为左值(lvalue)和消亡值(xvalue, eXpiring value)。右值又可分为消亡值(这里没有打错,它既是广义左值也是右值)和纯右值(prvalue, pure rvalue)。下面这幅图直观地说明了它们之间的关系。

  C++11标准中对它们的定义是:

1.An lvalue (so called, historically, because lvalues could appear on the left-hand side of an assignment expression) designates a function or an object.
2.An xvalue (an “eXpiring” value) also refers to an object, usually near the end of its lifetime (so that its resources may be moved, for example). An xvalue is the result of certain kinds of expressions involving rvalue references (8.3.2). [Example: The result of calling a function whose return type is an rvalue reference is an xvalue. —end example]
3.A glvalue (“generalized” lvalue) is an lvalue or an xvalue.
4.An rvalue (so called, historically, because rvalues could appear on the right-hand side of anassignment expression) is an xvalue, a temporary object(12.2) or subobject thereof, or a value that is not associated with an object.
5.A prvalue (“pure” rvalue) is an rvalue that is not an xvalue. [Example: The result of calling a function whose return type is not a reference is a prvalue. The value of a literal such as 12, 7.3e5, or true is also a prvalue. —end example]

  C++11中左值(lvalue)的定义和C++03中的左值定义差不多。纯右值的定义依然是用“排除法”的方式定义,即:“不是消亡值就是纯右值”,关键在于这个消亡值(xvalue)。

  按照定义的意思,消亡值也指向了一个对象,因为这个对象通常接近它生命周期的末期(也就是即将被销毁),所以把它叫做消亡值。很好理解为什么消亡值是“广义上的左值”,其实就因为它指向了一个对象。

  纯右值和C++03的右值很相似,比如字面值(如12, 3.14f, true)、函数的返回值(不是引用)都是纯右值。需要注意的是,C++规定字符串(const char*)是左值,而不是右值,虽然字符串也是字面值。(对此我的理解是:字符串是可以取得地址的,因此它至少是个“广义上的左值”)

左值与右值之间的转换

  如果某个表达式期待一个右值,但是却给了一个左值,那么这个左值会被转换成右值。考虑下面这一段代码:

1
2
3
int a = 9;       // 变量a是一个左值
int b = 1; // 变量b是一个左值
int c = a + b; // 加法运算需要一个右值,左值b被转换成右值

  正如注释中所述,变量a和变量b都是左值,而加法运算需要一个右值,所以变量b会被隐式地转换成右值。
  但是这可不意味着可以反过来把右值转换成左值,这就不符合左值的定义了。但右值转换成左值也不总是不成立,考虑下面这一段代码。
1
2
3
int arr[] = {1, 2};
int* p = &arr[0];
*(p + 1) = 10; // 行得通,(p + 1)是右值,但是*(p + 1)是左值

  这一段代码中,(p + 1) 计算的结果是一个临时值,但是操作符 * 可以使 (p + 1) 变为左值,所以 *(p + 1) 可以出现在赋值符号的左侧。
  反过来,操作符 & 则可以将左值转换成右值。
1
2
3
4
int var = 10;
int* bad_addr = &(var + 1); // 错误:& 操作符需要一个左值
int* addr = &var; // 可行:var是一个左值
&var = 40; // 错误:赋值符号左侧需要一个左值

  符号 & 的另一个作用是定义 “左值引用”,下面一段代码定义了一个左值引用。
1
2
int a = 123;
int& b = a;

  在上面这一段代码中,变量a是一个左值,而变量b是a的 “左值引用”。不能给 非常量左值引用 赋予一个右值,因为这会产生从右值到左值的转换。但是 常量左值引用 则可以被赋予右值。
1
2
3
4
5
6
7
string& str1 = string();        // 错误:不能给“非常量左值引用”赋予右值
const string& str2 = string(); // 可行:可以给常量左值引用赋予右值

void func(const string& str); // 函数func的参数类型是“常量左值引用”
string str = "Hello";
func(str); // 可行,可以把左值赋值给“常量左值引用”
func(string("Hi!")); // 可行,虽然string("Hi!")是右值,但是也可以赋值给“常量左值引用”

  上面这段代码演示了C++中一个很常见的优化方法,即按引用传参。从左值和右值的角度来看,这样做之所以行得通是因为左值和右值都可以赋值给“常量左值引用”。

CV限定下的左值和右值

  CV限定(cv-qualified) 这个术语指的是 constvolatile 两个类型限定符。按照C++中的定义,每一个没有被 constvolatile 的完成类型、未完成类型和 void 类型都有相应的三个 CV限定 版本的类型。举个例子,int 这个类型有三个 CV限定 版本的类型,分别是 const intvolatile intconst volatile int
  在C语言里,只有左值有CV类型限定符而右值不会有。在C++中,类的右值 可以有CV限定,但是内建类型(比如 int)没有。考虑下面这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 这里的代码直接使用了参考文献[3]中的代码
class A
{
public:
void foo() const { cout << "A::foo() const\n"; }
void foo() { cout << "A::foo()\n"; }
};

A bar() { return A(); }
const A cbar() { return A(); }

int main()
{
bar().foo(); // 调用 foo
cbar().foo(); // 调用 foo const
}

  在 main 函数中 对 foo 函数的第二次调用,实际上调用的是 A类 中的 void foo() const方法。因为函数 cbar 返回的是 const Aconst AA 不是同一个类型。

右值引用与移动语义

  右值引用就是对右值的引用,这个特性在C++11标准中引入。符号 & 可以声明 左值引用,对于右值引用则使用符号 && 声明。
  加入右值引用的特性是为了实现 移动语义(move semantic)精确转发(perfect forwarding)。这一节只简单地谈一下移动语义。(因为我也只是懂得那么点)
  要理解 移动语义 这个概念,最好还是结合一个实际的例子。考虑需要实现一个边长数组的类(类似std::vector)。
在实现赋值语句操作符的时候,可能会有这种实现:

1
2
3
4
5
6
7
8
// 这里的代码直接使用了参考文献[3]中的代码
Intvec& operator=(const Intvec& other)
{
Intvec tmp(other);
std::swap(m_size, tmp.m_size);
std::swap(m_data, tmp.m_data);
return *this;
}

  这个实现对传入的 other 对象做了一个完全的拷贝,也就是创造了个副本 tmp。然后,将自己的成员变量和副本 tmp 交换。在这个函数结束的时候,自己原本的数据随着 tmp 的析构而销毁,并拥有了和 other 对象完全一样的新数据。使用它的代码如下:
1
2
3
4
5
Intvec v1(20);
Intvec v2;

v2 = v1; // 语句1 : 给v2赋值一个左值
v2 = Intvec(30); // 语句2 : 给v2赋值一个右值

  对于 语句1 可以认为是确实需要一个和v1一模一样的副本,所以上面的代码完全没有问题。但是对于 语句2,也就是把一个右值赋值给v2,会发生什么?
  首先 Intvec(30) 这个表达式会调用 Intvec 类的构造函数,创造一个 Intvec 对象,为了方便说明,可以称他为 临时对象1
  然后,调用 v2.operator=(const Intvec& other),把 临时对象1 传递进去。(虽然这个函数的参数要求是 常量左值引用临时对象1 是个右值,但是上文已经说明这样做是合法的,这里没有问题。)
  之后,这个函数(v2.operator=)对传入的参数 other,做了一个完全的复制,得到 临时对象2 ,也就是副本 tmp
  到这里我想可以停止说明了。这短短一行代码,居然创建了两个临时对象!然而这真的有必要吗?考虑加入下面这个代码:
1
2
3
4
5
6
7
// 这里的代码直接使用了参考文献[3]中的代码
Intvec& operator=(Intvec&& other){
log("move assignment operator");
std::swap(m_size, other.m_size);
std::swap(m_data, other.m_data);
return *this;
}

  这里的赋值语句操作符的参数和上一个有所不同。这里的参数类型是一个 右值引用,而上一个是 常量左值引用。区别在于,当编译器检测到参数是右值是,编译器会调用这个参数为 右值引用 版本的赋值语句操作符函数,而不是上一个版本的函数。也就是说,上面用例中的 语句2 将会调用这个新的 Intvec& operator=(Intvec&& other) 函数(语句1没有变化,因为语句1的参数v1是个左值)。
  另一个主要区别是,这个版本的函数没有了创造副本 tmp 的过程。这是因为副本 tmp 的使命只有两个:1.将other完全复制一份 2.在析构的时候把旧的数据给销毁。副本 tmp 能够做到第二个使命是因为它是一个临时变量,在函数结束的时候就会被析构,所以它是“濒临死期”。而传入参数的右值也是如此。在语句2(“v2 = Intvec(30);”)执行完后,这个 临时对象1 就会被析构,它也是“濒临死期”。还记得上文讲到的“消亡值”吗,消亡值指的就是即将消亡的值,实际上这里的other参数就是个消亡值。
  利用这一点,就没有必要再创造一个副本 tmp临时对象1 拥有需要的新数据,同时它又即将消亡,能够代替副本 tmp 完成那两个使命。所以直接和 临时对象1 交换新旧数据就可以了,而不需要一个副本 tmp 作为中介。为了能够修改右值,C++11引入了右值引用。将旧数据移动到右值中,将右值中的数据移动给自己,使用“移动数据”代替“复制数据”,这就称为“移动语义”。(个人理解)

参考文献

[1] http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2005/n1905.pdf
[2] http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2013/n3690.pdf
[3] https://eli.thegreenplace.net/2011/12/15/understanding-lvalues-and-rvalues-in-c-and-c
[4] https://en.cppreference.com/w/cpp/language/value_category

作者

uint128.com

发布于

2020-01-17

更新于

2022-08-22

许可协议

评论