午夜视频在线网站,日韩视频精品在线,中文字幕精品一区二区三区在线,在线播放精品,1024你懂我懂的旧版人,欧美日韩一级黄色片,一区二区三区在线观看视频

分享

潘凱:C++對(duì)象布局及多態(tài)實(shí)現(xiàn)的探索(8-12)

 vavava 2010-11-19
普通的虛繼承

  下面我們來看虛繼承。首先看看這C020類,它從C010虛繼承:
struct C010
{
    C010() : c_(0x01) {}
    void foo() { c_ = 0x02; }
    char c_;
};
struct C020 : public virtual C010
{
    C020() : c_(0x02) {}
    char c_;
};
  運(yùn)行如下代碼,查看對(duì)象的內(nèi)存布局:
PRINT_SIZE_DETAIL(C020)
  結(jié)果為:
The size of C020 is 6
The detail of C020 is c0 c2 45 00 02 01
  很明顯對(duì)象的起始處是一個(gè)指針,然后是子類的成員變量,接下來是父類的成員變量。和以前的討論不同的是由于使用了虛繼承,父類的成員變量被放到了最后面。
  運(yùn)行如下的代碼:
C020 c020;
c020.C010::c_ = 0x04;
  由于子類中的變量和父類中的變量重名,所以我們必須用這種方式來訪問屬于父類的成員變量,普通情況下不需要這種寫法。我們看看后面這行代碼對(duì)應(yīng)的匯編代碼:
0042387E  mov         eax,dword ptr [ebp+FFFFF82Ch] 
00423884  mov         ecx,dword ptr [eax+4] 
00423887  mov         byte ptr [ebp+ecx+FFFFF82Ch],4 
  前面說過對(duì)象的起始是一個(gè)指針,第1行指令取到這個(gè)指針的值,第2行把這個(gè)指針指向的地址后移4字節(jié)后的值(做為一個(gè)4字節(jié)的值)取出來。執(zhí)行完這句我們看看ecx寄存器,可知取出來的值為5。最后一行是真正的賦值指令,它通過在對(duì)象的起始處(即[ebp+FFFFF32Ch])加上ecx中的值做偏移值(即5)來得到賦值的目的地址。接合前面的對(duì)象布局輸出,我們可以發(fā)現(xiàn)從對(duì)象起始地址開始加5字節(jié)的偏移值,剛好得到父類的成員變量的地址。這樣我們可以大致分析出直接虛繼承的子類的對(duì)象布局。
|子類5             |父類1   ?。?/span>
|偏移值指針4,5|子類成員變量1|父類成員變量1|
  (注:第一個(gè)數(shù)字為所在區(qū)域的長(zhǎng)度(字節(jié)數(shù)),偏移值指針后的第二個(gè)數(shù)字為該指針指向的偏移值。后同。)
  通過查看內(nèi)存可以發(fā)現(xiàn)偏移值指針指向的內(nèi)存前4字節(jié)為0,我不知道它的具體的用途是什么。接下來的4字節(jié)是一個(gè)32位的整數(shù),也就是真正的偏移值。即從子類的起始位置到被虛繼承的父類的起始位置的偏移值,在我們前面的例子中這個(gè)值為5(一個(gè)指針加一個(gè)char成員變量)。
  通過這個(gè)分析我們可以看到在虛承繼的情況下,通過子類的對(duì)象訪問父類的普通成員變量的效率是相當(dāng)?shù)偷摹H绻仨氂玫教摾^承,也應(yīng)該盡量不要在父類中放置普通成員變量(靜態(tài)成員變量不受影響)。
  另外為什么微軟不把偏移值直接放到子類中,而是采用偏移值指針。我想是因?yàn)椴捎弥羔樀姆绞礁鼮殪`活,即使以后需要擴(kuò)展也不影響類對(duì)象的布局。

  按下來我們?cè)倏纯催@幾行代碼:
PRINT_OBJ_ADR(c020);
C010 * pt = &c020;
PRINT_PT(pt);
pt->c_ = 0x03;
  第2行聲明了一個(gè)父類指針,并讓它指向一個(gè)子類的對(duì)象。第3行打印出這個(gè)指針的值。運(yùn)行結(jié)果為:
c020's address is : 0012F708
pt's value is : 0012F70D
  我們可以看到賦值后的指針的值并不等于賦給它的對(duì)象地址值。也就是說在這個(gè)賦值過程中編譯器進(jìn)行了額外的工作,即調(diào)整了指針的值。我們看看第2行對(duì)應(yīng)的匯編代碼,看看編譯器究竟做了些什么?
01 004238EA  lea         eax,[ebp+FFFFF82Ch] 
02 004238F0  test        eax,eax 
03 004238F2  jne         00423900 
04 004238F4  mov         dword ptr [ebp+FFFFF014h],0 
05 004238FE  jmp         00423916 
06 00423900  mov         ecx,dword ptr [ebp+FFFFF82Ch] 
07 00423906  mov         edx,dword ptr [ecx+4] 
08 00423909  lea         eax,[ebp+edx+FFFFF82Ch] 
09 00423910  mov         dword ptr [ebp+FFFFF014h],eax 
10 00423916  mov         ecx,dword ptr [ebp+FFFFF014h] 
11 0042391C  mov         dword ptr [ebp+FFFFF820h],ecx 
  喔!比想象的要復(fù)雜的多。一行簡(jiǎn)單的指針賦值語句卻產(chǎn)生了這么多的匯編代碼。這行代碼本身的語義是取對(duì)象的地址賦給一個(gè)指針,對(duì)于編譯器來說它把這做為指針到指針的賦值來處理。由于牽涉到了向上的類型轉(zhuǎn)換,同時(shí)又有虛繼承存在。根據(jù)前面的布局分析,在虛繼承的情況下,父類位于對(duì)象布局的后部。因此在這里要做一個(gè)指針位置的調(diào)整。由于調(diào)整要根據(jù)源指針來進(jìn)行計(jì)算,所以先要對(duì)源指針的合法性進(jìn)行檢查,以避免運(yùn)行時(shí)的指針異常錯(cuò)誤。前3行的匯編指令就是在做這件事,檢查源指針是否為NULL。如果為NULL則執(zhí)行4、5、10、11行,最終給pt賦0。如果不為NULL跳至第6行執(zhí)行到最后。重要的是第6、7、8行代碼,它們通過偏移值指針找到偏移值,并以此來調(diào)整指針的位置,讓目的指針最終指向?qū)ο笾械母割惒糠值臄?shù)據(jù)成員。
  對(duì)比一下普通的指針賦值,我們可以對(duì)上面賦值的復(fù)雜性和低效有更深的認(rèn)識(shí)。
C010 * pt1 = NULL;
C010 * pt2 = pt1;
  這兩行相應(yīng)的匯編代碼為:
0042397D  mov         dword ptr [ebp+FFFFF814h],0 
00423987  mov         eax,dword ptr [ebp+FFFFF814h] 
0042398D  mov         dword ptr [ebp+FFFFF808h],eax 
  第1行是普通的賦值,編譯器并不做任何的檢查,即使源指針為NULL。因?yàn)樗恍枰鶕?jù)源指針(本處為NULL)做任何計(jì)算。第2個(gè)賦值也很直接,只是通過eax做了一個(gè)中轉(zhuǎn)。這里我們就可以看到前面的虛繼承下的子類指針到父類指針的賦值是我么的低效。在程序中應(yīng)盡量的避免這種代碼。

  (未完待繼)

菱形結(jié)構(gòu)的虛繼承

  這次我們看看菱形結(jié)構(gòu)的虛繼承。虛繼承的引入本就是為了解決復(fù)雜結(jié)構(gòu)的繼承體系問題。上一篇我們?cè)谟懻撎摾^承時(shí)用的是一個(gè)簡(jiǎn)單的繼承結(jié)構(gòu),只是為了打個(gè)鋪墊。
  我們先看看這幾個(gè)類,這是一個(gè)典型的菱形繼承結(jié)構(gòu)。C100和C101通過虛繼承共享同一個(gè)父類C041。C110則從C100和C101多重繼承而來。
struct C041
{
C041() : c_(0x01) {}
virtual void foo() { c_ = 0x02; }
char c_;
};
struct C100 : public virtual C041
{
C100() : c_(0x02) {}
char c_;
};
struct C101 : public virtual C041
{
C101() : c_(0x03) {}
char c_;
};
struct C110 : public C100, public C101
{
C110() : c_(0x04) {}
char c_;
};
  運(yùn)行如下代碼:
PRINT_SIZE_DETAIL(C110)
  結(jié)果為:
The size of C110 is 16
The detail of C110 is 28 c3 45 00 02 1c c3 45 00 03 04 18 c3 45 00 01
  我們可以象上一篇一樣,畫出對(duì)象的內(nèi)存布局。
|C100,5 |C101,5 |C110,1 |C041,5 |
|ospt,4,11 |m,1 |ospt,4,6 |m,1 |m,1 |vtpt,4 |m1 |
  (注:為了不折行,我用了縮寫。ospt代表偏移值指針、m代表成員變量、vtpt代表虛表指針。第一個(gè)數(shù)字是該區(qū)域的大小,即字節(jié)數(shù)。只有偏移值指針有第二個(gè)數(shù)字,第二個(gè)數(shù)字就是偏移值指針指向的偏移值的大小。)
  可以看到對(duì)象的內(nèi)存布局中只有一個(gè)C041,即祖父類的部分只有一份,且放在最后面。這就是菱形繼承。對(duì)比前面幾篇的討論,我們可以知道,如果沒有用虛繼承機(jī)制,那么在C041對(duì)象的內(nèi)存布局中會(huì)出現(xiàn)兩份C041部分,這也就是所謂的V型繼承。相應(yīng)的對(duì)象布局為:C041+C100+C041+C101+C110。在V型繼承中是不能直接從C110,即孫子類,直接轉(zhuǎn)型到C041,即祖父類的。因?yàn)樵趯?duì)象的布局中有兩份祖父類的實(shí)體,一份從C100而來,一份從C101而來。編譯器在決議時(shí)會(huì)存在二義性,它不知道轉(zhuǎn)型后到底用哪一份實(shí)體。雖然可以通過先轉(zhuǎn)型到某一父類,然后再轉(zhuǎn)型到祖父類來解決。但使用這種方法時(shí),如果改寫了祖父類的成員變量的內(nèi)容,runtime是不會(huì)同步兩個(gè)祖父類實(shí)體的狀態(tài),因此可能會(huì)有語義錯(cuò)誤。
  我們?cè)俜治鲆幌律厦娴膬?nèi)存布局。普通繼承的布局,頂層類在前面。多重繼承時(shí)則按從左到右的順序排。從C100和C101到C110的繼承是普通繼承,所以遵循這個(gè)原則,先是左父類再右父類,接下去是子類。而虛繼承則要求將共享的父類放到整個(gè)對(duì)象布局的最后(即使虛父類沒有被真正的共享也是如此,前在一篇的C020類就是這樣。不知道打開優(yōu)化開關(guān)后會(huì)不會(huì)有變化。)所以在上例中的祖父類也是被置于最后的。
  我們?cè)倏纯磳?duì)成員的訪問情況。運(yùn)行以下代碼并查看相應(yīng)的匯編代碼。
C110 c110;
c110.c_ = 0x51;
c110.C100::c_ = 0x52;
c110.C101::c_ = 0x52;
c110.C041::c_ = 0x53;
c110.foo();
  對(duì)應(yīng)的匯編代碼為:
01 00423993 push 1 
02 00423995 lea ecx,[ebp+FFFFF7F0h] 
03 0042399B call 0041DE60 
04 004239A0 mov byte ptr [ebp+FFFFF7FAh],51h 
05 004239A7 mov byte ptr [ebp+FFFFF7F4h],52h 
06 004239AE mov byte ptr [ebp+FFFFF7F9h],52h 
07 004239B5 mov eax,dword ptr [ebp+FFFFF7F0h] 
08 004239BB mov ecx,dword ptr [eax+4] 
09 004239BE mov byte ptr [ebp+ecx+FFFFF7F4h],53h 
10 004239C6 mov eax,dword ptr [ebp+FFFFF7F0h] 
11 004239CC mov ecx,dword ptr [eax+4] 
12 004239CF lea ecx,[ebp+ecx+FFFFF7F0h] 
13 004239D6 call 0041DF32 
  前3行是對(duì)象的初始化,調(diào)用了對(duì)象的構(gòu)造函數(shù)。4、5、6行是對(duì)子類、左右父類的成員變量的賦值。我們可以看到是直接寫的,因?yàn)檫@一層的繼承是普通繼承。第7、8、9行是對(duì)祖父類成員變量的賦值,和上篇討論過的一樣,是通過偏移值指針指向的偏移值來間接訪問的。最后的4行指令是對(duì)成員函數(shù)的調(diào)用。我們可以看到調(diào)用的函數(shù)地址是直接給出的(最后一行),因?yàn)槲覀兪峭ㄟ^對(duì)象來調(diào)用,即使是虛函數(shù)調(diào)用也不會(huì)有多態(tài)的行為。但是得到this指針的方式卻是頗為間接,即第10、11、12行。因?yàn)檫@個(gè)函數(shù)在祖父類中定義,那么它操作的數(shù)據(jù)成員應(yīng)該是祖父類的。因此編譯器要調(diào)整this指針的位置。而祖父類又是被虛繼承,因此要通過偏移值指針指向的偏移值來進(jìn)行調(diào)整。
  再觀察一下第9行和第12行,可以看到計(jì)算出來的地址值是不一樣的。這是因?yàn)榈?行為給祖父類的成員變量賦值,而祖父類中有虛表指針存在,所以在得到對(duì)象的起始地址后,編譯器給它加了4字節(jié)的偏移量以跳過虛指針。實(shí)際的得到地址的運(yùn)算為:[ebp+ecx+FFFFF7F0h+4h],編譯器在生成代碼時(shí)會(huì)直接把最后一步運(yùn)算做掉。

  (未完待續(xù))

 
菱形結(jié)構(gòu)的虛繼承(2)


  我們?cè)倏匆粋€(gè)例子,這個(gè)例子的繼承結(jié)構(gòu)和上一篇中是一樣的,也是菱形結(jié)構(gòu)。不同的是,每一個(gè)類都重寫了頂層類聲明的虛函數(shù)。代碼如下:
struct C041
{
    C041() : c_(0x01) {}
    virtual void foo() { c_ = 0x02; }
    char c_;
};
struct C140 : public virtual C041
{
    C140() : c_(0x02) {}
    virtual void foo() { c_ = 0x11; }
    char c_;
};
struct C141 : public virtual C041
{
    C141() : c_(0x03) {}
    virtual void foo() { c_ = 0x12; }
    char c_;
};
struct C150 : public C140, public C141
{
    C150() : c_(0x04) {}
    virtual void foo() { c_ = 0x21; }
    char c_;
};
  首先我們運(yùn)行下面的代碼,看看它們的內(nèi)存布局。
PRINT_SIZE_DETAIL(C041)
PRINT_SIZE_DETAIL(C140)
PRINT_SIZE_DETAIL(C141)
PRINT_SIZE_DETAIL(C150)
  結(jié)果為:
The size of C041 is 5
The detail of C041 is f0 c2 45 00 01
The size of C140 is 14
The detail of C140 is 48 c3 45 00 02 00 00 00 00 44 c3 45 00 01
The size of C141 is 14
The detail of C141 is 58 c3 45 00 03 00 00 00 00 54 c3 45 00 01
The size of C150 is 20
The detail of C150 is 74 c3 45 00 02 68 c3 45 00 03 04 00 00 00 00 64 c3 45 00 01
  和前面的布局不同之處在于,共享部分和前面的非共享部分之間多了4字節(jié)的0值。只有共享部分有虛表指針,這是因?yàn)榕缮惗紱]有定義自己的虛函數(shù),只是重寫了頂層類的虛函數(shù)。我們分析一下C150的對(duì)象布局。
|C140,5         |C141,5         |C150,1 |zero,4 |C041,5     |
|ospt,4,15 |m,1 |ospt,4,10 |m,1 |m,1    |4      |vtpt,4 |m1 |
  (注:為了不折行,我用了縮寫。ospt代表偏移值指針、m代表成員變量、vtpt代表虛表指針。第一個(gè)數(shù)字是該區(qū)域的大小,即字節(jié)數(shù)。只有偏移值指針有第二個(gè)數(shù)字,第二個(gè)數(shù)字就是偏移值指針指向的偏移值的大小。)
  再看函數(shù)的調(diào)用:
C150 obj;
PRINT_OBJ_ADR(obj)
obj.foo();
  輸出的對(duì)象地址為:
obj's address is : 0012F624
  最后一行函數(shù)調(diào)用的代碼對(duì)應(yīng)的匯編代碼為:
00423F74  lea         ecx,[ebp+FFFFF757h] 
00423F7A  call        0041DCA3 
  單步執(zhí)行后,我們可以看到ecx中的值為:0x0012F633,這個(gè)地址也就是obj對(duì)象布局中的祖父類部分的起始地址。通過上面的布局分析我們知道C150起始的偏移值指針指向的值為15,即對(duì)象起始到共享部分(祖父類部分)的偏移值。上面輸出的obj起始地址為0x0012F624加上十進(jìn)制的15后,正好是我們看到的ecx中的值0x0012f633。
  由于函數(shù)調(diào)用是作用于對(duì)象上,我們看到第二行的call指令是直接到地址的。
  在這里令人困惑的問題是,我們知道ecx是用來傳遞this指針的。在前一篇中,我們分析了在C110對(duì)象上的foo方法調(diào)用。在那個(gè)例子中,由于foo是頂層類中定義的虛函數(shù),并且沒有被下面的派生類重寫,因此通過子類對(duì)象調(diào)用這個(gè)方法時(shí),編譯器產(chǎn)生的代碼是通過子類起始的偏移指針指向的偏移值來計(jì)算出祖父類部分的起始地址,并將這個(gè)地址做為this指針?biāo)赶虻牡刂?。但是在C150類中,foo不再是從祖父類繼承的,而是被子類自己所重寫。照理這時(shí)的this指針應(yīng)該指向子類的起始地址,也就是0x0012F62E,而不是ecx中的值0x0012F633。
  我們跟進(jìn)去看看C150::foo()的匯編代碼,看它是怎樣通過指向祖父類部分的this指針,來定位到子類的成員變量。
01 00426C00  push        ebp  
02 00426C01  mov         ebp,esp 
03 00426C03  sub         esp,0CCh 
04 00426C09  push        ebx  
05 00426C0A  push        esi  
06 00426C0B  push        edi  
07 00426C0C  push        ecx  
08 00426C0D  lea         edi,[ebp+FFFFFF34h] 
09 00426C13  mov         ecx,33h 
10 00426C18  mov         eax,0CCCCCCCCh 
11 00426C1D  rep stos    dword ptr [edi] 
12 00426C1F  pop         ecx  
13 00426C20  mov         dword ptr [ebp-8],ecx 
14 00426C23  mov         eax,dword ptr [ebp-8] 
15 00426C26  mov         byte ptr [eax-5],21h 
16 00426C2A  pop         edi  
17 00426C2B  pop         esi  
18 00426C2C  pop         ebx  
19 00426C2D  mov         esp,ebp 
20 00426C2F  pop         ebp  
21 00426C30  ret        
  果然,由于此時(shí)指針指向的不是子類的起始部分(而是祖父類的起始部分),因?yàn)槭峭ㄟ^減于一個(gè)偏移值為向前定位成員變量的地址的。注意第15行,這時(shí)eax中存放的是this指針的值,寫入值的地址是[eax-5],結(jié)合前面的對(duì)象布局和對(duì)象的內(nèi)存輸出,我們可以知道this指針的值(此時(shí)指向祖父類C041的起始部分)減去5個(gè)字節(jié)(4字節(jié)的0值和1字節(jié)的成員變量值)后,剛好是子類C150的起始地址。
  為什么不直接用子類的地址而是通過祖父類的起始地址間接的進(jìn)行定位?這牽涉到編譯內(nèi)部的實(shí)現(xiàn)限制和對(duì)一系統(tǒng)問題的全面的理解。只是通過分析現(xiàn)象很難找到答案。

  我們?cè)偻ㄟ^指針來調(diào)用一次。
C150 * pt = &obj;
pt->foo();
  第二行代碼對(duì)應(yīng)的匯編指令為:
01 00423F8B  mov         eax,dword ptr [ebp+FFFFF73Ch] 
02 00423F91  mov         ecx,dword ptr [eax] 
03 00423F93  mov         edx,dword ptr [ecx+4] 
04 00423F96  mov         eax,dword ptr [ebp+FFFFF73Ch] 
05 00423F9C  mov         ecx,dword ptr [eax] 
06 00423F9E  mov         eax,dword ptr [ebp+FFFFF73Ch] 
07 00423FA4  add         eax,dword ptr [ecx+4] 
08 00423FA7  mov         ecx,dword ptr [ebp+FFFFF73Ch] 
09 00423FAD  mov         edx,dword ptr [ecx+edx] 
10 00423FB0  mov         esi,esp 
11 00423FB2  mov         ecx,eax 
12 00423FB4  call        dword ptr [edx] 
13 00423FB6  cmp         esi,esp 
14 00423FB8  call        0041DDF2 
  喔!更加迂回了。這段代碼非常的低效,里面很多明顯的冗余指令,如第1、4、6行,2、5行等,如果打開了優(yōu)化開關(guān)可能這段指令的效率會(huì)好很多。
  第9行通過祖父類的虛表指針得到了函數(shù)地址,第11行同樣把祖父類部分的起始地址0x0012F633做為this指針指向的地址存入ecx。
  最后我們做個(gè)指針的動(dòng)態(tài)轉(zhuǎn)型再調(diào)用一次:
C141 * pt1 = dynamic_cast<C141*>(pt);
pt1->foo();
  第1行代碼對(duì)應(yīng)的匯編指令如下:
01 00423FBD  cmp         dword ptr [ebp+FFFFF73Ch],0 
02 00423FC4  je          00423FD7 
03 00423FC6  mov         eax,dword ptr [ebp+FFFFF73Ch] 
04 00423FCC  add         eax,5 
05 00423FCF  mov         dword ptr [ebp+FFFFF014h],eax 
06 00423FD5  jmp         00423FE1 
07 00423FD7  mov         dword ptr [ebp+FFFFF014h],0 
08 00423FE1  mov         ecx,dword ptr [ebp+FFFFF014h] 
09 00423FE7  mov         dword ptr [ebp+FFFFF730h],ecx 
  這里實(shí)際做了一個(gè)pt是否為零的判斷,第4條指令把pt指向的地址后移了5字節(jié),最后賦給了pt1。這樣pt1就指向了右父類部分的地址位置,也就是C141的起始位置。
  第2行代碼對(duì)應(yīng)的匯編指令為:
01 00423FED  mov         eax,dword ptr [ebp+FFFFF730h] 
02 00423FF3  mov         ecx,dword ptr [eax] 
03 00423FF5  mov         edx,dword ptr [ecx+4] 
04 00423FF8  mov         eax,dword ptr [ebp+FFFFF730h] 
05 00423FFE  mov         ecx,dword ptr [eax] 
06 00424000  mov         eax,dword ptr [ebp+FFFFF730h] 
07 00424006  add         eax,dword ptr [ecx+4] 
08 00424009  mov         ecx,dword ptr [ebp+FFFFF730h] 
09 0042400F  mov         edx,dword ptr [ecx+edx] 
10 00424012  mov         esi,esp 
11 00424014  mov         ecx,eax 
12 00424016  call        dword ptr [edx] 
13 00424018  cmp         esi,esp 
14 0042401A  call        0041DDF2 
  由于是通過偏移值指針進(jìn)行運(yùn)算,最后在調(diào)用時(shí)ecx和edx的值和前面通過pt指針調(diào)用時(shí)是一樣的,這也是正確的多態(tài)行為。

  (未完待續(xù))

 
菱形結(jié)構(gòu)的虛繼承(3)

  最后我們看看,如果在上篇例子的基礎(chǔ)上,子類及左、右父類都各自定義了自己的虛函數(shù),這時(shí)的情況又會(huì)怎樣。
struct C140 : public virtual C041
{
    C140() : c_(0x02) {}
    virtual void foo() { c_ = 0x11; }
    char c_;
};
struct C160 : public virtual C041
{
    C160() : c_(0x02) {}
    virtual void foo() { c_ = 0x12; }
    virtual void f160() { c_ = 0x12; }
    char c_;
};
struct C161 : public virtual C041
{
    C161() : c_(0x03) {}
    virtual void foo() { c_ = 0x13; }
    virtual void f161() { c_ = 0x13; }
    char c_;
};
struct C170 : public C160, public C161
{
    C170() : c_(0x04) {}
    virtual void foo() { c_ = 0x14; }
    virtual void f170() { c_ = 0x14; }
    char c_;
};
  首先運(yùn)行如下的代碼,看看內(nèi)存的布局。
PRINT_SIZE_DETAIL(C041)
PRINT_SIZE_DETAIL(C160)
PRINT_SIZE_DETAIL(C161)
PRINT_SIZE_DETAIL(C170)
  結(jié)果為:
The size of C041 is 5
The detail of C041 is f0 b2 45 00 01
The size of C160 is 18
The detail of C160 is 84 b3 45 00 88 b3 45 00 02 00 00 00 00 80 b3 45 00 01
The size of C161 is 18
The detail of C161 is 98 b3 45 00 9c b3 45 00 03 00 00 00 00 94 b3 45 00 01
The size of C170 is 28
The detail of C170 is b0 b3 45 00 c8 b3 45 00 02 ac b3 45 00 bc b3 45 00 03 04 00 00 00 00 a8 b3 45 00 01
  C170對(duì)象的布局為:
|C160,9             |C161,9             |C170,1 |zero,4 |C041,5   |
|vp,4 |op,4,19 |m,1 |vp,4 |op,4,10 |m,1 |m,1    |       |vp,4 |m1 |
  (注:為了不折行,我用了縮寫。op代表偏移值指針、m代表成員變量、vp代表虛表指針。第一個(gè)數(shù)字是該區(qū)域的大小,即字節(jié)數(shù)。只有偏移值指針有第二個(gè)數(shù)字,第二個(gè)數(shù)字就是偏移值指針指向的偏移值的大小。)
  左右父類由于各自定義了自己的新的虛函數(shù),因此都擁有了自己的虛表指針。奇怪的是子類雖然也定義了自己的新的虛函數(shù),我們?cè)谏厦娴牟季种袇s看到它并沒有自己的虛表指針。那么它應(yīng)該是和頂層類或是某一父類共用了虛表。我們可以在后面通過對(duì)調(diào)用的跟蹤來找到答案。
  另一個(gè)奇怪的地方是在左右父類中的偏移值指針指向的偏移值不再是到祖父類的偏移量,而是變成了到祖父類之前的4字節(jié)0值的偏移量。同時(shí)在前面第八篇中我們說過偏移值指針指向的地址的前4個(gè)字節(jié)為零,接下來的4個(gè)字節(jié)才是真正的偏移量。在這個(gè)例子中,前4個(gè)字節(jié)不再為0,而是0xFFFFFFFC,即整數(shù)-4。
  照例我們先通過對(duì)象來調(diào)用一下。
C170 obj;
PRINT_OBJ_ADR(obj);
obj.foo();
  結(jié)果為:
obj's address is : 0012F54C
  最后一行調(diào)用對(duì)應(yīng)的匯編指令為:
004245B8  lea         ecx,[ebp+FFFFF687h] 
004245BE  call        0041D122
  ecx中的值(即this指針的值)為0x0012F563,和前面一樣是指向祖父類的起始部分。同樣函數(shù)中的指令也是通過將this-5字節(jié)來定位到正確的成員變量的地址,這里不再列出函數(shù)的匯編指令。
  再看看調(diào)用它自己新定義的虛函數(shù)。
obj.f170();
  對(duì)應(yīng)的匯編指令為:
004245C3  lea         ecx,[ebp+FFFFF670h] 
004245C9  call        0041D127 
  讓我非常驚奇的是這次this指針的值居然是0x0012F54C。和前面的對(duì)象地址輸出是一樣的,也就是指向了整個(gè)對(duì)象的起始位置。這就讓人非常的奇怪了,在同一個(gè)對(duì)象上調(diào)用的兩個(gè)虛函數(shù),編譯器為它們傳遞的this指針卻是不同的。
  讓我們跟到函數(shù)中,看它怎樣取得正確的成員變量的地址。
01 00426F80  push        ebp  
02 00426F81  mov         ebp,esp 
03 00426F83  sub         esp,0CCh 
04 00426F89  push        ebx  
05 00426F8A  push        esi  
06 00426F8B  push        edi  
07 00426F8C  push        ecx  
08 00426F8D  lea         edi,[ebp+FFFFFF34h] 
09 00426F93  mov         ecx,33h 
10 00426F98  mov         eax,0CCCCCCCCh 
11 00426F9D  rep stos    dword ptr [edi] 
12 00426F9F  pop         ecx  
13 00426FA0  mov         dword ptr [ebp-8],ecx 
14 00426FA3  mov         eax,dword ptr [ebp-8] 
15 00426FA6  mov         byte ptr [eax+12h],14h 
16 00426FAA  pop         edi  
17 00426FAB  pop         esi  
18 00426FAC  pop         ebx  
19 00426FAD  mov         esp,ebp 
20 00426FAF  pop         ebp  
21 00426FB0  ret       
  看看第15行可以知道,是直接在this指針上加了18字節(jié)(即16進(jìn)制的12h)來定位到子類的成員變量。
  由于函數(shù)中的指令是以這種方式來定位子類成員變量,所以即使我們是通過指針來調(diào)用,不同的只是怎樣定位函數(shù)地址,而this指針的值是肯定不會(huì)變的。我們來驗(yàn)證一下。
C170 * pt = &obj;
pt->f170();
  第二行代碼對(duì)應(yīng)的匯編指令如下:
01 004245DA  mov         eax,dword ptr [ebp+FFFFF664h] 
02 004245E0  mov         edx,dword ptr [eax] 
03 004245E2  mov         esi,esp 
04 004245E4  mov         ecx,dword ptr [ebp+FFFFF664h] 
05 004245EA  call        dword ptr [edx+4] 
06 004245ED  cmp         esi,esp 
07 004245EF  call        0041DDF2 
  第一行把整個(gè)對(duì)象的起始地址放到eax中,第2行把eax當(dāng)指針,并把所指地址放到edx中。對(duì)象的起始地址正好也是左父類中的虛表指針,第5行進(jìn)行調(diào)用的時(shí)候果然是把edx指向的地址后移了4字節(jié)后取值,做為函數(shù)地址。這也就回答了前面的一個(gè)問題,子類沒有虛表,它的虛表實(shí)際合并到了左父類的虛表中,左父類定義了一個(gè)自己的虛函數(shù),占用了虛函數(shù)表的第一個(gè)條目,子類的虛函數(shù)則占用了第二個(gè)條目。因此在尋址時(shí)要加上4個(gè)字節(jié)。ecx中的this指針值和我們前面估計(jì)一樣,是整個(gè)對(duì)象的起始地址。
 
  最后我們看看怎樣得到祖父類地址。
pt->C041::c_ = 0x33;
  對(duì)應(yīng)的匯編指令為:
01 004245F4  mov         eax,dword ptr [ebp+FFFFF664h] 
02 004245FA  mov         ecx,dword ptr [eax+4] 
03 004245FD  mov         edx,dword ptr [ecx+4] 
04 00424600  mov         eax,dword ptr [ebp+FFFFF664h] 
05 00424606  mov         byte ptr [eax+edx+8],33h 
  首先把對(duì)象的起始地址賦給eax。第2行把eax+4字節(jié)后得到的指針指向的地址賦給ecx,這個(gè)值就是偏移值指針指向的地址。果然第3行把它+4字節(jié)后取值,再賦給edx。這時(shí)edx的值為13h,照理這應(yīng)該是到祖父類區(qū)域的偏移值,但實(shí)際是只到我們?cè)趯?duì)象布局中列出的4字節(jié)0值,也就是真正的祖父類起始地址的前4個(gè)字節(jié)。我們?cè)谇懊嬗懻揅170的對(duì)象布局時(shí)已經(jīng)提到這個(gè)問題。所以我們看到第5行定位到成員變量時(shí)再加了8字節(jié),以跳過4字節(jié)的0值為4字節(jié)的祖父類的虛表指針,而不是只加4字節(jié)跳過虛表指針。在C150對(duì)象中我們可以看到偏移值是直接跳過4字節(jié)0值,定位到祖父類起始地址的。

  我們始終沒有清楚的解釋過祖父類之前的4字節(jié)0值及偏移值指針指向地址的前4字節(jié)的語義。有可能是出于兼容的原因,也有可能是為編譯器提供一些薄記信息。而且,引入虛繼承后的對(duì)象繼承的拓樸結(jié)構(gòu)可以比我們討論過的菱形結(jié)構(gòu)要復(fù)雜得多。這兩個(gè)值也可能是用來處理更復(fù)雜的繼承結(jié)構(gòu)。要想通過表象去揣測(cè)出使用它們的動(dòng)機(jī)太困難了。

  (未完待續(xù))
后記

  結(jié)合前面的討論,我們可以看到,只要牽涉到了虛繼承,在訪問父類的成員變量時(shí)生成的代碼相當(dāng)?shù)牡托?,需要通過很多間接的計(jì)算來定位成員變量的地址。在指針類型轉(zhuǎn)換,動(dòng)態(tài)轉(zhuǎn)型,及虛函數(shù)調(diào)用時(shí),也需要生成很多額外的代碼來調(diào)整this指針。象前一篇中對(duì)C170對(duì)象的obj.foo()和obj.f170()兩次調(diào)用,傳遞到兩個(gè)函數(shù)中的this指針居然是不一樣的。
  前面我們碰到過的怪異行為還有很多,比如偏移值指針指向地址的前4字節(jié),及C150、C170對(duì)象中的4字節(jié)0值的語義,為什么對(duì)C150和C170調(diào)用foo函數(shù)時(shí),this指針指向的不是子類部分的起始位置而是祖父類的起始位置,等等。去徹底的探究這些問題的意義并不是很大。虛繼承的實(shí)現(xiàn)屬于編譯器廠商的行為,廠商出于不同的考慮,實(shí)現(xiàn)的方法也會(huì)大相徑庭。
  對(duì)于傳統(tǒng)的C程序員,他們可能會(huì)認(rèn)為C++的效率低。其實(shí)效率低是低在多態(tài)部分,因?yàn)檫@要在運(yùn)行時(shí)通過虛表來決議出函數(shù)的地址。但對(duì)于設(shè)計(jì)而言,多態(tài)是一個(gè)非常強(qiáng)大的武器。多態(tài)也是面向?qū)ο笤O(shè)計(jì)的核心技術(shù)之一。雖然在執(zhí)行的效率上有所損失,但對(duì)于大規(guī)模的程序設(shè)計(jì),對(duì)于問題域到模型的映射,使用以多態(tài)為核心的面向?qū)ο笤O(shè)計(jì)技術(shù)可以提高設(shè)計(jì)、實(shí)現(xiàn)及維護(hù)的效率,對(duì)于大部分的應(yīng)用,總體來說得大于失。
  但是對(duì)于虛繼承,個(gè)人感覺只是為了解決菱形繼承及更復(fù)雜繼承問題不得已而引入的一項(xiàng)機(jī)制,而且沒有完美的解決方案,不但大幅的損失效率,而且?guī)砹司薮蟮膹?fù)雜性,使得繼承結(jié)構(gòu)晦澀難懂。如非萬不得已,且在自己清楚一切后果的情況下,建議不要使用。尤其是不要在被虛繼承的基類中聲明非靜態(tài)的成員變量。
  C++支持多種編程范型,面向過程式的、數(shù)據(jù)抽象及封裝、面向?qū)ο?、現(xiàn)在又多了一種基于模板技術(shù)的泛型編程。我們以一個(gè)優(yōu)秀的開源C++編程環(huán)境ACE(之所以叫編程環(huán)境,因?yàn)樗峁┝藦念悗斓娇蚣艿亩鄬哟蔚闹С?為例,看看在設(shè)計(jì)時(shí)的衡量及各種編程范型的運(yùn)用契機(jī)。ACE分幾個(gè)層次,依次為:OS適配層、wrapper facade層、框架層、服務(wù)組件層。OS適配層、wrapper facade層主要運(yùn)用了數(shù)據(jù)抽象及封裝,沒有用到多態(tài)及虛機(jī)制。因?yàn)檫@兩層的關(guān)鍵是效率。而且在這兩層中的類的成員方法盡量的內(nèi)聯(lián)。其實(shí)不使用多態(tài)及虛機(jī)制的話,C++的效率和C應(yīng)該是差不多的,但對(duì)象封裝導(dǎo)致了大量的瑣碎方法(如對(duì)成員變量的訪問封裝,即set,get方法),而方法調(diào)用的成本也是相當(dāng)高昂的(需要保存及恢復(fù)當(dāng)前的執(zhí)行環(huán)境上下文,參數(shù)的傳遞及返回值可能產(chǎn)生很多的臨時(shí)變量及對(duì)象等)。所以這兩層通過內(nèi)聯(lián)來減少函數(shù)調(diào)用的開銷,提高執(zhí)行效率。在框架層則使用了大量的設(shè)計(jì)模式,大量使用了多態(tài)機(jī)制及泛型技術(shù)。在這一層的主要關(guān)注點(diǎn)是結(jié)構(gòu)的清晰,及實(shí)現(xiàn)設(shè)計(jì)上的語義。在這時(shí)多態(tài)機(jī)制在執(zhí)行效率上的損失是可以忽略不計(jì)的。

  最后,我用Lippman在他的經(jīng)典書籍《Inside the C++ Object Model》中關(guān)于描述虛成員函數(shù)章節(jié)中的一段話來做為這系列文章的結(jié)束。“Although I have a folder full of examples worked out and more than one algorithm for determining the proper offsets and adjustments, the material is simply too esoteric to warrant discussion in this text. My recommendation is not to declare nonstatic data members within a virtual base class. Doing that goes a long way in taming the complexity.”大意為在虛繼承時(shí)用以確定偏移地址及進(jìn)行this指針調(diào)整的可行算法很多,而且大都非常的詭異(這個(gè)我們已經(jīng)見識(shí)了:))。同時(shí)他建議不要在被虛繼承的基類中聲明非靜態(tài)的成員變量,這樣做純屬自取煩惱。

  另,感謝張水松和張建業(yè)這兩個(gè)土人,在寫這些文章時(shí)和他們進(jìn)行了一些非常有益的探討。最后也是他們提醒我,不要再深入下去,以免走火入魔。

  (全文完)

    本站是提供個(gè)人知識(shí)管理的網(wǎng)絡(luò)存儲(chǔ)空間,所有內(nèi)容均由用戶發(fā)布,不代表本站觀點(diǎn)。請(qǐng)注意甄別內(nèi)容中的聯(lián)系方式、誘導(dǎo)購(gòu)買等信息,謹(jǐn)防詐騙。如發(fā)現(xiàn)有害或侵權(quán)內(nèi)容,請(qǐng)點(diǎn)擊一鍵舉報(bào)。
    轉(zhuǎn)藏 分享 獻(xiàn)花(0

    0條評(píng)論

    發(fā)表

    請(qǐng)遵守用戶 評(píng)論公約

    類似文章 更多