【C++ 学习总结】- 21 - 引用

引言:

引用是 C++ 中新增的一种传递参数的方式,其本质是对指针的一种封装使用,比指针更加简洁的同时有许多与指针不同的特性。


一、引用的概念

  在 C 中,参数的传递主要有两种方式:值传递指针传递。参数的传递本质上都是数据的拷贝,值传递是直接拷贝数据的值,在函数内复制一份该变量的“备份”,这个备份和外部的原变量相互独立、互不影响;而指针传递是拷贝数据的指针,再通过指针来找到外部的变量,直接操作外部的变量。值传递不适合传递构造类型数据如数组、构造体等,因为这类数据体积庞大,使得拷贝的时间开销和栈的内存空间开销巨大,导致程序效率低下。所以在 C/C++ 中,数组的传递被强制进行了隐式转换,实际传递的是数组的指针(数组名,即首元素地址),但是结构体等没有做限制,我们推荐使用指针来传递这类数据量庞大的构造类型变量。
  在 C++ 中,一种新的传递数据的方式被引入了,这就是 「 引用(Reference)」,这种方式比指针更加简洁。引用可以看做是变量的一个别名,通过别名和原名都能够访问这个变量;可以理解为 Windows 系统里应用的快捷方式和原程序入口,无论通过哪个都可以运行程序。

  • C++ 中三个参数传递的方式:值传递指针传递引用传递


二、引用的使用

1. 基本使用

  引用的定义方式与指针类似,唯一区别是将指针的 “ * ” 换成了 “ & ”,其定义方式如下:

	// 定义引用
	varType &refName = Variable;

  如果不希望通过引用来修改原变量的数值,可以使用 <const> 来修饰:

	// const 修饰写法 1
	const varType &refName = Variable;
	// const 修饰写法 2
	varType const &refName = Variable;
  • 引用定义时需要使用 “ & ” 但使用时不用添加,否则会被识别为 <取址>,这跟指针需要用 “ * ” 来寻址不同。

  • 引用对象和引用的可读写性必须是引用遵从变量,简单说:不能用非 const 引用绑定 const 变量。因为 const 变量本身是不可被修改的,所以引用也只能是 const,而非 const 变量的引用可以是 const 引用。

  [2.1] 通过一个简单示例来理解引用的基本使用:

================================ 代码示例 ================================
int main()
{
	int A = 10;
	int B = 30;
	int &rA = A;		// 对 非const变量 定义 非const引用
	const int &rB = B;	// 对 非const变量 定义  const引用
	
	rA += 10;
	//rB += 10;			// 非法操作, 此条不注释将会导致报错:  error: assignment of read-only reference 'rB'
	
	const int C = 50;
	const int &rC = C;	// 正确引用
	//int &rC = C;		// 错误引用, 此条不注释将会导致报错: 
						// error: invalid initialization of reference of type 'int&' from expression of type 'const int'
						
	printf("-> A is : %d,  rA is : %d \n", A, rA);
	printf("-> B is : %d,  rB is : %d \n", B, rB);
	printf("-> C is : %d,  rC is : %d \n", C, rC);
	
	return 0;
}
================================ 运行结果 ================================
-> A is : 20,  rA is : 20
-> B is : 30,  rB is : 30
-> C is : 50,  rC is : 50


2. 引用与函数

  在定义或声明函数时,我们可以将函数的形参指定为引用的形式,这样函数调用时就会将实参和形参绑定在一起,让它们都指代同一份数据。如此一来,如果在函数体中修改了形参的数据,相应的实参的数据也会被修改,从而拥有在函数内部影响函数外部数据的效果。
  引用也可以作为返回值,但是不能返回局部变量的引用,因为局部变量在函数结束时就被销毁了,这点和不能返回局部变量的指针相同,毕竟引用实际上是指针的封装形式。

  [2.2] 使用引用作为函数的参数和返回值的示例:

================================ 定义示例 ================================
int & pow (int &r) {
	int p = r*r;
	return p;
}
================================ 程序示例 ================================
int main()
{
	int A = 10;
	printf("-> 1. A : %d \n", A);
	int &B = pow(A);
	printf("-> 2. B : %d \n", B);
	int &C = pow(B);
	printf("-> 3. C : %d \n", C);
	printf("-> Final: A : %d,  B : %d,  C : %d \n", A, B, C);
	
	return 0;
}
================================ 运行结果 ================================
-> 1. A : 10
-> 2. B : 100
-> 3. C : 10000
-> Final: A : 10000,  B : 10000,  C : 10000


3. 引用与数组

  引用可以引用一个数组,这被称为数组引用,就像指针可以指向一个数组一样,但不同的是,可以创建一个每个元素都是指针的指针数组,但不能创建一个每个元素都是引用的引用数组。数组的引用会严格检查数组的大小,因此,引用时定义的数组大小和被引用的数组大小必须严格匹配,也不能留白不指定引用的数组大小。
  如果形参是数组的引用,编译器不会将数组实参转化为指针,而是传递数组的引用本身。在这种情况下,数组大小成为形参与实参类型的一部分,编译器检查数组实参的大小与形参的大小是否匹配。

  [2.3] 引用数组的示例:

================================ 定义示例 ================================
void output (int (&n)[10]) {
	for(int i=0; i<10; i++) {
		printf("  Num[%d] is : %d \n", i, n[i]);
	}
}
// 大小不匹配: 将示例中的函数参数 `int (&n)[10]` 改为 `int (&n)[11]` 会导致编译器报错:
// 		error: invalid initialization of reference of type 'int (&)[11]' from expression of type 'int [10]'

// 不指定长度: 如果将函数参数的引用中的数组长度留白,改为 `int (&n)[]` ,会导致编译器报错:
// 		error: parameter 'n' includes reference to array of unknown bound 'int []'

================================ 程序示例 ================================
int main()
{
	printf("-> 普通引用数组: \n");
	int num[10] = {1, 2, 3, 4, 5, 6};
	int (&r)[10] = num;
	
	// int (&r)[] = num;	// 错误引用, 将引起报错
							// error: invalid initialization of reference of type 'int (&)[]' from expression of type 'int [10]'			
	// int (&r)[11] = num;	// 错误引用, 将引起报错: 
							// error: invalid initialization of reference of type 'int (&)[11]' from expression of type 'int [10]'
							
	// int &r[2] = {num, num};	// 试图创建引用数组, 将引起报错
								// error: declaration of 'r' as array of references
	
	for(int i=0; i<10; i++) {
		printf("  Num[%d] is : %d \n", i, r[i]);
	}
	printf("-> 函数传参引用数组: \n");
	output(num);
	
	return 0;
}
================================ 运行结果 ================================
-> 普通引用数组:
  Num[0] is : 1
  Num[1] is : 2
  Num[2] is : 3
  Num[3] is : 4
  Num[4] is : 5
  Num[5] is : 6
  Num[6] is : 0
  Num[7] is : 0
  Num[8] is : 0
  Num[9] is : 0
-> 函数传参引用数组:
  Num[0] is : 1
  Num[1] is : 2
  Num[2] is : 3
  Num[3] is : 4
  Num[4] is : 5
  Num[5] is : 6
  Num[6] is : 0
  Num[7] is : 0
  Num[8] is : 0
  Num[9] is : 0
  • 要点:
    • 数组引用的数组大小严格匹配,不能留白
    • 不能创建引用数组


三、引用的本质

1. 引用的实现方法

  引用本质上是 对指针进行了简单的封装 之后提供的一种便捷的使用方式,它实际是通过指针来实现的。指针使用时需要通过 “ * ” 寻址来找到变量,这使得指针的使用比较麻烦,不仅是在书写的频繁上,也在代码阅读的不直观上,在复杂的表达式中尤其让人感到麻烦。引用正是为了解决这一点而诞生,为了让代码更加简洁直观,对指针做了简单的封装,然后提供的更直观、便捷、符合逻辑的使用方法。

  C++ 的发明人 Bjarne Stroustrup 是这么说的,他在 C++ 中引入引用的直接目的是为了让代码的书写更加漂亮,尤其是在运算符重载中,不借助引用有时候会使得运算符的使用很麻烦。

  • 实际上 C++ 并没有规定引用的底层实现,但是大多数编译器是通过指针来实现的。

  [3.1] 用一个示例来证明一些编译器通过指针实现引用:

================================ 定义示例 ================================
class A {
	private:
		int m_A;
		
	public:
		A(int A) : m_A(A){}
};

class B {
	private:
		int m_B;
		int &m_rB;
		
	public:
		B(int B) : m_B(B), m_rB(B) {}
};
================================ 程序示例 ================================
int main()
{
	A a(99);
	B b(88);
	printf("-> Size of A is : %d Byte\n", sizeof(a));
	printf("-> Size of B is : %d Byte\n", sizeof(b));
}
================================ 运行结果 ================================
-> Size of A is : 4 Byte
-> Size of B is : 8 Byte

  可以看出引用是占用内存空间的,并且占用的大小是指针所占用的大小,在32位系统中是4字节大小。


2. 引用的封装形式

  引用本身实际上是被引用变量的指针,对变量的引用定义实际上是新建了一个指向此变量的指针,因此占用实际的内存空间,而通过引用对变量的操作实际上是通过这个指针对变量的操作。引用是不可以被取址的,对引用取址得到的是变量的地址,这是由编译器规定的,因为获取引用的地址毫无意义,还可能带来额外的风险。

  [3.2] 引用的封装形式说明:

================ 引用的形式 ================
void swap (int &a, int &b) {
	int t = a;
	a = b;
	b = t;
}

int main () 
{
	int A = 10;
	int &rA = A;
	rA = 100;
	printf("%d", &rA);
	
	return 0;
}

================ 实际的形式 ================
void swap (int *a, int *b) {
	int t = *a;
	*a = *b;
	*b = t;
}

int main () 
{
	int A = 10;
	int *rA = &A;
	*rA = 100;
	printf("%d", &A);
	
	return 0;
}

  可以看出,C++ 通过封装将 *rA 的书写封装成了引用的形式 rA ,同时将对引用的取址 &rA 封装成了对变量的取址 &A


3. 引用和指针的区别

  1. 引用必须在定义时初始化,初始化后不能再更改指向对象,而指针则不必须初始化也可以随意更改指向。(从一而终)

  2. 指针进行自增、自减运算是指向下一个、上一个同类型对象,而引用是对变量做自增和自减。

  3. 指针可以有多级,引用没有多级;多级指针的定义 int **p; 是合法的,而 int &&f; 是非法的。

  4. sizeof 引用得到的是变量的大小,sizeof 指针得到的是指针自身的大小。

  5. 指针和引用都可以指向数组,但是指针有指针数组,而引用没有引用数组。



四、引用不能绑定的对象

1. 引用不能绑定临时数据

  “指针是变量在内存中的地址”,这个概念中有一个关键词需要被强调,那就是 “内存”。指针只能指向内存,不能指向寄存器或是储存,因为它们并不能被寻址。
  在 C++ 中,变量、对象、字符串常量、函数形参、函数体本身、new或malloc()分配的内存等等都可以用 “ & ” 来获取地址以定义指向他们的指针。除了这些数据外,还有一些我们平时不太留意的临时数据如:表达式的结果、函数的返回值等,可能会放在内存中,也可能会放在寄存器中,而一旦它们被放到了寄存器中,就没法获取它们的地址了,也就没法定义指向他们的指针。

  引用本质上是指针的封装,因此和指针一样不能指向临时数据。C++ 对引用的要求更加严格,在某些编译器下甚至连放在内存中的临时数据都不能指代:
  ① 在 GCC 中,引用不能指代任何临时数据,不管它保存到哪里。
  ② 在 Visual C++ 中,引用只能指代位于内存中非代码区的临时数据,不能指代寄存器中的临时数据。

  • 寄存器会存储哪些临时数据
      寄存器离 CPU 近、存取速度快,一些临时数据如:计算中间值、函数返回值等,往往会被放到寄存器中以提高程序的运行效率。但是寄存器的数量有限,不能把什么数据都放进去,所以只有短小的数据会被存储其中,如 charintfloat 等简单的基本类型数据,只需一两个寄存器就能存储;而对象、结构体变量等构造类型变量大小不可预测,这些类型的临时数据通常会放到内存中。

  • 常量表达式不可被寻址
      常量表达式(Constant expression) 是指不包含变量的表达式。不包含变量使得常量表达式没有不确定因素,在编译阶段就能确定数值,编译器就不会分配单独的内存来存储常量表达式的值,而是将常量表达式的值和代码合并到一起,放到虚拟地址空间中的代码区。从汇编的角度看,常量表达式的值就是一个立即数,会被“硬编码”到指令中,不能寻址。

  因此,在使用引用的过程中应该更加细致,不要引用临时数据和常量表达式,特别是注意不要将临时数据传给函数的引用形参,最好在简单的形参中使用值传递而非引用传递。


2. 常引用可以绑定临时数据

  引用不能绑定临时数据,但是常引用可以。编译器对常引用采取了一种妥协机制:编译器会为临时数据创建一个新的、无名的临时变量,并将临时数据放入该临时变量中,然后再将引用绑定到该临时变量。编译器的这种机制是灵活的,只有在必要的情况下才会创建临时变量,而非定义为常引用就一定创建临时变量。

  • 为什么不为普通引用创建临时变量
      常引用所引用的对象只能读而不能写,而普通引用则可读也可写。如果对普通引用采取相同的机制,为其创建临时变量,那么我们在修改其值的时候,修改的实际上是临时变量中的值而无法改变原变量的值,最终导致了两份不同的数据产生,这就失去了普通引用的意义。常变量只需要读而不用关心写,因而不存在这个问题,可以使用临时变量。

3. 示例:引用、常引用与临时数据

  我们通过一个示例来理解引用和常引用对待临时数据的不同结果:

================================ 程序示例 ================================
int main()
{
	int a = 10;
	int b = 20;
	
	/* * * * 错误取址, 引起报错 * * * */
	//int *ip1 = &(a + b);	// error: lvalue required as unary '&' operand
	//int *ip2 = &(6 + 8);	// error: lvalue required as unary '&' operand

	/* * * * 非法引用, 引起报错 * * * */
	//int &ir1 = (a + b);	// error: invalid initialization of non-const reference of type 'int&' from an rvalue of type 'int'
	//int &ir2 = (6 + 8);	// error: invalid initialization of non-const reference of type 'int&' from an rvalue of type 'int'

	/* * * * 常引用可以引用临时数据 * * * */
	const int &ir1 = (a + b);
	const int &ir2 = (6 + 8);

	printf("-> a + b  is : %d \n", ir1);
	printf("-> 6 + 8  is : %d \n", ir2);
	
	return 0;
}
================================ 运行结果 ================================
-> a + b  is : 30
-> 6 + 8  is : 14



五、引用与类型转换

1. 引用必须类型严格一致

  不同类型的数据相互赋值时,编译器会进行隐式的转换,但这个转换对指针是不成立的,编译器 禁止指针指向不同类型的数据
  不同类型的数据相互赋值时,编译器会对数据作转换,如 floatint 会截断小数部分,将整数部分赋值给 int 变量,这时,被转换的是数值,而各自的数据类型没有改变;而指针指向不同数据类型时,数据的值并没有被改变,但数据类型被改变了,虽然是同一个二进制数,但是所表达的数值却不同了,比如: floatint 都是32位,但一个是浮点型,一个是整型,计算机对这两种数据的解释方式不同,同样的32位二进制数在这两种类型下所代表的数值实际上是不同的。(有计算机基础的应该很好理解,这里所说的是浮点数与整形数的数据格式的区别)

  通过一个简单的示例来说明:

================================ 程序示例 ================================
int main ()
{
	float A = 233.33f;
	printf("-> A is : %.2f \n", A);		// 以float类型"翻译"
	int *ip = (int*)&A;
	printf("-> A is : %d \n", *ip);		// 以int类型"翻译"
	return 0;
}
================================ 运行结果 ================================
-> B is : 233.33
-> B is : 1130976379

  所以编译器要求,指针更改指向的对象时,类型必须严格一致。由于引用本质是指针,所以这条对引用也适用,编译器禁止定义不同数据类型的引用。

  示范一个正确示例和一个错误示例:

	// 正确示例
	int A = 233;
	int &ra = A;
	
	// 错误示例
	int B = 233;	// 这么做会引起报错, 报错内容如下
	float &rB = B;	// error: invalid initialization of reference of type 'float&' from expression of type 'int'


2. 常引用可以跨数据类型

  常引用就是这么神奇,不仅可以引用临时数据,还可以无视数据类型的不同进行引用。常引用可以跨数据类型进行引用的原理和其引用临时数据的原理相同,都是通过编译器为其创建临时变量实现的。
  当引用的类型和变量的数据类型不同时,如果它们类型相近,并且遵守「数据类型的自动转换」规则,编译器就会创建一个和引用数据类型相同的临时变量,将原变量的赋值给这个临时变量,而后再将引用绑定到这个临时的变量。因为临时变量和原变量之间有一次不同类型的数据相互赋值,所以这过程中发生了一次「数据类型的自动转换」。
  所以有两点需要注意: ① 常引用通过创建并引用临时变量来跨数据类型进行引用。
             ② 引用过程中发生了「数据类型的自动转换」

  通过一个示例来说明:

================================ 程序示例 ================================
int main ()
{
	float B = 233.33f;
	printf("-> B is : %.2f \n", B);
	const int &rB = B;				// 跨类型常引用
	printf("-> B is : %d \n", rB);
	return 0;
}
================================ 运行结果 ================================
-> B is : 233.33
-> B is : 233

  通过常引用实现了 int 型引用对 float 型变量的跨类型引用,同时因为发生了自动数据类型转换,浮点数 233.33 被截断了小数变成了整型数 233

  • 常引用不仅可以引用临时数据,还可以跨类型进行引用,其实现方式都是通过创建临时变量



六、引用的使用建议

  1. 数据对象较小,如基本类型或小型结构,使用 「值传递」

  2. 数据对象是数组时,使用 「指针传递」,编译器会自动进行隐式转换

  3. 数据对象是较大的结构体,使用 「指针传递」「引用传递」,以减少复制所花的时间和空间,提高程序效率

  4. 数据对象是类对象,则使用 「引用传递」,传递类对象参数的标准方式是按引用传递。

  5. 引用作为函数参数时,如果函数内部不会修改变量的值,则尽量使用 const 引用,其优点如下:
     ① 使用 const 可以避免无意中修改数据的错误;
     ② 使用 const 能让函数接收 const 以及非 const 类型的实参,否则将只能接收非 const 类型的实参;
     ③ 使用 const 引用能让函数正确生成并使用临时变量。



  —— DaveoCKII
2022.05.15

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值