简谈C++中指针与引用的底层实现

引用是C++相对于C而引入的一个重要特性,它使得许多地方的语法变得更加简洁,但是它们的底层究竟是怎么实现的呢?

在Wikipedia中,对指针有如下介绍:

In computer science, a pointer is a programming language object that stores the memory address of another value located in computer memory. A pointer references a location in memory, and obtaining the value stored at that location is known as dereferencing the pointer.

从定义可以看出,指针从本质上来讲就是一个变量,其中所存储的就是其他变量的地址。 而C语言中的指针非常灵活,它可以任意指向某一个地址,不论这个地址究竟是否存在,或它究竟存储的是否为指针所代表类型的数据。

那么也不难想到,指针在实现的时候也是内存里的一个变量,它存有其他变量的地址。

在Wikipedia中,对引用有如下介绍:

In computer science, a reference is a value that enables a program to indirectly access a particular datum, such as a variable’s value or a record, in the computer’s memory or in some other storage device. The reference is said to refer to the datum, and accessing the datum is called dereferencing the reference.

In the C++ programming language, a reference is a simple reference datatype that is less powerful but safer than the pointer type inherited from C. The name C++ reference may cause confusion, as in computer science a reference is a general concept datatype, with pointers and C++ references being specific reference datatype implementations. The definition of a reference in C++ is such that it does not need to exist. It can be implemented as a new name for an existing object (similar to rename keyword in Ada).

从上面的定义可以看出,在C++中,可以狭义地认为是某一个变量的别名,它本身是并不存在的。

基于以上说法,我就一度认为引用只是C++编译器在编译时的一些黑魔法,它在运行的时候将两个解析到的符号链接成了一个,从而完成了引用,而在编译之后,引用与本体就是一个变量(一个寄存器或栈上的值)。

但是,事实却打了我的脸。

我们通过以下程序进行检验:

1
2
3
4
5
6
7
8
int main()
{
int a = 0;
int *pa = &a;
int &ra = a;
++(*pa);
++ra;
}

该程序声明了一个变量a,然后分别声明指针pa指向a的地址,申请引用ra指向a,最后分别使用指针和地址对a进行了一次自加操作。

接下来,使用gcc -S test.cpp -o test.s -O0将上面的C++程序编译为汇编,看一下这些操作具体都是怎么实现的。(环境为MacOS,LLVM 10.0.1)

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
47
48
	.section	__TEXT,__text,regular,pure_instructions
.build_version macos, 10, 14 sdk_version 10, 14
.globl _main ## -- Begin function main
.p2align 4, 0x90
_main: ## @main
.cfi_startproc
## %bb.0:
; 保护现场
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
; 保存栈指针
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
; 设置返回值为0
xorl %eax, %eax
; int a = 0
movl $0, -4(%rbp)
; t0 = &a
leaq -4(%rbp), %rcx
; int *pa = t0
movq %rcx, -16(%rbp)
; int &ra = t0
movq %rcx, -24(%rbp)
; t0 = pa
movq -16(%rbp), %rcx
; t1 = *t0 = *pa
movl (%rcx), %edx
; ++t1
addl $1, %edx
; *t0 = *pa = t1
movl %edx, (%rcx)
; t0 = &ra = &a
movq -24(%rbp), %rcx
; t1 = *t0 = *(&ra) = ra = a
movl (%rcx), %edx
; ++t1
addl $1, %edx
; *t0 = *(&ra) = ra = a = t1
movl %edx, (%rcx)
; 恢复现场
popq %rbp
; 返回
retq
.cfi_endproc
## -- End function

.subsections_via_symbols

我已经将汇编中的关键代码加上了注释,可以看出,变量a,指针pa,以及引用ra都位于栈上,index分别在-4、-16、-24。需要注意的是,引用并不是直接复用了变量a的-4(%rbp)地址,而是像指针一样,使用了一个新地址,并且将leaq计算得到的a的地址写入了其中。

而在进行自加的时候,除了最开始将指针中的内容拷贝到寄存器中所用的地址不同以外,指针和引用所使用的方式是完全相同的。

这个结果令我非常意外,编译器其实是将开发者对引用的操作翻译成了对指针的操作

最后,发现现代编译器还是很聪明的,如果将优化级别调到更高,就会发现它直接将中间的计算过程全部简化,直接返回,这是因为计算结果并没有任何输出,它是不必要的。如果将上面的代码从main函数转移到其他函数中,编译器这时虽然不能放弃计算其中的数值,但还是做了尽力的优化,直接返回结果(movl $2, %eax)。

请鼓励我的原创文章