C++語言特性(2)---構造函數與析構函數

 

 

轉自:http://blog.csdn.net/befun/article/details/1928931

構造函數和析構函數的特點是當創建對象時,自動執行構造函數;當銷燬對象時,析構函數自動被執行。這兩個函數分別是一個對象最先和最後被執行的函數,構造函數在創建對象時調用,用來初始化該對象的初始狀態和取得該對象被使用前需要的一些資源,比如文件/網絡連接等;析構函數執行與構造函數相反的操作,主要是釋放對象擁有的資源,而且在此對象的生命週期這兩個函數都只被執行一次。

創建一個對象一般有兩種方式,一種是從線程運行棧中創建,也稱爲"局部對象",一般語句爲: 

{
        ……
        Object obj;             ①
        ……
}                               ②

銷燬這種對象並不需要程序顯式地調用析構函數,而是當程序運行出該對象所屬的作用域時自動調用。比如上述程序中在①處創建的對象obj在②處會自動調用該對象的析構函數。在這種方式中,對象obj的內存在程序進入該作用域時,編譯器生成的代碼已經爲其分配(一般都是通過移動棧指針),①句只需要調用對象的構造函數即可。②處編譯器生成的代碼會調用該作用域內所有局部的用戶自定義類型對象的析構函數,對象obj屬於其中之一,然後通過一個退棧語句一次性將空間返回給線程棧。

另一種創建對象的方式爲從全局堆中動態創建,一般語句爲:

{
        ……
        Object* obj = new Object;   ①
        ……
        delete obj;                 ②
        ……
}                                   ③

當執行①句時,指針obj所指向對象的內存從全局堆中取得,並將地址值賦給obj。但指針obj本身卻是一個局部對象,需要從線程棧中分配,它所指向的對象從全局堆中分配內存存放。從全局堆中創建的對象需要顯式調用delete銷燬,delete會調用該指針指向的對象的析構函數,並將該對象所佔的全局堆內存空間返回給全局堆,如②句。執行②句後,指針obj所指向的對象確實已被銷燬。但是指針obj卻還存在於棧中,直到程序退出其所在的作用域。即執行到③處時,指針obj纔會消失。需要注意的是,指針obj的值在②處至③處之間,仍然指向剛纔被銷燬的對象的位置,這時使用這個指針是危險的。在 Win32平臺中,訪問剛纔被銷燬對象,可能出現3種情況。第1種情況是該處位置所在的"內存頁"沒有任何對象,堆管理器已經將其進一步返回給系統,此時通過指針obj訪問該處內存會引起"訪問違例",即訪問了不合法的內存,這種錯誤會導致進程崩潰;第2種情況是該處位置所在的"內存頁"還有其他對象,且該處位置被回收後,尚未被分配出去,這時通過指針obj訪問該處內存,取得的值是無意義的,雖然不會立刻引起進程崩潰,但是針對該指針的後續操作的行爲是不可預測的;第3種情況是該處位置所在的"內存頁"還有其他對象,且該處位置被回收後,已被其他對象申請,這時通過指針obj訪問該處內存,取得的值其實是程序其他處生成的對象。雖然對指針obj的操作不會立刻引起進程崩潰,但是極有可能會引起該對象狀態的改變。從而使得在創建該對象處看來,該對象的狀態會莫名其妙地變化。第2種和第3種情況都是很難發現和排查的bug,需要小心地避免。

創建一個對象分成兩個步驟,即首先取得對象所需的內存(無論是從線程棧還是從全局堆中),然後在該塊內存上執行構造函數。在構造函數構建該對象時,構造函數也分成兩個步驟。即第1步執行初始化(通過初始化列表),第2步執行構造函數的函數體,如下:

class  Derived  :  public Base
{
public :
        Derived() : i(10), string("unnamed") ①
        { 
            ...                               ②
        }
        ...
    
private :
        int  i;
        string  name;
        ...
};

①步中的 ": i(10), string("unnamed")" 即所謂的"初始化列表",以":"開始,後面爲初始化單元。每個單元都是"變量名(初始值)"這樣的模式,各單元之間以逗號隔開。構造函數首先根據初始化列表執行初始化,然後執行構造函數的函數體,即②處語句。對初始化操作,有下面幾點需要注意。

(1)構造函數其實是一個遞歸操作,在每層遞歸內部的操作遵循嚴格的次序。遞歸模式爲首先執行父類的構造函數(父類的構造函數操作也相應的包括執行初始化和執行構造函數體兩個部分),父類構造函數返回後構造該類自己的成員變量。構造該類自己的成員變量時,一是嚴格按照成員變量在類中的聲明順序進行,而與其在初始化列表中出現的順序完全無關;二是當有些成員變量或父類對象沒有在初始化列表中出現時,它們仍然在初始化操作這一步驟中被初始化。內建類型成員變量被賦給一個初值。父類對象和類成員變量對象被調用其默認構造函數初始化,然後父類的構造函數和子成員變量對象在構造函數執行過程中也遵循上述遞歸操作。一直到此類的繼承體系中所有父類和父類所含的成員變量都被構造完成後,此類的初始化操作才告結束。

(2)父類對象和一些成員變量沒有出現在初始化列表中時,這些對象仍然被執行構造函數,這時執行的是"默認構造函數"。因此這些對象所屬的類必須提供可以調用的默認構造函數,爲此要求這些類要麼自己"顯式"地提供默認構造函數,要麼不能阻止編譯器"隱式"地爲其生成一個默認構造函數,定義除默認構造函數之外的其他類型的構造函數就會阻止編譯器生成默認構造函數。如果編譯器在編譯時,發現沒有可供調用的默認構造函數,並且編譯器也無法生成,則編譯無法通過。

(3)對兩類成員變量,需要強調指出即"常量"(const)型和"引用"(reference)型。因爲已經指出,所有成員變量在執行函數體之前已經被構造,即已經擁有初始值。根據這個特點,很容易推斷出"常量"型和"引用"型變量必須在初始化列表中正確初始化,而不能將其初始化放在構造函數體內。因爲這兩類變量一旦被賦值,其整個生命週期都不能修改其初始值。所以必須在第一次即"初始化"操作中被正確賦值。

(4)可以看到,即使初始化列表可能沒有完全列出其子成員或父類對象成員,或者順序與其在類中聲明的順序不符,這些成員仍然保證會被"全部"且"嚴格地按照順序"被構建。這意味着在程序進入構造函數體之前,類的父類對象和所有子成員變量對象都已經被生成和構造。如果在構造函數體內爲其執行賦初值操作,顯然屬於浪費。如果在構造函數時已經知道如何爲類的子成員變量初始化,那麼應該將這些初始化信息通過構造函數的初始化列表賦予子成員變量,而不是在構造函數體中進行這些初始化。因爲進入構造函數體時,這些子成員變量已經初始化一次。

下面這個例子演示了構造函數的這些重要特性:

A::A()                ③
B::B()                ④
C1::C1()              ⑤
C2::C2()              ⑥
C3::C3()              ⑦
D::D()                ⑧

可以看到,①處調用D::D(double,int)構造函數構造對象d,此構造函數從②處開始引起了一連串的遞歸構造。從輸出可以驗證遞歸操作的如下規律。

(1)遞歸從父類對象開始,D的構造函數首先通過"初始化"操作構造其直接父類B的構造函數。然後B的構造函數先執行"初始化"部分,該"初始化"操作構造B的直接父類A,類A沒有自己的成員需要初始化,所以其"初始化"不執行任何操作。初始化後,開始執行類A的構造函數,即③的輸出。

(2)構造類A的對象後,B的"初始化"操作執行初始化類表中的j(0)對j進行初始化。然後進入B的構造函數的函數體,即④處輸出的來源。至此類B的對象構造完畢,注意這裏看到初始化列表中並沒有"顯式"地列出其父類的構造函數。但是子類在構造時總是在其構造函數的"初始化"操作的最開始構造其父類對象,而忽略其父類構造函數是否顯式地列在初始化列表中。

(3)構造類B的對象後,類D的"初始化"操作接着初始化其成員變量對象,這裏是c1,c2和c3。因爲它們在類D中的聲明順序就是c1 -> c2 -> c3,所以看到它們也是按照這個順序構造的,如⑤,⑥,⑦ 3處輸出所示。注意這裏故意在初始化列表中將c2的順序放在了c1的前面,c3甚至都沒有列在初始化列表中。但是輸出顯示了成員變量的初始化嚴格按照它們在類中的聲明順序進行,而忽略其是否顯式地列在初始化列表中,或者顯示在初始化列表中的順序如何。應該儘量將成員變量初始化列表中出現的順序與其在類中聲明的順序保持一致,因爲如果使用一個變量的值來初始化另外一個變量時,程序的行爲可能不是開發人員預想的那樣,比如:

class Object
{
public:
        Object() :  v2(5),  v1(v2 * 3) {    …  }
private:
        int  v1, v2;
}

這段程序的本意應該是首先將v2初始化爲5,然後用v2的值來初始化v1,從而v1=15。然而通過驗證,初始化後的v2確實爲5,但v1則是一個非常奇怪的值(在筆者的電腦上輸出是12737697)。這是因爲實際初始化時首先初始化v1,這時v2還尚未正確初始化,根據v2計算出來的v1也就不是一個合理的值了。當然除了將成員變量在初始化列表中的順序與其在類中聲明的順序保持一致之外,最好還是避免在初始化列表中用某個成員變量的值初始化另外一個成員變量的值。

(4)隨着c1、c2和c3這3個成員變量對象構造完畢,類D的構造函數的"初始化"操作部分結束,程序開始進入其構造函數的第2部分。即執行構造函數的函數體,這就是⑧處輸出的來源。

析構函數的調用與構造函數的調用一樣,也是類似的遞歸操作。但有兩點不同,一是析構函數沒有與構造函數相對應的初始化操作部分,這樣析構函數的主要工作就是執行析構函數的函數體;二是析構函數執行的遞歸與構造函數剛好相反,而且在每一層的遞歸中,成員變量對象的析構順序也與構造時剛好相反。

正是因爲在執行析構函數時,沒有與構造函數的初始化列表相對應的列表,所以析構函數只能選擇成員變量在類中聲明的順序作爲析構的順序參考。因爲構造函數選擇了自然的正序,而析構函數的工作又剛好與其相反,所以析構函數選擇逆序。因爲析構函數只能用成員變量在類中的聲明順序作爲析構順序(要麼正序,要麼逆序),這樣使得構造函數也只能選擇將這個順序作爲構造的順序依據,而不能採用初始化列表中的作爲順序依據。

與構造函數類似,如果操作的對象屬於一個複雜繼承體系中的末端節點,那麼其析構函數也是十分耗時的操作。

因爲構造函數/析構函數的這些特性,所以在考慮或者調整程序的性能時,也必須考慮構造函數/析構函數的成本,在那些會大量構造擁有複雜繼承體系對象的大型程序中尤其如此。下面兩點是構造函數/析構函數相關的性能考慮。

(5)在C++程序中,創建/銷燬對象是影響性能的一個非常突出的操作。首先,如果是從全局堆中生成對象,則需要首先進行動態內存分配操作。衆所周知,動態內存分配/回收在C/C++程序中一直都是非常費時的。因爲牽涉到尋找匹配大小的內存塊,找到後可能還需要截斷處理,然後還需要修改維護全局堆內存使用情況信息的鏈表等。因爲意識到頻繁的內存操作會嚴重影響性能的下降,所以已經發展出很多技術用來緩解和降低這種影響,比如後續章節中將說明的內存池技術。其中一個主要目標就是爲了減少從動態堆中申請內存的次數,從而提高程序的總體性能。當取得內存後,如果需要生成的目標對象屬於一個複雜繼承體系中末端的類,那麼該構造函數的調用就會引起一長串的遞歸構造操作。在大型複雜系統中,大量此類對象的創建很快就會成爲消耗CPU操作的主要部分。因爲注意和意識到對象的創建/銷燬會降低程序的性能,所以開發人員往往對那些會創建對象的代碼非常敏感。在儘量減少自己所寫代碼生成的對象同時,開發人員也開始留意編譯器在編譯時"悄悄"生成的一些臨時對象。開發人員有責任儘量避免編譯器爲其程序生成臨時對象,下面會有一節專門討論這個問題。語義保持完全一致。

(6)已經看到,如果在實現構造函數時,沒有注意到執行構造函數體前的初始化操作已經將所有父類對象和成員變量對象構造完畢。而在構造函數體中進行第2次的賦值操作,那麼也會浪費很多的寶貴CPU時間用來重複計算。這雖然是小疏忽,但在大型複雜系統中積少成多,也會造成程序性能的顯著下降。

減少對象創建/銷燬的一個很簡單且常見的方法就是在函數聲明中將所有的值傳遞改爲常量引用傳遞,比如下面的函數聲明:

int foo( Object  a);

應該相應改爲:

int foo( const Object&  a );

因爲C/C++語言的函數調用都是"值傳遞",因此當通過下面方式調用foo函數時:

Object a;                  ①
...
int i = foo(a);            ②

②處函數foo內部引用的變量a雖然名字與①中創建的a相同,但並不是相同的對象,兩個對象"相同"的含義指其生命週期的每個時間點所指的是內存中相同的一塊區域。這裏①處的a和②處的a並不是相同的對象,當程序執行到②句時,編譯器會生成一個局部對象。這個局部對象利用①處的a拷貝構造,然後執行 foo函數。在函數體內部,通過名字a引用的都是通過①處a拷貝構造的複製品。函數體內所有對a的修改,實質上也只是對此複製品的修改,而不會影響到①處的原變量。當foo函數體執行完畢退出函數時,此複製品會被銷燬,這也意味着對此複製品的修改在函數結束後都被丟失。

通過下面這段程序來驗證值傳遞的行爲特徵:

#include <iostream>
using namespace std;

class Object
{
public:
        Object(int i = 1)        { n = i;    cout << "Object::Object()" << endl; }
        Object(const Object& a)    
        { 
            n = a.n;    
            cout << "Object::Object(const Object&)" << endl; 
        }
        ~Object()                { cout << "Object::~Object()" << endl; }

        void inc()                { ++n; }
        int val() const            { return n; }

private:
        int n;
};

void foo(Object a)
{
        cout << "enter foo, before inc(): inner a = " << a.val() << endl;
        a.inc();
        cout << "enter foo, after inc(): inner a = " << a.val() << endl;
}

int main()
{
        Object a;       ①
    
        cout << "before call foo : outer a = " << a.val() << endl;
        foo(a);         ②
        cout << "after call foo : outer a = " << a.val() << endl;      ③
    
        return 0;

}

輸出爲:

Object::Object()                              ④
before call foo : outer a = 1                            
Object::Object(const Object&)             ⑤
enter foo, before inc(): inner a = 1          ⑥
enter foo, after inc(): inner a = 2              ⑦
Object::~Object()                             ⑧
after call foo : outer a = 1                  ⑨
Object::~Object()

可以看到,④處的輸出爲①處對象a的構造,而⑤處的輸出則是②處foo(a)。調用開始時通過構造函數生成對象a的複製品,緊跟着在函數體內檢查複製品的值。輸出與外部原對象的值相同(因爲是通過拷貝構造函數),然後複製品調用inc()函數將值加1。再次打印出⑦處的輸出,複製品的值已經變成了 2。foo函數執行後需要銷燬複製品a,即⑧處的輸出。foo函數執行後程序又回到main函數中繼續執行,重新打印原對象a的值,發現其值保持不變(⑨ 處的輸出)。

重新審視foo函數的設計,既然它在函數體內修改了a。其原意應該是想修改main函數的對象 a,而非複製品。因爲對複製品的修改在函數執行後被"丟失",那麼這時不應該傳入Object a,而是傳入Object& a。這樣函數體內對a的修改,就是對原對象的修改。foo函數執行後其修改仍然保持而不會丟失,這應該是設計者的初衷。

如果相反,在foo函數體內並沒有修改a。即只對a執行"讀"操作,這時傳入const Object& a是完全勝任的。而且還不會生成複製品對象,也就不會調用構造函數/析構函數。

綜上所述,當函數需要修改傳入參數時,如果函數聲明中傳入參數爲對象,那麼這種設計達不到預期目的。即是錯誤的,這時應該用應用傳入參數。當函數不會修改傳入參數時,如果函數聲明中傳入參數爲對象,則這種設計能夠達到程序的目的。但是因爲會生成不必要的複製品對象,從而引入了不必要的構造/析構操作。這種設計是不合理和低效的,應該用常量引用傳入參數。

下面這個簡單的小程序用來驗證在構造函數中重複賦值對性能的影響,爲了放大絕對值的差距,將循環次數設置爲100 000:

#include <iostream>
#include <windows.h>
using namespace std;

class Val
{
public:
        Val(double v = 1.0)    
        {
            for(int i = 0; i < 1000; i++)
                d[i] = v + i;
        }

        void Init(double v = 1.0)
        {
            for(int i = 0; i < 1000; i++)
                d[i] = v + i;
        }

private:
        double d[1000];
};

class Object
{
public:
        Object(double d) :  v(d) {}       ①
        /*Object(double d)                ②
        {
             v.Init(d);
        }*/

private:
        Val v;
};

int main()
{
        unsigned long  i,     nCount;

        nCount = GetTickCount();

        for(i = 0;  i < 100000;  i++)
        {
            Object obj(5.0);
        }

        nCount = GetTickCount() - nCount;
        cout << "time used : " << nCount << "ms" << endl;

        return 0;
}

類Object中包含一個成員變量,即類Val的對象。類Val中含一個double數組,數組長度爲1 000。Object在調用構造函數時就知道應爲v賦的值,但有兩種方式,一種方式是如①處那樣通過初始化列表對v成員進行初始化;另一種方式是如②處那樣在構造函數體內爲v賦值。兩種方式的性能差別到底有多大呢?測試機器(VC6 release版本,Windows XP sp2,CPU爲Intel 1.6 GHz內存爲1GB)中測試結果是前者(①)耗時406毫秒,而後者(②)卻耗時735毫秒,如圖2-1所示。即如果改爲前者,可以將性能提高 44.76%。

圖2-1 兩種方式的性能對比

從圖中可以直觀地感受到將變量在初始化列表中正確初始化,而不是放置在構造函數的函數體內。從而對性能的影響相當大,因此在寫構造函數時應該引起足夠的警覺和關注。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章