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(){} 因爲判斷爲零直接跳轉到構造函數的用戶代碼裏。

 

好了,本文就到這裏就差不多了,這裏只是介紹了虛繼承中構造函數調用的原理。望大家多多提意見哈。 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章