標籤:
在我調試和研究 netscape 系瀏覽器外掛程式開發時,注意到了這個問題。即,在物件版面配置已知(即對象之間具有繼承關係)時,不同類型對象的指標進行轉換(不管是隱式的從下向上轉換,還是強制的從上到下轉換)時,編譯器會根據物件版面配置對相應的指標的值進行調整。不管是 microsoft 的編譯器,還是 gcc 編譯器都會做這個動作,因為這和 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 _tmain(int argc, _TCHAR* 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。
在上面的 demo 中,指標類型從 B* 隱式轉換到了 A*,地址值增加了 8 Bytes。如果指標類型從 A* 強制轉換到 B*,這個地址也會進行相反的調整。觀察彙編代碼可以看到,這個地址值的位移調整是編譯器在編譯時間插入的操作,由 ADD / SUB 指令完成。這裡,就不再顯示其彙編代碼了。
值得一提的是,在 C++ 中,struct 和 class 本質上沒有區別,僅僅是成員的預設存取層級不同。所以上面的代碼中,把任何一個對象在聲明時,使用 class 或者 struct 關鍵字,都不影響結論。
上面的例子簡要的說明了在對象具有繼承關係時,指標轉換過程中,地址值可能發生調整,這個動作是編譯器完成的。上面的例子,對象之間的地址差異,是由對象頭部是否含有虛函數表指標造成的。下面我要舉一個更詳細的例子來進一步說明這個問題。即,如果一個對象執行個體包含多個子物件(具有多個父類)時的地址調整。以及為什麼在這種情況下,對象的解構函式必須為 virtual 函數。
第二個例子的代碼如下:
#include <string.h>#include <stdio.h>//Parent 1class 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 Bytesclass 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 Bytesclass 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); }};//Child1class 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 _tmain(int argc, _TCHAR* 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); printf("&c1->m_y4: %p\n", &c1->m_y4); //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。所有類均具有虛解構函式,即對象執行個體有虛函數表指標。顯示的是,類的繼承關係:
圖 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. 具有多個子物件的物件模型
給出 C1 的執行個體的物件模型。當把指向 C1 的指標,轉換到指向 P2 或 P3 的指標時,前面已經說過,這時候編譯器已經插入了對地址值的調整。在這個例子中,我通過設定成員變數佔用空間的大小,使得地址位移值分別為 0x10,0x20。上面的代碼產生的輸出如下:
P1 constructor.
P2 constructor.
P3 constructor.
C1 constructor.
C1: SayHi
c1: 003E5068
p1: 003E5068
p2: 003E5078
p3: 003E5088
&c1->m_y4: 003E50A4
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++ 書中,對此是這樣說的,如果虛構函數不是虛的,則這個對象可能只是被半析構。當然對於一個普通的單一繼承的對象來說,如果執行個體只有一個虛函數表指標,如果子類中都是基礎資料型別 (Elementary Data Type)不需要額外處理,實際上這樣也不會導致什麼問題。因為分配記憶體時,在記憶體前面的資訊塊已經描述了記憶體的大小。所以釋放記憶體的環節不會出錯。但如果子類對象的成員中也需要釋放,則這時會發生問題。例如某個成員指向動態申請的記憶體,則很顯然這時它們會成為記憶體泄露狀態。
結論:
通過以上分析,可以看到,
(1)在具有繼承關係的類型之間進行指標類型轉換,編譯器在轉換時添加了地址調整。
(2)當存在多個父類且父類虛構函數是虛函數時,由於子物件相對於對象基址發生了位移,所以編譯器也會為每個具有位移的父類視角(沒有排在父類列表的首位),插入一段 trunk 代碼,先調整地址為實際對象地址,然後再跳轉到實際對象的解構函式,從而保證對象正確被析構。
物件版面配置已知時 C++ 對象指標的轉換時地址調整