從最早被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++代碼非常簡單,但匯編代碼卻不容易理解,比較抽象(以下的每一個序號表明對應的C++代碼行號):
movq %rax, -8(%rbp) // -8(%rbp) -> b
movl $9999, (%rax)
movl (%rax), %eax
movl %eax, -12(%rbp)
其中-12(%rbp)處存放的是變量a,-8(%rbp)處存放的是邊變量b。現在分別來分析每行C++語句的實現:
從上面的分析可見,在匯編語言級,引用的實現是通過指針來實現的:變量b存放的是變量a的指針。引用在底層上的實現非常直接,既沒有額外的空間消耗,也沒有多余的時間消耗。
class CusOne
{
Int a;
Flaot b;
Void* c;
};
下面是匯編代碼:
leaq 8(%rsp), %rcx
movl $0, 28(%rsp)
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個字節大小):
接著,代碼將存放有one變量地址的寄存器rcx賦值給寄存器rsp所指向的內存單元,即變量ref。也就是說,自定義類型引用在底層的實現,同樣是通過指針。最後是自定義類型變量的賦值,代碼先將ref值(也就是one的地址)存放在寄存器rcx中,然後以8個字節為單元將one變量的內容賦值給ref,完成ref = one賦值語句的實現。
本文對比C指針,介紹了C++引用的語法語義特殊性及其優點;通過實驗,測試引用在匯編級的代碼生成量大小和運行時的效率;並從底層切入,分析了引用的實現機制。希望本文可以拋磚引玉,幫助開發人員深入理解C++中的引用機制,高效地加以利用。