天天看點

《C++面向對象高效程式設計(第2版)》——4.1 什麼是初始化

本節書摘來自異步社群出版社《c++面向對象高效程式設計(第2版)》一書中的第4章,第4.1節,作者: 【美】kayshav dattatri,更多章節内容可以通路雲栖社群“異步社群”公衆号檢視。

c++面向對象高效程式設計(第2版)

在函數内部建立一個基本資料類型,或在類中建立此類型的資料成員時(如c++中的char),該基本類型中包含的值是什麼?

其中包含的是預定義的内容,還是未定義的無用單元?

這些都是在學習新語言或新範式(如oop)時,應該真正關心的問題。舉例說明,tperson類如下所示,該類用于機動車輛注冊系統、員工資料庫等。

main()

{

  int i;   // 局部變量

  int j = 10;

  i = 20;

  tperson alien; // tperson類的對象

  alien.print();

}<code>`</code>

這就引出了許多問題:

(1)定義局部變量i時,i中的值是什麼?

(2)tperson類的對象alien中的資料成員_ssn、_name和_birthdate中的值是什麼?

根據c++(和c)中的定義,i中的值是未定義的。該值就是在建立i的記憶體區域中所包含的值(在運作時棧上),沒人知道是什麼。換言之,變量i未初始化。另一方面,我們用10建立了j,變量j中的值即為10。在該程式中,j中包含的值是已定義的,變量j即代表初始值10。

初始化是在建立變量(或常量)時,向變量儲存已知值的過程。這意味着該變量在被建立(無論以何種方式)時即獲得一個值。進一步而言,在初始化期間,為變量儲存值(即初始值,如示例中的10)時,我們并未覆寫該變量中的任何值。換言之,初始化并不用于擦除變量中的任何現有值。初始化隻是在變量被建立的同時,為其儲存一個已知值。

以上代碼保證了在使用j時,j中就已儲存了值10。當然,變量i也可用,但在為其指派20之前,包含在i中的值是未知的。而且,當我們将20指派給i的同時,正在擦除其中包含的任何值(即使是未知的)。這就是指派與初始化的不同。

指派一定會擦除變量中的現有值,變量中的原始值在該步驟中丢失。初始化是在建立變量的同時便為其儲存一個值。由于被初始化的變量,在初始化步驟開始前并不存在,是以該步驟并未丢失任何值。任何變量隻可初始化一次,但可以指派任意多次。這是初始化和指派的根本差別。 如果在給i指派前使用i(如數組下标),将無法預知結果。而使用j則很安全,因為我們知道它确切的值。使用已初始化的變量更加安全,因為它的行為可預知。在後面的章節中,我們将把該原則擴充到對象上。

之前主要讨論的是main()中的局部變量。不過,我們更感興趣的是對象。對象alien的資料成員中所包含的值是什麼?我們并不知道,因為并未用合适的值初始化它們。

c++:

除非類的構造函數顯式初始化對象的資料成員中的值,否則該值是未定義的。這與上面的變量i類似。i和_ssn資料成員(或其他資料成員)之間的唯一差別是:前者是main()内部的一個局部變量,而後者是類内部的資料成員。預設情況下,c++編譯器不會初始化對象的任何資料成員。這項工作由實作者負責讓構造函數完成。即使是類的預設構造函數,也不會為對象的資料成員儲存任何預定義的值。

為什麼初始化資料成員如此重要?

假設我們實作了tperson類的print()成員函數的代碼,如下所示:

tperson::print() const

    cout &lt;&lt; “name is” &lt;&lt; _name &lt;&lt; “ ssn: ” &lt;&lt; _ssn &lt;&lt; endl;

    // ... 更多

}

在用對象alien調用print()時,你能否猜到print()會輸出什麼?絕對不能。_name和_ssn字元指針都并未初始化,我們不知道它們包含的記憶體位址是什麼。這樣通過cout調用插入操作符(insertion operator)(&lt;&lt;)可能會導緻各種無法預知的輸出。如果我們能肯定已正确初始化指針_name,那麼,調用插入操作符輸出的内容才可預知。不僅如此,除非正确地初始化對象的所有資料成員,否則對象的行為都無法準确地預知,而且使用這樣的對象也不安全。類的設計者和實作者必須保證類對象的行為正确,無論以何種方式建立對象,該對象的行為都應該是已知的。如果無法履行這樣的承諾,就違反了實作者與客戶之間的契約。記住,一旦建立了類的對象,客戶便可通過該對象調用它所屬類的任何成員函數。實作者不能強制執行規則來限制通路成員函數,而且實作者也不能假定客戶通過對象調用成員函數的順序。是以,黃金法則如下:

hand 一定要記住,用合适的值初始化對象的所有資料成員。

所謂合适的值,指的是類的每個成員函數都能清楚解析的值。類的任何成員函數都必須能了解資料成員中的值,并且根據該值作出判斷。

這看起來容易,但實際上并非如此。初始化資料成員不是簡單的問題。在構造函數内部執行初始化時,可能會調用其他成員函數,如果這些成員函數使用尚未初始化的資料成員,後果将不堪設想。構造函數必須確定,在它内部被調用的任何成員函數都能運作正常。這意味着構造函數在調用其他成員函數之前,必須在所有資料成員中都儲存了适當的值。在某些情況下,為了完成對象的初始化,該構造函數必須依賴于其他成員函數的結果。但是,如果這些成員函數試圖使用尚未初始化的資料成員,程式會無法控制。實作者必須特别注意,一旦出現這種棘手的情形,可能不得不重新組織代碼。解決方案之一是:使用非成員函數(靜态或全局函數),避免通路資料成員。

警告:

在某些情況下,當為對象調用構造函數時,構造函數可以判斷新對象不會使用哪些資料成員(基于傳遞給構造函數的參數)。鑒于此,實作者可能會選擇不初始化某些資料成員(因為它們不會在對象中使用)。這樣做可以接受,但實作者作出這樣的假設時必須十分謹慎。如果将來需要修改類的實作,還需額外注意對未初始化資料成員的假設。無論資料成員在對象中使用與否,初始化對象的所有資料成員也許會更加容易些。否則,應使用類的相關知識,并提供詳細的文檔和關于假設的斷言。

smalltalk:

就初始化而言,smalltalk和c++迥然不同。在smalltalk中,一旦建立了對象,就保證已正确地初始化所有的執行個體成員。如果通過預設建立方法建立對象的所有執行個體成員,這些執行個體成員均包含一個預定義值nil。注意,nil是已知值。這是一種特殊的情況,意味着執行個體成員未初始化。在c++中,沒有這樣的初始化,實作者必須要顯式地進行初始化。在smalltalk中,類對象的建立将由它的元類(metaclass)控制。

eiffel:

在eiffel中,用make操作建立對象,這與c++中的構造函數非常類似。該方法用已知值初始化所有的基本類型(如整數和字元),所有的整數資料成員都設定為0;布爾類型設定為false;所有類引用設定為void 1。既然eiffel不支援基本類型和引用之外的其他類型,那麼這種初始化方案就要考慮到對象的所有類型。如果預設的make不合适,為了處理初始化,實作者可以為類特别定義make操作(帶通路保護)。還需注意,僅有對象的聲明并不能建立一個新對象,必須通過對象名調用make方法,才能在運作時建立該對象。用!!字首表明建立新對象(!!objectname.make)。如果調用make的語句前未加!!字首,make将重置現有對象中的值。

了解以上知識後,現在我們來完成tperson類的構造函數。應該用此構造函數替換116頁的内聯實作(①)。

class tperson {

    public:

      tperson(unsigned long thebirthdate);

      tperson(const char name[], const char theaddress[],

            unsigned long thessn, unsigned long thebirthdate);

      tperson&amp; operator=(const tperson&amp; source);

      tperson(const tperson&amp; source);

      ~tperson();

      void setname(const char newname[]);

      void print() const;

      // 為簡化起見,省略一些細節

    private:

      char*     _name;   // 作為字元數組

      unsigned long   _ssn;

      const unsigned long  _birthdate;

      char*     _address;  // 作為字元數組

};<code>`</code>

_birthdate成為一個const,我們再也無法給_birthdate字段指派,隻能初始化它。為滿足這樣的要求,c++提供了如下初始化文法(為構造函數),如下所示:

class tcar {

     private:

        unsigned _weight;  // 汽車的重量,英鎊。

        short   _drivetype; // 四輪驅動或兩輪驅動

        tperson _owner;  // 誰擁有這輛車?

        // 省略不重要的細節

     public:

       tcar(const char name[], const char address[],

         unsigned long ssn, unsigned long ownerbirthdate,

         unsigned weight = 900, short drivetype = 2);

tcar的構造函數可以寫成:

tperson::tperson (unsigned long thebirthdate) :

    _ssn(0) ,

    _name(0),

    _birthdate(thebirthdate),

    _address(0)

    { / 構造函數體中無代碼 / }<code>`</code>

以上代碼在初始化階段便完成了所有工作,指派階段無需做任何事情。當處理基本類型時,選擇這樣的初始化方式還是其他方式,隻是樣式的選擇或偏好問題。但是,在某些情況下,如後續章節所述,采用初始化樣式效率更高(帶内嵌對象時)。

樣式:

無論何時,如果可以在初始化文法和指派兩種樣式之間作選擇,應選擇初始化樣式。假設在最初的實作中,被初始化的某成員是基本資料類型,而在後來的實作中将該成員改為類對象(參見下一頁tdate類的用法),如果選擇初始化樣式,則無需修改代碼。如果我們為對象使用指派文法,要記住,在開始指派前,可能已經通過該對象調用了構造函數,而且我們可能已經使用構造函數初始化了該對象(是以,無需再進行指派操作)。

但是,在初始化階段不一定就能完成所有的工作。在很多類中,許多操作都必須在所有資料成員被完全初始化之前執行。這些操作涉及計算不同的值或調用不同的函數(成員函數和非成員函數)。在構造函數中,可能要按照預定義的順序執行一些步驟,這些步驟隻能在指派階段完成。當某資料成員依賴于另一個被初始化的值時,甚至更加複雜。是以,盡可能地使用初始化文法,但也不能過分依賴它。無論如何,最終的目标是構造出完整且正确的對象。

在面向過程程式設計中,依賴于某個特殊函數來初始化很常見。該函數通常稱為initialize(或init),用于在程式啟動後完成應用程式(或子產品)中的初始化工作。在面向過程程式設計中,以這樣的方式初始化很合适。但是,在面向對象程式設計(oop)中不要用這種方式初始化。完全初始化對象的正确(且唯一)方法是,在構造函數中進行。類的設計者不應該要求客戶調用initialize()方法來初始化對象,這很可能導緻錯誤,因為很容易忘記調用initialize()。是以,對這種方式的初始化應避而遠之。隻有當對象依賴于另一個對象進行初始化,且另一個對象尚未建立時才需要采取這種方式初始化。在包含虛基類的複雜繼承層次中會出現這種情況。

回到tperson類,用數字表示日期非常不友善。當然,你可以使用儒略日(julian date),但那更适用于機器,而我們傾向于用更簡單的格式來表示日期(如6/11/95)。如果用這樣的格式比較日期是否相等,會非常友善。為了讓日期對使用者更加友好,我們用另一個tdate類來表示日期,這是另一個簡單的抽象。這種情況下,我們對tdate類的接口更感興趣,實作的問題反而不太重要。使用諸如tdate這樣的類,使得接口更易于了解,而且簡化了實作。對于這樣的類需要考慮諸多設計因素,詳見第11章。

        tperson(const char birthdate[]);

        tperson(const char name[], const char theaddress[],

        unsigned long thessn, const char birthdate[]);

        tperson&amp;   operator=(const tperson&amp; source);

        tperson(const tperson&amp; source);

        ~tperson();

        void setname(const char thenewname[]);

        void print() const;

        // 為簡化起見,省略細節

        char*       _name;

        unsigned long  _ssn;

        const tdate    _birthdate;

        char*       _address;

新的tperson類對象概念上的布局,如圖4-2所示。從現在開始,該tperson類将用于所有的示例中。

《C++面向對象高效程式設計(第2版)》——4.1 什麼是初始化

圖4-2

(1)如果類的構造函數中包含其他類的對象,那麼必須在構造函數的初始化階段,為它所使用的所有内嵌對象調用合适的構造函數。

(2)如果實作者在調用内嵌對象的構造函數時失敗,編譯器将設法為内嵌對象調用預設構造函數(如果有可用且可通路的預設構造函數)。

(3)如果(1)和(2)都不成功,則構造函數的實作是錯誤的(導緻編譯時錯誤)。

(4)每個内嵌對象的析構函數,将由包含該對象的類的析構函數自動調用,無需程式員幹預。

1如前面的章節所述,eiffel中的引用與c++中的指針非常類似。在eiffel中,對象隻能包含對其他對象的引用。

2内嵌對象就是另一個對象的資料成員(如tcar的_owner)。

本文僅用于學習和交流目的,不代表異步社群觀點。非商業轉載請注明作譯者、出處,并保留本文的原始連結。

繼續閱讀