今天,我终于学懂了C++中的引用-2

简介: 今天,我终于学懂了C++中的引用

2、做返回值【⭐⭐⭐】

第二种引用的场景就是【做返回值】,因为这种场景在后面说到的C++里的类和对象中会大量出现,而且由于引用的语法很难理解,因此我会带你一步步学习,搞懂这这一种场景的使用

① 引入:栈区与静态区的对比

在讲引用做返回值之前我需要讲解一些知识点作为铺垫,希望正在阅读的你也可以认真观看和思考,这对下面的理解至关重要

  • 首先我们通过下面这段代码再来谈谈有关函数返回值的问题,再调用完一个函数之后,它的值是如何返回的呢?外界又是如何接收到的呢?一起来看看吧👇
int Add(int x, int y)
{
  int c = x + y;
  return c;
}
int main(void)
{
  int ret = Add(1, 2);
  cout << "ret = " << ret << endl;
  return 0;
}
  • 通过【反汇编】我们可以观察到当程序执行到call指令的时候,会把call指令的下一条指令地址压入栈中,相当于记住了这个地址。

image.png

  • 接着进入到函数中,其内部计算出来一个值要返回给到外界的时候并不是直接返回,而是将这个需要返回的值放到一个临时的寄存器中去(VS下一般都是eax

image.png

  • 然后当这个函数调用完成后会直接转到call指令的下一跳指令开始执行,此时我们通过汇编指令mov就可以知道编译器将【eax】中临时存放的函数的返回值转存到了这个临时变量ret中

image.png

如果上面有些看不懂的话可以看看👉函数栈帧的创建和销毁


知道了这些以后我们再来对比一下下面的两个Count函数,你觉得它们哪里不太一样呢🤨

int Count()
{
  int n = 0;
  n++;
  // ...
  return n;
}
int Count()
{
  static int n = 0;
  n++;
  // ...
  return n;
}
  • 没错,就是这个static的区别。通过画出函数调用的堆栈图我们可以看出对于两个不同的Count()函数而言其内部临时变量所存放的位置是不同的。我们知道,对于函数中的普通变量而言,是存放在当前所开辟函数的栈帧中的,即存放在内存中的==栈区==;但是对于函数中的静态变量而言,是不存放在当前函数栈帧中的,而是存放在内存中的==静态区==,包括平常可能会使用到的全局变量也是存放在其中
  • 对于【栈区】和【静态区】而言,如果你有了解的过的话应该可以清楚地知道存在其内部的变量的生命周期是不同的:
  • 存放在【栈区】中的临时变量当函数调用结束后整个函数栈帧就会被销毁,那么存放在这个栈帧中的临时变量也随之消亡,不复存在
  • 存放在【静态区】中的变量它们的生命周期是从创建开始到整个程序结束为止,所以不会随着当前所在的函数栈帧销毁而消亡💀

image.png

  • 上面通过画出两个Count()函数的堆栈图了解到了函数中临时变量和静态变量所在空间是不同的,那当执行完这个函数之后其所在栈帧一定会销毁,此时又会发生怎样的故事呢?我们继续看下去

首先你必须要清楚的一些点:

  1. 当我们定义变量 / 创建函数 / 申请堆内存的空间时,系统会把这块空间的使用权给到你💪,那么这块空间你在使用的时候是被保护的🛡,被人无法轻易来访问、入侵你的这块空间。但是当你将这个空间销毁之后,它并不是不存在了、被粉碎了,只是你把对于这块空间的使用权还给操作系统了,不过这块空间还是存在的,因此你可以通过某种手段访问到这块空间🗡,由于操作系统又收回了这块空间的使用权,继而它便可以对其进行再度分配给其他的进程,那它就可能又属于别人了
  2. 所以你通过某种手段去访问这个空间的时候其实属于一种非法访问⚠,可是呢这种非法访问又不一定会报错,就像之前我们说到过的==数组越界、访问野指针==都不一定会存在报错。为什么?因为编译器对于程序的检查是一种【抽查行为】,不一定能百分百查到,所以你在通过某些手段又再次访问到这块空间后所做的一些事都是存在一种【随机性】的
  • [x] 上面所说的这些还望读者一定要牢记!!!因为这对于下文的理解以及后续的学习都是非常有帮助的
  • 好,接下去我们回归正题,继续来说一说有关函数栈帧销毁之后这个返回值是如何给到这个外界的值做接收的。相信你在本小节一开始讲到的这些内容之后再来看下图一定是非常得清晰
  • 不过对于这个临时变量而言,还要提一嘴的是它不一定就是一个寄存器,因为对于寄存器而言计算机内部的容量是非常小的,大概是只有4 / 8B,若是一个函数需要返回空间很大的东西时就无法承载,就比如说要返回一个结构体就可能会放不下,因为结构体中存在各种各样的数据类型。所以对于临时变量而言有下面两种形式
  • 若是返回空间小一点的变量时使用的就是【寄存器】
  • 若是返回空间大一点的内容时使用的就是【临时变量】,这个临时变量会提前在main函数栈帧中提前开好

image.png

  • 对于普通的存放在函数栈帧中的变量需要通过【临时变量】来暂存一下然后再返回,那现在我们来看看存放在静态区中的变量在函数栈帧销毁之后是如何返回给到外界的值做接收的呢?那有同学想:既然它都不存在于这个函数的栈帧中,那么也就不需要临时变量了吧,直接返回这个n不就好了
  • 可是呢事实却不是这样,编译器可不会去管你这个变量是在【栈区】还是【静态区】的,它依旧还是傻傻🤨地在返回的时候将这个n先存放到临时变量中,然后回到调用的main函数中时再把临时变量中的内容拷贝到这个接收值中

image.png

  • 当然,如果你不相信的话,我们还可以通过反汇编的形式来看看是否真的用到了这个【临时变量】。通过下图的观察可以得知,无论是对于Count1()还是Count2(),在调用函数结束之后都会存在一个eax最后通过【mov】指令将寄存器中存放的临时值给到ret所在的这块空间

image.png

有些不太理解函数的调用和返回过程的同学可能就会钻牛角尖🐂提出这样的问题:做这么一个临时变量做返回不是很麻烦吗,为什么不先把这个值返回给外界,然后再销毁函数栈帧呢?

  • 这一块的话你就要非常清楚有关函数栈帧建立和销毁的过程,以及这个==临时变量是何时创建的❓何时暂收返回值的?又是何时赋值的?==
  • 因为我们通过call指令去调了这个函数,它的栈帧就被建立起来。此时就在我们就正处在这个函数的栈帧内部了,每个栈帧都是通过[ebp][esp]来维护它所在这块空间的。虽然是记住了call指令的下一跳指令,但是没有记住需要接收的这个【ret】,因此在被调用的函数栈帧内部是无法找到这个接收变量ret的,是很难定位到它所在的这个空间的。但是我们又想把这个值返回回去,此时只能借助一个出了栈帧不会销毁的容器去承载、暂时保存一下这个返回值,然后当我们回到call指令的下一条指令时继续往下执行,才能顺理成章地找到这个【ret】,然后将寄存器中存放的临时值再赋值给到它做接收

希望我这么说你可以真正理解了这个过程👆

【总结一下】:

  • [x] 当需要将函数中的临时变量返回时,无论这个变量是在栈区、堆区或者静态区开辟空间,都会通过一个临时变量去充当返回值【小一点的话可能是寄存器eax,大一点可能是在上一层栈帧开好的】然后再返回给外界的值做接受

② 优化:传引用返回【权力反转】

通过上面的示例你应该会觉得对于【栈区】而言使用临时变量返回还是合情合理的,可以【静态区】为什么也要通过临时变量来返回呢,这不是多此一举吗?

  • 那有什么办法可以免去这种拷贝的过程,直接将得出的结果返回回去呢?那就是==引用返回==
int& Count()
{
  static int n = 0;
  n++;
  // ...
  return n;
}
  • 对于引用返回来说就不会产生这个临时变量了,返回的只是n的别名,那你也可以说相当于就是把n返回回去了,编译器呢把这个权利给到了你,对于函数栈帧销毁依旧存在的内容,如果我们不想让其拷贝到临时变量中进行返回,是可以通过引用来进行返回的,这样就可以减少拷贝,对程序做了一小部分的优化

image.png

如果你想要进一步了解其返回的过程和直接【传值返回】有何区别,此时可以通过汇编来看看

  • 通过观察其实可以发现【传引用返回】和【传值返回】是存在一定区别的。对于后者而言并不是简单地将n中的内容暂存到寄存器eax中,而是通过汇编里的一个属性操作符offset进行了n个位置的偏移(汇编这一块我研究的不是很深,感兴趣的老铁可以去看看 链接

image.png

  • 因为我们可以做一个小结:对于像静态变量、全局变量等这些出了作用域不会销毁的对象,就可以使用【传引用返回】

但其实也不局限于上面的这两种,只要是出了作用域不会销毁都可以使用【传引用返回】

  • 来看到下面这几段代码,我定义了一个有关数组Array的结构体,然后写了一个函数去访问这个数组中的第i个元素
#define N 10
typedef struct Array
{
  int a[N];
  int size;
}AY;
int PostAt(AY& ay, int i)
{
  assert(i < N);
  return ay.a[i];
}
  • 可以看到访问到了这个数组中的内容时对其进行了一个修改,此时我们来分析一下这个PostAt()函数,其所返回的ay.a[i]出了这个函数的作用域之后会不会销毁?很明显它并不是一个静态变量或者是全局变量,而是在外部就已经开好的一个结构体变量,其实也算是一个局部变量,只是它不存在于PostAt()这个函数的栈帧中,而是在main函数的栈帧中
AY ay;
// 修改返回值
for (int i = 0; i < N; i++)
{
  PostAt(ay, i) = i * 10;
}
  • 所以当一个函数的返回值不会因为当前所在函数的函数栈帧销毁而消亡的时候,此时我们就可以对其去进行一个优化,做一个【传引用返回】
int& PostAt(AY& ay, int i)
  • 最后再来看看修改完后最后的运行结果

image.png

  • 其实对于上面的这个Array结构体和PostAt()函数,是C++11在STL中出现的新函数叫做【at()】,功能就是我上面所实现的这些,随着后面C++STL的学习会说到这一块,感兴趣的可以提前了解一下

image.png【总结一下】:

  • [x] 传引用返回的好处在于:① 可以减少拷贝;② 调用者可以修改返回对象

③ 理解:引用返回的危害 - 造成未定义的行为【薛定谔的猫🐱】

在上面,我介绍到了一种对函数返回进行优化的方法 ——> 传引用返回,于是有的同学就觉得它很高大上,因此所以函数都使用了传引用返回,你认为可以吗?

  • 来看看下面这段代码,你认为它的输出结果是什么呢?是3吗❓ 还是7❓ 亦或者是其他值
int& Add(int a, int b)
{
  int c = a + b;
  return c;
}
int main()
{
  int& ret = Add(1, 2);
  Add(3, 4);
  cout << "Add(1, 2) is :" << ret << endl;
  return 0;
}
  • 首先我去编译了一下就已经发现不对劲了🤨,报了一个Warning说返回局部变量或临时变量的地址,上面我有说到过对于传引用返回而言并不需要临时变量去进行拷贝,返回的是这个变量的别名
  • 其实细心的读者已经可以发现了,这个c只是存在于Add()函数栈帧中的一个临时变量而已,上面我们说到过对于出了作用域就会销毁的变量是不可以进行返回的,因此会报出下面这个Warning

image.png

  • 正式运行一下可以发现输出的结果是【7】,而不是一开始计算出来的的【3】

image.png

  • 此时,我手痒痒:hand:又去打印了一次,就发现这个【ret】变成了一个很大的随机值,这是为什么呢🤔

image.png

接下去就来好好谈一谈究竟问题出在哪里:mag:

  • 首先通过画出堆栈图,在Add()内部,通过计算出两数之和将其放到一个处于当前栈帧中的临时变量c中,最后将其return,设想若是在函数的返回值中不加&的话,那这就是我们平常写的一个函数,然后外界去做一个接受。但若是加上引用之后就不对了,因为这是一个临时变量,出了当前作用域后会随着函数栈帧的销毁而销毁,此时就已经出现问题了👈
  • 所以抛开外界如何去接收这个返回值,首先你要认清的一点是这个Add函数本身就已经是错误的了。然后我们再来看外界的接收值,本来应该是正常去做接收,但是我使用了一个【引用接收】,这一点我会在下一小节具体阐述
  • [x] 现在你要知道的是因为Add函数做了一个【引用返回】,即返回了c的别名,但此时呢ret又使用引用接收了c的引用,所以可以说【ret又引用了c的引用】,那此时也就可以说ret与c就融为一体了,那么ret也就是c这块空间的别名

image.png如果此刻直接去访问【ret】的话它的值会是多少呢?==答:可能是3,可能是7,也有可能是一个随机值==

  • 为了方便观察,现在我们将Add(3,4);屏蔽掉。那么此时的结果就有可能是3,有可能是随机值

image.png

< 原理解说 >

  • 为什么呢?这一点我在上面也有提到过。因为当Add函数栈帧销毁的时候,其空间还是在的,只是使用权不是你的了,可是呢它被操作系统回收了,操作系统就还可以把它分配给其他进程,那此时就可以说这块空间被重复利用了,下一次的函数调用可能还是在这块空间上建立栈帧,但是上一次的栈帧是否清理取决于编译器,可能清理了,也可能没清理
  • 如果编译器没有清理这个栈帧的话,那么这个c就还是3
  • 如果编译器清理了这个栈帧的话,这个c就有可能是个随机值

image.png

可能就像上面这么说不是很好理解,我们通过【薛定谔的猫🐱】这个梗来进行讲解

  • 你呢,在外面的酒店开始一间房,第二天呢你要退房了,不过你在房间里放一只猫,当退完房后便去外面配了一把和酒店房门一模一样的钥匙:key:找了回来,但是这个猫不一定在,存在下面几种可能性
  1. 这个房间和你走的时候一模一样没有变过,猫还静悄悄地躺在那里
  2. 猫不在了!酒店保洁在打扫完了这个房间后发现了一只猫就把它带走了
  3. 猫没有被发现,这个房间又分配给别人了,那个人很喜欢这只猫🐱,把它抱在怀里:heart:

image.png

  • 从上面这个案例其实可以看出对于你在酒店里面放一只猫,退房之后再找回去,能否找到这只猫存在一定的随机性😛在网上有一个梗就是【薛定谔的猫】,表示所要发生的这一件事是玄幻的或是不确定的,刚好和我们这里的例子🌰很照应,所以拿出来给大家开心一刻:smile:
  • 不过呢,也不应该把一个重要的物品放到一个已经退掉的房间里,如果要走了,但是猫放在这个房间里,可以续订一下这个房间,那么这个房间就还是你的,猫放在里面就不会出现问题

通过上面这个案例我想说明什么呢?

  • [x] 其实很简单,就是不要去返回出了作用域就销毁的变量,虽然语法是允许的,但这个程序是错误的,结果是未定义的👈 如果觉得上面的内容难以理解,记住这句话就行了

④ 回瞰:引用接收与非引用接收

接下去我们再来详细说说ret使用引用接收这一块较难理解的地方

  • 首先你可以思考,为什么我要使用引用来进行接收,不是已经使用【引用返回】了吗?这不又是多次一举吗?这里你确实可以认为我是多次一举,因为这其实是为了==提升读者对于引用的理解罢了==😁
  1. 【引用接收】:ret充当的就是c的别名,后面cout访问ret的时候访问的就是c,第一次是3,第二次就变成随机值了,是不确定的
int& ret = Add(1, 2);
  1. 【非引用接收】:相当于是把c的值做一份临时拷贝给到ret,那ret的值是什么取决于编译器第一次在销毁栈帧的时候是否清理这块空间
int ret = Add(1, 2);
  • 通过我上面的对比分析你应该就可以明白引用接受和非引用接收不是这代代码错误的根本,这段代码错就错在对于返回出了作用域就销毁的变量,导致出现了结果未定义的现象

⑤ 结语:正确认识【传值返回】与【传引用返回】

好了,看到这里,相信你对引用做返回值的使用场景应该有了很深刻的理解,来做个总结

  • 如果你觉得很难理解,那说明你是个正常人,C++引用这一块尤其是做函数返回值的时候是最难理解的,但是通过画图去理解分析就会好很多了,通过画出这个函数的栈帧图就可以很清晰地看明白所有的一切image.png

最后的话再带读者来回顾一下【传值返回】和【传引用返回】

  • [x] 传值返回:如果已经还给系统了,随着当前作用域的结束而一起销毁的
  • [x] 传引用返回:只要是出了当前作用域不会销毁,并且函数栈帧销毁不影响其生命周期【全局变量、静态变量、上一层栈帧、malloc的】
相关文章
|
5天前
|
C++
C++中的const指针与const引用
C++中的const指针与const引用
21 2
|
8天前
|
C++
C++程序中对象成员的引用
C++程序中对象成员的引用
18 2
|
13天前
|
存储 安全 编译器
【C++入门】缺省参数、函数重载与引用(下)
【C++入门】缺省参数、函数重载与引用
|
1天前
|
存储 C++
c++引用
c++引用
8 1
|
8天前
|
存储 安全 编译器
从C语言到C++②(第一章_C++入门_中篇)缺省参数+函数重载+引用(下)
从C语言到C++②(第一章_C++入门_中篇)缺省参数+函数重载+引用
13 0
|
8天前
|
存储 编译器 C语言
从C语言到C++②(第一章_C++入门_中篇)缺省参数+函数重载+引用(中)
从C语言到C++②(第一章_C++入门_中篇)缺省参数+函数重载+引用
16 0
|
8天前
|
自然语言处理 编译器 C语言
从C语言到C++②(第一章_C++入门_中篇)缺省参数+函数重载+引用(上)
从C语言到C++②(第一章_C++入门_中篇)缺省参数+函数重载+引用
21 0
|
13天前
|
C++
c++引用是什么意思?
c++引用是什么意思?
6 2
|
13天前
|
C++
c++引用看这个就够了
c++引用看这个就够了
17 0
|
13天前
|
存储 安全 C++
深入理解C++中的指针与引用
深入理解C++中的指针与引用
13 0