多重繼承首先我們考慮一個(非虛擬)多重繼承的相對簡單的例子。看看下面的C++類層次結構。
注意Top被繼承了兩次(在Eiffel語言中這被稱作重復繼承)。這意味着類型Bottom的一個實例bottom將有兩個叫做a的元素(分別為bottom.Left::a和bottom.Right::a)。 |
Left、Right和Bottom在內存中是如何布局的?讓我們先看一個簡單的例子。Left和Right擁有如下的結構:
請注意第一個屬性是從Top繼承下來的。這意味着在下面兩條語句后
如果我們提升Bottom指針,會發生什么事呢?
這段代碼工作正常。我們可以把一個Bottom的對象當作一個Left對象來使用,因為兩個類的內存部局是一樣的。那么,如果將其提升為Right呢?會發生什么事?
|
虛擬繼承為了避免重復繼承Top,我們必須虛擬繼承Top:
雖然從程序員的角度看,這也許更加的明顯和簡便,但從編譯器的角度看,這就變得非常的復雜。重新考慮下Bottom的布局,其中的一個(也許沒有)可能是: Bottom Left::Top::a Left::b Right::c Bottom::d |
這個布局的優點是,布局的第一部分與Left的布局重疊了,這樣我們就可以很容易的通過一個Left指針訪問 Bottom類。可是我們怎么處理
我們將哪個地址賦給right呢? 經過這個賦值,如果right是指向一個普通的Right對象,我們應該就能使用 right了。但是這是不可能的!Right本身的內存布局是完全不同的,這樣我們就無法像訪問一個"真正的"Right對象一樣,來訪問升級的Bottom對象。而且,也沒有其它(簡單的)可以正常運作的Bottom布局。 解決辦法是復雜的。我們先給出解決方案,之后再來解釋它。 你應該注意到了這個圖中的兩個地方。第一,字段的順序是完全不同的(事實上,差不多是相反的)。第二,有幾個vptr指針。這些屬性是由編譯器根據需要自動插入的(使用虛擬繼承,或者使用虛擬函數的時候)。編譯器也在構造器中插入了代碼,來初始化這些指針。 |
vptr (virtual pointers)指向一個 “虛擬表”。類的每個虛擬基類都有一個vptr指針。要想知道這個虛擬表 (vtable)是怎樣運用的,看看下面的C++ 代碼。
第二個賦值使left指向了bottom的所在地址(即,它指向了Bottom對象的“頂部”)。我們想想最后一條賦值語句的編譯情況(稍微簡化了):
用語言來描述的話,就是我們用left指向虛擬表,並且由它獲得了“虛擬基類偏移”(vbase)。這個偏移之后就加到了left,然后left就用來指向Bottom對象的Top部分。從這張圖你可以看到Left的虛擬基類偏移是20;如果假設Bottom中的所有字段都是4個字節,那么給left加上20字節將會確實指向a字段。 |
經過這個設置,我們就可以同樣的方法訪問Right部分。按這樣
之后right將指向Bottom對象的合適的部位:
對top的賦值現在可以編譯成像前面Left同樣的方式。唯一的不同就是現在的vptr是指向了虛擬表的不同部位:取得的虛擬表偏移是12,這完全正確(確定!)。我們可以將其圖示概括: 當然,這個例子的目的就是要像訪問真正Right對象一樣訪問升級的Bottom對象。因此,我們必須也要給Right(和Left)布局引入vptrs: 現在我們就可以通過一個Right指針,一點也不費事的訪問Bottom對象了。不過,這是付出了相當大的代價:我們要引入虛擬表,類需要擴展一個或更多個虛擬指針,對一個對象的一個簡單屬性的查詢現在需要兩次間接的通過虛擬表(即使編譯器某種程度上可以減小這個代價)。 |
向下轉換如我們所見,將一個派生類的指針轉換為一個父類的指針(或者說,向上轉換)可能涉及到給指針增添一個偏移。有人可能會想了,這樣向下轉換(反方向的)就可以簡單的通過減去同樣的偏移來實現。確實,對非虛擬繼承來說是這樣的。可是,虛擬繼承(毫不奇怪的!)帶來了另一種復雜性。 假設我們像下面這個類這樣擴展繼承層次。
繼承層次現在看起來是這樣 現在考慮一下下面的代碼。
下圖顯示了Bottom和AnotherBottom的布局,而且在最后一個賦值后面顯示了指向top的指針。
|
現在考慮一下怎么去實現從top1到left的靜態轉換,同時要想到,我們並不知道top1是否指向一個Bottom類型的對象,或者是指向一個AnotherBottom類型的對象。所以這辦不到!這個重要的偏移依賴於top1運行時的類型(Bottom則20,AnotherBottom則24)。編譯器將報錯:
因為我們需要運行時的信息,所以應該用一個動態轉換來替代實現:
可是,編譯器仍然不滿意:
(注:polymorphic多態的) 問題在於,動態轉換(轉換中使用到typeid)需要top1所指向對象的運行時類型信息。但是,如果你看看這張圖,你就會發現,在top1指向的位置,我們僅僅只有一個integer (a)而已。編譯器沒有包含指向Top的虛擬指針,因為它不認為這是必需的。為了強制編譯器包含進這個vptr指針,我們可以給Top增加一個虛擬的析構器:
這個修改需要指向Top的vptr指針。Bottom的新布局是 ![]() (當然類似的其它類也有一個新的指向Top的vptr指針)。現在編譯器為動態轉換插進了一個庫調用:
這個函數__dynamic_cast定義在stdc++庫中(相應的頭文件是cxxabi.h);參數為Top的類型信息,Left和Bottom(通過vptr.Top),這個轉換可以執行。 (參數 -1 標示出Left和Top之間的關系現在還是未知)。更多詳細資料,請參考tinfo.cc 的具體實現 。 |
總結語最后,我們來看看一些沒了結的部分。 指針的指針這里出現了一點令人迷惑的問題,但是如果你仔細思考下一的話它其實很簡單。我們來看一個例子。假設使用上一節用到的類層次結構(向下類型轉換).在前面的小節我們已經看到了它的結果:
編譯器會接受這樣的形式嗎?我們快速測試一下,編譯器會報錯:
因此,bb和rr都指向b,並且b和r指向Bottom對象的正確的章節。現在考慮當我們賦值給*rr時會發生什么(注意*rr的類型時Right*,因此這個賦值是有效的):
|
這樣的賦值和上面的賦值給r在根本上是一致的。因此,編譯器會用同樣的方式實現它!特別地,它會在賦值給*rr之前將b的值調整8個字節。辦事*rr指向的是b!我們再一次圖示化這個結果: 只要我們通過*rr來訪問Bottom對象這都是正確的,但是只要我們通過b自身來訪問它,所有的內存引用都會有8個字節的偏移---明顯這是個不理想的情況。 因此,總的來說,及時*a 和*b通過一些子類型相關,**aa和**bb卻是不相關的。 |
虛擬基類的構造函數編譯器必須確保對象的所有虛指針都被正確的初始化。特別是,編譯器確保了類的所有虛基類都被調用,並且只被調用一次。如果你不顯示地調用虛擬超類(不管他們在繼承層次結構中的距離有多遠),編譯器都會自動地插入調用他們缺省構造函數。 這樣也會引來一些不可以預期的錯誤。以上面給出的類層次結構作為示例,並添加上構造函數的部分:
為了避免這種情況,你應該顯示的調用虛基類的構造函數:
|
指針等價再假設同樣的(虛擬)類繼承等級,你希望這樣就打印“相等”嗎?
記住這兩個地址並不實際相等(r偏移了8個字節)。但是這應該對用戶完全透明;因此,實際上編譯器在r與b比較之前,就給r減去了8個字節;這樣,這兩個地址就被認為是相等的了。 |
轉換為void類型的指針最后,我們來思考一下當將一個對象轉換為void類型的指針時會發生什么事情。編譯器必須保證一個指針轉換為void類型的指針時指向對象的頂部。使用虛函數表這很容易實現。你可能已經想到了指向top域的偏移量是什么。它是虛函數指針到對象頂部的偏移量。因此,轉化為void類型的指針操作可以使用查詢虛函數表的方式來實現。然而一定要確保使用動態類型轉換,如下: dynamic_cast<void*>(b); 參考文獻[1] CodeSourcery, 特別是C++ ABI Summary, Itanium 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. |