在建構函式中呼叫虛函數雖非常見技巧,但深入研究能加深對虛函數機制與物件建構過程的理解。這個行為往往與直覺認知有所出入。首先看看以下兩個類別定義:
struct C180 {
C180() {
foo();
this->foo();
}
virtual void foo() {
cout << "<< C180.foo this: " << this << " vtadr: " << *(void**)this << endl;
}
};
struct C190 : public C180 {
C190() {}
virtual void foo() {
cout << "<< C190.foo this: " << this << " vtadr: " << *(void**)this << endl;
}
};
父類別擁有一個虛函數,並在其建構函式中以兩種方式呼叫:直接呼叫與透過 this 指標呼叫。子類別重寫了該虛函數。預測建構 C190 物件時會發生什麼情況。
建構物件時,先配置記憶體,再以指向該記憶體的指標作為 this 調用建構函式。若有基底類別,會優先遞迴呼叫基底類別建構函式,完成後才執行衍生類別本身的程式碼。依此邏輯,在 C180 建構函式中,第一個 foo() 應為靜態繫結(呼叫 C180::foo),第二個 this->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
前兩行為建構期間輸出,第三行為建構完成後呼叫。明顯可見:
依 C++ 標準 §12.7/3,在衍生類別建構期間呼叫基底類別建構函式時,虛函數呼叫不會表現多型行為,強制繫結至基底類別版本。原因在於此時衍生類別成員尚未初始化,若允許呼叫衍生類別覆寫函數並存取其成員,將導致未定義行為。
觀察 VC7.1 產生的組合語言可窺探實作細節。C190 建構函式先呼叫 C180 建構函式(第 15 行),此時 ecx 為 this 指標。進入 C180 建構函式後,第 15 行將虛表指標設為 C180 的虛表位址(0x0045C404),接著兩次呼叫 foo 均為靜態繫結(第 16-19 行)。返回 C190 建構函式後,第 17 行再將虛表指標更新為 C190 的虛表位址(0x0045C400)。
編譯器透過在建構函式實體程式碼執行前,先設定虛表指標指向對應類別的虛表,以實作標準語義。即便使用 this->foo(),編譯器仍優化為靜態繫結,調整虛表指標主要為防止巢狀虛函數呼叫時出錯。
VC7.1 提供 __declspec(novtable) 抑制虛表指標調整代碼產生。加上此屬性後,建構期間虛表指標保持未初始化狀態(0xCCCCCCCC),若無編譯器優化,第二次呼叫將導致存取違規。這也印證了編譯器優化的必要性。ATL 大量使用此屬性避免為純虛擬介面類別產生無意義的虛表與設值指令,節省空間與執行時間。