目录
1、引用概念
引用不是新定义一个变量,而是给已存在变量取了一个别名,编译器不会为引用变量开辟内存空间,它和它引用的变量共用同一块内存空间。
类型& 引用变量名(对象名) = 引用实体;看代码:
int main() { //把b叫做a的引用,也叫做b是a的别名 int a = 10; int& b = a; return 0; }
这里有一个变量a,a这块空间占4个字节,现在又给a取了一个名字叫b,也就是说a和b同时可以访问且修改这块空间,并且这里a和b的地址均是一样的。
引用的实质就是在取别名,就好比西游记里的孙悟空,你叫他弼马温、齐天大圣、孙行者都是一个道理
既然引用是在取别名,那我对别名进行修改,就相当于对自己本身进行修改:
2、引用特性
引用具有三大特性:
- 引用在定义时必须初始化
- 一个变量可以有多个引用
- 引用一旦引用一个实体,再不能引用其它实体
接下来,我将做具体演示:
引用在定义时必须初识化
我能否写出如下的引用呢?
int& d;
答案很明显,还没等你编译,就已经肉眼看见的错误了,综上,引用在定义时必须初始化。
一个变量可以有多个引用
比如我有个变量a,你可以给其取个别名b,也可以取个别名c,甚至给别名c再取别名d都可以,并且这些别名和a的地址均是一样的,我改变其中一个,其它的也会随之改变。
引用一旦引用一个实体,再不能引用其它实体
看代码:
在这段代码中,我们已经给a取别名b,随后把e的值赋给b,这里可不是对e取别名了,通过编译即可看出来,b的地址同引用的a的地址,而不同于e的地址。
3、常引用
3.1、取别名的规则
我们在取别名的时候,不是在所有情况下都可以随便取的,要在一定范围内。
对原引用变量,权限只能缩小,不能放大。
权限放大error
我们都清楚C语言有个const,在C++的引用这一块也是有const引用的
假如我们现在有const修饰过的变量x,现在想对x取别名,还能像如下的方式进行吗?
//权限放大 err const int x = 20; int& y = x;
此时的y还是x的别名吗?编译起来一看全是错误
这就是典型的权限放大,学过C语言我们都清楚,const是只读的,对于变量x,我们只可以进行读,不能进行修改。而此时我们对x引用成y,且是int型的,此时y是可读可写的,不满足x的只读条件。
那怎么样才能对x进行引用呢?只需要确保权限不变即可,见下文:
权限不变
想要控制权限不变非常简单,只需要对x引用的同时加上const修饰即可,让变量y也是只读的
//权限不变 const int x = 20; const int& y = x;
那如果变量没有加const修饰,但是在引用时加了const可以吗?这就是权限缩小,看下文:
权限缩小
//权限缩小 int c = 30; const int& d = c;
我们针对上述代码进行编译,发现编译器没有任何报错,答案是可以的。
这里的c是可读可写的,我对c进行const引用,顶多就是把c改变为只读的,只是权限缩小,不违反要求,当然是可以的。
3.2、拓展1:如何给常量取别名
可以给常量取别名吗?
int& c = 20; // err
其实是不可以直接进行取别名的,但是我们加上const就可以了:
const int& c = 20; // right
拓展2:临时变量具有常性
看如下代码:
double d = 2.2; int& e = d;
现在e能成为d的别名吗?
很明显不可以,编译器发生错误。但是我加上const,发现它竟然就不会出错了:
double d = 2.2; const int& e = d;
怎么解释上述代码呢?这就需要我们先回顾下C语言的类型转换
C++本身是在C语言的基础上走的,C语言在相似类型是允许隐式类型转换的。大给小会截断,小给大会提升。看如下代码:
double d = 2.2; int f = d;
编译器运行后:
这里的会丢失数据其实就是会丢失精度
- 注意:
这里在把d的值赋给f时并不是直接赋值的,会把d的整数部分取出来,赋值给一个临时变量,该临时变量大小4个字节,随后再把这个临时变量给给f
临时变量具有常性,就像被const修饰了一样,不能被修改
- 谈到这,你就应该能够理解上文的这段代码为什么要加上const才能编译通过:
double d = 2.2; const int& e = d;
答案很简单,这里e引用的是临时变量,临时变量具有常性,不能直接引用,否则就是放大了权限,加上const才能保证其权限不变
- 可能又会有人提问了,那为什么这段代码在赋值的时候不加上const呢?
double d = 2.2; int f = d;
其实很简单,上述加const是在我引用的基础上加的,如若不加const,那么就是放大权限,让e变为可读可写的同时临时变量也如此,而此段代码中,对f的改变并不会影响到我临时变量,更不会影响到d,主要就是普通的变量不存在权限放大或缩小。
- 此时又有人提问了,那么此时的e还是对d的引用吗?
double d = 2.2; const int& e = d;
这当然不是,此时的e是对临时变量的引用,是临时变量的别名。可以通过编译来验证:
3.3、对权限控制的用处
这里简单提下,例如这个传参的问题。
如若函数写出普通的引用,那么很多参数可能会传不过来:
仔细看这段代码,只有a能正常传过去,后面的均传不过去,因为后面传的参数均涉及权限放大,固然编译器会出错
但是当我们在函数的形参那加上const呢?
加了const后编译器就会报错了
4、引用的使用场景
引用的使用场景分为两个:
- 做参数
- 做返回值
接下来,我将会详细讲解下:
4.1、做参数
就比如说我现在要写一个Swap函数,以前是用指针写的:
//指针版 void Swap(int* pa, int* pb) { int tmp = *pa; *pa = *pb; *pb = tmp; }
而现在,我们就可以巧用引用来完成Swap函数
//引用版 void Swap(int& x, int& y) { int tmp = x; x = y; y = tmp; } //支持函数重载 void Swap(double& x, double& y) { double tmp = x; x = y; y = tmp; }
现在,引用就可以做我的形参,就不用再像以前C语言那样总是取地址&,并且在调用的时候也会非常方便,因为有函数重载的加持。
int main() { //交换整数 int a = 0, b = 1; Swap(a, b); //交换浮点数 double c = 1.1, d = 2.2; Swap(c, d); return 0; }
- 引用还有一个好处在输出型参数会得到体现:
int* preorderTraversal(struct TreeNode* root, int* returnSize) { //…… }
这里给一个*returnSize多少有点奇怪,其实这样写就非常方便:
int* preorderTraversal(struct TreeNode* root, int& returnSize) { //…… } int main() { preorderTraversal(tree, size); }
加上引用会在调用时省去写&,也更方便理解,减少对指针的使用。
综上,引用做参数的好处如下:
- 输出型参数
- 减少拷贝、提高效率
引用还有一个使用场景是做返回值,具体看下文:
4.2、做返回值
先看一段代码:
int Count() { static int n = 0; n++; return n; } int main() { cout << Count() << endl; //1 cout << Count() << endl; //2 cout << Count() << endl; //3 return 0; }
针对此段代码,我们运行的结果是1、2、3。
- 这里可能有人会提问为什么不是1、1、1呢?注意这里使用了静态区的变量只会初始化一次,也就是说我static int n = 0这行代码在编译时只有第一次会跳到这,其余两次均不会走这一行代码,你每次进去的n都是同一个n。通过调试我们就可以看出,这里n的地址始终都是一样的。
传值返回
传值返回是有讲究的。正如这段代码:
int Count() { int n = 0; n++; return n; } int main() { int ret = Count(); return 0; }
在传值返回的过程中会产生一个临时变量(类型是int),如果这个临时变量小它会用寄存器替代,如果大就不会用寄存器替代。
具体返回的过程是先把函数的n拷贝给临时变量,再把临时变量拷贝给ret。
为什么要设计这个临时变量呢?
上述代码不可以直接把n返回给ret,这里我们简要画个栈帧图即可看出:
main函数里有个变量ret,汇编时会call一个指令跳到函数Count,Count里有一个变量n。这里不能把n直接传给ret,因为在函数Count调用完成后要拿一个值赋给ret,且函数调用完后函数栈帧就销毁了,所以赋给ret的这个值就是设计出的临时变量
如何证明我这中间会产生临时变量呢?
只需要加个引用即可。
这里很明显编译发生错误。为什么呢?这里其实答案就很明显了,这里ret之所以出错不就是因为其引用的是临时变量呢,因为临时变量具有常性,只读不可修改,直接引用则会出现上文所述的权限放大问题。 所以这不很巧合的验证了此函数调用中途会产生临时变量。
解决方法也很简单,保持权限不变即可,即加上const修饰:
传引用返回
我们对上述代码进行微调:
int& Count() { int n = 0; n++; return n; } int main() { int ret = Count(); return 0; }
这里加上了引用&后,中间也会产生一个临时变量,只是这个临时变量的类型是int&。我们把这个临时变量假定为tmp,那么此时tmp就是n的别名,再把tmp赋值给ret。这个过程不就是直接把n赋给ret吗。这里区分于传值返回的核心就在于传引用的返回就是n的别名。
如何证明传引用返回的是n的别名?
只需要在函数调用时加个引用即可:
我们也可以通过打印法来验证:
这里ret和n的地址一样,也就意味着ret其实就是n的别名。综上,传值返回和传引用的返回的区别如下:
- 传值返回:会有一个拷贝
- 传引用返回:没有这个拷贝了,返回的直接就是返回变量的别名
现在又存在一个问题了:我传引用的代码对不对?
我传引用返回后,ret就是n的别名,但是有没有想过,出了函数出了这个作用域我n不是都销毁了吗,怎么还会有别名呢?
空间的销毁不是说空间就不在了。空间的归还就好比你退房,虽然你退房了,但是这个房间还是在的,只是说使用权不是你的了。但是假说你在不小心的情况下留了一把钥匙,你依旧是可以进入这个房间,不过你这个行为是非法的。这个例子也就足矣说明了上述的代码是有问题的。是一个间接的非法访问。
仔细看我这段截图:
这里第一次打印ret的值为1,第二次打印的ret为随机值,这就是因为发生了覆盖。这里你第一次打印是正常的,随后打印完后,函数栈帧销毁,此时又打印了其它东西,又会函数调用覆盖了原来函数的位置,当你第二次打印ret的值时自然就是随机值了。
综上这种情况是不能进行引用返回的。
- 若我非要引用返回呢?如何正确使用?
加上static即可:
int& Count() { static int n = 0; n++; cout << "&n: " << &n << endl; return n; } int main() { int& ret = Count(); cout << ret << endl; cout << "&ret: " << &ret << endl; cout << ret << endl; return 0; }
加上了static后就把n放在了静态区了,出了作用域不会销毁,自然而然可以正确使用引用返回了,并且输出结果也是我们预期的:
- 注意:
如果函数返回时,出了函数作用域,如果返回对象还未还给系统,则可以使用引用返回,如果已经还给系统了,则必须使用传值返回。否则就可能会出越界问题。
- 再举一个例子:
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; //7 return 0; }
这段代码执行的结果ret的值为7,首先我Add(1,2),调用完后,返回c的别名给ret,随即调用完Add栈帧销毁,当我第二次调用时c的值就被修改为7了,这里想表达的是这里是不安全的。
正常情况下我们应该加上static:
加上static后这里ret的值就是3了,因为加上了static初始化只有一次。此时c在静态区了,销毁栈帧它还在。
- 这里再演示下其被覆盖的情形:
正常情况:
不加static发生覆盖:
5、传值、传引用效率比较
以值作为参数或者返回值类型,在传参和返回期间,函数不会直接传递实参或者将变量本身直接返回,而是传递实参或者返回变量的一份临时的拷贝,因此用值作为参数或者返回值类型,效率是非常低下的,尤其是当参数或者返回值类型非常大时,效率就更低。
#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; cout << "TestFunc2(A&)-time:" << end2 - begin2 << endl; } int main() { TestRefAndValue(); }
- 值和引用的作为返回值类型的性能比较
#include <time.h> struct A { int a[10000]; }; A a; // 值返回 A TestFunc1() { return a; } // 引用返回 A& TestFunc2() { return a; } void TestReturnByRefOrValue() { // 以值作为函数的返回值类型 size_t begin1 = clock(); for (size_t i = 0; i < 100000; ++i) TestFunc1(); size_t end1 = clock(); // 以引用作为函数的返回值类型 size_t begin2 = clock(); for (size_t i = 0; i < 100000; ++i) TestFunc2(); size_t end2 = clock(); // 计算两个函数运算完成之后的时间 cout << "TestFunc1 time:" << end1 - begin1 << endl; cout << "TestFunc2 time:" << end2 - begin2 << endl; } int main() { TestReturnByRefOrValue(); }
6、引用和指针的区别
引用和指针的不同点:
- 引用概念上定义一个变量的别名,指针存储一个变量地址
- 引用在定义时必须初始化,指针没有要求
- 引用在初始化时引用一个实体后,就不能再引用其他实体,而指针可以在任何时候指向任何一个同类型实体
- 没有NULL引用,但有NULL指针
- 在sizeof中含义不同:引用结果为引用类型的大小,但指针始终是地址空间所占字节个数(32位平台下占4个字节)
- 引用自加即引用的实体增加1,指针自加即指针向后偏移一个类型的大小
- 有多级指针,但是没有多级引用
- 访问实体方式不同,指针需要显式解引用,引用编译器自己处理
- 引用比指针使用起来相对更安全
接下来就上述指针与引用不同点做详细解析:
- 引用在定义时必须初始化,指针没有要求
int& r; //err 引用没有初始化 int* p; //right 指针可以不初始化
- 在sizeof中含义不同:引用结果为引用类型的大小,但直至始终时地址空间所占字节个数(32位平台下占4个字节)
double d = 2.2; double& r = d; cout << sizeof(r) << endl; //8
- 在语法概念上引用就是一个别名,没有独立空间,和其引用实体共用同一块空间。
在底层实现上实际是有空间的,因为引用是按照指针方式来实现的。
int main() { int a = 10; //语法角度而言:ra是a的别名,没有额外开空间 //底层的角度:它们是一样的方式实现的 int& ra = a; ra = 20; //语法角度而言:pa存储a的空间地址,pa开了4/8字节的空间 //底层的角度:它们是一样的方式实现的 int* pa = &a; *pa = 20; return 0; }
我们来看下引用和指针的汇编代码对比:
通过反汇编我们可以看出:引用是按照指针方式来实现的。