1. 程式人生 > >虛繼承與虛基類的本質(介紹的非常詳細)

虛繼承與虛基類的本質(介紹的非常詳細)

      虛繼承與虛基類的本質
    虛繼承和虛基類的定義是非常的簡單的,同時也是非常容易判斷一個繼承是否是虛繼承
的,雖然這兩個概念的定義是非常的簡單明確的,但是在C++語言中虛繼承作為一個比較生
僻的但是又是絕對必要的組成部份而存在著,並且其行為和模型均表現出和一般的繼承體系
之間的巨大的差異(包括訪問效能上的差異),現在我們就來徹底的從語言、模型、效能和
應用等多個方面對虛繼承和虛基類進行研究。
    首先還是先給出虛繼承和虛基類的定義。
    虛繼承:在繼承定義中包含了virtual關鍵字的繼承關係;
    虛基類:在虛繼承體系中的通過virtual繼承而來的基類,需要注意的是:
            struct CSubClass : public virtual CBase {}; 其中CBase稱之為CSubClass
            的虛基類,而不是說CBase就是個虛基類,因為CBase還可以不不是虛繼承體系
            中的基類。
    有了上面的定義後,就可以開始虛繼承和虛基類的本質研究了,下面按照語法、語義、
模型、效能和應用五個方面進行全面的描述。

    1. 語法
       語法有語言的本身的定義所決定,總體上來說非常的簡單,如下:
           struct CSubClass : public virtual CBaseClass {};
       其中可以採用public、protected、private三種不同的繼承關鍵字進行修飾,只要
       確保包含virtual就可以了,這樣一來就形成了虛繼承體系,同時CBaseClass就成為
       了CSubClass的虛基類了。
       其實並沒有那麼的簡單,如果出現虛繼承體系的進一步繼承會出現什麼樣的狀況呢?
       如下所示:
          


       注意上面程式碼中的CDiamondClass1和CDiamondSubClass1兩個類的建構函式初始化列
       表中的內容。可以發現其中均包含了虛基類CBaseClass1的初始化工作,如果沒有這
       個初始化語句就會導致編譯時錯誤,為什麼會這樣呢?一般情況下不是隻要在
       CSubClassV1和CSubClassV2中包含初始化就可以了麼?要解釋該問題必須要明白虛
       繼承的語義特徵,所以參看下面語義部分的解釋。
       
    2. 語義
       從語義上來講什麼是虛繼承和虛基類呢?上面僅僅是從如何在C++語言中書寫合法的
       虛繼承類定義而已。首先來了解一下virtual這個關鍵字在C++中的公共含義,在C++
       語言中僅僅有兩個地方可以使用virtual這個關鍵字,一個就是類成員虛擬函式和這裡
       所討論的虛繼承。不要看這兩種應用場合好像沒什麼關係,其實他們在背景語義上
       具有virtual這個詞所代表的共同的含義,所以才會在這兩種場合使用相同的關鍵字。
       那麼virtual這個詞的含義是什麼呢?
       virtual在《美國傳統詞典[雙解]》中是這樣定義的:
           adj.(形容詞)
           1. Existing or resulting in essence or effect though not in actual 
              fact, form, or name:
              實質上的,實際上的:雖然沒有實際的事實、形式或名義,但在實際上或效
              果上存在或產生的;
           2. Existing in the mind, especially as a product of the imagination. 
              Used in literary criticism of text.
              虛的,內心的:在頭腦中存在的,尤指意想的產物。用於文學批評中。
       我們採用第一個定義,也就是說被virtual所修飾的事物或現象在本質上是存在的,
       但是沒有直觀的形式表現,無法直接描述或定義,需要通過其他的間接方式或手段
       才能夠體現出其實際上的效果。
       那麼在C++中就是採用了這個詞意,不可以在語言模型中直接呼叫或體現的,但是確
       實是存在可以被間接的方式進行呼叫或體現的。比如:虛擬函式必須要通過一種間接的
       執行時(而不是編譯時)機制才能夠啟用(呼叫)的函式,而虛繼承也是必須在執行
       時才能夠進行定位訪問的一種體制。存在,但間接。其中關鍵就在於存在、間接和共
       享這三種特徵。
       對於虛擬函式而言,這三個特徵是很好理解的,間接性表明了他必須在執行時根據實際
       的物件來完成函式定址,共享性表象在基類會共享被子類過載後的虛擬函式,其實指向
       相同的函式入口。
       對於虛繼承而言,這三個特徵如何理解呢?存在即表示虛繼承體系和虛基類確實存在,
       間接性表明了在訪問虛基類的成員時同樣也必須通過某種間接機制來完成(下面模型
       中會講到),共享性表象在虛基類會在虛繼承體系中被共享,而不會出現多份拷貝。
       那現在可以解釋語法小節中留下來的那個問題了,“為什麼一旦出現了虛基類,就必
       須在沒有一個(這裡疑似筆誤,應為“每一個”)
繼承類中都必須包含虛基類的初始化語句”。由上面的分析可以知道,
       虛基類是被共享的,也就是在繼承體系中無論被繼承多少次,物件記憶體模型中均只會
       出現一個虛基類的子物件(這和多繼承是完全不同的),這樣一來既然是共享的那麼
       每一個子類都不會獨佔,但是總還是必須要有一個類來完成基類的初始化過程(因為
       所有的物件都必須被初始化,哪怕是預設的),同時還不能夠重複進行初始化,那到
       底誰應該負責完成初始化呢?C++標準中(也是很自然的)選擇在每一次繼承子類中
       都必須書寫初始化語句(因為每一次繼承子類可能都會用來定義物件),而在最下層
       繼承子類中實際執行初始化過程。所以上面在每一個繼承類中都要書寫初始化語句,
       但是在建立物件時,而僅僅會在建立物件用的類建構函式中實際的執行初始化語句,
       其他的初始化語句都會被壓制不呼叫。

 


    3. 模型
       為了實現上面所說的三種語義含義,在考慮物件的實現模型(也就是記憶體模型)時就
       很自然了。在C++中物件實際上就是一個連續的地址空間的語義代表,我們來分析虛
       繼承下的記憶體模型。
       3.1. 存在
           也就是說在物件記憶體中必須要包含虛基類的完整子物件,以便能夠完成通過地址
           完成物件的標識。那麼至於虛基類的子物件會存放在物件的那個位置(頭、中間、
           尾部)則由各個編譯器選擇,沒有差別。(在VC8中無論虛基類被宣告在什麼位置,
           虛基類的子物件都會被放置在物件記憶體的尾部)
       3.2. 間接
           間接性表明了在直接虛基承子類中一定包含了某種指標(偏移或表格)來完成通
           過子類訪問虛基類子物件(或成員)的間接手段(因為虛基類子物件是共享的,
           沒有確定關係),至於採用何種手段由編譯器選擇。(在VC8中在子類中放置了
           一個虛基類指標vbc,該指標指向虛擬函式表中的一個slot,該slot中存放則虛基
           類子物件的偏移量的負值,實際上就是個以補碼錶示的int型別的值,在計算虛
           基類子物件首地址時,需要將該偏移量取絕對值相加,這個主要是為了和虛表
           中只能存放虛擬函式地址這一要求相區別,因為地址是原碼錶示的無符號int型別
           的值)
       3.3. 共享
           共享表明了在物件的記憶體空間中僅僅能夠包含一份虛基類的子物件,並且通過
           某種間接的機制來完成共享的引用關係。在介紹完整個內容後會附上測試程式碼,
           體現這些內容。
    4. 效能
       由於有了間接性和共享性兩個特徵,所以決定了虛繼承體系下的物件在訪問時必然
       會在時間和空間上與一般情況有較大不同。
       4.1. 時間
           在通過繼承類物件訪問虛基類物件中的成員(包括資料成員和函式成員)時,都
           必須通過某種間接引用來完成,這樣會增加引用定址時間(就和虛擬函式一樣),
           其實就是調整this指標以指向虛基類物件,只不過這個調整是執行時間接完成的。
           (在VC8中通過開啟彙編輸出,可以檢視*.cod檔案中的內容,在訪問虛基類物件
           成員時會形成三條mov間接定址語句,而在訪問一般繼承類物件時僅僅只有一條mov
           常量直接定址語句)
       4.2. 空間
           由於共享所以不同在物件記憶體中儲存多份虛基類子物件的拷貝,這樣較之多繼承
           節省空間。
    5. 應用
       談了那麼多語言特性和內容,那麼在什麼情況下需要使用虛繼承,而一般應該如何使
       用呢?
       這個問題其實很難有答案,一般情況下如果你確性出現多繼承沒有必要,必須要共享
       基類子物件的時候可以考慮採用虛繼承關係(C++標準ios體系就是這樣的)。由於每
       一個繼承類都必須包含初始化語句而又僅僅只在最底層子類中呼叫,這樣可能就會使
       得某些上層子類得到的虛基類子物件的狀態不是自己所期望的(因為自己的初始化語
       句被壓制了),所以一般建議不要在虛基類中包含任何資料成員(不要有狀態),只
       可以作為介面類來提供。

附錄:測試程式碼


測試環境:
    軟體環境:Visual Studio2005 Pro + SP1, boost1.34.0
    硬體環境:PentiumD 3.0GHz, 4G RAM
測試資料:
================================ 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

結果分析:
    1. 由於虛繼承引入的間接性指標所以導致了虛繼承類的尺寸會增加4個位元組;
    2. 由Layout輸出可以看出,虛基類子物件被放在了物件的尾部(偏移為16),並且vbc
       指標必須緊緊的接在虛基類子物件的前面,所以vbc指標所指向的內容為“偏移 - 4”;
    3. 由於VC8將偏移放在了虛擬函式表中,所以為了區分函式地址和偏移,所以偏移是用補
       碼int表示的負值;
    4. 間接性可以通過效能來看出,在虛繼承體系同通過指標訪問成員時的時間一般是一般
       類訪問情況下的4倍左右,符合組合語言輸出檔案中的彙編語句的安排。

那麼,為什麼要使用虛繼承??

為什麼要引入虛擬繼承?

  虛擬繼承在一般的應用中很少用到,所以也往往被忽視,這也主要是因為在C++中,多重繼承是不推薦的,也並不常用,而一旦離開了多重繼承,虛擬繼承就完全失去了存在的必要(因為這樣只會降低效率和佔用更多的空間,關於這一點,我自己還沒有太多深刻的理解,有興趣的可以看網路上白楊的作品《RTTI、虛擬函式和虛基類的開銷分析及使用指導》,說實話我目前還沒看得很明白,高人可以指點下我)。

一個例子

  以下面的一個例子為例:

   

  當編譯上述程式碼時,我們會收到如下的錯誤提示:

  error C2385: 'CD::f' is ambiguous

  即編譯器無法確定你在d.f()中要呼叫的函式f到底是哪一個。這裡可能會讓人覺得有些奇怪,命名只定義了一個CA::f,既然大家都派生自CA,那自然就是呼叫的CA::f,為什麼還無法確定呢?

  這是因為編譯器在進行編譯的時候,需要確定子類的函式定義,如CA::f是確定的,那麼在編譯CB、CC時還需要在編譯器的語法樹中生成CB::f,CC::f等標識,那麼,在編譯CD的時候,由於CB、CC都有一個函式f,此時,編譯器將試圖生成這兩個CD::f標識,顯然這時就要報錯了。(當我們不使用CD::f的時候,以上標識都不會生成,所以,如果去掉d.f()一句,程式將順利通過編譯)

  要解決這個問題,有兩個方法:

  1、過載函式f():此時由於我們明確定義了CD::f,編譯器檢查到CD::f()呼叫時就無需再像上面一樣去逐級生成CD::f標識了;

  此時CD的元素結構如下:

  |CB(CA)|

  |CC(CA)|

  故此時的sizeof(CD) = 8;(CB、CC各有一個元素k)

  2、使用虛擬繼承:虛擬繼承又稱作共享繼承,這種共享其實也是編譯期間實現的,當使用虛擬繼承時,上面的程式將變成下面的形式:

   

  此時,當編譯器確定d.f()呼叫的具體含義時,將生成如下的CD結構:

  |CB|

  |CC|

  |CA|

  同時,在CB、CC中都分別包含了一個指向CA的虛基類指標列表vbptr(virtual base table pointer),其中記錄的是從CB、CC的元素到CA的元素之間的偏移量。此時,不會生成各子類的函式f標識,除非子類過載了該函式,從而達到“共享”的目的(這裡的具體記憶體佈局,可以參看鑽石型繼承記憶體佈局,在白楊的那篇文章中也有)。

  也正因此,此時的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不變。原因如下所示:帶有虛擬函式的類,其記憶體佈局上包含一個指向虛擬函式列表的指標(vptr),這跟有幾個虛擬函式無關。