> 文章列表 > C++入门:引用

C++入门:引用

C++入门:引用

目录

一. 什么是引用

1.1 引用的概念

1.2 引用的定义

二. 引用的性质和用途

2.1 引用的三大主要性质

2.2 引用的主要应用 

三. 引用的效率测试

3.1 传值调用和传引用调用的效率对比

3.2 值返回和引用返回的效率对比

四. 常引用 

4.1 权限放大和权限缩小问题

4.2 跨数据类型的引用问题

五. 引用和指针的区别


一. 什么是引用

1.1 引用的概念

引用,通俗的讲,就是给已经存在的变量取一个别名,而不是创建一个新的变量。引用和被引用对象共同使用一块内存空间。

引用就好比一个人的大名和小名,大名和小名都是一个人。再比如,李逵外号黑旋风,叫李逵和黑旋风表示同一个人。

1.2 引用的定义

引用定义的语法格式:类型& 引用的名称 = 被引用实体

如,定义int a = 10,希望再定义一个引用b,来表示整形变量a的别名,语法为:int& b = a。演示代码1.1展示了引用的定义过程,对原变量a和引用b的其中任意一个赋值,都会使a和b的值均发生改变,这是因为a和b共用一块内存空间。

演示代码1.1:

int main()
{int a = 10;int& b = a;  //b是a的引用(别名)printf("a = %d, b = %d\\n", a, b);  //10,10a = 20;  //对a赋值,同时改变a和bprintf("a = %d, b = %d\\n", a, b);  //20,20b = 30;  //对b赋值,同时改变a和bprintf("a = %d, b = %d\\n", a, b);  //30,30return 0;
}
图1.1 演示代码1.1的运行结果

二. 引用的性质和用途

2.1 引用的三大主要性质

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

定义引用时必须给出这个引用的被引用实体是谁,如:int &b; -- 是非法的。

演示代码2.1:

int main()
{int a = 10;int& b;  //报错int& c = a;  //初始化引用return 0;
}
图2.1  演示代码2.1的报错信息

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

我们可以为一个变量取多个别名。如演示代码2.2所示,给a变量取b、c、d三个别名是可行的。对a、b、c、d中的任意一个赋值,都会使a、b、c、d的值均发生改变。a、b、c、d共用一块内存空间。

演示代码2.2:

int main()
{int a = 10;int& b = a;int& c = a;int& d = a;  //为a取b、c、d三个别名printf("a = %d, b = %d, c = %d, d = %d\\n", a, b, c, d);  //10,10,10,10c = 20;printf("a = %d, b = %d, c = %d, d = %d\\n", a, b, c, d);  //20,20,20,20return 0;
}
图2.2  演示代码2.2的运行结果

 3、一个引用一旦引用了某个实体,就不能再引用其他实体

演示代码2.3中的b = c并不是将b变为变量c的引用,而是将变量c的值赋给b,通过打印b和c的地址,我们可以发现b和c并不共用一块内存空间,而赋值之后,a和b的值都变为了20。

演示代码2.3:

int main()
{int a = 10;int& b = a;int c = 20;b = c;   //将c的值赋给b,而不是让b变为c的引用printf("&b = %p, &c = %p\\n", &b, &c);   //b和c的地址不一致printf("a = %d, b = %d\\n", a, b);  //a、b都变为了c的值20return 0;
}
图2.3  演示代码2.3的运行结果

正是因为引用一旦引用了某个实体之后就不能再引用其他实体,所以引用无法替代指针来实现链表数据结构。否则就无法实现链表的增、删等操作,链表的增删操作需要改变指针的指向。

2.2 引用的主要应用 

1、引用做函数参数

要写一个swap函数,实现两个整形数据的交换,如果用C语言来写这个函数,就必须使用指针来作为函数的参数,即:void swap(int* px, int* py)。但是,如果使用C++来写,则可以用引用传参来替代指针传参,因为引用和被引用实体共用一块内存空间,引用传参使得函数内部可以控制实参所占用的内存空间,这是,swap可以声明为:void swap(int& rx, int& ry)。

演示代码2.4:

void swap(int& rx, int& ry)
{int tmp = rx;rx = ry;ry = tmp;
}int main()
{int x = 10, y = 20;printf("交换前:x = %d,y = %d\\n", x, y);  //10,20swap(x, y);printf("交换后:x = %d,y = %d\\n", x, y);  //20,10return 0;
}
图2.4  演示代码2.4的运行结果

至此,可以总结出函数的三种调用方法: 

  1. 传值调用。
  2. 传地址调用。
  3. 传引用调用。

 问题:void swap(int x, int y)和void swap(int& x, int& y)能否构成函数重载?

答案是可以的。因为其满足构成函数重载的条件之一 :参数的类型不同。

但是,在调用这两个swap函数时,会存在歧义。通过语句swap(x,y)调用,无法确定是调用swap(int x, int y)还是swap(int&x, int& y)。

2、引用做函数的返回值

在演示代码2.5中,定义函数int& Add(int x, int y),函数返回z的别名。我们希望这个函数能够对x+y进行计算。但是显然,这段代码是有潜在问题的,因为在add函数调用结束后,为add函数创建的栈帧会被销毁,这块栈空间会还给操作系统。此时再使用add函数的返回值,就会造成对内存空间的非法访问,而大部分情况下,编译器不会对非法访问内存报错。

演示代码2.5:

int& add(int x, int y)
{int z = x + y;return z;
}int main()
{int& ret = add(1, 2);printf("ret = %d\\n", ret);return 0;
}

对于演示代码2.5的运行结果,可以分为两种情况讨论:

  • 函数栈帧销毁后,编译器不对被销毁的栈空间进行清理,打印函数的返回值,结果依旧为x + y的值。
  • 函数栈帧销毁后,编译器对被销毁的栈空间进行清理,函数的返回值为随机值。

在VS2019 编译环境下,演示代码2.5的运行结果为3,说明VS编译器不会清理被销毁的函数栈帧空间中内容。

图2.5  演示代码2.5的运行结果

既然VS编译器不会对被销毁的函数栈帧进行清理,那么是否在VS编译环境下,可以正常使用演示代码2.5中的add函数呢?答案显然是否定的,这可以从以下两个方面解释:

  • 如果在其他编译环境下进行编译,则被销毁的函数空间可能会被清理,这样会降低代码的可移植性。
  • 即使函数栈帧空间不被清理,但这块空间已经换给了操作系统,如果调用完add函数后再调用其他函数,那么原本为z开辟的空间可能会被覆盖,从而改变ret的值。

如演示代码2.6所示,第一次调用add函数使用ret来接收返回值,第二次调用add函数不接收返回值。但是第二次调用add函数之后,ret的值却变为了30,这是因为第二次调用add函数覆盖了第一次调用时创建的函数栈帧,原来第一次调用存放变量z的内存空间的内容由3变为了30,因此,程序运行的结果为30。这段代码在运行过程中栈帧的创建和销毁情况见图2.7。

演示代码2.6:

int& add(int x, int y)
{int z = x + y;return z;
}int main()
{int& ret = add(1, 2);cout << ret << endl;add(10, 20);cout << ret << endl;return 0;
}
图2.6 演示代码2.6的运行结果
图2.7  两次调用add函数栈帧的开辟和被覆盖情况

总结(什么时候可以用引用返回,什么时候不可以):

  • 如果出了函数作用域,函数返回的对象被销毁了,则不能使用引用类型作为返回值。
  • 如果出了函数作用域,函数的返回对象还没有被销毁(存储返回对象的内存还没有还给操作系统),则可以使用引用作为返回值。

演示代码2.7给出了两种可以使用引用作为返回的情况,一种是以静态变量作为返回对象,另一种是返回对象为调用函数中开辟的一块内存空间中的内容(调用函数中开辟的数组)。

演示代码2.7:

int& func1()
{static int n = 0;++n;return n;
}char& func2(char* str, int i)
{return str[i];
}int main()
{cout << func1() << endl;  //1cout << func1() << endl;  //2char ch[] = "abcdef";for (int i = 0; i < strlen(ch); ++i){func2(ch, i) = '0' + i;}cout << ch << endl;  //012345return 0;
}
图2.8  演示代码2.7的运行结果

思考问题:既然函数完成调用时才会返回,而调用完成时函数栈帧又会被销毁。那么,以值作为函数返回类型时,时如何从函数中接收返回值的呢?

就比如演示代码2.8中的add函数,函数返回值时add函数中的临时变量z的值,在主函数中的ret如何从add函数中接收z值。

演示代码2.8: 

int add(int x, int y)
{int z = x + y;return z;
}int main()
{int ret = add(2, 3);return 0;
}

答案其实很简单,ret并不是直接从add函数栈帧的空间中接收返回值,而是在add函数完成调用、函数栈帧销毁之前,存储一个临时变量用于接收函数的返回值,然后在将临时变量的值赋给ret。

那么,这个临时变量存储在什么位置呢?分两种情况讨论:

  • 如果返回值比较小,则使用寄存器充当临时变量。
  • 如果返回值比较大,则将临时变量放在调用add函数的函数内部,在调用add函数之前在调用add的函数的栈帧中预先开辟一块空间用于存储临时变量。
图2.9  值返回情况下函数返回值被接收的过程

三. 引用的效率测试

3.1 传值调用和传引用调用的效率对比

演示代码3.1分别执行100000次传值调用和100000次传引用调用,每次传值调用传给函数的形参的大小为40000bytes,记录传值调用和传引用调用消耗的时间。

程序运行结果显示,10000次传值调用耗时71ms,100000次传引用调用耗时2ms,传引用调用的效率远高于传值调用。这是因为传引用调用不用再为形参开辟一块内存空间,而为形参开辟空间存在一定的时间消耗。

演示代码3.1:

#include<iostream>
#include<time.h>
using namespace std;//大小为40000bytes的结构体
typedef struct A
{int arr[10000];
}A;void Testvaluefunc(A a) { };   //传值调用测试函数
void TestReffunc(A& a) { };  //传引用调用测试函数void TestRefAndValue1()
{A a;int i = 0;size_t begin1 = clock();  //记录开始传值调用的时间(传值调用100000次)for (i = 0; i < 100000; ++i){Testvaluefunc(a);}size_t end1 = clock();  //记录结束传值调用的时间size_t begin2 = clock();  //记录开始传引用调用的时间(调用100000次)for (i = 0; i < 100000; ++i){TestReffunc(a);}size_t end2 = clock();cout << "传值调用10000次耗费时间:" << end1 - begin1 << endl;cout << "传引用调用10000次耗费时间:" << end2 - begin2 << endl;
}
图3.1  演示代码3.1的运行结果

3.2 值返回和引用返回的效率对比

演示代码3.2分别执行100000次值返回函数和100000次引用返回函数,记录调用值返回函数和调用引用返回函数消耗的时间。程序运行结果表明,调用100000次值返回函数耗时136ms,调用100000次引用返回函数耗时2ms,引用返回的效率远高于值返回。

演示代码3.2:

#include<iostream>
#include<time.h>
using namespace std;typedef struct A
{int arr[10000];
}A;A a;A TestValuefunc2()
{return a;
}A& TestReffunc2()
{return a;
}void TestRefAndValue2()
{int i = 0;size_t begin1 = clock();  //记录开始时间(调用100000次)for (i = 0; i < 100000; ++i){TestValuefunc2();}size_t end1 = clock();  //记录结束时间size_t begin2 = clock();  //记录开始的时间(调用100000次)for (i = 0; i < 100000; ++i){TestReffunc2();}size_t end2 = clock();  //记录结束时间cout << "以值作为返回:" << end1 - begin1 << "ms" << endl;cout << "以引用作为返回:" << end2 - begin2 << "ms" << endl;
}int main()
{TestRefAndValue2();  //引用作为返回和值作为返回的效率测试return 0;
}
图3.2  演示代码3.2的运行结果

四. 常引用 

4.1 权限放大和权限缩小问题

如果int& b = a,而a是整形常量,被const关键字修饰,那么b就不能作为a的别,因为a变量是只读的,而将b定义为int&类型,则表明b是可读可写的类型,b对a存在权限放大问题。

对于int a = 10,使用const int& b = a来表示a的别名是可以编译通过的。因为a为读写类型,而b为只读类型,b相对于a权限缩小,C++允许权限缩小。

总结:C++允许权限缩小,不允许权限放大。

演示代码4.1:

int main()
{//权限放大问题const int a = 10;//int& b = a;   //报错const int& b = a;  //编译通过//权限缩小int c = 10;const int& d = c;   //能够编译通过return 0;
}

4.2 跨数据类型的引用问题

看一个很诡异的问题。在演示代码4.2中,定义一个双精度浮点型数据double d = 1.1,编译程序,出现下面的现象:

  • 将d赋给int型数据i1,编译通过。
  • 用int& i2 = d来作为d的引用(别名),编译报错。
  • 但是,使用const int& i3 = d来作为d的引言,编译通过。

演示代码4.2:

int main()
{double d = 11.11;int i1 = d;  //强转,编译通过//int& i2 = d;   //编译报错const int& i3 = d;  //编译通过printf("&d = %p\\n", &d);printf("&i3 = %p\\n", &i3);return 0;
}

那么,为什么const int& i3类型的可以作为d的引用,而int& i2却不行?问题出在强制类型转换上。要理解这个问题,首先要清楚强制类型转换的过程,强制类型转换(int i1 = d),并不是将d强转后的数据直接赋给i1,而是先将d强转为int类型数据的值存储在一个临时变量中,然后再将临时变量的值传给i1,详见图4.1。

图4.1 数据强制类型转换的过程

临时变量具有常性,只可读不可改。因此,int& i2 = d就存在权限放大的问题,编译无法通过,而const int& i3 = d不会存在权限放大的问题,可以编译通过。但是,这里的i3就不再是d的别名,而是存储d强转为int类型数据值的临时变量的别名,因此i3和d的地址也就不同。演示代码4.2打印了i3和d的地址,表面他们不同,i3其实并不是d的别名。

提示:一定要弄清楚强转类型转换时临时变量做中间值的问题!

图4.2  演示代码4.2的运行结果

五. 引用和指针的区别

  1. 引用是定义一个变量的别名,而指针存储一个地址。
  2. 引用不占用额外的内存空间,而指针要占用4bytes或8bytes的内存空间。
  3. 引用在定义时必须初始化,而指针可以不初始化。(建议指针在定义时避免不初始化)。
  4. 引用一旦引用了某个实体,便不能更改被引用实体,而指针可以更改指向。
  5. 对引用自加,即对被引用的实体+1,指针自加,向后偏移一个指针类型的大小(bytes)。
  6. 没有多级引用,有多级指针。
  7. 访问实体时,引用直接由编译器处理即可,指针需要解应用。
  8. 没有空引用,但有空指针NULL。
  9. 引用相对于指针更加安全。

因为指针存在野指针、空指针等问题,造成指针过于灵活,所以指针的安全性不如引用。

引用的底层是通过指针来实现的。

引用最大的局限性在于不能更改引用实体,因此虽然引用的底层是通过指针实现的,但引用不能替代指针来实现链表数据结构。因为链表的操作需要更改指针的指向。