文章目录
- 一、前言
- 二、概念介绍
- 三、引用的五大特性
- 1、引用在定义时必须初始化
- 2、一个变量可以有多个引用
- 3、一个引用可以继续有引用
- 4、引用一旦引用一个实体,再不能引用其他实体
- 5、可以对任何类型做引用【变量、指针....】
- 四、引用的两种使用场景
- 1、做参数
- a.案例一:交换两数
- b.案例二:单链表的头结点修改【SLNode*& p】
- c.案例三:二叉树递归遍历
- 2、做返回值【⭐⭐⭐】
- ① 引入:栈区与静态区的对比
- ② 优化:传引用返回【权力反转】
- ③ 理解:引用返回的危害 - 造成未定义的行为【薛定谔的猫🐱】
- ④ 回瞰:引用接收与非引用接收
- ⑤ 结语:正确认识【传值返回】与【传引用返回】
- 五、传值、传引用效率对比
- 1、函数传参对比
- 2、返回值的对比
- 六、常引用
- 1、权限放大【×】
- 2、权限保持【✔】
- 3、权限缩小【✔】
- 🈲警惕:经典错误分析🔍
- 七、引用与指针的区别总结
- 1、汇编层面对比指针和引用
- 2、感性理解【大众与保时捷的关系🚗】
- 3、指针与引用的不同点总结
- 八、总结与提炼
一、前言
Hello,大家好,今天我们就来聊聊有关C++中的引用知识📖
- 回忆一下我们曾经在写C语言的时候因为指针所引发的种种难题,特别是对于【两数交换】的时候因为函数内部的概念不会引发外部的变化,使得我们需要传入两个需要交换数的地址,在函数内部进行解引用才可才可以交换二者的值
- 另一块就是在数据结构中的【单链表】,面对二级指针的恐惧😱是否还伴随在你的身边,因为考虑到要修改单链表的头结点,所以光是传入指针然后用指针来接受还不够,面对普通变量要使用一指针来进行修改,那对于一级指针就需要用到二级指针来进行修改,此时我们就要传入一级指针的地址,才可以在函数内部真正得修改这个单链表的结构
- 对前面所学知识做了一个回顾,另一目的也是为了引入C++的一大特性 —— 【引用】,若是你学习了引用之后,就不需要担心是否要传入变量的地址还是指针的地址,让我们一起进入学习吧!
二、概念介绍
引用不是新定义一个变量,而是给已存在变量取了一个别名,编译器不会为引用变量开辟内存空间,它和它引用的变量共用同一块内存空间
比如:李逵,在家称为"铁牛",江湖上人称"黑旋风"。【水浒108将各个有称号】
那要怎么去“引用”呢?
- 此时需要使用到我们在C语言中学习到的一个操作符叫做
[&]
,它是【按位与】,也是【取地址】,但是在C++中呢,它叫做【引用】
它的语法是怎样的呢?
类型& 引用变量名(对象名) = 引用实体;
int a = 10;
int& b = a;
- 通过运行我们可以看到变量a和变量b的地址是一样的,这是为什么呢?就是因为b是a的引用,那b就相当于a,所以它们共用一块地址
- 那既然他们公用一块地址的话,内容也是一样的。此时若是我去修改b的值,a是否和跟着改变呢?
- 可以看到,若是去修改b的话,a也会跟着一起变化
三、引用的五大特性
- 引用在定义时必须初始化
- 一个变量可以有多个引用
- 一个引用可以继续有引用
- 引用一旦引用一个实体,再不能引用其他实体
- 可以对任何类型做引用【变量、指针…】
1、引用在定义时必须初始化
- 首先来看第一个,若是定义了一个引用类型的变量
int&
,那么就必须要去对其进行一个初始化,指定一个其引用的对象,否则就会报错
int a = 10;
int& b = a;
int& c;
2、一个变量可以有多个引用
- 对于第二个特定,通俗一点来说就是b引用了a,那么b等价于a;此时c也可以引用a,那么c也等价于a,此时
a == b == c
- 你可以无限对a进行引用,直到把操作系统的内存申请光为止(应该没那么狠吧)
int a = 10;
int& b = a;
int& c = a;
3、一个引用可以继续有引用
- 对于第三个特性而言,其实就是一个传递性。当一个变量引用了另一个变量之后,其他变量还可以再对其进行一个引用。通过运行就可以看出它们也都是属于同一块空间
int a = 10;
int& b = a;
int& c = b;
4、引用一旦引用一个实体,再不能引用其他实体
- 这个特性很重要【⭐】,要牢记。因为上面有说到对于引用而言在定义时必须初始化,那么在定义结束完后它就已经引用了一个值,无法在对其去进行修改了,这是非法的!
int a = 10;
int c = 20;
int& b = a;
int& b = c;
- 这里我要做一个辨析,因为对于引用来说它和指针非常得类似,也有着千丝万缕般的关系,后面我也会对【指针】和【引用】做一个对比分析
- 看下图就可以知道,对于指针而言一旦指向了一块地址后是可以继续修改其指向的【这点也是指针和引用最大的不同】
这个特性和常量指针与指针常量很类似,可以一起记忆
5、可以对任何类型做引用【变量、指针…】
- 最后一点特性作为拓展。上面我们介绍了对于变量而言可以有引用,当然除了整型之外其他类型也是可以的
- 看到下面c1是
double
类型,c2引用c1,所以c2也是double
类型的。其他类型可以自己试试看
double c1 = 3.14;
double& c2 = c1;
然后我们重点来说说有关指针这一块的引用【⭐】
int a = 10;
int* p = &a;
int*& q = p;
- 通过代码可以看出,指针p指向了a所在的这块地址,接着我用q引用了p,那么指针q就相当于是指针p,q也指向了a所在的这块地址。来分解一下
int*
代表q是一个指针类型,&
则表示指针q将会去引用另一个指针
- 通过下图就可以看出对于指针【p】和指针【q】来说,它们都是同一块空间,因为q引用了p,p指向了a所在的地址,那么q也指向a所在的地址。这么看应该是非常清楚了,只是
int*&
的这个写法要认识一下,我在下面还会讲到
以上就是有关C++中的引用所要介绍的特性,还望读者牢记😁
四、引用的两种使用场景
知道了引用的基本特性后,接下去我们来聊聊有关它的使用场景
1、做参数
a.案例一:交换两数
- 还记得我们在C语言中学习过的【交换两数】吗?需要传入两个变量的地址,从而可以在函数内部通过指针的解引用来访问到所指向变量的那块地址从而对里面的内部进行一个修改
- 相信这也是我们在初次学习指针时接触的一个东西,也是最经典的一块内容,那除了使用【指针】的这种形式,你还有没有其他的方法呢?没错,就是使用我们刚学的引用
void swap1(int* px, int* py)
{
int t = *px;
*px = *py;
*py = t;
}
swap1(&a, &b);
- 我们来看看下面这种引用的方式,相信在学习了引用的基本语法和特性之后你一定很快看懂下面的代码。因为x引用了a,y引用了b,所以它们是等价的,在函数内部使用临时变量对二者进行交换就可以带动外界的变化
void swap2(int& x, int& y)
{
int t = x;
x = y;
y = t;
}
通过运行结果来看确实也可以起到交换两数的功能
- 接下去再普及一点📖,如果你了解C++中的函数重载就可以知道,若是我将这个两个函数的函数名改成一样,但是形参部分的数据类型不同,是可以构成重载的
void swap(int* px, int* py)
{
int t = *px;
*px = *py;
*py = t;
}
void swap(int& x, int& y)
{
int t = x;
x = y;
y = t;
}
通过调试来看一下吧💻
b.案例二:单链表的头结点修改【SLNode*& p】
在讲解引用的特性时,我说到了引用的类型不仅仅限于普通变量,还可以是指针。但上面说的是普通指针,接下去我们来说说结构体指针,也涉及到了引用类型在做参数时的场景
- 看到如下一段代码,我定义了一个链表结点的结构体,还记得我们在链表章节学习过的头插,因为涉及到会修改链表的头结点,因此函数内部的修改不会导致外部一起修改,继而我们需要传入这个链表的地址,然后使用二级指针来进行接收,相信这一块一定令很多小伙伴非常头疼🤦
typedef struct SingleNode {
struct SingleNode * next;
int val;
}SLNode;
void PushFront(SLNode** SList, int x)
{
SLNode* newNode = BuyNode(x);
newNode->next = *SList;
*SList = newNode;
}
int main(void)
{
SLNode* slist;
PushFront(&slist, 1);
return 0;
}
- 但现在学习了引用之后,我们就不需要去关心传入什么指针的地址了,只需要将这个链表传入即可,在函数形参部分对其做一个引用,那么内部的修改也就一同带动了外部的修改
- 看了上面讲到的【普通指针】的引用,相信你对下面这种写法一定不陌生,内部的形参
SList
也就相当于是外部函数外部传入的实参slist
。这就是很多学校《数据结构》的教科书中统一的写法,说是使用了纯C实现,但却利用了C++中的【引用】,如果没有学习过C++的小伙伴一定是非常难受😖
void PushFront(SLNode*& SList, int x);
- 此时
PushFront()
内部我们也可以去做一个修改,直接使用形参SList
即可,无需考虑到要对二级指针进行解引用变为一级指针
void PushFront(SLNode*& SList, int x)
{
SLNode* newNode = BuyNode(x);
newNode->next = SList;
SList = newNode;
}
最后再来补充一点,很多教科书不仅仅是像上面这种写法,而且还会更加精简,它们将结构体定义成这种形式👇
typedef struct SingleNode {
struct SingleNode * next;
int val;
}SLNode, *PNode;
- 来解释一下为什么这么定义,看过结构体章节的小伙伴一定都知道这是
typedef
的作用,对于SLNode而言其实就是对这个结构体的类型由进行了一个typedef,也就是对其进行一个重命名,这样我们在使用这个结构体定义变量的时候就不需要再去写struct SingleNode slist
了,直接写成SLNode slist
即可
typedef struct SingleNode SLNode
- 那又有同学说:“前面这个我知道,但是后面的
*PNode
是什么意思呀❓”。这也是我重点要说明的部分,其实这就相当于是对struct SingleNode*
做了一个typedef,也就是对这个结构体指针的类型做了一个重命名叫做【PNode】,那后面如果要使用这个结构体指针的话直接使用的【PNode】即可
typedef struct SingleNode* PNode
于是对于头插的形参部分又可以写成下面这种形式,与SLNode*& SList
是等价的
void PushFront(PNode& SList, int x);
c.案例三:二叉树递归遍历
最后,我们来讲讲有关引用做参的第三个场景,也就是在递归调用的时候
- 还记得二叉树高频面试题中的最后一题 —— 二叉树的遍历,需要通过题目给出的字符数组去重构一下这棵树。我们是通过递归来实现的,又因为要在每次的递归层中访问到后面的字符,所以需要传入一个变量做数组的遍历,可是在我们去通过画递归展开图做模拟的时候发现随着递归的层层深入,可函数内部的参数变化不会使上一层也跟着变化,所以就发生了覆盖的现象,出现了随机值的情况。所以后来通过指针接受遍历变量的地址,在每一层的递归调用过程中,通过
指针的解引用
来带动外部的变化
BTNode* ReBuildTree(char* str, int* pi)
char str[20] = "abc##de#g##f###";
int i = 0;
BTNode* root = ReBuildTree(str, &i);
InOrder(root);
- 上面是对这一题的描述,如果想仔细了解得就去看看那篇文章,这里我们主要是通过所学的【引用】来对这个题目做一个优化
- 其实不用我说你应该都会了,就是将这个指针类型的pi改成引用类型即可,内部也不需要什么解引用了,直接使用这个pi即可(为了对照也叫
pi
好了),因为pi
就是i
的引用,两者是等价的,属于同一块空间,因此无论函数递归调用多少层,内部参数的变化会也会带动外部的变化
BTNode* ReBuildTree(char* str, int& pi)
{
if (str[pi] == '#')
{
pi++;
return NULL;
}
BTNode* root = (BTNode*)malloc(sizeof(BTNode));
root->val = str[pi++];
root->left = ReBuildTree(str, pi);
root->right = ReBuildTree(str, pi);
return root;
}
BTNode* root = ReBuildTree(str, i);
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指令的下一条指令地址压入栈中,相当于记住了这个地址。
- 接着进入到函数中,其内部计算出来一个值要返回给到外界的时候并不是直接返回,而是将这个需要返回的值放到一个临时的寄存器中去(VS下一般都是
eax
)
- 然后当这个函数调用完成后会直接转到
call
指令的下一跳指令开始执行,此时我们通过汇编指令mov
就可以知道编译器将【eax】中临时存放的函数的返回值转存到了这个临时变量ret中
如果上面有些看不懂的话可以看看👉函数栈帧的创建和销毁
知道了这些以后我们再来对比一下下面的两个Count函数,你觉得它们哪里不太一样呢🤨
int Count()
{
int n = 0;
n++;
// ...
return n;
}
int Count()
{
static int n = 0;
n++;
// ...
return n;
}
- 没错,就是这个
static
的区别。通过画出函数调用的堆栈图我们可以看出对于两个不同的Count()函数而言其内部临时变量所存放的位置是不同的。我们知道,对于函数中的普通变量而言,是存放在当前所开辟函数的栈帧中的,即存放在内存中的栈区;但是对于函数中的静态变量而言,是不存放在当前函数栈帧中的,而是存放在内存中的静态区,包括平常可能会使用到的全局变量也是存放在其中 - 对于【栈区】和【静态区】而言,如果你有了解的过的话应该可以清楚地知道存在其内部的变量的生命周期是不同的:
- 存放在【栈区】中的临时变量当函数调用结束后整个函数栈帧就会被销毁,那么存放在这个栈帧中的临时变量也随之消亡,不复存在
- 存放在【静态区】中的变量它们的生命周期是从创建开始到整个程序结束为止,所以不会随着当前所在的函数栈帧销毁而消亡💀
- 上面通过画出两个Count()函数的堆栈图了解到了函数中临时变量和静态变量所在空间是不同的,那当执行完这个函数之后其所在栈帧一定会销毁,此时又会发生怎样的故事呢?我们继续看下去
首先你必须要清楚的一些点:
- 当我们定义变量 / 创建函数 / 申请堆内存的空间时,系统会把这块空间的使用权给到你💪,那么这块空间你在使用的时候是被保护的🛡,被人无法轻易来访问、入侵你的这块空间。但是当你将这个空间销毁之后,它并不是不存在了、被粉碎了,只是你把对于这块空间的使用权还给操作系统了,不过这块空间还是存在的,因此你可以通过某种手段访问到这块空间🗡,由于操作系统又收回了这块空间的使用权,继而它便可以对其进行再度分配给其他的进程,那它就可能又属于别人了
- 所以你通过某种手段去访问这个空间的时候其实属于一种非法访问⚠,可是呢这种非法访问又不一定会报错,就像之前我们说到过的数组越界、访问野指针都不一定会存在报错。为什么?因为编译器对于程序的检查是一种【抽查行为】,不一定能百分百查到,所以你在通过某些手段又再次访问到这块空间后所做的一些事都是存在一种【随机性】的
- 上面所说的这些还望读者一定要牢记!!!因为这对于下文的理解以及后续的学习都是非常有帮助的
- 好,接下去我们回归正题,继续来说一说有关函数栈帧销毁之后这个返回值是如何给到这个外界的值做接收的。相信你在本小节一开始讲到的这些内容之后再来看下图一定是非常得清晰
- 不过对于这个临时变量而言,还要提一嘴的是它不一定就是一个寄存器,因为对于寄存器而言计算机内部的容量是非常小的,大概是只有
4 / 8B
,若是一个函数需要返回空间很大的东西时就无法承载,就比如说要返回一个结构体就可能会放不下,因为结构体中存在各种各样的数据类型。所以对于临时变量而言有下面两种形式- 若是返回空间小一点的变量时使用的就是【寄存器】
- 若是返回空间大一点的内容时使用的就是【临时变量】,这个临时变量会提前在main函数栈帧中提前开好
- 对于普通的存放在函数栈帧中的变量需要通过【临时变量】来暂存一下然后再返回,那现在我们来看看存放在
静态区中的变量
在函数栈帧销毁之后是如何返回给到外界的值做接收的呢?那有同学想:既然它都不存在于这个函数的栈帧中,那么也就不需要临时变量了吧,直接返回这个n不就好了 - 可是呢事实却不是这样,编译器可不会去管你这个变量是在【栈区】还是【静态区】的,它依旧还是傻傻🤨地在返回的时候将这个n先存放到临时变量中,然后回到调用的main函数中时再把临时变量中的内容拷贝到这个接收值中
- 当然,如果你不相信的话,我们还可以通过反汇编的形式来看看是否真的用到了这个【临时变量】。通过下图的观察可以得知,无论是对于Count1()还是Count2(),在调用函数结束之后都会存在一个
eax
最后通过【mov】指令将寄存器中存放的临时值给到ret所在的这块空间
有些不太理解函数的调用和返回过程的同学可能就会钻牛角尖🐂提出这样的问题:做这么一个临时变量做返回不是很麻烦吗,为什么不先把这个值返回给外界,然后再销毁函数栈帧呢?
- 这一块的话你就要非常清楚有关函数栈帧建立和销毁的过程,以及这个临时变量是何时创建的❓何时暂收返回值的?又是何时赋值的?
- 因为我们通过
call
指令去调了这个函数,它的栈帧就被建立起来。此时就在我们就正处在这个函数的栈帧内部了,每个栈帧都是通过[ebp]
和[esp]
来维护它所在这块空间的。虽然是记住了call
指令的下一跳指令,但是没有记住需要接收的这个【ret】,因此在被调用的函数栈帧内部是无法找到这个接收变量ret的,是很难定位到它所在的这个空间的。但是我们又想把这个值返回回去,此时只能借助一个出了栈帧不会销毁的容器去承载、暂时保存一下这个返回值,然后当我们回到call
指令的下一条指令时继续往下执行,才能顺理成章地找到这个【ret】,然后将寄存器中存放的临时值再赋值给到它做接收
希望我这么说你可以真正理解了这个过程👆
【总结一下】:
- 当需要将函数中的临时变量返回时,无论这个变量是在栈区、堆区或者静态区开辟空间,都会通过一个
临时变量
去充当返回值【小一点的话可能是寄存器eax,大一点可能是在上一层栈帧开好的】然后再返回给外界的值做接受
② 优化:传引用返回【权力反转】
通过上面的示例你应该会觉得对于【栈区】而言使用临时变量返回还是合情合理的,可以【静态区】为什么也要通过临时变量来返回呢,这不是多此一举吗?
- 那有什么办法可以免去这种拷贝的过程,直接将得出的结果返回回去呢?那就是引用返回
int& Count()
{
static int n = 0;
n++;
// ...
return n;
}
- 对于引用返回来说就不会产生这个临时变量了,返回的只是n的别名,那你也可以说相当于就是把n返回回去了,编译器呢把这个权利给到了你,对于函数栈帧销毁依旧存在的内容,如果我们不想让其拷贝到临时变量中进行返回,是可以通过引用来进行返回的,这样就可以减少拷贝,对程序做了一小部分的优化
如果你想要进一步了解其返回的过程和直接【传值返回】有何区别,此时可以通过汇编来看看
- 通过观察其实可以发现【传引用返回】和【传值返回】是存在一定区别的。对于后者而言并不是简单地将n中的内容暂存到寄存器
eax
中,而是通过汇编里的一个属性操作符offset
进行了n个位置的偏移(汇编这一块我研究的不是很深,感兴趣的老铁可以去看看 链接)
- 因为我们可以做一个小结:对于像静态变量、全局变量等这些出了作用域不会销毁的对象,就可以使用【传引用返回】
但其实也不局限于上面的这两种,只要是出了作用域不会销毁都可以使用【传引用返回】
- 来看到下面这几段代码,我定义了一个有关数组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)
- 最后再来看看修改完后最后的运行结果
- 其实对于上面的这个Array结构体和
PostAt()
函数,是C++11在STL中出现的新函数叫做【at()】,功能就是我上面所实现的这些,随着后面C++STL的学习会说到这一块,感兴趣的可以提前了解一下
【总结一下】:
- 传引用返回的好处在于:① 可以减少拷贝;② 调用者可以修改返回对象
③ 理解:引用返回的危害 - 造成未定义的行为【薛定谔的猫🐱】
在上面,我介绍到了一种对函数返回进行优化的方法 ——> 传引用返回,于是有的同学就觉得它很高大上,因此所以函数都使用了传引用返回,你认为可以吗?
- 来看看下面这段代码,你认为它的输出结果是什么呢?是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
- 正式运行一下可以发现输出的结果是【7】,而不是一开始计算出来的的【3】
- 此时,我手痒痒✋又去打印了一次,就发现这个【ret】变成了一个很大的随机值,这是为什么呢🤔
接下去就来好好谈一谈究竟问题出在哪里🔍
- 首先通过画出堆栈图,在Add()内部,通过计算出两数之和将其放到一个处于当前栈帧中的临时变量c中,最后将其return,设想若是在函数的返回值中不加
&
的话,那这就是我们平常写的一个函数,然后外界去做一个接受。但若是加上引用之后就不对了,因为这是一个临时变量,出了当前作用域后会随着函数栈帧的销毁而销毁,此时就已经出现问题了👈 - 所以抛开外界如何去接收这个返回值,首先你要认清的一点是这个Add函数本身就已经是错误的了。然后我们再来看外界的接收值,本来应该是正常去做接收,但是我使用了一个【引用接收】,这一点我会在下一小节具体阐述
- 现在你要知道的是因为Add函数做了一个【引用返回】,即返回了c的别名,但此时呢ret又使用引用接收了c的引用,所以可以说【ret又引用了c的引用】,那此时也就可以说ret与c就融为一体了,那么ret也就是c这块空间的别名
如果此刻直接去访问【ret】的话它的值会是多少呢?答:可能是3,可能是7,也有可能是一个随机值
- 为了方便观察,现在我们将
Add(3,4);
屏蔽掉。那么此时的结果就有可能是3,有可能是随机值
< 原理解说 >
- 为什么呢?这一点我在上面也有提到过。因为当Add函数栈帧销毁的时候,其空间还是在的,只是使用权不是你的了,可是呢它被操作系统回收了,操作系统就还可以把它分配给其他进程,那此时就可以说这块空间被重复利用了,下一次的函数调用可能还是在这块空间上建立栈帧,但是上一次的栈帧是否清理取决于编译器,可能清理了,也可能没清理
- 如果编译器没有清理这个栈帧的话,那么这个c就还是3
- 如果编译器清理了这个栈帧的话,这个c就有可能是个随机值
可能就像上面这么说不是很好理解,我们通过【薛定谔的猫🐱】这个梗来进行讲解
- 你呢,在外面的酒店开始一间房,第二天呢你要退房了,不过你在房间里放一只猫,当退完房后便去外面配了一把和酒店房门一模一样的钥匙🔑找了回来,但是这个猫不一定在,存在下面几种可能性
- 这个房间和你走的时候一模一样没有变过,猫还静悄悄地躺在那里
- 猫不在了!酒店保洁在打扫完了这个房间后发现了一只猫就把它带走了
- 猫没有被发现,这个房间又分配给别人了,那个人很喜欢这只猫🐱,把它抱在怀里❤️
- 从上面这个案例其实可以看出对于你在酒店里面放一只猫,退房之后再找回去,能否找到这只猫存在一定的随机性😛在网上有一个梗就是【薛定谔的猫】,表示所要发生的这一件事是玄幻的或是不确定的,刚好和我们这里的例子🌰很照应,所以拿出来给大家开心一刻😄
- 不过呢,也不应该把一个重要的物品放到一个已经退掉的房间里,如果要走了,但是猫放在这个房间里,可以续订一下这个房间,那么这个房间就还是你的,猫放在里面就不会出现问题
通过上面这个案例我想说明什么呢?
- 其实很简单,就是不要去返回出了作用域就销毁的变量,虽然语法是允许的,但这个程序是错误的,结果是未定义的👈 如果觉得上面的内容难以理解,记住这句话就行了
④ 回瞰:引用接收与非引用接收
接下去我们再来详细说说ret使用引用接收这一块较难理解的地方
- 首先你可以思考,为什么我要使用引用来进行接收,不是已经使用【引用返回】了吗?这不又是多次一举吗?这里你确实可以认为我是多次一举,因为这其实是为了提升读者对于引用的理解罢了😁
- 【引用接收】:ret充当的就是c的别名,后面cout访问ret的时候访问的就是c,第一次是3,第二次就变成随机值了,是不确定的
int& ret = Add(1, 2);
- 【非引用接收】:相当于是把c的值做一份临时拷贝给到ret,那ret的值是什么取决于编译器第一次在销毁栈帧的时候是否清理这块空间
int ret = Add(1, 2);
- 通过我上面的对比分析你应该就可以明白引用接受和非引用接收不是这代代码错误的根本,这段代码错就错在对于返回出了作用域就销毁的变量,导致出现了结果未定义的现象
⑤ 结语:正确认识【传值返回】与【传引用返回】
好了,看到这里,相信你对引用做返回值的使用场景应该有了很深刻的理解,来做个总结
- 如果你觉得很难理解,那说明你是个正常人,C++引用这一块尤其是做函数返回值的时候是最难理解的,但是通过画图去理解分析就会好很多了,通过画出这个函数的栈帧图就可以很清晰地看明白所有的一切
最后的话再带读者来回顾一下【传值返回】和【传引用返回】
- 传值返回:如果已经还给系统了,随着当前作用域的结束而一起销毁的
- 传引用返回:只要是出了当前作用域不会销毁,并且函数栈帧销毁不影响其生命周期【全局变量、静态变量、上一层栈帧、malloc的】
五、传值、传引用效率对比
在上一模块,我介绍了有关引用的两种使用场景,相信你在学习了之后也是一头雾水,学它有什么用呢?和普通的传值有何区别?本模块就来对【传值】和【传引用】这两种方式来做一个对比
1、函数传参对比
- 首先我们来看看以值和引用分别作为函数参数有什么不同
#include <time.h>
struct A {
int a[10000];
};
void TestFunc1(A a){}
void TestFunc2(A& a){}
void TestRefAndValue()
{
A a;
// 以值作为函数参数
size_t begin1 = clock();
for (size_t i = 0; i < 10000; ++i)
{
TestFunc1(a);
}
size_t end1 = clock();
// 以引用作为函数参数
size_t begin2 = clock();
for (size_t i = 0; i < 10000; ++i)
{
TestFunc2(a);
}
size_t end2 = clock();
// 分别计算两个函数运行结束后的时间
cout << "TestFunc1(A)-time:" << end1 - begin1 << endl << endl;
cout << "TestFunc2(A&)-time:" << end2 - begin2 << endl;
}
- 通过运行可以观察到,其实二者也差得不太多,传值做参是
19ms
,引用做参则是0ms
,虽然二者存在差距,但是差距并不大。传引用作参替代了我们在C语言中学习的二级指针,无需考虑传入一级指针的地址,然后再函数内部在做解引用 - 直接使用引用传参省去了这些步骤,那你可以想想差不多是可以省下来一些时间
2、返回值的对比
不过呢,这个时间其实还看不出引用的强大之处,我们通过另一个场景,来看看值返回与引用返回二者的差距是否会大一些
// 值返回
A TestFunc1()
{
return a;
}
// 引用返回
A& TestFunc2()
{
return a;
}
- 通过运行可以发现,在引用做返回值这一块可是比普通返回值要来的高效很多,足足有快了100倍这样
- 为什么它可以这么高效?因为在程序执行的过程中拷贝这一个步骤其实很耗时间的,首先你要讲想返回的值给到一个临时变量,然后再通过这个临时变量传递给到外面的变量做接收。可是对于引用来说,是直接返回了自己本身的一个别名,那也就是你拥有了我这块地址,不需要再去花费大量的辗转拷贝工作了。通过这样的对比再去回想我们上面一步步讲下来的这些内容,你就可以很清楚的认识到引用的强大之处了
六、常引用
看到这里之后你对C++中的【引用】应该是有一个很清晰的概念了,也知道了它的强大之处。接下去我来普及一个东西叫做【常引用】,也是引用里面很重要的一块知识点
1、权限放大【×】
权限放大 —— 用普通的变量替代只读变量
首先来看下下面这段代码,你认为什么地方有问题?
- 变量b对a进行了引用,这一点肯定不会有问题;不过问题就出在这个变量对c的引用,有同学说:“为什么呢?不都是引用吗?”
- 那你就要注意到这个
const
了,首先对于【const】关键字修饰的变量具有常性,是不可以被随意修改的,但此时变量d引用了c,那么c和d就从属于同一块地址了,不过变量d不具有常性,因此它是可以被修改的。那么这个时候就会产生歧义了,也就出现了问题 - 这种引用形式叫做【权限放大】,本来我这个c是不具有再度修改权限的,但是你引用了我,那就可以修改了,这也就破坏了原先的规则👈
int a = 1;
int& b = a;
const int c = 2;
int& d = c;
2、权限保持【✔】
权限保持 —— 用只读的变量替代只读变量
- 那我们修改一下,不要让权限放大了,给变量d也加上一个常属性,让他俩一样,看看是否可行
const int c = 2;
const int& d = c;
- 可以看到,此时就不会出现问题了,因为我们进行了一个【权限保持】,不会因为权限的放大而导致规则被破坏
3、权限缩小【✔】
权限缩小 —— 用只读的变量替代普通变量
- 那既然权限保持不会出现问题,若是我现在将权限缩小会不会出现问题呢?
int c = 2;
const int& d = c;
- 可以看到,也是不会出现问题。你呢允许我修改,但是我加上了常属性不去修改,那也是说得通的
🈲警惕:经典错误分析🔍
看完了上面这段三点你应该清楚了常引用是怎样一个概念,但是有很多同学在学习了常引用之后却将其他知识混淆了,所以我专门拎出几块容易搞混的给读者说明
1. 权限方法只适用于引用和指针类型
- 看了上面的【权限放大】之后再来看下面这段代码,你认为它会有问题吗?
const int m = 1;
int n = m; //普通变量不受约束
- 可以看到,编译器并没有报出错误,这是为什么呢?有些同学在看了上面的【权限放大】之后认为这应该就会报错呀,可这是编译器出问题了吗?
- 来揭晓一下答案:其实不是的,在上面我们讲到的是有关常引用方面的语法特性,但是在这里并没有使用到引用,只是普通的变量赋值而言,通过去修改一下m和n就可以发现,n的修改是不会影响到m的,虽然m具有常性,但是n不具有常性,而对于我们上面所讲到的【引用】两个相互之间的变量是会绑在一起的,修改了任何一方另一方都是随之改变。对于权限放大只适用于引用和指针类型
2. 临时变量具有常属性【⭐】
- 接下去我们再来看第二个容易混淆知识点,下面是一个普通的函数调用,在外界使用了ret去接受了这个返回值。相信在学习了上面内容之后你的定义反映就是返回了临时的局部变量,出现的会是一个随机值。确实是要使用临时变量,不过我要讲的并不是这个知识点
int Count()
{
int n = 0;
n++;
// ...
return n;
}
int ret = Count();
- 此时,我使用【引用接受】去接受了函数内部的返回值,但是去编译的时候就出现了问题,非常引用的初始值必须为左值。也就是说这个右边的Count()返回的内容是一个【常】,具有常属性,这是为何呢?
- 这就涉及到一个很重要的知识点了,无论是在哪里,临时变量都具有常属性。当Count()函数的返回一个函数内部的临时变量时,这个变量就具有常性,若外界使用一个非const的引用类型去引用这个临时变量的话,就会造成【权限放大】的问题
int& ret = Count();
- 我们可以再来看一个场景,对于下面这个函数调用也是会出现问题的
void print(string& str)
{
cout << str << endl;
}
int main(void)
{
print("hello world!");
return 0;
}
Windows环境下运行
LInux环境下运行
- 从双平台的运行结果来看,这段代码都是有问题的,仔细观察可以发现问题都出在这个【const常】上,可以在代码里面我并没有写
const
呀,怎么会和常扯上关系呢? - 这其实就是要涉及到临时变量具有常性这个特点了,回忆一下我们上面讲到过的【权限放大】,就是将一个常属性的变量给到一个非常性的变量做引用,此时就会造成权限的放大。对于这个
hello world!
而言,其实就是准备传入函数的一个实参,此时编译器根据字符串hello world
构造一个string类型的临时对象,这个临时变量具有const属性,当这个临时变量传递给非const的string&引用类型时,无法隐式完成const到非const的类型转换,造成了一个权限的放大,便出现上面的编译错误❌
- 修改的办法很简单,只需要在形参部分加上一个
const
做修饰即可,这样便可以做到【权限保持】,顺利通过编译✔
void print(const string& str)
通过以上代码,可以看出在设计函数时,形参尽可能地使用const,这样可以使代码更为健壮,将错误暴露于编译阶段
3. 类型转换都会产生临时变量
- 接下去我们再来看第三个难点,涉及类型转换相关的知识,在下面我使用了一个
double
类型的变量引用了一个整型的变量,可以看到也出现了我们上面所碰到的一些编译问题
int i = 10;
double& rd = i;
- 那有同学说:“你把一个
int
类型的变量给到一个double
类型的变量做引用,那类型的都不一样肯定是会出问题呀!” - 但是我在double前面加上了一个
const
,却不会出现问题了,你怎么解释呢😎
- 我们先来讲讲【int】和【double】之间的转换,帮助大家做理解。对于
(double)i
你我们在C语言都有学过,这是一种的显式的强制类型转换,将一个int类型的变量强制转换为了double类型,但其实在编译器看来,却不是这样的👈 - 这个
i
并不是被转换成了一个【double】类型,而是产生了一个【double】类型的临时变量,然后把i
的值按照【double】的类型放到了这个临时变量中,在C语言数据存储章节我们有提到过对于浮点数放到内存中是要分为整数部分和小数部分的,按照对应的权值转换为二进制的形式存放到内存中
int i = 10;
cout << (double)i << endl;
- 上面的显示类型转换,下面的是隐式类型转换,如果不是很了解的看看这篇文章
int i = 10;
double dd = i;
- 但无论是对于隐式还是显示,只要是类型转换都会产生临时变量,所以对于下面这个rd引用的其实并不是整型变量
i
,而是i
在进行类型转换的时候产生一个【double】类型的临时变量,rd是对它进行了一个引用 - 而上面我们说到了临时变量具有常性,所以一个非const的变量去引用了一个const的变量,就会产生【权限放大】的问题,那解决办法我们都知道,在前面加上一个
const
做一个【权限保持】就不会出问题了
const double& rd = i;
有关【const常】和引用之间的语法点其实还有很多,但涉及到一些读者的水平,将上面这些都理解了也算懂了七八十,后面有机会再做补充😊
七、引用与指针的区别总结
好,最后我们对指针和引用这一块来做一个小结,相信你一定觉得它们之间有着千丝万缕般的关系🔗
1、汇编层面对比指针和引用
- 在学习了这么多有关引用的知识之后,相信读者也知道了。在语法概念上引用就是一个别名,没有独立空间,和其引用实体共用同一块空间
int a = 10;
int& ra = a;
cout<<"&a = "<<&a<<endl;
cout<<"&ra = "<<&ra<<endl;
- 不过呢对于指针而言是会单独去开出一块空间来进行存放的
int a = 10;
int* pa = &a;
不过从【汇编层面】来看,其实二者是一样的,引用也是用指针去实现的,也会开空间
int main()
{
int a = 10;
int& ra = a;
ra = 20;
int* pa = &a;
*pa = 20;
return 0;
}
- 通过将这段代码转到【反汇编】,就可以发现引用和指针在底层的实现竟然是一样的😮
我们可以来浅浅分析一下🔍
- 首先
lea
是【load effective address】加载有效地址,将变量a中存放的内容放到寄存器eax
中
lea eax,[a]
- 然后
eax
中的值,也就是变量i的地址放入变量【ra】的地址所指示的内存单元中,虽然引用使用的都是同一块空间,但是在底层还是开出了一块空间来存放,这个我们可以不用关心
mov dword ptr [ra],eax
- 接下去就要去修改ra的值了。将变量【ra】的地址所指示的内存单元中的值,也就是变量i的地址放入寄存器
eax
mov eax,dword ptr [ra]
- 最后,再将
14h(十六进制)
也就是20放到寄存器eax
的值(指向变量i的地址)所指示的内存单元中
mov dword ptr [eax],14h
- 那对于指针来说在底层的实现也是同理,如果觉得难以理解的小伙伴也可以不关心这些底层的实现,你只需要知道从【汇编层面】来看引用其实和指针一样,都是需要开出空间的
2、感性理解【大众与保时捷的关系🚗】
其实难以理解的读者可以通过这么一个生活中的案例去理解
- 对于保时捷来说其实大众旗下的一个牌子,大众有一款车叫途锐,保时捷有一款车叫卡宴。卡宴是百万级别的,途锐以前还神一点,现在是五六十万级别的车
- 但是它们的三大件;发动机、悬架、变速箱基本都是一样的
- 所以我们可以得出从上层看它们是不一样的,但是从底层看其实都是一样的
所以对于引用来说在我们看来是不会开新空间的,但实际上底层却做了相反的事,和我们想的是千差万别
- 其实在生活中有很多事情与我们肉眼看到的都不一样,比如说:🐟鱼香肉丝有鱼吗?💑夫妻肺片有肺片吗?🧇老婆饼有老婆吗?
3、指针与引用的不同点总结
下面对本文所讲解的内容进行一个总结,希望读者可以分清楚引用和指针之间的区别👈
- 引用概念上定义一个变量的别名,指针存储了一个变量地址。实际上在底层和指针的实现都一样
- 实际上看来的东西其实内部的实现却并不是想象的那样
- 引用在定义时必须初始化,指针没有要求
- 指针可以不初始化,引用必须初始化
- 引用在初始化时引用一个实体后,就不能再引用其他实体,而指针可以在任何时候指向任何一个同类型实体
- 一旦引用了一个变量,就不可以修改【指针常量】;指针可以继续指向其他地址【常量指针】
- 没有NULL引用,但有NULL指针
int a = 10;
int& b = NULL; //×
int* pa = NULL; //✔
- 在sizeof中含义不同:引用结果为引用类型的大小,但指针始终是地址空间所占字节个数(32位平台下占4个字节、64位平台下占8个字节)
- 引用自加即引用的实体增加1,指针自加即指针向后偏移一个类型的大小
- 有多级指针,但是没有多级引用
- 可以对一个引用继续做引用,这一点我们在上面的特性中也有讲解到
int a = 1;
int& b = a;
int& c = b;
- 但是不能写成
int&&
这种形式再去引用,因为这个涉及到C++中的左值、右值引用,后续专门出文章讲解
int&& c = b;
- 访问实体方式不同,指针需要显式解引用,引用编译器自己处理
- 指针若是想访问到所存地址的内容,则需要进行解引用;引用不需要,因为二者是属于同一块空间,绑定在一起,访问本体即可找到引用体
- 引用比指针使用起来相对更安全
- 引用是一个更好用一点、更简单一点、更安全一点的指针
- 指针和引用都是大肠,但是吃的时候要小心,吃引用更安全,吃指针就不好说了,可能吃到的就是【九转大肠】
八、总结与提炼
最后,来总结一下本文所学习的内容📖
- 首先我们了解了什么是引用,知晓了原来引用就是【取别名】,主体与被引用体使用的都是同一块空间
- 接下去学习了有关引用的五大特性,知道了
- 在定义时必须初始化
- 一个变量可以有多个引用
- 一个引用可以继续有引用
- 用一旦引用一个实体,再不能引用其他实体
- 可以对任何类型做引用
- 有了基本的概念和理解之后,我们就开始真正地使用引用,关注到它被使用的两种场景,分别是:① 做参数;② 做返回值;这一模块讲解地非常细致,里面不仅包含引用相关的很多难点,而且还有一些内存空间相关的知识,特别是对于【引用做返回值】这一块读者一定要细细品味🍵
- 了解了引用的两种使用场景后,便通过传值、传引用去分别比较了在这两种场景下二者的差距,很明显引用还是更胜一筹,比较拷贝是需要耗费的时间
- 接下去拓展了一点,说了引用的另一块知识点 ——【常引用】,我们日常在使用引用的时候,一定要注意千万不可将权限放大,只可做到权限保持或者是权限缩小
- 最后的最后,又去对比了指针和引用二者区别所在,知道了原来在底层的实现中【引用】和【指针】其实差不太多,都是需要开辟空间的。但二者还是存在很多的区别,都得读者列出来了,这些都是在笔试面试当中常考的内容,还望谨记!
以上就是本文要介绍的所有内容,如果觉得有帮助可以给个三连哦🌹🌹🌹