从最早被 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 单个引用和指针��量的编译后代码量(汇编代码)
可以看到,类型引用的代码量与单纯的指针是一样,不需要产生额外的代码。 引用的运行效率比较接下来测试引用的效率。我们对每种类型的变量赋值10亿次,分别通过指针和引用,统计它们的运行时间。编译及测试环境同上(同样禁止编译优化)。 表7-1 引用和指针的效率测试
在效率上,引用与指针相差无几,几乎没有效率上的包袱。以上测试是针对引用的'存'操作,'取'操作与'存'操作几乎相同,这里不再重复检测。 底层实现分析想要了解引用在底层的实现,最好的方法就是从汇编语言探究其实现。因为任何高级语言特性,都是在汇编的基础上实现的。我们将从一小段 C++ 代码出发,将其编译成汇编语言进行研究。
C++ 代码非常简单,但汇编代码却不容易理解,比较抽象(以下的每一个序号表明对应的 C++ 代码行号):
其中-12(% rbp )处存放的是变量 a ,-8(% rbp )处存放的是边变量 b 。现在分别来分析每行 C++ 语句的实现:
从上面的分析可见,在汇编语言级,引用的实现是通过指针来实现的:变量 b 存放的是变量 a 的指针。引用在底层上的实现非常直接,既没有额外的空间消耗,也没有多余的时间消耗。
下面是汇编代码:
其中% rsp 为栈指针寄存器,汇编代码先将栈增加32个字节,用以存储 one 变量和引用变量,并将 one 变量的地址存储在 rcx 寄存器中。第三个指令用来初始化多余的填充字节,这里与主题无关,不多加考虑。因此,(% rsp )处存放的是引用变量 ref ,8(% rsp )开始24个字节存放的是 one 变量。如下图(注:途中每个单元为8个字节大小):
接着,代码将存放有 one 变量地址的寄存器 rcx 赋值给寄存器 rsp 所指向的内存单元,即变量 ref 。也就是说,自定义类型引用在底层的实现,同样是通过指针。最后是自定义类型变量的赋值,代码先将 ref 值(也就是 one 的地址)存放在寄存器 rcx 中,然后以8个字节为单元将 one 变量的内容赋值给 ref ,完成 ref = one 赋值语句的实现。 结束语本文对比 C 指针,介绍了 C++ 引用的语法语义特殊性及其优点;通过实验,测试引用在汇编级的代码生成量大小和运行时的效率;并从底层切入,分析了引用的实现机制。希望本文可以抛砖引玉,帮助开发人员深入理解 C++ 中的引用机制,高效地加以利用。 (责任编辑:好模板) |