C++多繼承和虛繼承的內存布局(虛函數表不光有虛函數指針)


https://blog.csdn.net/yockie/article/details/50603236

 

警告. 本文有點技術難度,需要讀者了解C++和一些匯編語言知識。 
在本文中,我們解釋由gcc編譯器實現多繼承和虛繼承的對象的布局。雖然在理想的C++程序中不需要知道這些編譯器內部細節,但不幸的是多重繼承(特別是虛擬繼承)的實現方式有各種各樣的不太明確的結論(尤其是,關於向下轉型指針,使用指向指針的指針,還有虛擬基類的構造方法的調用命令)。 如果你了解多重繼承是如何實現的,你就能預見到這些結論並運用到你的代碼中。而且,如果你關心性能,理解虛擬繼承的開銷也是非常有用的。最后,這很有趣。 :-)

多重繼承

首先我們考慮一個(非虛擬)多重繼承的相對簡單的例子。看看下面的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擁有如下的結構:

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

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

Left* left = new 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指針,會發生什么事呢?

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

這段代碼工作正常。我們可以把一個Bottom的對象當作一個Left對象來使用,因為兩個類的內存部局是一樣的。那么,如果將其提升為Right呢?會發生什么事?

Right* right = bottom;

為了執行這條語句,我們需要判斷指針的值以便讓它指向Bottom中對應的段。

Bottom
Left::Top::a
Left::b
Right::Top::a <—rightpoints
Right::c
Bottom::d

經過這一步,我們可以像操作正常Right對象一樣使用right指針訪問bottom。雖然,bottom與right現在指向兩個不同的內存地址。出於完整性的緣故,思考一下執行下面這條語句時會出現什么狀況。

Top* top = bottom;

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

error: `Top' is an ambiguous base of `Bottom'

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

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的布局,其中的一個(也許沒有)可能是:

Bottom
Left::Top::a
Left::b
Right::c
Bottom::d

這個布局的優點是,布局的第一部分與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字段。

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

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

 

之后right將指向Bottom對象的合適的部位:

Bottom
vptr.Left
Left::b
vptr.Right <—right
Right::c
Bottom::d
Top::a

對top的賦值現在可以編譯成像前面Left同樣的方式。唯一的不同就是現在的vptr是指向了虛擬表的不同部位:取得的虛擬表偏移是12,這完全正確(確定!)。我們可以將其圖示概括:

這里寫圖片描述

當然,這個例子的目的就是要像訪問真正Right對象一樣訪問升級的Bottom對象。因此,我們必須也要給Right(和Left)布局引入vptrs:

這里寫圖片描述

現在我們就可以通過一個Right指針,一點也不費事的訪問Bottom對象了。不過,這是付出了相當大的代價:我們要引入虛擬表,類需要擴展一個或更多個虛擬指針,對一個對象的一個簡單屬性的查詢現在需要兩次間接的通過虛擬表(即使編譯器某種程度上可以減小這個代價)。

向下轉換

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

假設我們像下面這個類這樣擴展繼承層次。

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)

(注: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);
  • 1

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

總結語

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

指針的指針

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

Bottom* b = new Bottom(); Right* r = b;
  • 1
  • 2

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

Bottom** bb = &b; Right** rr = bb;
  • 1
  • 2

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

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

為什么呢?假設編譯器可以接受從bb到rr的賦值。我們可以只管的看到結果如下:

這里寫圖片描述

因此,bb和rr都指向b,並且b和r指向Bottom對象的正確的章節。現在考慮當我們賦值給rr時會發生什么(注意*rr的類型時Right,因此這個賦值是有效的):

*rr = b; 
  • 1

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

這里寫圖片描述

只要我們通過*rr來訪問Bottom對象這都是正確的,但是只要我們通過b自身來訪問它,所有的內存引用都會有8個字節的偏移—明顯這是個不理想的情況。 
因此,總的來說,及時*a 和*b通過一些子類型相關,**aa和**bb卻是不相關的。

虛擬基類的構造函數

編譯器必須確保對象的所有虛指針都被正確的初始化。特別是,編譯器確保了類的所有虛基類都被調用,並且只被調用一次。如果你不顯示地調用虛擬超類(不管他們在繼承層次結構中的距離有多遠),編譯器都會自動地插入調用他們缺省構造函數。 
這樣也會引來一些不可以預期的錯誤。以上面給出的類層次結構作為示例,並添加上構造函數的部分:

class Top { public: Top() { printf("Top()\n"); a = -1; } Top(int _a) { printf("Top(int _a=%d)\n", _a); a = _a; } int a; }; class Left : virtual public Top { public: Left() { printf("Left()\n"); b = -2; } Left(int _a, int _b) : Top(_a) { printf("Left(int _a=%d, int _b=%d)\n", _a, _b); b = _b; } int b; }; class Right : virtual public Top { public: Right() { printf("Right()\n"); c = -3; } Right(int _a, int _c) : Top(_a) { printf("Right(int _a=%d, int _c=%d)\n", _a, _c); c = _c; } int c; }; class Bottom : public Left, public Right { public: Bottom() { printf("Bottom()\n"); d = -4; } Bottom(int _a, int _b, int _c, int _d) : Left(_a, _b), Right(_a, _c) { printf("Bottom(int _a=%d, int _b=%d, int _c=%d, int _d=%d)\n", _a, _b, _c, _d); 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
  • 1

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

 -1 -1 2 3 4
  • 1

為什么呢?通過跟蹤構造函數的執行,會發現:

Top::Top() Left::Left(1,2) Right::Right(1,3) Bottom::Bottom(1,2,3,4)
  • 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; }
  • 1
  • 2
  • 3
  • 4

指針等價

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

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

記住這兩個地址並不實際相等(r偏移了8個字節)。但是這應該對用戶完全透明;因此,實際上編譯器在r與b比較之前,就給r減去了8個字節;這樣,這兩個地址就被認為是相等的了。

轉換為void類型的指針

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

dynamic_cast<void*>(b);
  • 1

參考文獻

[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.

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


免責聲明!

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



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