C++类型引用浅析
引言
从最早被Bjarne Stroustrup 发明,作为C语言的扩展,到广为人知C++98标准,再到最新的C++11、C++14和C++17标准,C++一直在不断地进步、演化。面向对象、泛型编程、模板、range based for、lamnda表达式,一个又一个强大的功能概念被不断地提出并最终采纳到标准当中。C++正在向着更加现代化的方向前进。
然而,也许是因为C++包容的太多的缘故,它总有一些偏僻而生涩的角落,暗藏着陷阱,时常让用户迷惑。类型引用就是这样的一个语言特性,很多书籍中对它只是一笔带过,让用户把它想象成一个指针。但是,引用的用法却和指针不同,使用者经常在没有深入理解引用概念的情况下将两者混为一谈。
本文从实际工程应用出发,探讨引用在使用上相比指针的优点;建立测试,对比两者在代码效率方面的差别;并从底层切入,以编译器实现的视角探索引用类型的实质。
引用初始化
引用的声明语法为: <Type>&<Name>,它的初始化必须同时伴随赋值。也就是说,引用类型必须同时声明和初始化。而指针不一样,指针可以将声明与初始化分离,不需要在声明时初始化。
那为什么引用的语法会有这样的要求呢?因为引用概念的出现是为了改善C++中的安全问题。指针声明与初始化的分离固然带来了使用上的灵活性,却也在一定程度上加大了程序出错的可能性: 变量可能在初始化之前被使用。尤其是在工程中,错综复杂的模块关系和难以理解的算法代码容易让开发者在代码的阅读中丢失上下文,而短至一两行的初始化代码往往难以辨析。
引用赋值
引用不允许单独赋值,唯一的赋值就是在初始化时。同样的,引用牺牲了灵活性来获得更多的安全性。
考虑如下的代码片段:
void* ptr = malloc(1);
ptr = malloc(1);
指针ptr被两次赋值,但对于第一次获取的内存而言,我们不能再次使用它,也没有办法释放它因为没有任何指针指向它(典型的内存泄漏)。
但是如果使用引用的话,就能够在语法上今早发现这种问题,消除内存泄漏存在的可能性(编译器将会在第二行处报错):
void* const & ref = malloc(1);
ref = malloc(1);
空引用
引用不能为空,每一个引用都引用某个对象或内建类型。
对于指针ptr,可以以ptr = NULL 或者 ptr = nullptr的形式声明空指针,但是这就意味着指针可能为空。在代码中,需要显示地检测这种情况。大量的实践表明,这会造成逻辑的不连续,扰乱代码的一致性。
而引用不允许空引用。对于引用ref,形似ref = NULL 或ref = nullptr的引用对象的方式是不被允许的,因为每一个引用都必须引用(也就是指向)某个用户自定义对象或内建类型。引用语法上的限制,既消除了多余的空值检查,保证了自身的有效性,又减轻了开发者的负担,间接改善了代码的可读性,使工程易于维护和发展。
引用语义
使用引用进行的操作,相当于直接在被引用对象上进行这些操作。
这与指针非常相似,除了语法方面的不同:通过指针进行的操作使用->操作符,通过引用进行的操作使用.操作符。考虑下面的代码片段:
int a = 0;
int& b = a;
b = 9;
代码非常简单,只有三行:第一行声明整型变量a,第二行声明整型引用b,第三行对b进行赋值。最后结果是:a和b的值都为9 。因为b只是对a的引用,对b赋值语义上就是对a赋值,b只是a的一个别名,实际上都指向同一块内存。
虽然上述例子中是举例内建类型的引用,但引用语义同样适用于自定义类型(即类)。这种环境下,引用的使用效果与指针相同,但引用使得我们能够以一种更现代化、更贴近面向对象的方式进行对象的操作(即.操作符),使代码在形式上更符合人类的逻辑。
引用类型的汇编级代码量比较
从实际角度看引用类型的编译后代码量,我们对C++内建类型以及两个极端的自定义类进行测试,类定义如下:
class CusOne {};
class CusOne
{
Int a;
Short b;
Float c;
Double d;
CusOne one;
};
CusOne类型不包含任何成员,而CusTwo类则包含多个内建类型成员以及一个自定义类成员。
编译环境为X86_64机器,Win8.1系统下,编译采用clang编译器3.8版本(-O0为禁止优化选项,为了防止编译器对测试代码进行优化,妨碍测试结果的正确性)。以下是编译后代码量结果:
表6-1 单个引用和指针变量的编译后代码量(汇编代码)
Type |
Pointer (-O0) |
Reference (-O0) |
Int |
40 byte |
40 byte |
Short |
40 byte |
40 byte |
Long |
40 byte |
40 byte |
Long Long |
40 byte |
40 byte |
Float |
40 byte |
40 byte |
Double |
40 byte |
40 byte |
CusOne |
40 byte |
40 byte |
CusTwo |
39 byte |
39 byte |
可以看到,类型引用的代码量与单纯的指针是一样,不需要产生额外的代码。
引用的运行效率比较
接下来测试引用的效率。我们对每种类型的变量赋值10亿次,分别通过指针和引用,统计它们的运行时间。编译及测试环境同上(同样禁止编译优化)。
表7-1 引用和指针的效率测试
Type |
Pointer (-O0) |
Reference (-O0) |
Int |
2.421s |
2.406s |
Short |
2.343s |
2.343s |
Long |
2.343s |
2.328s |
Long Long |
2.328s |
2.328s |
Float |
2.359s |
2.328s |
Double |
2.375s |
2.343s |
CusOne |
2.390s |
2.390s |
CusTwo |
2.562s |
2.531s |
在效率上,引用与指针相差无几,几乎没有效率上的包袱。以上测试是针对引用的'存'操作,'取'操作与'存'操作几乎相同,这里不再重复检测。
底层实现分析
想要了解引用在底层的实现,最好的方法就是从汇编语言探究其实现。因为任何高级语言特性,都是在汇编的基础上实现的。我们将从一小段C++代码出发,将其编译成汇编语言进行研究。
-
我们取以下C++代码作为内建类型引用的例子:
- int a = 0;
- int& b = a;
- b = 9999;
- a = b;
C++代码非常简单,但汇编代码却不容易理解,比较抽象(以下的每一个序号表明对应的C++代码行号):
- movl $0, -12(%rbp) // -12(%rbp) -> a
-
leaq -12(%rbp), %rax
movq %rax, -8(%rbp) // -8(%rbp) -> b
-
movq -8(%rbp), %rax
movl $9999, (%rax)
-
movl $9999, (%rax)
movl (%rax), %eax
movl %eax, -12(%rbp)
其中-12(%rbp)处存放的是变量a,-8(%rbp)处存放的是边变量b。现在分别来分析每行C++语句的实现:
- 将常量值0赋值给a。
- 取变量a的地址赋值给寄存器rax,再将寄存器rax的值赋值给变量b。
- 将寄存器rbx的值(也就是a的地址)赋值给rax,再将常量9999赋值给寄存器rax所指向的内存单元(也就是变量a)。
- 将常量9999赋值给寄存器rax指向的内存单元(也就是变量a),将寄存器rax指向的内存值(也就是变量a)赋值给寄存器rax,再将rax赋值给a。
从上面的分析可见,在汇编语言级,引用的实现是通过指针来实现的:变量b存放的是变量a的指针。引用在底层上的实现非常直接,既没有额外的空间消耗,也没有多余的时间消耗。
-
最后来看看对于自定义类型,引用的实现机制,以下是C++代码:
-
类定义:
class CusOne
{
Int a;
Flaot b;
Void* c;
};
-
引用测试代码:
- CusOne one;
- CusOne& ref = one;
- ref = one;
-
类定义:
下面是汇编代码:
-
subq $32, %rsp
leaq 8(%rsp), %rcx
movl $0, 28(%rsp)
- movq %rcx, (%rsp) // (%rsp) -> ref
-
movq (%rsp), %rcx
movq 8(%rsp), %rdx // 8(%rsp) -> [one.a, one.b]
movq %rdx, (%rcx)
movq 16(%rsp), %rdx// 16(%rsp) -> one.c
movq %rdx, 8(%rcx)
其中%rsp为栈指针寄存器,汇编代码先将栈增加32个字节,用以存储one变量和引用变量,并将one变量的地址存储在rcx寄存器中。第三个指令用来初始化多余的填充字节,这里与主题无关,不多加考虑。因此,(%rsp)处存放的是引用变量ref,8(%rsp)开始24个字节存放的是one变量。如下图(注:途中每个单元为8个字节大小):