C++中的左值和右值

原文:Understanding lvalues and rvalues in C and C++

我们在 C/C++ 编程中并不会经常用到 左值 (lvalue)和 右值 (rvalue)两个术语。然而一旦遇见,又常常不清楚它们的含义。最可能出现两这个术语的地方是在编译错误或警告的信息中。例如,使用 gcc 编译以下代码时:

1
2
3
4
5
6
7
8
int foo() {return 2;}

int main()
{
foo() = 2;

return 0;
}//编译命令g++ -std=c++11 % -Wall -o test.cpp

你会得到:

1
2
test.c: In function 'main':
test.c:8:5: error: lvalue required as left operand of assignment

这个例子有点夸张,不像是你能写出来的代码。不过错误信息中提到了左值 (lvalue)。另一个例子是当你用 g++ 编译以下代码:

1
2
3
4
int& foo()
{
return 2;
}

现在错误信息是:

1
2
testcpp.cpp: In function 'int& foo()':
testcpp.cpp:5:12: error: invalid initialization of non-const reference of type 'int&' from an rvalue of type 'int'

同样的,错误信息中提到了术语右值 (rvalue)。那么,在 C 和 C++ 中,左值 和 右值 到底是什么意思呢?

简单的定义

左值和右值的简化版本定义,文章剩下的部分还会进行详细的介绍。

  • 左值 (lvalue, locator value)表示了一个占据内存中某个可识别的位置(也就是一个地址)的对象。
  • 右值 (rvalue) 则使用排除法来定义。一个表达式不是 左值 就是 右值 。 那么,右值是一个 表示内存中某个可识别位置的对象的表达式。

举例

我们假设定义并赋值了一个整形变量:

1
2
int var;
var = 4;

赋值操作需要左操作数是一个左值。var 是一个有内存位置的对象,因此它是左值。然而,下面的写法则是错的:

1
2
4 = var;       // 错误!
(var + 1) = 4; // 错误!

常量 4 和表达式 var + 1 都不是左值(也就是说,它们是右值),因为它们都是表达式的临时结果,而没有可识别的内存位置(也就是说,只存在于计算过程中的每个临时寄存器中(对比微机原理中的8086加法寄存器等))。因此,赋值给它们是没有任何语义上的意义的——我们赋值到了一个不存在的位置。

那么,我们就能理解第一个代码片段中的错误信息的含义了。foo 返回的是一个临时的值。它是一个右值,赋值给它是错误的。因此当编译器看到 foo() = 2 时,会报错——赋值语句的左边应当是一个左值。

然而,给函数返回的结果赋值,不一定总是错误的操作。例如,C++ 的引用让我们可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
int globalvar = 20;

int& foo()
{
return globalvar;
}

int main()
{
foo() = 10;
return 0;
}

这里 foo 返回一个引用,引用一个左值,因此可以赋值给它。实际上,C++ 中函数可以返回左值的功能对实现一些重载的操作符非常重要。

可修改的左值

左值一开始在 C 中定义为“可以出现在赋值操作左边的值”。然而,当 ISO C 加入 const 关键字后,这个定义便不再成立。毕竟:

1
2
const int a = 10; // 'a' 是左值
a = 10; // 但不可以赋值给它!

于是定义需要继续精化。不是所有的左值都可以被赋值。可赋值的左值被称为 可修改左值 (modifiable lvalues) 。C99标准定义可修改左值为:

[…] 可修改左值是特殊的左值,不含有数组类型、不完整类型、const 修饰的类型。如果它是 `struct` 或 `union`,它的成员都(递归地)不应含有 const 修饰的类型。

左值与右值间的转换

通常来说,计算对象的值的语言成分,都使用右值作为参数。例如,两元加法操作符 '+' 就需要两个右值参数,并返回一个右值:

1
2
3
4
int a = 1;     // a 是左值
int b = 2; // b 是左值
int c = a + b; // + 需要右值,所以 a 和 b 被转换成右值
// + 返回右值

上述例子中,ab 都是左值。因此,在第三行中,它们经历了隐式的左值到右值转换除了数组、函数、不完整类型的所有左值都可以转换为右值。

那右值能否转换为左值呢?当然不能!根据左值的定义,这违反了左值的本质。【注1:右值可以显式地赋值给左值。之所以没有隐式的转换,是因为右值不能使用在左值应当出现的位置。】

不过,右值可以通过一些更显式的方法产生左值。例如,一元解引用操作符 '*' 需要一个右值参数,但返回一个左值结果。考虑这样的代码:

1
2
3
int arr[] = {1, 2};
int* p = &arr[0];
*(p + 1) = 10; // 正确: p + 1 是右值,但 *(p + 1) 是左值

相反地,一元取地址操作符 '&' 需要一个左值参数,返回一个右值:

1
2
3
4
int var = 10;
int* bad_addr = &(var + 1); // 错误: 一元 '&' 操作符需要左值参数
int* addr = &var; // 正确: var 是左值
&var = 40; // 错误: 赋值操作的左操作数需要是左值

在 C++ 中 '&' 符号还有另一个功能——定义引用类型。引用类型又叫做“左值引用”。因此,不能将一个右值赋值给(非常量的)左值引用:

1
std::string& sref = std::string();  // 错误: 非常量的引用 'std::string&' 错误地使用右值 'std::string` 初始化(改为const + ...)

常量的左值引用可以使用右值赋值。因为你无法通过常量的引用修改变量的值,也就不会出现修改了右值的情况。这也使得 C++ 中一个常见的习惯成为可能:函数的参数使用常量引用接收参数,避免创建不必要的临时对象

CV限定的右值

如果我们仔细阅读 C++ 标准中关于左值到右值的转换的部分【注2:在新的 C++11 标准草稿的第 4.1 节】,我们会发现:

1
一个非函数、非数组的类型T的左值可以转换为右值。 […] 如果T不是类类型【类类型即C++中使用类定义的类型,区别与内置类型】,转换后的右值的类型是T的未限定CV的版本 (cv-unqualified version of T)。其他情况下,转换后的右值类型就是T本身。

什么叫做 “未限定 CV” (cv-unqualified) 呢? CV 限定符 这个术语指的是 constvolatile 两个类型限定符。C++ 标准的 3.9.3 节写到:

每个类型都有三个对应的 CV-限定类型版本: `const 限定` 、 `volatile 限定` 和 `const-volatile 限定` 版本。有或无 CV 限定的不同版本的类型是不同的类型,但写法和赋值需求都是相同的。

那么,这些又和右值有什么关系呢?在 C 中,只有左值有 CV 限定的类型,而右值从来没有。而在 C++ 中,类右值可以有 CV 限定的类型,但内置类型 (如 int) 则没有。考虑下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>

class A {
public:
void foo() const { std::cout << "A::foo() const\n"; }
void foo() { std::cout << "A::foo()\n"; }
};

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


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

main 中的第二个函数调用实际上调用的是 A 中的 foo() const 函数,因为 cbar 返回的类型是 const A,这和 A 是两个不同的类型。这就是上面的引用中最后一句话所表达的意思。另外注意到,cbar 的返回值是一个右值,所以这是一个实际的 CV 限定的右值的例子。

C++11的右值引用

C++11 标准中引入的最强有力的特性就是右值引用,以及相关的移动语义 (move semantics)概念。

这篇文章的大部分内容都在解释:左值和右值的主要区别是,左值可以被修改,而右值不能。不过,C++11 改变了这一区别。在一些特殊的情况下,我们可以使用右值的引用,并对右值进行修改。

假设我们有一个很大的类,叫做Foo:

1
2
3
4
5
class Foo {
private:
int i;
// 假设这里有许许多多你看不见的属性
}

接着有一个函数f(),使他返回Foo

1
2
3
4
5
Foo f() {
Foo a;
a.i = 101;
return a;
}

由于Foo很大,当我们使用f()的返回值,比如Foo foo = f();的时候,按照常理,f()的返回值会被赋予foo,这个过程可能会发生拷贝,导致性能下降。这时候靠谱的编译器会自动进行返回值优化,避免这个拷贝。这个聪明的动作叫做Return Value Optimization。这就是所谓的编译器自动优化。

那总有些情况,编译器是无法优化的,只好靠手动优化了。

1、使用const引用

我们可以把foo变成函数返回值的引用,比如:const Foo& foo = f();。由于是引用,所以就避免了拷贝。这里有几点需要注意:

  1. 必须用const来修饰这个引用,这是因为函数的返回值属于右值,普通的引用是左值引用,不能指向右值引用,只有const的左值引用才能用来指向右值。

  2. 本来f()的返回值是一个临时的变量,在它调用结束后,就应该销毁了。可是通过像这样const Foo& foo = f();,把临时的返回值赋值给一个const左值引用,f()返回值并不会立即销毁。这等于是在const引用的作用域内,延长了f()返回值的存活时间。

2、使用右值引用

右值引用的写法是&&,所以可以把const Foo& foo = f();改写成右值引用形式:const Foo&& foo = f();。这样做看起来好像没有多大差别!那再改一下,把const去掉:Foo&& foo = f();。这就是右值的好处,不加const就可以直接指向右值,而且可以对右值进行更改,比如:foo.i = 122;

再谈一下右值引用和左值引用的关系和差别:右值引用本身是一个左值,所以左值引用可以指向一个右值引用:

1
2
3
4
5
6
int k = 10;
int&& i = 1;
i++; // 右值引用是左值,可以加加
i = k; // 右值引用是左值,可以重新复制

int&& j = k; //错误,一个右值引用不能指向左值

3、偷天换日

上面写到对于这个语句Foo foo = f();,编译器会自动优化,直接用f()的返回值当作foo。但是,在foo太复杂的情况,或者是编译器不够聪明的情况下,优化有可能不会进行。这时候默认的行为是调用Foo的拷贝构造函数,用f()返回的临时变量来拷贝构造 foo。我们的假设是Foo很复杂,所以这是个很大的工程,非常消耗性能。但这里有一点可以优化,也就是f()返回的是临时变量,不会有其他人使用了,何不将其的内容直接搬移过来使用,说不定可以节省许多在拷贝构造中所需要的内存分配和释放的操作。这个偷天换日的任务就交给右值引用了。

首先,偷天换日之前有一个准备工作要做,那就是定义一个搬移构造函数(move constructor,也叫移动构造函数)。和拷贝构造函数Foo(Foo&)不一样的是,搬移构造函数接受的是一个右值引用:Foo(Foo&&)。如果是从一个临时变量来构造新的Foo的话,编译器会优先调用搬移构造函数,来把临时对象开膛破肚,取出自己需要的东西。拷贝构造函数就不敢这么做,因为它不确定对象在其他地方有没有被使用,如果误操作了,恐怕会被人打。

和搬移构造函数有异曲同工之妙的是移动赋值操作符(move assignment operator),考虑如下的程序:

1
2
Foo v;
v = Foo(100);

虽然这里的例子中是赋值一个新创建的 Foo,但它可以代表更一般的情况——创建了一个临时的右值,然后赋值给 v (例如当一个函数返回 Foo 的情况)。在没有移动操作符时,编译器会先生成一个临时对象,然后调用赋值操作符。有了移动复制操作符,便可以把这个临时对象开膛破肚。

4、std::move

C++11提供了std::move来废弃一个对象,也就是把它标识为临时变量,这样可以丢给搬移构造函数处理,比如下面的例子:

1
2
3
4
std::string x1 = "hello, world!";
std::string x2 = std::move(x1);
std::cout << "x1 = " << x1 << std::endl;
std::cout << "x2 = " << x2 << std::endl;

由于x1通过std::move被标识为临时对象,于是就被x2给开膛破肚,存储的内容被抢走了。上面的例子输出结果是:

1
2
x1 = 
x2 = hello, world!

对于函数的返回值,如果编译器没有办法自动优化,我们提供搬移构造函数给编译器,从而降低拷贝构造的成本。这时候可以返回局部变量的std::move()用户自己来优化,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include<iostream>
using namespace std;

class A{};

A fun()
{
A a;
return std::move(a); //这里显式调用 std:move 强制 A 的移动构造函数被调用,因此它可能会在调用 fun()时禁止返回值优化;对于这个简单的例子,return a;更优。
}
int main()
{
auto f = fun();
return 0;
}

C++函数返回局部变量的std::move()的使用场景

听说打赏我的人,最后都找到了真爱。