1.關于構造函數的一個違反直覺的行為
我會以重複标題開始:你不應該在構造或者析構的過程中調用虛函數,因為這些調用的結果會和你想的不一樣。如果你同時是一個java或者c#程式員,那麼請着重注意這個條款,因為這是c++同它們不一樣的地方。
假設你已經有一個為股票交易模組化的類繼承體系,它可以買賣股票等。這些交易的可審計性很重要,是以每次交易對象被建立的時候,需要在審計日志中建立一個合适的記錄。這看上去是解決問題的合理方法:
1 class Transaction { // base class for all
2
3 public: // transactions
4
5 Transaction();
6
7 virtual void logTransaction() const = 0; // make type-dependent
8
9 // log entry
10
11 ...
12
13 };
14
15 Transaction::Transaction() // implementation of
16
17 { // base class ctor
18
19 ...
20
21 logTransaction(); // as final action, log this
22
23 } // transaction
24
25 class BuyTransaction: public Transaction { // derived class
26
27 public:
28
29 virtual void logTransaction() const; // how to log trans-
30
31 // actions of this type
32
33 ...
34
35 };
36
37 class SellTransaction: public Transaction { // derived class
38
39 public:
40
41 virtual void logTransaction() const; // how to log trans-
42
43 // actions of this type
44
45 ...
46
47 };
考慮執行下面的代碼會發生什麼:
1 BuyTransaction b;
BuyTransaction的構造函數會被調用,但是在這之前,Transaction的構造函數必須被調用:派生類的基類部分的建構要早于派生類部分。Transaction構造函數的最後一行調用虛函數logTransaction,這個地方會讓你感到驚訝。被調用的logTransaction版本是Transaction中的版本而不是BuyTransaction中的版本,即使對象被建立的類型是BuyTransaction.在基類的構造函數中,虛函數永遠不會下降到派生類中。相反,對象的行為看上去會像一個基類類型。非正式的說法就是,在基類建構期間,虛函數不再是虛函數。
2.這種行為為什麼會出現(一)
對于這個違反直覺的行為有一個很好的原因。因為基類構造函數先于派生類構造函數執行,當基類構造函數執行的時候派生類資料成員還沒來得及被初始化。如果在基類構造期間虛函數的調用會下降到派生類,派生類函數基本上肯定會引用本地資料成員,但是這些資料成員還沒有被初始化呢。這會直達未定義行為和調試到深夜的後果(late-night debugging sessions)。向下調用一個對象的未初始化部分本身就是很危險的,是以c++不讓你這麼做。
3.這種行為為什麼會出現(二)
還有更根本的原因。在派生類對象建構基類部分期間,對象的類型屬于基類。不但虛函數會被處理成基類類型,使用運作時類型資訊的語言部分(dynamic_cast Item 27和typeid)也會把對象當作基類類型.在我們的例子中,當Transaction構造函數在初始化BuyTransaction對象的基類部分時,對象的類型是Transaction.這就是c++的每個部分是如何處理它的,并且這種處理方法也是合理的:當對象的BuyTransaction部分還沒有被初始化,最安全的做法就是當它們不存在。一個對象直到派生類構造函數被執行其類型才會變成派生類對象。
4.上面的行為析構函數也會出現
理由同樣适用于析構函數。一旦一個派生類的析構函數運作完成,就假設對象的派生類資料成員未定義,于是c++當做它們不存在。一進入基類析構函數,對象就會變成一個基類對象,c++的所有部分——虛函數,dynamic_casts等等——都會按基類的方式來處理。
5.如何防止這個行為出現?
在上面的示例代碼中,Transaction構造函數直接調用虛函數,很容易看到它違反了這個條款。這個違反是如此容易被發現,一些編譯器會發出警告。(其他的則不會,關于warning的讨論見Item53).即使在沒有警告的情況下,這個問題在運作時之前很容易顯現出來,因為logTransaction函數是Transaction中的純虛函數。除非它被定義(不太有希望,但是可能,見Item34),否則程式連結會出現問題:連結器将找不到Transaction::logTransaction的定義。
在構造和析構期間對虛函數的調用不總是這麼容易能夠被發現。如果Transaction有多個構造函數,每個構造函數必須執行相同的工作,防止代碼重複的一個好的軟體工程是将普通的初始化代碼,包含對logTransaction的調用,放到一個私有的非虛初始化函數中,也即是 Init:
1 class Transaction {
2
3 public:
4
5 Transaction()
6
7 { init(); } // call to non-virtual...
8
9 virtual void logTransaction() const = 0;
10
11 ...
12
13 private:
14
15 void init()
16
17 {
18
19 ...
20
21 logTransaction(); // ...that calls a virtual!
22
23 }
24
25 };
這部分代碼和早一點的那個版本從概念上來說是相同的,但是它更加陰險,因為它能夠被成功的編譯和連結。在這種情況下,因為logTransaction是Transaction的純虛函數,大多數運作的系統會在調用純虛函數的時候終止程式(通常會發出一個消息)。然而,如果logTransaction是一個“普通的”虛函數(也就是不是純虛函數),并且在Transaction中有一個實作,如果這個版本的logTransaction被調用,程式會愉快的執行下去,讓你自己去了解為什麼建立派生類對象的時候會調用錯誤的logTransaction版本。防止這個問題的唯一方法是在建立和銷毀對象的時候你的構造函數和虛構函數不會去調用虛函數并且它們調用的函數也需要遵守這個約定。
6.如何保證調用到繼承體系中正确的函數版本
但是你怎麼才能夠確定每次Transaction繼承體系中的對象被建立的時候,能夠調用合适的logTransaction版本?這裡很清楚,從Transaction中的構造函數中調用這個對象的虛函數是錯誤的做法。
有不同的方法來處理這個問題。一個方法是将logTransaction變成一個非虛函數,這就需要派生類的構造函數将必要的log資訊傳遞給Transaction構造函數。這時候Transaction構造函數就能夠安全的調用非虛的logTransaction,像下面這樣:
1 class Transaction {
2
3 public:
4
5 explicit Transaction(const std::string& logInfo);
6
7 void logTransaction(const std::string& logInfo) const; // now a non-
8
9 // virtual func
10
11 ...
12
13 };
14
15 Transaction::Transaction(const std::string& logInfo)
16
17 {
18
19 ...
20
21 logTransaction(logInfo); // now a non-
22
23 } // virtual call
24
25 class BuyTransaction: public Transaction {
26
27 public:
28
29 BuyTransaction( parameters )
30
31 : Transaction(createLogString( parameters )) // pass log info
32
33 { ... } // to base class
34
35 ... // constructor
36
37 private:
38
39 static std::string createLogString( parameters );
40
41 };
換句話說,既然你不能夠在構造對象期間在基類中使用虛函數向下調用,你可以使用由派生類向上傳遞必要的構造資訊到基類構造函數的方法來進行彌補。
在這個例子中,注意BuyTransaction類中(private)靜态函數createLogString的使用。使用一個helper函數來建立傳遞到基類構造函數的值比在成員初始化清單中提供基類需要的值更加友善(更加易讀)。通過将此函數聲明成static,就不會有引用BuyTransaction對象未初始化資料成員的危險(static函數隻能夠操作static資料成員)。這是很重要的,因為資料成員處于未定義狀态的事實,就是在基類構造或析構期間調用虛函數不能向下調用的原因。
作者:
HarlanC部落格位址:
http://www.cnblogs.com/harlanc/個人部落格:
http://www.harlancn.me/本文版權歸作者和部落格園共有,歡迎轉載,但未經作者同意必須保留此段聲明,且在文章頁面明顯位置給出,
原文連結如果覺的部落客寫的可以,收到您的贊會是很大的動力,如果您覺的不好,您可以投反對票,但麻煩您留言寫下問題在哪裡,這樣才能共同進步。謝謝!