虛繼承與虛基類的本質(zhì) 虛繼承和虛基類的定義是非常的簡單的,同時也是非常容易判斷一個繼承是否是虛繼承 的,,雖然這兩個概念的定義是非常的簡單明確的,但是在C++語言中虛繼承作為一個比較生 僻的但是又是絕對必要的組成部份而存在著,,并且其行為和模型均表現(xiàn)出和一般的繼承體系 之間的巨大的差異(包括訪問性能上的差異),,現(xiàn)在我們就來徹底的從語言、模型,、性能和 應(yīng)用等多個方面對虛繼承和虛基類進(jìn)行研究,。 首先還是先給出虛繼承和虛基類的定義。 虛繼承:在繼承定義中包含了virtual關(guān)鍵字的繼承關(guān)系,; 虛基類:在虛繼承體系中的通過virtual繼承而來的基類,,需要注意的是: struct CSubClass : public virtual CBase {}; 其中CBase稱之為CSubClass 的虛基類,而不是說CBase就是個虛基類,,因為CBase還可以不不是虛繼承體系 中的基類,。 有了上面的定義后,就可以開始虛繼承和虛基類的本質(zhì)研究了,,下面按照語法,、語義、 模型,、性能和應(yīng)用五個方面進(jìn)行全面的描述,。
1. 語法 語法有語言的本身的定義所決定,總體上來說非常的簡單,,如下: struct CSubClass : public virtual CBaseClass {}; 其中可以采用public,、protected、private三種不同的繼承關(guān)鍵字進(jìn)行修飾,,只要 確保包含virtual就可以了,,這樣一來就形成了虛繼承體系,同時CBaseClass就成為 了CSubClass的虛基類了,。 其實(shí)并沒有那么的簡單,,如果出現(xiàn)虛繼承體系的進(jìn)一步繼承會出現(xiàn)什么樣的狀況呢,? 如下所示: 注意上面代碼中的CDiamondClass1和CDiamondSubClass1兩個類的構(gòu)造函數(shù)初始化列 表中的內(nèi)容??梢园l(fā)現(xiàn)其中均包含了虛基類CBaseClass1的初始化工作,,如果沒有這 個初始化語句就會導(dǎo)致編譯時錯誤,為什么會這樣呢,?一般情況下不是只要在 CSubClassV1和CSubClassV2中包含初始化就可以了么,?要解釋該問題必須要明白虛 繼承的語義特征,所以參看下面語義部分的解釋,。 2. 語義 從語義上來講什么是虛繼承和虛基類呢,?上面僅僅是從如何在C++語言中書寫合法的 虛繼承類定義而已。首先來了解一下virtual這個關(guān)鍵字在C++中的公共含義,,在C++ 語言中僅僅有兩個地方可以使用virtual這個關(guān)鍵字,,一個就是類成員虛函數(shù)和這里 所討論的虛繼承。不要看這兩種應(yīng)用場合好像沒什么關(guān)系,,其實(shí)他們在背景語義上 具有virtual這個詞所代表的共同的含義,,所以才會在這兩種場合使用相同的關(guān)鍵字。 那么virtual這個詞的含義是什么呢,? virtual在《美國傳統(tǒng)詞典[雙解]》中是這樣定義的: adj.(形容詞) 1. Existing or resulting in essence or effect though not in actual fact, form, or name: 實(shí)質(zhì)上的,,實(shí)際上的:雖然沒有實(shí)際的事實(shí)、形式或名義,,但在實(shí)際上或效 果上存在或產(chǎn)生的,; 2. Existing in the mind, especially as a product of the imagination. Used in literary criticism of text. 虛的,內(nèi)心的:在頭腦中存在的,,尤指意想的產(chǎn)物,。用于文學(xué)批評中。 我們采用第一個定義,,也就是說被virtual所修飾的事物或現(xiàn)象在本質(zhì)上是存在的,, 但是沒有直觀的形式表現(xiàn),,無法直接描述或定義,,需要通過其他的間接方式或手段 才能夠體現(xiàn)出其實(shí)際上的效果。 那么在C++中就是采用了這個詞意,,不可以在語言模型中直接調(diào)用或體現(xiàn)的,,但是確 實(shí)是存在可以被間接的方式進(jìn)行調(diào)用或體現(xiàn)的。比如:虛函數(shù)必須要通過一種間接的 運(yùn)行時(而不是編譯時)機(jī)制才能夠激活(調(diào)用)的函數(shù),,而虛繼承也是必須在運(yùn)行 時才能夠進(jìn)行定位訪問的一種體制,。存在,但間接,。其中關(guān)鍵就在于存在,、間接和共 享這三種特征,。 對于虛函數(shù)而言,這三個特征是很好理解的,,間接性表明了他必須在運(yùn)行時根據(jù)實(shí)際 的對象來完成函數(shù)尋址,,共享性表象在基類會共享被子類重載后的虛函數(shù),其實(shí)指向 相同的函數(shù)入口,。 對于虛繼承而言,,這三個特征如何理解呢?存在即表示虛繼承體系和虛基類確實(shí)存在,, 間接性表明了在訪問虛基類的成員時同樣也必須通過某種間接機(jī)制來完成(下面模型 中會講到),,共享性表象在虛基類會在虛繼承體系中被共享,而不會出現(xiàn)多份拷貝,。 那現(xiàn)在可以解釋語法小節(jié)中留下來的那個問題了,,“為什么一旦出現(xiàn)了虛基類,就必 須在沒有一個(這里疑似筆誤,,應(yīng)為“每一個”)繼承類中都必須包含虛基類的初始化語句”,。由上面的分析可以知道, 虛基類是被共享的,,也就是在繼承體系中無論被繼承多少次,,對象內(nèi)存模型中均只會 出現(xiàn)一個虛基類的子對象(這和多繼承是完全不同的),這樣一來既然是共享的那么 每一個子類都不會獨(dú)占,,但是總還是必須要有一個類來完成基類的初始化過程(因為 所有的對象都必須被初始化,,哪怕是默認(rèn)的),同時還不能夠重復(fù)進(jìn)行初始化,,那到 底誰應(yīng)該負(fù)責(zé)完成初始化呢,?C++標(biāo)準(zhǔn)中(也是很自然的)選擇在每一次繼承子類中 都必須書寫初始化語句(因為每一次繼承子類可能都會用來定義對象),而在最下層 繼承子類中實(shí)際執(zhí)行初始化過程,。所以上面在每一個繼承類中都要書寫初始化語句,, 但是在創(chuàng)建對象時,而僅僅會在創(chuàng)建對象用的類構(gòu)造函數(shù)中實(shí)際的執(zhí)行初始化語句,, 其他的初始化語句都會被壓制不調(diào)用,。 3. 模型 為了實(shí)現(xiàn)上面所說的三種語義含義,在考慮對象的實(shí)現(xiàn)模型(也就是內(nèi)存模型)時就 很自然了,。在C++中對象實(shí)際上就是一個連續(xù)的地址空間的語義代表,,我們來分析虛 繼承下的內(nèi)存模型。 3.1. 存在 也就是說在對象內(nèi)存中必須要包含虛基類的完整子對象,,以便能夠完成通過地址 完成對象的標(biāo)識,。那么至于虛基類的子對象會存放在對象的那個位置(頭、中間、 尾部)則由各個編譯器選擇,,沒有差別,。(在VC8中無論虛基類被聲明在什么位置, 虛基類的子對象都會被放置在對象內(nèi)存的尾部) 3.2. 間接 間接性表明了在直接虛基承子類中一定包含了某種指針(偏移或表格)來完成通 過子類訪問虛基類子對象(或成員)的間接手段(因為虛基類子對象是共享的,, 沒有確定關(guān)系),,至于采用何種手段由編譯器選擇。(在VC8中在子類中放置了 一個虛基類指針vbc,,該指針指向虛函數(shù)表中的一個slot,,該slot中存放則虛基 類子對象的偏移量的負(fù)值,實(shí)際上就是個以補(bǔ)碼表示的int類型的值,,在計算虛 基類子對象首地址時,,需要將該偏移量取絕對值相加,這個主要是為了和虛表 中只能存放虛函數(shù)地址這一要求相區(qū)別,,因為地址是原碼表示的無符號int類型 的值) 3.3. 共享 共享表明了在對象的內(nèi)存空間中僅僅能夠包含一份虛基類的子對象,,并且通過 某種間接的機(jī)制來完成共享的引用關(guān)系。在介紹完整個內(nèi)容后會附上測試代碼,, 體現(xiàn)這些內(nèi)容,。 4. 性能 由于有了間接性和共享性兩個特征,所以決定了虛繼承體系下的對象在訪問時必然 會在時間和空間上與一般情況有較大不同,。 4.1. 時間 在通過繼承類對象訪問虛基類對象中的成員(包括數(shù)據(jù)成員和函數(shù)成員)時,,都 必須通過某種間接引用來完成,這樣會增加引用尋址時間(就和虛函數(shù)一樣),, 其實(shí)就是調(diào)整this指針以指向虛基類對象,,只不過這個調(diào)整是運(yùn)行時間接完成的。 (在VC8中通過打開匯編輸出,,可以查看*.cod文件中的內(nèi)容,,在訪問虛基類對象 成員時會形成三條mov間接尋址語句,而在訪問一般繼承類對象時僅僅只有一條mov 常量直接尋址語句) 4.2. 空間 由于共享所以不同在對象內(nèi)存中保存多份虛基類子對象的拷貝,,這樣較之多繼承 節(jié)省空間,。 5. 應(yīng)用 談了那么多語言特性和內(nèi)容,那么在什么情況下需要使用虛繼承,,而一般應(yīng)該如何使 用呢,? 這個問題其實(shí)很難有答案,一般情況下如果你確性出現(xiàn)多繼承沒有必要,,必須要共享 基類子對象的時候可以考慮采用虛繼承關(guān)系(C++標(biāo)準(zhǔn)ios體系就是這樣的),。由于每 一個繼承類都必須包含初始化語句而又僅僅只在最底層子類中調(diào)用,這樣可能就會使 得某些上層子類得到的虛基類子對象的狀態(tài)不是自己所期望的(因為自己的初始化語 句被壓制了),,所以一般建議不要在虛基類中包含任何數(shù)據(jù)成員(不要有狀態(tài)),只 可以作為接口類來提供。
附錄:測試代碼
測試環(huán)境: 軟件環(huán)境:Visual Studio2005 Pro + SP1, boost1.34.0 硬件環(huán)境:PentiumD 3.0GHz, 4G RAM 測試數(shù)據(jù): ================================ sizeof ================================ ---------------------------------------------------------------- sizeof( CBaseClass1 ) = 4
sizeof( CSubClassV1 ) = 8 sizeof( CSubClassV2 ) = 8 sizeof( CDiamondClass1 ) = 12 sizeof( CDiamondSubClass1 ) = 12
sizeof( CSubClassN1 ) = 4 sizeof( CSubClassN2 ) = 4 sizeof( CMultiClass1 ) = 8 sizeof( CMultiSubClass1 ) = 8 ---------------------------------------------------------------- sizeof( CBaseClass2 ) = 4
sizeof( CSubClassV3 ) = 8 sizeof( CSubClassV4 ) = 8 sizeof( CDiamondClass2 ) = 12 sizeof( CDiamondSubClass2 ) = 12
sizeof( CSubClassN3 ) = 4 sizeof( CSubClassN4 ) = 4 sizeof( CMultiClass2 ) = 8 sizeof( CMultiSubClass2 ) = 8 ================================ layout ================================ --------------------------------MI------------------------------ sizeof( CLayoutSubClass1 ) = 20 CLayoutBase1 offset of CLayoutSubClass1 is 0 CBaseClass1 offset of CLayoutSubClass1 is 16 CLayoutBase2 offset of CLayoutSubClass1 is 8 vbc in CLayoutSubClass1 is -12 --------------------------------SI------------------------------ sizeof( CSubClassV1 ) = 8 CBaseClass1 offset of CSubClassV1 is 4 vbc in CSubClassV1 is 0 ================================ Performance ================================ CSubClassV1::ptr1->m_val 0.062 s CSubClassN1::ptr2->m_val 0.016 s
結(jié)果分析: 1. 由于虛繼承引入的間接性指針?biāo)詫?dǎo)致了虛繼承類的尺寸會增加4個字節(jié),; 2. 由Layout輸出可以看出,,虛基類子對象被放在了對象的尾部(偏移為16),并且vbc 指針必須緊緊的接在虛基類子對象的前面,,所以vbc指針?biāo)赶虻膬?nèi)容為“偏移 - 4”,; 3. 由于VC8將偏移放在了虛函數(shù)表中,所以為了區(qū)分函數(shù)地址和偏移,,所以偏移是用補(bǔ) 碼int表示的負(fù)值,; 4. 間接性可以通過性能來看出,在虛繼承體系同通過指針訪問成員時的時間一般是一般 類訪問情況下的4倍左右,,符合匯編語言輸出文件中的匯編語句的安排,。
那么,為什么要使用虛繼承,?,? 為什么要引入虛擬繼承? 虛擬繼承在一般的應(yīng)用中很少用到,,所以也往往被忽視,,這也主要是因為在C++中,多重繼承是不推薦的,,也并不常用,,而一旦離開了多重繼承,虛擬繼承就完全失去了存在的必要(因為這樣只會降低效率和占用更多的空間,,關(guān)于這一點(diǎn),,我自己還沒有太多深刻的理解,有興趣的可以看網(wǎng)絡(luò)上白楊的作品《RTTI,、虛函數(shù)和虛基類的開銷分析及使用指導(dǎo)》,,說實(shí)話我目前還沒看得很明白,高人可以指點(diǎn)下我),。 一個例子 以下面的一個例子為例: 當(dāng)編譯上述代碼時,,我們會收到如下的錯誤提示: error C2385: 'CD::f' is ambiguous 即編譯器無法確定你在d.f()中要調(diào)用的函數(shù)f到底是哪一個。這里可能會讓人覺得有些奇怪,,命名只定義了一個CA::f,,既然大家都派生自CA,那自然就是調(diào)用的CA::f,,為什么還無法確定呢,? 這是因為編譯器在進(jìn)行編譯的時候,需要確定子類的函數(shù)定義,,如CA::f是確定的,,那么在編譯CB,、CC時還需要在編譯器的語法樹中生成CB::f,CC::f等標(biāo)識,,那么,,在編譯CD的時候,由于CB,、CC都有一個函數(shù)f,,此時,編譯器將試圖生成這兩個CD::f標(biāo)識,,顯然這時就要報錯了,。(當(dāng)我們不使用CD::f的時候,以上標(biāo)識都不會生成,,所以,,如果去掉d.f()一句,程序?qū)㈨樌ㄟ^編譯) 要解決這個問題,,有兩個方法: 1,、重載函數(shù)f():此時由于我們明確定義了CD::f,編譯器檢查到CD::f()調(diào)用時就無需再像上面一樣去逐級生成CD::f標(biāo)識了,; 此時CD的元素結(jié)構(gòu)如下: |CB(CA)| |CC(CA)| 故此時的sizeof(CD) = 8;(CB,、CC各有一個元素k) 2、使用虛擬繼承:虛擬繼承又稱作共享繼承,,這種共享其實(shí)也是編譯期間實(shí)現(xiàn)的,,當(dāng)使用虛擬繼承時,上面的程序?qū)⒆兂上旅娴男问剑?/p> 此時,,當(dāng)編譯器確定d.f()調(diào)用的具體含義時,,將生成如下的CD結(jié)構(gòu): |CB| |CC| |CA| 同時,在CB,、CC中都分別包含了一個指向CA的虛基類指針列表vbptr(virtual base table pointer),,其中記錄的是從CB、CC的元素到CA的元素之間的偏移量,。此時,,不會生成各子類的函數(shù)f標(biāo)識,除非子類重載了該函數(shù),,從而達(dá)到“共享”的目的(這里的具體內(nèi)存布局,,可以參看鉆石型繼承內(nèi)存布局,在白楊的那篇文章中也有),。 也正因此,,此時的sizeof(CD) = 12(兩個vbptr + sizoef(int)); 另注: 如果CB,CC中各定義一個int型變量,,則sizeof(CD)就變成20(兩個vbptr + 3個sizoef(int) 如果CA中添加一個virtual void f1(){},,sizeof(CD) = 16(兩個vbptr + sizoef(int)+vptr); 再添加virtual void f2(){},,sizeof(CD) = 16不變。原因如下所示:帶有虛函數(shù)的類,,其內(nèi)存布局上包含一個指向虛函數(shù)列表的指針(vptr),,這跟有幾個虛函數(shù)無關(guān),。
|