C++ 合成預設建構函式的真相
對於C++預設建構函式,我曾經有兩點誤解:
- 類如果沒有定義任何的建構函式,那麼編譯器(一定會!)將為類定義一個合成的預設建構函式。
- 合成預設建構函式會初始化類中所有的資料成員。
第一個誤解來自於我學習C++的第一本書 《C++ Primer》,在書中392頁:“只有當一個類沒有定義建構函式時,編譯器才會自動生成一個預設建構函式”。
實際上這句話也沒有說錯,它說明了預設建構函式定義的必要非充分條件,然而卻給當時初學C++的我造成了一定的誤解。
第二個誤解依舊來自於Primer中的一句話:“合成的預設建構函式使用與變數初始化相同的規則來初始化成員。具有類型別的成員通過執行各自的預設建構函式來進行初始化”。
總結了我有這些誤解的原因,第一是初學時知識體系沒形成,對Primer中所說的內容沒有真正的理解,第二就是Primer在某種程度上的確不是C++初學者能看懂的書,或許看時覺得懂了,卻是遺漏了很多知識。也說明了Primer 是座寶庫,常常回顧將會有新的感悟。
讓我對上面兩個觀點產生疑惑,是在看《Effective C++》時,條款05《瞭解C++預設編寫並呼叫哪些函式》中說到“….惟有當這些函式被需要(被呼叫),它們才會被編譯器創建出來。”
我寫這篇文章的目的是給和我有同樣誤解或疑惑的C++初學者看的,如果你對合成預設建構函式已有充分的認識,請忽略本文的內容。
正文
-
什麼是預設建構函式?
預設建構函式是可以不用實參進行呼叫的建構函式,它包括了以下兩種情況:
- 沒有帶明顯形參的建構函式。
- 提供了預設實參的建構函式。
類設計者可以自己寫一個預設建構函式。編譯器幫我們寫的預設建構函式,稱為“合成的預設建構函式”。強調“沒有帶明顯形參”的原因是,編譯器總是會為我們的建構函式形參表插入一個隱含的this指標,所以”本質上”是沒有不帶形參的建構函式的,只有不帶明顯形參的建構函式,它就是預設建構函式。
-
預設建構函式什麼時候被呼叫?
如果定義一個物件時沒有提供初始化式,就使用預設建構函式。例如:
class A { public: A(bool _isTrue= true, int _num=10){ isTrue = isTrue; num = _num; }; //預設建構函式 bool isTrue; int num; }; int main() { A a; //呼叫類A的預設建構函式 }
-
理解“被需要”這三個字
前面提到在《Effective C++》中指出惟有預設建構函式”被需要“的時候編譯器才會合成預設建構函式。關鍵字眼是”被需要“。被誰需要?做什麼事情?像下面這段程式碼,預設建構函式”被需要“了嗎?
class A { public: bool isTrue; int num; }; int main() { A a; if (a.isTrue) cout << a.num; return 0; }
你可能認為這裡定義類物件a的時候沒有提供引數且A沒有定義預設建構函式,編譯器肯定是合成了一個預設建構函式並呼叫它來初始化A的資料成員,實則不是。當你試圖檢視合成預設建構函式把資料成員num初始化為什麼值的時候,你會發現編譯器甚至都讓你執行不了程式:
當類只含有內建型別或複合型別的成員時,編譯器是不會為類合成預設建構函式的,這種類並不符合”被需要“的條件,甚至當類滿足“被需要”條件,編譯器合成了預設建構函式時,類中內建型別與複合型別資料成員依然不會在預設建構函式中進行初始化。Primer中也有提到:“如果類包含內建或複合型別的成員,則該類不應該依賴於合成的預設建構函式“。
上面程式碼中,預設建構函式”被需要“是對程式來說的,程式需要isTrue被初始化以便可以進行條件判斷,需要num被初始化以便可以輸出。然而這種需要並不會促使編譯器合成預設建構函式。惟有被編譯器所需要時,編譯器才會合成預設建構函式。那怎樣的類才是編譯器需要合成預設建構函式的呢?
總結:
- 合成預設建構函式總是不會初始化類的內建型別及複合型別的資料成員。
- 分清楚預設建構函式被程式需要與被編譯器需要,只有被編譯器需要的預設建構函式,編譯器才會合成它。
-
何時預設建構函式才會被編譯器需要?
以下四種情況的類,編譯器總是需要預設建構函式完成某些工作:
1. 含有類物件資料成員,該類物件型別有預設建構函式。
如果一個類沒有任何建構函式,但是它含有一個類物件資料成員,且該類物件型別有預設建構函式,那麼編譯器就會為該類合成一個預設建構函式,不過這個合成操作只有在建構函式真正需要被呼叫的時候才會發生。舉個例子,編譯器將為類B合成一個預設建構函式:
class A { public: A(bool _isTrue=true, int _num = 0){ isTrue = _isTrue; num = _num; }; //預設建構函式 bool isTrue; int num; }; class B { public: A a;//類A含有預設建構函式 int b; //... }; int main() { B b; //編譯至此時,編譯器將為B合成預設建構函式 return 0; }
被合成的預設建構函式做了什麼事情?大概如下面這樣:
B::B()
{
a.A::A();
}
被合成的預設建構函式內只含必要的程式碼,它完成了對資料成員a的初始化,但不產生任何程式碼來初始化B::b。正如上面所說,初始化類的內建型別或複合型別成員是程式的責任而不是編譯器的責任。為了滿足程式的需要,我們一般會自己寫建構函式來對B::b進行初始化,像這樣:
B::B() { a.A::A(); //編譯器插入的程式碼 b = 0; //顯示定義的程式碼 }
如果類中有多種類物件成員,則編譯器按照這些類物件成員宣告的順序,在建構函式按順序插入呼叫各個類預設建構函式的程式碼。
2.基類帶有預設建構函式的派生類。
當一個類派生自一個含有預設建構函式的基類時,該類也符合編譯器需要合成預設建構函式的條件。編譯器合成的預設建構函式將根據基類宣告順序呼叫上層的基類預設建構函式。同樣的道理,如果設計者定義了多個建構函式,編譯器將不會重新定義一個合成預設建構函式,而是把合成預設建構函式的內容插入到每一個建構函式中去。
3. 帶有虛擬函式的類
類帶有虛擬函式可以分為兩種情況:
- 類本身定義了自己的虛擬函式
- 類從繼承體系中繼承了虛擬函式(成員函式一旦被宣告為虛擬函式,繼承不會改變虛擬函式的”虛性質“)。
這兩種情況都使一個類成為帶有虛擬函式的類。這樣的類也滿足編譯器需要合成預設建構函式的類,原因是含有虛擬函式的類物件都含有一個虛表指標vptr,編譯器需要對vptr設定初值以滿足虛擬函式機制的正確執行,編譯器會把這個設定初值的操作放在預設建構函式中。如果設計者沒有定義任何一個預設建構函式,則編譯器會合成一個預設建構函式完成上述操作,否則,編譯器將在每一個建構函式中插入程式碼來完成相同的事情。
4.帶有虛基類的類
虛基類的概念是存在於類與類之間的,是一種相對的概念。例如類A虛繼承於類X,則對於A來說,類X是類A的虛基類,而不能說類X就是一個虛基類。虛基類是為了解決多重繼承下確保子類物件中每個父類只含有一個副本的問題,比如菱形繼承。如下圖:
於是,類A物件中含有一份類X物件,類C中也含有一份類X物件,當我們遇上如下程式碼時:
class X { public: int i; }; class A : public virtual X{ public:int j; }; class B : public virtual X{ public:double d; }; class C : public A, public B{ public: int k; }; void function(A *pa) { pa->i = 1000; } int main() { A *a= new A(); C *c= new C(); function(a); //關注重點在這裡 function(c); //關注重點在這裡 return 0; }
函式function引數pa的真正型別是可以改變的,既可以把A物件指標賦值給pa,也可以把物件指標賦值給pa,在編譯階段並無法確定pa儲存的i是屬於A還是C的虛基類物件。為了解決這問題,編譯器將產生一個指向虛基類X的指標,使得程式得以在執行期確定經由pa而存取的X::i的實際儲存位置。這個指標的安插,編譯器將會在合成預設建構函式中完成,同樣的,如果設計者已經寫了多個建構函式,那麼編譯器不會重新寫預設建構函式,而是把虛基類指標的安插程式碼插入已有的建構函式中。
-
總結
重新強調文章開篇所提,以下兩個觀點都是誤解:
a) 任何類如果沒有定義建構函式,則編譯器會幫我們合成一個預設建構函式。
b) 合成預設建構函式會對類中的每一個數據成員進行初始化。
只有在編譯器需要預設建構函式來完成編譯任務的時候,編譯器才會為沒有任何建構函式的類合成一個預設建構函式,或者是把這些操作插入到已有的建構函式中去。
編譯器需要預設建構函式的四種情況,總結起來就是:
a) 呼叫物件成員或基類的預設建構函式。
b) 為物件初始化虛表指標與虛基類指標。
PS:如果本文哪個地方闡述不清楚或者錯誤,十分期待指出,多謝!