Ⅲ. 关于引用的探讨
0x00 比较传值和传引用的效率
❓ 那传值返回和传引用返回的区别是什么呢?
💡 传引用返回速度更快。
📚 以值作为参数或者返回值类型,在传参和返回期间,
函数不会直接传递实参或者将变量本身直接返回,而是传递实参或者返回变量的一份临时拷贝。
因此值作为参数或者返回值类型,效率是非常低下的,
尤其是当参数或者返回值类型非常大时,效率就更低。
传值和传引用的效率比较:
#include <iostream> #include <time.h> using namespace std; struct S { int arr[10000]; }; void CallByValue(S a) { ; } void CallByReference(S& a) { ; } void TimeCompare() { S s1; /* 以值作为函数参数 */ size_t begin1 = clock(); for (size_t i = 0; i < 100000; i++) { CallByValue(s1); } size_t end1 = clock(); /* 以引用作为函数参数 */ size_t begin2 = clock(); for (size_t i = 0; i < 100000; i++) { CallByReference(s1); } size_t end2 = clock(); /* 计算两个函数运行结束后的时间 */ cout << "Call by Value: " << end1 - begin1 << endl; cout << "Call By Reference: " << end2 - begin2 << endl; } int main(void) { TimeCompare(); return 0; }
🚩 运行结果:
值和引用作为返回值类型的性能对比:
💬 记录起始时间和结束时间,从而计算出两个函数完成之后的时间。
#include <iostream> #include <time.h> using namespace std; struct S { int arr[10000]; }; void ByValue(S a) { ; } void ReturnByReference(S& a) { ; } void TimeCompare() { S s1; /* 以值作为函数参数 */ size_t begin1 = clock(); for (size_t i = 0; i < 100000; i++) { ByValue(s1); } size_t end1 = clock(); /* 以引用作为函数参数 */ size_t begin2 = clock(); for (size_t i = 0; i < 100000; i++) { ReturnByReference(s1); } size_t end2 = clock(); /* 计算两个函数运行结束后的时间 */ cout << "Return By Value: " << end1 - begin1 << endl; cout << "Return By Reference: " << end2 - begin2 << endl; } int main(void) { TimeCompare(); return 0; }
🚩 运行结果如下:
传值返回会创建临时变量,每次会拷贝 40000 byte。
而传引用返回没有拷贝,所以速度会快很多很多,因为是全局变量所以栈帧不销毁。
所以这种场景我们就可以使用传引用返回,从而提高程序的运行效率。
🔺 总结:传值和船只真在作为传参以及返回值类型上效率相差十分悬殊。
引用的作用主要体现在传参和传返回值:
① 引用传参和传返回值,有些场景下面,可以提高性能(大对象 + 深拷贝对象)。
② 引用传参和传返回值,输出型参数和输出型返回值。
有些场景下面,形参的改变可以改变实参。
有些场景下面,引用返回,可以减少拷贝、改变返回对象。(了解一下,后面会学)
引用后面用的非常的多!非常重要!
0x01 引用和指针的区别
在语法概念上:引用就是一个别名,没有独立空间,和其引用实体共用同一块空间。
但是在底层的实现上:实际上是有空间的,因为引用是按照指针方式来实现的。
#include <iostream> #include <time.h> using namespace std; int main(void) { int a = 10; int& ra = a; ra = 20; int* pa = &a; *pa = 20; return 0; }
🔍 我们来看看引用和指针的汇编代码对比:
0x02 指针和引用的不同点
总结 ❌ 整活 ✅
① 引用是在概念上定义一个变量的别名,而指针是存储一个变量的地址。
② 引用在定义时必须初始化,而指针是最好初始化,不初始化也不会报错。
int a = 0; int& ra; ❌ 必须初始化! int* pa; ✅ 可以不初始化
③ 引用在初始化时引用一个实体后,就不能再引用其他实体,而指针可以在任何时候指向任何一个同类型的实体。
④ 有空指针,但是没有空引用。
⑤ 在 sizeof 中含义不同,引用结果为引用类型的大小,但指针始终是地址空间所占字节数(64位平台下占8个字节)
🚩 运行结果如下:(本机为64位环境)
⑥ 引用++即引用的实体增加1,指针++即指针向后偏移一个类型的大小。
#include <iostream> using namespace std; int main(void) { int a = 10; int& ra = a; int* pa = &a; cout << "ra加加前:" << ra << endl; ra++; cout << "ra加加后:" << ra << endl; cout << "pa加加前:" << pa << endl; pa++; cout << "pa加加后:" << pa << endl; return 0; }
🚩 运行结果如下:
⑦ 有多级指针,但是没有多级引用。
⑧ 访问实体方式不同,指针需要显式解引用,引用编译器自己处理。
#include <iostream> using namespace std; int main(void) { int a = 10; int& ra = a; int* pa = &a; cout << ra << endl; // 引用直接是编译器自己处理,即取即用。 cout << *pa << endl; // 指针得加解引用操作符*,才能取到。 return 0; }
🚩 运行结果如下:
⑨ 引用比指针使用起来相对更加安全。
🔺 总结:指针使用起来更复杂一些,更容易出错一些。(指针和引用的区别,面试经常考察)
使用指针有考虑空指针,野指针等等问题,指针太灵活了,所以相对而言没有引用安全!
Ⅳ. 常引用
0x00 常引用的概念
如果既要利用引用来提高程序的效率,又想要保护传递给函数的数据不能在函数中被改变,就应使用常引用。
📜 语法:const 数据类型& 引用名 = 引用实体;
一共有三种情况:分别是权限的放大、保持权限不变、权限的缩小。
0x01 权限的放大
💬 下面是一个引用的例子:
int main(void) { int a = 10; int& ra = a; return 0; }
💬 如果对引用实体使用 const 修饰,直接引用会导致报错:
int main(void) { const int a = 10; int& ra = a; return 0; }
🚩 运行结果如下:(报错)
🔑 分析:导致这种问题的原因是,我本身标明了 const,这块空间上的值不能被修改。
我自己都不能修改,你 ra 变成我 a 的引用,意味着你 ra 可以修改我的 a,
这就是属于权限的放大问题,a 是可读的,你 ra 要变成可读可写的,当然不行。
这要是能让你随随便便修改,那我岂不是 const 了个寂寞?
这合理吗?这不合理!
❓ 那么如何解决这样的问题,我们继续往下看。
0x02 保持权限的一致
既然引用实体用了 const 进行修饰,我直接引用的话属于权限的放大,
我们可以给引用前面也加上 const,让他们的权限保持不变。
💬 给引用前面加上 const:
int main(void) { const int a = 10; const int& ra = a; return 0; }
🔑 解读:const int& ra = a 的意思就是,我变成你的别名,但是我不能修改你。
这样 a 是可读不可写的,ra 也是可读不可写的,这样就保持了权限的不变。
如果我们想使用引用,但是不希望它被修改,我们就可以使用常引用来解决。
0x03 权限的缩小
如果引用实体并没有被 const 修饰,是可读可写的,
但是我希望它的引用不能修改它,我们可以用常引用来解决。
💬 a 是可读可写的,但是我限制 ra 是可读单不可写:
int main(void) { int a = 10; const int& ra = a; return 0; }
🔑 解读:这当然是可以的,这就是权限的缩小。
举个例子,就好比你办身份证,你的本名是可以印在身份证上的,
但是你的绰号可以印在身份证上吗?
所以就需要加以限制,你的绰号可以被人喊,但是不能写在身份证上。
所以,权限的缩小,你可以理解为是一种自我的约束。
0x04 常引用的应用
💬 举个例子:
假设 x 是一个大对象,或者是后面学的深拷贝的对象
那么尽量用引用传参,以减少拷贝。
如果 Func 函数中不需要改变 x,那么这里就尽量使用 const 引用传参。
void Func(int& x) { cout << x << endl; } int main(void) { const int a = 10; int b = 20; Func(a); // ❌ 报错,涉及权限的放大 Func(b); // 权限是一致的,没问题 return 0; }
加 const 后,让权限保持一致:
// "加上保持权限的一致" 👇 void Func(const int& x) { cout << x << endl; } int main(void) { 👇 const int a = 10; int b = 20; Func(a); // 权限是一致的 Func(b); // 权限的缩小 return 0; }
🔑 解读:如此一来,a 是可读不可写的,传进 Func 函数中也是可读不可写的,
就保持了权限的一致了。b 是可读可写的,刚才形参还没使用 const 修饰之前,
x 是可读可写的,但是加上 const 后,属于权限的缩小,x 就是可读但不可写的了。
常引用后期会用的比较多,现在理解的不深刻也没关系,早晚的事情。
后面讲类和对象的时候会反复讲的,印象会不断加深的。
0x05 带常性的变量的引用
💬 先看代码:
int main(void) { double d = 3.14; int i = d; return 0; }
这里的 d 是可以给 i 的,这个在C语言里面叫做 隐式类型转换 。
它会把 d 的整型部分给 i,浮点数部分直接丢掉。
❓ 但是我在这里加一个引用呢?
int main(void) { double d = 3.14; int& i = d; // 我能不能用i去引用d呢? return 0; }
🚩 运行结果:(报错)
直接用 i 去引用 d 是会报错的,思考下是为什么?
这里可能有的朋友要说,d 是浮点型,i 是整型啊,会不会是因为类型不同导致的?
但是奇葩的是 —— 如果我在它前面加上一个 const 修饰,
却又不报错了,这又是为什么?
int main(void) { double d = 3.14; const int& i = d; // ??? 又可以了 return 0; }
哎它* *滴!const,const 为什么行!!!
🔑 解析:因为 内置类型产生的临时变量具有常性,不能被修改。
隐式类型转换不是直接发生的,而是现在中间产生一个临时变量。
是先把 d 给了临时变量,然后再把东西交给 i 的:
如果这里用了引用,生成的是临时变量的别名,
又因为临时变量是一个右值,是不可以被修改的,所以导致了报错。
🔺 结论:如果引用的是一个带有常性的变量,就要用带 const 的引用。
0x06 常引用做参数
使用引用传参,如果函数中不改变参数的值,建议使用 const 引用
💬 举个例子:
一般栈的打印,是不需要改变参数的值的,这里就可以加上 const
void StackPrint(const struct Stack& st) {...}
const 数据类型& 可以接收各种类型的对象。
使用 const 的引用好处有很多,如果传入的是 const 对象,就是权限保持不变;
普通的对象,就是权限的缩小;中间产生临时变量,也可以解决。
因为 const 引用的通吃的,它的价值就在这个地方,如果不加 const 就只能传普通对象。