Performanced C++ 經驗規則(2):你不知道的構造函數(中)
4、虛表初始化
上一篇曾提到,如果一個類有虛函數,那么虛表的初始化工作,無論構造函數是你定義的還是由編譯器產生的,這部分工作都將由編譯器隱式“合成”到構造函數中,以表示其良苦用心。上一篇還提到,這部分工作,在“剛”進入構造函數的時候,就開始了,之后,編譯器才會理會,你構造函數體的第一行代碼。這一點,通過反匯編,我們已經看的非常清楚。
虛表初始化的主要內容是:將虛表指針置于對象的首4字節;用該類的虛函數實際地址替換虛表中該同特征標(同名、同參數)函數的地址,以便在調用的時候實現多態,如果有新的虛函數(派生類中新聲明的),則依次添加至虛表的后面位置。
5、構造函數中有虛特性(即多態、即動態綁定、晚綁定)產生嗎?
這個問題,看似簡單,答案卻比較復雜,正確答案是:對于構造函數,構造函數中沒有虛特性產生(在C++中答案是NO,但在Java中,答案是YES,非常的奇葩)。
先從基類構造函數說起,為什么要提基類構造函數呢,因為,派生類總是要調用一個基類的構造函數(無論是顯式調用還是由編譯器隱式地調用默認構造函數,因為這里討論的是有虛函數的情況,所以一定會有基類構造函數產生并調用),而此時,在基類構造函數中,派生類對象根本沒有創建,也就是說,基類根本不知道派生類中產生了override,即多態,故沒有虛特性產生。
這一段非常讓人疑惑。讓我們再看一小段代碼,事實勝于雄辯。
#include <iostream> using namespace std; class Base { public: Base() { foo(); } virtual void foo(void) { cout << "Base::foo(void)" << endl; } virtual void callFoo(void) { foo(); } }; class Derived : public Base { public: Derived() { foo(); } void foo(void) { cout << "Derived::foo(void)" << endl; } }; int main(int argc, char** argv) { Base* pB = new Derived; pB->callFoo(); if(pB) delete pB; return 0; }
在Ubuntu 12.04 + gcc 4.6.3輸出結果如下:
Base::foo(void) Derived::foo(void) Derived::foo(void)
這個結果可以很好的解釋上述問題,第一行,由于在Base構造函數中,看不到Derived的存在,所以根本不會產生虛特性;而第二行,雖然輸出了Derived::foo(void),但因為在派生類直接調用方法名,調用的就是本類的方法,(當然,也可認為在Derived構造函數中,執行foo()前,虛表已經OK,故產生多態,輸出的是派生類的行為)。再看第三行,也產生多態,因為,此時,派生類對象已經構建完成,虛表同樣也已經OK,所以產生多態是必然。
這個問題其實是C++比較詬病的陷阱問題之一,但我們只要記住結論:不要在構造函數內調用其它的虛成員函數,否則,當這個類被繼承后,在構造函數內調用的這些虛成員函數就沒有了虛特性(喪失多態性)。(非虛成員函數本來就沒有多態性,不在此討論范圍)
解決此類問題的方法,是使用“工廠模式”,在后續篇幅中筆者會繼續提到,這也是《Effective C++》中闡述的精神:盡可能以工廠方法替換公有構造函數。
另外,有興趣的同學,可以將上述代碼稍加修改成Java跑一跑,你會驚喜的發現,三個輸出都是Derived::foo(void),也就是說,JVM為你提供了一種未卜先知的超自然能力。
6、構造函數中調用構造函數、析構函數
上面已經提到,不要在構造函數內調用其它成員函數,那么調用一些“特殊”的函數,情況又如何呢?我知道,有同學想到了,在構造函數中調用本類的析構函數,情況如何?如下面的代碼
#include <iostream> using namespace std; class A { public: ~A() { cout << hex << (int)this <<"destructed!" << endl; } A() { cout << hex << (int)this << "constructed!" << endl; ~A(); } }; int main(int argc, char** argv) { A a; return 0; }
雖然我對有這種想法的同學有強拖之去精神病院的沖動,但還是本著研究精神,把上述“瘋子”代碼跑一遍,還特地把析構函數的定義提到構造函數之前以防構造函數不認識它。結論是:構造函數中調用析構函數,編譯器拒絕接受~A()是析構函數,從而拒絕這一不講理行為。此時編譯器認為,你是在重載~操作符,并給出沒有找到operator ~()聲明的錯誤提示。其實,無論是在構造函數A()里面調用~A()不行,在成員函數里,也是不行的(編譯器仍認為你要調用operator ~(),而你并沒有聲明這個函數)。但是,有個小詭計,卻可以編譯通過,就是通過this->~A()來調用析構函數,這將導致對象a被析構多次,隱藏著巨大的安全隱患。
總之,在構造函數中調用析構函數,是十分不道德的行為,應嚴格禁止。
好了,接下來是,構造函數中,調用構造函數,情況又如何呢?
(1)首先,如果構造函數中遞歸調用本構造函數,產生無限遞歸調用,很快就棧溢出(棧上分配)或其它crash,應嚴格禁止;
(2)如果構造函數中,調用另一個構造函數,情況如何?
#include <iostream> using namespace std; class ConAndCon { public: int _i; ConAndCon( int i ) : _i(i){} ConAndCon() { ConAndCon(0); } }; int main(int argc, char** argv) { ConAndCon cac; cout << cac._i << endl; return 0; }
上面代碼,輸出為0嗎?
答案是:不一定。輸出結果是不確定的。根據C++類非靜態成員是沒有默認值的規則,可以推定,上述代碼里,在無參構造函數中調用另一個構造函數,并沒有成功完成對成員的初始化工作,也就是說,這個調用,是不正確的。
那么,由ConAndCon產生的對象哪里去了?如果用gdb跟蹤調試或在上述類的構造、析構函數中打印出對象信息就會發現,在構造函數中調用另一個構造函數,會產生一個匿名的臨時對象,然后這個對象又被銷毀,而調用它的cac對象,仍未得到本意的初始化(設置_i為0)。這也是應嚴格禁止的。
通常解決此問題的三個方案是:
方案一,我們稱為一根筋方案,即,我仍要繼續在構造函數中調用另一個構造函數,還要讓它正確工作,即“一根筋”,解決思路:不要產生新分配的對象,即在第一個構造函數產生了對象的內存分配之后,仍在此內存上調用另一個構造函數,通過布局new操作符(replacement new)可以做到:
//標準庫中replacement new操作符的定義: //需要#include <new> inline void *__cdecl operator new(size_t, void *_P) { return (_P); } //那么修改ConAndCon()為: ConAndCon() { new (this)ConAndCon(0); }
即在第一次分配好的內存上再次分配。
某次在Ubuntu 12.04 + gcc 4.6.3運行結果如下(修改后的代碼):
#include <iostream> #include <new> using namespace std; class ConAndCon { public: int _i; ConAndCon( int i ) : _i(i){cout << hex << (int)this <<"constructed!" << endl;} ConAndCon() { cout << hex << (int)this <<"constructed!" << endl; new (this)ConAndCon(0); } ~ConAndCon() { cout << hex << (int)this <<"destructed!" << endl; } }; int main(int argc, char** argv) { ConAndCon cac; cout << cac._i << endl; return 0; } //運行結果: bfd1ae9cconstructed! bfd1ae9cconstructed! 0 bfd1ae9cdestructed!
可以看到,成功在第一次分配的內存上調用了另一個構造函數,且無需手動為replacement new調用析構函數(此處不同于在申請的buffer上應用replacement new,需要手動調用對象析構函數后,再釋放申請的buffer)
方案二,我們稱為“AllocAndCall”方案,即構造函數只完成對象的內存分配和調用初始化方法的功能,即把在多個構造函數中都要初始化的部分“提取”出來,通常做為一個private和非虛方法(為什么不能是虛的參見上面第5點),然后在每個構造函數中調用此方法完成初始化。通常,這樣的方法取名為init,initialize之類。
class AllocAndCall { private: void initial(...) {...} //初始化集中這里 public: AllocAndCall() { initial(); ...} AllocAndCall(int x) { initail(); ...} };
這個方案和后面要詳述的“工廠模式”,在一些思想上類似。
這個方案最大的不足,是在于,initial()初始化方法不是構造函數而不能使用初始化列表,對于非靜態const成員的初始化將無能為力。也就是說,如果該類包含非靜態的const成員(靜態的成員初始化參看上一篇中的第2點),則對這些非靜態const成員的初始化,必須要在每個構造函數的初始化列表完成,無法“抽取“到初始化方法中。
方案三,我們稱為“C++ 0x“方案,這是C++ 0x中的新特性,叫做“委托構造函數”,通過在構造函數的初始化列表(注意不是構造函數體內)中調用其它構造函數,來得到相應目的。感謝C++ 0x!
class CPerson { public: CPerson() : CPerson(0, "") { NULL; } CPerson(int nAge) : CPerson(nAge, "") { NULL; } CPerson(int nAge, const string &strName) { stringstream ss; ss << strName << "is " << nAge << "years old."; m_strInfo = ss.str(); } private: string m_strInfo; };
其實,對于這樣的問題,筆者認為,最好的解決方式,沒有在這幾種方案中討論,仍是——使用“工廠模式”,替換公有構造函數。
中篇到此結束,下一篇將會有更多精彩內容——in C++ Constructor!。謝謝大家!