天天看點

讀書筆記 effective c++ Item 35 考慮虛函數的替代者 

1. 突破思維——不要将思維限定在面向對象方法上

你正在制作一個視訊遊戲,你正在為遊戲中的人物設計一個類繼承體系。你的遊戲處在農耕時代,人類很容易受傷或者說健康度降低。是以你決定為其提供一個成員函數,healthValue,傳回一個整型值來表明一個人物的健康度。因為不同的人物會用不同的方式來計算健康度,将healthValue聲明為虛函數看上去是一個比較明顯的設計方式:

1 class GameCharacter {
2 public:
3 
4 virtual int healthValue() const; // return character’s health rating;
5 
6 ...                                               // derived classes may redefine this
7 
8 };           

healthValue沒有被聲明為純虛函數的事實表明了會有一個預設的算法來計算健康度(

Item 34

)。

這的确是設計這個類的一個明顯的方式,在某種意義上來說,這也是它的弱點。因為這個設計是如此明顯,你可能不會對其他的設計方法有足夠的考慮。為了讓你逃離面向對象設計之路的車轍,讓我們考慮一些處理這個問題的其它方法。

2. 替換虛函數的四種設計方法

2.1 通過使用非虛接口(non-virtual interface(NVI))的模闆方法模式

一個很有意思的學派認為虛函數幾乎應該總是private的。這個學派的信徒建議一個更好的設計方法是仍然将healthValue聲明成public成員函數但是使其變為非虛函數,然後讓它調用一個做實際工作的private虛函數,也就是,doHealthValue:

1 class GameCharacter {
 2 public:
 3 int healthValue() const // derived classes do not redefine
 4 { // this — see Item 36
 5 
 6 ...                                       // do “before” stuff — see below
 7 
 8 int retVal = doHealthValue(); // do the real work
 9 
10 ...                                       // do “after” stuff — see below
11 
12 return retVal;                  
13 
14 
15 }
16 ...
17 private:
18 virtual int doHealthValue() const // derived classes may redefine this
19 {
20 ... // default algorithm for calculating
21 } // character’s health
22 };      

在上面的代碼中(這個條款中剩餘的代碼也如此),我在類定義中展示了成員函數體。正如

Item30

中所解釋的,将其隐式的聲明為inline。我使用這種方式的目的隻是使你更加容易的看到接下來會發生什麼。我所描述的設計和inline之間是獨立的,是以不要認為在類内部定義成員函數是有特定意義的,不是如此。、

客戶通過public非虛成員函數調用private虛函數的基本設計方法被稱作非虛接口(non-virtual interface(NVI))用法。它是更一般的設計模式——模闆方法模式(這個設計模式和C++模闆沒有任何關系)的一個特定表現。我把非虛函數(healthValue)叫做虛函數的一個包裝。

NVI用法的一個優點可以從代碼注釋中看出來,也就是“do before stuff”和“do after stuff”。這些注釋指出了在做真正工作的虛函數之前或之後保證要被執行的代碼。這意味着這個包裝函數在一個虛函數被調用之前,確定了合适的上下文的建立,在這個函數調用結束後,確定了上下文被清除。舉個例子,“before”工作可以包括lock a mutex,記錄log,驗證類變量或者檢查函數先驗條件是否滿足要求,等等。”after”工作可能包含unlocking a mutex,驗證函數的後驗條件是否滿足要求,重新驗證類變量等等。如果你讓客戶直接調用虛函數,那麼沒有什麼好的方法來做到這些。

你可能意識到NVI用法涉及到在派生類中重新定義private虛函數——重新定義它們不能調用的函數!這在設計上并不沖突。重新定義一個虛函數指定如何做某事,而調用一個虛函數指定何時做某事。這些概念是互相獨立的。NVI用法允許派生類重新定義一個虛函數,這使他們可以對如何實作一個功能進行控制,但是基類保有何時調用這個函數的權利。初次看起來很奇怪,但是C++中的派生類可以重新定義繼承而來的private虛函數的規則是非常明智的。

對于NVI用法,虛函數并沒有嚴格限定必須為private的。在一些類的繼承體系中,一個虛函數的派生類實作需要能夠觸發基類中對應的部分,如果使得這種調用是合法的,虛函數就必須為protected,而不是private的。有時一個虛函數甚至必須是public的(例如,多态基類中的析構函數——

Item7

),但是這種情況下,NVI用法就不能夠被使用了。

2.2 通過函數指針實作的政策模式

NVI用法是public虛函數的一個很有意思的替換者,但是從設計的角度來說,有一點弄虛作假的嫌疑。畢竟,我們仍然使用了虛函數計算每個人物的健康度。一個更加引人注目的設計方法是将計算一個人物的健康度同這個人物的類型獨立開來——這種計算不必作為這個人物的一部分。舉個例子,我們可以使用每個人物的構造函數來為健康計算函數傳遞一個函數指針,然後在函數指針所指的函數中進行實際的運算:

1 class GameCharacter;                                                                            // forward declaration
 2 
 3 // function for the default health calculation algorithm                       
 4 
 5 int defaultHealthCalc(const GameCharacter& gc);                               
 6 
 7 class GameCharacter {                                                                         
 8 
 9 public:                                                                                                 
10 
11 typedef int (*HealthCalcFunc)(const GameCharacter&);                     
12 
13 explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc)       
14 
15 : healthFunc(hcf )                                                                                
16 
17 {}                                                                                                         
18 
19 int healthValue() const                                                                        
20 
21 { return healthFunc(*this); }                                                                
22 
23 ...                                                                                                          
24 
25 private:                                                                                                
26 
27 HealthCalcFunc healthFunc;                                                                
28 
29 };      

這個方法是另外一種普通設計模式的簡單應用,也就是政策模式。同在GameCharacter繼承體系中基于虛函數的方法進行對比,它能提供了一些有意思的靈活性: 

  • 相同人物類型的不同執行個體能夠擁有不同的健康度計算函數。舉個例子:
    • 1 class EvilBadGuy: public GameCharacter {
       2 
       3 public:
       4 
       5 explicit EvilBadGuy(HealthCalcFunc hcf = defaultHealthCalc)
       6 
       7 : GameCharacter(hcf )
       8 
       9 { ... }
      10 
      11 ...
      12 
      13 };
      14 
      15 
      16 int loseHealthQuickly(const GameCharacter&); // health calculation
      17 int loseHealthSlowly(const GameCharacter&); // funcs with different
      18 // behavior
      19 EvilBadGuy ebg1(loseHealthQuickly); // same-type charac
      20 EvilBadGuy ebg2(loseHealthSlowly); // ters with different
      21 // health-related
      22 // behavior      
  • 特定人物的健康度計算函數能夠在運作時發生變化。舉個例子,GameCharacter可能提供一個成員函數,setHealthCalculator,它可以對目前的健康度計算函數進行替換。

此外,健康度計算函數不再是GameCharacter繼承體系中的成員函數的事實意味着它不能對正在計算健康度的對象的内部資料進行特殊通路。例如,defaultHealthCalc對EvilBadGuy的非public部分沒有通路權。如果一個人物的健康度計算僅僅依賴于人物的public接口,這并沒有問題,但是如果精确的健康計算需要非public資訊,在任何時候當你用類外的非成員非友元函數或者另外一個類的非友元函數來替換類内部的某個功能時,這都會是一個潛在的問題。這個問題在此條款接下來的部分會一直存在,因為我們将要考慮的所有其他的設計方法都涉及到對GameCharacter繼承體系外部函數的使用。

作為通用的方法,非成員函數能夠對類的非public部分進行通路的唯一方法就是降低類的封裝性。例如,類可以将非成員函數聲明為友元函數,或者對隐藏起來的部分提供public通路函數。使用函數指針來替換虛函數的優點是否抵消了可能造成的GameCharacter的封裝性的降低是你在每個設計中要需要确定的。

2.3 通過tr1::function實作的政策模式

一旦你适應了模闆以及它們所使用的隐式(implicit)接口(Item 41),基于函數指針的方法看起來就非常死闆了。為什麼健康電腦必須是一個函數而不能用行為同函數類似的一些東西來代替(例如,一個函數對象)?如果它必須是一個函數,為什麼不能是一個成員函數?為什麼必須傳回一個int類型而不是能夠轉換成Int的任意類型呢?

如果我們使用tr1::funciton對象來替換函數指針的使用,這些限制就會消失。正如Item54所解釋的,這些對象可以持有任何可調用實體(也就是函數指針,函數對象,或者成員函數指針),隻要它們的簽名同客戶所需要的互相相容。這是我們剛剛看到的設計,這次我們使用tr1::function:

1 class GameCharacter; // as before
 2 int defaultHealthCalc(const GameCharacter& gc); // as before
 3 class GameCharacter {
 4 public:
 5 // HealthCalcFunc is any callable entity that can be called with
 6 // anything compatible with a GameCharacter and that returns anything
 7 // compatible with an int; see below for details
 8 typedef std::tr1::function<int (const GameCharacter&)> HealthCalcFunc;
 9 
10 explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc)
11 : healthFunc(hcf )
12 {}
13 int healthValue() const
14 { return healthFunc(*this); }
15 ...
16 private:
17 HealthCalcFunc healthFunc;
18 };      

正如你所看到的,HealthCalcFunc是對一個執行個體化tr1::function的typedef。這意味着它的行為像一個泛化函數指針類型。看看HealthCalcFunc對什麼進行了typedef:

1 std::tr1::function<int (const GameCharacter&)>      

這裡我對這個tr1::function執行個體的“目标簽名”(target signature)做了字型加亮。這個目标簽名是“函數帶了一個const GameCharacter&參數,并且傳回一個int類型”。這個tr1::function類型的對象可以持有任何同這個目标簽名相相容的可調用實體。相相容的意思意味着實體的參數要麼是const GameCharacter&,要麼可以轉換成這個類型,實體的傳回值要麼是int,要麼可以隐式轉換成int。

同上一個設計相比我們看到(GameCharacter持有一個函數指針),這個設計基本上是相同的。唯一的不同是GameCharacter現在持有一個tr1::function對象——一個指向函數的泛化指針。這個改動是小的,但是結果是客戶現在在指定健康計算函數上有了更大的靈活性:

1 short calcHealth(const GameCharacter&);     // health calculation
 2 // function; note
 3 // non-int return type
 4 
 5 struct HealthCalculator {                     // class for health
 6 
 7 
 8 int operator()(const GameCharacter&) const // calculation function
 9 { ... } // objects
10 };
11 class GameLevel {
12 public:
13 
14 float health(const GameCharacter&) const;    // health calculation
15 
16 ...                                                                 // mem function; note
17 
18 };                                                                 // non-int return type
19 
20  
21 
22 class EvilBadGuy: public GameCharacter { // as before
23 
24 ...                                                                
25 
26 };
27 
28 class EyeCandyCharacter: public GameCharacter { // another character
29 ... // type; assume same
30 
31 };                                                               // constructor as
32 // EvilBadGuy
33 
34 EvilBadGuy ebg1(calcHealth);                   // character using a
35 // health calculation
36 // function
37 
38 EyeCandyCharacter ecc1(HealthCalculator());          // character using a
39 // health calculation
40 // function object
41 
42 GameLevel currentLevel;
43 ...
44 EvilBadGuy ebg2(                                     // character using a
45 
46 
47 std::tr1::bind(&GameLevel::health, // health calculation
48 
49 currentLevel,               // member function;
50 
51 _1)                               // see below for details
52 
53 
54 );      

你會因為tr1::function的使用而感到吃驚。它一直讓我很興奮。如果你不感到興奮,可能是因為剛開始接觸ebg2的定義,并且想知道對tr1::bind的調用會發生什麼。看下面的解釋:

我想說為了計算ebg2的健康度,應該使用GameLevel類中的健康成員函數。現在,GameLevel::health是一個帶有一個參數的函數(指向GameCharacter的引用),但是它實際上有兩個參數,因為它同時還有一個隐含的GameLevel參數——由this所指向的。然而GameCharacters的健康計算函數卻隻有一個參數:也就是需要計算健康度的GameCharacter。如果我們對ebg2的健康計算使用GameLevel::health,我們必須做一些“适配”工作,以達到隻帶一個參數(GameCharacter)而不是兩個參數(GameCharacter和GameLevel)的目的。在這個例子中,我們想使用GameLevel對象currentLevel來為ebg2計算健康度,是以我們每次使用”bind”到currentLevel的GameLevel::health函數來計算ebg2的健康度。這也是調用tr1::bind所能做到的:它指定了ebg2的健康計算函數應該總是使用currentLevel作為GameLevel對象。

我跳過了tr1::bind調用的很多細節,因為這樣的細節不會有很多啟發意義,并且會分散我要強調的基本觀點:通過使用tr1::function而不是一個函數指針,當計算一個人物的健康度時我們可以允許客戶使用任何相容的可調用實體。這是不是很酷。

2.4 “典型的”政策模式

 如果你對設計模式比上面的C++之酷更有興趣,政策模式的一個更加友善的方法是将健康計算函數聲明為一個獨立健康計算繼承體系中的虛成員函數。最後的繼承體系設計會是下面的樣子:

讀書筆記 effective c++ Item 35 考慮虛函數的替代者 

如果你對UML符号不熟悉,上面的UML圖說明的意思是GameCharacter是繼承體系中的root類,EvilBadGuy和EyeCandyCharacter是派生類;HealthCalcFunc是root類,SlowHealthLoser和FastHealthLoser是派生類;每個GameCharacter類型都包含了一個指向HealthCalcFunc繼承體系對象的指針。

下面是代碼的骨架:

1 class GameCharacter;                                                                      // forward declaration
 2 
 3 class HealthCalcFunc {                                                                   
 4 
 5 public:                                                                                            
 6 
 7 ...                                                                                                    
 8 
 9 virtual int calc(const GameCharacter& gc) const                            
10 
11 { ... }                                                                                                
12 
13 ...                                                                                                    
14 
15 };                                                                                                    
16 
17 HealthCalcFunc defaultHealthCalc;                                                 
18 
19 class GameCharacter {                                                                    
20 
21 public:                                                                                            
22 
23 explicit GameCharacter(HealthCalcFunc *phcf = &defaultHealthCalc)          
24 
25 : pHealthCalc(phcf)                                                                        
26 
27 {}                                                                                                    
28 
29 int healthValue() const                                                                   
30 
31 { return pHealthCalc->calc(*this); }                                                
32 
33 ...                                                                                                    
34 
35 private:                                                                                          
36 
37 HealthCalcFunc *pHealthCalc;                                                        
38 
39 };        

很容易識别出來這是人們所熟知的”标準”政策模式的實作,它也為現存的健康計算算法的調整提供了可能性,你隻需要添加一個HealthCalcFunc的派生類就可以了。

2.5 替換方法總結

這個條款的基本建議是當為你所要解決的問題尋找一個設計方法時,考慮一下虛函數設計的替代方法。下面是我們介紹的設計方法回顧:

  • 使用非虛接口用法(NVI idiom),這是模闆方法設計模式(Template Method design pattern),它用public非虛成員函數來包裹更低通路權的虛函數來實作。
  • 用函數指針成員函數來替代虛函數,這是政策設計模式的分解表現形式。
  • 用tr1::function資料成員來代替虛函數,它可以使用同目标簽名(signature)相相容的任何可調用實體。這也是政策設計模式的一種形式。
  • 将一個繼承體系中的虛函數替換為另外一個繼承體系的虛函數。這是政策設計模式的傳統實作方法。

這并不是替換虛函數的所有設計方法,但是應該足夠使你确信這些方法是确實存在的。進一步來說,它們的優缺點使你更加清楚你應該考慮使用它們。

為了避免在面向對象設計的路上被卡住,你需要時不時的拉一把。有很多其他的方法。值得我們花時間去研究它們。

7. 總結

    • 虛函數的替換方法包括NVI用法和政策設計模式的其他不同的形式。NVI用法本身是模闆方法設計模式的一個例子。
    • 将功能從成員函數移到類外函數的一個缺點是非成員函數不能再通路類的非public成員。
    • Tr1::function對象的行為就像一個泛化函數指針。這種對象支援同給定目标簽名相相容的所有可調用實體。

作者:

HarlanC

部落格位址:

http://www.cnblogs.com/harlanc/

個人部落格:

http://www.harlancn.me/

本文版權歸作者和部落格園共有,歡迎轉載,但未經作者同意必須保留此段聲明,且在文章頁面明顯位置給出,

原文連結

如果覺的部落客寫的可以,收到您的贊會是很大的動力,如果您覺的不好,您可以投反對票,但麻煩您留言寫下問題在哪裡,這樣才能共同進步。謝謝!