前言
本文通過觀察對象的內存布局,跟蹤函數調用的匯編代碼。分析了C++對象內存的布局情況,虛函數的執行方式,以及虛繼承,等等。
寫這篇文章源於我在論壇上看到的一個貼子。有人問VC使用了哪種方式來實現虛繼承。當時我寫了一點代碼想驗證一下,結果發現情況比我想象的要復雜。所以我就干脆認真把相關的問題都過了一遍,並記錄成本文。
我對於C++對象模型的知識主要來自於Lippman的書《Inside the C++ Object Model》,中譯版為候捷翻的《深度探索C++對象模型》,中英版我都看過,不過我還是推薦中譯版,因為中譯版的確翻得不錯,而且候捷加入了很多的圖,並修正了原版中的一些錯誤。
我所使用的編譯器是VC7.1,文中的代碼我都在VC7.1上驗證通過。如果在其他的編譯器下運行需要作相應的調整,即使是VC7.0和VC6也是如此。不同編譯器產生的匯編代碼也不一樣,如果你在不同編譯器上編譯文中的代碼生成出的匯編代碼和我所列出的不同,也不足為奇。如果你想在其他的編譯器上驗證這些代碼請自行做相應的改動。
另外我發現VC7.1在實現虛繼承時所用的方法和Lippman在書中提到的微軟所用的方法不同,不過那時還沒有VC7.1.有趣的是,Lippman在寫那本書時,是在迪斯尼工作,應該是做和三維影片的渲染軟件相關的事。而現在他已經到了微軟,相信應該是主導VC7.1編譯器的設計工作。另外值得一提的是Herb,此人是C++標准委員會的一員,寫過多本C++方面的經典書籍,現在也已經加入了微軟。雖然我不是微軟的“粉絲”,但對於VC不得不關注。VC8.0的beta版也已經出來了。
在後文中可以看到列出的很多匯編代碼,有些明顯效率很低。這可能是因為我沒有打開編譯器的優化開關。打開優化開關,設置不同的優化選項後,編譯器可能產生出高效得多的匯編代碼。有興趣的朋友可以自行試試,並和文中列出的匯編代碼做一下比較。
為了便於分析和觀察對象的內存布局,我把代碼生成時的結構成員對齊選項設置為1字節,默認為8字節。如果你在自己的工程下編譯文中的代碼,請做同樣的設置。因為我寫了一些函數打印對象中的布局信息,如果對象選項不是1字節,運行這些代碼會出現指針異常錯誤。
文中所列出的代碼可以從附件中下載到。代碼所用到的宏的語義及參數說明,和代碼中每一個類的簡單描述可以在附錄中找到。
普通類對象的內存布局
首先我們從普通類對象的內存布局開始。C000為一個空類,定義如下:
struct C000
{
};
運行如下代碼打印它的大小及對象中的內容。
PRINT_SIZE_DETAIL(C000)
結果為:
The size of C000 is 1
The detail of C000 is cc
可以看到它的大小為1字節,這是一個占位符。我們可以看到它的值是0xcc.在debug模式下,這表示是由編譯器插入的調試代碼所初始化的內存。在release模式下可能是個隨機值,我測試時值為0x00.
定義兩個類,C010和C011如下:
struct C010
{
C010() : c_(0x01) {}
void foo() { c_ = 0x02; }
char c_;
};
struct C011
{
C011() : c1_(0x02), c2_(0x03) {}
char c1_;
char c2_;
};
運行如下代碼打印它們的大小及對象中的內容。
PRINT_SIZE_DETAIL(C010)
PRINT_SIZE_DETAIL(C012)
結果為:
The size of C010 is 1
The detail of C010 is 01
The size of C011 is 2
The detail of C011 is 02 03
我們從對象的內存輸出中可以看到,它們的值就是我們在構造函數中賦的值,C010為0x01,C011為0x0203.大小分別為1、2.
定義C012類。
struct C012
{
static int sfoo() { return 1; }
int foo() { return 1; }
char c_;
static int i_;
};
int C012::i_ = 1;
在這個類中我們加入了一個靜態數據成員,一個普通成員函數和一個靜態成員函數。
運行如下代碼打印它的大小及對象中的內容。
PRINT_SIZE_DETAIL(C012)
結果為:
The size of C012 is 1
The detail of C012 is cc
可以看到它的大小還是1字節,值為0xcc是因為我們沒有初始化它,原因前面說過了。
從上面的結果我們可以映證,普通成員函數,靜態成員函數,及靜態成員變量皆不會在類的對象中有所表示,成員函數和對象的關聯由編譯器在編譯時處理,正如我們會在後面看到的那樣,編譯器會在編譯時決議出正確的普通成員函數地址,並將對象的地址以this指針的方式,做為第一個參數傳遞給普通成員函數,以此來進行關聯。靜態成員函數類似於全局函數,不和具體的對象關聯。靜態成員變量也一樣。靜態成員函數和靜態成員變量和普通的全局函數及全局變量不同之處在於它們多了一層名字限定。
普通繼承類對象的內存布局
下面看看普通繼承類對象的內存布局。
定義一個空類C014從C011繼承,再定義C015也是一個空類從C010和C011繼承。
struct C010
{
C010() : c_(0x01) {}
void foo() { c_ = 0x02; }
char c_;
};
struct C011
{
C011() : c1_(0x02), c2_(0x03) {}
char c1_;
char c2_;
};
struct C014 : private C011
{
};
struct C015 : public C010, private C011
{
};
運行如下代碼打印它們的大小及對象中的內容。
PRINT_SIZE_DETAIL(C014)
PRINT_SIZE_DETAIL(C015)
結果為:
The size of C014 is 2
The detail of C014 is 02 03
The size of C015 is 3
The detail of C015 is 01 02 03
C014的大小為2字節,也就是C011的大小,對象的內存值也是在C011的構造函數中初始化的兩個值0x0203.C015的大小為3字節,也就是C010和C011的大小之和,對象的內存值為0x010203.
這裡我們可以發現父類的成員變量悉數被子類繼承,並且於繼承方式(公有或私有)無關,如C015是私有繼承自C011.繼承方式只影響數據成員的“能見度”。子類對象中屬於從父類繼承的成員變量由父類的構造函數初始化。通常會調用默認構造函數,除非子類在它的構造函數初始化列表中顯式調用父類的非默認構造函數。如果沒有指定,而父類又沒有缺省構造函數,則會產生編譯錯誤。
我們可以再加一層繼承來驗證一下。定義類C016,從C015繼承,並有自己的4字節int成員變量。
struct C016 : C015
{
C016() : i_(1) {}
int i_;
};
運行如下代碼打印它的大小及對象中的內容。
PRINT_SIZE_DETAIL(C016)
結果為:
The size of C016 is 7
The detail of C016 is 01 02 03 01 00 00 00
它的大小為7字節,也就是C015的大小(也即是C010和C011的大小和)加上自身的4字節int變量之和。同樣對象的內存輸出也驗證了這一點,前三個字節為從父類繼承的,後4個字節為自身的int變量,值為1.
因此關於普通繼承,子類的對象布局為父類中的數據成員加上子類中的數據成員,多層繼承時(如C016),頂層類在前,多重繼承時則最左父類在前。