在我調(diào)試和研究 netscape 系瀏覽器插件開發(fā)時,注意到了這個問題。即,在對象布局已知(即對象之間具有繼承關(guān)系)時,不同類型對象的指針進(jìn)行轉(zhuǎn)換(不管是隱式的從下向上轉(zhuǎn)換,還是強(qiáng)制的從上到下轉(zhuǎn)換)時,編譯器會根據(jù)對象布局對相應(yīng)的指針的值進(jìn)行調(diào)整。不管是 microsoft 的編譯器,還是 gcc 編譯器都會做這個動作,因為這和 C++ 對象模型有關(guān)。
舉一個簡單的例子,如下代碼:
#include <stdio.h> class A { public: int x; void foo1() { printf("A:foo1 \n"); }; }; class B : public A { public: double y; virtual void foo2() { printf("B:foo2 \n"); }; }; int main(int argc, char* argv[]) { B* pb = (B*)0x00480010; A* pa = pb; printf(" pb:%p\n pa:%p\n", pb, pa); getchar(); return 0; }
上面的代碼內(nèi)容為,B 繼承于 A,A 沒有虛函數(shù),B 有虛函數(shù)。因此A對象的起始位置,不包含虛函數(shù)表指針。而 B 對象的起始位置,包含虛函數(shù)表指針。在 VC 2005 中,會輸出:
pb:00480010 可以看到兩個地址之間的差值為 8 bytes。兩個對象的地址并不相等,是因為虛函數(shù)表指針的關(guān)系。虛函數(shù)表指針通常占 4 Bytes。而輸出結(jié)果中這個差值和對象布局有關(guān),即也和編譯器的選項中,對象的對齊的設(shè)置相關(guān)。但總之,這兩個地址存在一個編譯時確定的差值。在不同的條件下,這個差值也可能是 4 bytes。例如如果 B 對象的成員 y 改為 int 類型。這個差值就為 4 bytes。
在上面的 demo 中,指針類型從 B* 隱式轉(zhuǎn)換到了 A*,地址值增加了 8 Bytes。如果指針類型從 A* 強(qiáng)制轉(zhuǎn)換到 B*,這個地址也會進(jìn)行相反的調(diào)整。觀察匯編代碼可以看到,這個地址值的偏移調(diào)整是編譯器在編譯時插入的操作,由 ADD / SUB 指令完成。這里,就不再顯示其匯編代碼了。
值得一提的是,在 C++ 中,struct 和 class 本質(zhì)上沒有區(qū)別,僅僅是成員的默認(rèn)訪問級別不同。所以上面的代碼中,把任何一個對象在聲明時,使用 class 或者 struct 關(guān)鍵字,都不影響結(jié)論。
上面的例子簡要的說明了在對象具有繼承關(guān)系時,指針轉(zhuǎn)換過程中,地址值可能發(fā)生調(diào)整,這個動作是編譯器完成的。上面的例子,對象之間的地址差異,是由對象頭部是否含有虛函數(shù)表指針造成的。下面我要舉一個更詳細(xì)的例子來進(jìn)一步說明這個問題。即,如果一個對象實例包含多個子對象(具有多個父類)時的地址調(diào)整。以及為什么在這種情況下,對象的析構(gòu)函數(shù)必須為 virtual 函數(shù)。
第二個例子的代碼如下:
#include <string.h> #include <stdio.h> //Parent 1 class P1 { public: int m_x1; int m_x2; int m_x3; public: P1() { m_x1 = 0x12345678; m_x2 = 0xAABBCCDD; m_x3 = 0xEEFF0011; printf("P1 constructor.\n"); } virtual ~P1() { printf("P1 destructor.\n"); } virtual void SayHi() { printf("P1: hello!\n"); } }; //Parent 2: 16 Bytes class P2 { public: char m_name[12]; public: P2() { strcpy(m_name, "Jack"); printf("P2 constructor.\n"); } virtual ~P2() { printf("P2 destructor.\n"); } virtual void ShowName() { printf("P2 name: %s\n", m_name); } }; //Parent 3: 16 Bytes class P3 { public: char m_nick[12]; public: P3() { strcpy(m_nick, "fafa"); printf("P3 constructor.\n"); } virtual ~P3() { printf("P3 destructor.\n"); } virtual void ShowNick() { printf("P3 Nick: %s\n", m_nick); } }; //Child1 class C1 : public P1, public P2, public P3 { public: int m_y1; int m_y2; int m_y3; int m_y4; public: C1() { m_y1 = 0x01; m_y2 = 0x02; m_y3 = 0x03; m_y4 = 0x04; printf("C1 constructor.\n"); } virtual ~C1() { printf("C1 destructor.\n"); } virtual void SayHi() { printf("C1: SayHi\n"); } virtual void C1_Func_01() { printf("C1: C1_Func_01\n"); } }; int main(int argc, char* argv[]) { C1 *c1 = new C1(); P1 *p1 = c1; P2 *p2 = c1; P3 *p3 = c1; p1->SayHi(); printf("c1: %p\np1: %p\np2: %p\np3: %p\n", c1, p1, p2, p3); //show object's binary data unsigned char* pBytes = (unsigned char*)(c1); //_CrtMemBlockHeader *pHead = pHdr(pBytes); size_t cb = sizeof(C1); unsigned int i; for(i = 0; i < cb; i++) { printf("%02X ", pBytes[i] & 0xFF); if((i & 0xF) == 0xF) printf("\n"); } printf("\n"); //_CrtDumpMemoryLeaks(); delete p2; return 0; }
第二個例子的主要內(nèi)容是:子類 C1,具有三個父類:P1,P2,P3。所有類均具有虛析構(gòu)函數(shù),即對象實例有虛函數(shù)表指針。下圖顯示的是,類的繼承關(guān)系:
圖 1. 第二個范例中的類繼承關(guān)系
當(dāng)類 C1 被構(gòu)造時,它將含有三個子對象:P1,P2,P3。我們知道,第一個父類 P1 的虛函數(shù)表指針,是采用了 C1 的虛函數(shù)表指針的,即子類具有對父類虛函數(shù)的覆蓋能力,這就是 C++ 中實現(xiàn)多態(tài)的重要部分。因此在 C1 對象實例中,實際上沒有 P1 的虛函數(shù)表指針。而是直接采用了子類的。那么 P2 和 P3 也是 C1 的父類,P2 和 P3 的虛函數(shù)表內(nèi)容如何獲取呢?這就涉及到了 C++ 對象模型。
P2,P3 的虛函數(shù)表不能和 C1 的虛函數(shù)表內(nèi)容合并,這會使得編譯器很難實現(xiàn)對 P2,P3 的虛函數(shù)的調(diào)用。而是將其向后偏移,即除了第一個父類,其他父類要在對象中各自保留一個獨立的虛函數(shù)表指針。即對象具有 P2,P3 的獨立視角。在這個例子中,對象一共具有三個虛函數(shù)表指針,三個視角:P1/C1,P2,P3。對象模型如下圖所示:
圖2. 具有多個“獨立”子對象的對象模型
請注意圖中,在 P2,P3 的析構(gòu)函數(shù),都有插入了地址調(diào)整代碼。這樣,當(dāng)我們用 P2 或 P3 的指針,指向一個實際的 C1 實例時,對這個指針調(diào)用 delete,都能夠以正確的實例地址調(diào)用到 C1 的析構(gòu)函數(shù)。
在此范例中,C1 具有三個“獨立”的子對象 P1~P3,這里“獨立”的意思是指 P1~P3 沒有從屬性的繼承關(guān)系(即 P1~P3 之間,沒有一個類是另一個類的祖先/后代)。這就使得在模型中,子對象的地址發(fā)生向后偏移,而不能共用同一個虛函數(shù)表指針/視角。
上圖給出 C1 的實例的對象模型。當(dāng)把指向 C1 的指針,轉(zhuǎn)換到指向 P2 或 P3 的指針時,前面已經(jīng)說過,這時候編譯器已經(jīng)插入了對地址值的調(diào)整。在這個例子中,我通過設(shè)置成員變量占用空間的大小,使得地址偏移值分別為 0x10,0x20。上面的代碼產(chǎn)生的輸出如下(在 Windows 中使用 VC 編譯或在 Linux 下使用 g++ 編譯得到的結(jié)果相似,僅對象被動態(tài)分配的地址值不同 ):
P1 constructor. P2 constructor. P3 constructor. C1 constructor. C1: SayHi c1: 003E5068 p1: 003E5068 p2: 003E5078 p3: 003E5088 B8 76 41 00 78 56 34 12 DD CC BB AA 11 00 FF EE A8 76 41 00 4A 61 63 6B 00 CD CD CD CD CD CD CD 98 76 41 00 66 61 66 61 00 CD CD CD CD CD CD CD 01 00 00 00 02 00 00 00 03 00 00 00 04 00 00 00 C1 destructor. P3 destructor. P2 destructor. P1 destructor.
在輸出的中間部分,給出了對象的二進(jìn)制內(nèi)容,即將其 dump。可以看到第一行為 P1/C1 視角。第二行為 P2 視角,第三行為 P3 視角。第四行為 C1 的成員變量。
同時可以看到,再對 P2* 的指針調(diào)用 delete 時,對象能夠正確的析構(gòu)。這是因為編譯器在構(gòu)造 C1 對象時,因為 P2,P3 的析構(gòu)函數(shù)是虛函數(shù),所以編譯器對其析構(gòu)函數(shù)也加入了地址調(diào)整處理。由于編譯器已知 P2,P3 相對于 C1 的布局,所以它知道對象真正的內(nèi)存起點,因此它在代碼段中插入了對應(yīng)的 trunk 代碼,即將對象地址減去偏移值后,得到對象實際地址,然后跳轉(zhuǎn)到 C1 的析構(gòu)函數(shù)。以上結(jié)論是通過反匯編 debug 版本的輸出結(jié)果得到的。這里,對匯編代碼的展示和分析省略。
假設(shè)去掉 P2 的析構(gòu)函數(shù)的 virtual 關(guān)鍵字,則運行上面的代碼就會彈出錯誤。因此這時編譯器直接把 P2 指針的值當(dāng)做一個實際的 P2 對象地址,來進(jìn)行析構(gòu),即它會嘗試 free 這個地址值。而很顯然這樣是錯誤的。在 debug 模式下,會彈出如下的 assertion fail 對話框:
因此,從上面的例子中可以看到,類的虛構(gòu)函數(shù)為什么要定義成虛函數(shù)。在 effective c++ 書中,對此是這樣說的,如果虛構(gòu)函數(shù)不是虛的,則這個對象可能只是被半析構(gòu)。當(dāng)然對于一個普通的單一繼承的對象來說,如果實例只有一個虛函數(shù)表指針,如果子類中都是基本數(shù)據(jù)類型不需要額外處理,實際上這樣也不會導(dǎo)致什么問題。因為分配內(nèi)存時,在內(nèi)存前面的信息塊已經(jīng)描述了內(nèi)存的大小。所以釋放內(nèi)存的環(huán)節(jié)不會出錯。但如果子類對象的成員中也需要釋放,則這時會發(fā)生問題。例如某個成員指向動態(tài)申請的內(nèi)存,則很顯然這時它們會成為內(nèi)存泄露狀態(tài)。
結(jié)論:
通過以上分析,可以看到,
(1)在具有繼承關(guān)系的類型之間進(jìn)行指針類型轉(zhuǎn)換,編譯器在轉(zhuǎn)換時添加了地址調(diào)整。
(2)當(dāng)存在多個父類且父類虛構(gòu)函數(shù)是虛函數(shù)時,由于子對象相對于對象基址發(fā)生了偏移,所以編譯器也會為每個具有偏移的父類視角(沒有排在父類列表的首位),插入一段 trunk 代碼,先調(diào)整地址為實際對象地址,然后再跳轉(zhuǎn)到實際對象的析構(gòu)函數(shù),從而保證對象正確被析構(gòu)。
補(bǔ)充討論:
在第二個例子中,編譯器在 C1 的構(gòu)造和析構(gòu)函數(shù)中,也會同樣進(jìn)行相關(guān)的地址調(diào)整。例如在 C1 的構(gòu)造函數(shù)中,編譯器負(fù)責(zé)插入對 C1 的所有父類的構(gòu)造函數(shù)的調(diào)用(構(gòu)造/析構(gòu)函數(shù)只負(fù)責(zé)傳入的對象地址進(jìn)行初始化,不負(fù)責(zé)內(nèi)存分配/釋放)。由于 P2,P3 視角相對于對象 C1 的地址存在偏移,所以調(diào)用 P2,P3的構(gòu)造函數(shù)時,也會相應(yīng)的調(diào)整對象地址到對應(yīng)視角,這是顯而易見的。如下是 C1 的構(gòu)造函數(shù)的 VC debug 版本的反匯編片段:
可以看到,在分別調(diào)用 P1,P2,P3 的構(gòu)造函數(shù)時,構(gòu)造函數(shù)實際上也為對象頭部填充了虛函數(shù)表的地址(這時候 P2,P3 構(gòu)造函數(shù)填充的都是實際的 P2,P3 的虛函數(shù)表地址),然后編譯器負(fù)責(zé)的部分,對 P1,P2,P3 的虛函數(shù)表指針再次賦值。這時候 P1 的虛函數(shù)表指針實際指向了 C1 的虛函數(shù)表。P2,P3 視角的虛函數(shù)表指向了專為 C1 定制的虛函數(shù)表(這些定制的虛函數(shù)表,只有析構(gòu)函數(shù)入口是特殊的,其他部分和原虛函數(shù)表內(nèi)容相同)。 mov [ebp+var_14], ecx mov ecx, [ebp+var_14] call sub_4110AA ; 調(diào)用 P1_Constructor mov [ebp+var_4], 0 mov ecx, [ebp+var_14] add ecx, 10h call sub_4110B9 ; 調(diào)用 P2_Contructor mov byte ptr [ebp+var_4], 1 mov ecx, [ebp+var_14] add ecx, 20h call sub_4110BE ; 調(diào)用 P3_Contructor mov eax, [ebp+var_14] mov dword ptr [eax], offset off_4176B8 ; 重設(shè) P1/C1 vftable 地址 mov eax, [ebp+var_14] mov dword ptr [eax+10h], offset off_4176A8 ; 重設(shè) P2 視角 vftable 地址 mov eax, [ebp+var_14] mov dword ptr [eax+20h], offset off_417698 ; 重設(shè) P3 視角 vftable 地址 mov eax, [ebp+var_14] ; 以下是用戶編寫的 C1 構(gòu)造函數(shù)的內(nèi)容 mov dword ptr [eax+30h], 1 mov eax, [ebp+var_14] mov dword ptr [eax+34h], 2 mov eax, [ebp+var_14] mov dword ptr [eax+38h], 3 mov eax, [ebp+var_14] mov dword ptr [eax+3Ch], 4 mov esi, esp push offset aC1Constructor_ ; "C1 constructor.\n" call ds:printf add esp, 4
如果父類 P1 的析構(gòu)函數(shù)是非虛的,子類 C1 的析構(gòu)函數(shù)是虛的,這時候的行為是比較古怪的,即 C1 的虛函數(shù)表中也沒有 C1 的析構(gòu)函數(shù)了(看起來要讓子類具有虛析構(gòu)函數(shù),它的父類也必須首先具有虛析構(gòu)函數(shù)才行)。這時候如果用 P1 指針,析構(gòu) C1 對象,則實際上只會調(diào)用 P1 的析構(gòu)函數(shù),然后(假設(shè)對象由 new 操作符分配)由 delete 運算符負(fù)責(zé)釋放對象所占用的內(nèi)存。即造成 C1 對象被半析構(gòu)的結(jié)果。這是 P1 的虛函數(shù)表被 C1 重疊覆蓋的較好結(jié)果。如果對象視角之間存在偏移(例如用 P2 指針 delete C1 對象,且 P2 的析構(gòu)函數(shù)為非虛),則 delete 時,由于釋放內(nèi)存時的地址,并不是實際分配時返回的地址,因此可以肯定,必然導(dǎo)致運行時錯誤。 |
|