在我調試和研究 netscape 系瀏覽器插件開發時,注意到了這個問題。即,在對象佈局已知(即對象之間具備繼承關係)時,不一樣類型對象的指針進行轉換(不論是隱式的從下向上轉換,仍是強制的從上到下轉換)時,編譯器會根據對象佈局對相應的指針的值進行調整。不論是 microsoft 的編譯器,仍是 gcc 編譯器都會作這個動做,由於這和 C++ 對象模型有關。c++
舉一個簡單的例子,以下代碼:瀏覽器
#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; }
上面的代碼內容爲,B 繼承於 A,A 沒有虛函數,B 有虛函數。所以A對象的起始位置,不包含虛函數表指針。而 B 對象的起始位置,包含虛函數表指針。在 VC 2005 中,會輸出:函數
pb:00480010
pa:00480018
佈局
能夠看到兩個地址之間的差值爲 8 bytes。兩個對象的地址並不相等,是由於虛函數表指針的關係。虛函數表指針一般佔 4 Bytes。而輸出結果中這個差值和對象佈局有關,即也和編譯器的選項中,對象的對齊的設置相關。但總之,這兩個地址存在一個編譯時肯定的差值。在不一樣的條件下,這個差值也多是 4 bytes。例如若是 B 對象的成員 y 改成 int 類型。這個差值就爲 4 bytes。spa
在上面的 demo 中,指針類型從 B* 隱式轉換到了 A*,地址值增長了 8 Bytes。若是指針類型從 A* 強制轉換到 B*,這個地址也會進行相反的調整。觀察彙編代碼能夠看到,這個地址值的偏移調整是編譯器在編譯時插入的操做,由 ADD / SUB 指令完成。這裏,就再也不顯示其彙編代碼了。插件
值得一提的是,在 C++ 中,struct 和 class 本質上沒有區別,僅僅是成員的默認訪問級別不一樣。因此上面的代碼中,把任何一個對象在聲明時,使用 class 或者 struct 關鍵字,都不影響結論。debug
上面的例子簡要的說明了在對象具備繼承關係時,指針轉換過程當中,地址值可能發生調整,這個動做是編譯器完成的。上面的例子,對象之間的地址差別,是由對象頭部是否含有虛函數表指針形成的。下面我要舉一個更詳細的例子來進一步說明這個問題。即,若是一個對象實例包含多個子對象(具備多個父類)時的地址調整。以及爲何在這種狀況下,對象的析構函數必須爲 virtual 函數。指針
第二個例子的代碼以下:調試
#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; }
第二個例子的主要內容是:子類 C1,具備三個父類:P1,P2,P3。全部類均具備虛析構函數,即對象實例有虛函數表指針。下圖顯示的是,類的繼承關係:code
圖 1. 第二個範例中的類繼承關係
當類 C1 被構造時,它將含有三個子對象:P1,P2,P3。咱們知道,第一個父類 P1 的虛函數表指針,是採用了 C1 的虛函數表指針的,即子類具備對父類虛函數的覆蓋能力,這就是 C++ 中實現多態的重要部分。所以在 C1 對象實例中,實際上沒有 P1 的虛函數表指針。而是直接採用了子類的。那麼 P2 和 P3 也是 C1 的父類,P2 和 P3 的虛函數表內容如何獲取呢?這就涉及到了 C++ 對象模型。
P2,P3 的虛函數表不能和 C1 的虛函數表內容合併,這會使得編譯器很難實現對 P2,P3 的虛函數的調用。而是將其向後偏移,即除了第一個父類,其餘父類要在對象中各自保留一個獨立的虛函數表指針。即對象具備 P2,P3 的獨立視角。在這個例子中,對象一共具備三個虛函數表指針,三個視角:P1/C1,P2,P3。對象模型以下圖所示:
圖2. 具備多個「獨立」子對象的對象模型
請注意圖中,在 P2,P3 的析構函數,都有插入了地址調整代碼。這樣,當咱們用 P2 或 P3 的指針,指向一個實際的 C1 實例時,對這個指針調用 delete,都可以以正確的實例地址調用到 C1 的析構函數。
在此範例中,C1 具備三個「獨立」的子對象 P1~P3,這裏「獨立」的意思是指 P1~P3 沒有從屬性的繼承關係(即 P1~P3 之間,沒有一個類是另外一個類的祖先/後代)。這就使得在模型中,子對象的地址發生向後偏移,而不能共用同一個虛函數表指針/視角。
上圖給出 C1 的實例的對象模型。當把指向 C1 的指針,轉換到指向 P2 或 P3 的指針時,前面已經說過,這時候編譯器已經插入了對地址值的調整。在這個例子中,我經過設置成員變量佔用空間的大小,使得地址偏移值分別爲 0x10,0x20。上面的代碼產生的輸出以下(在 Windows 中使用 VC 編譯或在 Linux 下使用 g++ 編譯獲得的結果類似,僅對象被動態分配的地址值不一樣 ):
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.
在輸出的中間部分,給出了對象的二進制內容,即將其 dump。能夠看到第一行爲 P1/C1 視角。第二行爲 P2 視角,第三行爲 P3 視角。第四行爲 C1 的成員變量。
同時能夠看到,再對 P2* 的指針調用 delete 時,對象可以正確的析構。這是由於編譯器在構造 C1 對象時,由於 P2,P3 的析構函數是虛函數,因此編譯器對其析構函數也加入了地址調整處理。因爲編譯器已知 P2,P3 相對於 C1 的佈局,因此它知道對象真正的內存起點,所以它在代碼段中插入了對應的 trunk 代碼,即將對象地址減去偏移值後,獲得對象實際地址,而後跳轉到 C1 的析構函數。以上結論是經過反彙編 debug 版本的輸出結果獲得的。這裏,對彙編代碼的展現和分析省略。
假設去掉 P2 的析構函數的 virtual 關鍵字,則運行上面的代碼就會彈出錯誤。所以這時編譯器直接把 P2 指針的值當作一個實際的 P2 對象地址,來進行析構,即它會嘗試 free 這個地址值。而很顯然這樣是錯誤的。在 debug 模式下,會彈出以下的 assertion fail 對話框:
所以,從上面的例子中能夠看到,類的虛構函數爲何要定義成虛函數。在 effective c++ 書中,對此是這樣說的,若是虛構函數不是虛的,則這個對象可能只是被半析構。固然對於一個普通的單一繼承的對象來講,若是實例只有一個虛函數表指針,若是子類中都是基本數據類型不須要額外處理,實際上這樣也不會致使什麼問題。由於分配內存時,在內存前面的信息塊已經描述了內存的大小。因此釋放內存的環節不會出錯。但若是子類對象的成員中也須要釋放,則這時會發生問題。例如某個成員指向動態申請的內存,則很顯然這時它們會成爲內存泄露狀態。
結論:
經過以上分析,能夠看到,
(1)在具備繼承關係的類型之間進行指針類型轉換,編譯器在轉換時添加了地址調整。
(2)當存在多個父類且父類虛構函數是虛函數時,因爲子對象相對於對象基址發生了偏移,因此編譯器也會爲每一個具備偏移的父類視角(沒有排在父類列表的首位),插入一段 trunk 代碼,先調整地址爲實際對象地址,而後再跳轉到實際對象的析構函數,從而保證對象正確被析構。
補充討論:
在第二個例子中,編譯器在 C1 的構造和析構函數中,也會一樣進行相關的地址調整。例如在 C1 的構造函數中,編譯器負責插入對 C1 的全部父類的構造函數的調用(構造/析構函數只負責傳入的對象地址進行初始化,不負責內存分配/釋放)。因爲 P2,P3 視角相對於對象 C1 的地址存在偏移,因此調用 P2,P3的構造函數時,也會相應的調整對象地址到對應視角,這是顯而易見的。以下是 C1 的構造函數的 VC debug 版本的反彙編片斷:
能夠看到,在分別調用 P1,P2,P3 的構造函數時,構造函數實際上也爲對象頭部填充了虛函數表的地址(這時候 P2,P3 構造函數填充的都是實際的 P2,P3 的虛函數表地址),而後編譯器負責的部分,對 P1,P2,P3 的虛函數表指針再次賦值。這時候 P1 的虛函數表指針實際指向了 C1 的虛函數表。P2,P3 視角的虛函數表指向了專爲 C1 定製的虛函數表(這些定製的虛函數表,只有析構函數入口是特殊的,其餘部分和原虛函數表內容相同)。
mov [ebp+var_14], ecx mov ecx, [ebp+var_14] call sub_4110AA ; 調用 P1_Constructor mov [ebp+var_4], 0 mov ecx, [ebp+var_14] add ecx, 10h call sub_4110B9 ; 調用 P2_Contructor mov byte ptr [ebp+var_4], 1 mov ecx, [ebp+var_14] add ecx, 20h call sub_4110BE ; 調用 P3_Contructor mov eax, [ebp+var_14] mov dword ptr [eax], offset off_4176B8 ; 重設 P1/C1 vftable 地址 mov eax, [ebp+var_14] mov dword ptr [eax+10h], offset off_4176A8 ; 重設 P2 視角 vftable 地址 mov eax, [ebp+var_14] mov dword ptr [eax+20h], offset off_417698 ; 重設 P3 視角 vftable 地址 mov eax, [ebp+var_14] ; 如下是用戶編寫的 C1 構造函數的內容 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 的析構函數是非虛的,子類 C1 的析構函數是虛的,這時候的行爲是比較古怪的,即 C1 的虛函數表中也沒有 C1 的析構函數了(看起來要讓子類具備虛析構函數,它的父類也必須首先具備虛析構函數才行)。這時候若是用 P1 指針,析構 C1 對象,則實際上只會調用 P1 的析構函數,而後(假設對象由 new 操做符分配)由 delete 運算符負責釋放對象所佔用的內存。即形成 C1 對象被半析構的結果。這是 P1 的虛函數表被 C1 重疊覆蓋的較好結果。若是對象視角之間存在偏移(例如用 P2 指針 delete C1 對象,且 P2 的析構函數爲非虛),則 delete 時,因爲釋放內存時的地址,並非實際分配時返回的地址,所以能夠確定,必然致使運行時錯誤。