Performanced C++ 經驗規則(2):你不知道的建構函式(中)

發表於2015-12-27

上一篇你不知道的建構函式(上)主要講述了,C++建構函式在進入建構函式體之前,你可能不知道的一些細節。這一篇將講述,進入建構函式體後,又發生了什麼。

4、虛表初始化

上一篇曾提到,如果一個類有虛擬函式,那麼虛表的初始化工作,無論建構函式是你定義的還是由編譯器產生的,這部分工作都將由編譯器隱式“合成”到建構函式中,以表示其良苦用心。上一篇還提到,這部分工作,在“剛”進入建構函式的時候,就開始了,之後,編譯器才會理會,你建構函式體的第一行程式碼。這一點,通過反彙編,我們已經看的非常清楚。

虛表初始化的主要內容是:將虛表指標置於物件的首4位元組;用該類的虛擬函式實際地址替換虛表中該同特徵標(同名、同引數)函式的地址,以便在呼叫的時候實現多型,如果有新的虛擬函式(派生類中新宣告的),則依次新增至虛表的後面位置。

5、建構函式中有虛特性(即多型、即動態繫結、晚繫結)產生嗎?

這個問題,看似簡單,答案卻比較複雜,正確答案是:對於建構函式,建構函式中沒有虛特性產生(在C++中答案是NO,但在Java中,答案是YES,非常的奇葩)。

先從基類建構函式說起,為什麼要提基類建構函式呢,因為,派生類總是要呼叫一個基類的建構函式(無論是顯式呼叫還是由編譯器隱式地呼叫預設建構函式,因為這裡討論的是有虛擬函式的情況,所以一定會有基類建構函式產生並呼叫),而此時,在基類建構函式中,派生類物件根本沒有建立,也就是說,基類根本不知道派生類中產生了override,即多型,故沒有虛特性產生。

這一段非常讓人疑惑。讓我們再看一小段程式碼,事實勝於雄辯。

在Ubuntu 12.04 + gcc 4.6.3輸出結果如下:

這個結果可以很好的解釋上述問題,第一行,由於在Base建構函式中,看不到Derived的存在,所以根本不會產生虛特性;而第二行,雖然輸出了Derived::foo(void),但因為在派生類直接呼叫方法名,呼叫的就是本類的方法,(當然,也可認為在Derived建構函式中,執行foo()前,虛表已經OK,故產生多型,輸出的是派生類的行為)。再看第三行,也產生多型,因為,此時,派生類物件已經構建完成,虛表同樣也已經OK,所以產生多型是必然。

這個問題其實是C++比較詬病的陷阱問題之一,但我們只要記住結論:不要在建構函式內呼叫其它的虛成員函式,否則,當這個類被繼承後,在建構函式內呼叫的這些虛成員函式就沒有了虛特性(喪失多型性)。(非虛成員函式本來就沒有多型性,不在此討論範圍)

解決此類問題的方法,是使用“工廠模式”,在後續篇幅中筆者會繼續提到,這也是《Effective C++》中闡述的精神:儘可能以工廠方法替換公有建構函式。

另外,有興趣的同學,可以將上述程式碼稍加修改成Java跑一跑,你會驚喜的發現,三個輸出都是Derived::foo(void),也就是說,JVM為你提供了一種未卜先知的超自然能力。

6、建構函式中呼叫建構函式、解構函式

上面已經提到,不要在建構函式內呼叫其它成員函式,那麼呼叫一些“特殊”的函式,情況又如何呢?我知道,有同學想到了,在建構函式中呼叫本類的解構函式,情況如何?如下面的程式碼

雖然我對有這種想法的同學有強拖之去精神病院的衝動,但還是本著研究精神,把上述“瘋子”程式碼跑一遍,還特地把解構函式的定義提到建構函式之前以防建構函式不認識它。結論是:建構函式中呼叫解構函式,編譯器拒絕接受~A()是解構函式,從而拒絕這一不講理行為。此時編譯器認為,你是在過載~操作符,並給出沒有找到operator ~()宣告的錯誤提示。其實,無論是在建構函式A()裡面呼叫~A()不行,在成員函式裡,也是不行的(編譯器仍認為你要呼叫operator ~(),而你並沒有宣告這個函式)。但是,有個小詭計,卻可以編譯通過,就是通過this->~A()來呼叫解構函式,這將導致物件a被析構多次,隱藏著巨大的安全隱患。

總之,在建構函式中呼叫解構函式,是十分不道德的行為,應嚴格禁止。

好了,接下來是,建構函式中,呼叫建構函式,情況又如何呢?

(1)首先,如果建構函式中遞迴呼叫本建構函式,產生無限遞迴呼叫,很快就棧溢位(棧上分配)或其它crash,應嚴格禁止;

(2)如果建構函式中,呼叫另一個建構函式,情況如何?

上面程式碼,輸出為0嗎?

答案是:不一定。輸出結果是不確定的。根據C++類非靜態成員是沒有預設值的規則,可以推定,上述程式碼裡,在無參建構函式中呼叫另一個建構函式,並沒有成功完成對成員的初始化工作,也就是說,這個呼叫,是不正確的。

那麼,由ConAndCon產生的物件哪裡去了?如果用gdb跟蹤除錯或在上述類的構造、解構函式中列印出物件資訊就會發現,在建構函式中呼叫另一個建構函式,會產生一個匿名的臨時物件,然後這個物件又被銷燬,而呼叫它的cac物件,仍未得到本意的初始化(設定_i為0)。這也是應嚴格禁止的。

通常解決此問題的三個方案是:

方案一,我們稱為一根筋方案,即,我仍要繼續在建構函式中呼叫另一個建構函式,還要讓它正確工作,即“一根筋”,解決思路:不要產生新分配的物件,即在第一個建構函式產生了物件的記憶體分配之後,仍在此記憶體上呼叫另一個建構函式,通過佈局new操作符(replacement new)可以做到:

即在第一次分配好的記憶體上再次分配。

某次在Ubuntu 12.04 + gcc 4.6.3執行結果如下(修改後的程式碼):

可以看到,成功在第一次分配的記憶體上呼叫了另一個建構函式,且無需手動為replacement new呼叫解構函式(此處不同於在申請的buffer上應用replacement new,需要手動呼叫物件解構函式後,再釋放申請的buffer)

方案二,我們稱為“AllocAndCall”方案,即建構函式只完成物件的記憶體分配和呼叫初始化方法的功能,即把在多個建構函式中都要初始化的部分“提取”出來,通常做為一個private和非虛方法(為什麼不能是虛的參見上面第5點),然後在每個建構函式中呼叫此方法完成初始化。通常,這樣的方法取名為init,initialize之類。

這個方案和後面要詳述的“工廠模式”,在一些思想上類似。

這個方案最大的不足,是在於,initial()初始化方法不是建構函式而不能使用初始化列表,對於非靜態const成員的初始化將無能為力。也就是說,如果該類包含非靜態的const成員(靜態的成員初始化參看上一篇中的第2點),則對這些非靜態const成員的初始化,必須要在每個建構函式的初始化列表完成,無法“抽取“到初始化方法中。

方案三,我們稱為“C++ 0x“方案,這是C++ 0x中的新特性,叫做“委託建構函式”,通過在建構函式的初始化列表(注意不是建構函式體內)中呼叫其它建構函式,來得到相應目的。感謝C++ 0x!

其實,對於這樣的問題,筆者認為,最好的解決方式,沒有在這幾種方案中討論,仍是——使用“工廠模式”,替換公有建構函式。

中篇到此結束,下一篇將會有更多精彩內容——in C++ Constructor!。謝謝大家!

相關文章