深入理解C++對象模型之Data Member存取成本

一、前言

    對於C++語言而言,不好理解的地方大多數由繼承和多態這兩個東西產生,比如說一個C++類型的內存佈局,會因爲其是繼承而來的派生類或者獨立類而不同,如果是派生類,由於繼承的存在,它需要包含基類部分,因此其內存佈局(模型)需要改變。同時,這裏介紹的對一個Class的member的存取成本也與繼承和多態有關?


二、問題與結論

1、問題描述

    如果有一個Class Base(包含x,y兩個數據成員),定義如下代碼:

Base Bobj;
Bobj.x = 0.0;
第一個問題是對於上面的代碼,x的存取成本是什麼呢?如果又有下面的代碼定義:

Base Bobj;
Base *pB = &Bobj;
Bobj.x = 0.0;   //(1)
pB->x = 0.0;    //(2)

第二個問題是對於上面的代碼,代碼(1)和(2)的存取成本有什麼差異?

2、結論

    這裏先給出具體的結論,然後再做詳細解釋。對於第一個問題成員變量x的存取成本,你需要考慮到成員變量x的聲明類型(static或non-static)、以及類型Base是繼承而來,還是獨立的Class,如果是繼承,那具體是單一繼承(沒有多態,即沒有虛函數)、單一繼承(加上多態)、多重繼承、還是虛擬繼承呢?當然,對於第一個問題,以上列出的所有情況對成員x的存取成本都沒有影響。

    對於第二個問題,兩種形式的存取成本有沒有差異呢?答案是有差異但是隻在以上列出的情況中的一種情況下有差異,那就是虛擬繼承體系下,如果成員變量x是從virtual base class繼承而來(且是通過指針來訪問),那麼代碼(2)的存取成本就會變慢,因爲在編譯階段我們不能確定指針pB具體指向虛擬基類還是派生類,所以我們就不知道這個成員x在Class內存模型中的具體offset,所以這個存取操作必須要延遲到執行階段才能確定(具體分析下面會給出)。


三、繼承與Class Data Member佈局

    以上結論也說明了:具體繼承(相對於虛擬繼承)並不會增加存取時間上的額外負擔。下面將會主要針對4種情況對“繼承對Class Data Member佈局影響”進行分析,也正是因爲虛擬繼承對Class Data Member的影響才導致了上述第二個問題中代碼(2)的差異,這4種情況分別爲:單一繼承(沒有多態)、單一繼承(有多態)、多重繼承、虛擬繼承。

1、單一繼承(沒有多態)

    首先要理解C++語言的類對象模型,及對象的內存佈局, 在C++class定義中,有兩種class data member:static和non-static,以及三種class member function:static、non-static和virtual。而在class的對象模型中卻只包含non-static data member和虛函數表指針(如果是虛擬繼承則需要另外討論)。

    在單一繼承且沒有多態的情況下,那麼派生類中也就不存在虛函數表指針了,即只有其自身和繼承而來的data member,也就是說class object的內存模型佈局在編譯階段已經確定,這時,如果是代碼(1)來存取data member,其就是直接存取,如果通過代碼(2)來存取,只需要計算data member在內存模型中的offset即可,也是直接存取。所以無論是通過代碼(1)還是代碼(2)的形式來存取數據成員,對存取時間都沒有影響。

2、單一繼承(有多態)

    當存在多態時,對於class object的內存模型只是多了一個虛函數表指針,這個指針具體放在內存模型的哪個位置(可以是基類的首端、也可以是末端,甚至中間)在C++標準中沒有要求,這就與不同的編譯器有關,不同編譯器可以有不同的實現方法,但不管怎麼實現,class object的內存模型佈局同樣在編譯階段已經確定,因此對data member的存取時間也沒有影響。

3、多重繼承

    在解釋了前面兩種情況之後,多重繼承應該也就自然明白了,多重繼承還是具體繼承,不是虛擬繼承,即是前兩種情況的綜合,所以其對data member的存取時間同樣沒有影響。

    對於多重繼承,儘管其對data member的存取時間沒有影響的,但是它對於繼承體系中的類型轉換有很大的影響,主要表現在:derived class object和其(派生列表中)第二或者後繼base class object之間的轉換會存在很多問題與陷阱(這需要各位同學去查閱相關資料)。

4、虛擬繼承

    關於虛擬繼承,即在派生列表中經由virtual關鍵字所指的基類,如果一個class繼承於一個virtual base class,那麼這個class內存模型被分爲兩個部分:不變部分和共享部分,其中共享部分指從虛擬基類派生而來的部分。所謂不變部分,就是其在class的內存模型中具有固定的offset(從object的開頭算起),而共享部分在class內存模型中的位置會因爲每次派生操作而有變化,因此,派生類的不變部分是可以直接存取的(根據其offset),而共享部分是不可以的。

    同時,我們也清楚,一個派生類其內存模型中也可以分爲基類部分和派生類部分,因此綜合不變部分和共享部分的分類方法,一個包含virtual base class的派生類的內存模型包含3個部分:non-virtual base class、virtual base class和派生類本身部分,這三個部分在內存模型中的邏輯順序視不同的編譯器而不同,一種主流的做法是根據派生列表,在class的內存模型中,從上到下(地址從小到大)依次是base1、base2……派生類本身、共享部分(virtual base class),如下所示,有如下繼承關係

class Base
{
public:
	int x;
};
class Derive1:virtual public Base
{
public:
	//含有虛函數
public:
	int y;
};
class Derive2:virtual public Base
{
public:
	//含有虛函數
public:
	int z;
};
class Derive:public Derive1, Derive2
{
	int m;
};

則class Derive的內存模型可以表示爲:


    可以看出,在這個模型中共享部分的位置是有class Derive1和class Derive2的虛函數表指針(vptr)所指出,其實關於怎麼表示或指出共享部分的位置也有很多種不同,各種編譯器的實現也不同,上圖中的表示是一種流行的表示方法,可以解決其他一些方法的不足(在侯捷先生的《深度探索C++對象模型》中3.4節有詳細的解釋)。所以,在這種模型下,每次對共享部分數據的存儲都需要經由派生類虛函數表指針進行間接操作,就會消耗更多的時間,當然這種時間消耗是建立在通過指針進行數據訪問的情況下的,即Derive *pd的形式,因爲在通過指針訪問時,class的動態類型是未知的,所以無法在編譯期間確定class內存模型中共享區域的成員變量的offset,導致對共享區域成員變量的訪問操作要延遲到執行期間。而通過class object進行共享區域的訪問是不需要延遲到執行期的,因爲使用class object時,如Derive ObjD時,對象ObjD的靜態類型和動態類型一致,即在編譯器可以知道其內存模型中所有成員變量的offset,不管是不變部分還是共享部分,都是確定的,所以即使是對共享區域的變量進行存取操作也是進行直接存取的。

    現在再回過來看開頭提出的兩個問題和結論,各位同學就應該明白是什麼情況了。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章