C++中構造函數中調用虛函數的問題

在構造函數中調用虛成員函數,雖然這是個不很常用的技術,但研究一下可以加深對虛函數機制及對象構造過程的理解。這個問題也和一般直觀上的認識有所差異。先看看下面的兩個類定義。

struct C180
{
 C180() {
  
foo();
  
this->foo();
 
}
 
virtual foo() {
  
cout << "<< C180.foo this: " << this << " vtadr: " << *(void**)this << endl;
 
}
};
struct C190 : public C180
{
 
C190() {}
 
virtual foo() {
  
cout << "<< C190.foo this: " << this << " vtadr: " << *(void**)this << endl;
 
}
};

  父類中有一個虛函數,並且父類在它的構造函數中調用了這個虛函數,調用時它採用了兩種方法一種是直接調用,一種是通過this指針調用。同時子類又重寫了這個虛函數。


  我們可以來預測一下如果構造一個C190的對象會發生什麼情況。

  我們知道,在構造一個對象時,過程是這樣的:
        
1) 首先會按對象的大小得到一塊內存(heap上或在stack)
        2)
把指向這塊內存的指針做爲this指針來調用類的構造函數,對這塊內存進行初始化。
        3)
如果對象有父類就會先調用父類的構造函數(並依次遞歸)如果有多個父類(多重繼承)會依次對父類的構造函數進行調用,並會適當的調整this指針的位置。在調用完所有的父類的構造函數後,再執行自己的代碼。

  照上面的分析構造C190時也會調用C180的構造函數,這時在C180構造函數中的第一個foo調用爲靜態綁定,會調用到C180::foo()函數。第二個foo調用是通過指針調用的,這時多態行爲會發生,應該調用的是C190::foo()函數。

  執行如下代碼:

C190 obj;
obj.foo();

  結果爲:

<< C180.foo this: 0012F7A4 vtadr: 0045C404
<< C180.foo this: 0012F7A4 vtadr: 0045C404
<< C190.foo this: 0012F7A4 vtadr: 0045C400

 

和我們的分析大相徑庭。第一行是在C180中運行foo()函數得到的,這裏的foo()當然是調用C180中的foo()函數。第二行是調用C180中的this->foo()得到的,此時this指向的應該是C180的虛表地址,按照調用規則,應該是動態綁定,即,此時若派生類對該虛函數實現過,則應該調用派生類的虛函數,這裏是一個例外,下面會詳細講到。 至此,C190的父類的構造函數運行完畢,轉而運行C190的構造函數,但是這裏C190的構造函數什麼都沒有。第三行是在main函數中調用obj.foo()得到的,這裏直接進入C190運行就可以了。 這裏必須注意一點,就是前兩行和第三行的虛表是不同的,這是因爲前兩行的虛表是C180的虛表,而第三行的虛表是C190的虛表。 其實這正是奧祕所在。

  爲此我查了一下C++標準規範。12.7.3條中有明確的規定。這是一種特例,在這種情況下,即在構造子類時調用父類的構造函數,而父類的構造函數中又調用了虛成員函數,這個虛成員函數即使被子類重寫,也不允許發生多態的行爲。即,這時必須要調用父類的虛函數,而不子類重寫後的虛函數。

  我想這樣做的原因是因爲在調用父類的構造函數時,對象中屬於子類部分的成員變量是肯定還沒有初始化的,因爲子類構造函數中的代碼還沒有被執行。如果這時允許多態的行爲,即通過父類的構造函數調用到了子類的虛函數,而這個虛函數要訪問屬於子類的數據成員時就有可能出錯。

  我們看看VC7.1生成的彙編代碼就可以很容易的理解這個行爲了。


這是C190的構造函數:


01 00426FE0 push ebp
02 00426FE1 mov ebp,esp
03 00426FE3 sub esp,0CCh
04 00426FE9 push ebx
05 00426FEA push esi
06 00426FEB push edi
07 00426FEC push ecx
08 00426FED lea edi,[ebp+FFFFFF34h]
09 00426FF3 mov ecx,33h
10 00426FF8 mov eax,0CCCCCCCCh
11 00426FFD rep stos dword ptr [edi]
12 00426FFF pop ecx
13 00427000 mov dword ptr [ebp-8],ecx
14 00427003 mov ecx,dword ptr [ebp-8]
15 00427006 call 0041D451
16 0042700B mov eax,dword ptr [ebp-8]
17 0042700E mov dword ptr [eax],45C400h
18 00427014 mov eax,dword ptr [ebp-8]
19 00427017 pop edi
20 00427018 pop esi
21 00427019 pop ebx
22 0042701A add esp,0CCh
23 00427020 cmp ebp,esp
24 00427022 call 0041DDF2
25 00427027 mov esp,ebp
26 00427029 pop ebp
27 0042702A ret

開始部分的指令在前面幾篇中陸續解釋過,這裏不再詳述。我們看看第15是對父類的構造函數C180::C180()的調用,根據前文的說明,我們知道此時ecx中放的是this指針,也就是C190對象的地址。這時如果跳到this指針批向的地址看看會發現值爲0xcccccccc即沒有初始化,虛表指針也沒有被初始化。那麼我們跟着跳到C180的構造函數看看。

01 00427040 push ebp
02 00427041 mov ebp,esp
03 00427043 sub esp,0CCh
04 00427049 push ebx
05 0042704A push esi
06 0042704B push edi
07 0042704C push ecx
08 0042704D lea edi,[ebp+FFFFFF34h]
09 00427053 mov ecx,33h
10 00427058 mov eax,0CCCCCCCCh
11 0042705D rep stos dword ptr [edi]
12 0042705F pop ecx
13 00427060 mov dword ptr [ebp-8],ecx
14 00427063 mov eax,dword ptr [ebp-8]
15 00427066 mov dword ptr [eax],45C404h
16 0042706C mov ecx,dword ptr [ebp-8]
17 0042706F call 0041DA8C
18 00427074 mov ecx,dword ptr [ebp-8]
19 00427077 call 0041DA8C
20 0042707C mov eax,dword ptr [ebp-8]
21 0042707F pop edi
22 00427080 pop esi
23 00427081 pop ebx
24 00427082 add esp,0CCh
25 00427088 cmp ebp,esp
26 0042708A call 0041DDF2
27 0042708F mov esp,ebp
28 00427091 pop ebp
29 00427092 ret

  看看第15行,在this指針的位置也就是對象的起始處,填入了一個4字節的值0x0045C404,其實這就是我們前面的打印過的C180的虛表地址。第1617行和1819行分別調用了兩次foo()函數,用的都是靜態綁定。這個就有點奇怪,因爲對後一個調用我們使用了this指針,照理應該是動態綁定纔對。可這裏卻是靜態綁定,爲什麼編譯器要做這個優化?我們繼承往後看。

這個函數執行完後,我們再回到C190構造函數中,我們接着看C190構造函數彙編代碼的第17行,這裏又在對象的起始處重新填入了0x0045C400,覆蓋了原來的值,而這個值就是我們前面打印過的真正的C190的虛表地址。

  也就是說VC7.1是通過在調用構造函數的真正代碼前把對象的虛指針值設置爲指向對應類的虛表來實現C++規範的相應語義。C++標準中只規定了行爲,並不規定具體編譯器在實現這一行爲時所用的方法。象我們上面看到的,即使是通過this指針調用,編譯器也把它優化爲靜態綁定,也就是說即使不做這個虛指針的調整也不會有錯。之所以要調整我想可能是防止在被調用的虛成員中又通過this指針來調用其他的虛函數,不過誰會這麼變態呢?
   
  還有值得一提的是,VC7.1中有一個擴展屬性可以用來抑制編譯器產生對虛指針進行調整的代碼。我們可以在C180類的聲明中加入這個屬性。


struct __declspec(novtable) C180
{
 C180() {
  
foo();
  
this->foo();
 
}
 
virtual foo() {
  
cout << "<< C180.foo this: " << this << " vtadr: " << *(void**)this << endl;
 
}
};

  這樣再執行前面的代碼,輸出就會變成:


<< C180.foo this: 0012F7A4 vtadr: CCCCCCCC
<< C180.foo this: 0012F7A4 vtadr: CCCCCCCC
<< C190.foo this: 0012F7A4 vtadr: 0045C400

  由於編譯器抑制了對虛指針的調整所以在調C180的構造函數時虛指針的值沒有初始化,這時我們纔看到多虧編譯器把第二個通過this指針對foo的調用優化成了靜態綁定,否則由於虛指針(指的就是this)沒有初始化一定會出現一個指針異常的錯誤,這就回答我們上面的那個問題。

  在這種情況下產生的彙編代碼我就不列了,有興趣的朋友可以自己去看一看。另外對於析構函數的調用,也請有興趣的朋友自行分析一下。

 

 

 

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