【C++】引用

一、引用相关概念

引用不是新定义一个变量,而是给已存在变量取了一个别名,编译器不会为引用变量开辟内存空间,它和它引用的变量共用同一块内存空间。

比如:李逵,在家称为"铁牛",江湖上人称"黑旋风"。
引用的定义方法如下:

void TestRef()
{
    int a = 10;
    int& ra = a;//<====定义引用类型
    printf("%p\n", &a);
    printf("%p\n", &ra);
}

运行结果为:
在这里插入图片描述
所以可以证明,引用就是给对应变量创建一个别名,二者表示的是内容空间中同一块内容。

二、引用的特性

引用包含以下三个特性:
※1. 引用在定义时必须初始化

  1. 一个变量可以有多个引用

  2. 引用一旦引用一个实体,再不能引用其他实体

首先,引用在定义时必须进行初始化。根据其性质我们也不难理解,它本身就要和它所引用的内容指向同一块空间,你不告诉它是哪块空间自然是不合理的。
其次,一个变量可以有多个引用也不难理解,因为他们只是他们本身引用的对象的类似于标签的东西,一个对象可以有很多标签,也就可以定义多个引用。
最后,与其他面向对象的语言不同,C++中引用一旦选择好了对象,就不能再发生改变,也要注意一下。
实例代码:

void TestRef()
{
   int a = 10;
   int b = 10;
   // int& ra;   // 该条语句编译时会出错,因为它没有初始化
   int& ra = a;
   int& rra = a;
   //ra = b;  //这条语句并不能改变ra的指向,而是把ra指向(引用)内容的值改为b的值
   printf("%p %p %p\n", &a, &ra, &rra);  
}

三、引用的几个应用场景

①做参数

void Swap(int& left, int& right)
{
   int temp = left;
   left = right;
   right = temp;
}

这里如果我们把函数参数中的引用符号去掉大家应该都不陌生,这是一个整型数字交换的函数。之前我们在主函数中调用此函数时,为了能够实现两个数字在内存中能够真正交换,我们都会选择将两个数字的地址(&)传入函数,然后在函数内部解引用(*)并完成交换。我们知道引用是对于某个变量的别名,它和原变量指向的是同一块内存地址,因此这里我们直接将引用作为参数传入函数就相当于把要交换的数字本身传入了函数,无需再取地址,这样也能直接完成交换工作。

②做函数返回值

先贴一段存在一些小问题的代码:

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;
    return 0;
}

我们先猜一猜,这段代码在终端的输出结果是什么?

答案是7
我们贴图为证:
在这里插入图片描述
这里我们先要记住引用的机制。引用作为返回值,主函数接受到这个引用后他仍然指向它原来所引用的变量本身。

在这个Add函数中,变量c是在函数体中刚刚创建的,我们将c的引用返回后,函数生命结束,c这块空间是要被释放掉的,这里的释放是指我们要将这块空间归还给操作系统,这块空间在被操作系统重新分配利用之前它里面的值确实不会变,但这并不代表我们有权利去访问它。就好比我们出门在外要住酒店,住宿结束后要进行归还。但酒店房间依然还在,只不过我们不能再进了(你有本事能进去也行,但是illegal);与此同时比如我们在用酒店房间的时候在屋里扔了一个空瓶子,我们刚离开的时候确实可以确定瓶子仍在里面,但是房间已经归还了,酒店可能会马上派人来清理,因此过一会这个瓶子还在不在屋里我们也就无从得知了。所以我们这里返回引用严格来说是一种野指针(因为没有野引用这一说,所以现在先这样理解),因此我们通过这个引用来访问内存并将其打印出来的值按理来说是无法确定的。然后我们再谈谈为什么最后打印出来的值是7,这里就必须要借助我们之前讲过的函数栈帧的知识了。我们知道函数在被调用的时候是要进行压栈的,当我们执行完第一次调用后,Add函数的栈帧销毁,但紧接着我们又调用了一次Add函数,并传入了3和4两个参数。我们知道我们刚才返回的引用对应的内存空间在Add函数的栈帧中应该是负责存储加和的,因此在第二次调用Add函数时,原来存储3的位置现在就用来存储7了,所以我们在打印返回的引用里面的值的时候打印出来的值是7。
此外,这里我们如果在两次调用Add函数后再次调用其他的函数,这块空间就会被重新覆盖,这个时候我们就无从得知(除了debug看内存,这个就别杠了)原来传回的引用中存储的内容了,而且很有可能是随机值。相关知识具体的图示讲解如下:
(有关函数栈帧知识的经验贴 函数栈帧的创建与销毁
在这里插入图片描述
这里我们如果仍想返回c,可以将其定义为静态(static)变量,这样在函数栈帧销毁时变量并不会被销毁,也就不会有“野指针”问题了。
这里再贴一段非常神奇的代码,如果你不懂函数栈帧,它非常玄学,但是如果你懂函数栈帧和C++引用机制,他自然也就没什么神奇的了:在这里插入图片描述
首先,我们调用的Count函数的返回类型是引用,n为局部变量,因此返回的是一块会被系统收回的空间,此时这块空间的内容是1。然后,在主函数中我们用引用进行接收,因此ret又是一个指向这块被收回空间的引用,(注意理解)所以它会一直盯着这块空间。再然后我们调用了两次cout函数,第一次调用打出了意料之中的1,而第二次就不再是1了,这是为什么呢?在第一次调用cout时,被ret引用的地址中的内容仍然是1,它作为函数参数传入,通过cout打印了出来,但是cout函数再调用的过程中会被压入到栈中,刚才存放1的地址在调用了一次cout函数后被其他值所覆盖了,因此被ret引用的这块内存里的数也由于这个原因变成了随机数2034276744。最此时我们再调用另外一个Func函数,和第二个cout道理相同,也会使被ret引用的空间变为意料之外的数,但其实他们都是一个地方:
在这里插入图片描述

③引用做参数和返回值的意义

1.提高效率

我们知道,以值作为参数或者返回值类型,在传参和返回期间,函数不会直接传递实参或者将变量本身直接返回,而是传递实参或者返回变量的一份临时的拷贝,因此用值作为参数或者返回值类型,效率是非常低下的,尤其是当参数或者返回值类型非常大时,效率就更低。而将变量的引用作为函数参数或者返回值就可以直接省去这个步骤,大大提高程序的执行效率,这里我们用一段代码来验证一下:

#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();
	return 0;
}

运行结果如下:在这里插入图片描述
因此使用引诱作为参数或返回值是能够提高程序效率的,但同时也要把握好使用前提。

2.修改返回值

这里我们主要通过举例来体现修改返回值的优势。
我们在用C语言实现顺序表结构时,如果想要改变顺序表中元素的值(假设为int型顺序表),我们就要进行遍历,根据要求对对应的数进行相应的处理,而C语言实现的顺序表中如果要根据条件修改元素就只能在遍历函数中进行修改,否则就只能通过直接访问表来进行改变,而这样又违背了写程序低耦合的理念,绝大多数情况下我们都要通过接口来访问成员,而不能直接去访问成员,而传引用返回就完美解决了我们的问题,实现的接口如下:

typedef int SLDataType;
typedef struct SeqList
{
	SLDataType* a;
	size_t size;//存储数据的个数
	int capacity;//存储空间大小
}SL;
//这里省略掉了其他相关接口
//...
size_t SLSize(SL* psl)
{
	assert(psl);
	return psl->size;
}

SLDataType& SLAt(SL* psl, size_t pos)
{
	assert(psl);
	assert(pos < psl->size);

	return psl->a[pos];
}

有了这个两个接口之后,我们就可以在遍历的同时,访问到顺序表内数据元素的本身(因为是传引用返回),然后就可以根据某些人为规定的条件,对数据元素进行相应的处理。这里比如我们想对顺序表中偶数元素进行乘2操作,实现方法如下:
在这里插入图片描述

四、常引用(const)及其意义

int main()
{
	//权限平移和缩小
	int a = 10;
	int& ra = a;
	const int& rra = ra;
	//权限放大	
	const int b = 20;
	//int& rb = b;  //此行代码会报错
}

在对指针和引用赋值时,权限是可以缩小的,但不允许放大权限。在上面的例子中,变量a的权限是读写,定义ra为a的引用,同时没有添加其他的限制,因此ra的权限也是读写,此操作合法;再往后定义了rra作为ra的引用,并添加了const进行修饰,原ra的操作权限是读写,其引用rra添加了const修饰,权限变为只读,此操作也合法。但是对于只读变量b,在定义其引用rb时,类型为int&,此类型的权限为读写,要比被引用对象b的权限大,因此这样定义引用是非法的,会报错。

①解除传参限制

我们来对比下面两段代码:

void func(int& x)
{
	//...
}

int main()
{

	int a = 10;
	int& ra = a;
	const int& rra = ra;
	func(a);
	func(ra); 
	func(rra); //error
}
void func(const int& x)
{
	//...
}

int main()
{

	int a = 10;
	int& ra = a;
	const int& rra = ra;
	func(a);
	func(ra); 
	func(rra); //正确
}

在引用作为函数参数时,如果不加const进行修饰,那么所有const引用类型的数据就不能作为参数进行传入,因为这是一种权限的放大,函数要的是能够读写的变量,而用户向传只读的变量,这显然不是函数想要的,自然也就不能传进去。但是如果在函数参数部分加上const进行修饰,用户就既可以传入不带有const的值,也可以传带const的值,没有了限制。(这里主要是指函数内对传入参数不进行修改的情况,如果你的函数内要对传入参数进行修改,那形参必然是不能用const进行修饰的,传入的变量自然也不能带有const)

②解除接收返回值限制

在讲解这个之前我们要先了解一下c/c++中临时变量这个概念,看如下代码:

int main()
{
	double pi = 3.14;
	int& copy_pi = pi; //error
	return 0;
}

有人看到这段代码会直接说:你这两个数据的类型都不一样,引用赋值肯定会有问题啊!但是这两行代码中真正的问题并不是二者类型不一样,我们要透过现象看本质。我们知道,在c/c++中,如果等号两侧的数据类型不一样,会发生隐式类型转换,有时候我们也通过强制类型转换来完成赋值。无论隐式类型转换还是强制类型转换(截断、整型提升),都不会改变被转换的变量本身,也就是pi这个变量从始至终都是不变的。之所以能够完成赋值,是因为在发生类型转换时会产生一个临时变量,临时变量收录好原变量的数据,进行转换,在赋予给目标变量,而这个临时变量在编译器眼里它是一个常量,因此这里还有一条结论,就是临时变量具有常性

知道了这条结论,能在很大程度上帮我们理解上面的这两行代码:因为在将pi赋值给copy_pi之前,pi要先给到临时变量处,然后由临时变量进行相关操作后赋予给copy_pi,而临时变量具有常性,他被当作是一个const常量。因此等号右侧作为一个只读常量,赋予给一个可读写的int型变量是一种权力放大,这是我们所不允许的。所以这里赋值失败的真正原因是背后有权力的放大,所以想要解决这个问题只需在int&前加一个const就好了:

int main()
{
	double pi = 3.14;
	const int& copy_pi = pi; //√
	return 0;
}

和这个具有相同道理的还有下面这两个例子:

void func(int& n = 10)//error
{
	//...
}
int main()
{
	return 0;
}

这是关于引用作为函参是否能够添加缺省值的问题,上面的代码是存在问题的:因为作为一个常数必然是具有常性的,而将他直接复制给一个引用,这个引用代表的就直接是int型常量10了,这同样也是一种权力的放大,因此引用作为函参时要想添加缺省值,必须将函参类型修改为const int&:

void func(const int& n = 10)//√
{
	//...
}
int main()
{
	return 0;
}

int Count()
{
	int n = 0;
	n++;
	//...
	return n;
}

int main()
{
	int& ret = Count();//error
	return 0;
}

同样的道理,返回值为int的函数所返回的是一份临时拷贝,他也是一个临时变量,若返回时用int&型数据来接收也是权力的放大,因此只能用const引用的类型来进行接收:

int Count()
{
	int n = 0;
	n++;
	//...
	return n;
}

int main()
{
	const int& ret = Count();//√
	return 0;
}

五、引用和指针对比

在语法概念上引用就是一个别名,没有独立空间,和其引用实体共用同一块空间:

int main()
{
int a = 10;
int& ra = a;
cout<<"&a = "<<&a<<endl;
cout<<"&ra = "<<&ra<<endl;
return 0;
}

在这里插入图片描述
但其实在底层实现上实际是有空间的,因为引用是按照指针方式来实现的(见汇编代码):

int main()
{
	int a = 10;
	int& ra = a;
	ra = 20;
	int* pa = &a;
	*pa = 20;
	return 0;
}

在这里插入图片描述
所以引用和指针的区别总结如下:

  1. 引用概念上定义一个变量的别名,指针存储一个变量地址。
  2. 引用在定义时必须初始化,指针没有要求。
  3. 引用在初始化时引用一个实体后,就不能再引用其他实体,而指针可以在任何时候指向任何一个同类型实体。
  4. 没有NULL引用,但有NULL指针。
  5. 在sizeof中含义不同:引用结果为引用类型的大小,但指针始终是地址空间所占字节个数。(32位平台下占4个字节,64位平台下8个字节)
  6. 引用自加即引用的实体增加1,指针自加即指针向后偏移一个类型的大小。
  7. 有多级指针,但是没有多级引用。
  8. 访问实体方式不同,指针需要显式解引用,引用编译器自己处理。
  9. 引用比指针使用起来相对更安全。

结束语

以上就是关于C++中关于引用的知识,如文章有不足或遗漏之处还请大家指正,笔者感激不尽;同时也欢迎大家在评论区进行讨论,一起学习,共同进步!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值