五、传值、传引用效率对比
在上一模块,我介绍了有关引用的两种使用场景,相信你在学习了之后也是一头雾水,学它有什么用呢?和普通的传值有何区别?本模块就来对【传值】和【传引用】这两种方式来做一个对比
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不具有常性,因此它是可以被修改的。那么这个时候就会产生歧义了,也就出现了问题 - [x] 这种引用形式叫做【权限放大】,本来我这个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; //普通变量不受约束
- 可以看到,编译器并没有报出错误,这是为什么呢?有些同学在看了上面的【权限放大】之后认为这应该就会报错呀,可这是编译器出问题了吗?
- [x] 来揭晓一下答案:其实不是的,在上面我们讲到的是有关常引用方面的语法特性,但是在这里并没有使用到引用,只是普通的变量赋值而言,通过去修改一下m和n就可以发现,n的修改是不会影响到m的,==虽然m具有常性,但是n不具有常性==,而对于我们上面所讲到的【引用】两个相互之间的变量是会绑在一起的,修改了任何一方另一方都是随之改变。对于权限放大只适用于引用和指针类型
2. 临时变量具有常属性【⭐】
- 接下去我们再来看第二个容易混淆知识点,下面是一个普通的函数调用,在外界使用了ret去接受了这个返回值。相信在学习了上面内容之后你的定义反映就是返回了临时的局部变量,出现的会是一个随机值。确实是要使用临时变量,不过我要讲的并不是这个知识点
int Count() { int n = 0; n++; // ... return n; }
int ret = Count();
- 此时,我使用【引用接受】去接受了函数内部的返回值,但是去编译的时候就出现了问题,非常引用的初始值必须为左值。也就是说这个右边的Count()返回的内容是一个【常】,具有常属性,这是为何呢?
- [x] 这就涉及到一个很重要的知识点了,无论是在哪里,临时变量都具有常属性。当Count()函数的返回一个函数内部的临时变量时,这个变量就具有常性,若外界使用一个非const的引用类型去引用这个临时变量的话,就会造成【权限放大】的问题
int& ret = Count();
- 我们可以再来看一个场景,对于下面这个函数调用也是会出现问题的
void print(string& str) { cout << str << endl; } int main(void) { print("hello world!"); return 0; }
Windows环境下运行
LInux环境下运行
- 从双平台的运行结果来看,这段代码都是有问题的,仔细观察可以发现问题都出在这个【const常】上,可以在代码里面我并没有写
const
呀,怎么会和常扯上关系呢? - [x] 这其实就是要涉及到临时变量具有常性这个特点了,回忆一下我们上面讲到过的【权限放大】,就是将一个常属性的变量给到一个非常性的变量做引用,此时就会造成权限的放大。对于这个
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;
- [x] 但无论是对于隐式还是显示,只要是类型转换都会产生临时变量,所以对于下面这个rd引用的其实并不是整型变量
i
,而是i
在进行类型转换的时候产生一个【double】类型的临时变量,rd是对它进行了一个引用 - [x] 而上面我们说到了临时变量具有常性,所以一个非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; }
- 通过将这段代码转到【反汇编】,就可以发现引用和指针在底层的实现竟然是一样的😮
我们可以来浅浅分析一下:mag:
- 首先
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
- [x] 那对于指针来说在底层的实现也是同理,如果觉得难以理解的小伙伴也可以不关心这些底层的实现,你只需要知道从【汇编层面】来看引用其实和指针一样,都是需要开出空间的
2、感性理解【大众与保时捷的关系🚗】
其实难以理解的读者可以通过这么一个生活中的案例去理解
- 对于保时捷来说其实大众旗下的一个牌子,大众有一款车叫途锐,保时捷有一款车叫卡宴。卡宴是百万级别的,途锐以前还神一点,现在是五六十万级别的车
- 但是它们的三大件;==发动机、悬架、变速箱==基本都是一样的
- [x] 所以我们可以得出从上层看它们是不一样的,但是从底层看其实都是一样的
所以对于引用来说在我们看来是不会开新空间的,但实际上底层却做了相反的事,和我们想的是千差万别
- 其实在生活中有很多事情与我们肉眼看到的都不一样,比如说:🐟鱼香肉丝有鱼吗?💑夫妻肺片有肺片吗?🧇老婆饼有老婆吗?
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;
- 访问实体方式不同,指针需要显式解引用,引用编译器自己处理
- 指针若是想访问到所存地址的内容,则需要进行解引用;引用不需要,因为二者是属于同一块空间,绑定在一起,访问本体即可找到引用体
- 引用比指针使用起来相对更安全
- 引用是一个更好用一点、更简单一点、更安全一点的指针
- 指针和引用都是大肠,但是吃的时候要小心,吃引用更安全,吃指针就不好说了,可能吃到的就是【九转大肠】
✍一道阿里巴巴2015笔试题
接下去放一道阿里的历年笔试题,比较经典,也容易出错
关于引用以下说法错误的是( )
A.引用必须初始化,指针不必 B.引用初始化以后不能被改变,指针可以改变所指的对象 C.不存在指向空值的引用,但是存在指向空值的指针 D.一个引用可以看作是某个变量的一个“别名” E.引用传值,指针传地址 F.函数参数可以声明为引用或指针类型
【答案】:E
【解析】;下面的解析均有演示过,此处不再演示:computer:
A. 引用必须初始化,必须在定义引用时明确引用的是哪个变量或者对象,否则语法错误,指针不初 始化时值为随机指向
B.引用一旦定义时初始化指定,就不能再修改,指针可以改变指向
C.引用必须初始化,不能出现空引用,指针可以赋值为空
D.简单粗暴的引用理解可以理解为被引用变量或对象的"别名"
👉E.有看过汇编,引用表面是传值,其实底层也是传地址,只是这个工作有编译器来做,所以错误
F.函数调用为了提高效率,常使用引用或指针作为函数参数传递变量或对象
八、总结与提炼
最后,来总结一下本文所学习的内容:book:
- 首先我们了解了什么是引用,知晓了原来引用就是【取别名】,主体与被引用体使用的都是同一块空间
- 接下去学习了有关引用的五大特性,知道了
- 在定义时必须初始化
- 一个变量可以有多个引用
- 一个引用可以继续有引用
- 用一旦引用一个实体,再不能引用其他实体
- 可以对任何类型做引用
- 有了基本的概念和理解之后,我们就开始真正地使用引用,关注到它被使用的两种场景,分别是:① 做参数;② 做返回值;这一模块讲解地非常细致,里面不仅包含引用相关的很多难点,而且还有一些内存空间相关的知识,特别是对于【引用做返回值】这一块读者一定要细细品味:tea:
- 了解了引用的两种使用场景后,便通过传值、传引用去分别比较了在这两种场景下二者的差距,很明显引用还是更胜一筹,比较拷贝是需要耗费的时间
- 接下去拓展了一点,说了引用的另一块知识点 ——【常引用】,我们日常在使用引用的时候,一定要注意千万不可将权限放大,只可做到==权限保持或者是权限缩小==
- 最后的最后,又去对比了指针和引用二者区别所在,知道了原来在底层的实现中【引用】和【指针】其实差不太多,都是需要开辟空间的。但二者还是存在很多的区别,都得读者列出来了,这些都是在笔试面试当中常考的内容,还望谨记!
以上就是本文要介绍的所有内容,如果觉得有帮助可以给个三连哦:rose::rose::rose: