1. 程式人生 > >C++中引用的本質

C++中引用的本質

程式碼執行環境:Windows7 32bits+VS2017。

引用是C++引入的重要機制,它使原來在C中必須用指標實現的功能有了另一種實現的選擇,在書寫形式上更為簡潔。那麼引用的本質是什麼,它與指標又有什麼關係呢?

1.引用的底層實現方式

引用被稱為變數的別名,它不能脫離被引用物件獨立存在,這是在高階語言層面的概念和理解,並未揭示引用的實現方式。常見錯誤說法是“引用“自身不是一個變數,甚至編譯器可以不為引用分配空間。

實際上,引用本身是一個變數,只不過這個變數的定義和使用與普通變數有顯著的不同。為了解引用變數底層實現機制,考查如下程式碼:

int i=5;
int &ri=i;
ri=8
;

在Visual Studio 2017環境的debug模式除錯程式碼,反彙編檢視原始碼對應的彙編程式碼的步驟是:除錯->視窗->反彙編,即可得到如下原碼對應的彙編程式碼:

int i=5;
00A013DE  mov        dword ptr [i],5        //將文字常量5送入變數i
int &ri=i;
00A013E5  lea        eax,[i]                //將變數i的地址送入暫存器eax
00A013E8  mov        dword ptr [ri],eax     //將暫存器的內容(也就是變數i的地址)送入變數ri
ri=8
; 00A013EB mov eax,dword ptr [ri] //將變數ri的值送入暫存器eax 00A013EE mov dword ptr [eax],8 //將數值8送入以eax的內容為地址的單元中 return 0; 00A013F4 xor eax,eax

考查以上程式碼,在彙編程式碼中,ri的資料型別為dword,也就是說,ri要在記憶體中佔據4個位元組的位置。所以,ri的確是一個變數,它存放的是被引用物件的地址。由於通常情況下,地址是由指標變數存放的,那麼,指標變數和引用變數有什麼區別呢?使用指標常量實現上面的程式碼功能。考查如下程式碼:

int i=5;
int* const pi=&i;
*pi=8;

按照相同的方式,在VS2017中得都如下彙編程式碼:

int i=5;
011F13DE  mov         dword ptr [i],5  
int * const pi=&i;
011F13E5  lea         eax,[i]  
011F13E8  mov         dword ptr [pi],eax  
*pi=8;
011F13EB  mov         eax,dword ptr [pi]  
011F13EE  mov         dword ptr [eax],8  

觀察以上程式碼可以看出:
(1)只要將pi換成ri,所得彙編程式碼與第一段所對應的彙編程式碼完全一樣。所以,引用變數在功能上等於一個指標常量,即一旦指向某一個單元就不能在指向別處。
(2)在底層,引用變數由指標按照指標常量的方式實現。

2.高階語言層面引用與指標常量的關係

(1)在記憶體中都是佔用4個位元組(32bits系統中)的儲存空間,存放的都是被引用物件的地址,都必須在定義的同時進行初始化。

(2)指標常量本身(以p為例)允許定址,即&p返回指標常量(常變數)本身的地址,被引用物件用*p表示;引用變數本身(以r為例)不允許定址,&r返回的是被引用物件的地址,而不是變數r的地址(r的地址由編譯器掌握,程式設計師無法直接對它進行存取),被引用物件直接用r表示。

(3)凡是使用了引用變數的程式碼,都可以轉換成使用指標常量的對應形式的程式碼,只不過書寫形式上要繁瑣一些。反過來,由於對引用變數使用方式上的限制,使用指標常量能夠實現的功能,卻不一定能夠用引用來實現。

例如,下面的程式碼是合法的:

int i=5,j=6;
int* const array[]={&i,&j};

而如下程式碼是非法的:

int i=5,j=6;
int& array[]={i,j};

也就是說,陣列元素允許是指標常量,卻不允許是引用。C++語言機制如此規定,原因是避免C++語法變得過於晦澀。假如定義一個“引用的陣列”,那麼array[0]=8;這條語句該如何理解?是將陣列元素array[0]本身的值變成8呢,還是將array[0]所引用的物件的值變成8呢?對於程式設計師來說,這種解釋上的二義性對正確程式設計是一種嚴重的威脅,畢竟程式設計師在編寫程式的時候,不可能每次使用陣列時都要回過頭去檢查陣列的原始定義。

3.非正常的使引用變數指向別的物件

C++語言規定,引用變數在定義的時候就必須初始化,也即是將引用變數與被引用物件進行繫結。而這種引用關係一旦確定就不允許改變,直到引用變數結束其生命期。這種規定是在高階語言的層面上,由C++語言和編譯器所做的檢查來保障實施的。在特定的環境下,利用特殊的手段,還是可以在執行時動態地改變一個引用變數與被引用物件的對應關係,使引用變數指向一個別的物件。見下面的程式:

#include <iostream>
using namespace std;

int main(int argc,char* argv[])
{
    int i=5,j=6;
    int &r=i;
    void *pi,*pj;
    int* addr;
    int dis;

    pi=&i;    //取整型變數i的地址
    pj=&j;    //取整型變數j的地址
    dis=(int)pj-(int)pi;//計算連續兩個整型變數的記憶體地址之間距離
    addr=(int*)((int)pj+dis);//計算引用變數r在記憶體中的地址

    cout<<"&i:"<<pi<<endl;
    cout<<"&j:"<<pj<<endl;
    cout<<"&pi:"<<&pi<<endl;
    cout<<"&pj:"<<&pj<<endl;
    cout<<"&addr:"<<&addr<<endl;
    cout<<"&dis:"<<&dis<<endl;
    cout<<"distance:"<<dis<<endl;

    (*addr)=(int)&j;    //將j的地址賦給引用r(此處把r看作指標)

    cout<<"addr:"<<addr<<endl;
    r=100;
    cout<<i<<" "<<j<<endl;
    return 0;
}

這個程式在Debug模式下輸出結果如下:

&i:0038FC1C
&j:0038FC10
&pi:0038FBF8
&pj:0038FBEC
&addr:0038FBE0
&dis:0038FBD4
distance:-12
addr:0038FC04
5 100

仔細觀察程式碼和輸出結果可以得出如下結論:
(1)Win32(Windows 32bits)平臺下,int型變數和指標變數都佔用4個位元組,但是&i-&j=-12,並非想象中的4。
原因有二:
一是區域性變數儲存在棧空間,棧在主存中的生長方向是從高地址到低地址,因此i和j的地址差為負數;
二是Debug模式下,int變數前後均新增4個位元組的除錯資訊,故一個int佔用了12位元組。模式設為Release,就會發現棧上連續定義的int變數,地址相差4個位元組。

(2)指標變數pi與int變數j地址間相差了24位元組,按照推理,如果引用r不佔用記憶體空間,那麼地址差應該為12位元組,這也說明了引用變數在記憶體佔用空間。

(3)將引用變數r理解成指標,間接的獲取r的地址並修改r的值,使r指向變數j。從引用的角度理解就是將引用r與j繫結。對r賦值,結果顯示j的值被修改。

以上程式碼是較為詭異,實際程式設計絕不提倡大家模仿。利用以上程式可以看出“引用“本身的確是一個變數,它存放被引用物件的地址。並且,利用特殊手段能夠找到這個引用變數的地址並修改其自身在記憶體中的值,從而實現與其他物件的繫結。

這個程式在VS環境下的Release模式,編譯不通過,會出現記憶體訪問衝突,無法通過引用變數r修改j的值,可能與 Release模式下編譯器對引用的優化有關。與此同時,該程式可移植性很差,在64bits平臺上,由指標轉換為int可能會發生截斷從而丟失資料。其次,如果引用變數前的變數不是int型,考慮到記憶體對齊等因素,要準確計算引用變數的地址不是一件容易的事,很可能跟具體的編譯器和執行環境相關。因此,研究此程式的目的是為了對引用變數的底層實現機制有所瞭解。在實際使用中,還是要遵循C++語言對引用制定的規範。

參考文獻