帶虛函數的類的物件版面配置(2)
接下來我們看看多重繼承。定義兩個類,各含一個虛函數,及一個資料成員。再從這兩個類派生一個空子類。
struct C041
{
C041() : c_(0x01) {}
virtual void foo() { c_ = 0x02; }
char c_;
};
struct C042
{
C042() : c_(0x02) {}
virtual void foo2() {}
char c_;
};
struct C051 : public C041, public C042
{
};
運行如下代碼:
PRINT_SIZE_DETAIL(C041)
PRINT_SIZE_DETAIL(C042)
PRINT_SIZE_DETAIL(C051)
結果為:
The size of C041 is 5
The detail of C041 is 64 b3 45 00 01
The size of C042 is 5
The detail of C042 is 68 b3 45 00 02
The size of C051 is 10
The detail of C051 is 6c b4 45 00 01 68 b4 45 00 02
注意,首先我們觀察C051的對象輸出,發現它的大小為10位元組,這說明它有兩個虛表指標,從匯出的記憶體資料我們可以推斷,首先是一個虛表指標,然後是從C041繼承的成員變數,值也是我們在C041的建構函式中賦的值0x01,然後又是一個虛表指標,再是從C042繼承的成員變數,值為0x02。
為了驗證,我們再運行如下代碼:
C041 c041;
C042 c042;
C051 c051;
PRINT_VTABLE_ITEM(c041, 0, 0)
PRINT_VTABLE_ITEM(c042, 0, 0)
PRINT_VTABLE_ITEM(c051, 0, 0)
PRINT_VTABLE_ITEM(c051, 5, 0)
注意最後一行的第二個參數,5。它是從對象起始地址開始到虛表指標的位移值(按位元組計算),從上面的對象記憶體輸出我們看到C041的大小為5位元組,因此C051中第二個虛表指標的起始位置距對象地址的位移為5位元組。輸出的結果為:
(註:這個位移值是通過觀察而判斷出來的,並不通用,而且它依賴於我們前面所說的編譯器在產生代碼時所用的結構成員對齊,我們將這個值設為1。如果設為其他值會影響對象的大小及這個位移值。參見第一篇起始處的說明。下同。)
c041 : objadr:0012FB88 vpadr:0012FB88 vtadr:0045B364 vtival(0):0041DF1E
c042 : objadr:0012FB78 vpadr:0012FB78 vtadr:0045B368 vtival(0):0041D43D
c051 : objadr:0012FB64 vpadr:0012FB64 vtadr:0045B46C vtival(0):0041DF1E
c051 : objadr:0012FB64 vpadr:0012FB69 vtadr:0045B468 vtival(0):0041D43D
這下我們可以看到C051的兩個虛表指標指向兩個不現的虛表(第3、4行的vtadr列),而虛表中的條目的值分別等於C041和C042(即它的兩個父類)的虛表條目的值(第1、3行和2、4行的vtival列的值相同)。
為什麼子類要有兩個虛表,而不是將它們合并為一個。主要是在處理類型的動態轉換時這種物件版面配置更方便調整指標,後面我們看到這樣的例子。
如果子類重寫父類的虛函數會怎麼樣?前面的類C071我們已經看到過一次了。我們再定義一個從C041和C042派生的類C082,並重寫這兩個父類中的虛函數,同時再增加一個虛函數。
struct C041
{
C041() : c_(0x01) {}
virtual void foo() { c_ = 0x02; }
char c_;
};
struct C042
{
C042() : c_(0x02) {}
virtual void foo2() {}
char c_;
};
struct C082 : public C041, public C042
{
C082() : c_(0x03) {}
virtual void foo() {}
virtual void foo2() {}
virtual void foo3() {}
char c_;
};
運行和上面類似的代碼:
PRINT_SIZE_DETAIL(C082)
C041 c041;
C042 c042;
C082 c082;
PRINT_VTABLE_ITEM(c041, 0, 0)
PRINT_VTABLE_ITEM(c042, 0, 0)
PRINT_VTABLE_ITEM(c082, 0, 0)
PRINT_VTABLE_ITEM(c082, 5, 0)
結果為:
The size of C082 is 11
The detail of C082 is 70 b3 45 00 01 6c b3 45 00 02 03
c041 : objadr:0012FA74 vpadr:0012FA74 vtadr:0045B364 vtival(0):0041DF1E
c042 : objadr:0012FA64 vpadr:0012FA64 vtadr:0045B368 vtival(0):0041D43D
c082 : objadr:0012FA50 vpadr:0012FA50 vtadr:0045B370 vtival(0):0041D87A
c082 : objadr:0012FA50 vpadr:0012FA55 vtadr:0045B36C vtival(0):0041D483
果然C082的兩個虛表中的條目值都和父類的不一樣了(vtival列),指向了重寫後的新函數地址。觀察C082的大小和對象記憶體,我們可以知道它並沒有為新定義的虛函數foo3產生新的虛表。那麼foo3的函數地址到底是加到了類的第一個虛表,還是第二個虛表中?在調試狀態下,我們在“局部變數”視窗中展開c082對象。我們可以看到兩個虛表及其中的條目,但兩個虛表都只能看到第一個條目。這應該是VC7.1IDE的一個小BUG。看來我們只有另想辦法來驗證。我們先把兩個虛表中的第二個條目位置上的值列印出來。運行如下代碼。
PRINT_VTABLE_ITEM(c082, 0, 1)
PRINT_VTABLE_ITEM(c082, 5, 1)
結果如下:
c082 : objadr:0012FA50 vpadr:0012FA50 vtadr:0045B370 vtival(1):0041D32F
c082 : objadr:0012FA50 vpadr:0012FA55 vtadr:0045B36C vtival(1):0041D87A
然後我們調用一下foo3函數:
c082.foo3();
查看它的彙編代碼:
004225F3 lea ecx,[ebp+FFFFFB74h]
004225F9 call 0041D32F
第2條call指令後的地址就是foo3的函數地址了(實際上是一個跳轉指令),對照前面的輸出我們就可以知道,子類新定義的虛函數對應的虛表條目加入到了子類的第一個虛表中,並位於繼承自父類的虛表條目之後。
類型動態轉換和類型強制轉換
為了驗證前面提到過的類型動態轉換(即dynamic_cast轉換),以及物件類型的強制轉換。我們利用前面定義的C041、C042及C082類來進行驗證。
運行下列代碼:
c082.C041::c_ = 0x05;
PRINT_VTABLE_ITEM(c041, 0, 0)
PRINT_DETAIL(C041, ((C041)c082))
PRINT_VTABLE_ITEM(((C041)c082), 0, 0)
PRINT_VTABLE_ITEM(c082, 5, 0)
C042 * pt = dynamic_cast<C042*>(&c082);
PRINT_VTABLE_ITEM(*pt, 0, 0)
第2行和第5行是為了對照方便而列印原對象中的資訊。第3、4行把C082物件類型進行強制轉換並分別列印轉換後的對象記憶體資訊及虛表資訊。第6行我們用dynamic_cast進行了一次動態類型轉換,從子類的指標轉型為右父類的指標,再把指標指向的對象的資訊列印出來。
結果為:
c041 : objadr:0012FA74 vpadr:0012FA74 vtadr:0045B364 vtival(0):0041DF1E
The detail of C041 is 64 b3 45 00 05
((C041)c082) : objadr:0012F2A3 vpadr:0012F2A3 vtadr:0045B364 vtival(0):0041DF1E
c082 : objadr:0012FA50 vpadr:0012FA55 vtadr:0045B36C vtival(0):0041D483
*pt : objadr:0012FA55 vpadr:0012FA55 vtadr:0045B36C vtival(0):0041D483
首先我們比較最後兩行,從objadr列我們可以知道pt指向的並不是c082對象的起始地址,而是指向了c082的第2個虛表指標的所在地址(因為最後一行的objadr值等於倒數第2行的vpadr的值)。倒數第二行的vpadr值是c082對象的第2個虛表指標(我們在輸出時指定了位移值5)。而這個地址正是c082對象中屬於從C042類繼承而來的部分,即在進行動態類型轉換時,除了改變類型資訊,編譯器還調整了指標的位置,以確保轉換語義的正確性。所以我們可以知道,對指向有複雜繼承結構的類對象的指標進行類型轉換(一般在繼承樹中向上或向下轉換)時,必須使用dynamic_cast,它會正確的處理指標位置的調整,如果轉換是非法的,它會返回一個NULL指標。使用dynamic_cast時記得要做這個檢查,文中為了簡略把這些檢查省去了。這種檢查可以通過宏來定義,以便於在release版中去掉,提高效率。
再將((C041)c082)和c082兩行的輸出進行對照,可以發現對對象進行向上的類型強制轉換實際上編譯器產生了一個新的臨時對象,因為它們的objadr列不一樣了,這表明它們已經不是同一個對象。再觀察c041、((C041)c082)及c082三行的vtadr和vtival(0),前兩行相比是一樣的,而後兩行相比就不一樣了。這也說明編譯器在處理強制轉換時,實際上是new了一個新的C041對象出來。因為對象的強制類型轉換不象指標的動態類型轉換,指標的動態類型轉換同時要確保多態的語義,所以只需要調整指標位置。而對象強制類型轉換,還要調整虛表中的條目值,因為物件類型轉換不需要多態的行為。c082類的第一個虛表的第一個條目中存放的是C082::foo()函數的地址,做了物件類型轉換後,應該調整為C041::foo()才對,做這種調整過於複雜,所以編譯器乾脆new了一個新的C041的臨時對象出來。對比這三行的最後二列即知。我不知道這是否是C++標準規範中定義的行為,改天查到我再更新上來。
在new出新對象的同時,編譯器還將原對象中屬於父類部分的資料成員的值拷貝了過來。注意代碼的第1行,c082.C041::c_ = 0x05;,我們先把c082對象中從C041類繼承過來的資料成員的值改寫為0x05,原來是的值是0x01,由C041的建構函式初始化。我們觀察輸出的第2行,上面說了這個被列印的對象並非c082而是編譯器new出的來的臨時對象,可以注意到對象的最後一位元組為0x05,即資料成員的值。所以我們知道編譯器除了new出新的臨時對象外,還把原對象中相應的資料成員的值也複製了過來。
這和我以前的認識有點偏差,直觀上我一直以為這種轉換不會產生新的對象,不過仔細想想編譯器的這種作法也是對的,如果不產生新的對象,就意味著它要象前述的那樣動態改變虛表中條目的值。但new出臨時對象,也意味著使用下列的語句調用,可能產生意想不到的結果。
((C041)c082).somefun();
如果somefun函數會改變對象的狀態,那麼上邊的代碼執行後,c082的狀態並不會被改變。因為somefun實際改變的是臨時對象,在執行完後該臨時對象就扔掉了。這和直觀的認識有所差異,一般會認為這個調用會作用於c082對象上。為了驗證我們聲明以下兩個類。
struct C010
{
C010() : c_(0x01) {}
void foo() { c_ = 0x02; }
char c_;
};
struct C013 : public C010
{
C013() : c1_(0x01) {}
void foo() { c1_ = 0x02; }
char c1_;
};
兩個類為繼承關係,各有一個同名的普通成員函數,該函數改寫類的相應成員變數。我們做以下的調用:
C013 obj;
obj.foo();
((C010)obj).foo();
第1個foo調用,改變的是c1_值,最後一行的調用改變的是c_的值。直觀上容易認為上述代碼執行後obj.c_和obj.c1_的值均為0x02。但我們在調試環境的局部變數視窗中把obj對象展開可以發現obj.c1_為0x02,但obj.c_為0x01。原因就是前面所說的((C010)obj)實際產生了一個臨時對象,所以最後一行的調用沒有作用到obj對象上。
更進一步的想想,如果我們在一個類上運用了單件(singleton)模式,而這個類又有一個繼承結構,當在程式中想利用對對象進行向上轉型來調用父類的方法時,應該會出現編譯時間錯誤,因為父類臨時對象無法構造。在這裡有個前提,父類的建構函式應該用protected進行保護,而不是用private,否則子類根本無法構造。這種我沒有驗證了,因為用這種方法調用實在是比較蠢的作法,但不排除這種可能性。向上例中最後一行正確的調用方法應該是:
obj.C010::foo();
這樣就可以調用到父類中被覆蓋的函數,而且也是作用在正確的對象上。
(未完待續)