1. 程式人生 > 其它 >C++:29 --- C++繼承關係下的記憶體佈局(下)

C++:29 --- C++繼承關係下的記憶體佈局(下)

技術標籤:指標java程式語言c++vue

1 單繼承

C++ 提供繼承的目的是在不同的型別之間提取共性。比如,科學家對物種進行分類,從而有種、屬、綱等說法。有了這種層次結構,我們才可能將某些具備特定性質的東西歸入到最合適的分類層次上,如“懷孩子的是哺乳動物”。由於這些屬性可以被子類繼承,所以,我們只要知道“鯨魚、人”是哺乳動物,就可以方便地指出“鯨魚、人都可以懷孩子”。那些特例,如鴨嘴獸(生蛋的哺乳動物),則要求我們對預設的屬性或行為進行覆蓋。
C++中的繼承語法很簡單,在子類後加上“:base”就可以了。下面的D繼承自基類C。

struct C
{
    int c1;
    void cf();
};


struct D : C
{
    int d1;
    void df();
};


既然派生類要保留基類的所有屬性和行為,自然地,每個派生類的例項都包含了一份完整的基類例項資料。在D中,並不是說基類C的資料一定要放在D的資料之前,只不過這樣放的話,能夠保證D中的C物件地址,恰好是D物件地址的第一個位元組。這種安排之下,有了派生類D的指標,要獲得基類C的指標,就不必要計算偏移量了。幾乎所有知名的C++廠商都採用這種記憶體安排(基類成員在前)。在單繼承類層次下,每一個新的派生類都簡單地把自己的成員變數新增到基類的成員變數之後。看看上圖,C物件指標和D物件指標指向同一地址。

2.多重繼承

大多數情況下,其實單繼承就足夠了。但是,C++為了我們的方便,還提供了多重繼承。

比如,我們有一個組織模型,其中有經理類(分任務),工人類(幹活)。那麼,對於一線經理類,即既要從上級經理那裡領取任務幹活,又要向下級工人分任務的角色來說,如何在類層次中表達呢?單繼承在此就有點力不勝任。我們可以安排經理類先繼承工人類,一線經理類再繼承經理類,但這種層次結構錯誤地讓經理類繼承了工人類的屬性和行為。反之亦然。當然,一線經理類也可以僅僅從一個類(經理類或工人類)繼承,或者一個都不繼承,重新宣告一個或兩個介面,但這樣的實現弊處太多:多型不可能了;未能重用現有的介面;最嚴重的是,當介面變化時,必須多處維護。最合理的情況似乎是一線經理從兩個地方繼承屬性和行為——經理類、工人類。

C++就允許用多重繼承來解決這樣的問題:

struct Manager ... { ... };
struct Worker ... { ... };
structMiddleManager:Manager,Worker{...};

這樣的繼承將造成怎樣的類佈局呢?下面我們還是用“字母類”來舉例:

struct E
{
    int e1;
    void ef();
};


struct F : C, E
{
    int f1;
    void ff();
};

結構F從C和E多重繼承得來。與單繼承相同的是,F例項拷貝了每個基類的所有資料。與單繼承不同的是,在多重繼承下,內嵌的兩個基類的物件指標不可能全都與派生類物件指標相同:

F f;
// (void*)&f == (void*)(C*)&f;
// (void*)&f < (void*)(E*)&f;


上面那行說明C物件指標與F物件指標相同,下面那行說明E物件指標與F物件指標不同。

觀察類佈局,可以看到F中內嵌的E物件,其指標與F指標並不相同。正如後文討論強制轉化和成員函式時指出的,這個偏移量會造成少量的呼叫開銷。

具體的編譯器實現可以自由地選擇內嵌基類和派生類的佈局。VC++按照基類的宣告順序先排列基類例項資料,最後才排列派生類資料。當然,派生類資料本身也是按照宣告順序佈局的(本規則並非一成不變,我們會看到,當一些基類有虛擬函式而另一些基類沒有時,記憶體佈局並非如此)。

3. 虛繼承

回到我們討論的一線經理類例子。讓我們考慮這種情況:如果經理類和工人類都繼承自“僱員類”,將會發生什麼?

struct Employee { ... };  
struct Manager : Employee { ... };  
struct Worker : Employee { ... };  
structMiddleManager:Manager,Worker{...};

如果經理類和工人類都繼承自僱員類,很自然地,它們每個類都會從僱員類獲得一份資料拷貝。如果不作特殊處理,一線經理類的例項將含有兩個僱員類例項,它們分別來自兩個僱員基類。如果僱員類成員變數不多,問題不嚴重;如果成員變數眾多,則那份多餘的拷貝將造成例項生成時的嚴重開銷。更糟的是,這兩份不同的僱員例項可能分別被修改,造成資料的不一致。因此,我們需要讓經理類和工人類進行特殊的宣告,說明它們願意共享一份僱員基類例項資料。

很不幸,在C++中,這種“共享繼承”被稱為“虛繼承”,把問題搞得似乎很抽象。虛繼承的語法很簡單,在指定基類時加上virtual關鍵字即可。

struct Employee { ... };  
struct Manager : virtual Employee { ... };  
struct Worker : virtual Employee { ... };  
structMiddleManager:Manager,Worker{...};

使用虛繼承,比起單繼承和多重繼承有更大的實現開銷、呼叫開銷。回憶一下,在單繼承和多重繼承的情況下,內嵌的基類例項地址比起派生類例項地址來,要麼地址相同(單繼承,以及多重繼承的最靠左基類),要麼地址相差一個固定偏移量(多重繼承的非最靠左基類)。然而,當虛繼承時,一般說來,派生類地址和其虛基類地址之間的偏移量是不固定的,因為如果這個派生類又被進一步繼承的話,最終派生類會把共享的虛基類例項資料放到一個與上一層派生類不同的偏移量處。請看下例:

struct G : virtual C
{
    int g1;
    void gf();
};


GdGvbptrG(In G, the displacement of G’s virtual base pointer to G)意思是:在G中,G物件的指標與G的虛基類表指標之間的偏移量,在此可見為0,因為G物件記憶體佈局第一項就是虛基類表指標;GdGvbptrC(In G, the displacement of G’s virtual base pointer to C)意思是:在G中,C物件的指標與G的虛基類表指標之間的偏移量,在此可見為8。

struct H : virtual C
{
    int h1;
    void hf();
};

struct I : G, H
{
    int i1;
    void _if();
};


暫時不追究vbptr成員變數從何而來。從上面這些圖可以直觀地看到,在G物件中,內嵌的C基類物件的資料緊跟在G的資料之後,在H物件中,內嵌的C基類物件的資料也緊跟在H的資料之後。但是,在I物件中,記憶體佈局就並非如此了。VC++實現的記憶體佈局中,G物件例項中G物件和C物件之間的偏移,不同於I物件例項中G物件和C物件之間的偏移。當使用指標訪問虛基類成員變數時,由於指標可以是指向派生類例項的基類指標,所以,編譯器不能根據宣告的指標型別計算偏移,而必須找到另一種間接的方法,從派生類指標計算虛基類的位置。
在VC++中,對每個繼承自虛基類的類例項,將增加一個隱藏的“虛基類表指標”(vbptr)成員變數,從而達到間接計算虛基類位置的目的。該變數指向一個全類共享的偏移量表,表中專案記錄了對於該類而言,“虛基類表指標”與虛基類之間的偏移量。
其它的實現方式中,有一種是在派生類中使用指標成員變數。這些指標成員變數指向派生類的虛基類,每個虛基類一個指標。這種方式的優點是:獲取虛基類地址時,所用程式碼比較少。然而,編譯器優化程式碼時通常都可以採取措施避免重複計算虛基類地址。況且,這種實現方式還有一個大弊端:從多個虛基類派生時,類例項將佔用更多的記憶體空間;獲取虛基類的虛基類的地址時,需要多次使用指標,從而效率較低等等。

在VC++中,G擁有一個隱藏的“虛基類表指標”成員,指向一個虛基類表,該表的第二項是G dGvbptrC。(在G中,虛基類物件C的地址與G的“虛基類表指標”之間的偏移量(當對於所有的派生類來說偏移量不變時,省略“d”前的字首))。比如,在32位平臺上,GdGvptrC是8個位元組。同樣,在I例項中的G物件例項也有 “虛基類表指標”,不過該指標指向一個適用於“G處於I之中”的虛基類表,表中一項為IdGvbptrC,值為20。

觀察前面的G、H和I,我們可以得到如下關於VC++虛繼承下記憶體佈局的結論:
1 首先排列非虛繼承的基類例項;
2 有虛基類時,為每個基類增加一個隱藏的vbptr,除非已經從非虛繼承的類那裡繼承了一個vbptr;
3 排列派生類的新資料成員;
4 在例項最後,排列每個虛基類的一個例項。

該佈局安排使得虛基類的位置隨著派生類的不同而“浮動不定”,但是,非虛基類因此也就湊在一起,彼此的偏移量固定不變。

4多重繼承下的虛擬函式

如果從多個有虛擬函式的基類繼承,一個例項就有可能包含多個vfptr。考慮如下的R和S類:

struct R {  
   int r1;  
   virtual void pvf(); // new   
   virtual void rvf(); // new   
};


struct S : P, R {  
   int s1;  
   void pvf(); // overrides P::pvf and R::pvf   
   void rvf(); // overrides R::rvf   
   void svf(); // new   
};

這裡R是另一個包含虛擬函式的類。因為S從P和R多重繼承,S的例項內嵌P和R的例項,以及S自身的資料成員S::s1。注意,在多重繼承下,靠右的基類R,其例項的地址和P與S不同。S::pvf覆蓋了P::pvf()和R::pvf(),S::rvf()覆蓋了R::rvf()。

S s; S* ps = &s;  
((P*)ps)->pvf(); // (*(P*)ps)->P::vfptr[0])((S*)(P*)ps)   
((R*)ps)->pvf(); // (*(R*)ps)->R::vfptr[0])((S*)(R*)ps)   
ps->pvf();       // one of the above; calls S::pvf()

呼叫((P*)ps)->pvf()時,先到P的虛擬函式表中取出第一項,然後把ps轉化為S*作為this指標傳遞進去;
呼叫((R*)ps)->pvf()時,先到R的虛擬函式表中取出第一項,然後把ps轉化為S*作為this指標傳遞進去;

因為S::pvf()覆蓋了P::pvf()和R::pvf(),在S的虛擬函式表中,相應的項也應該被覆蓋。然而,我們很快注意到,不光可以用P*,還可以用R*來呼叫pvf()。問題出現了:R的地址與P和S的地址不同。表示式(R*)ps與表示式(P*)ps指向類佈局中不同的位置。因為函式S::pvf希望獲得一個S*作為隱藏的this指標引數,虛擬函式必須把R*轉化為S*。因此,在S對R虛擬函式表的拷貝中,pvf函式對應的項,指向的是一個“調整塊”的地址,該調整塊使用必要的計算,把R*轉換為需要的S*。
這就是“thunk1: this-= sdPR; goto S::pvf”乾的事。先根據P和R在S中的偏移,調整this為P*,也就是S*,然後跳轉到相應的虛擬函式處執行。

在微軟VC++實現中,對於有虛擬函式的多重繼承,只有當派生類虛擬函式覆蓋了多個基類的虛擬函式時,才使用調整塊。

5 地址點與“邏輯this調整”

考慮下一個虛擬函式S::rvf(),該函式覆蓋了R::rvf()。我們都知道S::rvf()必須有一個隱藏的S*型別的this引數。但是,因為也可以用R*來呼叫rvf(),也就是說,R的rvf虛擬函式槽可能以如下方式被用到:

  1. ((R*)ps)->rvf();//(*((R*)ps)->R::vfptr[1])((R*)ps)

  1. ((R*)ps)->rvf();//(*((R*)ps)->R::vfptr[1])((R*)ps)

所以,大多數實現用另一個調整塊將傳遞給rvf的R*轉換為S*。還有一些實現在S的虛擬函式表末尾新增一個特別的虛擬函式項,該虛擬函式項提供方法,從而可以直接呼叫ps->rvf(),而不用先轉換R*。MSC++的實現不是這樣,MSC++有意將S::rvf編譯為接受一個指向S中巢狀的R例項,而非指向S例項的指標(我們稱這種行為是“給派生類的指標型別與該虛擬函式第一次被引入時接受的指標型別相同”)。所有這些在後臺透明發生,對成員變數的存取,成員函式的this指標,都進行“邏輯this調整”。

當然,在debugger中,必須對這種this調整進行補償。

ps->rvf();//((R*)ps)->rvf();//S::rvf((R*)ps)

呼叫rvf虛擬函式時,直接給入R*作為this指標。

所以,當覆蓋非最左邊的基類的虛擬函式時,MSC++一般不建立調整塊,也不增加額外的虛擬函式項。

6 調整塊

正如已經描述的,有時需要調整塊來調整this指標的值(this指標通常位於棧上返回地址之下,或者在暫存器中),在this指標上加或減去一個常量偏移,再呼叫虛擬函式。某些實現(尤其是基於cfront的)並不使用調整塊機制。它們在每個虛擬函式表項中增加額外的偏移資料。每當虛擬函式被呼叫時,該偏移資料(通常為0),被加到物件的地址上,然後物件的地址再作為this指標傳入。

ps->rvf();  
// struct { void (*pfn)(void*); size_t disp; };   
// (*ps->vfptr[i].pfn)(ps + ps->vfptr[i].disp);

當呼叫rvf虛擬函式時,前一句表示虛擬函式表每一項是一個結構,結構中包含偏移量;後一句表示呼叫第i個虛擬函式時,this指標使用儲存在虛擬函式表中第i項的偏移量來進行調整。

這種方法的缺點是虛擬函式表增大了,虛擬函式的呼叫也更加複雜。

現代基於PC的實現一般採用“調整—跳轉”技術:

S::pvf-adjust: // MSC++   
this -= SdPR;  
gotoS::pvf();

當然,下面的程式碼序列更好(然而,當前沒有任何實現採用該方法):

S::pvf-adjust:  
this -= SdPR; // fall into S::pvf()   
S::pvf() { ... }

IBM的C++編譯器使用該方法。

7 虛繼承下的虛擬函式

T虛繼承P,覆蓋P的虛成員函式,聲明瞭新的虛擬函式。如果採用在基類虛擬函式表末尾新增新項的方式,則訪問虛擬函式總要求訪問虛基類。在VC++中,為了避免獲取虛擬函式表時,轉換到虛基類P的高昂代價,T中的新虛擬函式通過一個新的虛擬函式表獲取,從而帶來了一個新的虛擬函式表指標。該指標放在T例項的頂端。

struct T : virtual P {  
   int t1;  
   void pvf();         // overrides P::pvf   
   virtual void tvf(); // new   
};  
void T::pvf() {  
   ++p1; // ((P*)this)->p1++; // vbtable lookup!   
   ++t1; // this->t1++;   
}

如上所示,即使是在虛擬函式中,訪問虛基類的成員變數也要通過獲取虛基類表的偏移,實行計算來進行。這樣做之所以必要,是因為虛擬函式可能被進一步繼承的類所覆蓋,而進一步繼承的類的佈局中,虛基類的位置變化了。下面就是這樣的一個類:

struct U : T {  
   int u1;  
};

在此U增加了一個成員變數,從而改變了P的偏移。因為VC++實現中,T::pvf()接受的是巢狀在T中的P的指標,所以,需要提供一個調整塊,把this指標調整到T::t1之後(該處即是P在T中的位置)。

8 虛解構函式與delete操作符

假如A是B的父類,
A* p = new B();
如果解構函式不是虛擬的,那麼,你後面就必須這樣才能安全的刪除這個指標:
delete (B*)p;
但如果建構函式是虛擬的,就可以在執行時動態繫結到B類的解構函式,直接:
delete p;
就可以了。這就是虛解構函式的作用。
實際上,很多人這樣總結:當且僅當類裡包含至少一個虛擬函式的時候才去宣告虛解構函式。
考慮結構V和W。

struct V {  
   virtual ~V();  
};  
struct W : V {  
   operator delete();  
};

解構函式可以為虛。一個類如果有虛解構函式的話,將會象有其他虛擬函式一樣,擁有一個虛擬函式表指標,虛擬函式表中包含一項,其內容為指向對該類適用的虛解構函式的地址。這些機制和普通虛擬函式相同。虛解構函式的特別之處在於:當類例項被銷燬時,虛解構函式被隱含地呼叫。呼叫地(delete發生的地方)雖然不知道銷燬的動態型別,然而,要保證呼叫對該型別合適的delete操作符。例如,當pv指向W的例項時,當W::~W被呼叫之後,W例項將由W類的delete操作符來銷燬。

V* pv = new V;  
delete pv;   // pv->~V::V(); // use ::operator delete()   
pv = new W;  
delete pv;   // pv->~W::W(); // use W::operator delete() 動態繫結到 W的解構函式,W預設的解構函式呼叫{delete this;}   
pv = new W;  
::delete pv; // pv->~W::W(); // use ::operator delete()

V沒有定義delete操作符,delete時使用函式庫的delete操作符;
W定義了delete操作符,delete時使用自己的delete操作符;
可以用全域性範圍標示符顯示地呼叫函式庫的delete操作符。
為了實現上述語意,VC++擴充套件了其“分層析構模型”,從而自動建立另一個隱藏的析構幫助函式——“deleting解構函式”,然後,用該函式的地址來替換虛擬函式表中“實際”虛解構函式的地址。析構幫助函式呼叫對該類合適的解構函式,然後為該類有選擇性地呼叫合適的delete操作符。