1. 程式人生 > >C++中的【菱形虛繼承】深入剖析

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(){} 因為判斷為零直接跳轉到建構函式的使用者程式碼裡。

好了,本文就到這裡就差不多了,這裡只是介紹了虛繼承中建構函式呼叫的原理。望大家多多提意見哈。