【構造和析構函數】

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

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

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

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

 virtual void aMethod() {}

private:
 int aValue;
};

它的構造函數會被擴充成大概下面的樣子:

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

 // 2.基類的構造函數被調用(可視為遞歸過程)
 B::B();

 // 3.初始化虛指針

 // 4.展開初始化列表,如果有數據元素有默認構造函數將被調用
 aValue(i);

 // 5.構造函數實體
}

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

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

 

【拷貝構造函數】

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

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

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

理解該問題另一個益處是幫助我們理解自定義拷貝構造函數的時機。編譯器安插的拷貝構造函數,無論是哪種方式,都是一個淺拷貝。因為這對于編譯器來說,以足夠保證它不會出錯了。但如果我們類型中涉及到指針類型的成員時候,我們可能需要的是一個深拷貝。這對于編譯器來說不重要,但對于保證我們所需的程序邏輯是很重要的。只有在這種編譯器無法滿足需求的情況下才使用自定義拷貝構造函數,除此之外都不用畫蛇添足。因為不但增加了工作量,還很有可能降低了效率(當用成員拷貝頂替了位拷貝時)。

 

【動態分配】

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

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

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

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

 

【變量管理】

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

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