C++ 引用(&)的超详细解析(小白必看系列)
目录
一、前言
二、引用的概念介绍
三、引用的五大特性
💦 引用在定义时必须初始化
💦 一个变量可以有多个引用
💦 一个引用可以继续有引用
💦 引用一旦引用一个实体,再不能引用其他实体
💦 可以对任何类型做引用【变量、指针…】
四、引用的两种使用场景
1、做参数
a.案例一:交换两数
b.案例二:单链表的头结点修改【SLNode*& p】
2、做返回值【⭐⭐⭐】
① 引入:栈区与静态区的对比
② 优化:传引用返回【权力反转】
③ 理解:引用返回的危害 - 造成未定义的行为【薛定谔的猫🐱】
④ 结语:正确认识【传值返回】与【传引用返回】
五、传值、传引用效率对比
1、函数传参对比
2、返回值的对比
六、常引用
1、权限放大【×】
2、权限保持【✔】
3、权限缩小【✔】
4、拓展
4.1如何给常量取别名
4.2临时变量具有常性(重点)
5、 对权限控制的用处
七、引用与指针的区别总结
八、总结与提炼
九、共勉
一、前言
本次博客来讲解以下C++ 的 引用 是如何运用的。那么问题来了,为什么要用到引用?用C语言中的指针不是挺好的吗 ?
其实,在C语言中的指针会引发很多的难题,比如【两数交换】的时候因为函数内部的概念不会引发外部的变化,使得我们需要传入两个需要交换数的地址,在函数内部进行解引用才可才可以交换二者的值
另一块就是在数据结构中的【单链表】,面对二级指针的恐惧😱是否还伴随在你的身边,因为考虑到要修改单链表的头结点,所以光是传入指针然后用指针来接受还不够,面对普通变量要使用一指针来进行修改,那对于一级指针就需要用到二级指针来进行修改,此时我们就要传入一级指针的地址,才可以在函数内部真正得修改这个单链表的结构
所以,为了解决上述简化上述问题,C++中引入了一大特性 —— 【引用】,在学习了引用之后,就不要担心是否要传入变量的地址还是指针的地址啦,然后我们一起来学习吧!
二、引用的概念介绍
引用不是新定义一个变量,而是给已存在变量取了一个别名,编译器不会为引用变量开辟内存空间,它和它引用的变量共用同一块内存空间
比如:李逵,在家称为"铁牛",江湖上人称"黑旋风"。【水浒108将各个有称号】
❓那要怎么去“引用”呢?❓
此时需要使用到我们在C语言中学习到的一个操作符叫做[&],它是【按位与】,也是【取地址】,但是在C++中呢,它叫做【引用】
❓ 它的语法是怎样的呢?❓
类型& 引用变量名(对象名) = 引用实体;
int a = 10; int& b = a; // b 是 a 的别名
- 通过运行我们可以看到变量a和变量b的地址是一样的,这是为什么呢?就是因为b是a的引用,那b就相当于a,所以它们共用一块地址
- 那既然他们公用一块地址的话,内容也是一样的。此时若是我去修改b的值,a是否和跟着改变呢?
- 可以看到,若是去修改b的话,a也会跟着一起变化
三、引用的五大特性
💦 引用在定义时必须初始化
💦 一个变量可以有多个引用
💦 一个引用可以继续有引用
💦 引用一旦引用一个实体,再不能引用其他实体
💦 可以对任何类型做引用【变量、指针…】
💦 引用在定义时必须初始化
- 首先来看第一个,若是定义了一个引用类型的变量int&,那么就必须要去对其进行一个初始化,指定一个其引用的对象,否则就会报错
int a = 10; int& b = a; int& c;
💦 一个变量可以有多个引用
- 对于第二个特定,通俗一点来说就是b引用了a,那么b等价于a;此时c也可以引用a,那么c也等价于a,此时a == b == c
- 你可以无限对a进行引用,直到把操作系统的内存申请光为止(应该没那么狠吧)
int a = 10; int& b = a; int& c = a;
💦 一个引用可以继续有引用
- 对于第三个特性而言,其实就是一个传递性。当一个变量引用了另一个变量之后,其他变量还可以再对其进行一个引用。通过运行就可以看出它们也都是属于同一块空间
int a = 10; int& b = a; int& c = b;
💦 引用一旦引用一个实体,再不能引用其他实体
- 这个特性很重要【⭐】,要牢记。因为上面有说到对于引用而言在定义时必须初始化,那么在定义结束完后它就已经引用了一个值,无法在对其去进行修改了,这是非法的!
int a = 10; int c = 20; int& b = a; int& b = c;
- 这里我要做一个辨析,因为对于引用来说它和指针非常得类似,也有着千丝万缕般的关系,后面我也会对【指针】和【引用】做一个对比分析
- 看下图就可以知道,对于指针而言一旦指向了一块地址后是可以继续修改其指向的【这点也是指针和引用最大的不同】
💦 可以对任何类型做引用【变量、指针…】
- 最后一点特性作为拓展。上面我们介绍了对于变量而言可以有引用,当然除了整型之外其他类型也是可以的
- 看到下面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将会去引用另一个指针
以上就是有关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; } swap2(a, b);
通过运行结果来看确实也可以起到交换两数的功能
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++的小伙伴一定是非常难受😖
- 此时PushFront()内部我们也可以去做一个修改,直接使用形参SList即可,无需考虑到要对二级指针进行解引用变为一级指针
void PushFront(SLNode*& SList, int x) { SLNode* newNode = BuyNode(x); newNode->next = SList; SList = newNode; }
2、做返回值【⭐⭐⭐】
首先看一下,下面的两个Count函数,你觉得它们哪里不太一样呢🤨
① 引入:栈区与静态区的对比
int Count() { int n = 0; n++; // ... return n; }
int Count() { static int n = 0; n++; // ... return n; }
- 没错,就是这个static的区别。通过画出函数调用的堆栈图我们可以看出对于两个不同的Count()函数而言其内部临时变量所存放的位置是不同的。我们知道,对于函数中的普通变量而言,是存放在当前所开辟函数的栈帧中的,即存放在内存中的栈区;但是对于函数中的静态变量而言,是不存放在当前函数栈帧中的,而是存放在内存中的静态区,包括平常可能会使用到的全局变量也是存放在其中
- 对于【栈区】和【静态区】而言,如果你有了解的过的话应该可以清楚地知道存在其内部的变量的生命周期是不同的:
- 存放在【栈区】中的临时变量当函数调用结束后整个函数栈帧就会被销毁,那么存放在这个栈帧中的临时变量也随之消亡,不复存在
- 存放在【静态区】中的变量它们的生命周期是从创建开始到整个程序结束为止,所以不会随着当前所在的函数栈帧销毁而消亡💀
- 上面通过画出两个Count()函数的堆栈图了解到了函数中临时变量和静态变量所在空间是不同的,那当执行完这个函数之后其所在栈帧一定会销毁,此时又会发生怎样的故事呢?我们继续看下去
首先你必须要清楚的一些点:
- 当我们定义变量 / 创建函数 / 申请堆内存的空间时,系统会把这块空间的使用权给到你💪,那么这块空间你在使用的时候是被保护的🛡,被人无法轻易来访问、入侵你的这块空间。但是当你将这个空间销毁之后,它并不是不存在了、被粉碎了,只是你把对于这块空间的使用权还给操作系统了,不过这块空间还是存在的,因此你可以通过某种手段访问到这块空间🗡,由于操作系统又收回了这块空间的使用权,继而它便可以对其进行再度分配给其他的进程,那它就可能又属于别人了
- 所以你通过某种手段去访问这个空间的时候其实属于一种非法访问⚠,可是呢这种非法访问又不一定会报错,就像之前我们说到过的数组越界、访问野指针都不一定会存在报错。为什么?因为编译器对于程序的检查是一种【抽查行为】,不一定能百分百查到,所以你在通过某些手段又再次访问到这块空间后所做的一些事都是存在一种【随机性】的
- 上面所说的这些还望读者一定要牢记!!!因为这对于下文的理解以及后续的学习都是非常有帮助的
- 好,接下去我们回归正题,继续来说一说有关函数栈帧销毁之后这个返回值是如何给到这个外界的值做接收的。相信你在本小节一开始讲到的这些内容之后再来看下图一定是非常得清晰
- 对于普通的存放在函数栈帧中的变量需要通过【临时变量】来暂存一下然后再返回,那现在我们来看看存放在静态区中的变量在函数栈帧销毁之后是如何返回给到外界的值做接收的呢?那有同学想:既然它都不存在于这个函数的栈帧中,那么也就不需要临时变量了吧,直接返回这个n不就好了
- 可是呢事实却不是这样,编译器可不会去管你这个变量是在【栈区】还是【静态区】的,它依旧还是傻傻🤨地在返回的时候将这个n先存放到临时变量中,然后回到调用的main函数中时再把临时变量中的内容拷贝到这个接收值
【总结一下】:
- 当需要将函数中的临时变量返回时,无论这个变量是在栈区、堆区或者静态区开辟空间,都会通过一个临时变量去充当返回值【小一点的话可能是寄存器eax,大一点可能是在上一层栈帧开好的】然后再返回给外界的值做接受
② 优化:传引用返回【权力反转】
通过上面的示例你应该会觉得对于【栈区】而言使用临时变量返回还是合情合理的,可以【静态区】为什么也要通过临时变量来返回呢,这不是多此一举吗?
- 那有什么办法可以免去这种拷贝的过程,直接将得出的结果返回回去呢?那就是引用返回
int& Count() { static int n = 0; n++; // ... return n; }
- 对于引用返回来说就不会产生这个临时变量了,返回的只是n的别名,那你也可以说相当于就是把n返回回去了,编译器呢把这个权利给到了你,对于函数栈帧销毁依旧存在的内容,如果我们不想让其拷贝到临时变量中进行返回,是可以通过引用来进行返回的,这样就可以减少拷贝,对程序做了一小部分的优化
-
- 因为我们可以做一个小结:对于像静态变量、全局变量等这些出了作用域不会销毁的对象,就可以使用【传引用返回】
-
这里ret和n的地址一样,也就意味着ret其实就是n的别名。综上,传值返回和传引用的返回的区别如下:
- 传值返回:会有一个拷贝
- 传引用返回:没有这个拷贝了,返回的直接就是返回变量的别名
③ 理解:引用返回的危害 - 造成未定义的行为【薛定谔的猫🐱】
在上面,我介绍到了一种对函数返回进行优化的方法 ——> 传引用返回,于是有的同学就觉得它很高大上,因此所以函数都使用了传引用返回,你认为可以吗?
- 来看看下面这段代码,你认为它的输出结果是什么呢?是1吗❓ 还是随机值❓ 亦或者是其他值
int& Count() { int n = 0; n++; cout
- 来看看下面这段代码,你认为它的输出结果是什么呢?是1吗❓ 还是随机值❓ 亦或者是其他值
- 那有什么办法可以免去这种拷贝的过程,直接将得出的结果返回回去呢?那就是引用返回
- 当需要将函数中的临时变量返回时,无论这个变量是在栈区、堆区或者静态区开辟空间,都会通过一个临时变量去充当返回值【小一点的话可能是寄存器eax,大一点可能是在上一层栈帧开好的】然后再返回给外界的值做接受
- 上面通过画出两个Count()函数的堆栈图了解到了函数中临时变量和静态变量所在空间是不同的,那当执行完这个函数之后其所在栈帧一定会销毁,此时又会发生怎样的故事呢?我们继续看下去
- 此时PushFront()内部我们也可以去做一个修改,直接使用形参SList即可,无需考虑到要对二级指针进行解引用变为一级指针
- 我们来看看下面这种引用的方式,相信在学习了引用的基本语法和特性之后你一定很快看懂下面的代码。因为x引用了a,y引用了b,所以它们是等价的,在函数内部使用临时变量对二者进行交换就可以带动外界的变化
- 通过代码可以看出,指针p指向了a所在的这块地址,接着我用q引用了p,那么指针q就相当于是指针p,q也指向了a所在的这块地址。来分解一下int*代表q是一个指针类型,&则表示指针q将会去引用另一个指针
- 这个特性很重要【⭐】,要牢记。因为上面有说到对于引用而言在定义时必须初始化,那么在定义结束完后它就已经引用了一个值,无法在对其去进行修改了,这是非法的!
- 对于第三个特性而言,其实就是一个传递性。当一个变量引用了另一个变量之后,其他变量还可以再对其进行一个引用。通过运行就可以看出它们也都是属于同一块空间
- 首先来看第一个,若是定义了一个引用类型的变量int&,那么就必须要去对其进行一个初始化,指定一个其引用的对象,否则就会报错
- 可以看到,若是去修改b的话,a也会跟着一起变化