0%

真正理解引用

频繁使用的引用,到底是什么呢❓

前排提示:本文所讨论的引用都是 C/C++ 中的引用。

概念

还是一样,我们需要先明确**引用(reference)**的概念到底是什么,我们会从两个层面来讨论引用的概念。

语言规范层面(抽象层)

注意到我们的标题,可能会有人疑惑语言规范层面(抽象层)是什么意思?
所谓语言规范层面,其实就是指 C++ 标准委员会规定的 C++ 标准,它是人为规定的,可能会与实际相同也可能完全不是一回事。换句话说,这是高级语言的设计者对高级语言底层实现的抽象,可以帮助使用高级语言的开发者更好的使用高级语言进行开发工作。

那么好了,引用在这个层面上的概念又是怎样的呢?
在 C++ 标准中,规定:

  1. 引用是已存在对象的别名,比如:
1
2
3
int i = 10;
int& ri = i; // ri 是 i 的别名
// 此时对`ri`的操作都会直接作用在`i`上
  1. 引用不是独立对象,不存在“引用的存储空间”概念,所以也就不能创建引用的引用(这里在设计时应该是想避免类似指针的套娃吧😂),比如:
1
2
3
4
int i = 10;
int& ri = i; // 左值引用
sizeof(ri); // 等价于 sizeof(i)
&ri; // 等价于 &i
  1. 引用在定义时,必须要赋初始值,编译器会把引用和它的初始值绑定在一起,且无法再改变绑定。

尽管我们在前面提到了这些都是抽象出来的概念,想必也有一些同学看的很困惑。特别是像引用不存在存储空间,这些概念,计算机内还有不需要存储空间的内容吗?下面我们就从编译器的角度来看看这些概念。

底层实现层面(编译器角度)

实际上,在汇编层面,编译器可能会将引用当作指针来解释。注意这句话的用词,首先是状语——“在汇编层面”,限制了范围,接着我们说引用可能会被当作指针来解释,也就是说编译器如何解释引用是编译器开发人员决定的,他可以当作指针解释,也可以用别的其他手段,只要实现了即可。所以,引用和指针并不是同样的东西,要注意区分。
了解 C 语言的同学此时也会发现,有些引用(比如左值引用)在使用时和指针常量的行为很相似,比如必须要有初始值,或一旦指向某个内容,就无法再改变等。
同时关于不存在“引用的存储空间”的概念,我们也可以使用一些技巧来得到引用的存储空间大小:

1
2
3
4
struct Test {
int& r;
};
sizeof(Test);

在上面的代码中,我们根本没有为引用赋初始值,可这段代码在 C++ 中是不会报错的。但这也只是在这种情况下,我们通过一些小花招得到了“引用的大小”。在使用引用的其他情况下,编译器到底做了什么优化,我们是没办法完全清楚的。特别是在一些简单使用的场景,比如:

1
2
3
int a = 10;
int& ra = a;
std::cout << ra << std::endl;

此时,编译器是否直接把第三行代码中的ra优化成a的值,或者直接优化成10,我们是不清楚的,可能有的编译器没有直接优化掉,有的直接优化掉了,这取决于具体编译器的实现。

那话又说回来了,为什么要这么规定呢?这个问题得问 C++ 的设计者们了...😂这里就不深究了。
总而言之,就是一句话,抽象层的概念与底层的实现细节需要区分开来,特别是对于 C++ 而言。

小结

现在,我们先来总结下我们的疑问以及得到的答案:

  1. 引用是不是指针?
    从抽象层语义上看,二者是完全不同的东西,指针是存储地址的变量,是一种对象,但引用不是对象,也不具有存储空间。从底层的实现上看,二者是类似的,但也仅仅是类似,编译器可能会对指针和引用做完全不同的事情。
  2. 引用不存在存储空间吗?
    显然底层实现是存在存储空间的,且与指针的大小应该一致。但是在抽象层上(也就是我们使用的时候),我们可以认为引用不存在存储空间。
  3. 为什么引用需要绑定初始值?
    因为从抽象层语义上讲,规定就是这样,遵守就行了。
  4. 为什么不存在引用的引用?
    尽管存在指针的指针,但语言本身规定引用不是对象,也不存在存储空间,那就不存在引用的引用了,这样设计的目的应该是为了简化引用的使用。
  5. 为什么要这么设计呢?
    我想可能是因为 C++ 标准允许编译器开发人员对底层实现进行最大限度的优化导致的。而对用户而言,只要在抽象层上使用即可。

使用

明确引用的概念后,我们还需要探究一下如何使用,这部分我们会从左值引用、右值引用和常引用来深入。

左值引用

左值引用(可以简单理解为左值的引用,但这又会衍生出另外一个问题:什么是左值?)是 C++ 中最常见的引用类型,之所以叫做左值引用,一方面是因为所引用的对象都是左值,另一方面也是为了与 C++11 引入的右值引用区分开(实际上,C++11 之前,大家理解的引用基本都是左值引用)。它的用法也比较简单直观,使用一个&来获取,我们前面已经提到了引用是已存在对象的别名,这里我们再简单的举几个例子:

  1. 函数传参时,避免拷贝,替代指针,比如:
1
void swap(int& a, int& b);
  1. 与指针一样,引用也支持多态,且使用起来比指针简单。
  2. C++11 引用语义对容器元素的访问,比如:
1
2
3
4
std::vector<int> v{1, 2, 3, 4};
for(int& i: v) {
//...
}

右值引用

右值引用是 C++11 引入的概念,它同样可以简单理解为右值的引用(同样会衍生出问题:什么是右值?),所以初始化时它必须绑定到右值。与左值引用一样,它也是某个右值对象的别名。同时,右值引用只能绑定到一个将要销毁的对象(也叫将亡值,也可以绑定到临时对象或者字面量等其他右值),并且这些对象的生命周期会延长到与右值引用一致。与左值引用类似,我们使用&&来获取右值引用,比如:

1
2
3
4
int i = 10; // 初始化变量 i
int& ri = i; // 左值引用
int&& rri = 10; // 右值引用
int&& rri2 = std::move(ri) // 使用 std::move 函数可以将左值转化为右值

那么右值引用一般有什么用途呢?这里,我们只简单的举一些例子:

  1. 引用匿名对象,或者说延长临时对象生命周期,比如:
1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>

class A {
public:
A() { };
};

int main() {
A&& rra = A();

return 0;
}
  1. 实现移动语义和完美转发,避免多余的深拷贝操作(这里暂时不深入探讨,不然又可以引出一大堆内容...😴)。

常引用

所谓常引用,其实也可以理解成常量的引用,也就是对const修饰的对象的引用,这种情况在 C++11 中也叫做常量左值引用(const T&)。同时在 C++11 中,既然有右值引用,那也肯定有常量右值引用(const T&&)。针对常量左值引用,常见的用法比如:

1
2
3
int i = 10;
const int& ri = i;
ri = 20; // 错误,不能通过常引用修改被引用对象的值

类似地,我们也简单的提供一些例子:

  1. 函数传参使用引用,避免拷贝的同时也能避免被修改:
1
void func(const int& a);
  1. 与右值引用类似,也可以引用临时对象、将亡值或字面量:
1
const std::string& s = "hello";
  1. 常引用既可以绑定非常量,也可以绑定常量,还可以绑定左值和右值,简直是万能的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int i = 10;
int& ri = i;
int&& rri = 5;
const int j = 20;
const int& ri1 = i; // 绑定非常量左值
const int& ri2 = 20; // 绑定字面量(也是右值)
const int& ri3 = j; // 绑定常量左值
const int& ri4 = ri; // 绑定左值引用
const int& ri5 = rri; // 绑定右值引用
const int& ri6 = 1 + 2; // 绑定表达式
const int& ri7 = i + 1;
double f = 3.14;
const int& rf = f; // 绑定不同类型
/*
这里编译器会做一个转换的过程
double f = 3.14;
-> const int temp = f;
const int& rf = temp;
*/

而常量右值引用一般很少使用,这里就不深入了。

小结

到目前为止,我们讨论了左值引用、右值引用和常引用(包括常量左值引用和常量右值引用)。现在,我们总结一下各种引用可以引用的类型:

引用类型 非常量左值 常量左值 非常量右值 常量右值 用途
非常量左值引用 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_referenceis_lvalue_referenceis_reference

std::move

在 C++11 中,标准库在<utility>中提供了一个叫做std::move的函数,这里我们先不做过多介绍,只需要知道这个函数以一个左值作为参数,可以得到一个将亡值(xvalue)表达式,且在这个过程中不会创建新对象。比如下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <iostream>
#include <utility>

class A {
public:
A() {
std::cout << "A constructor" << std::endl;
}
~A() {
std::cout << "A destructor" << std::endl;
}
};

int main() {

A&& rra = std::move(A());
std::cout << "-----" << std::endl;
A a;
return 0;
}
/*
A constructor
A destructor
-----
A constructor
A destructor
*/

观察结果会发现,尽管我们企图使用右值引用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
2
3
4
5
6
// 注意:这是 C++11 的用法
template<typename T>
void func(T&& param) {
//...
return gfunc(std::forward<T>(x));
}

或者:

1
2
3
4
5
6
7
8
// 注意:这是 C++14 的特性
auto lambda = [](auto&& param) {
// ...
}
// 注意:这是 C++20 的特性
void func(auto&& param) {
// ...
}

转发引用的形式与右值引用有点类似,都需要使用&&,但其前面需要模板参数或auto关键字来修饰,而且二者也完全不是一回事。在使用时,根据传入参数的值类别,自动推导值类别并按照原样传递。

最直观来说,引用折叠其实就是为了解决引用的引用是什么而存在的,比如:

1
2
using T = int&;
T&& t; // 等价于 int& && t,如何解释这种类型?

此时,t会被当作int&
上述只是一个简单的例子,实际情况可以以下面的表格内为准:

模板类型 T 的类型 最终类型
T& R R&
T& R& R&
T& R&& R&
T&& R R&&
T&& R& R&
T&& R&& R&&

这个表格的记忆方法就是:一左则左,也就是说,类型模板和T实际类型中有一个是左值引用,那么最终类型就是左值引用,否则若没有左值引用,但有一个是右值引用,那么最终类型就是右值引用