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

C++對象布局及多態之虛成員函數調用

編輯:關於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的對象會發生什麼情況。

我們知道,在構造一個對象時,首先會按對象的大小得到一塊內存(在heap上或在stack上),然後會把指向這塊內存的指針做為this指針來調用類的構造函數,對這塊內存進行初始化。如果對象有父類就會先調用父類的構造函數(並依次遞歸),如果有多個父類(多重繼承)會依次對父類的構造函數進行調用,並會適當的調整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

和我們的分析大相徑庭。前2行是構造C190時的輸出,後1行是我們用靜態綁定方式調用的C190::foo()函數。第2行的輸出說明多態行為並沒有象預期的那樣發生。而且比較輸出的最後一列,發現在調用C180的構造函數時對象對應的虛表和構造後對象對應的虛表不是同一個。其實這正是奧秘的所在。

為此我查了一下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的虛表地址。第16、17行和18、19行分別調用了兩次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的調用優化成了靜態綁定,否則由於虛指針沒有初始化一定會出現一個指針異常的錯誤,這就回答我們上面的那個問題。

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

另外這個屬性在ATL的代碼中大量的使用。在ATL中接口一般為純虛基類,如果不用這個優化屬性,由於在子類即實現類的構造函數中要調用父類的構造函數,而編譯器產生的父類構造函數又要設置虛指針的值。所以編譯器必須要把父類的虛表構建出來。而實際上這個虛表是沒有任何意義的,因為ATL的純虛接口類的虛函數都是無實現的。這樣不僅僅是多了幾行無用的設值指令,同時也浪費了空間。有興趣的朋友可以自行驗證一下。

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