三种函数参数传递原理分析

  • 前言:为了能够简洁快速说明问题,一路贴代码
  • 本博文主要想分析:引用传递的底层实现

一、值传递

#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的地址赋值给寄存器rax
mov 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++编译器在编译过程中使用常指针作为引用的内部实现,引用所占用的空间的大小与指针相同