换个角度来看看C++中的左值、右值、左值引用、右值引用

前言

对于左值和右值有一个不太严谨的定义——在赋值表达式 = 左侧是的左值,而在 = 右侧的是右值。通过不断学习和尝试,最近我发现一个新的说法更加贴切,那就是“左值是容器,右值是东西”。对于这个定义我们可以类比一下水杯和水,通过水杯可以操作水杯中的水,操作过程中的中间结果如果想要进一步操作,可以将其放入其他的水杯,如果没有水杯就无法找到曾经操作过的水了,也就无法继续操作了。

1
2
3
int a = 2;
int b = 6;
int c = a + b;

在这个例子中,变量 ab, c 都是水杯,而 26a + b 都是被用来操作的水,只有把这些“水”放到“水杯”中才能被找到,才可以进行下一步操作。

关于左值、右值、左值引用和右值引用的概念可以看看之前的总结:

虽然温故不一定知新,但绝对可以增强记忆,参照着之前的理解,今天来换一种窥探本质的方式。

汇编代码初探

为了熟悉一下汇编代码,我们先写个简单的例子,内容就是上述提到的那一段,新建一个文件 main.cpp,然后编写如下代码:

1
2
3
4
5
6
7
8
int main()
{
int a = 6;
int b = 2;
int c = a + b;

return 0;
}

运行 g++ main.cpp --std=c++11 -S -o main.s 编译这段代码,生成汇编文件 main.s,打开文件内容如下:

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
    .file   "main.cpp"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl $6, -12(%rbp)
movl $2, -8(%rbp)
movl -12(%rbp), %edx
movl -8(%rbp), %eax
addl %edx, %eax
movl %eax, -4(%rbp)
movl $0, %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.12) 5.4.0 20160609"
.section .note.GNU-stack,"",@progbits

其中代表定义变量和做加法的语句转换成汇编代码如下:

1
2
3
4
5
6
movl    $6, -12(%rbp)       // 把立即数6放到内存地址为-12(%rbp)的位置,也就是变量a中
movl $2, -8(%rbp) // 把立即数2放到内存地址为-8(%rbp)的位置,也就是变量b中
movl -12(%rbp), %edx // 把内存地址为-12(%rbp)的位置(变量a)的数据放到寄存器%edx中
movl -8(%rbp), %eax // 把内存地址为-8(%rbp)的位置(变量b)的数据放到寄存器%eax中
addl %edx, %eax // 把寄存器%edx中的数据加到寄存器%eax中
movl %eax, -4(%rbp) // 把寄存器%eax中的计算所得结果数据放到内存地址为-4(%rbp)的位置,也就是变量c中

指针变量

首先来看看通过指针来修改变量值的过程,测试代码如下:

1
2
3
int a = 6;
int* p = &a;
*p = 2;

转换成汇编代码如下:

1
2
3
4
5
movl    $6, -20(%rbp)       // 把立即数6放到内存地址为-20(%rbp)的位置,也就是变量a中
leaq -20(%rbp), %rax // 把这个内存地址-20(%rbp),也就是变量a的地址保存在寄存器%rax中
movq %rax, -16(%rbp) // 把寄存器%rax中的保存的变量a的地址,放到内存地址为-16(%rbp)的位置,也就是变量p中
movq -16(%rbp), %rax // 把内存地址为-16(%rbp)的位置(变量p)的数据放到寄存器%rax中
movl $2, (%rax) // 把立即数2放在寄存器%rax中保存的地址位置中,也就是p所指向的地址,即变量a中

通过汇编代码可以发现,通过指针修改变量的值实际上是在指针变量中保存变量的地址值,修改变量时是通过指针变量直接找到变量所在内存,然后直接修改完成的。

左值引用

接着来看下通过引用来修改变量值的过程,测试代码如下:

1
2
3
int a = 6;
int& r = a;
r = 2;

转换成汇编代码如下:

1
2
3
4
5
movl    $6, -20(%rbp)
leaq -20(%rbp), %rax
movq %rax, -16(%rbp)
movq -16(%rbp), %rax
movl $2, (%rax)

看到这里是不是有点意思了,这几行通过引用修改变量值的代码转换成汇编代码以后,居然和之前通过指针修改变量值的汇编代码一模一样。咦?仿佛发现了引用的本质呀!

常量引用

在传统C++中我们知道,引用变量不能引用一个右值,但是常引用可以办到这一点,测试代码如下:

1
const int& a = 6;

转换成汇编代码如下:

1
2
3
4
movl    $6, %eax            //把立即数放到寄存器%eax中
movl %eax, -20(%rbp) //把寄存器%eax中的数字6放到内存地址为-20(%rbp)的位置,一个临时变量中
leaq -20(%rbp), %rax //把临时变量的内存地址-20(%rbp)放到寄存器%rax中
movq %rax, -16(%rbp) //把寄存器%rax中存储的临时变量的内存地址-20(%rbp)放到内存地址为-16(%rbp)的位置

这段代码的翻译结果与前面指针变量的例子很像,首先有一个变量(匿名变量)来存储值,然后是一个新的内存地址来保存之前变量的地址。

右值引用

右值引用需要C++11才能使用,与常引用对比的优点就是可以修改右值,实际上我认为还是修改的左值!测试代码如下:

1
2
int&& a = 6;
a = 2

转换成汇编代码如下:

1
2
3
4
5
6
movl    $6, %eax            //把立即数放到寄存器%eax中
movl %eax, -20(%rbp) //把寄存器%eax中的数字6放到内存地址为-20(%rbp)的位置,一个临时变量中
leaq -20(%rbp), %rax //把临时变量的内存地址-20(%rbp)放到寄存器%rax中
movq %rax, -16(%rbp) //把寄存器%rax中存储的临时变量的内存地址-20(%rbp)放到内存地址为-16(%rbp)的位置
movq -16(%rbp), %rax // 把内存地址为-16(%rbp)的位置(变量p)的数据放到寄存器%rax中
movl $2, (%rax) // 把立即数2放在寄存器%rax中保存的地址位置中,也就是p所指向的地址,即变量a中

这段汇编代码与常量引用相比只缺少赋值的部分,与左值引用相比几乎一样,只有在最开始立即数6的处理上有一点点差异,是不是感觉很神奇?

一点点惊奇

对比了前面这些代码的汇编指令后有没有什么想法?什么常量引用,什么右值引用,这些不过都是“愚弄”程序员的把戏,但这些概念的出现并不是为了给程序员们带来麻烦,相反它们的出现使得程序编写更加可控,通过编译器帮助“粗心”的开发者们先暴露了一波问题。

通过汇编代码来看,常量引用其实引用的并非常量,而是引用了一个变量;右值引用引用的也并非右值,同样是一个保存了右值的变量。这年头常量都能变,还有什么不能变的呢?

来看看下面这段代码,仔细想想常量真的变了吗?运行之后各个变量的值是多少呢?

1
2
3
4
5
6
const int a = 6;
int *p = const_cast<int*>(&a);
*p = 2;

int b = *p;
int c = a;

这段代码运行之后的打印结果:a=6, b=2, c=6,变量a作为一个常量没有被改变,貌似常量还是有点用的,哈哈~

这段代码转换成汇编代码如下:

1
2
3
4
5
6
7
8
9
movl    $6, -28(%rbp)
leaq -28(%rbp), %rax
movq %rax, -16(%rbp)
movq -16(%rbp), %rax
movl $2, (%rax)
movq -16(%rbp), %rax
movl (%rax), %eax
movl %eax, -24(%rbp)
movl $6, -20(%rbp)

通过汇编来看你会发现,其实变量a的值已经通过指针 p 修改过了,只不过后面引用a变量的地方,因为它是常量,直接使用立即数6替换了。

改写一下代码,将常量6换成一个变量:

1
2
3
4
5
6
7
int i = 3;
const int a = i;
int *p = const_cast<int*>(&a);
*p = 2;

int b = *p;
int c = a;

转换成汇编代码为:

1
2
3
4
5
6
7
8
9
10
11
12
movl    $3, -28(%rbp)
movl -28(%rbp), %eax
movl %eax, -32(%rbp)
leaq -32(%rbp), %rax
movq %rax, -16(%rbp)
movq -16(%rbp), %rax
movl $2, (%rax)
movq -16(%rbp), %rax
movl (%rax), %eax
movl %eax, -24(%rbp)
movl -32(%rbp), %eax
movl %eax, -20(%rbp)

这段代码运行的结果为:i=3, a=2, b=2, c=2,看来常量也禁不住我们这么折腾啊

所以从这一点可以看出C++代码中无常量,只要是定义出的变量都可以修改,而常量只是给编译器优化提供一份指导,比如可以把一些字面量在编译期间替换,但是运行时的常量还是能改的。

总结

  • 左值和右值更像是容器与数据的关系,不过C++11提出的将亡值的概念又模糊这两者的界限,将亡值可以看成是即将失去容器的数据
  • 在Ubuntu16.04、GCC5.4.0的环境下,通过左值引用和指针修改一个变量值生成的汇编代码完全一致
  • C++11中右值引用与常量引用生成的汇编代码一致,与左值引用生成的代码只在初始化时有一点差异
  • 常量并非不可修改,它只是一种“君子协定”,你要知道什么情况下可以改,什么情况下绝对不可以改
  • const_cast 目的并不是让你去修改一个本身被定义为const的值,这样修改后果是可能是无法预期的,它存在的目的是调整一些指针、引用的权限,比如在函数传递参数的时候

==>> 反爬链接,请勿点击,原地爆炸,概不负责!<<==

身上若无千斤担,谁拿生命赌明天~
世间唯一不变的就是变化

2021-7-5 00:36:29

Albert Shi wechat
欢迎您扫一扫上面的微信公众号,订阅我的博客