三种函数参数传递原理分析
- 前言:为了能够简洁快速说明问题,一路贴代码
- 本博文主要想分析:引用传递的底层实现
一、值传递
#include<cstdio> //值传递 void swap(int x,int y) { int temp=x; x=y; y=temp; } int main() { int a=3; int b=5; swap(a,b); printf("a=%d b=%d",a,b); return 0; }
Output:
a=3 b=5
值传递分析:
进入swap函数之前,新开的x变量将a中的bits全复制过来,也就是a将自己的值传递给了x
相当于是这样的
int a=3; int x=a;
这样的对x的修改,那种副作用当然是影响不到a的。
二、地址传递
#include<cstdio> //地址传递 void swap(int *x,int *y) { int temp=*x; *x=*y; *y=temp; } int main() { int a=3; int b=5; swap(&a,&b); printf("a=%d b=%d",a,b); return 0; }
Output:
a=5 b=3
地址传递分析:
进入swap函数之前,新开的指针变量x指向变量a,也就是a将自己的地址传递给了指针变量x
相当于是这样的
int a=3; int *x=&a;
所以,我们对指针x的赋值,其实就是对变量a在操作,因而能反映到a上去。
三、引用传递
#include<cstdio> //引用传递 void swap(int &x,int &y) { int temp=x; x=y; y=temp; } int main() { int a=3; int b=5; swap(a,b); printf("a=%d b=%d",a,b); return 0; }
Output:
a=5 b=3
引用传递分析:
进入swap函数之前,新开的变量x引用变量a
相当于是这样的
int a=3; int &x=a;
所以,我们对x的赋值,其实就是对变量a在操作,因而能反映到a上去。
PS:其实本质有一半是下面这样
#include<cstdio> //引用传递的底层等价方式 void swap(int * const x,int * const y) { int temp=*x; *x=*y; *y=temp; } int main() { int a=3; int b=5; swap(&a,&b); printf("a=%d b=%d",a,b); return 0; }
四、引用的实质
1)结论
引用的底层实现是常量指针,指针的指向不可变,指向的对象可变
int a; int &x = a;
有一半等价于
int a; int * const p = &a;
2)思路分析
引用概念有三:
- 1)定义引用时一定要将其初始化成引用某个变量。
- 2)初始化后,它就一直引用该变量,不会再引用别的变量了。(从一而终)
- 3)引用只能引用变量,不能引用常量和表达式。
从这3条规定,我联想到的是C语言编译器向C++
编译器过渡的时候,如何用C语言来支持这些规定。
就像最初C++
中的类,底层是用C语言中的struct实现一样。
所以联想到了C语言中的const指针。
五、汇编角度的“引用”底层释疑
FAQ:
- Q:引用占用内存吗?
- A:占用,并且引用本身存放的是引用对象的地址,通俗点理解就是引用就是通过指针来实现的
- Q:那如何解释下面这段代码的输出?
#include<cstdio> int main() { int a=3; int &x=a; printf("&a=%d \n",&a); printf("&x=%d \n",&x); return 0; }
输出
&a=10485316 &x=10485316
A:我们反汇编来观察一下
VS2012反汇编节选,我用的win32
int a=3; 0111368E mov dword ptr [a],3 int &x=a; 01113695 lea eax,[a] 01113698 mov dword ptr [x],eax
mov dword ptr [a],3
//把3赋值给变量a的地址lea eax,[a
] //将变量a的地址放到eax寄存器中mov dword ptr [x],eax
//把寄存器eax里的值赋值给变量x的地址
其他版本的VS反汇编可能不一样,比如有的反汇编为下面这样
用的Win64
int a=3; mov DWORD PTR [rbp-12], 3 int &x=a; lea rax, [rbp-12] mov QWORD PTR [rbp-8], rax
解释:
由于写的win64程序,所以反汇编后的寄存器,是64位的寄存器了,比如rax和rbp
这种反汇编得到的,可以说,更好的从内存管理的角度解释了一些其他问题mov DWORD PTR [rbp-12], 3
//把3赋值给rbp(栈底指针)-12的地址lea rax, [rbp-12]
//把rbp-12的地址赋值给寄存器raxmov QWORD PTR [rbp-8], rax
///把寄存器eax里的值赋值给rbp-8的这块地址
引用的另一半解释
- 引用x的地址我们没法通过&x获得,因为编译器会将&b解释为:&(*b) =&a ,所以&b将得到&a。
也验证了对所有的x的操作,和对a的操作等同
六、尝试利用传统的内存管理获取引用的真正地址
一、DevC++5.11中测试
#include<cstdio> int main() { int begin=1; int a=3; int &x=a; int end=5; printf("&begin=%d \n",&begin); printf("&a=%d \n",&a); printf("&end=%d \n",&end); return 0; }
输出
&begin=10485316 &a=10485312 &end=10485308
分析:
变量begin,a,end的地址分布,符合标准的内存管理模型的栈的从上往下生长
但是,疑问:那引用x的地址呢?
本想利用,类似于数学上的“夹逼准则”的方式,获取引用的地址。。。失败
继续换平台测试
二、VS2012测试同样的代码
&begin=1637444 &a=1637432 &end=1637408
分析:
这个结果就有意思了,变量begin,a,end的地址分布,符合标准的内存管理模型的栈的从上往下生长
并且,我们发现,begin和a之间相隔12个字节??
a和end之间相隔24个字节??
先不说那些个奇奇怪怪的,相邻变量的地址之间为啥相隔12个字节,而不是像DevC++那样的标准4个字节。
我们先关注一下a和end之间24个字节
反汇编一下:
int begin=1; 00D33C7E mov dword ptr [begin],1 int a=3; 00D33C85 mov dword ptr [a],3 int &x=a; 00D33C8C lea eax,[a] 00D33C8F mov dword ptr [x],eax int end=5; 00D33C92 mov dword ptr [end],5
这就尴尬了,又给我优化了那些地址。。汇编代码可读性是倒是提高了,但是我想看实质。。。
那我进行猜想吧,猜测变量x放在变量a和end中
三、gcc 10.2反汇编
在线反汇编网站:https://godbolt.org/
x86-64 gcc 10.2
反汇编:
int begin=1; mov DWORD PTR [rbp-12], 1 int a=3; mov DWORD PTR [rbp-16], 3 int &x=a; lea rax, [rbp-16] mov QWORD PTR [rbp-8], rax int end=5; mov DWORD PTR [rbp-20], 5
容易知道,哪个引用变量的地址竟然在栈的高地址去了,而且没在变量a和end之间,有点和传统的内存管理有出入
四、总结上述3次反汇编
- 由于在早期的内存管理上,我们的变量与变量之间地址是连续的。这就可以通过很明显的地址映射进行修改,破坏具体的程序。
- 后来,为了混淆这些连续的地址,有的编译器进行了修改。这让以前,通过在栈区进行地址+1,-1啥的修改临近变量的方式得到了限制,一定程度上保护了程序的安全性。
- 那么,现在这样设计真的很安全吗?A:还是可以修改,只是麻烦点。
- 正如VS2012和gcc 10.2反汇编的结果那样,无论各种编译器底层各种方式的进行地址修改,编译器设计者的文档一旦被获取,我们还是能够进行特定编译器编译的代码的修改。只是需要看具体设计文档。
结论:目前,栈中变量地址不一定是连续的,因为这跟地址分配有关。
此外,至于C++中引用到底底层是不是用const指针和特殊的编译方式实现的,还是需要看具体编译器的。
但是笔者喜欢用这种理解方式,就相当于,这是帮助我理解引用的一个脚手架。
七、引用占用空间分析(和指针大小一样)
//测试环境gcc 4.9.2 64位 #include<bits/stdc++.h> using namespace std; int main() { do { int *p=NULL; int a=9; int &num=a; char c='k'; char &ss=c; printf("sizeof(p)==%d\n",sizeof(p)); printf("sizeof(num)==%d\n",sizeof(num)); printf("sizeof(ss)==%d\n",sizeof(ss)); }while(0); return 0; }
输出
sizeof(p)==8 sizeof(num)==4 sizeof(ss)==1
看到这个,是不是很懵逼?感觉,引用到底是不是指针实现存疑。
其实,导致上面这个结果的出现,倒不是因为引用的底层实现不是指针,而是sizeof运算符的实现
我们现在测试另外一组
//测试环境gcc 4.9.2 64位 #include<bits/stdc++.h> using namespace std; struct bb { int &a; int &b; }; struct aa { int c; int d; }; int main() { do { //设置结构体aa,是为了检测内存对齐 printf("sizeof(aa)==%d\n",sizeof(aa)); //设置结构体bb,是为了测试引用的底层实现 printf("sizeof(bb)==%d\n",sizeof(bb)); }while(0); return 0; }
输出
sizeof(aa)==8 sizeof(bb)==16
支撑了,我们的想法,C++中引用的实现真的是指针实现
结论:C++编译器在编译过程中使用常指针作为引用的内部实现,引用所占用的空间的大小与指针相同。