C++中的左值和右值

左值和右值

C++中有左值(lvalue)和右值(rvalue),简单来说,左值是一个对象的引用,而右值是一个值。

左值是一个表达式,它产生一个对象的引用,比如变量名,一个数组下标的引用,一个解除引用后的指针(dereferenced pointer),或一个返回方法引用的方法调用。左值在内存中必定存在一个确定的位置,从而我们可以得到它的地址。

右值是不是左值的表达式,例如literals,大部分操作符的结果,或者返回非引用的方法调用。右值并不需要在内存中右确切的地址。

在C++中,一个值要么是右值,要么是左值,左值是指表达式结束后依然存在的持久化对象,右值是指表达式结束时就不再存在的临时对象。所有的具名变量或者对象都是左值,而右值不具名。

只有左值可以出现在赋值符号(=)的左边,而左值和右值均可以出现在赋值符号的右边。

当一个对象被用作左值时,使用的是它的引用(地址),被用作右值时,使用的是它的值。

1
2
3
4
int x = 1;      // x是左值,1是右值,这里将1赋值给x,使用的是1的值,储存在x的地址中。
int y = x; // y是左值,x是右值,这里x成为了左值,实际使用的是它的值(1),储存在y的地址中。
1 = x; // 错误,1是literal,只能是右值。
(x + 1) = 2; // 错误,x+1可以看做是一个内嵌方法,它返回的是一个值(2),同样是literal。

但并不是所有的方法都只能当作右值,一个方法可以通过它返回的对象类型进行判断,当这个方法返回一个非引用的对象时,它是右值,返回引用时,是左值。

1
2
3
4
5
6
7
8
9
10
11
12
int GLOBAL_VAR = 0;

int& foo()
{
return GLOBAL_VAR; // 返回的是GLOBAL_VAR的引用
}

int main()
{
foo() = 1; // 正确,foo返回的是引用,可以作为左值。
return 0;
}

这样一来,我们可以使用左值和右值来解释一些现象。例如++操作,++i是自增操作,这个表达式是将i的值加一,然后返回i,这时返回的是加一之后i的地址,所以++i是一个左值表达式。相反,后自增i++返回的是i+1的结果值,是一个临时变量,而i的值不会发生变化,所以i++是一个右值表达式。

1
2
3
4
5
int i = 0, j;
j = ++i; // i=1,j=1
j = i++; // i=1,j=2
j = (++i)++; // i=2,j=3,++i返回的是i的地址,是左值表达式,可以进行后自增操作
j = (i++)++; // 错误,i++返回的是一个值,是右值表达式,右值表达式不能进行后自增操作

值得注意的是,如果一个变量在声明为常量(const),它虽然在内存中有确切的地址,但是它不可修改,不可出现在赋值符号的左边。此外,有些地方说左值可以被修改,而右值不能,这是不严谨的。右值引用的出现使得右值也可以被修改。

左值引用和右值引用

左值引用

有些人定义左值为,一个在内存中有地址的表达式,我们可以通过”&”获取它的地址。因此,左值引用就是对左值的引用,我们使用&符号获取它的地址。

1
2
3
4
5
6
7
8
9
10
11
int i = 1;
i = 2; // 正确,i是左值
int* p = &i; // 正确,&i取i的地址,用指针p指向该地址
int& foo(); // foo()的返回值是一个引用,因此它是左值
foo() = 1; // 正确,foo()是一个左值
int* p1 = &foo(); // 正确,&取foo()的地址,用指针p1指向该地址

int foo1(); // foo1的返回值为literal,是一个右值
int j = 1;
j = foo1(); // 正确,foo1是右值
int* p2 = &foo1(); // 错误,&并不能取右值的地址

右值引用

而右值引用则是两个&,即&&。右值引用是C++11标准中出现的新特性,它和相关的移动语义概念的出现可以在某种程度上避免一些不必要的复制,从而提高效率。

右值引用,用以引用一个右值,可以延长右值的生命期,因为我们在上面说到,右值在表达式结束后将不存在,而使用右值引用,就可以使其继续存在,从而充分使用临时变量,或者即将不使用的变量即右值的资源,减少不必要的拷贝,提高效率。

关于右值引用的介绍,我觉得这篇博客解释的很好,我用它的例子解释一下。

首先,我们想要实现一个整数的vector类,那么我们可以这样定义。

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
class IntVec
{
public:
explicit IntVec(size_t num = 0)
: m_size(num), m_data(new int[m_size])
{
log("constructor"); // 构造函数,参数是int,构建一个长度为num的向量
}

~Intvec()
{
log("destructor"); // 析构函数
if (m_data)
{
delete[] m_data;
m_data = 0;
}
}

IntVec(const IntVec& other)
: m_size(other.m_size), m_data(new int[m_size])
{
log("copy constructor");
// 拷贝构造函数,参数是另一个IntVec实例,实现深拷贝,将参数中的向量的每个元素复制到该向量中
for (size_t i = 0; i < m_size; ++i)
m_data[i] = other.m_data[i];
}

IntVec& operator=(const Intvec& other)
{
// 重载=操作符,也是深拷贝,先用上面的拷贝构造函数拷贝一个tmp,然后用swap拷贝数据
log("copy assignment operator");
IntVec tmp(other);
std::swap(m_size, tmp.m_size);
std::swap(m_data, tmp.m_data);
return *this;
}
private:
void log(const char* msg)
{
cout << "[" << this << "] " << msg << "\n";
}

size_t m_size;
int* m_data;
};

每个成员方法都有调用了log方法,我们可以通过这样查看每个方法的调用情况。

首先,我们运行一个将v1实例的内容用重载后的=号拷贝到v2的操作。

1
2
3
4
5
6
IntVec v1(20);
IntVec v2;

cout << "assigning lvalue...\n";
v2 = v1;
cout << "ended assigning lvalue...\n";

结果是

1
2
3
4
5
assigning lvalue...
[0x28fef8] copy assignment operator
[0x28fec8] copy constructor
[0x28fec8] destructor
ended assigning lvalue...

可以看到,首先调用了重载=的方法,然后用拷贝构造方法构造tmp,swap数据,最后析构tmp。

十分正常,但是如果我们将一个右值赋值给v2。

1
2
3
cout << "assigning rvalue...\n";
v2 = IntVec(33); // IntVec(33)是一个右值
cout << "ended assigning rvalue...\n";

这里虽然是自定义的类的右值,但是可以代表一般情况,也就是我们创建了一个临时的右值,例如数字,字符,字符串等,然后赋值给一个变量。我们会得到这样的输出。

1
2
3
4
5
6
7
assigning rvalue...
[0x28ff08] constructor
[0x28fef8] copy assignment operator
[0x28fec8] copy constructor
[0x28fec8] destructor
[0x28ff08] destructor
ended assigning rvalue...

这里就多了一些步骤了,首先调用构造方法构造了一个临时IntVec对象,这个对象是右值。然后用=号重载将这个对象赋值给v2,但由于这个对象是右值,它是不可更改的,因此就还是和上面一样,再构造一个左值tmp储存数据,然后再通过swap才能将数据最终给v2。然后将tmp和临时对象都析构。

我们可以看到,为了将这个右值赋值给左值v2,我们在内存中创建了两个一模一样的对象,这完全是没有必要的。所以说,有没有什么办法,可以直接将临时变量赋值给v2呢?

因此,就出现了右值引用。右值引用是对右值的引用,用&&表示,我们可以在IntVec类的声明里再加入一个方法,用来重载参数是右值时的=号。

1
2
3
4
5
6
7
IntVec& operator=(Intvec&& other)
{
log("move assignment operator");
std::swap(m_size, other.m_size);
std::swap(m_data, other.m_data);
return *this;
}

借助右值引用可以为IntVec类添加移动构造方法,这样当使用该类的右值对象(可以理解为临时对象)初始化同类对象时,编译器会优先选择移动构造函数。这时other是一个右值引用,我们可以直接将右值的值复制到当前的对象中,而不用再通过tmp进行中转。而且在调用结束后,右值引用就会被销毁。得到的输出是。

1
2
3
4
5
assigning rvalue...
[0x28ff08] constructor
[0x28fef8] move assignment operator
[0x28ff08] destructor
ended assigning rvalue...

这样一来,少了一次对象的创建和析构的过程,减少了内存的占用和运行时间。由于将一个右值赋值给了v2,移动赋值操作符被调用。虽然IntVec(33)仍然会创建一个临时对象,调用其构造器和析构器,但赋值操作符中的另一个临时对象不会再创建了。这个赋值操作符直接将右值的内部内容和自己的相交换,自己获得右值的内容,然后右值的析构器会销毁自己原先的内容,而这一内容已经不需要了。

右值引用和std::move()方法密不可分,我会在另一篇文章中介绍std::move()方法。


C++中的左值和右值
http://nougatca.github.io/2023/01/03/cpp-lr-value/
作者
Changan NIU
发布于
2023年1月3日
许可协议