C++中的【菱形虛繼承】深入剖析
轉眼間有過了一個月了,自從【C/C++語言入門篇】連載結束後,已經很久沒有寫博了。最近一直忙著本科畢業論文和工作上的任務,加上一個對於我來說非常重要的事情正在進行中。所以近段時間腦子一直處於繃緊狀態,發現自己的腦細胞還真是不夠用。加油!
今天有朋友問到一個問題,那就是在C++的多重繼承中,出現菱形狀繼承的情況下,在構造物件時的記憶體分佈及建構函式的呼叫流程上出現了問題。最後跟他解釋清楚之後,我感覺還是有必要把這個過程寫下來,有什麼說得不對的地方請大家提出寶貴意見,在此感謝,同時知道這裡面的朋友可以直接略過本篇。
好了,直接切入正題,所謂的菱形繼承,最簡單的構造如下:
class A
{
public:
A( void ) : nVar( 0xaaaa0000 ){}
public:
int nVar;
};
class B1 : public A
{
public:
B1( void ){}
};
class B2 : public A
{
public:
B2( void ){}
};
class C : public B1, public B2
{
public:
C( void ){}
};
就是這樣一個多重繼承,用圖形化來表示之間的關係就是:
A
/ /
/ /
B1 B2
/ /
/ /
C
然後,在建立C的物件:
int main( void )
{
C obj;
return 0;
}
我想大家應該知道這樣將造成什麼情況,在這裡可以清楚的知道obj的大小為8,為什麼是8,先看記憶體分佈:
假如obj的記憶體地址為0x0012ff18.
0x0012FF18: 00 00 aa aa 00 00 aa aa
看了obj物件的記憶體,裡面有2個A的副本,紅色的就是B1那條線繼承下來的記憶體,藍色就是B2那條線繼承下來的。因此A的建構函式被呼叫了兩次,這裡B1在前面,B2在後面是因為一對多繼承是從左到右分佈記憶體的。
從這裡明顯知道這樣的結局肯定是很悲劇的。更可怕的是假如使用obj訪問nVar成員將導致編譯出錯:
obj.nVar = 0x100;
對nVar的訪問不明確,因為有兩個副本,編譯器不知道你到底要修改那個副本,從而導致編譯錯誤,這裡訪問成員函式也是一個道理。
那麼,有什麼解決辦法不讓這種現象出現呢,C++提出了虛繼承,以解決這個問題:
class A
{
public:
A( void ) : nVar( 0xaaaa0000 ){}
public:
int nVar;
};
class B1 : virtual public A
{
public:
B1( void ){}
};
class B2 : virtual public A
{
public:
B2( void ){}
};
class C : public B1, public B2
{
public:
C( void ){}
};
這樣繼承下來後,A就只會保留一個副本,再來看記憶體分佈(這裡宣告,我使用的是VC2008版本來測試的):
假如obj的記憶體地址為:0x0012FF10
0x0012FF10: 0041580c 00415800 aaaa0000
可以清晰看出這裡0xaaaa0000只有一個,而這時前面多了兩個值,obj的大小為12位元組,前面藍色的地址就是C類的虛基指標(vbtable)如果A有虛擬函式的話,在藍色和紅色之間還會加上虛擬函式表(vftable)這時就佔16位元組了。這裡就不具體介紹多重繼承的虛表的記憶體分佈了。
好了,下面就是本文的重點了,來看看obj物件建立時,呼叫建構函式的流程:
流程大概就是:在obj建立時,首先會呼叫C類的建構函式,在建構函式中,首先會將兩個vbtable的偏移賦值給前面的藍色部分記憶體。之後就會呼叫A的建構函式,呼叫之後再調B1和B2的建構函式。
用虛擬碼來表示:
C()
{
vbtable;
vbtable;
A::A();
B1::B1();
B2::B2();
}
那麼在呼叫B1和B2的建構函式是時,按理說會呼叫A的建構函式,因為B1、B2也是繼承於A,但是為什麼沒有呼叫A的建構函式呢?來看看反彙編程式碼:
首先看main函式:
C obj;
004113DE push 1
004113E0 lea ecx,[obj]
004113E3 call C::C (4110E6h)
在紅色處呼叫C的建構函式,再來看C的建構函式:
00411460 push ebp
00411461 mov ebp,esp
00411463 sub esp,0CCh
00411469 push ebx
0041146A push esi
0041146B push edi
0041146C push ecx
0041146D lea edi,[ebp-0CCh]
00411473 mov ecx,33h
00411478 mov eax,0CCCCCCCCh
0041147D rep stos dword ptr es:[edi]
0041147F pop ecx
00411480 mov dword ptr [ebp-8],ecx
00411483 cmp dword ptr [ebp+8],0
00411487 je C::C+47h (4114A7h)
00411489 mov eax,dword ptr [this]
0041148C mov dword ptr [eax],offset C::`vbtable' (41580Ch)
00411492 mov eax,dword ptr [this]
00411495 mov dword ptr [eax+4],offset C::`vbtable' (415800h)
0041149C mov ecx,dword ptr [this]
0041149F add ecx,8
004114A2 call A::A (4110EBh)
004114A7 push 0
004114A9 mov ecx,dword ptr [this]
004114AC call B2::B2 (4110AAh)
004114B1 push 0
004114B3 mov ecx,dword ptr [this]
004114B6 add ecx,4
004114B9 call B1::B1 (41107Dh)
004114BE mov eax,dword ptr [this]
004114C1 pop edi
004114C2 pop esi
004114C3 pop ebx
004114C4 add esp,0CCh
004114CA cmp ebp,esp
004114CC call @ILT+330(__RTC_CheckEsp) (41114Fh)
004114D1 mov esp,ebp
004114D3 pop ebp
004114D4 ret 4
上面藍色的為加粗字型,可以看出在賦值vbtable。下面的紅色為加粗的部分就是呼叫A的建構函式。這不奇怪。
在呼叫A的構造之前有一句:add ecx, 8 這一句的目的是為了將this定位到兩個vbtable之後,在呼叫A的建構函式時,直接往this所指向的記憶體地址下寫值:0xaaaa0000。因此就構成了佈局:
0x0012FF10: 0041580c 00415800 aaaa0000
C::this/( vbtable) vbtable A::this
C的this在這裡看當然是0x0012ff10,A的this就是0x0012ff18,中間相隔兩個vbtable,其實this也就是某個類的起始地址,沒有什麼特別的。
到這裡,你可能注意到了藍色加粗和紅色加粗的兩條一樣的指令push 0,這條語句顯然是編譯器新增的,B2的建構函式明顯沒有引數,這樣push一個0進去有點類似隱含的一個引數,那麼push一個0進去到底做了些什麼呢,再看B1的建構函式:
00411550 push ebp
00411551 mov ebp,esp
00411553 sub esp,0CCh
00411559 push ebx
0041155A push esi
0041155B push edi
0041155C push ecx
0041155D lea edi,[ebp-0CCh]
00411563 mov ecx,33h
00411568 mov eax,0CCCCCCCCh
0041156D rep stos dword ptr es:[edi]
0041156F pop ecx
00411570 mov dword ptr [ebp-8],ecx
00411573 cmp dword ptr [ebp+8],0
00411577 je B1::B1+3Dh (41158Dh)
00411579 mov eax,dword ptr [this]
0041157C mov dword ptr [eax],offset B1::`vbtable' (415818h)
00411582 mov ecx,dword ptr [this]
00411585 add ecx,4
00411588 call A::A (4110EBh)
0041158D mov eax,dword ptr [this]
00411590 pop edi
00411591 pop esi
00411592 pop ebx
00411593 add esp,0CCh
00411599 cmp ebp,esp
0041159B call @ILT+330(__RTC_CheckEsp) (41114Fh)
004115A0 mov esp,ebp
004115A2 pop ebp
004115A3 ret 4
紅色的那句指令很明顯,ebp+8正是函式的第一個引數,這裡雖然沒有,但是壓入了一個0,這樣一個cmp與0比較相等,執行藍色的跳轉直接躍過A的建構函式呼叫到綠色的那條指令。這樣便實現了只調用一次A的建構函式的功能。B2的建構函式也是同理,這裡就不介紹了。
有了這樣一個push 0 然後又檢查是否為零的操作,所以就算你在B1、B2中顯示呼叫A的建構函式,結果還是不會呼叫A的建構函式的。
形如: B1( void ): A(){} 因為判斷為零直接跳轉到建構函式的使用者程式碼裡。
好了,本文就到這裡就差不多了,這裡只是介紹了虛繼承中建構函式呼叫的原理。望大家多多提意見哈。