频繁使用的引用,到底是什么呢❓
前排提示:本文所讨论的引用都是 C/C++ 中的引用。
概念
还是一样,我们需要先明确**引用(reference)**的概念到底是什么,我们会从两个层面来讨论引用的概念。
语言规范层面(抽象层)
注意到我们的标题,可能会有人疑惑语言规范层面(抽象层)是什么意思?
所谓语言规范层面,其实就是指 C++ 标准委员会规定的 C++ 标准,它是人为规定的,可能会与实际相同也可能完全不是一回事。换句话说,这是高级语言的设计者对高级语言底层实现的抽象,可以帮助使用高级语言的开发者更好的使用高级语言进行开发工作。
那么好了,引用在这个层面上的概念又是怎样的呢?
在 C++ 标准中,规定:
- 引用是已存在对象的别名,比如:
1 | int i = 10; |
- 引用不是独立对象,不存在“引用的存储空间”概念,所以也就不能创建引用的引用(这里在设计时应该是想避免类似指针的套娃吧😂),比如:
1 | int i = 10; |
- 引用在定义时,必须要赋初始值,编译器会把引用和它的初始值绑定在一起,且无法再改变绑定。
尽管我们在前面提到了这些都是抽象出来的概念,想必也有一些同学看的很困惑。特别是像引用不存在存储空间,这些概念,计算机内还有不需要存储空间的内容吗?下面我们就从编译器的角度来看看这些概念。
底层实现层面(编译器角度)
实际上,在汇编层面,编译器可能会将引用当作指针来解释。注意这句话的用词,首先是状语——“在汇编层面”,限制了范围,接着我们说引用可能会被当作指针来解释,也就是说编译器如何解释引用是编译器开发人员决定的,他可以当作指针解释,也可以用别的其他手段,只要实现了即可。所以,引用和指针并不是同样的东西,要注意区分。
了解 C 语言的同学此时也会发现,有些引用(比如左值引用)在使用时和指针常量的行为很相似,比如必须要有初始值,或一旦指向某个内容,就无法再改变等。
同时关于不存在“引用的存储空间”的概念,我们也可以使用一些技巧来得到引用的存储空间大小:
1 | struct Test { |
在上面的代码中,我们根本没有为引用赋初始值,可这段代码在 C++ 中是不会报错的。但这也只是在这种情况下,我们通过一些小花招得到了“引用的大小”。在使用引用的其他情况下,编译器到底做了什么优化,我们是没办法完全清楚的。特别是在一些简单使用的场景,比如:
1 | int a = 10; |
此时,编译器是否直接把第三行代码中的ra优化成a的值,或者直接优化成10,我们是不清楚的,可能有的编译器没有直接优化掉,有的直接优化掉了,这取决于具体编译器的实现。
那话又说回来了,为什么要这么规定呢?这个问题得问 C++ 的设计者们了...😂这里就不深究了。
总而言之,就是一句话,抽象层的概念与底层的实现细节需要区分开来,特别是对于 C++ 而言。
小结
现在,我们先来总结下我们的疑问以及得到的答案:
- 引用是不是指针?
从抽象层语义上看,二者是完全不同的东西,指针是存储地址的变量,是一种对象,但引用不是对象,也不具有存储空间。从底层的实现上看,二者是类似的,但也仅仅是类似,编译器可能会对指针和引用做完全不同的事情。 - 引用不存在存储空间吗?
显然底层实现是存在存储空间的,且与指针的大小应该一致。但是在抽象层上(也就是我们使用的时候),我们可以认为引用不存在存储空间。 - 为什么引用需要绑定初始值?
因为从抽象层语义上讲,规定就是这样,遵守就行了。 - 为什么不存在引用的引用?
尽管存在指针的指针,但语言本身规定引用不是对象,也不存在存储空间,那就不存在引用的引用了,这样设计的目的应该是为了简化引用的使用。 - 为什么要这么设计呢?
我想可能是因为 C++ 标准允许编译器开发人员对底层实现进行最大限度的优化导致的。而对用户而言,只要在抽象层上使用即可。
使用
明确引用的概念后,我们还需要探究一下如何使用,这部分我们会从左值引用、右值引用和常引用来深入。
左值引用
左值引用(可以简单理解为左值的引用,但这又会衍生出另外一个问题:什么是左值?)是 C++ 中最常见的引用类型,之所以叫做左值引用,一方面是因为所引用的对象都是左值,另一方面也是为了与 C++11 引入的右值引用区分开(实际上,C++11 之前,大家理解的引用基本都是左值引用)。它的用法也比较简单直观,使用一个&来获取,我们前面已经提到了引用是已存在对象的别名,这里我们再简单的举几个例子:
- 函数传参时,避免拷贝,替代指针,比如:
1 | void swap(int& a, int& b); |
- 与指针一样,引用也支持多态,且使用起来比指针简单。
- C++11 引用语义对容器元素的访问,比如:
1 | std::vector<int> v{1, 2, 3, 4}; |
右值引用
右值引用是 C++11 引入的概念,它同样可以简单理解为右值的引用(同样会衍生出问题:什么是右值?),所以初始化时它必须绑定到右值。与左值引用一样,它也是某个右值对象的别名。同时,右值引用只能绑定到一个将要销毁的对象(也叫将亡值,也可以绑定到临时对象或者字面量等其他右值),并且这些对象的生命周期会延长到与右值引用一致。与左值引用类似,我们使用&&来获取右值引用,比如:
1 | int i = 10; // 初始化变量 i |
那么右值引用一般有什么用途呢?这里,我们只简单的举一些例子:
- 引用匿名对象,或者说延长临时对象生命周期,比如:
1 |
|
- 实现移动语义和完美转发,避免多余的深拷贝操作(这里暂时不深入探讨,不然又可以引出一大堆内容...😴)。
常引用
所谓常引用,其实也可以理解成常量的引用,也就是对const修饰的对象的引用,这种情况在 C++11 中也叫做常量左值引用(const T&)。同时在 C++11 中,既然有右值引用,那也肯定有常量右值引用(const T&&)。针对常量左值引用,常见的用法比如:
1 | int i = 10; |
类似地,我们也简单的提供一些例子:
- 函数传参使用引用,避免拷贝的同时也能避免被修改:
1 | void func(const int& a); |
- 与右值引用类似,也可以引用临时对象、将亡值或字面量:
1 | const std::string& s = "hello"; |
- 常引用既可以绑定非常量,也可以绑定常量,还可以绑定左值和右值,简直是万能的:
1 | int i = 10; |
而常量右值引用一般很少使用,这里就不深入了。
小结
到目前为止,我们讨论了左值引用、右值引用和常引用(包括常量左值引用和常量右值引用)。现在,我们总结一下各种引用可以引用的类型:
| 引用类型 | 非常量左值 | 常量左值 | 非常量右值 | 常量右值 | 用途 |
|---|---|---|---|---|---|
| 非常量左值引用 | Y | N | N | N | 修改外部变量/避免拷贝 |
| 常量左值引用 | Y | Y | Y | Y | 万能类型,安全访问 |
| 非常量右值引用 | N | N | Y | N | 用于移动语义/完美转发 |
| 常量右值引用 | N | N | Y | Y | 用途很少 |
总结
现在回到文章本身,我们文章的标题是『真正的理解引用』。在文章中,我们明确了引用的概念(不仅从语言规范层面和底层实现层面,包括 C++11 之前和之后的概念差异也有所提及),理解了引用的部分设计理念;同时,我们还明确了常见的引用类型(左值引用、右值引用和常引用)的基本使用场景和使用方法,当然我们并没有很深入(有些只是一笔带过)。实际上,关于引用,还存在转发引用、引用折叠等概念。总之,要想真正的理解引用,还需要大量的实践,但目前这些应该也已经勉强够用了。而需要深入的部分,就留给日后遇见了再说吧😛。
补充
type_traits
有时候,我们可能不知道一个引用到底是左值引用还是右值引用,为此 C++ 11 引入了<type_traits>来帮助我们解决这个问题。这个头文件提供了三个类模板来帮助我们判断:is_rvalue_reference、is_lvalue_reference和is_reference。
std::move
在 C++11 中,标准库在<utility>中提供了一个叫做std::move的函数,这里我们先不做过多介绍,只需要知道这个函数以一个左值作为参数,可以得到一个将亡值(xvalue)表达式,且在这个过程中不会创建新对象。比如下面这段代码:
1 |
|
观察结果会发现,尽管我们企图使用右值引用rra绑定临时对象,但临时对象却被析构掉了,这好像与我们知道的延长临时对象生命周期存在冲突。实际上,C++ 规定临时对象的生命周期仅限于创建它的完整表达式结束前。换句话说,表达式std::move(A())中,A()创建临时对象,std::move(A())表达式开始执行,结束后临时对象会被立即销毁。对应地,由std::move(A())这个表达式返回的将亡值也就没有了实际的意义。而rra这个引用就变成了悬垂引用(Dangling references,指针中也有类似的概念),继续使用就是未定义行为了。
所以,我们应当避免中间函数,直接使用右值引用绑定临时对象:
1 | A&& rra = A(); |
转发引用/引用折叠
由于 C++11 还引入了两个关于引用的新概念:转发引用(Forwarding references)和引用折叠(Reference collapsing)。
转发引用常用在模板的类型自动推导中,比如:
1 | // 注意:这是 C++11 的用法 |
或者:
1 | // 注意:这是 C++14 的特性 |
转发引用的形式与右值引用有点类似,都需要使用&&,但其前面需要模板参数或auto关键字来修饰,而且二者也完全不是一回事。在使用时,根据传入参数的值类别,自动推导值类别并按照原样传递。
最直观来说,引用折叠其实就是为了解决引用的引用是什么而存在的,比如:
1 | using T = int&; |
此时,t会被当作int&。
上述只是一个简单的例子,实际情况可以以下面的表格内为准:
| 模板类型 | T 的类型 | 最终类型 |
|---|---|---|
| T& | R | R& |
| T& | R& | R& |
| T& | R&& | R& |
| T&& | R | R&& |
| T&& | R& | R& |
| T&& | R&& | R&& |
这个表格的记忆方法就是:一左则左,也就是说,类型模板和T实际类型中有一个是左值引用,那么最终类型就是左值引用,否则若没有左值引用,但有一个是右值引用,那么最终类型就是右值引用。