左值和右值

左值和右值

C++中关于左值右值以及移动语义这块内容并不是很好理解,对这块内容做一个总结。

1. 身份和可移动性

C++中,一个表达式除了有一个type(例如int或者string),还有一个value category,也就是左值(lvalue)和右值(rvalue)。

大部分时候,在等号左边的是左值,等号右边的是右值。左值也常常被称为locate value,因为他总是有一个位置,你可以获取到他的地址。

随着C++11中移动语义的引入, value categories被重新定义,以描述表达式的两个独立属性:

  • 具有身份(has identity):可以确定该表达式是否与另一个表达式所指的实体相同,比如通过比较对象的地址或它们所标识的函数(直接或间接获得)。
  • 可以被移动(can be moved from):移动构造函数、移动赋值运算符或另一个实现移动语义的函数重载可以与表达式绑定。

现在区分左值和右值,只需要观察表达式的两个重要的属性:是否具有身份(可以得到它的地址)和是否可以被移出

lvalue & right

identity可以理解成有身份,一个独一无二的实体。没有两个实体可以具有相同的身份。

can be moved from也就是其内容都可以从一个地方或内存移动到其他地方。

在C++11中,他们是这么区分的:

  • 具有身份并且不能被移动的表达式被称为lvalue表达式。

  • 有身份且可移动的表达式被称为xvalue表达式。

  • 没有身份并且可以被移出的表达式被称为prvalue(”纯rvalue”)表达式。

  • 没有身份并且不能被移动的表达式不被使用。

  • 有身份的表达式被称为 “glvalue表达式”(glvalue代表 “广义lvalue”)。lvalues和xvalues都是glvalue表达式。

可以移动的表达式被称为 “rvalue表达式”。prvalues和xvalues都是rvalue表达式。

2. 左值和右值

通过前面的描述,我们可以理解到一个左值是指向一个特定内存位置的东西,而右值是临时的,右值是不指向任何地方的东西。

在赋值语句中,=左边的必须是一个左值。

1
2
int num = 5; // ① 在栈内存中分配一块内存,存储值为5
int *ptr = # ② // 在栈内存中分配一块内存,存储值为变量num的内存地址

上面第①句中numptr都是左值。而5是一个右值,5没有具体的内存地址,仅仅在程序运行时存在于临时的寄存器中。

第②句中,在赋值的左边,我们有一个左值,是一个指针类型变量,在右边是由操作符的地址产生的右值。也就是说&num是一个右值。如果你做以下操作,GCC会给你一个报错。

1
&num = 55; // error: lvalue required as left operand of assignment

而如果你对第①句中的5做一个取地址的操作,GCC会给你报一个异常,&操作符想要一个左值作为输入。

1
int *p = &5; // error: lvalue required as unary ‘&’ operand

常见的右值还包含例如函数返回值加减乘除等表达式等。

从特性和用途上再看下左值和右值的区别:

  • 内存地址: 左值有对应的内存地址,因此可以通过指针访问其值。而右值没有固定的内存地址,无法通过指针直接访问。

  • 赋值操作: 左值可以出现在赋值运算符(=)的左边,因为它们表示可以被修改的对象。右值不能出现在赋值运算符的左边,因为它们通常是临时值或字面量常量。

  • 生命周期: 左值通常具有更长的生命周期,因为它们代表具有持久性的对象。右值通常具有更短的生命周期,因为它们可能是临时的、立即求值的值。

  • 引用绑定: 左值可以被引用(lvalue reference)绑定,而右值可以被右值引用(rvalue reference)绑定。C++11引入了右值引用,使我们可以更好地管理右值,并实现移动语义。

3. 左值引用

引用也就是取地址操作,通过上面的描述我们已经清楚,只有左值是存储在内存中某个地址的。

所以引用类型一般都指左值引用。接着前面的例子,我们有了int num = 5;,num是一个左值。

1
2
int& b = num; // 引用一个左值
int& c = 5; // error: cannot bind non-const lvalue reference of typeint&’ to an rvalue of typeint

注意:常量引用可以引用一个右值

1
const int& c = 5; // 通过使用const,右值可以转换成一个左值引用

实际上,上面这段代码和下面这段代码完全等价。

1
2
int temp = 5;
int& c = temp;

可以通过写这样两个简单的demo,然后g++ xxx.cpp -S并查看两个demo输出的编译结果,会发现两个xxx.s文件除了文件名不一样,其他完全一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
hangliebe@ubuntu:~/newwork/test$ cat demo1.cpp
int main()
{
const int& a = 5;
}
hangliebe@ubuntu:~/newwork/test$ cat demo2.cpp
int main()
{
int temp = 5;
int& a = temp; // 这里其实也可以写成const int& a = temp;
}
hangliebe@ubuntu:~/newwork/test$ g++ demo1.cpp -S
hangliebe@ubuntu:~/newwork/test$ g++ demo2.cpp -S
hangliebe@ubuntu:~/newwork/test$ diff demo1.s demo2.s
1c1
< .file "demo1.cpp"
---
> .file "demo2.cpp"

4. C++11中的右值引用与移动语义

在C++11中引入了右值引用和移动语义,这是一个重要的进步,右值引用使用&&引入右值引用最主要的作用就是提高效率

移动语义是一种通过将资源从一个对象转移到另一个对象,而不是进行深拷贝,从而提高性能的技术。通过使用右值引用,我们可以区分左值和右值,并且在合适的时候执行移动操作而不是复制操作。

让我们来看一个简单的例子,通过使用移动语义来改进代码性能:

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
#include <vector>
#include <iostream>

// 模拟一个大规模数据的类
class BigData {
public:
BigData() {
// 假设在构造函数中分配大量资源
data = new int[1000000];
}

// 移动构造函数
BigData(BigData&& other) {
// 直接转移资源的所有权
data = other.data;
other.data = nullptr;
}

// 析构函数
~BigData() {
if (data != nullptr) {
delete[] data;
}
}

private:
int* data;
};

int main() {
// 创建一个BigData对象
BigData source;

// 使用移动构造函数,将source的资源转移到target
BigData target = std::move(source);

return 0;
}

在上面的代码中,我们定义了一个包含大规模数据的类 BigData。通过实现移动构造函数,我们可以直接将 source 对象的资源转移到 target 对象,而不是进行深拷贝。这样可以大幅度提高代码的性能,特别是在处理大规模数据时。

5. 完美转发与std::forward

C++11中引入了完美转发(Perfect Forwarding)的概念,允许我们将参数在函数调用链中完美地转发给另一个函数,包括转发左值和右值。

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
#include <iostream>
#include <utility>

// 示例函数,使用完美转发
template <typename T>
void processValue(T&& value) {
otherFunction(std::forward<T>(value));
}

// 另一个函数,用于接收完美转发的值
void otherFunction(int& value) {
std::cout << "左值引用: " << value << std::endl;
}

void otherFunction(int&& value) {
std::cout << "右值引用: " << value << std::endl;
}

int main() {
int a = 50;

processValue(a); // 输出:左值引用: 50
processValue(100); // 输出:右值引用: 100

return 0;
}

在上面的代码中,processValue 函数接收一个通用引用 T&& value,然后使用 std::forward 函数将参数完美转发给 otherFunction。在 main 函数中,我们分别传递一个左值和一个右值给 processValue 函数,它们都被成功转发给了 otherFunction,并根据参数类型选择了正确的函数版本。

6. 右值引用与std::move的注意事项

尽管右值引用和移动语义在很多情况下可以显著提高代码性能,但在使用它们时需要注意一些细节。

  • std::move: 在使用 std::move 函数时,必须谨慎,确保资源所有权的正确转移。对于使用 std::move 转移资源后的对象,尽量避免再次使用它,因为它的状态可能不再有效。

  • 使用引用和指针: 在处理右值引用时,一定要注意使用引用和指针的有效性。避免在引用或指针指向无效的右值后进行访问,这可能导致未定义的行为。

  • 返回值优化: C++编译器在某些情况下会自动执行返回值优化(RVO)和命名返回值优化(NRVO),这可以避免不必要的对象复制或移动。

7. 结论

左值和右值是C++中重要的概念,对于理解语言的基本机制和编写高效的代码至关重要。左值代表具有内存地址的表达式,可以被赋值和修改;右值代表临时的、立即求值的值,通常不具有内存地址,不能被赋值。

C++11中的右值引用和移动语义为我们提供了更好地管理右值的能力,使得代码性能得到显著提升。通过使用右值引用,我们可以实现移动语义,避免不必要的数据复制,提高代码效率。



关注博客或微信搜索公众号多媒体与图形,获取更多内容,欢迎在公众号留言交流!
扫一扫关注公众号
作者

占航

发布于

2023-07-23

更新于

2023-10-04

许可协议

评论