当前位置: 首页 > news >正文

【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的地址是一样的,这也验证了我们的结论。

image-20221011111955881

这里再强调一下,引用就是取别名,不管你给他取什么别名,他仍然还是那个他。

既然引用是在取别名,那么我们对别名进行修改,就相当于对原数据进行修改:

image-20221011144224029

注意:引用类型必须和引用实体必须是同种类型

2.引用特性

引用有三大特性:

  1. 引用在定义时必须初始化

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

  3. 引用一旦引用一个实体,再不能引用其它实体

1.我们能否不初始化引用呢?

image-20221011145725152

这里我们上手敲了一下,答案很明显,我们还没开始编译,就已经出现错误了,所以引用在定义时必须初始化。

2.这里我们定义一个变量a,我们给他取一个别名b,再取一个别名c,这三个变量的地址都是一样的,并且相互影响,这里我们改变一个,其他的也会随之改变。

image-20221011150445683

3.直接看代码:

image-20221011151206715

在这段代码中,我们已经给a取别名ra,随后又给b取别名ra,在编译过程中出错,引用一旦引用一个实体,再不能引用其它实体。

3.常引用

3.1.取别名的规则

我们在取别名的时候,不是在所有情况下都能够随便取的,要在一定范围内。

对常引用变量来说,权限只能缩小,不能放大。

权限放大

我们都知道C语言有一个const关键字,被const修饰的变量会变成只读变量,不能修改。在C++的引用中也有const引用。假设我们现在有一个变量a被const修饰,现在我们想对a取别名,是否能够做到?

const int a = 10;
int& ra = a;

这里我们编译看看?好吧,全是错误。

image-20221011160514880

这就是典型的权限放大,上面我们说过,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。

image-20221011163832517

这里的变量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的别名吗?

image-20221011230654478

很明显不可以,编译器发生错误。但是我加上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;
}

编译器运行后:

image-20221011231152351

这里的会丢失数据其实就是会丢失精度

  • 注意:

这里在把d的值赋给a时并不是直接赋值的,会把d的整数部分取出来,赋值给一个临时变量,该临时变量大小4个字节,随后再把这个临时变量赋给a。

image-20221011231731824

临时变量具有常性,就像被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是对临时变量的引用,是临时变量的别名。可以通过编译来验证:

image-20221011232816716

这里我们发现e的地址和d的地址不同,以及a的地址也不同。

3.3.对权限控制的用处

这里简单提下,例如这个传参的问题。

如若函数写出普通的引用,那么很多参数可能会传不过来:

image-20221011233502484

仔细看这段代码,只有a能正常传过去,后面的均传不过去,因为后面传的参数均涉及权限放大,固然编译器会出错

但是当我们在函数的形参那加上const呢?

image-20221011233538165

加了const后编译器就不会报错了


4.引用的使用场景

引用的使用场景分为两个:

  1. 做参数
  2. 做返回值

接下来,我将会详细讲解下:

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) {

加上引用会在调用时省去写&,也更方便理解,减少对指针的使用。

综上,引用做参数的好处如下:

  1. 输出型参数
  2. 减少拷贝、提高效率

引用还有一个使用场景是做返回值,具体看下文:

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,这里我们简要画个栈帧图即可看出:

image-20221011235140203

main函数里有个变量ret,汇编时会call一个指令跳到函数Count,Count里有一个变量n。这里不能把n直接传给ret,因为在函数Count调用完成后要拿一个值赋给ret,且函数调用完后函数栈帧就销毁了,所以赋给ret的这个值就是设计出的临时变量。

如何证明我这中间会产生临时变量呢?

只需要加个引用即可。

image-20221012001714859

这里很明显编译发生错误。为什么呢?这里其实答案就很明显了,这里ret之所以出错不就是因为其引用的是临时变量呢,因为临时变量具有常性,只读不可修改,直接引用则会出现上文所述的权限放大问题。 所以这不很巧合的验证了此函数调用中途会产生临时变量。

解决方法也很简单,保持权限不变即可,即加上const修饰:

image-20221012001755063

传引用返回

我们对上述代码进行微调:

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的别名?

只需要在函数调用时加个引用即可:

image-20221012145413685

我们也可以通过打印法来验证:

image-20221012150042456

这里ret和n的地址一样,也就意味着ret其实就是n的别名。综上,传值返回和传引用的返回的区别如下:

  • 传值返回:会有一个拷贝
  • 传引用返回:没有这个拷贝了,返回的直接就是返回变量的别名

现在又存在一个问题了:我传引用的代码对不对?

我传引用返回后,ret就是n的别名,但是有没有想过,出了函数出了这个作用域我n不是都销毁了吗,怎么还会有别名呢?

空间的销毁不是说空间就不在了。空间的归还就好比你退房,虽然你退房了,但是这个房间还是在的,只是说使用权不是你的了。但是假说你在不小心的情况下留了一把钥匙,你依旧是可以进入这个房间,不过你这个行为是非法的。这个例子也就足矣说明了上述的代码是有问题的。是一个间接的非法访问。

仔细看我这段截图:

image-20221012150440360

这里第一次打印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放在了静态区了,出了作用域不会销毁,自然而然可以正确使用引用返回了,并且输出结果也是我们预期的:

image-20221012150709136

  • 注意:

如果函数返回时,出了函数作用域,如果返回对象还未还给系统,则可以使用引用返回,如果已经还给系统了,则必须使用传值返回。否则就可能会出越界问题。

  • 再举一个例子:
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;
}

image-20221012154259325

这段代码执行的结果ret的值为3,首先我的Add(1,1)调用完后,返回c的别名给ret,随即调用完Add栈帧销毁,当我第二次调用时c的值就被修改为3了,这里想表达的是这里是不安全的。

正常情况下我们应该加上static:

image-20221012154353980

static初始化只有一次。此时c被存放在静态区了,函数栈帧销毁了,它仍然存在。

  • 这里再演示下其被覆盖的情形:

正常情况:

image-20221012154716749

不加static发生覆盖:

image-20221012154804383


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

image-20221012155005841

  • 值和引用的作为返回值类型的性能比较
#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();
}

image-20221012184826489


6.引用和指针的区别

引用和指针的不同点:

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

接下来就上述指针与引用不同点做详细解析:

  • 引用在定义时必须初始化,指针没有要求
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;
}

我们来看下引用和指针的汇编代码对比:

image-20221012185320693

相关文章:

  • 新网站怎么做seo优化/精准引流怎么推广
  • 怎么做网站模板/无锡百度关键词优化
  • 网站备案编号查询/每日新闻播报
  • 基于jsp的b2b网站建设/宁波关键词优化企业网站建设
  • 静态网站开发基础/北京seo业务员
  • php招生网站开发/交友网站有哪些
  • 编译protoc方法名称被自动大写
  • LabVIEW应用程序exe和安装程序的区别
  • 基于HI3516/HI3518/HI3559内部ADC驱动实现
  • swift学习资料2022
  • 百家CMS代码审计
  • SpringSecurity (二) --------- 认证
  • Java设计模式实战 - 02 工厂类获取设备连接器 DeviceConnectorFactory
  • 分享30个有趣的 Python小游戏,我能玩一天
  • Sqlilabs靶场1-10关详解(Get请求式)
  • Redis集群(五)
  • 深入理解Linux内核-进程
  • 【RocketMQ】第二篇-RocketMQ解决分布式事务