假設你正在設計一款游戲軟件,你需要為游戲中的各種角色設計一個層次結構。你游戲的劇情中充滿了風險刺激,那么游戲中的角色就會經常遇到受傷或者生命值降低的情況。于是你決定為角色類提供一個成員函數:healthValue,這一函數通過返回一個整數值來表示角色當前的生命值。由于計算不同角色生命值的方式可能會不一樣,因此不妨將healthValue聲明為虛函數,這樣做十分順理成章:
class GameCharacter {
public:
virtual int healthValue() const; // 返回角色的生命值
... // 派生類可以重定義該函數
};
此處,healthValue函數并沒有直接聲明為純虛函數,這一事實告訴我們計算生命值存在著一種默認的算法(參見條目34)。
事實上,所謂“順理成章”的方法,在某些情況下卻會成為工作的絆腳石。由于這一設計太過“順理成章”了,你也許就不會再為尋找替代方案花費足夠的精力。為了讓你從面向對象設計的思維定勢中解脫出來,讓我們另辟蹊徑,解決這一問題。
模板方法模式:通過“非虛接口慣用法”實現
我們從討論一個有趣的思想流派開始,這一流派堅持虛函數必定為私有函數。其追捧者也許會提出,更優秀的方案中healthValue仍為公共成員函數,但將其聲明為非虛函數,并讓其調用一個私有的虛函數,真正的工作由這個虛函數來完成。不妨將其命名為doHealthValue:
class GameCharacter {
public:
int healthValue() const // 派生類不能對其進行重定義
{ // 參見條目36
... // 準備工作:參見下文
int retVal = doHealthValue(); // 進行實際的工作
... // 后期工作:參見下文
return retVal;
}
...
private:
virtual int doHealthValue() const // 派生類可以對其進行重定義
{
... // 計算角色生命值的默認算法
}
};
上文的代碼中(以及本條目中所有其它代碼中),我將成員函數的定義內容放置在了類定義中。如同條目30中所解釋的,這樣做使得這些函數隱式帶有內聯屬性。我這樣做只是為了讓所說明的問題更加簡單明了。是否選擇內聯與本條款的設計方案無關,因此不要認為這里在類的內部編寫成員函數是出于什么目的的。不要誤會了。
讓客戶通過調用公有的非虛成員函數來間接的調用私有虛函數——這是一個基本的設計方案。我們一般將其稱為“非虛擬接口慣用法(non-virtual interface,簡寫為NVI)” 。這一方案是一個更為一般化的設計模式“模板方法”(可惜這一模式和C++中的模板并沒有什么關系)的一個特定的表現形式。我們將這些非虛函數(比如上文中的healthValue)稱為虛函數的“包裝器”。
請留意上文代碼中的“準備工作”、“后期工作”這兩段注釋內容。在虛函數做實際工作的前后,這兩段注釋處的代碼肯定會得到調用。這是NVI慣用法的一個優勢,這樣做意味著在程序調用虛函數之前,相關的背景工作已經設定好了;在調用虛函數以后,后臺清理工作也能得以進行,這兩項工作都是由包裝器確保進行的。舉例說,“準備工作”可能包含互斥鎖加鎖、日志記錄、確認當前類的約束條件和函數的先決條件是否滿足,等等。“后期工作”可能包括互斥鎖解鎖、檢查函數的后置條件、重新檢查類的約束條件,等等。如果你讓客戶直接調用虛函數的話,那么這些工作也就很難開展了。
你也許注意到了:在使用NVI慣用法時,我們可以在派生類中對私有虛函數進行重定義,而這些函數在派生類中是無法調用的啊!這一點看上去匪夷所思,但實際上這里并不存在設計上的矛盾。虛函數的重定義工作確認了它實現其功能的方式,虛函數的調用工作確認了它實現其功能的時機。兩者是相互獨立的:NVI慣用法允許在派生類中重定義虛函數,這樣做使得基類將虛函數調用方式的選擇權賦予派生類,然而基類仍保留函數調用時機的決定權。這一點乍看上去有些異樣,但是,“允許派生類對私有虛函數進行重定義”這一C++規則是非常合理的。
在NVI慣用法的背景下,并沒有嚴格要求虛函數必須是私有的。在一些類層次結構中,一個虛函數可能在某個派生類中的實現版本中調用基類的其他成員(參見條目27中SpecialWindow的例子),為了讓這樣的調用合法,這個虛函數必須聲明為受保護的,而不是私有的。還有一些情況下虛函數甚至要聲明為公有的(參見條目7:多態基類中的析構函數),在這些情況下NVI慣用法不再可用。
策略模式:通過函數指針實現
NVI慣用法是公有虛函數的一個有趣的替代方案,但是從設計的觀點來看,這樣做無異于粉飾門面罷了。畢竟我們仍然在使用虛函數來計算每一個角色的生命值。這里存在著一個改進效果更為顯著的設計主張:計算角色生命值的工作應與角色類型完全無關,于是計算生命值的工作便無須為角色類的成員。舉例說,我們可以讓每個角色類的構造函數包含一個函數指針參數,并由這一參數將生命值計算方法函數傳入,我們可以通過調用這個函數進行實際的計算工作:
class GameCharacter; // 前置聲明
// defaultHealthCalc函數:計算生命值的默認算法
int defaultHealthCalc(const GameCharacter& gc);
class GameCharacter {
public:
typedef int (*HealthCalcFunc)(const GameCharacter&);
explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc)
: healthFunc(hcf)
{}
int healthValue() const
{ return healthFunc(*this); }
...
private:
HealthCalcFunc healthFunc;
};
這一方案是對另一個常見的設計模式——“策略模式”的一個簡單應用。與使用虛函數的GameCharacter層次結構解決方案相比而言,本方案為我們帶來了一些有趣的靈活性:
l 同一角色類型的不同實例可以使用不同的生命值計算函數。比如:
class EvilBadGuy: public GameCharacter {
public:
explicit EvilBadGuy(HealthCalcFunc hcf = defaultHealthCalc)
: GameCharacter(hcf)
{ ... }
...
};
int loseHealthQuickly(const GameCharacter&); // 不同計算方式的
int loseHealthSlowly(const GameCharacter&); // 生命值計算函數
EvilBadGuy ebg1(loseHealthQuickly); // 同一類型的角色
EvilBadGuy ebg2(loseHealthSlowly); // 使用不同類型的
// 生命值計算方法
l 對特定的角色的生命值計算函數可以在運行時更改。比如說,GameCharacter可以提供一個名為setHealthCalculator的成員函數,我們可以使用這個函數來更換當前的生命值計算函數。
另一方面,我們發現,生命值計算函數不再是GameCharacter層次結構的成員函數了,這一事實說明,這類生命值計算函數不再有訪問它們所處理對象的內部成員的特權。比如說,defaultHealthCalc函數對于EvilBadGuy類中的非公有成員就沒有訪問權限。如果角色的生命值可以單純依靠角色類的公有接口所得到的信息來計算的話,上述的情況并不是問題,但是一旦精確的生命值計算需要調用非公有的信息,那么這一情況便成為問題了。事實上,凡是你使用類外部的等價功能(比如通過非成員非友元函數或通過其它類的非友元的成員函數)來替代類內部的功能(比如通過成員函數)時,都存在潛在的問題。本篇余下部分內容都存在這一問題,因為我們要考慮的其它設計方案都會涉及到GameCharacter層次結構外部函數的使用。
讓非成員函數訪問類內部的非公有成員只有一個解決辦法,那就是降低類的封裝度,這是一個一般的規則。比如說,類可以將非成員函數生命為友元,類也可以為需要隱藏的具體實現提供公有訪問函數。使用函數指針代替虛函數可以帶來一些優勢(比如可以為每一個角色的實例提供一個生命值計算函數,可以在運行時更換計算函數),而這樣做也會降低GameCharacter的封裝度,至于優缺點之間孰輕孰重,你在實戰中必須做到具體問題具體分析,盡可能的審時度勢。
策略模式:通過tr1::function實現
如果你對模板和模板的隱式接口的用法(參見條目41)很熟悉的話,那么上述的“基于函數指針”的方案就顯得十分蹩腳了。我們考慮:舊的方案必須使用一個函數來實現生命值計算的功能,能不能用其它一些東西(比如函數對象)來代替這個函數呢?為什么這個函數不能是成員函數呢?還有,為什么這個函數一定要返回一個整數值,而不能返回一個可以轉換成int類型的其他對象呢?
如果我們使用一個tr1::function類型的對象來代替函數指針,那么上述問題中的約束則會瞬間瓦解。就像條目54中所解釋的,tr1::function對象可以保存任意可調用實體(也就是:函數指針、函數對象、或成員函數指針),這些實體的簽名一定與所期待內容的類型兼容。以下是我們剛剛看到的設計方案,這次加入了tr1::function的使用:
class GameCharacter; // 同上
int defaultHealthCalc(const GameCharacter& gc);
// 同上
class GameCharacter {
public:
// HealthCalcFunc可以是任意可調用的實體,
// 它可以被任意與GameCharacter類型兼容的對象調用
// 且返回值必與int類型兼容,詳情見下文
typedef std::tr1::function<int (const GameCharacter&)>
HealthCalcFunc;
explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc)
: healthFunc(hcf)
{}
int healthValue() const
{ return healthFunc(*this); }
...
private:
HealthCalcFunc healthFunc;
};
正如你所見到的,HealthCalcFunc是對tr1::function某個實例的一個typedef。這意味著它與一般的函數指針類型并無二致。請仔細觀察,HealthCalcFunc所表示的自定義類型:
std::tr1::function<int(const GameCharacter&)>
在這里,我對此tr1::function實例“目標簽名”做加粗處理。這里目標簽名為“一個包含const GameCharacter&參數并返回int類型的函數”。此tr1::function類型(也就是HealthCalcFunc類型)的對象可以保存兼容此目標簽名的任意可調用實體。“兼容”意味著實體中必須包含(或可隱式轉換為)const GameCharacter&類型的參數,并且此實體必須返回一個(或可隱式轉換為)int類型的返回值。
與上文中我們剛剛看到過的設計方案(GameCharacter保存一個函數指針)相比,本方案并沒有顯著的改動。二者僅有的差別就是后一版本中GameCharacter可以保存tr1::function對象——指向函數的一般化指針。這一改變實際上是十分微不足道的,甚至有些跑題,但是,它顯著提高了客戶在程序中修改角色的生命值計算函數的靈活性,這一點還是沒有偏離議題的:
short calcHealth(const GameCharacter&); // 生命值計算函數
// 注意:返回值類型非int
struct HealthCalculator { // 生命值計算類
int operator()(const GameCharacter&) const // 函數對象
{ ... }
};
class GameLevel {
public:
float health(const GameCharacter&) const; // 生命值計算成員函數
... // 注意:返回類型非int
};
class EvilBadGuy: public GameCharacter { // 同上
...
};
class EyeCandyCharacter: public GameCharacter {
... // 另一個角色類型;
}; // 假設與EvilBadGuy
// 有同樣的構造函數
EvilBadGuy ebg1(calcHealth); // 使用函數的角色
EyeCandyCharacter ecc1(HealthCalculator()); // 使用函數對象的角色
GameLevel currentLevel;
...
EvilBadGuy ebg2( std::tr1::bind(&GameLevel::health, currentLevel, _1) );
// 使用成員函數的角色
// 詳情見下文
我個人認為,tr1::function可以把你帶入一個全新的美妙境界,它讓我“熱血沸騰”。如果你的熱血暫時沒有那么“沸騰”,那可能是因為你還糾結于ebg2的定義,你還不了解tr1::bind的工作機理。請允許我對它做出介紹。
我要說的是,要計算ebg2的生命值,需要使用GameLevel類中的health成員函數。現在,我們所聲明的GameLevel::health函數表面上看包含一個參數(一個指向GameCharacter對象的引用),但是它實際上包含兩個參數,這是因為它還包含一個隱式的GameLevel參數(this指針所指對象)。然而,GameCharacter類的生命值計算函數僅包含一個參數(需要計算生命值的GameCharacter對象)。如果我們使用GameLevel::health函數來計算ebg2的生命值的話,為了“適應”它,我們這里只能使用一個參數(GameCharacter),而不能使用兩個(GameCharacter, GameLevel),這種情況有些蹩腳。本例中,對于ebg2的生命值計算,我們希望始終選用currentLevel作為GameLevel對象,我們可以將currentLevel作為GemeLevel對象將兩者“綁定”(bind)起來,這樣,我們在每次使用GameLevel::health函數來計算ebg2的生命值時,都可以使用綁定好的二者。這就是tr1::bind調用所做的:他告訴ebg2的生命值計算函數,應該一直指定currentLevel作為GameLevel對象。
上文中我略去了大量細節內容,比如說,為什么“_1”意味著“在為ebg2調用GameLevel::health時選用currentLevel作為GameLevel對象”。這些細節并不太難理解,而且它們與本節所講的基本點并無關系,通過使用tr1::function來代替函數指針,在計算角色的生命值時,我們可以讓客戶使用任意可調用實體。這是再酷不過的事情了!
“經典”策略模式
如果相對于用C++來耍酷,你更加深諳設計模式之道,那么更加傳統的策略模式是這樣實現的:將生命值計算函數聲明為虛函數。并在此基礎上創建一個獨立的生命值計算層次結構。以下的UML圖體現了這一設計方案:

如果UML符號還是讓你一頭霧水的話,我就簡單點兒說吧:GameCharacter是一個繼承層次結構中的基類,EvilBadGuy和EyeCandyCharacter是派生類;HealthCalcFunc是另一個繼承層次結構的基類,其下包含SlowHealthLoser和FastHealthLoser等派生類。GameCharacter類型的每一個對象都有一個指針,這一指針指向對應的HealthCalcFunc層次結構中的對象。
以下是對應的代碼框架:
class GameCharacter; // 前置聲明
class HealthCalcFunc {
public:
...
virtual int calc(const GameCharacter& gc) const
{ ... }
...
};
HealthCalcFunc defaultHealthCalc;
class GameCharacter {
public:
explicit GameCharacter(HealthCalcFunc *phcf = &defaultHealthCalc)
: pHealthCalc(phcf)
{}
int healthValue() const
{ return pHealthCalc->calc(*this);}
...
private:
HealthCalcFunc *pHealthCalc;
};
以上的方案可以使熟悉“標準”策略模式實現方式的朋友很快的識別出來,同時,此方案還可以通過為HealthCalcFunc層次結構中添加一個派生類的方式,使對現有的生命值算法作出改進成為可能。
小節
本條目中最基本的建議是:在你為當前的問題設計解決方案時,不妨考慮一下虛函數以外的其他替代方案。以下是對上述替代方案的概括:
l 使用“非虛擬接口”慣用法(NVI慣用法),它是模板方法設計模式的一種形式,它將公有非虛成員函數包裝成為更低訪問權限的虛函數。
l 使用函數指針數據成員代替虛函數。策略設計模式一個簡裝的表現形式。
l 使用tr1::function數據成員代替虛函數。可以使用任意可調用實體,只要實體特征與所期待的相兼容即可。同樣是策略設計模式的一種形式。
l 使用另一個層次結構中的虛函數代替同一層次結構內的虛函數。這是策略設計模式的傳統實現形式。
以上并不是虛函數替代方案的完整列表,但是這足以說服你虛函數是存在替代方案的。此外,這些方案的比較優勢和劣勢,清楚的告訴你在應用的過程中應該對它們加以考慮。
為了避免陷入面向對象設計的思維定勢,我們不妨不失時機的另辟蹊徑,所謂“條條大路通羅馬”。花些時間來研究這些替代方案還是頗有裨益的。
時刻牢記
l 虛函數的替代方案包括:NVI慣用法和策略模式的不同實現方式。NVI慣用法是模板方法設計模式的一個實例。
l 將成員函數的功能挪至類外部存在著以下缺點:非成員函數對類的非公有成員沒有訪問權限。
l tr1::function對象就像更一般化的函數指針。這類對象支持給定特征的任意可調用實體。