程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> C++ >> 關於C++ >> C++對象布局及多態探索之菱形結構虛繼承

C++對象布局及多態探索之菱形結構虛繼承

編輯:關於C++

這次我們看看菱形結構的虛繼承。虛繼承的引入本就是為了解決復雜結構的繼承體系問題。上一篇我們在討論虛繼承時用的是一個簡單的繼承結構,只是為了打個鋪墊。

我們先看看這幾個類,這是一個典型的菱形繼承結構。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_;
};

運行如下代碼:

PRINT_SIZE_DETAIL(C110)

結果為:

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

我們可以象上一篇一樣,畫出對象的內存布局。

|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代表虛表指針。第一個數字是該區域的大小,即字節數。只有偏移值指針有第二個數字,第二個數字就是偏移值指針指向的偏移值的大小。)

可以看到對象的內存布局中只有一個C041,即祖父類的部分只有一份,且放在最後面。這就是菱形繼承。對比前面幾篇的討論,我們可以知道,如果沒有用虛繼承機制,那麼在C041對象的內存布局中會出現兩份C041部分,這也就是所謂的V型繼承。相應的對象布局為:C041+C100+C041+C101 +C110。在V型繼承中是不能直接從C110,即孫子類,直接轉型到C041,即祖父類的。因為在對象的布局中有兩份祖父類的實體,一份從C100而來,一份從C101而來。編譯器在決議時會存在二義性,它不知道轉型後到底用哪一份實體。雖然可以通過先轉型到某一父類,然後再轉型到祖父類來解決。但使用這種方法時,如果改寫了祖父類的成員變量的內容,runtime是不會同步兩個祖父類實體的狀態,因此可能會有語義錯誤。

我們再分析一下上面的內存布局。普通繼承的布局,頂層類在前面。多重繼承時則按從左到右的順序排。從C100和C101到C110的繼承是普通繼承,所以遵循這個原則,先是左父類再右父類,接下去是子類。而虛繼承則要求將共享的父類放到整個對象布局的最後(即使虛父類沒有被真正的共享也是如此,前在一篇的C020類就是這樣。不知道打開優化開關後會不會有變化。)所以在上例中的祖父類也是被置於最後的。

我們再看看對成員的訪問情況。運行以下代碼並查看相應的匯編代碼。

C110 c110;
c110.c_ = 0x51;
c110.C100::c_ = 0x52;
c110.C101::c_ = 0x52;
c110.C041::c_ = 0x53;
c110.foo();

對應的匯編代碼為:

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行是對象的初始化,調用了對象的構造函數。4、5、6行是對子類、左右父類的成員變量的賦值。我們可以看到是直接寫的,因為這一層的繼承是普通繼承。第7、8、9行是對祖父類成員變量的賦值,和上篇討論過的一樣,是通過偏移值指針指向的偏移值來間接訪問的。最後的4行指令是對成員函數的調用。我們可以看到調用的函數地址是直接給出的(最後一行),因為我們是通過對象來調用,即使是虛函數調用也不會有多態的行為。但是得到this指針的方式卻是頗為間接,即第10、11、12行。因為這個函數在祖父類中定義,那麼它操作的數據成員應該是祖父類的。因此編譯器要調整this指針的位置。而祖父類又是被虛繼承,因此要通過偏移值指針指向的偏移值來進行調整。

再觀察一下第9行和第12行,可以看到計算出來的地址值是不一樣的。這是因為第9行為給祖父類的成員變量賦值,而祖父類中有虛表指針存在,所以在得到對象的起始地址後,編譯器給它加了4字節的偏移量以跳過虛指針。實際的得到地址的運算為: [ebp+ecx+FFFFF7F0h+4h],編譯器在生成代碼時會直接把最後一步運算做掉。

我們再看一個例子,這個例子的繼承結構和上一篇中是一樣的,也是菱形結構。不同的是,每一個類都重寫了頂層類聲明的虛函數。代碼如下:

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_;
};

首先我們運行下面的代碼,看看它們的內存布局。

PRINT_SIZE_DETAIL(C041)
PRINT_SIZE_DETAIL(C140)
PRINT_SIZE_DETAIL(C141)
PRINT_SIZE_DETAIL(C150)

結果為:

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字節的0值。只有共享部分有虛表指針,這是因為派生類都沒有定義自己的虛函數,只是重寫了頂層類的虛函數。我們分析一下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代表虛表指針。第一個數字是該區域的大小,即字節數。只有偏移值指針有第二個數字,第二個數字就是偏移值指針指向的偏移值的大小。)

再看函數的調用:

C150 obj;
PRINT_OBJ_ADR(obj)
obj.foo();

輸出的對象地址為:

obj's address is : 0012F624

最後一行函數調用的代碼對應的匯編代碼為:

00423F74 lea ecx,[ebp+FFFFF757h]
00423F7A call 0041DCA3

單步執行後,我們可以看到ecx中的值為:0x0012F633,這個地址也就是obj對象布局中的祖父類部分的起始地址。通過上面的布局分析我們知道 C150起始的偏移值指針指向的值為15,即對象起始到共享部分(祖父類部分)的偏移值。上面輸出的obj起始地址為0x0012F624加上十進制的 15後,正好是我們看到的ecx中的值0x0012f633。

由於函數調用是作用於對象上,我們看到第二行的call指令是直接到地址的。

在這裡令人困惑的問題是,我們知道ecx是用來傳遞this指針的。在前一篇中,我們分析了在C110對象上的foo方法調用。在那個例子中,由於 foo是頂層類中定義的虛函數,並且沒有被下面的派生類重寫,因此通過子類對象調用這個方法時,編譯器產生的代碼是通過子類起始的偏移指針指向的偏移值來計算出祖父類部分的起始地址,並將這個地址做為this指針所指向的地址。但是在C150類中,foo不再是從祖父類繼承的,而是被子類自己所重寫。照理這時的this指針應該指向子類的起始地址,也就是0x0012F62E,而不是ecx中的值0x0012F633。

我們跟進去看看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],結合前面的對象布局和對象的內存輸出,我們可以知道this指針的值(此時指向祖父類 C041的起始部分)減去5個字節(4字節的0值和1字節的成員變量值)後,剛好是子類C150的起始地址。

為什麼不直接用子類的地址而是通過祖父類的起始地址間接的進行定位?這牽涉到編譯內部的實現限制和對一系統問題的全面的理解。只是通過分析現象很難找到答案。

我們再通過指針來調用一次。

C150 * pt = &obj;
pt->foo();

第二行代碼對應的匯編指令為:

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行等,如果打開了優化開關可能這段指令的效率會好很多。

第9行通過祖父類的虛表指針得到了函數地址,第11行同樣把祖父類部分的起始地址0x0012F633做為this指針指向的地址存入ecx。

最後我們做個指針的動態轉型再調用一次:

C141 * pt1 = dynamic_cast<C141*>(pt);
pt1->foo();

第1行代碼對應的匯編指令如下:

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字節,最後賦給了pt1。這樣pt1就指向了右父類部分的地址位置,也就是C141的起始位置。

第2行代碼對應的匯編指令為:

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

由於是通過偏移值指針進行運算,最後在調用時ecx和edx的值和前面通過pt指針調用時是一樣的,這也是正確的多態行為。

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved