国产一级a片免费看高清,亚洲熟女中文字幕在线视频,黄三级高清在线播放,免费黄色视频在线看

打開APP
userphoto
未登錄

開通VIP,暢享免費(fèi)電子書等14項超值服

開通VIP
潘凱:C++對象布局及多態(tài)實現(xiàn)的探索(8-12)
普通的虛繼承

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

  按下來我們再看看這幾行代碼:
PRINT_OBJ_ADR(c020);
C010 * pt = &c020;
PRINT_PT(pt);
pt->c_ = 0x03;
  第2行聲明了一個父類指針,并讓它指向一個子類的對象。第3行打印出這個指針的值。運(yùn)行結(jié)果為:
c020's address is : 0012F708
pt's value is : 0012F70D
  我們可以看到賦值后的指針的值并不等于賦給它的對象地址值。也就是說在這個賦值過程中編譯器進(jìn)行了額外的工作,即調(diào)整了指針的值。我們看看第2行對應(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ù)雜的多。一行簡單的指針賦值語句卻產(chǎn)生了這么多的匯編代碼。這行代碼本身的語義是取對象的地址賦給一個指針,對于編譯器來說它把這做為指針到指針的賦值來處理。由于牽涉到了向上的類型轉(zhuǎn)換,同時又有虛繼承存在。根據(jù)前面的布局分析,在虛繼承的情況下,父類位于對象布局的后部。因此在這里要做一個指針位置的調(diào)整。由于調(diào)整要根據(jù)源指針來進(jìn)行計算,所以先要對源指針的合法性進(jìn)行檢查,以避免運(yùn)行時的指針異常錯誤。前3行的匯編指令就是在做這件事,檢查源指針是否為NULL。如果為NULL則執(zhí)行4、5、10、11行,最終給pt賦0。如果不為NULL跳至第6行執(zhí)行到最后。重要的是第6、7、8行代碼,它們通過偏移值指針找到偏移值,并以此來調(diào)整指針的位置,讓目的指針最終指向?qū)ο笾械母割惒糠值臄?shù)據(jù)成員。
  對比一下普通的指針賦值,我們可以對上面賦值的復(fù)雜性和低效有更深的認(rèn)識。
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。因為它不需要根據(jù)源指針(本處為NULL)做任何計算。第2個賦值也很直接,只是通過eax做了一個中轉(zhuǎn)。這里我們就可以看到前面的虛繼承下的子類指針到父類指針的賦值是我么的低效。在程序中應(yīng)盡量的避免這種代碼。

  (未完待繼)

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

  這次我們看看菱形結(jié)構(gòu)的虛繼承。虛繼承的引入本就是為了解決復(fù)雜結(jié)構(gòu)的繼承體系問題。上一篇我們在討論虛繼承時用的是一個簡單的繼承結(jié)構(gòu),只是為了打個鋪墊。
  我們先看看這幾個類,這是一個典型的菱形繼承結(jié)構(gòu)。C100和C101通過虛繼承共享同一個父類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
  我們可以象上一篇一樣,畫出對象的內(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代表虛表指針。第一個數(shù)字是該區(qū)域的大小,即字節(jié)數(shù)。只有偏移值指針有第二個數(shù)字,第二個數(shù)字就是偏移值指針指向的偏移值的大小。)
  可以看到對象的內(nèi)存布局中只有一個C041,即祖父類的部分只有一份,且放在最后面。這就是菱形繼承。對比前面幾篇的討論,我們可以知道,如果沒有用虛繼承機(jī)制,那么在C041對象的內(nèi)存布局中會出現(xiàn)兩份C041部分,這也就是所謂的V型繼承。相應(yīng)的對象布局為:C041+C100+C041+C101+C110。在V型繼承中是不能直接從C110,即孫子類,直接轉(zhuǎn)型到C041,即祖父類的。因為在對象的布局中有兩份祖父類的實體,一份從C100而來,一份從C101而來。編譯器在決議時會存在二義性,它不知道轉(zhuǎn)型后到底用哪一份實體。雖然可以通過先轉(zhuǎn)型到某一父類,然后再轉(zhuǎn)型到祖父類來解決。但使用這種方法時,如果改寫了祖父類的成員變量的內(nèi)容,runtime是不會同步兩個祖父類實體的狀態(tài),因此可能會有語義錯誤。
  我們再分析一下上面的內(nèi)存布局。普通繼承的布局,頂層類在前面。多重繼承時則按從左到右的順序排。從C100和C101到C110的繼承是普通繼承,所以遵循這個原則,先是左父類再右父類,接下去是子類。而虛繼承則要求將共享的父類放到整個對象布局的最后(即使虛父類沒有被真正的共享也是如此,前在一篇的C020類就是這樣。不知道打開優(yōu)化開關(guān)后會不會有變化。)所以在上例中的祖父類也是被置于最后的。
  我們再看看對成員的訪問情況。運(yùn)行以下代碼并查看相應(yīng)的匯編代碼。
C110 c110;
c110.c_ = 0x51;
c110.C100::c_ = 0x52;
c110.C101::c_ = 0x52;
c110.C041::c_ = 0x53;
c110.foo();
  對應(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行是對象的初始化,調(diào)用了對象的構(gòu)造函數(shù)。4、5、6行是對子類、左右父類的成員變量的賦值。我們可以看到是直接寫的,因為這一層的繼承是普通繼承。第7、8、9行是對祖父類成員變量的賦值,和上篇討論過的一樣,是通過偏移值指針指向的偏移值來間接訪問的。最后的4行指令是對成員函數(shù)的調(diào)用。我們可以看到調(diào)用的函數(shù)地址是直接給出的(最后一行),因為我們是通過對象來調(diào)用,即使是虛函數(shù)調(diào)用也不會有多態(tài)的行為。但是得到this指針的方式卻是頗為間接,即第10、11、12行。因為這個函數(shù)在祖父類中定義,那么它操作的數(shù)據(jù)成員應(yīng)該是祖父類的。因此編譯器要調(diào)整this指針的位置。而祖父類又是被虛繼承,因此要通過偏移值指針指向的偏移值來進(jìn)行調(diào)整。
  再觀察一下第9行和第12行,可以看到計算出來的地址值是不一樣的。這是因為第9行為給祖父類的成員變量賦值,而祖父類中有虛表指針存在,所以在得到對象的起始地址后,編譯器給它加了4字節(jié)的偏移量以跳過虛指針。實際的得到地址的運(yùn)算為:[ebp+ecx+FFFFF7F0h+4h],編譯器在生成代碼時會直接把最后一步運(yùn)算做掉。

  (未完待續(xù))

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


  我們再看一個例子,這個例子的繼承結(jié)構(gòu)和上一篇中是一樣的,也是菱形結(jié)構(gòu)。不同的是,每一個類都重寫了頂層類聲明的虛函數(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值。只有共享部分有虛表指針,這是因為派生類都沒有定義自己的虛函數(shù),只是重寫了頂層類的虛函數(shù)。我們分析一下C150的對象布局。
|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代表虛表指針。第一個數(shù)字是該區(qū)域的大小,即字節(jié)數(shù)。只有偏移值指針有第二個數(shù)字,第二個數(shù)字就是偏移值指針指向的偏移值的大小。)
  再看函數(shù)的調(diào)用:
C150 obj;
PRINT_OBJ_ADR(obj)
obj.foo();
  輸出的對象地址為:
obj's address is : 0012F624
  最后一行函數(shù)調(diào)用的代碼對應(yīng)的匯編代碼為:
00423F74  lea         ecx,[ebp+FFFFF757h] 
00423F7A  call        0041DCA3 
  單步執(zhí)行后,我們可以看到ecx中的值為:0x0012F633,這個地址也就是obj對象布局中的祖父類部分的起始地址。通過上面的布局分析我們知道C150起始的偏移值指針指向的值為15,即對象起始到共享部分(祖父類部分)的偏移值。上面輸出的obj起始地址為0x0012F624加上十進(jìn)制的15后,正好是我們看到的ecx中的值0x0012f633。
  由于函數(shù)調(diào)用是作用于對象上,我們看到第二行的call指令是直接到地址的。
  在這里令人困惑的問題是,我們知道ecx是用來傳遞this指針的。在前一篇中,我們分析了在C110對象上的foo方法調(diào)用。在那個例子中,由于foo是頂層類中定義的虛函數(shù),并且沒有被下面的派生類重寫,因此通過子類對象調(diào)用這個方法時,編譯器產(chǎn)生的代碼是通過子類起始的偏移指針指向的偏移值來計算出祖父類部分的起始地址,并將這個地址做為this指針?biāo)赶虻牡刂贰5窃贑150類中,foo不再是從祖父類繼承的,而是被子類自己所重寫。照理這時的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        
  果然,由于此時指針指向的不是子類的起始部分(而是祖父類的起始部分),因為是通過減于一個偏移值為向前定位成員變量的地址的。注意第15行,這時eax中存放的是this指針的值,寫入值的地址是[eax-5],結(jié)合前面的對象布局和對象的內(nèi)存輸出,我們可以知道this指針的值(此時指向祖父類C041的起始部分)減去5個字節(jié)(4字節(jié)的0值和1字節(jié)的成員變量值)后,剛好是子類C150的起始地址。
  為什么不直接用子類的地址而是通過祖父類的起始地址間接的進(jìn)行定位?這牽涉到編譯內(nèi)部的實現(xiàn)限制和對一系統(tǒng)問題的全面的理解。只是通過分析現(xiàn)象很難找到答案。

  我們再通過指針來調(diào)用一次。
C150 * pt = &obj;
pt->foo();
  第二行代碼對應(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)可能這段指令的效率會好很多。
  第9行通過祖父類的虛表指針得到了函數(shù)地址,第11行同樣把祖父類部分的起始地址0x0012F633做為this指針指向的地址存入ecx。
  最后我們做個指針的動態(tài)轉(zhuǎn)型再調(diào)用一次:
C141 * pt1 = dynamic_cast<C141*>(pt);
pt1->foo();
  第1行代碼對應(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 
  這里實際做了一個pt是否為零的判斷,第4條指令把pt指向的地址后移了5字節(jié),最后賦給了pt1。這樣pt1就指向了右父類部分的地址位置,也就是C141的起始位置。
  第2行代碼對應(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)用時ecx和edx的值和前面通過pt指針調(diào)用時是一樣的,這也是正確的多態(tài)行為。

  (未完待續(xù))

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

  最后我們看看,如果在上篇例子的基礎(chǔ)上,子類及左、右父類都各自定義了自己的虛函數(shù),這時的情況又會怎樣。
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對象的布局為:
|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代表虛表指針。第一個數(shù)字是該區(qū)域的大小,即字節(jié)數(shù)。只有偏移值指針有第二個數(shù)字,第二個數(shù)字就是偏移值指針指向的偏移值的大小。)
  左右父類由于各自定義了自己的新的虛函數(shù),因此都擁有了自己的虛表指針。奇怪的是子類雖然也定義了自己的新的虛函數(shù),我們在上面的布局中卻看到它并沒有自己的虛表指針。那么它應(yīng)該是和頂層類或是某一父類共用了虛表。我們可以在后面通過對調(diào)用的跟蹤來找到答案。
  另一個奇怪的地方是在左右父類中的偏移值指針指向的偏移值不再是到祖父類的偏移量,而是變成了到祖父類之前的4字節(jié)0值的偏移量。同時在前面第八篇中我們說過偏移值指針指向的地址的前4個字節(jié)為零,接下來的4個字節(jié)才是真正的偏移量。在這個例子中,前4個字節(jié)不再為0,而是0xFFFFFFFC,即整數(shù)-4。
  照例我們先通過對象來調(diào)用一下。
C170 obj;
PRINT_OBJ_ADR(obj);
obj.foo();
  結(jié)果為:
obj's address is : 0012F54C
  最后一行調(diào)用對應(yīng)的匯編指令為:
004245B8  lea         ecx,[ebp+FFFFF687h] 
004245BE  call        0041D122
  ecx中的值(即this指針的值)為0x0012F563,和前面一樣是指向祖父類的起始部分。同樣函數(shù)中的指令也是通過將this-5字節(jié)來定位到正確的成員變量的地址,這里不再列出函數(shù)的匯編指令。
  再看看調(diào)用它自己新定義的虛函數(shù)。
obj.f170();
  對應(yīng)的匯編指令為:
004245C3  lea         ecx,[ebp+FFFFF670h] 
004245C9  call        0041D127 
  讓我非常驚奇的是這次this指針的值居然是0x0012F54C。和前面的對象地址輸出是一樣的,也就是指向了整個對象的起始位置。這就讓人非常的奇怪了,在同一個對象上調(diào)用的兩個虛函數(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指針的值是肯定不會變的。我們來驗證一下。
C170 * pt = &obj;
pt->f170();
  第二行代碼對應(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 
  第一行把整個對象的起始地址放到eax中,第2行把eax當(dāng)指針,并把所指地址放到edx中。對象的起始地址正好也是左父類中的虛表指針,第5行進(jìn)行調(diào)用的時候果然是把edx指向的地址后移了4字節(jié)后取值,做為函數(shù)地址。這也就回答了前面的一個問題,子類沒有虛表,它的虛表實際合并到了左父類的虛表中,左父類定義了一個自己的虛函數(shù),占用了虛函數(shù)表的第一個條目,子類的虛函數(shù)則占用了第二個條目。因此在尋址時要加上4個字節(jié)。ecx中的this指針值和我們前面估計一樣,是整個對象的起始地址。
 
  最后我們看看怎樣得到祖父類地址。
pt->C041::c_ = 0x33;
  對應(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 
  首先把對象的起始地址賦給eax。第2行把eax+4字節(jié)后得到的指針指向的地址賦給ecx,這個值就是偏移值指針指向的地址。果然第3行把它+4字節(jié)后取值,再賦給edx。這時edx的值為13h,照理這應(yīng)該是到祖父類區(qū)域的偏移值,但實際是只到我們在對象布局中列出的4字節(jié)0值,也就是真正的祖父類起始地址的前4個字節(jié)。我們在前面討論C170的對象布局時已經(jīng)提到這個問題。所以我們看到第5行定位到成員變量時再加了8字節(jié),以跳過4字節(jié)的0值為4字節(jié)的祖父類的虛表指針,而不是只加4字節(jié)跳過虛表指針。在C150對象中我們可以看到偏移值是直接跳過4字節(jié)0值,定位到祖父類起始地址的。

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

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

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

  最后,我用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.”大意為在虛繼承時用以確定偏移地址及進(jìn)行this指針調(diào)整的可行算法很多,而且大都非常的詭異(這個我們已經(jīng)見識了:))。同時他建議不要在被虛繼承的基類中聲明非靜態(tài)的成員變量,這樣做純屬自取煩惱。

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

  (全文完)
本站僅提供存儲服務(wù),所有內(nèi)容均由用戶發(fā)布,如發(fā)現(xiàn)有害或侵權(quán)內(nèi)容,請點擊舉報。
打開APP,閱讀全文并永久保存 查看更多類似文章
猜你喜歡
類似文章
潘凱:C 對象布局及多態(tài)實現(xiàn)的探索(十)
c++虛函數(shù)機(jī)制
簡明x86匯編語言教程(6)
淺析C++中的this指針 - 數(shù)組指針 - 龍行天下
C++對象布局及多態(tài)之虛成員函數(shù)如何調(diào)用 -- RocketMan'sRoom -- 編程...
VC2008多重繼承下的Virtual Functions:Adjustor Thunk技術(shù)
更多類似文章 >>
生活服務(wù)
分享 收藏 導(dǎo)長圖 關(guān)注 下載文章
綁定賬號成功
后續(xù)可登錄賬號暢享VIP特權(quán)!
如果VIP功能使用有故障,
可點擊這里聯(lián)系客服!

聯(lián)系客服