Performanced C++ 經驗規則(1):你不知道的建構函式(上)
Performanced C++ 經驗規則
前言:Performanced C++,意為“高效能C++“程式設計,是筆者和所在團隊多年C++程式設計總結的經驗規則,按條款方式講述(參考了《Effective C++》的方式),希望能對初入C++的程式設計師提供幫助,少走彎路,站在前人的肩膀上,看得更高走的更遠。我們也同樣是腳踩許許多多大牛的經典著作,還有無數默默付出的程式設計師的辛勞,以及自己許許多多慘痛的程式設計體驗,才有了這些“規則”。
第一條:你不知道的建構函式(上)
首先來看,我們“知道”的建構函式,C++建構函式究竟做了哪些事情?
1、建立一個類的物件時,編譯器為物件分配記憶體空間,然後呼叫該類的建構函式;
2、建構函式的目的,是完成物件非靜態成員的初始化工作(靜態成員如何初始化?記住以下要點:在類外進行、預設值為0、在程式開始時、在主函式之前、單執行緒方式、主執行緒完成),記住:C++類非靜態成員是沒有預設值的(可對比Java)。
3、如果建構函式有初始化列表,則先按照成員宣告順序(非初始化列表中的順序)執行初始化列表中的內容,然後再進入建構函式體。這裡又有疑問了,如果類本身沒有非虛擬的基類,應顯式地呼叫直接基類的某個建構函式,否則,將會自動其直接基類的預設建構函式(如果此時直接基類沒有預設建構函式,得到編譯錯誤);如果類本身有虛擬基類,也應顯式地呼叫虛擬基類的某個建構函式,否則,將會自動呼叫虛擬基類的預設建構函式;如果成員有其它類的物件,則應顯式地呼叫成員所屬類的相應建構函式,否則對於沒有在初始化列表中出現的類成員,也會自動呼叫其預設的建構函式
注意上述呼叫順序,程式設計時應按照“先祖再客最後自己”的原則進行,即,首先完成自身包含的“祖先物件”的初始化,之後,完成自身包含的成員是其它型別(客人)的初始化,最後才是自身非類型別成員的初始化工作。
再注意,上面多次提到了術語“預設建構函式”,預設建構函式是指:無參建構函式或每個引數均有預設值的建構函式。當且僅當,一個類沒有宣告任何建構函式時,可認為編譯器會自動為該類建立一個預設建構函式(無參的,注意“可認為”,即實際情況並非如此,編譯器並不一定總是會自動建立預設建構函式,除非必要,這涉及到更深的彙編層面。當然,在寫程式碼的時候,這個“可認為”是正確的)。
這一小部分內容可能資訊量過大,讓我們看一段程式碼以加深理解。
#include <iostream>
using namespace std;
class Base
{
private:
int _x;
public:
Base(int x) : _x(x) { cout << "Base(x) _x=" << _x << endl; }
Base() {}
};
class DerivedA :virtual public Base
{
int _y;
public:
DerivedA(int x = 0, int y = 1) : Base(x), _y(y)
{ cout << "DerivedA(x,y) _y=" << _y << endl; }
};
class DerivedB :virtual public Base
{
int _z;
public:
DerivedB(int x = 0, int z = 2) : Base(x), _z(z)
{ cout << "DerivedB(x,z) _z=" << _z << endl; }
};
class Other
{
int _o;
public:
Other() : _o(3) { cout << "Other() _o=" << _o << endl; }
};
class DerivedFinal : public DerivedB, public DerivedA
{
int _xyz;
Other _other;
public:
DerivedFinal(int x = 10, int y = 20, int z = 30, int o = 50) : DerivedA(x,y), DerivedB(x,z), Base(x), _xyz(x * y * z)
{ cout << "DerivedFinal(x,y,z,o) _xyz=" << _xyz << endl; }
};
int main(int argc, char** argv)
{
DerivedFinal df;
return 0;
}
輸出結果(Ubuntu 12.04 + gcc 4.6.3):
Base(x) _x=10
DerivedB(x,z) _z=30
DerivedA(x,y) _y=20
Other() _o=3
DerivedFinal(x,y,z,o) _xyz=6000
和你心中的答案是否一致呢?
一切從DerivedFinal的呼叫順序說起,首先,這是虛繼承,故虛基類Base的建構函式將首先被呼叫,儘管它在DerivedFinal建構函式的初始化列表順序中排在後面的位置(再次記住,呼叫順序與初始化列表中的順序無關),接下來是DerivedB(x,z),因為它先被繼承;之後是DerivedA(x,z),再之後,DerivedFinal自身非類型別成員_xyz被初始化,最後是Other(),other成員並沒有出現在DerivedFinal的初始化列表中,所以它的預設建構函式將被自動呼叫。另外,如果不是虛繼承,呼叫間接基類Base的建構函式將是非法的,但此處是虛繼承,必須這樣做。
接下來繼續討論,上面提到,編譯器不一定總是會產生預設建構函式,雖然在編寫程式碼時,你“可以這麼認為”,這聽起來太玄乎了,那麼,到底什麼時候,編譯器才會真正在你沒有定義任何建構函式時,為你產生一個預設建構函式呢?有以下三種情況,編譯器一定會產生預設建構函式:
(1)該類、該類的基類或該類中定義的類型別成員物件中,有虛擬函式存在。
發生這種情況時,由於必須要完成物件的虛表初始化工作(關於虛擬函式的原理,筆者建議參考陳皓的《C++虛擬函式表解析》),所以編譯器在沒有任何建構函式的時候,會產生一個預設建構函式來完成這部分工作;然而,如果已經有任何建構函式,編譯器則把初始化虛表這部分工作“合成”到你已定義的建構函式之中(用心良苦)。
讓我們稍稍進入彙編領域(筆者強烈建議,要精通C/C++,一定的彙編和反彙編能力是必須的,能精通更好)看一下,一個有虛擬函式的類,建構函式的x86反彙編程式碼:
class VirtualTest
{
public:
virtual void foo(int x) { cout << x << endl; }
};
int main(int argc, char** argv)
{
VirtualTest vt;
lea ecx, [ebp-4] ;獲取物件首地址
call @ILT+15(VitrualTest::VirtualTest) (0048A500)
;呼叫建構函式,由於該類沒有定義任何建構函式又包含虛擬函式,編譯器產生了一個預設建構函式並呼叫
return 0;
}
//下面是預設建構函式反彙編
004013D0 55 push ebp
004013D1 8B EC mov ebp,esp
004013D3 51 push ecx
;頭三句,初始化函式呼叫過程,詳見彙編知識
004013D4 89 4D FC mov dword ptr [ebp-4],ecx
;獲取物件首地址,即this指標
004013D7 8B 45 FC mov eax,dword ptr [this]
;取出this指標,這個地址將會作為指標儲存到虛表首地址
004013DA C7 00 60 68 40 00 mov dword ptr [eax],offset VirtualTest::`vftable' (0042201c)
;取虛表首地址,儲存到虛表指標中(即物件頭4位元組)
004013E0 8B 45 FC mov eax,dword ptr [this]
;再次取出this指標地址,返回函式呼叫,即得到物件
004013E3 8B E5 mov esp,ebp
004013E5 5D pop ebp
004013E6 C3 ret
由該彙編程式碼還可以看出,虛表指標初始化,在建構函式初始化列表之後,進入建構函式體程式碼之前。
(2)該類、該類的基類中所定義的類型別成員物件中,帶有建構函式。
發生這種情況時,由於需要顯式地呼叫這些類型別成員的建構函式,編譯器在沒有任何建構函式的時候,也會產生一個預設建構函式來完成這個過程;同樣,如果你已經定義一個建構函式但沒有對這些類型別成員顯式呼叫建構函式,編譯器則把這部分工作“合成”到你定義的建構函式中(呼叫它們的預設建構函式,再次用心良苦)。
(3)該類擁有虛基類。
發生這種情況,需要維護“獨此一份”的虛基類繼承而來的物件,所以也需要通過建構函式完成。方式同(1)(2)。
除上述3種情況外,“可認為在沒有任何建構函式時候,編譯器產生一個預設建構函式”是不對的,因為這樣的預設建構函式是“無用”的,編譯器也就不會再用心良苦去做沒用的工作。這部分涉及彙編較多,如果想詳細瞭解,建議閱讀錢林松所著的《C++反彙編與逆向分析技術揭祕》,機械工業出版社,2012.5。
這裡只要記住結論就可以了。
終於講述完了,進入建構函式體之前的奧祕,你是否覺得不過癮呢?不著急,下一篇將講述C++進入建構函式體之後,那些你不知道的內容。