上一篇你不知道的建構函式(上)主要講述了,C++建構函式在進入建構函式體之前,你可能不知道的一些細節。這一篇將講述,進入建構函式體後,又發生了什麼。
4、虛表初始化
上一篇曾提到,如果一個類有虛擬函式,那麼虛表的初始化工作,無論建構函式是你定義的還是由編譯器產生的,這部分工作都將由編譯器隱式“合成”到建構函式中,以表示其良苦用心。上一篇還提到,這部分工作,在“剛”進入建構函式的時候,就開始了,之後,編譯器才會理會,你建構函式體的第一行程式碼。這一點,通過反彙編,我們已經看的非常清楚。
虛表初始化的主要內容是:將虛表指標置於物件的首4位元組;用該類的虛擬函式實際地址替換虛表中該同特徵標(同名、同引數)函式的地址,以便在呼叫的時候實現多型,如果有新的虛擬函式(派生類中新宣告的),則依次新增至虛表的後面位置。
5、建構函式中有虛特性(即多型、即動態繫結、晚繫結)產生嗎?
這個問題,看似簡單,答案卻比較複雜,正確答案是:對於建構函式,建構函式中沒有虛特性產生(在C++中答案是NO,但在Java中,答案是YES,非常的奇葩)。
先從基類建構函式說起,為什麼要提基類建構函式呢,因為,派生類總是要呼叫一個基類的建構函式(無論是顯式呼叫還是由編譯器隱式地呼叫預設建構函式,因為這裡討論的是有虛擬函式的情況,所以一定會有基類建構函式產生並呼叫),而此時,在基類建構函式中,派生類物件根本沒有建立,也就是說,基類根本不知道派生類中產生了override,即多型,故沒有虛特性產生。
這一段非常讓人疑惑。讓我們再看一小段程式碼,事實勝於雄辯。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
#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輸出結果如下:
1 2 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、建構函式中呼叫建構函式、解構函式
上面已經提到,不要在建構函式內呼叫其它成員函式,那麼呼叫一些“特殊”的函式,情況又如何呢?我知道,有同學想到了,在建構函式中呼叫本類的解構函式,情況如何?如下面的程式碼
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
#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)如果建構函式中,呼叫另一個建構函式,情況如何?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
#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)可以做到:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
//標準庫中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執行結果如下(修改後的程式碼):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
#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之類。
1 2 3 4 5 6 7 8 |
class AllocAndCall { private: void initial(...) {...} //初始化集中這裡 public: AllocAndCall() { initial(); ...} AllocAndCall(int x) { initail(); ...} }; |
這個方案和後面要詳述的“工廠模式”,在一些思想上類似。
這個方案最大的不足,是在於,initial()初始化方法不是建構函式而不能使用初始化列表,對於非靜態const成員的初始化將無能為力。也就是說,如果該類包含非靜態的const成員(靜態的成員初始化參看上一篇中的第2點),則對這些非靜態const成員的初始化,必須要在每個建構函式的初始化列表完成,無法“抽取“到初始化方法中。
方案三,我們稱為“C++ 0x“方案,這是C++ 0x中的新特性,叫做“委託建構函式”,通過在建構函式的初始化列表(注意不是建構函式體內)中呼叫其它建構函式,來得到相應目的。感謝C++ 0x!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
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!。謝謝大家!