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

如果UML符號(hào)還是讓你一頭霧水的話,我就簡(jiǎn)單點(diǎn)兒說吧:GameCharacter是一個(gè)繼承層次結(jié)構(gòu)中的基類,EvilBadGuy和EyeCandyCharacter是派生類;HealthCalcFunc是另一個(gè)繼承層次結(jié)構(gòu)的基類,其下包含SlowHealthLoser和FastHealthLoser等派生類。GameCharacter類型的每一個(gè)對(duì)象都有一個(gè)指針,這一指針指向?qū)?yīng)的HealthCalcFunc層次結(jié)構(gòu)中的對(duì)象。
以下是對(duì)應(yīng)的代碼框架:
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;
};
以上的方案可以使熟悉“標(biāo)準(zhǔn)”策略模式實(shí)現(xiàn)方式的朋友很快的識(shí)別出來,同時(shí),此方案還可以通過為HealthCalcFunc層次結(jié)構(gòu)中添加一個(gè)派生類的方式,使對(duì)現(xiàn)有的生命值算法作出改進(jìn)成為可能。
小節(jié)
本條目中最基本的建議是:在你為當(dāng)前的問題設(shè)計(jì)解決方案時(shí),不妨考慮一下虛函數(shù)以外的其他替代方案。以下是對(duì)上述替代方案的概括:
l 使用“非虛擬接口”慣用法(NVI慣用法),它是模板方法設(shè)計(jì)模式的一種形式,它將公有非虛成員函數(shù)包裝成為更低訪問權(quán)限的虛函數(shù)。
l 使用函數(shù)指針數(shù)據(jù)成員代替虛函數(shù)。策略設(shè)計(jì)模式一個(gè)簡(jiǎn)裝的表現(xiàn)形式。
l 使用tr1::function數(shù)據(jù)成員代替虛函數(shù)。可以使用任意可調(diào)用實(shí)體,只要實(shí)體特征與所期待的相兼容即可。同樣是策略設(shè)計(jì)模式的一種形式。
l 使用另一個(gè)層次結(jié)構(gòu)中的虛函數(shù)代替同一層次結(jié)構(gòu)內(nèi)的虛函數(shù)。這是策略設(shè)計(jì)模式的傳統(tǒng)實(shí)現(xiàn)形式。
以上并不是虛函數(shù)替代方案的完整列表,但是這足以說服你虛函數(shù)是存在替代方案的。此外,這些方案的比較優(yōu)勢(shì)和劣勢(shì),清楚的告訴你在應(yīng)用的過程中應(yīng)該對(duì)它們加以考慮。
為了避免陷入面向?qū)ο笤O(shè)計(jì)的思維定勢(shì),我們不妨不失時(shí)機(jī)的另辟蹊徑,所謂“條條大路通羅馬”。花些時(shí)間來研究這些替代方案還是頗有裨益的。
時(shí)刻牢記
l 虛函數(shù)的替代方案包括:NVI慣用法和策略模式的不同實(shí)現(xiàn)方式。NVI慣用法是模板方法設(shè)計(jì)模式的一個(gè)實(shí)例。
l 將成員函數(shù)的功能挪至類外部存在著以下缺點(diǎn):非成員函數(shù)對(duì)類的非公有成員沒有訪問權(quán)限。
l tr1::function對(duì)象就像更一般化的函數(shù)指針。這類對(duì)象支持給定特征的任意可調(diào)用實(shí)體。