c++中指针和引用的区别

一、问题背景

昨天被问到了c++中指针和引用的区别,其实这个问题很早就从网上和书中看过,毕竟这是c++最常用也是很重要的知识点。我一直的理解是指针是一种数据结构,它和int, float等其他数据结构其实没有什么本质的区别,只不过指针这个数据结构表示的值是一个内存地址,用户可以通过指针变量的值去访问相应内存地址中的数据;对于引用,一直以来网上和书上大多数对引用的解释是引用和其绑定的变量是同一个东西,但是怎么去解释什么叫同一个东西,就不好说了,而且我一直以为引用是不占空间的,直观上的理解和它绑定的变量是同一个东西嘛,那引用自身就不占空间咯,但是事实并不是这样,今天从底层对这两个数据类型分析了下,从原理上来理解指针和引用的本质区别。

二、用法上的区别

首先列一下指针和引用使用上的区别,也就是常见的书上和网上说的区别:
此处引用下面bolg中写的内容:
https://www.cnblogs.com/dolphin0520/archive/2011/04/03/2004869.html

(1) 指针:指针是一个变量,只不过这个变量存储的是一个地址,指向内存的一个存储单元;而引用跟原来的变量实质上是同一个东西,只不过是原变量的一个别名而已。如:

int a=1;int *p=&a;
int a=1;int &b=a;

上面定义了一个整形变量和一个指针变量p,该指针变量指向a的存储单元,即p的值是a存储单元的地址。
而下面2句定义了一个整形变量a和这个整形a的引用b,事实上a和b是同一个东西,在内存占有同一个存储单元。

(2)可以有const指针,但是没有const引用;
(3)指针可以有多级,但是引用只能是一级(int **p;合法 而 int &&a是不合法的)
(4)指针的值可以为空,但是引用的值不能为NULL,并且引用在定义的时候必须初始化;
(5)指针的值在初始化后可以改变,即指向其它的存储单元,而引用在进行初始化后就不会再改变了。
(6)”sizeof引用”得到的是所指向的变量(对象)的大小,而”sizeof指针”得到的是指针本身的大小;
(7)指针和引用的自增(++)运算意义不一样;

三、从汇编代码看看引用和指针的实现

我们通常用引用的时候是函数调用传参时,使用引用的代码相对于用指针更加简洁,而且不容易出错,我拿3份代码做对比:

测试环境:
操作系统: osx 10.12.6
g++版本:4.2.1

3.1 普通传参

main.cpp文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
using namespace std;
int f(int a, int b) {
a = a + b;
return a;
}
int main()
{
int a = 1, b = 2;
int c = f(a, b);
cout << c << endl;
return 0;
}

这是最简单的函数调用了,当然我们知道f()函数里面对a的修改并不会影响main()函数中的变量a的值。
我用-O0优化来编译,看下汇编代码

1
g++ main.cpp -S -O0 -o main.asm

汇编代码如下:

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
... ...
__Z1fii: ## @_Z1fii
## BB#0:
pushq %rbp
movq %rsp, %rbp
movl %edi, -4(%rbp)
movl %esi, -8(%rbp)
movl -4(%rbp), %esi
addl -8(%rbp), %esi
movl %esi, -4(%rbp)
movl -4(%rbp), %eax
popq %rbp
retq
... ...
_main: ## @main
... ...
pushq %rbp
subq $48, %rsp
movl $0, -20(%rbp)
movl $1, -24(%rbp)
movl $2, -28(%rbp)
movl -24(%rbp), %edi
movl -28(%rbp), %esi
callq __Z1fii
... ...

这个很简单,程序栈中的数据分布如图所示,调用f函数就是将main函数中的a,b变量值拷贝到f栈空间中a,b变量处,然后进行计算,在f函数中对a,b的赋值都是只改变f栈空间中的a,b变量,并不会影响到main中的a,b。

3.2 指针传参

同样的方法:
pointer.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
using namespace std;
int f(int *a, int *b) {
*a = *a + *b;
return *a;
}
int main()
{
int a = 1, b = 2;
int c = f(&a, &b);
cout << c << endl;
return 0;
}
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
__Z1fPiS_: ## @_Z1fPiS_
## BB#0:
... ...
pushq %rbp
movq %rsp, %rbp
movq %rdi, -8(%rbp)
movq %rsi, -16(%rbp)
movq -8(%rbp), %rsi
movl (%rsi), %eax
movq -16(%rbp), %rsi
addl (%rsi), %eax
movq -8(%rbp), %rsi
movl %eax, (%rsi)
movq -8(%rbp), %rsi
movl (%rsi), %eax
popq %rbp
retq
... ...
_main: ## @main
## BB#0:
pushq %rbp
movq %rsp, %rbp
Ltmp5:
subq $48, %rsp
leaq -24(%rbp), %rdi
leaq -28(%rbp), %rsi
movl $0, -20(%rbp)
movl $1, -24(%rbp)
movl $2, -28(%rbp)
callq __Z1fPiS_
... ...

从汇编里看到,这里利用rdi, rsi讲 a, b的地址传给了f函数,f函数中通过获取的addr_a, addr_b,从而间接获取a,b的值,以及可以对a,b的值进行操作。其函数栈数据分布如下图:

3.3 引用传参

reference.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
using namespace std;
int f(int &a, int &b) {
a = a + b;
return a;
}
int main()
{
int a = 1, b = 2;
int c = f(a, b);
cout << c << endl;
return 0;
}
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
... ...
__Z1fRiS_: ## @_Z1fRiS_
## BB#0:
pushq %rbp
movq %rsp, %rbp
movq %rdi, -8(%rbp)
movq %rsi, -16(%rbp)
movq -8(%rbp), %rsi
movl (%rsi), %eax
movq -16(%rbp), %rsi
addl (%rsi), %eax
movq -8(%rbp), %rsi
movl %eax, (%rsi)
movq -8(%rbp), %rsi
movl (%rsi), %eax
popq %rbp
retq
_main: ## @main
## BB#0:
pushq %rbp
movq %rsp, %rbp
subq $48, %rsp
leaq -24(%rbp), %rdi
leaq -28(%rbp), %rsi
movl $0, -20(%rbp)
movl $1, -24(%rbp)
movl $2, -28(%rbp)
callq __Z1fRiS_
... ...

从汇编代码看出来,利用引用传参时,传的参数和利用指针其实是一样的,都是传的a,b的地址。那么从这里可以看出来,其实引用和指针在底层实现其实是一样的(至少在函数传参方面),两者在使用上的区别主要是c++规定的语法上的编写代码方法不同和限制不同而已。

3.4 引用占空间吗?

网上总说,引用和它绑定的变量是同一个东西,只是别名而已,然后我就一直理解为名称替换这种形式的,就是编译时编译器把所有引用名称替换成它绑定的变量就行了。实际情况呢,我写了个代码试试,我把3.3的代码改改,加个引用变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
using namespace std;
int f(int &a, int &b) {
a = a + b;
return a;
}
int main()
{
int a = 1, b = 2;
int &d = a;
int c = f(d, b);
cout << c << endl;
return 0;
}
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
... ...
__Z1fRiS_: ## @_Z1fRiS_
## BB#0:
pushq %rbp
movq %rsp, %rbp
movq %rdi, -8(%rbp)
movq %rsi, -16(%rbp)
movq -8(%rbp), %rsi
movl (%rsi), %eax
movq -16(%rbp), %rsi
addl (%rsi), %eax
movq -8(%rbp), %rsi
movl %eax, (%rsi)
movq -8(%rbp), %rsi
movl (%rsi), %eax
popq %rbp
retq
_main: ## @main
## BB#0:
pushq %rbp
movq %rsp, %rbp
subq $64, %rsp
leaq -28(%rbp), %rsi
leaq -24(%rbp), %rax
movl $0, -20(%rbp)
movl $1, -24(%rbp)
movl $2, -28(%rbp)
movq %rax, -40(%rbp)
movq -40(%rbp), %rdi
callq __Z1fRiS_
... ...

可以从汇编代码看出来,这里main函数的栈空间长度从3.3中的

1
subq $48, %rsp

变成了

1
subq $64, %rsp。

也就是说实际上引用d是占了栈空间的,从下面两行看出来,其实底层它还是一个指针。

1
2
movq %rax, -40(%rbp)
movq -40(%rbp), %rdi

四、结论

通过上面的分析,我最后得出的结论就是:

  1. 在使用上,指针和引用的区别就像第二章列的几点,有着不同的限制和写法。
  2. 在底层(汇编)实现上,其实是一样的,都是用指针方式,也就是说所有的不同都是编译器在语法层面的限制,底层实现原理是一样的。

最后,不同的编译器和优化选项可能造成不同的结果,不知道开-O2优化后会是什么样,我总觉得从c++的规定来说,在不传参的时候,引用不就是别名么,为什么要分配个空间呢~,我觉得开优化编译器会把这个优化掉,直接做个名称替换不就可以了,对于开优化后的情况,以后有时间再试试吧。