一、引用的概念
在我们的现实生活中,一个人经常不只一个名字,比如一个人叫张三,那么在家里,它的父母可能喊他老三,在学校/公司,由于他本身性格或其他方面的一些特征,他可能又有别的外号;在古代,这种情况极为正常,比如宋江又叫及时雨,李逵又叫黑旋风、铁牛,鲁迅又叫周树人等等;我们把上面这些外号/亲称叫做别名。
而引用就是给一个已经存在的变量取别名:引用不是新定义一个变量,而是给已存在变量取了一个别名,编译器不会为引用变量开辟内存空间,它和它引用的变量共用同一块内存空间。
引用的符号和我们C语言中取地址的符号一样,为 &;在某类型名的后面加上引用符号 (&) 就变为了引用类型。设计引用的目的是简化指针的使用,但是引用不能代替指针 (实际上引用的底层是用指针实现的)。
void TestRef() { int a = 10; int& ra = a;//<====定义引用类型 printf("%p\n", &a); printf("%p\n", &ra); }
注意:引用类型必须和引用实体是同种类型的。
二、引用的特性
引用有如下特性:
- 引用在定义时必须初始化;
- 一个变量可以有多个引用;
- 引用一旦引用一个实体,就不能再引用其他实体;
void TestRef() { int a = 10; // int& ra; // 该条语句编译时会出错 int& ra = a; int& rra = a; printf("%p %p %p\n", &a, &ra, &rra); }
三、常引用
常引用就是在引用类型前面用 const 来进行修饰;和 const 修饰变量一样,被 cosnt 修饰的引用只能够读,而不能够写,即不能修改引用变量的值;
对于常引用来说,另外一个重要的知识点就是引用的权限问题 – 权限只能被缩小,不能被放大;
注: 这里的权限指的是读和写的权限,且只针对于指针和引用。
下面我们来看一些引用的例子:
常规引用:
void TestConstRef() { int a = 10; int& ra = a; const int& rra = a; const int b = 20; int& rb = b; const int& rrb = b; }
首先,对于 a 来说,a 的权限是读写,而常量 10 是只读的,但是我们将 10 赋值给 a 并没有报错,这是因为我们上述权限的规则只针对指针和引用,而 a 只是一个普通变量,所以可行;
然后,对于 ra 和 rra 来说,ra 的权限是读写,rra 的权限是只读,将权限为读写的 a 赋值给他们,一个是权限平移,一个是权限缩小,都没问题;
最后,对于 b、rb 和 rrb 来说,b 是只读权限,rb 是读写权限,rrb 是只读权限,所以将 b 赋值给 rb 属于权限扩大,报错。
对常量的引用:
void TestConstRef() { int& ra = 10; const int& rra = 10; }
在C语言的学习中,我们知道可以用一个变量的地址对指针进行初始化,也可以直接赋给指针一个地址;那么对于引用来说,出来对变量进行引用,我们是否也可以对常量进行引用呢?答案是:可以,只是有一些限制条件。
对上面的代码进行分析:我们知道,数字10只存在于指令中,在内存中并不占用空间,所以当我们引用对其进行引用时,10会先赋给一个临时变量,我们对这个临时变量进行引用;而临时变量具有常性,即临时变量是只读的,所以我们需要用常引用来引用这个临时变量。
对不同类型的变量进行引用:
void TestConstRef() { double b = 13.14; int& rb = b; const int& rrb = b; }
和对常量进行引用类似,当我们的引用类型和变量类型不同时,该变量会先被赋值给一个与引用同类型的临时变量;在上面的例子中,由于引用类型是 int&,而变量类型是 double,二者不相同,所以对 b 进行引用时,b 会被先转化为 int 类型,然后赋值给一个临时变量,然后我们对这个临时变量进行引用;后面的内容就和常引用相同了,由于临时变量具有常性,所以我们需要用 const 修饰 int&,防止权限扩大。
注:常引用以后会频繁出现在我们的函数参数中,非常重要。
四、引用的使用场景
1、引用做参数
引用做参数的两个实例:
void Swap(int& left, int& right) { int temp = left; left = right; right = temp; }
//单链表 typedef int SLTDataType; //数据类型重命名 typedef struct SListNode //链表的一个节点 { SLTDataType data; struct SListNode* next; //存放下一个节点的地址 }SLTNode; //在头部插入数据 void SListPushFront(SLTNode*& rphead, SLTDataType x) //引用做形参,直接操作plist { SLTNode* newNode = BuySLTNode(x); //开辟新节点 newNode->next = *rphead; *rphead = newNode; }
引用做函数参数的优点
1、减少空间浪费,提高程序效率:在C语言中我们学习到,形参是实参的一份临时拷贝,那么既然是拷贝,就会有时间和空间上的开销;而引用是实参的别名,相当于我们直接对实参进行操作,没有数据拷贝的过程。
2、引用可以直接改变实参,作为输入性参数及输出型参数可以不再传递指针;比如上面的 Swap 函数,我们在 Swap 函数内部交换的其实就是两个实参的值,不用再像以前一样需要传递实参的指针;
又比如单链表的头插等操作,由于我们之前实现的单链表是不带头的,所以我们需要传递 plist 的指针,方便插入第一个数据时改变 plist,而现在,我们可以直接使用 plsit 的引用即可,不用再传递二级指针了,从而使代码变得更易理解。当然,我们不能把 SListNode 中的 next 指针也设计为引用,因为尾结点的 next 是在不断改变的,而引用一旦引用一个实体,就不能再引用其他实体,这也从侧面说明了引用不能代替指针,只能简化指针。
常引用做参数
现在我们知道了引用做参数可以提高效率以及直接改变实参,那么如果我们只想提高效率可不可以用引用呢?比如,我们想要打印一个非常大的结构体中的数据,如果用一般参数,那么形参拷贝的消耗就很大;但如果我们用引用,虽然提高了效率,但原数据的安全性又得不到保证 (可以在打印函数中修改结构体中的数据);而常引用的特性正好可以解决这个问题;实际上在C++中,一般非输出型参数都是常引用参数。
2、引用做返回值
一般参数返回
int Func() { int n = 0; n++; return n; } int main() { int ret = Func(); cout << ret << endl; return 0; }
在 函数栈帧的创建和销毁 中我们知道:调用函数要开辟栈帧,函数栈帧是在栈区上开辟的,并且当我们调用完毕时该函数栈帧会自动销毁;同时函数中的各种局部变量以及函数形参都是在函数栈帧中分配空间的,所以当我们离开函数范围后再去访问函数中的局部变量,编译器就会报错。
那么,我们自然也不可能去访问被调函数的函数栈帧中寻找函数的返回值 (被调函数的函数栈帧已经销毁),实际上,函数的返回值会先被拷贝到一个临时变量中 (引用做返回值引用的就是这个临时变量),如果返回值较小,比如四个字节,那么临时变量就由寄存器充当;如果函数的返回值较大,比如是一个结构体,那么就由调用此函数的函数在其自身函数栈帧中开辟一块空间来充当临时变量。
在上面这段代码中,由于 n 是局部变量,函数调用完毕空间销毁,所以我们返回的是保存函数返回值的局部变量的值,而不是 n 的值。
在明白了这些之后,我们来看下面两个代码段:
代码段1:
int& Func() { static int n = 0; n++; printf("&n:%p\n", &n); return n; } int main() { int ret = Func(); cout << ret << endl; printf("&ret:%p\n", &ret); int& rret = Func(); printf("&rret:%p\n", &rret); return 0; }
在 Func 函数中,n 由于是静态变量,而静态变量在静态区中开辟空间,不在栈区上开辟空间,所以当 Func 函数的栈帧销毁后 n 并不会被销毁;
同时,我们用引用做返回值,相当于直接将 n 这个变量返回给了函数调用者,所以,当我们再用一个引用来接收的话,就等价于给 n 再起了一个别名;代码段2:(注:下面对引用的使用方式是错误的,但是我们需要知道它为什么错了)
代码段2:(注:下面对引用的使用方式是错误的,但是我们需要知道它为什么错了)
int& Func() { int n = 0; n++; printf("&n:%p\n", &n); return n; } void Cover() { int x = 100; printf("&x:%p\n", &x); } int main() { int& ret = Func(); printf("ret=%d\n", ret); printf("&ret:%p\n", &ret); printf("ret=%d\n", ret); Cover(); printf("ret=%d\n", ret); }
要想理解上面这段代码,我们需要知道函数栈帧销毁的本质是什么?
函数栈帧的销毁并不是说真的就将那块空间给销毁了,而是代表该栈帧空间中的数据不受保护了,即该空间中的数据可能被进行如下操作:
- 该空间没有被编译器分配给其他函数使用,则空间中的数据没有被覆盖,我们去访问时还能拿到原来的数据;
- 该空间被分配给了其他函数使用,作为了其他函数的栈帧,数据被覆盖;
现在我们再回过头来看上面这段代码:
1、Func 函数中的 n 变量是局部变量,函数调用完成后被销毁,但是我们这里是引用做返回值,所以返回的是 n 那块栈区空间;
2、ret 是Func 函数返回值的引用,而函数返回值是局部变量 n 的引用,所以我们第一次打印的是 n 的值1;
3、我们在打印 ret 的时候调用了 printf 函数,该函数使用了Func被销毁的空间,所以 n 空间中原本的数据被覆盖,而 ret 是 n 的引用,所以打印 ret 就是打印 printf 函数中原本属于 n 的那块内存空间的数据,所以打印出来的是一个随机值。
4、我们调用了Cover函数,然后发现Cover中的 x 变量的地址和原本 n 的地址相同,说明 Cover 函数又使用了 printf 函数的空间,且 x 的空间恰好位于之前 n 的位置,所以打印 ret 的结果为100;
总结::如果函数返回时,出了函数作用域,如果返回对象还在(还没还给系统),就可以使用引用返回,如果已经还给系统了,则必须使用传值返回。
引用做返回值的优点
- 减少一次数据拷贝,节省空间,提高效率;
- 直接返回变量本身,从而可以通过返回值修改变量。
五、性能比较
1、传值、传引用性能比较
以值作为参数或者返回值类型,在传参和返回期间,函数不会直接传递实参或者将变量本身直接返回,而是传递实参或者返回变量的一份临时的拷贝,因此用值作为参数或者返回值类型,效 率是非常低下的,尤其是当参数或者返回值类型非常大时,效率就更低。
#include <iostream> #include <time.h> using namespace std; struct A { int a[10000]; }; 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(); }
2、值和引用作为返回值的性能比较
#include <iostream> #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(); }
通过上述代码的比较,你会发现传值和指针在作为传参以及返回值类型上效率相差是很大的。
六、引用和指针的区别
语法概念上:引用就是一个别名,没有独立空间,和其引用实体共用同一块空间。
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; }
我们调试代码,然后转到反汇编后可以发现,引用和指针的汇编代码是完全一样的,即引用的底层实际上就是指针。
引用和指针的不同点
引用概念上定义一个变量的别名,指针存储一个变量地址;
引用在定义时必须初始化,指针没有要求;
引用在初始化时引用一个实体后,就不能再引用其他实体,而指针可以在任何时候指向任何一个同类型实体;
没有NULL引用,但有NULL指针;
在sizeof中含义不同:引用结果为引用类型的大小,但指针始终是地址空间所占字节个数(32 位平台下占4个字节);
引用自加即引用的实体增加1,指针自加即指针向后偏移一个类型的大小;
有多级指针,但是没有多级引用;
访问实体方式不同,指针需要显式解引用,引用编译器自己底层处理;
引用比指针使用起来相对更安全。