C++箴言:避免解構函式呼叫虛擬函式

gudesheng發表於2008-01-03

原文地址:http://blog.csdn.net/pdiy/archive/2005/12/14/551983.aspx

        如果你已經從另外一種語言如C#或者Java轉向了C++,你會覺得,避免在類的建構函式或者解構函式中呼叫虛擬函式這一原則有點違背直覺。但是在C++中,違反這個原則會給你帶來難以預料的後果和無盡的煩惱。

正文

  我想以重複本文的主題開篇:不要在類的構造或者解構函式中呼叫虛擬函式,因為這種呼叫不會如你所願,即使成功一點,最後還會使你沮喪不已。如果你以前是一個Java或者C#程式設計師,請密切注意本節的內容-這正是C++與其它語言的大區別之一。

  假設你有一個為股票交易建模的類層次結構,例如買單,賣單,等等。為該類交易建立審計系統是非常重要的,這樣的話,每當建立一個交易物件,在審計登入項上就生成一個適當的入口項。這看上去不失為一種解決該問題的合理方法:

  

  class Transaction {// 所有交易的基類

  public:

   Transaction();

   virtual void logTransaction() const = 0;//建立依賴於具體交易型別的登入項

   ...

  };

  Transaction::Transaction() //實現基類的建構函式

  {

   ...

   logTransaction(); //最後,登入該交易

  }

  class BuyTransaction: public Transaction {

  // 派生類

  public:

   virtual void logTransaction() const; //怎樣實現這種型別交易的登入?

   ...

  };

  class SellTransaction: public Transaction {

  //派生類

  public:

   virtual void logTransaction() const; //怎樣實現這種型別交易的登入?

   ...

  };

  現在,請分析執行下列程式碼呼叫時所發生的事情:

  BuyTransaction b;

  很明顯,一個BuyTransaction類構造器被呼叫。但是,首先呼叫的是Transaction類的構造器(派生類物件的基類部分是在派生類部分之前被構造的)。Transaction構造器的最後一行呼叫了虛擬函式logTransaction,但是奇怪的事情正是在此發生的。被呼叫函式logTransaction的版本是Transaction中的那個,而不是BuyTransaction中的那個-即使現在產生的物件的型別是BuyTransaction,情況也是如此。在基類的構造過程中,虛擬函式呼叫從不會被傳遞到派生類中。代之的是,派生類物件表現出來的行為好象其本身就是基型別。

        不規範地說,在基類的構造過程中,虛擬函式並沒有被"構造"。

  對上面這種看上去有點違背直覺的行為可以用一個理由來解釋:

        因為基類構造器是在派生類之前執行的,所以在基類構造器執行的時候派生類的資料成員還沒有被初始化。如果在基類的構造過程中對虛擬函式的呼叫傳遞到了派生類,派生類物件當然可以參照引用區域性的資料成員,但是這些資料成員其時尚未被初始化。這將會導致無休止的未定義行為和徹夜的程式碼除錯。沿類層次往下呼叫尚未初始化的物件的某些部分本來就是危險的,所以C++乾脆不讓你這樣做。
        事實上還有比這更具基本的要求。在派生類物件的基類物件構造過程中,該類的型別是基類型別。不僅虛擬函式依賴於基類,而且使用執行時刻資訊的語言的相應部分(例如,dynamic_cast(參見Item 27)和typeid)也把該物件當基類型別對待。在我們的示例中,當Transaction的構造器正執行以初始化BuyTransaction物件的基類部分時,該物件是Transaction型別。

        在C++程式設計中處處都這樣處理,這樣做很有意義:在基類物件的初始化中,派生類物件BuyTransaction相關部分並未被初始化,所以其時把這些部分當作根本不存在是最安全的。 在一個派生類物件的構造器開始執行之前,它不會成為一個派生類物件的。

  在物件的析構期間,存在與上面同樣的邏輯。一旦一個派生類的析構器執行起來,該物件的派生類資料成員就被假設為是未定義的值,這樣以來,C++就把它們當做是不存在一樣。一旦進入到基類的析構器中,該物件即變為一個基類物件,C++中各個部分(虛擬函式,dynamic_cast運算子等等)都這樣處理。

  在上面的示例程式碼中,Transaction構造器直接呼叫了一個虛擬函式,這明顯地破壞了本文所強調的原則。這種破壞性非常容易覺察,一些編譯器對此發出警告(注意:另外一些編譯器並不給出警告,請參考Item 53有關警告的討論)。即使沒有給出警告,該問題在程式碼執行時刻也是相當明顯的,因為函式logTransaction是類Transaction中的純虛擬函式。除非該函式被定義了(可能性不太大,但確實存在這種情況-參見Item 34),否則程式不會進行連結:連結器沒法找到Transaction::logTransaction的必需的實現程式碼。

  在類的構造或者解構函式中進行虛擬函式呼叫並非總是那麼容易被發現。如果Transaction類有多個構造器且其中每個必須執行一些相同的任務,也許只有優秀的軟體工程師才能夠避免程式碼的重複,這可以通過把相同的初始化程式碼(包括呼叫logTransaction)放到一個私有的且非虛的初始化函式中實現,譬如下面的init:

  

  class Transaction {

   public:

    Transaction()

    { init(); } //呼叫非虛擬函式...

    virtual void logTransaction() const = 0;

    ...

   private:

    void init()

    {

     ...

     logTransaction(); //注意這裡呼叫了虛擬函式

    }

  };

  這段程式碼從概念上看與前面的版本一樣,但是卻更具有潛在的危險性,因為典型情況下,該程式碼會被成功地編譯與連結。在這種情況下,因為logTransaction是Transaction類中的純虛擬函式,絕大多數的執行時刻系統會在該純虛擬函式被呼叫時(典型地是通過傳送一個帶有呼叫該函式意義的訊息實現)流產掉程式。然而,如果logTransaction是一個"正常的"虛擬函式"(也就是,不是純虛的),並在Transaction中有它的實現部分,該程式碼段將被呼叫而且程式會順利地執行一段時間,這讓你考慮為什麼在一個派生類物件被建立時呼叫了logTransaction的錯誤版本。唯一避免該問題的辦法是確保沒有任何一個構造器或者析構器在正被產生或毀壞的物件上呼叫了虛擬函式,而且所有其呼叫的函式都要遵循同樣的約束。

  但是,每當有一個物件在Transaction類層次結構中產生時,如何保證呼叫的是logTransaction的正確版本呢?很明顯,從Transaction的構造器中呼叫物件上的虛擬函式是錯誤的做法。

  有幾種不同的辦法可以解決這個問題。一種辦法就是在Transaction中把函式logTransaction改變為一個非虛擬函式,然後要求派生子類的構造器要把必要的登入資訊傳遞給Transaction的構造器。如此以來,上面的函式就能夠安全地呼叫非虛擬函式logTransaction了。如下所示:

  

  class Transaction {

   public:

    explicit Transaction(const std::string& logInfo);

    void logTransaction(const std::string& logInfo) const;//現在是一個非虛擬函式

    ...

  };

  

  Transaction::Transaction(const std::string& logInfo)

  {

   ...

   logTransaction(logInfo);// 現在呼叫的是一個非虛擬函式

  }

  

  class BuyTransaction: public Transaction {

   public:

    BuyTransaction( parameters )

    :Transaction(createLogString(parameters)) { ... } //把登入資訊傳送給基類的建構函式

    ...

   private:

    static std::string createLogString( parameters );

  };

  換句話說,既然在基類的建構函式中不能沿著類的繼承層次往下呼叫虛擬函式,你可以通過在派生類中沿著類的層次結構把必要的構造資訊傳遞到基類的構造器中來補償這一點。

  在這個例子中,請注意BuyTransaction中私有靜態函式createLogString的使用方法。通過使用幫助函式來建立一個值並把它傳遞到基類構造器中,這種方式比起在成員初始化列表中實現基類所需的操作要更方便和更具有可讀性。這裡我們把該函式建立為static型,這對於偶爾參照引用一下剛產生的BuyTransaction物件的尚未初始化的資料成員是沒有危險的。這一點很重要,因為那些資料成員還處於一種未定義的狀態中,這一事實解釋了為什麼在基類的構造或者解構函式中對於虛擬函式的呼叫不能首先傳遞到派生子類中去。

結論

  不要在類的構造或者析構過程中呼叫虛擬函式,因為這樣的呼叫永遠不會沿類繼承樹往下傳遞到子類中去。 



Trackback: http://tb.blog.csdn.net/TrackBack.aspx?PostId=1478837


相關文章