C++中“引用”的底層實現


    【聲明】本文無技術含量!在博客園上回復某個帖子,招來他的非議,我不想去細究這個人的治學態度,不想去問去管他到底有沒有修改過自己的文章,對我來說沒必要。我只能說不負責任,態度自大的,不嚴謹的人是令我失望的。但是對於一個問題,這里涉及到了“引用”,這是C++引入的一種新的形式,可以說是給程序員的一個語法上的好處,但是我翻看了BS的《The C++ Programming Lanuage》,並沒有看到對引用的實現的解釋。所以雖然我一直默認為引用是這樣實現的,但在對別人提出自己的觀點之前,我需要驗證自己的“猜想”。這個問題很好去驗證,所以我先給出一個最簡單的試驗:用一個 int 類型的引用來說明問題。

 

    輸入下面的代碼,我使用的是VC6.0:

void ModifyNum(int& x)
{
    x = x + 10;
}

int main(int argc, char* argv[])
{
    int a = 5;
    ModifyNum(a);
    printf("a=%d\n", a);
    return 0;
}

 

 

    上面的代碼將輸出 a = 15,然后用 IDA 打開編譯后的 exe 文件,查看函數 main 和 ModifyNum 的代碼:

.text:00401060 main            proc near               ; CODE XREF: j_mainj
.text:00401060
.text:00401060 var_44          = dword ptr -44h
.text:00401060 var_4           = dword ptr -4
.text:00401060
.text:00401060                 push    ebp
.text:00401061                 mov     ebp, esp
.text:00401063                 sub     esp, 44h
.text:00401066                 push    ebx
.text:00401067                 push    esi
.text:00401068                 push    edi

.text:00401078                 mov     [ebp+var_4], 5
.text:0040107F                 lea eax, [ebp+var_4] .text:00401082                 push eax .text:00401083                 call    j_ModifyNum
.text:00401088                 add     esp, 4
.text:0040108B                 mov     ecx, [ebp+var_4]
.text:0040108E                 push    ecx
.text:0040108F                 push    offset ??_C@_06DJNL@a?$DN?$CFd?$CB?6?$AA@ ; "a=%d!\n"
.text:00401094                 call    printf
.text:00401099                 add     esp, 8

//eax = 0 , return 0;
.text:0040109C                 xor     eax, eax
.text:0040109E                 pop     edi
.text:0040109F                 pop     esi
.text:004010A0                 pop     ebx
.text:004010A1                 add     esp, 44h
.text:004010A4                 cmp     ebp, esp
.text:004010A6                 call    __chkesp
.text:004010AB                 mov     esp, ebp
.text:004010AD                 pop     ebp
.text:004010AE                 retn
.text:004010AE main            endp

  

    注意上面的黃色背景的代碼,顯然函數的參數在底層上是把 int 變量的地址 (int*)作為參數傳遞的。那么 ModifyNum 的代碼實際上不看也就能猜到了,它和 ModifyNum ( int* pX) 應該是一樣的。這個代碼很好找,在代碼段(.text)的頂部,緊跟跳轉表后面依次是 ModifyNum, main, printf, mainCRTStartup (即 PE 文件頭中記錄的入口點) 這幾個函數。

 

.text:00401020 ModifyNum       proc near               ; CODE XREF: j_ModifyNumj
.text:00401020
.text:00401020 var_40          = dword ptr -40h
.text:00401020 arg_0           = dword ptr  8
.text:00401020
.text:00401020                 push    ebp
.text:00401021                 mov     ebp, esp
.text:00401023                 sub     esp, 40h
.text:00401026                 push    ebx
.text:00401027                 push    esi
.text:00401028                 push    edi

.text:00401038                 mov     eax, [ebp+arg_0]
.text:0040103B                 mov     ecx, [eax]
.text:0040103D                 add     ecx, 0Ah
.text:00401040                 mov     edx, [ebp+arg_0]
.text:00401043                 mov     [edx], ecx
.text:00401045                 pop     edi
.text:00401046                 pop     esi
.text:00401047                 pop     ebx
.text:00401048                 mov     esp, ebp
.text:0040104A                 pop     ebp
.text:0040104B                 retn
.text:0040104B ModifyNum       endp

 

    上面的代碼的實現顯然就是針對指針操作,也就是說,ModifyNum 的實現相當於:

 

void ModifyNum(int* pX)
{
    *pX = *pX + 10;
}

 

    我也觀察了下面的代碼在匯編級別的實現(匯編代碼就不貼了):

    int a =5;

    int& b = a;

    這里在匯編級別,b 相當於是一個 int* 類型的臨時變量,和 int* b = &a 等效。當然在語言層面上我們可以理解成“b 是 a 的別名,b 就是 a”,只是看起來是這樣,但它並不是實現,尤其是作為參數傳遞的時候編譯器只能使用指針去實現。而且非常重要的是,b 作為 a 的引用,它是一個指向 a 的指針變量,它是需要在棧上額外占用存儲空間的(如果理解成別名,有可能會誤以為 b 不需要占用存儲空間,這是不確切的)。

 

也就是說, C++中引用是編譯器通過指針實現的,但這個實現在語言層面對程序員做了透明化處理。

 

    很顯然,在C++里,如果一個函數需要使用(讀)一個比較大的對象中的數據(而不是修改它),和在棧上構造出一個臨時拷貝比起來,傳遞他的指針/引用是更高效的,《The C++ Programming Language》這本書中指出,這種情況,參數類型應該加 const 即 const T&,這在語義上明確表示,你僅僅是使用而不是修改它。相對的,如果不加 const,則意味着你想明確的在函數中修改對象。在規模越大的項目中,這種約定對代碼可理解性起到的作用越大。

 

    現在很多上層表述有一些“按引用傳遞”,“按值傳遞”,這種表述,我想它是比較模糊一點的,他們實際上意味着前者是傳遞了對象的地址,在函數中因此可以修改對象,即為所謂的引用。后者,按值傳遞,意味着棧上是一個對象的拷貝,所以在函數中修改的是棧上的臨時對象(當然臨時對象是沒必要修改的),而不能影響函數以外的那個對象。由於參數是通過棧通知給函數,所以只有“拷貝”(即 push)這個“傳遞”動作(所以你可以說底層上不存在前述的那些說法,那只是站在函數調用功能的上層角度來說的,而函數調用的底層實現只有按值傳遞一種,不管數據是從那里 push 到棧上的,棧上的數據都是從函數外傳入,且之后對棧上參數的值的修改和傳入的那個“源”無關),“按引用”和“按值”指的主要是參數意義(以及函數如何使用參數,這和參數意義是相關的)。例如,如果參數是一個地址,你可以通過這個地址讀寫它指向的對象即按引用方式,而你對這個地址的修改是無意義的,不會影響到函數以外的任何指針變量之類的東西。所以如果你想在函數里修改一個整數,傳遞它的地址,即整數的指針。如果修改一個指針,傳遞指針的地址,即指針的指針,修改一個對象,傳遞它的地址,。。。不論你想改的是什么(T),傳遞它的地址(參數類型是 T*),而不是它的值(拷貝),然后在函數里去解析引用(dereference)。

 

    順着這個話題說下去,說的更精確一些。在 C# 里,假設一個對象 T,一個函數 void foo(T t); 存在下面的代碼:

    T t = new T(); //或者 T t = null;

    foo(t);

    如果 T 是引用類型(class),它是按引用傳遞的,函數可以修改 T 的成員變量,但是不能修改 T 的指向。即函數foo調用后,t 的指向不會發生變化,依然是原來的對象,不能從 null 變為 其他對象,也不會被修改為 null。

    如果 T 是值類型(struct),它是按值傳遞的,函數對 T 的成員的修改只是針對棧上的臨時拷貝,而不會影響外面的 t。例如如下 c# 代碼:

 

struct StructA
{
    public int num;
    public int x;
    public int y;
    public StructA(int _n)
    {
        num = _n;
        x = 0;
        y = 0;
    }
    public StructA(int _n, int _x)
    {
        num = _n;
        x = _x;
        y = 0;
    }
}

static void foo3(StructA a)
{
    a.num = 300;  
}
static void foo4(ref StructA a)
{
    //a = new StructA(50, 1000);
a.num = 400; } static void Main(string[] args) { StructA a = new StructA(10); foo3(a); Console.WriteLine("a.num = {0}", a.num); //a.num=10 foo4(ref a); Console.WriteLine("a.x = {0}", a.x); Console.WriteLine("a.num = {0}", a.num); //a.num=400 }

 

    由於 foo3 函數中 StructA 是“按值傳遞”,所以函數內對對象的修改並不能影響到函數之外的那個“源對象”。加了 ref 參數以后,它相當於是引用類型的“按引用傳遞”。對於引用類型的對象來說,ref 使函數中不僅可以修改對象的成員,還可以修改 a 的指向指向另一個對象,也就是可以修改“指向”和“被指向對象的內容”[1]。 

 

    out 參數的應用場景更加明確,要求函數必須明確的修改一個指針的指向或者值類型的值。而對 ref 來說,對被指向的變量(注意這個變量通常已經是一個對象的指針)的使用是自由的,即你可以不修改而僅僅使用它。相對於 out ,用處是,就是一個對象在傳入函數時可能沒有賦值過(可能是 null ),在函數里如果發現它是 null 就創建它(要求影響到外部變量),其他情況我們使用或修改它,這時候就應該加 ref 了。

 

    在C#中由於完全 OO 的需要,所以隱藏了指針,而代之以“引用類型”的對象,所以對於一個引用類型的對象 T,在C++里相當於對象T 的指針,在參數上加 ref 在 C++ 里相當於指針的指針,即二級指針 T**。所以如果參數類型加 ref 意味着要修改一個指針變量的指向,這也就意味着你想在函數里對函數以外的那個對象重新賦值,即讓它指向其他對象或者 null。如果僅僅修改或讀取對象 T 的成員,就無須加 ref,因為引用型對象本身已經是指針了!這是在函數參數前面是否加 ref 的應用場景,對於 C++ 因為必須有內存模型的概念,所以這非常自然,可以毫無歧義的理解清楚。但對於 .net 程序員可能很難搞清楚這里的原因和區別。

 

    希望每個人都能嚴謹的對待技術,而不是覺得自己非常了不起,聽不進任何批評意見。PS:我盡可能去除了本文中的主觀評論成分。

 


    [1]  上面給出的范例代碼中值類型的 a 在 C++ 對應着什么取決於 C# 底層的內存管理,根據 MSDN 的說法:

    “值類型對象(例如結構)是在堆棧上創建的,而引用類型對象(例如類)是在堆上創建的。兩種類型的對象都是自動銷毀的,但是,基於值類型的對象是在超出范圍時銷毀,而基於引用類型的對象則是在對該對象的最后一個引用被移除之后在某個不確定的時間銷毀。對於占用固定資源(例如大量內存、文件句柄或網絡連接)的引用類型,有時需要使用確定性終止以確保對象被盡快銷毀。有關更多信息,請參見 using 語句(C# 參考) ”。

    “基於值類型的對象是在超出范圍時銷毀”,這句話印證了值類型對象是分配在函數的棧上的,因此一旦離開函數,這個對象占用的空間也就會被釋放,所以被銷毀的時間是確定的,即函數返回的時刻。而堆上的對象,由於依賴 GC 回收,所以銷毀的時間是不確定的。

    值類型的 a 分配在棧上,相當於棧上臨時對象,也就是 a 在 c++ 中對應的是對象本身(非指針),因此加了 ref 對應的是指針。所以 c# 中的值類型即使加了 ref 也是無法修改指向的(因為 c# 的語法達不到 c++ 中的二級指針,當然這個操作對“值類型”來說也沒什么必要,“指向一個值類型的指針變量”和“改變指向”屬於建立在“線性內存”模型上的說法,在完全面向對象時,沒有這個模型,也沒有“指向值類型對象的指針”的概念,也就無所謂有“修改指向”這個需求),而只能修改指向的對象的內容。當然這是由值類型的語言意義決定的,在 c++ 中 struct 的主要是數據封裝手段,並沒有上升到“值類型”這種語言意義高度去特殊看待,所以 c++ 中的struct,可以分配到棧上,堆上,其指針也是能通過函數修改指向的,因為它沒有語言上的特殊含義,所以和 class 的操作沒有特別區別。而在 c# 中的 struct 被賦予“值類型”的語言級別的意義,一些語言層次的代碼的含義就會不同,例如比較和賦值。

    如果函數 foo4 中調用 a = new StructA(...),則這句話修改的並不是 a 的指向,而是把一個新的臨時對象的內容拷貝到 a,因為 a 是值類型,賦值操作是一種內容拷貝。例如下面的代碼中b = a 的賦值代碼的效果,如果是值類型,a,b 是兩個獨立對象,如果是引用類型,a,b 將指向同一個對象:

 

    StructA a = new StructA();
          a.num = 1980;
          StructA b = a;    //因為StructA是值類型,因此 b 獨立於 a 的另一個結構體!
          b.num = 2012;
          Console.WriteLine("a.num = {0}", a.num);  //a.num = 1980
          Console.WriteLine("b.num = {0}", b.num);  //b.num = 2012


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM