1. 程式人生 > >鑽石型繼承模型的記憶體分佈

鑽石型繼承模型的記憶體分佈

轉自:點選開啟連結 和點選開啟連結 並更正一個小錯誤以及增加了自己的一些理解

關於C++物件記憶體佈局的資料和書籍也有很多,比如陳皓老師的部落格:

1、C++物件的記憶體佈局(上)

2、C++物件的記憶體佈局(下)

白楊:

RTTI、虛擬函式和虛基類的實現方式、開銷分析及使用指導

左手為你畫猜:

C++類物件記憶體模型與成員函式呼叫分析(上、中、下)

關於講解C++物件記憶體模型最好的書應該是侯捷老師翻譯的《深度探索C++物件記憶體模型》。

這兩天在看其他書籍時,對C++中虛擬繼承的實現機制不太理解,於是又重新翻回《深度探索C++物件記憶體模型》一書,並結合

C++物件的記憶體佈局(下)一文。在Visual Studio 2010下用“cl”編譯器進行測試,檢視虛擬多重繼承下的C++物件記憶體模型。總結如下:

一、重複繼承

所謂重複繼承,即某個基類被間接地重複繼承了多次。為方便對比說明,下面的程式碼採用了陳皓老師部落格中C++類例子。

UML類圖如下:

類繼承的原始碼如下,直接採用C++物件的記憶體佈局(下)中的例子,相關解釋已在原部落格中詳細說明,故在此不再贅述:

複製程式碼
 1 #include <iostream>
 2 using namespace std;
 3 
 4 class
B 5 { 6 public: 7 int ib; 8 char cb; 9 public: 10 B():ib(0),cb('B') 11 {} 12 virtual void f() 13 { 14 cout<<"B::f()"<<endl; 15 } 16 virtual void Bf() 17 { 18 cout<<"B::Bf()"<<endl; 19 } 20 }; 21 22 class B1:public B
23 { 24 public: 25 int ib1; 26 char cb1; 27 public: 28 B1():ib1(01),cb1('1'){} 29 30 virtual void f() 31 { 32 cout<<"B1::f()"<<endl; 33 } 34 virtual void f1() 35 { 36 cout<<"B1::f1()"<<endl; 37 } 38 virtual void Bf1() 39 { 40 cout<<"B1::Bf1()"<<endl; 41 } 42 }; 43 44 class B2:public B 45 { 46 public: 47 int ib2; 48 char cb2; 49 public: 50 B2():ib2(10),cb2('2'){} 51 virtual void f() 52 { 53 cout<<"B2::f()"<<endl; 54 } 55 virtual void f2() 56 { 57 cout<<"B2::f2()"<<endl; 58 } 59 virtual void Bf2() 60 { 61 cout<<"B2::Bf2()"<<endl; 62 } 63 }; 64 65 class D: public B1, public B2 66 { 67 public: 68 int id; 69 char cd; 70 public: 71 D():id(100),cd('D'){} 72 73 virtual void f() 74 { 75 cout<<"D::f()"<<endl; 76 } 77 virtual void f1() 78 { 79 cout<<"D::f1()"<<endl; 80 } 81 virtual void f2() 82 { 83 cout<<"D::f2()"<<endl; 84 } 85 virtual void Df() 86 { 87 cout<<"D::Df()"<<endl; 88 } 89 90 }; 91 int main(int argc, char *argv[]) 92 { 93 D d; 94 system("pause"); 95 return 0; 96 }
複製程式碼

在陳皓老師部落格中,直接利用函式指標呼叫C++物件起始位置處虛擬函式表指標指向的虛擬函式表中的虛擬函式,以檢視C++物件的記憶體模型。下面我們主要採用Visual Studio 2010 和 Visual C++下的“cl”編譯器檢視C++物件記憶體模型。

Visual Studio 2010 IDE開發環境中,我們檢視派生類D物件的記憶體模型。如下圖所示:

  

從上兩圖我們可以基本看出:

1、派生類D物件d的記憶體佈局中,由其基類依次組裝而成,再加上派生類自己的成員變數。

2、其中基類佈局依次按照在派生類中的宣告順序排列。

3、每個基類都有自己的虛擬函式表,指向虛擬函式表的指標_vfptr放置在最前面的位置。

為了再進一步瞭解重複繼承中的C++物件記憶體模型,我們採用Visual C++下的“cl”編譯器進行檢視。

在“Microsoft Visual C++”的編譯環境中,我們可以利用編譯器“cl”、連結器“link”、可執行檔案檢視器“dumpbin”來檢視Windows下可執行檔案(COFF格式)的變數、函式怎麼儲存。

“cl”即Visual C++ 的編譯器,即“Compiler”的縮寫。在Visual Studio 2010安裝完後,會有一個批處理檔案用來建立執行這些工具所需要的環境。它位於開始/程式/Microsoft Visual Studio 2010/Visual Studio Tools/Viusual Studio 2010 Command Prompt,這樣我們就可以利用命令列使用VC++的編譯器了。

在“cl”編譯器中有個編譯選項可以檢視C++類的記憶體佈局,使用如下:開啟Visual Studio的命令列提示符即Viusual Studio 2010 Command Prompt,按如下格式輸入:

>cl [.cpp] /d1reportSingleClassLayout[classname]

d1reportSingleClassLayout可以檢視原始檔中所有類及結構體的記憶體佈局,classname為類名,/d1reportSingleClassLayout[classname]之間沒有空格。使用如下圖所示:

使用cl編譯器檢視重複繼承中的C++物件記憶體模型結果如下圖所示:

 從上圖可以看出,編譯器在實現時使用了位元組對齊(Alignment),以實現在物件記憶體中存取更有效率。位元組對齊就是將數值調整到某數的整數倍,在32位計算機中,通常Alignment為4bytes,以使bus的“運輸量”達到最高效率。

可以看出,派生類D物件在記憶體中佔有44個位元組。

 重複繼承中的C++物件內部模型用圖片表示如下:

其中第一個vfptr指向的虛表是B1和D共享的,因此其中的函式介面應該覆蓋了B1和D,而第二個則只有B2的虛表。

從圖中可以看出,在派生類D中,存在著兩份基類B的成員例項,分別為ib和cb,所以在C++物件的記憶體佈局(下)指出這樣可能會出現二義性編譯錯誤。我們可以指定類作用域符::進行限定來消除二義性,也可以在語言層面利用虛擬繼承機制來解決。

二、鑽石型多重虛擬繼承

 在《深度探索C++物件模型》中提到:一個virtual base class subobject只會在derived class中存在一份實體,不管它在class繼承體系中出現多少次!

因此,虛擬繼承的就是為了解決重複繼承中多個間接父類的問題。鑽石型的結構就是最經典的虛擬多重繼承結構。

UML類圖如下:

 

 

如上圖,讓B1和B2各自維護的一個B子物件,摺疊成一個由D維護的單一的B子物件,並且還可以儲存基類和派生類的指標之間的多型指定操作,這對於編譯器實現來說,難度非常高。《深度探索C++物件模型》提到一般的實現方法如下所述:將D物件分割為兩部分,一個不變區域性和一個共享區域性。不變區域性中的資料,不管後繼如何衍化,總是擁有固定的偏移量,所以這一部分資料可以被直接存取,至於共享區域性,所表現的就是虛擬繼承的基類子物件,這一部分的資料,其位置會因為每次的派生操作而有變化,所以它們是間接存取。

所以,一般的佈局策略是安排好派生類物件的不變部分,然後再建立其共享部分。在接下來的分析可以看出,VC++編譯器實現中,在每一個派生類物件中插入一些指標vbptr,每個指標指向一個虛擬繼承的基類子物件。要存取繼承得來的基類子物件,可以使用相關指標間接完成。

要實現虛擬繼承,我們只需要在B1和B2繼承B的語法中加入virtual關鍵字即可。實現程式碼如下:

 

  View Code

 

使用cl編譯器檢視鑽石型虛擬重複繼承中的C++物件記憶體模型結果如下圖所示:

 

 

從上圖可以看出,虛擬重複繼承中的派生類D物件在記憶體中佔有52位元組,比之前多了8個位元組。

 虛擬重複繼承中的C++物件內部模型用圖片表示如下:


 

注意和非虛多重繼承不同的是,第一個虛表只記錄了B1(不包括B)和D的函式介面,B的函式介面在最後的基類虛表中,另外B1和B2部分各有一個vbptr。

從圖中可以看出,VC++編譯器在實現虛擬繼承時,在派生類的物件中安插了兩個vbptr指標。因此,對每個繼承自虛基類的類例項,將增加一個隱藏的“虛基類表指標”(vbptr)成員變數,從而達到間接計算虛基類位置的目的。該變數指向一個全類共享的偏移量表,表中專案記錄了對於該類而言,“虛基類表指標”與虛基類之間的偏移量。由上可以看出,B1虛基類表指標vbptr與虛基類B之間的偏移量是40位元組,B2虛基類表指標vbptr與虛基類B之間的偏移量是24位元組。第一項中-4的含義:表示的是vptr和vbptr的距離,如果B1中沒有虛擬函式的定義,這個地方就會是0。vbptr就是存放在vptr下面的位置。

我們注意到在虛擬繼承的C++物件記憶體佈局中,還有一個4個位元組的vtordisp欄位,vtordisp在MSDN中這樣解釋

Enables the addition of the hidden vtordisp construction/destruction displacement member. The vtordisp pragma is applicable only to code that uses virtual bases. If a derived class overrides a virtual function that it inherits from a virtual base class, and if a constructor or destructor for the derived class calls that function using a pointer to the virtual base class, the compiler may introduce additional hidden “vtordisp” fields into classes with virtual bases.

也就是說如果虛擬繼承中派生類重寫了基類的虛擬函式,並且在建構函式或者解構函式中使用指向基類的指標呼叫了該函式,編譯器會為虛基類新增vtordisp域
#include "stdafx.h"
#include <iostream>  
using namespace std;

class Point
{
public:
	Point(int x = 1, int y = 1) :_x(x), _y(y){}
	virtual void print(){ cout << "This is Point. "; }
protected:
	int _x, _y;
};

class Point3d :virtual public Point
{
public:
	Point3d() :_z(2){}
	void print(){ cout << "This is Point3d. "; }
protected:
	int _z;
};


int main()
{
	Point3d d;
	int *p = (int *)&d;
	p++;
	cout << *p << endl;//輸出_z的值2  
	p++;
	cout << *p << endl; //輸出vtordisp的值,這裡為0  
	p++;
	cout << *p << endl; //輸出vptr的值  

	p++;
	cout << *p << endl;//輸出_x的值 1  

	p++;
	cout << *p << endl; //輸出_y的值1  

	cout << sizeof(Point3d) << endl; //大小為24  
	system("pause");
	return 0;
}
首先注意虛繼承的基類部分在底部,另外Point3d沒有新的虛擬函式,而是隻有一個重寫了基類的print,因此不會在頂部生成一個vfptr,print介面在底部的基類vfptr中,於是p++跳過的是vbptr,則是成員變數z,再p++就是vtordisp。如果Point3d再增加一個新的虛擬函式,則會導致Point3d大小變為28,且需要兩次p++才能定位到z,多了一個屬於派生類Point3d自己的vfptr。 上面說明:vs2010中虛擬繼承中只要派生類重寫了基類的虛擬函式(一旦重寫就可能使用基類的虛擬函式),並且派生類有顯式宣告的建構函式(會認為它可能會呼叫基類的虛擬函式),此時vtordisp域就會被新增,若去掉顯式宣告,讓編譯器生成預設建構函式,則一定不會在建構函式/解構函式中使用基類的虛擬函式,因此則不會生成vtordisp域。當然這個域還可以手動關閉(注意:一定是確保在建構函式和解構函式中不會使用基類的虛擬函式):
#pragma vtordisp( off )
class GetReal : virtual public { ... };
#pragma vtordisp( on )

這個程式在VC++壞境是這樣的輸出結果,通過g++編譯後, 在g++中輸出結果是不一樣的,區別在於各個類的大小。這與編譯器記錄虛擬繼承的方式有關 VC++通過記錄偏移量的方式來找到虛擬基類的位置。所以在類中偏移量佔據一定位元組。g++將偏移量記錄在虛擬函式表中,在虛擬函式表中偏移量為正則存放的是虛擬函式地址;偏移量為負存放的是虛擬基類的偏移量 所以g++上虛擬繼承下的類的大小比VC++編譯的程式要小。
即vs2010中,滿足:1. 虛繼承;2. 派生類重寫了父類虛擬函式;3. 構造/解構函式中可能呼叫基類的虛擬函式(例如有顯式定義的建構函式)
鑽石繼承模型中的vtordisp域 在鑽石繼承模型中,vtordisp只會出現一次,即在基類部分的頂部。例如有如下程式碼:
class B
{
public:
	virtual void fooB(){}
	virtual void funcB(){}
	int mB=0;
};
class B1 :public virtual B
{
public:
	B1() :mB1(1){}
	void fooB(){}
	virtual void fooB1(){}
	int mB1;
};
class B2 :public virtual B
{
public:
	B2() :mB2(2){}
	void funcB(){}
	virtual void fooB2(){}
	int mB2;
};
class D :public B1, B2
{
	D() :mD(3){ }
	virtual void fooD(){}
	int mD;
};
則此時記憶體模型為:
若將其中派生類的重寫或者建構函式全部註釋掉,則會出現如下情況,vtordisp域不再生成。
注意:vtordisp域的生成只看派生類(包括D)是否對虛基類(B)的虛擬函式有重寫,並且有顯式構造/解構函式(可能呼叫虛擬函式),例如如下程式碼:
class B
{
public:
	virtual void fooB(){}
	virtual void funcB(){}
	int mB=0;
};
class B1 :public virtual B
{
public:
	//B1() :mB1(1){}
	void fooB(){}
	virtual void fooB1(){}
	int mB1;
};
class B2 :public virtual B
{
public:
	//B2() :mB2(2){}
	void funcB(){}
	virtual void fooB2(){}
	int mB2;
};
class D :public B1, B2
{
	D() :mD(3){ }
	void fooB(){}
	virtual void fooD(){}
	int mD;
};
仍然會生成vtordisp: 按照前邊的資料內容,這個欄位和編譯選項/vd相關。/vd被稱為構造置換(具體什麼意思,我也不太清楚,慚愧!),它所解決的問題是:由於對類的虛擬基的置換與對其派生類的置換之間有差異,可能會向虛擬函式傳遞錯誤的 this 指標。 該解決方案向類的各個虛擬基提供稱作 vtordisp 欄位的單個構造置換調整。但是如何構造產生錯誤this指標的測試用例,請恕作者才疏學淺不能給出,也希望看到此文的大牛們給出測試用例。