【構(gòu)造和析構(gòu)函數(shù)】

通常我們的看法是:當(dāng)定義一個類的時候,如果沒有為它寫一個構(gòu)造函數(shù),系統(tǒng)將幫我們生成一個,并完成成員的初始化。但是,從編譯器來看,上述看法中的兩點(diǎn)認(rèn)識都不夠正確。編譯器只會在編譯需要的情況下(nontrivial的條件)自動生成默認(rèn)構(gòu)造函數(shù)構(gòu)造函數(shù)。一般包括下面四種情況:1.類中包含的數(shù)據(jù)成員有默認(rèn)構(gòu)造函數(shù);2.其基類包含默認(rèn)構(gòu)造函數(shù);3.具有虛成員函數(shù);4.虛繼承至某個類。其他情況被稱為是trivial,編譯器將不會添加一個默認(rèn)構(gòu)造函數(shù)。把握好編譯器需要才添加這個前提,很容易理解為什么要添加默認(rèn)構(gòu)造函數(shù)。默認(rèn)函數(shù)完成編譯器需要做的一些事情,包括初始化基類,虛指針等。搞清楚這個概念有助于了解構(gòu)造函數(shù)的存在意義。構(gòu)造函數(shù)是為了完成對象的初始化(即初始化分配來的空間),與空間的分配沒有關(guān)系,而我們會經(jīng)常自覺不自覺的混淆這個概念,在處理一些問題(比如new操作符)的時候,理解不夠清晰。現(xiàn)在了解了編譯器的原理,可以加深對構(gòu)造函數(shù)的認(rèn)識。

另外如果一個類中被定義了一個構(gòu)造函數(shù),編譯器也會根據(jù)它的需求擴(kuò)充該構(gòu)造函數(shù)。考慮下面一段代碼:

class A
{
 virtual void aMethod() {}
};

class B :
 public A
{
public:
 A(int i) : aValue(i) {}

 virtual void aMethod() {}

private:
 int aValue;
};

它的構(gòu)造函數(shù)會被擴(kuò)充成大概下面的樣子:

 A* A::A(A* this, int i)
{
 // 1.如果有虛基類會被先行調(diào)用

 // 2.基類的構(gòu)造函數(shù)被調(diào)用(可視為遞歸過程)
 B::B();

 // 3.初始化虛指針

 // 4.展開初始化列表,如果有數(shù)據(jù)元素有默認(rèn)構(gòu)造函數(shù)將被調(diào)用
 aValue(i);

 // 5.構(gòu)造函數(shù)實(shí)體
}

請注意虛表和虛指針被初始化的位置。它處于基類構(gòu)造函數(shù)被調(diào)用后,本身內(nèi)容被執(zhí)行之前。也就是如果在該類基類的構(gòu)造函數(shù)中放置一個虛函數(shù),它執(zhí)行的內(nèi)容是定義在基類本身的版本(也許你想調(diào)用子類的實(shí)現(xiàn)版本),這個概念與其他的虛函數(shù)的調(diào)用不一樣(其他調(diào)用的都是屬于子類的版本)。因?yàn)樵诨悩?gòu)造的時候,虛指針依然指向基類的虛表,只有初始化虛指針后才會生成子類的虛表。這中虛表構(gòu)建的方式和其他一些語言會有所不同(比如C#),所以當(dāng)你在你的構(gòu)造函數(shù)中放置虛函數(shù)時(通常不建議這樣做),請保證你是真的要使用本身版本的虛函數(shù)。

默認(rèn)析構(gòu)函數(shù)與默認(rèn)構(gòu)造函數(shù)的問題類似。即,只有在編譯期需要的時候生成,目地是完成清理的工作,與空間回收無關(guān)。而且,析構(gòu)函數(shù)并不一定要于構(gòu)造函數(shù)配對。比如類中只是有一個虛函數(shù)的時候,就不必生成默認(rèn)析構(gòu)函數(shù)。所有的析構(gòu)和構(gòu)造對稱出現(xiàn)的想法,都只是我們出于美學(xué)的主觀臆斷罷了。而對析構(gòu)函數(shù)的擴(kuò)展,其順序與構(gòu)造函數(shù)相反,這一點(diǎn)倒是符合了對稱性。

 

【拷貝構(gòu)造函數(shù)】

 C++的每個類型中都有一個拷貝構(gòu)造函數(shù)(伴隨著一個復(fù)制操作符),如果類中沒有顯性的聲明,系統(tǒng)會安插一個(想一想,為什么是必須添加的),以完成由一個已有對象復(fù)制生成新的對象。拷貝構(gòu)造函數(shù)可以分成兩種。一種是按位拷貝(bitwise copy),另一種是按成員拷貝(member copy)。所謂按位拷貝就是指把一個對象在內(nèi)存中的每一位,一一拷貝到另一個新的位置,它的特點(diǎn)是拷貝速度快。而按函數(shù)拷貝就是分別調(diào)用各成員的拷貝構(gòu)造函數(shù),依次拷貝所需的成員,其特點(diǎn)是控制力強(qiáng),可以選擇需要拷貝的成員,或者對成員做特殊的處理。系統(tǒng)自動生成的拷貝構(gòu)造函數(shù)通常是按位拷貝,但在某些情況(這些情況和上面生成構(gòu)造函數(shù)的情況類似)下會采用按成員拷貝。

為什么需要按函數(shù)拷貝,舉個例子就理解這個問題。假設(shè)下面這樣一個繼承體系:設(shè)定B類是A類的子類,且A, B的對象大小不等,并B重載A的一個虛方法。當(dāng)一個B對象拷貝到另一個B對象中,按位拷貝不會出現(xiàn)問題。但如果把一個B對象拷貝到一個A對象上,再簡單的應(yīng)用按位拷貝就會出現(xiàn)問題。可能vptr被切割掉了(如果vptr放在對象的最后),要不即使不被切割掉也不會被正確設(shè)置(A對象和B對象的虛指針指向了同一個虛表)。因此在這種場合下,按位拷貝就不夠用了,系統(tǒng)會安插一個具有成員拷貝的拷貝構(gòu)造函數(shù),對虛指針的拷貝問題進(jìn)行特別的處理。

理解這個問題的益處在于可以幫助寫出更有效的代碼。通常我們寫類的拷貝構(gòu)造函數(shù)都會采用成員拷貝的方式。但其實(shí)很多時候更好的方式是先做一個按位拷貝,再修改不符合按位拷貝的內(nèi)容。這樣做不僅編碼簡單清晰,運(yùn)行的也更加高效。

理解該問題另一個益處是幫助我們理解自定義拷貝構(gòu)造函數(shù)的時機(jī)。編譯器安插的拷貝構(gòu)造函數(shù),無論是哪種方式,都是一個淺拷貝。因?yàn)檫@對于編譯器來說,以足夠保證它不會出錯了。但如果我們類型中涉及到指針類型的成員時候,我們可能需要的是一個深拷貝。這對于編譯器來說不重要,但對于保證我們所需的程序邏輯是很重要的。只有在這種編譯器無法滿足需求的情況下才使用自定義拷貝構(gòu)造函數(shù),除此之外都不用畫蛇添足。因?yàn)椴坏黾恿斯ぷ髁浚€很有可能降低了效率(當(dāng)用成員拷貝頂替了位拷貝時)。

 

【動態(tài)分配】

眾所周知,C++在運(yùn)行期間在堆上管理內(nèi)存通常采用new和delete操作符。但new操作有時候不僅僅是等價于調(diào)用new操作符以完成空間的分配。比如int *pi = new int(5)。它相當(dāng)于先調(diào)用new操作符:int *pi = __new(sizeof(int)),用以完成空間分配;再調(diào)用*pi = 5,用以完成初值設(shè)定。而深入一些來說,new操作符所作的內(nèi)存分配也不是簡單的按需分配。當(dāng)分配失敗的時候,會調(diào)用new_handler來進(jìn)行一些處理(可能包括回收空間,輸出信息等等),然后再重新分配直至分配成功或new_handler不再處理位置。因此,我們有時候只需要重寫new_handler的行為就可以達(dá)到我們的目的(比如自定義重分配方式),而不再需要重寫new操作符,這樣降低了工作的難度。只有真正需要改變內(nèi)存分配策略的時候,才需要重載new操作符。

相比較delete操作符所做的事情比較純粹,就是釋放對象占用的堆中的空間。但我們?nèi)绾沃缹ο笳加昧硕啻蟮目臻g,我們該釋放多少空間呢?常規(guī)的做法是在new的時候安插一個cookie,里面存放對象大小等信息,在delete的時候,函數(shù)訪問該cookie,重而保證正確的釋放。很明顯,cookie的插入增加了對象的大小,降低了操作的效率,還會出現(xiàn)安全性的問題。所以當(dāng)程序中有大量的小對象存在,或者是需要頻繁獲取和釋放空間的時候,往往會常用一些更具技巧性的分配方案,以滿足需求(比如,池化分配)。

數(shù)組的分配與單個對象的分配問題類似,可以簡單的視為連續(xù)分配n個對象空間。只是為了保證delete的正確執(zhí)行,在數(shù)組對象分配的時候,還需要多安插一些信息。

而除了常見的new操作符外,C++還支持另一種被稱為Placement operator new的操作符,它多接受一個void*指針類型,默認(rèn)情況下,是用于在指定地址開始分配空間(更確切的說法應(yīng)該在該子句前加上優(yōu)先)。當(dāng)然也有的時候,通過重載,void*也會指向其他對象(比如ostream對象),用已完成其他的特殊功能(比如書寫日志)。不論哪種,都需要保證placement new和placement delete成對出現(xiàn),這樣才能完成正常的空間回收。

 

【變量管理】

當(dāng)在一個函數(shù)中構(gòu)造了一個對象(棧中),會在離開函數(shù)域的時候析構(gòu)這個對象。如果在函數(shù)頭構(gòu)造了該對象,而該函數(shù)有多個出口(return語句),編譯器就會在每個出口前調(diào)用該對象析構(gòu)函數(shù)。因此,我們最好在第一次使用該對象前構(gòu)造該對象,這不僅僅是一種良好的編程習(xí)慣,有利于閱讀和理解,并且也是一種減少無謂的代碼損失的好方法。

另外一個特殊變量是全局變量。程序中任何代碼調(diào)用全局變量的時候,都要保證全局變量已經(jīng)按要求完成初始化。因此,系統(tǒng)會需要在進(jìn)入main函數(shù)后首先初始化全局變量,在離開main函數(shù)前,清理全局變量。一種實(shí)現(xiàn)方法,是在編譯每個全局變量的時候,為該變量添加一個用于構(gòu)造的函數(shù)(eg. __sti_xxx())和用于釋放的函數(shù)(eg. __std_xxx())。完成編譯后,還需要收集所有用于全局變量構(gòu)造和釋放的函數(shù)把它們放入兩個特殊的函數(shù)中(eg. _main()和exit())。把它們分別放在main()函數(shù)的入口和出口處。用于在進(jìn)入main后初始化和離開main前解構(gòu)。收集的具體實(shí)現(xiàn)取決于編譯器,如果編譯器支持跨平臺性,對文件的操作要特別小心,避免調(diào)用平臺文件格式相關(guān)的操作代碼。