C++ 多繼承和虛繼承的內存佈局(轉)

轉自:http://www.oschina.net/translate/cpp-virtual-inheritancephp

警告. 本文有點技術難度,須要讀者瞭解C++和一些彙編語言知識。html

在本文中,咱們解釋由gcc編譯器實現多繼承和虛繼承的對象的佈局。雖然在理想的C++程序中不須要知道這些編譯器內部細節,但不幸的是多重繼承(特別是虛擬繼承)的實現方式有各類各樣的不太明確的結論(尤爲是,關於向下轉型指針,使用指向指針的指針,還有虛擬基類的構造方法的調用命令)。 若是你瞭解多重繼承是如何實現的,你就能預見到這些結論並運用到你的代碼中。並且,若是你關心性能,理解虛擬繼承的開銷也是很是有用的。最後,這頗有趣。 :-)c++


 

多重繼承

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

?
1
2
3
4
5
6
7
8
9
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)。api

 

Left、Right和Bottom在內存中是如何佈局的?讓咱們先看一個簡單的例子。Left和Right擁有以下的結構:函數

Left
Top::a
Left::b
   Right
    Top::a
    Right::c

請注意第一個屬性是從Top繼承下來的。這意味着在下面兩條語句後佈局

?
1
2
Left* left = <b> new </b> Left();
Top* top = left;
left和top指向了同一地址,咱們能夠把Left Object當成Top Object來使用(很明顯,Right與此也相似)。那Buttom呢?GCC的建議以下:
Bottom        
Left::Top::a
Left::b
Right::Top::a
Right::c
Bottom::d

若是咱們提高Bottom指針,會發生什麼事呢?
?
1
2
Bottom* bottom = <b> new </b> Bottom();
Left* left = bottom;

這段代碼工做正常。咱們能夠把一個Bottom的對象看成一個Left對象來使用,由於兩個類的內存部局是同樣的。那麼,若是將其提高爲Right呢?會發生什麼事?性能

?
1
Right* right = bottom;
爲了執行這條語句,咱們須要判斷指針的值以便讓它指向Bottom中對應的段。
  Bottom
  Left::Top::a
  Left::b
rightpoints to  Right::Top::a
  Right::c
  Bottom::d

通過這一步,咱們能夠像操做正常Right對象同樣使用right指針訪問bottom。雖然,bottom與right如今指向兩個不一樣的內存地址。出於完整性的緣故,思考一下執行下面這條語句時會出現什麼情況。
?
1
Top* top = bottom;
是的,什麼也沒有。這條語句是有歧義的:編譯器將會報錯。
?
1
error: `Top ' is an ambiguous base of `Bottom'
兩種方式能夠避免這樣的歧義
?
1
2
Top* topL = (Left*) bottom;
Top* topR = (Right*) bottom;
執行這兩條語句後,topL和left會指向一樣的地址,topR和right也會指向一樣的地址。

 
 

 

虛擬繼承

爲了不重複繼承Top,咱們必須虛擬繼承Top:測試

?
1
2
3
4
5
6
7
8
9
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的佈局,其中的一個(也許沒有)多是:網站

Bottom

Left::Top::a

Left::b

Right::c

Bottom::d



這個佈局的優勢是,佈局的第一部分與Left的佈局重疊了,這樣咱們就能夠很容易的經過一個Left指針訪問 Bottom類。但是咱們怎麼處理

?
1
Right* right = bottom;

咱們將哪一個地址賦給right呢? 通過這個賦值,若是right是指向一個普通的Right對象,咱們應該就能使用 right了。可是這是不可能的!Right自己的內存佈局是徹底不一樣的,這樣咱們就沒法像訪問一個"真正的"Right對象同樣,來訪問升級的Bottom對象。並且,也沒有其它(簡單的)能夠正常運做的Bottom佈局。

解決辦法是複雜的。咱們先給出解決方案,以後再來解釋它。

layout of Bottom

你應該注意到了這個圖中的兩個地方。第一,字段的順序是徹底不一樣的(事實上,差很少是相反的)。第二,有幾個vptr指針。這些屬性是由編譯器根據須要自動插入的(使用虛擬繼承,或者使用虛擬函數的時候)。編譯器也在構造器中插入了代碼,來初始化這些指針。


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

?
1
2
Bottom* bottom = <b> new </b> Bottom();
Left* left = bottom; <b> int </b> p = left->a;

第二個賦值使left指向了bottom的所在地址(即,它指向了Bottom對象的「頂部」)。咱們想一想最後一條賦值語句的編譯狀況(稍微簡化了):

?
1
2
3
4
5
6
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字段。


 

通過這個設置,咱們就能夠一樣的方法訪問Right部分。按這樣

?
1
2
Bottom* bottom = <b> new </b> Bottom();
Right* right = bottom; <b> int </b> p = right->a;

以後right將指向Bottom對象的合適的部位:

  Bottom
  vptr.Left
  Left::b
rightpoints to  vptr.Right
  Right::c
  Bottom::d
  Top::a

對top的賦值如今能夠編譯成像前面Left一樣的方式。惟一的不一樣就是如今的vptr是指向了虛擬表的不一樣部位:取得的虛擬表偏移是12,這徹底正確(肯定!)。咱們能夠將其圖示歸納:virtual table

固然,這個例子的目的就是要像訪問真正Right對象同樣訪問升級的Bottom對象。所以,咱們必須也要給Right(和Left)佈局引入vptrs:

layout of Left and Right

如今咱們就能夠經過一個Right指針,一點也不費事的訪問Bottom對象了。不過,這是付出了至關大的代價:咱們要引入虛擬表,類須要擴展一個或更多個虛擬指針,對一個對象的一個簡單屬性的查詢如今須要兩次間接的經過虛擬表(即便編譯器某種程度上能夠減少這個代價)。


 

向下轉換

如咱們所見,將一個派生類的指針轉換爲一個父類的指針(或者說,向上轉換)可能涉及到給指針增添一個偏移。有人可能會想了,這樣向下轉換(反方向的)就能夠簡單的經過減去一樣的偏移來實現。確實,對非虛擬繼承來講是這樣的。但是,虛擬繼承(絕不奇怪的!)帶來了另外一種複雜性。

假設咱們像下面這個類這樣擴展繼承層次。
?
1
2
3
class AnotherBottom :  public Left,  public Right
public int e;  int f;
};

繼承層次如今看起來是這樣

class hierarchy

如今考慮一下下面的代碼。

?
1
2
3
4
5
Bottom* bottom1 =  new Bottom();
AnotherBottom* bottom2 =  new AnotherBottom();
Top* top1 = bottom1;
Top* top2 = bottom2;
Left* left =  static_cast <Left*>(top1);

 

下圖顯示了Bottom和AnotherBottom的佈局,並且在最後一個賦值後面顯示了指向top的指針。

  Bottom
  vptr.Left
  Left::b
  vptr.Right
  Right::c
  Bottom::d
top1points to  Top::a
  AnotherBottom
  vptr.Left
  Left::b
  vptr.Right
  Right::c
  AnotherBottom::e
  AnotherBottom::f
top2points to  Top::a

 


 

如今考慮一下怎麼去實現從top1到left的靜態轉換,同時要想到,咱們並不知道top1是否指向一個Bottom類型的對象,或者是指向一個AnotherBottom類型的對象。因此這辦不到!這個重要的偏移依賴於top1運行時的類型(Bottom則20,AnotherBottom則24)。編譯器將報錯:

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

由於咱們須要運行時的信息,因此應該用一個動態轉換來替代實現:

?
1
Left* left = <b> dynamic_cast <</b>Left*<b>></b>(top1);

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

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

(注:polymorphic多態的)

問題在於,動態轉換(轉換中使用到typeid)須要top1所指向對象的運行時類型信息。可是,若是你看看這張圖,你就會發現,在top1指向的位置,咱們僅僅只有一個integer (a)而已。編譯器沒有包含指向Top的虛擬指針,由於它不認爲這是必需的。爲了強制編譯器包含進這個vptr指針,咱們能夠給Top增長一個虛擬的析構器:

?
1
2
3
<b> class </b> Top
{ <b> public </b>: <span><b> virtual </b> ~Top() {}</span> <b> int </b> a;
};

這個修改須要指向Top的vptr指針。Bottom的新佈局是

layout of Bottom

(固然相似的其它類也有一個新的指向Top的vptr指針)。如今編譯器爲動態轉換插進了一個庫調用:

?
1
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 的具體實現 。


 

總結語

最後,咱們來看看一些沒告終的部分。

指針的指針

這裏出現了一點使人迷惑的問題,可是若是你仔細思考下一的話它其實很簡單。咱們來看一個例子。假設使用上一節用到的類層次結構(向下類型轉換).在前面的小節咱們已經看到了它的結果:

?
1
2
Bottom* b = <b> new </b> Bottom();
Right* r = b;
(在將b的值賦給r以前,須要將它調整8個字節,從而讓它指向Bottom對象的Right部分).所以,咱們能夠合法地將一個Bottom* 賦值給一個Right*的指針。可是Bottom**和Right**又會怎樣呢? 
?
1
2
Bottom** bb = &b;
Right** rr = bb;

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

?
1
error: invalid conversion from `Bottom** ' to `Right**'
爲何呢?假設編譯器能夠接受從bb到rr的賦值。咱們能夠只管的看到結果以下: 
?
1

所以,bb和rr都指向b,而且b和r指向Bottom對象的正確的章節。如今考慮當咱們賦值給*rr時會發生什麼(注意*rr的類型時Right*,所以這個賦值是有效的):

?
1
*rr = b;   

這樣的賦值和上面的賦值給r在根本上是一致的。所以,編譯器會用一樣的方式實現它!特別地,它會在賦值給*rr以前將b的值調整8個字節。辦事*rr指向的是b!咱們再一次圖示化這個結果:

bb points to the wrong part of Bottom

只要咱們經過*rr來訪問Bottom對象這都是正確的,可是隻要咱們經過b自身來訪問它,全部的內存引用都會有8個字節的偏移---明顯這是個不理想的狀況。

所以,總的來講,及時*a 和*b經過一些子類型相關,**aa和**bb倒是不相關的。


虛擬基類的構造函數

編譯器必須確保對象的全部虛指針都被正確的初始化。特別是,編譯器確保了類的全部虛基類都被調用,而且只被調用一次。若是你不顯示地調用虛擬超類(無論他們在繼承層次結構中的距離有多遠),編譯器都會自動地插入調用他們缺省構造函數。

這樣也會引來一些不能夠預期的錯誤。以上面給出的類層次結構做爲示例,並添加上構造函數的部分:

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<b> class </b> Top
{ <b> public </b>:
    Top() { a = -1; }
    Top(<b> int </b> _a) { a = _a; } <b> int </b> a;
}; <b> class </b> Left : <b> public </b> Top
{ <b> public </b>:
    Left() { b = -2; }
    Left(<b> int </b> _a, <b> int </b> _b) : Top(_a) { b = _b; } <b> int </b> b;
}; <b> class </b> Right : <b> public </b> Top
{ <b> public </b>:
    Right() { c = -3; }
    Right(<b> int </b> _a, <b> int </b> _c) : Top(_a) { c = _c; } <b> int </b> c;
}; <b> class </b> Bottom : <b> public </b> Left, <b> public </b> Right
{ <b> public </b>:
    Bottom() { d = -4; }
    Bottom(<b> int </b> _a, <b> int </b> _b, <b> int </b> _c, <b> int </b> _d) : Left(_a, _b), Right(_a, _c)
     {
       d = _d;
     } <b> int </b> d;
};
(首先考慮非虛擬的狀況。)你會指望下面的代碼段輸出什麼:
?
1
2
3
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 1 2 3 4
然而,如今考慮虛擬的狀況(咱們虛擬繼承自Top類)。若是咱們僅僅作那樣一個改變,並再一次運行程序,咱們會獲得:
?
1
-1 -1 2 3 4
爲何呢?經過跟蹤構造函數的執行,會發現:
?
1
2
3
4
Top::Top()
Left::Left(1,2)
Right::Right(1,3)
Bottom::Bottom(1,2,3,4)
就像上面解釋的同樣,編譯器在Bottom類執行其餘構造函數以前中插入調用了缺省構造函數。 而後,當Left去調用它自身的超類的構造函數時(Top),咱們會發現Top已經被初始化了所以構造函數不會被調用。

爲了不這種狀況,你應該顯示的調用虛基類的構造函數:

?
1
2
3
4
Bottom( int _a,  int _b,  int _c,  int _d): Top(_a), Left(_a,_b), Right(_a,_c)
{
    d = _d;
}

指針等價

再假設一樣的(虛擬)類繼承等級,你但願這樣就打印「相等」嗎?

?
1
2
3
Bottom* b =  new Bottom();
Right* r = b;  if (r == b)
    printf ( "Equal!\n" );

 

記住這兩個地址並不實際相等(r偏移了8個字節)。可是這應該對用戶徹底透明;所以,實際上編譯器在r與b比較以前,就給r減去了8個字節;這樣,這兩個地址就被認爲是相等的了。


轉換爲void類型的指針

最後,咱們來思考一下當將一個對象轉換爲void類型的指針時會發生什麼事情。編譯器必須保證一個指針轉換爲void類型的指針時指向對象的頂部。使用虛函數表這很容易實現。你可能已經想到了指向top域的偏移量是什麼。它是虛函數指針到對象頂部的偏移量。所以,轉化爲void類型的指針操做可使用查詢虛函數表的方式來實現。然而必定要確保使用動態類型轉換,以下:

dynamic_cast<void*>(b);

參考文獻

[1] CodeSourcery, 特別是C++ ABI SummaryItanium C++ ABI(不考慮名字,這些文檔是在平臺無關的上下文中引用的;特別低,structure of the vtables給出了虛函數表的詳細信息)。

libstdc++實現的動態類型轉化,和同RTTI和命名調整定義在 tinfo.cc中。

[2]libstdc++ 網站,特別是 C++ Standard Library API這一章節。

[3]Jan Gray 寫的C++: Under the Hood 

[4]Bruce Eckel的Thinking in C++(第二卷) 第9章"多重繼承"。 做者容許下載這本書download.

相關文章
相關標籤/搜索