Performanced C++ 經驗規則(2):你不知道的建構函式(中)
上一篇你不知道的建構函式(上)主要講述了,C++建構函式在進入建構函式體之前,你可能不知道的一些細節。這一篇將講述,進入建構函式體後,又發生了什麼。
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!。謝謝大家!喜歡小編分享的文章的小夥伴可以加下小編主頁的Q群941636044一起交流哦!