1. 程式人生 > >C++ 多繼承和虛繼承的記憶體佈局(Memory Layout for Multiple and Virtual Inheritance )

C++ 多繼承和虛繼承的記憶體佈局(Memory Layout for Multiple and Virtual Inheritance )

C++ 多繼承和虛繼承的記憶體佈局

在本文中,我們解釋由gcc編譯器實現多繼承和虛繼承的物件的佈局。雖然在理想的C++程式中不需要知道這些編譯器內部細節,但不幸的是多重繼承(特別是虛擬繼承)的實現方式有各種各樣的不太明確的結論(尤其是,關於向下轉型指標,使用指向指標的指標,還有虛擬基類的構造方法的呼叫命令)。 如果你瞭解多重繼承是如何實現的,你就能預見到這些結論並運用到你的程式碼中。而且,如果你關心效能,理解虛擬繼承的開銷也是非常有用的。最後,這很有趣。 :-)

  • 多重繼承(Multiple Inheritance)
  • 虛繼承(Virtual Inheritance)
  • 向下轉型(Downcasting)
  • 總結(Concluding Remarks)
  • 參考(References)

多重繼承(Multiple Inheritance)

首先我們考慮一個(非虛擬)多重繼承的相對簡單的例子。看看下面的C++類層次結構。

class Top
{
public:
   int a;
};

class Left : public Top
{
public:
   int b;
};

class Right : public Top
{
public:
   int c;
};

class Bottom : public Left, public Right
{
public:
   int
d; };

使用UML圖,我們可以把這個層次結構表示為:
這裡寫圖片描述
注意Top被繼承了兩次(在Eiffel語言中這被稱作重複繼承)。這意味著型別Bottom的一個例項bottom將有兩個叫做a的元素(分別為bottom.Left::a和bottom.Right::a)。

Left、Right和Bottom在記憶體中是如何佈局的?讓我們先看一個簡單的例子。Left和Right擁有如下的結構:
這裡寫圖片描述
請注意第一個屬性是從Top繼承下來的。這意味著在下面兩條語句後

Left* left = new Left();
Top* top = left;

left和top指向了同一地址,我們可以把Left Object當成Top Object來使用(很明顯,Right與此也類似)。那Buttom呢?GCC的建議如下:
這裡寫圖片描述


如果我們提升Bottom指標,會發生什麼事呢?

Bottom* bottom = new Bottom();
Left* left = bottom;

這段程式碼工作正常。我們可以把一個Bottom的物件當作一個Left物件來使用,因為兩個類的記憶體部局是一樣的。那麼,如果將其提升為Right呢?會發生什麼事?

Right* right = bottom;

為了執行這條語句,我們需要判斷指標的值以便讓它指向Bottom中對應的段。
這裡寫圖片描述
經過這一步,我們可以像操作正常Right物件一樣使用right指標訪問bottom。雖然,bottom與right現在指向兩個不同的記憶體地址。出於完整性的緣故,思考一下執行下面這條語句時會出現什麼狀況。

Top* top = bottom;

是的,什麼也沒有。這條語句是有歧義的:編譯器將會報錯。

從“Bottom *”到“Top *”的轉換不明確

兩種方式可以避免這樣的歧義:

Top* topL = (Left*) bottom;
Top* topR = (Right*) bottom;

執行這兩條語句後,topL和left會指向同樣的地址,topR和right也會指向同樣的地址。

虛繼承

為了避免重複繼承Top,我們必須虛擬繼承Top:

class Top
{
public:
   int a;
};

class Left : virtual public Top
{
public:
   int b;
};

class Right : virtual public Top
{
public:
   int c;
};

class Bottom : public Left, public Right
{
public:
   int d;
};

這就得到了如下的層次結構(也許是你一開始就想得到的)
這裡寫圖片描述
雖然從程式設計師的角度看,這也許更加的明顯和簡便,但從編譯器的角度看,這就變得非常的複雜。重新考慮下Bottom的佈局,其中的一個(也許沒有)可能是:
這裡寫圖片描述
這個佈局的優點是,佈局的第一部分與Left的佈局重疊了,這樣我們就可以很容易的通過一個Left指標訪問 Bottom類。可是我們怎麼處理

Right* right = bottom;

我們將哪個地址賦給right呢? 經過這個賦值,如果right是指向一個普通的Right物件,我們應該就能使用 right了。但是這是不可能的!Right本身的記憶體佈局是完全不同的,這樣我們就無法像訪問一個”真正的”Right物件一樣,來訪問升級的Bottom物件。而且,也沒有其它(簡單的)可以正常運作的Bottom佈局。
解決辦法是複雜的。我們先給出解決方案,之後再來解釋它。
這裡寫圖片描述
你應該注意到了這個圖中的兩個地方。第一,欄位的順序是完全不同的(事實上,差不多是相反的)。第二,有幾個vptr指標。這些屬性是由編譯器根據需要自動插入的(使用虛擬繼承,或者使用虛擬函式的時候)。編譯器也在構造器中插入了程式碼,來初始化這些指標。

vptr (virtual pointers)指向一個 “虛擬表”。類的每個虛擬基類都有一個vptr指標。要想知道這個虛擬表 (vtable)是怎樣運用的,看看下面的C++ 程式碼。

Bottom* bottom = new Bottom();
Left* left = bottom;
int p = left->a;

第二個賦值使left指向了bottom的所在地址(即,它指向了Bottom物件的“頂部”)。我們想想最後一條賦值語句的編譯情況(稍微簡化了):

movl  left, %eax        # %eax = left
movl  (%eax), %eax      # %eax = left.vptr.Left
movl  (%eax), %eax      # %eax = virtual base offset 
addl  left, %eax        # %eax = left + virtual base offset
movl  (%eax), %eax      # %eax = left.a
movl  %eax, p           # p = left.a

用語言來描述的話,就是我們用left指向虛擬表,並且由它獲得了“虛擬基類偏移”(vbase)。這個偏移之後就加到了left,然後left就用來指向Bottom物件的Top部分。從這張圖你可以看到Left的虛擬基類偏移是20;如果假設Bottom中的所有欄位都是4個位元組,那麼給left加上20位元組將會確實指向a欄位。
【PS:對bottom中的資料結構在VS2012下進行測試,程式碼如下

    Bottom* bottom = new Bottom();  
    cout<<"bottom "<<bottom<<endl;  
    cout<<"bottom->a "<<&(bottom->a)<<endl;
    cout<<"bottom->b "<<&(bottom->b)<<endl; 
    cout<<"bottom->c "<<&(bottom->c)<<endl;
    cout<<"bottom->d "<<&(bottom->d)<<endl;
結果如下:
bottom 0081C6B0
bottom->a 0081C6C4
bottom->b 0081C6B4
bottom->c 0081C6BC
bottom->d 0081C6C0

說明:a相對於bottom偏移20,b相對於bottom偏移4,c相對於bottom偏移12,d相對於bottom偏移16。那麼Bottom中的資料結構如下:

偏移        數值
0          Bottom::Left::vptr
4          Bottom::Left::b
8          Bottom::Right::vptr
12         Bottom::Right::c
16         Bottom::d
20         Bottom::top::a

經過這個設定,我們就可以同樣的方法訪問Right部分。按這樣

Bottom* bottom = new Bottom();
Right* right = bottom;
int p = right->a;

之後right將指向Bottom物件的合適的部位:
這裡寫圖片描述
對top的賦值現在可以編譯成像前面Left同樣的方式。唯一的不同就是現在的vptr是指向了虛擬表的不同部位:取得的虛擬表偏移是12,這完全正確(確定!)。我們可以將其圖示概括:
這裡寫圖片描述
當然,這個例子的目的就是要像訪問真正Right物件一樣訪問升級的Bottom物件。因此,我們必須也要給Right(和Left)佈局引入vptrs:
這裡寫圖片描述
現在我們就可以通過一個Right指標,一點也不費事的訪問Bottom物件了。不過,這是付出了相當大的代價:我們要引入虛擬表,類需要擴充套件一個或更多個虛擬指標,對一個物件的一個簡單屬性的查詢現在需要兩次間接的通過虛擬表(即使編譯器某種程度上可以減小這個代價)。

向下轉型(Downcasting)

如我們所見,將一個派生類的指標轉換為一個父類的指標(或者說,向上轉換)可能涉及到給指標增添一個偏移。有人可能會想了,這樣向下轉換(反方向的)就可以簡單的通過減去同樣的偏移來實現。確實,對非虛擬繼承來說是這樣的。可是,虛擬繼承(毫不奇怪的!)帶來了另一種複雜性。
假設我們像下面這個類這樣擴充套件繼承層次。

class AnotherBottom : public Left, public Right
{
public:
   int e;
   int f;
};

繼承層次現在看起來是這樣:
這裡寫圖片描述
考慮如下程式碼:

Bottom* bottom1 = new Bottom();
AnotherBottom* bottom2 = new AnotherBottom();
Top* top1 = bottom1;
Top* top2 = bottom2;
Left* left = static_cast<Left*>(top1);

下圖顯示了Bottom和AnotherBottom的佈局,而且在最後一個賦值後面顯示了指向top的指標。
這裡寫圖片描述
現在考慮一下怎麼去實現從top1到left的靜態轉換,同時要想到,我們並不知道top1是否指向一個Bottom型別的物件,或者是指向一個AnotherBottom型別的物件。所以這辦不到!這個重要的偏移依賴於top1執行時的型別(Bottom則20,AnotherBottom則24)。編譯器將報錯:

error: cannot convert from base `Top' to derived type `Left'
via virtual base `Top'

因為我們需要執行時的資訊,所以應該用一個動態轉換來替代實現:

Left* left = dynamic_cast<Left*>(top1);

可是,編譯器仍然不滿意:

error: cannot dynamic_cast `top' (of type `class Top*') to type 
   `class Left*' (source type is not polymorphic)

問題在於,動態轉換(轉換中使用到typeid)需要top1所指向物件的執行時型別資訊。但是,如果你看看這張圖,你就會發現,在top1指向的位置,我們僅僅只有一個integer (a)而已。編譯器沒有包含指向Top的虛擬指標,因為它不認為這是必需的。為了強制編譯器包含進這個vptr指標,我們可以給Top增加一個虛擬的析構器:

class Top
{
public:
   virtual ~Top() {} 
   int a;
};

這個修改需要指向Top的vptr指標。Bottom的新佈局是:
這裡寫圖片描述
(當然類似的其它類也有一個新的指向Top的vptr指標)。現在編譯器為動態轉換插進了一個庫呼叫:

left = __dynamic_cast(top1, typeinfo_for_Top, typeinfo_for_Left, -1);

這個函式__dynamic_cast定義在stdc++庫中(相應的標頭檔案是cxxabi.h);引數為Top的型別資訊,Left和Bottom(通過vptr.Top),這個轉換可以執行。 (引數 -1 標示出Left和Top之間的關係現在還是未知)。更多詳細資料,請參考tinfo.cc 的具體實現 。

總結(Concluding Remarks)

最後,我們來看看一些沒了結的部分。

指標的指標((In)variance of Double Pointers)
這裡出現了一點令人迷惑的問題,但是如果你仔細思考下一的話它其實很簡單。我們來看一個例子。假設使用上一節用到的類層次結構(向下型別轉換).在前面的小節我們已經看到了它的結果:

Bottom* b = new Bottom();
Right* r = b;

(在將b的值賦給r之前,需要將它調整8個位元組,從而讓它指向Bottom物件的Right部分).因此,我們可以合法地將一個Bottom* 賦值給一個Right*的指標。但是Bottom**和Right**又會怎樣呢?

Bottom** bb = &b;
Right** rr = bb;

編譯器會接受這樣的形式嗎?我們快速測試一下,編譯器會報錯:

error: invalid conversion from `Bottom**' to `Right**'

為什麼呢?假設編譯器可以接受從bb到rr的賦值。我們可以只管的看到結果如下:
這裡寫圖片描述
因此,bb和rr都指向b,並且b和r指向Bottom物件的正確的章節。現在考慮當我們賦值給rr時會發生什麼(注意*rr的型別時Right,因此這個賦值是有效的):

*rr = b;    

這樣的賦值和上面的賦值給r在根本上是一致的。因此,編譯器會用同樣的方式實現它!特別地,它會在賦值給*rr之前將b的值調整8個位元組。辦事*rr指向的是b!我們再一次圖示化這個結果:
這裡寫圖片描述
只要我們通過*rr來訪問Bottom物件這都是正確的,但是隻要我們通過b自身來訪問它,所有的記憶體引用都會有8個位元組的偏移—明顯這是個不理想的情況。
因此,總的來說,及時*a 和*b通過一些子型別相關,**aa和**bb卻是不相關的。

虛擬基類的建構函式(Constructors of Virtual Bases)
編譯器必須確保物件的所有虛指標都被正確的初始化。特別是,編譯器確保了類的所有虛基類都被呼叫,並且只被呼叫一次。如果你不顯示地呼叫虛擬超類(不管他們在繼承層次結構中的距離有多遠),編譯器都會自動地插入呼叫他們預設建構函式。
這樣也會引來一些不可以預期的錯誤。以上面給出的類層次結構作為示例,並新增上建構函式的部分:

class Top
{
public:
   Top() { a = -1; } 
   Top(int _a) { a = _a; } 
   int a;
};

class Left : public Top
{
public:
   Left() { b = -2; }
   Left(int _a, int _b) : Top(_a) { b = _b; }
   int b;
};

class Right : public Top
{
public:
   Right() { c = -3; }
   Right(int _a, int _c) : Top(_a) { c = _c; }
   int c;
};

class Bottom : public Left, public Right
{
public:
   Bottom() { d = -4; } 
   Bottom(int _a, int _b, int _c, int _d) : Left(_a, _b), Right(_a, _c) 
    { 
      d = _d; 
    }
   int d;
};

(首先考慮非虛擬的情況。)你會期望下面的程式碼段輸出什麼:

Bottom bottom(1,2,3,4);
printf("%d %d %d %d %d\n", bottom.Left::a, bottom.Right::a, 
   bottom.b, bottom.c, bottom.d);

你可能會希望得到下面的結果,並且也得到了下面的結果:

1 1 2 3 4

然而,現在考慮虛擬的情況(我們虛擬繼承自Top類)。如果我們僅僅做那樣一個改變,並再一次執行程式,我們會得到:

-1 -1 2 3 4

為什麼呢?通過跟蹤建構函式的執行,會發現:

Top::Top()
Left::Left(1,2)
Right::Right(1,3)
Bottom::Bottom(1,2,3,4)

就像上面解釋的一樣,編譯器在Bottom類執行其他建構函式之前中插入呼叫了預設建構函式。 然後,當Left去呼叫它自身的超類的建構函式時(Top),我們會發現Top已經被初始化了因此建構函式不會被呼叫。
為了避免這種情況,你應該顯示的呼叫虛基類的建構函式:

Bottom(int _a, int _b, int _c, int _d): Top(_a), Left(_a,_b), Right(_a,_c) 
{ 
   d = _d; 
}

指標相等(Pointer Equivalence)
再假設同樣的(虛擬)類繼承等級,你希望這樣就列印“相等”嗎?

Bottom* b = new Bottom(); 
Right* r = b;

if(r == b)
   printf("Equal!\n");

記住這兩個地址並不實際相等(r偏移了8個位元組)。但是這應該對使用者完全透明;因此,實際上編譯器在r與b比較之前,就給r減去了8個位元組;這樣,這兩個地址就被認為是相等的了(PS:VS2012下,列印r,b的地址,分別為005FC6B8 005FC6B0,證明上述結論)。

Casting to void*(轉換為void型別的指標)
最後,我們來思考一下當將一個物件轉換為void型別的指標時會發生什麼事情。編譯器必須保證一個指標轉換為void型別的指標時指向物件的頂部。使用虛擬函式表這很容易實現。你可能已經想到了指向top域的偏移量是什麼。它是虛擬函式指標到物件頂部的偏移量。因此,轉化為void型別的指標操作可以使用查詢虛擬函式表的方式來實現。然而一定要確保使用動態型別轉換,如下:

dynamic_cast<void*>(b);

參考(References)

[1]: CodeSourcery, in particular the C++ ABI Summary, the Itanium C++ ABI (despite the name, this document is referenced in a platform-independent context; in particular, the structure of the vtables is detailed here). The libstdc++ implementation of dynamic casts, as well RTTI and name unmangling/demangling, is defined in tinfo.cc.

[2]: The libstdc++ website, in particular the section on the C++ Standard Library API.

[3]: C++: Under the Hood by Jan Gray.

[4]: Chapter 9, “Multiple Inheritance” of Thinking in C++ (volume 2) by Bruce Eckel. The author has made this book available for download.