C++對象模型(五):The Semantics of Data Data語義學
本文是《Inside the C++ Object Model》第三章的讀書筆記。主要討論C++ data member的內存布局。這裏的data member 包含了class有虛函數時的vptr和vtable的布局情況。
1. 開頭幾個小問題
1. 首先回答一個問題: 一個空類,sizeof是多少?答案是1。因為編譯器會生成一個隱晦的1bytes,用於區分,當該類多個對象時,各個對象都能在內存分配唯一地址。
2. 還有虛函數表的指針vptr,可能在類的開始,也可能在類的結尾。通常是類的結尾。(注:比較新的VC++和GCC都是在開頭。不知道是否所有的版本都是)。
3. 關於成員變量的內存對齊,例如一個類隻有char a一個屬性; 但是它的大小是4(32位。64位機器是8?但是我是用GCC的sizeof仍然是1。熟悉匯編應該知道,這個地址應該不會存其他的內容了,因此說sizeof是4/8也可理解)。雖然char的大小是1。
4. 屬性的內存順序和聲明順序是一致的。不同級別(public、protected和private)屬性的排列順序是相對一致的,就是說可能不連續,但是必須符合較晚出現的屬性存在較高的地址。
2. vptr值的不同存儲方式
以下的圖來自https://blog.csdn.net/hherima同學。非常感謝hherima同學的圖。我將使用hherima同學的圖,加上我自身的理解來徹底鞏固並且分享給各位可愛的程序猿們。
下圖演示單一繼承並含有虛函數情況下的數據布局。Point2d 和Point3d是繼承關係。Point2d含有虛函數,而Point3d自身沒有虛函數。
注意:vptr放在類的末尾。這種方式在剛開始被很多編譯器采用,因為可以保存base c struct的內存布局。
但是到了C++2.0,開始支持虛擬繼承和抽象基類;並且由於OO的興起,某些編譯器開始把vptr放到class object的起頭處。比如微軟的第一個C++編譯器就是采用這種方法。

前端存放的好處就是編譯器可以直接訪問虛函數表而不需要通過offset。當然代價就是與C的struct不再兼容。但是誰會從一個C struct派生出具有虛函數的C++ class呢?
如果是前端存放,還存在一個問題:如果基類沒有虛函數,派生類有虛函數,那麼單一繼承的自然多態就會被打破。如果要將派生類轉換成基類,必須編譯器的介入。但是這種情況也比較少,因此多態就是為了繼承,誰會設計出這種繼承呢?既然這不是大多數的case,采用vptr在開頭,那麼就具有很好的意義。這種conventional實際上很利於編譯器將C++編譯到匯編,而且匯編也比較容易讀。否則,放在結尾的話,每個class的data member數量是不一樣的,因此vptr存儲的offset也不一樣。而放到頭上,那麼0號位置存的就是vptr,1號位置存的就是第一個data member,這樣不單利於編譯代碼,也便於我們閱讀反匯編的匯編代碼。
3. 數據成員(data member)的內存布局
在上一小節中我們討論了vptr的不同存放方式。編譯器需要通過設置offset來存取vptr和data member。在98頁關於對一個nonstatic data member的存取操作描述,feel confused:作者的意思是如果是直接取對象的第一個data member,那麼需要在對象的地址+1。我不是太明白。如果是存取對象的第一個成員,那麼對象的地址應該就是指向第一個成員的,它可能是vptr,也可能是第一個data member。那麼如果是匯編,那麼直接取該地址的內容,該地址的內容有可能是成員的值,也可能存的仍是地址(指針),那麼offset+1沒有意義。如果是C++的code,那麼本身不需要這麼麻煩,誰會直接將對象所在的地址進行解釋,而不是通過C++的方式?當然某些高性能編程可能是,但是我實在想不出有任何理由要這樣去做。
C++語言保證“出現在派生類中的基類對象,有其完整性”,這麼做是為了在位拷貝的時候,能夠拷貝正確。一般每個成員都會獨占一個地址,意思是在32位機器上,每個數據成員至少占用4個B。當然為了內存對齊,比如有一下class:
class data{ char a; char b; int c; };
那麼a和b可能會share一個地址單元,即sizeof(data) = 8;但是子類,父類的數據成員可以為了空間效率share一個地址單元嗎?
假如Concrete1 和Concere2都有一個char的屬性,而且Concere2繼承自Concrete1。那麼如果這兩個數據成員share一個地址單元會有什麼問題?那麼我們思考一下以下的賦值能符合我們的預期嗎?
Concrete1 *pc1_1, pc1_2; Concrete2 c2; pc1_1 = &c2; //memory allocate for pc1_2 *pc1_2 = *pc1_1;
注意,從pc1_1到pc1_2的memberwise複製(複製一個一個的member)時,pc1_1的char b就被抹掉了。那麼pc1_1就丟掉了派生類的信息。而這個複製很顯然不是我們需要的!
這也是為什麼C++語言保證“出現在派生類中的基類對象,有其完整性”!
3. 多重繼承(Multiple Inheritance)
對於一個多重派生對象,將其地址指定給“最左端(也就是第一個)基類的指針”,情況和單一繼承時相同,因為兩者都指向相同的起始地址。需要付出的成本隻是地址的指定操作而已,至於第二個或後繼的base class的地址指定操作,則需要進行地址修改:加上或者減去介於中間base class大小。
下圖展示了多繼承的關係。涉及到4個類 Point2d、Point3d、Vertex和Vertex3d(p115)
下麵展示了多重繼承的對象模型。
注意,多繼承的情況下,drived clas可能會有兩個或兩個以上虛函數表指針。
請看下麵的表達式:
Vertex3d v3d; Vertex* pv; Point2d* p2d; Point3d * p3d;那麼這個操作 pv = &v3d 需要轉換內部代碼
pv = (Vertex*)(((char*)&v3d) + sizeof(Point3d))
那麼如果pv是從另外一個Vertex3d的指針(比如是pv3d)拷貝過來呢?那麼需要考慮空指針的情況。
pv = pv3d ?(Vertex*)(((char*)&v3d) + sizeof(Point3d)) :0;下麵這兩個操作,隻需要拷貝地址就行了。
p2d = &v3d;
p3d = &v3d;
以下引自陳皓先生的名著《C++ 對象的內存布局(上)》中多重繼承。使用的是VC++和GCC3.4.4
使用圖片表示是下麵這個樣子:
我們可以看到:
1) 每個父類都有自己的虛表。
2) 子類的成員函數被放到了第一個父類的表中。
3) 內存布局中,其父類布局依次按聲明順序排列。
4) 每個父類的虛表中的f()函數都被overwrite成了子類的f()。這樣做就是為了解決不同的父類類型的指針指向同一個子類實例,而能夠調用到實際的函數。
4. 虛擬多繼承情況
下圖可以表現Vertex3d 的繼承體係圖。左為多重繼承,右為虛擬多重繼承。
各個class的定義如下:
class Point2d{ ... protect: float _x, _y; }; class Vertex: public virtual Point2d{ ... protected: Vertex *next; }; class Point3d: public virtual Point2d{ ... protected: float _z; }; class Vertex3d: public Vertex, public Point3d{ ... protected: float mumble; };
不論是Vertex還是Point3d都內含一個Point2d。然而在Vertex3d的對象布局中,我們隻需要單一一份Point2d就好。如何使多重繼承,那麼Vertex3d對象中將有兩個Point2d,那麼對Point2d的引用可能會有歧義。所以引入虛擬繼承。然而編譯器要實現虛擬繼承,實在是困難度頗高。虛擬繼承的原則就是:讓Vertex和Point3d各自維護的Point2d 折疊成一個有Vertex3d維護的單一Point2d,並且還可以保存base class 和derived class的指針之間的多台指定操作。
如果一個class含有virtual base classsubobjects, 那麼,該對象將被分割為兩部分:一個不變局部和一個共享局部。不變局部中的數據,不管後繼如何演化,總是擁有固定的offset,所以這部分數據可以直接存取。至於共享局部(即virtual base class),這一部分的數據,其位置會因為每次的派生操作而有變化,所以他們隻能被間接存取。各家編譯器實現技術之間的差異就是間接存取的方法不同。
如何存取class的共享局部呢?cfront編譯器會在每一個derived class中安插一個指向virtual base class的指針,這樣就可以間接存取。這樣的實現模型會有下麵兩個主要缺點:
1.每一個對象必須針對其每一個virtual base class 背負一個額外的指針。
解決方法有:第一個,Microsoft編譯器引入所謂的virtual base class table。每一個class object如果有一個或多個virtual base class,就會由編譯器安插一個指針,指向virtual base class table。至於真正的virtual base class 指針,當然是被放在該表格中。請看下麵的虛擬繼承對象模型,如圖。
紅框內即所謂的“共享局部”,其位置會因每次派生操作而有所變化。虛擬破壞了base class 的對象完整型,虛擬繼承會在自己類中生成一個虛函數表指針。
第二個、在virtual function table 中放置virtual base class的offset(不是地址)。
這個方法的好處是,巧妙的利用了虛函數表的結構,使得drived class 能夠節省一個指針的大小。上圖中藍色曲線是offset
2.由於虛擬繼承串鏈的加長,導致間接存取層次的增加。例如:如果我們有三層虛擬衍化,我就需要三次間接存取(經由三個virtual base class指針)。
這個問題的解決方案有:拷貝所有的virtual base class 的指針到drived class中。這樣就解決了存取時間的問題,雖然會有空間的開銷。
參考資料:
1. https://blog.csdn.net/haoel/archive/2008/10/15/3081328.aspx
2. https://blog.csdn.net/hherima/article/details/8888539
最後更新:2017-04-03 12:54:48