【C++】引用
文章目录
- 1.引用概念
- 2.引用特性
- 3.常引用
- 3.1.取别名的规则
- 3.2.拓展问题
- 3.3.对权限控制的用处
- 4.引用的使用场景
- 4.1.做参数
- 4.2.做返回值
- 传值返回
- 传引用返回
- 5.传值、传引用效率比较
- 6.引用和指针的区别
1.引用概念
引用不是新定义一个变量,而是给已存在变量取了一个别名,编译器不会为引用变量开辟内存空间,它和它引用的变量共用同一块内存空间。比如:孔明,在家称为"诸葛亮",江湖上人称**“卧龙先生”**。
类型& 引用变量名(对象名) = 引用实体;代码如下:
int main() { int a = 10; int& ra = a; //ra是a的引用,即ra是a的别名 return 0; }
这里有一个变量a,a变量开辟了4个字节的空间,现在给a取一个别名ra,这也就意味着ra和a可以同时访问并修改这块空间的值,是不是比指针好用多了。我们可以发现a和ra的地址是一样的,这也验证了我们的结论。
这里再强调一下,引用就是取别名,不管你给他取什么别名,他仍然还是那个他。
既然引用是在取别名,那么我们对别名进行修改,就相当于对原数据进行修改:
注意:引用类型必须和引用实体必须是同种类型。
2.引用特性
引用有三大特性:
引用在定义时必须初始化
一个变量可以有多个引用
引用一旦引用一个实体,再不能引用其它实体
1.我们能否不初始化引用呢?
这里我们上手敲了一下,答案很明显,我们还没开始编译,就已经出现错误了,所以引用在定义时必须初始化。
2.这里我们定义一个变量a,我们给他取一个别名b,再取一个别名c,这三个变量的地址都是一样的,并且相互影响,这里我们改变一个,其他的也会随之改变。
3.直接看代码:
在这段代码中,我们已经给a取别名ra,随后又给b取别名ra,在编译过程中出错,引用一旦引用一个实体,再不能引用其它实体。
3.常引用
3.1.取别名的规则
我们在取别名的时候,不是在所有情况下都能够随便取的,要在一定范围内。
对常引用变量来说,权限只能缩小,不能放大。
权限放大
我们都知道C语言有一个const关键字,被const修饰的变量会变成只读变量,不能修改。在C++的引用中也有const引用。假设我们现在有一个变量a被const修饰,现在我们想对a取别名,是否能够做到?
const int a = 10; int& ra = a;
这里我们编译看看?好吧,全是错误。
这就是典型的权限放大,上面我们说过,const修饰的变量是只读变量,不能修改。现在我们把a引用成ra,现在ra是可读可写的,不满足a是只读的。
那么我们如何对a进行引用才能不改变它的权限呢?只需要保证权限不变即可,如下:
权限不变(平移)我们想要控制权限不变非常简单,只需要对a引用的同时加上const修饰,使ra变量也是只读的即可。
const int a = 10; const int& ra = a;
让如果变量没有加上const修饰,但是在引用时加上const可以吗?这种情况就是权限缩小,如下:
权限缩小
int a = 10; const int& ra = a;
对上述代码进行编译,发现没有任何错误,说明这种情况是可以的。并且我们可以修改a,但是却不能修改ra。
这里的变量a是可读可写的,我对ra进行const引用,会把ra变成只读变量,只是权限缩小,不违反要求,所以没问题。
3.2.拓展问题
拓展1:如何给常量取别名
可以给常量取别名吗?
int& c = 20; // false
其实是不可以直接进行取别名的,但是我们加上const就可以了:
const int& c = 20; // true
拓展2:临时变量具有常性
看如下代码:
double d = 2.2; int& e = d;
现在e能成为d的别名吗?
很明显不可以,编译器发生错误。但是我加上const,发现它竟然就不会出错了:
int main() { double d = 2.2; const int& e = d; return 0; }
怎么解释上述代码呢?这就需要我们先回顾下C语言的类型转换
C++本身是在C语言的基础上改进的,C语言在相似类型是允许隐式类型转换的。范围大的数据给范围小的数据会截断,范围小的数据给范围大的数据会提升。看如下代码:
int main() { double d = 2.2; int a = d; return 0; }
编译器运行后:
这里的会丢失数据其实就是会丢失精度
- 注意:
这里在把d的值赋给a时并不是直接赋值的,会把d的整数部分取出来,赋值给一个临时变量,该临时变量大小4个字节,随后再把这个临时变量赋给a。
临时变量具有常性,就像被const修饰了一样,不能被修改。
- 谈到这,你就应该能够理解上文的这段代码为什么要加上const才能编译通过:
double d = 2.2; const int& e = d;
答案很简单,这里e引用的是临时变量,临时变量具有常性,不能直接引用,否则就是放大了权限,加上const才能保证其权限不变。
- 可能又会有人提问了,那为什么这段代码在赋值的时候不加上const呢?
double d = 2.2; int a = d;
其实很简单,上述加const是在我引用的基础上加的,如若不加const,那么就是放大权限,让e变为可读可写的同时临时变量也一样,而此段代码中,对a的改变并不会影响到我的临时变量,更不会影响到d,主要就是普通的变量不存在权限放大或缩小。
- 此时又有人提问了,那么此时的e还是对d的引用吗?
double d = 2.2; const int& e = d;
这当然不是,此时的e是对临时变量的引用,是临时变量的别名。可以通过编译来验证:
这里我们发现e的地址和d的地址不同,以及a的地址也不同。
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& a, int& b) { int tmp = a; a = b; b = tmp; } //支持函数重载 void Swap(double& c, double& d) { double tmp = c; c = d; d = tmp; }
现在,引用就可以做我的形参,就不用再像以前C语言那样总是取地址&,并且在调用的时候也会非常方便,因为有函数重载的加持。
int main() { //交换整数 int a = 0, b = 1; Swap(a, b); //交换浮点数 double c = 1.2, d = 2.1; Swap(c, d); return 0; }
- 引用还有一个好处在输出型参数会得到体现:
int* preorderTraversal(struct TreeNode* root, int* returnSize) { //…… }
这里给一个*returnSize多少有点奇怪,其实这样写就非常方便:
int* preorderTraversal(struct TreeNode* root, int& returnSize) {
加上引用会在调用时省去写&,也更方便理解,减少对指针的使用。
综上,引用做参数的好处如下:
- 输出型参数
- 减少拷贝、提高效率
引用还有一个使用场景是做返回值,具体看下文:
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的值时自然就是随机值了。其实第一次打印,也不一定绝对是1,因为函数栈帧已经销毁了,如果这块空间被操作系统使用,那么这里的值就不是1了。
综上这种情况是不能进行引用返回的。
- 若我非要引用返回呢?如何正确使用?
加上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, 1); Add(1, 2); cout << ret << endl; //3 return 0; }
这段代码执行的结果ret的值为3,首先我的Add(1,1)调用完后,返回c的别名给ret,随即调用完Add栈帧销毁,当我第二次调用时c的值就被修改为3了,这里想表达的是这里是不安全的。
正常情况下我们应该加上static:
static初始化只有一次。此时c被存放在静态区了,函数栈帧销毁了,它仍然存在。
- 这里再演示下其被覆盖的情形:
正常情况:
不加static发生覆盖:
5.传值、传引用效率比较
以值作为参数或者返回值类型,在传参和返回期间,函数不会直接传递实参或者将变量本身直接返回,而是传递实参或者返回变量的一份临时的拷贝,因此用值作为参数或者返回值类型,效率是非常低下的,尤其是当参数或者返回值类型非常大时,效率就更低。
#include <time.h> struct A { int a[100000]; }; void TestFunc1(A a) {} void TestFunc2(A& a) {} void TestRefAndValue() { A a; // 以值作为函数参数 size_t begin1 = clock(); for (size_t i = 0; i < 100000; ++i) TestFunc1(a); size_t end1 = clock(); // 以引用作为函数参数 size_t begin2 = clock(); for (size_t i = 0; i < 100000; ++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; //false 引用没有初始化 int* p; //true 指针可以不初始化
- 在sizeof中含义不同:引用结果为引用类型的大小,但直至始终时地址空间所占字节个数(32位平台下占4个字节)
double d = 2.2; double& r = d; cout << sizeof(r) << endl; //8
- 在语法概念上引用就是一个别名,没有独立空间,和其引用实体共用同一块空间。
在底层实现上实际是有空间的,因为引用是按照指针方式来实现的。
int main() { int a = 0; //语法角度而言:ra是a的别名,没有额外开空间 //底层的角度:它们是一样的方式实现的 int& ra = a; ra = 1; //语法角度而言:pa存储a的空间地址,pa开了4/8字节的空间 //底层的角度:它们是一样的方式实现的 int* pa = &a; *pa = 1; return 0; }
我们来看下引用和指针的汇编代码对比: