1. 程式人生 > >虛擬函式與虛繼承記憶體分析

虛擬函式與虛繼承記憶體分析

封裝、繼承、多型是面嚮物件語言的三大特性,熟悉C++的人對此應該不會有太多異議。C語言提供的struct,頂多算得上對資料的簡單封裝,而C++的引入把struct“升級”為class,使得面向物件的概念更加強大。繼承機制解決了物件複用的問題,然而多重繼承又會產生成員衝突的問題,虛繼承在我看來更像是一種“不得已”的解決方案。多型讓物件具有了執行時特性,並且它是軟體設計複用的本質,虛擬函式的出現為多型性質提供了實現手段。

如果說C語言的struct相當於對資料成員簡單的排列(可能有對齊問題),那麼C++的class讓物件的資料的封裝變得更加複雜。所有的這些問題來源於C++的一個關鍵字——virtual!virtual在C++中最大的功能就是宣告虛擬函式和虛基類,有了這種機制,C++物件的機制究竟發生了怎樣的變化,讓我們一起探尋之。

為了檢視物件的結構模型,我們需要在編譯器配置時做一些初始化。在VS2010中,在專案——屬性——配置屬性——C/C++——命令列——其他選項中新增選項“/d1reportAllClassLayout”。再次編譯時候,編譯器會輸出所有定義類的物件模型。由於輸出的資訊過多,我們可以使用“Ctrl+F”查詢命令,找到物件模型的輸出。

一、基本物件模型

首先,我們定義一個簡單的類,它含有一個數據成員和一個虛擬函式。

class MyClass
{
    int var;
public:
    virtual void fun()
    {}
};

編譯輸出的MyClass物件結構如下:

1>  class MyClass    size(8):
1>      +---
1>   0    | {vfptr}
1>   4    | var
1>      +---
1>  
1>  MyClass::[email protected]:
1>      | &MyClass_meta
1>      |  0
1>   0    | &MyClass::fun
1>  
1>  MyClass::fun this adjustor: 0

從這段資訊中我們看出,MyClass物件大小是8個位元組。前四個位元組儲存的是虛擬函式表的指標vfptr,後四個位元組儲存物件成員var的值。虛擬函式表的大小為4位元組,就一條函式地址,即虛擬函式fun的地址,它在虛擬函式表vftable的偏移是0。因此,MyClass物件模型的結果如圖1所示。

圖1 MyClass物件模型

MyClass的虛擬函式表雖然只有一條函式記錄,但是它的結尾處是由4位元組的0作為結束標記的。

adjust表示虛擬函式機制執行時,this指標的調整量,假如fun被多型呼叫的話,那麼它的形式如下:

*(this+0)[0]()

總結虛擬函式呼叫形式,應該是:

*(this指標+調整量)[虛擬函式在vftable內的偏移]()

二、單重繼承物件模型

我們定義一個繼承於MyClass類的子類MyClassA,它重寫了fun函式,並且提供了一個新的虛擬函式funA。

class MyClassA:public MyClass
{
    int varA;
public:
    virtual void fun()
    {}
    virtual void funA()
    {}
};


它的物件模型為:

1>  class MyClassA    size(12):
1>      +---
1>      | +--- (base class MyClass)
1>   0    | | {vfptr}
1>   4    | | var
1>      | +---
1>   8    | varA
1>      +---
1>  
1>  MyClassA::[email protected]:
1>      | &MyClassA_meta
1>      |  0
1>   0    | &MyClassA::fun
1>   1    | &MyClassA::funA
1>  
1>  MyClassA::fun this adjustor: 0
1>  MyClassA::funA this adjustor: 0

可以看出,MyClassA將基類MyClass完全包含在自己內部,包括vfptr和var。並且虛擬函式表內的記錄多了一條——MyClassA自己定義的虛擬函式funA。它的物件模型如圖2所示。

 

圖2 MyClassA物件模型

我們可以得出結論:在單繼承形式下,子類的完全獲得父類的虛擬函式表和資料。子類如果重寫了父類的虛擬函式(如fun),就會把虛擬函式表原本fun對應的記錄(內容MyClass::fun)覆蓋為新的函式地址(內容MyClassA::fun),否則繼續保持原本的函式地址記錄。如果子類定義了新的虛擬函式,虛擬函式表內會追加一條記錄,記錄該函式的地址(如MyClassA::funA)。

使用這種方式,就可以實現多型的特性。假設我們使用如下語句:

MyClass*pc= new MyClassA;
pc->fun();

編譯器在處理第二條語句時,發現這是一個多型的呼叫,那麼就會按照上邊我們對虛擬函式的多型訪問機制呼叫函式fun。

*(pc+0)[0]()

因為虛擬函式表內的函式地址已經被子類重寫的fun函式地址覆蓋了,因此該處呼叫的函式正是MyClassA::fun,而不是基類的MyClass::fun。

如果使用MyClassA物件直接訪問fun,則不會出發多型機制,因為這個函式呼叫在編譯時期是可以確定的,編譯器只需要直接呼叫MyClassA::fun即可。

三、多重繼承物件模型

和前邊MyClassA類似,我們也定義一個類MyClassB。

class MyClassB:public MyClass
{
    int varB;
public:
    virtual void fun()
    {}
    virtual void funB()
    {}
};

它的物件模型和MyClassA完全類似,這裡就不再贅述了。

為了實現多重繼承,我們再定義一個類MyClassC。

class MyClassB:public MyClass
{
    int varB;
public:
    virtual void fun()
    {}
    virtual void funB()
    {}
};


為了簡化,我們讓MyClassC只重寫父類MyClassB的虛擬函式funB,它的物件模型如下:

1>  class MyClassC    size(28):
1>      +---
1>      | +--- (base class MyClassA)
1>      | | +--- (base class MyClass)
1>   0    | | | {vfptr}
1>   4    | | | var
1>      | | +---
1>   8    | | varA
1>      | +---
1>      | +--- (base class MyClassB)
1>      | | +--- (base class MyClass)
1>  12    | | | {vfptr}
1>  16    | | | var
1>      | | +---
1>  20    | | varB
1>      | +---
1>  24    | varC
1>      +---
1>  
1>  MyClassC::[email protected]@:
1>      | &MyClassC_meta
1>      |  0
1>   0    | &MyClassA::fun
1>   1    | &MyClassA::funA
1>   2    | &MyClassC::funC
1>  
1>  MyClassC::[email protected]@:
1>      | -12
1>   0    | &MyClassB::fun
1>   1    | &MyClassC::funB
1>  
1>  MyClassC::funB this adjustor: 12
1>  MyClassC::funC this adjustor: 0

和單重繼承類似,多重繼承時MyClassC會把所有的父類全部按序包含在自身內部。而且每一個父類都對應一個單獨的虛擬函式表。MyClassC的物件模型如圖3所示。

 

圖3 MyClassC物件模型

多重繼承下,子類不再具有自身的虛擬函式表,它的虛擬函式表與第一個父類的虛擬函式表合併了。同樣的,如果子類重寫了任意父類的虛擬函式,都會覆蓋對應的函式地址記錄。如果MyClassC重寫了fun函式(兩個父類都有該函式),那麼兩個虛擬函式表的記錄都需要被覆蓋!在這裡我們發現MyClassC::funB的函式對應的adjust值是12,按照我們前邊的規則,可以發現該函式的多型呼叫形式為:

*(this+12)[1]()

此處的調整量12正好是MyClassB的vfptr在MyClassC物件內的偏移量。

四、虛擬繼承物件模型

虛擬繼承是為了解決多重繼承下公共基類的多份拷貝問題。比如上邊的例子中MyClassC的物件內包含MyClassA和MyClassB子物件,但是MyClassA和MyClassB內含有共同的基類MyClass。為了消除MyClass子物件的多份存在,我們需要讓MyClassA和MyClassB都虛擬繼承於MyClass,然後再讓MyClassC多重繼承於這兩個父類。相對於上邊的例子,類內的設計不做任何改動,先修改MyClassA和MyClassB的繼承方式:

class MyClassA: virtual  public MyClass
class MyClassB: virtual  public MyClass
class MyClassC: public MyClassA, public MyClassB

由於虛繼承的本身語義,MyClassC內必須重寫fun函式,因此我們需要再重寫fun函式。這種情況下,MyClassC的物件模型如下:

1>  class MyClassC    size(36):
1>      +---
1>      | +--- (base class MyClassA)
1>   0    | | {vfptr}
1>   4    | | {vbptr}
1>   8    | | varA
1>      | +---
1>      | +--- (base class MyClassB)
1>  12    | | {vfptr}
1>  16    | | {vbptr}
1>  20    | | varB
1>      | +---
1>  24    | varC
1>      +---
1>      +--- (virtual base MyClass)
1>  28    | {vfptr}
1>  32    | var
1>      +---
1>  
1>  MyClassC::[email protected]@:
1>      | &MyClassC_meta
1>      |  0
1>   0    | &MyClassA::funA
1>   1    | &MyClassC::funC
1>  
1>  MyClassC::[email protected]@:
1>      | -12
1>   0    | &MyClassC::funB
1>  
1>  MyClassC::[email protected]@:
1>   0    | -4
1>   1    | 24 (MyClassCd(MyClassA+4)MyClass)
1>  
1>  MyClassC::[email protected]@:
1>   0    | -4
1>   1    | 12 (MyClassCd(MyClassB+4)MyClass)
1>  
1>  MyClassC::[email protected]@:
1>      | -28
1>   0    | &MyClassC::fun
1>  
1>  MyClassC::fun this adjustor: 28
1>  MyClassC::funB this adjustor: 12
1>  MyClassC::funC this adjustor: 0
1>  
1>  vbi:       class  offset o.vbptr  o.vbte fVtorDisp
1>           MyClass      28       4       4 0

虛繼承的引入把物件的模型變得十分複雜,除了每個基類(MyClassA和MyClassB)和公共基類(MyClass)的虛擬函式表指標需要記錄外,每個虛擬繼承了MyClass的父類還需要記錄一個虛基類表vbtable的指標vbptr。MyClassC的物件模型如圖4所示。

 

圖4 MyClassC物件模型

虛基類表每項記錄了被繼承的虛基類子物件相對於虛基類表指標的偏移量。比如MyClassA的虛基類表第二項記錄值為24,正是MyClass::vfptr相對於MyClassA::vbptr的偏移量,同理MyClassB的虛基類表第二項記錄值12也正是MyClass::vfptr相對於MyClassA::vbptr的偏移量。

和虛擬函式表不同的是,虛基類表的第一項記錄著當前子物件相對與虛基類表指標的偏移。MyClassA和MyClassB子物件內的虛表指標都是儲存在相對於自身的4位元組偏移處,因此該值是-4。假定MyClassA和MyClassC或者MyClassB內沒有定義新的虛擬函式,即不會產生虛擬函式表,那麼虛基類表第一項欄位的值應該是0。

通過以上的物件組織形式,編譯器解決了公共虛基類的多份拷貝的問題。通過每個父類的虛基類表指標,都能找到被公共使用的虛基類的子物件的位置,並依次訪問虛基類子物件的資料。至於虛基類定義的虛擬函式,它和其他的虛擬函式的訪問形式相同,本例中,如果使用虛基類指標MyClass*pc訪問MyClassC物件的fun,將會被轉化為如下形式:

*(pc+28)[0]()

通過以上的描述,我們基本認清了C++的物件模型。尤其是在多重、虛擬繼承下的複雜結構。通過這些真實的例子,使得我們認清C++內class的本質,以此指導我們更好的書寫我們的程式。本文從物件結構的角度結合圖例為大家闡述物件的基本模型,和一般描述C++虛擬機制的文章有所不同。作者只希望藉助於圖表能把C++物件以更好理解的形式為大家展現出來,希望本文對你有所幫助。