假设你正在写一个视频游戏软件,你打算为游戏内的人物设计一个继承体系,剧中人物被伤害或因其他因素而降低健康状态的情况并不罕见。你因此决定提供一个成员函数healthValue,它会返回一个整数,表示人物的健康程度。
由于不同的人物可能以不同的方式计算他们的健康指数,将headthValue声明为virtual似乎是个再明白不过的做法:
class GameCharacter {
public:
virtual int healthValue() const;
//返回人物的健康指数,derived vlass可重新定义它。
//...
};
healthValue并未被声明为pure virtual,这暗示我们将会有个计算健康指数的缺省算法。
这的确是再明白不过的设计,但从某个角度说却反而成了它的弱点。
让我们考虑其他一些解法。
一、藉由non-virtual interface手法实现Template Method模式
我们将从一个有趣的思想流派开始,这个流派主张virtual函数应该几乎总是private。
这个流派的拥护者建议,较好的设计是保留headthValue为public成员函数 ,但让它成为non-virtual,并调用一个private virtual函数(例如doHealthValue)进行实际工作:
class GameCharacter {
public:
int healthValue() const//derived class不重新定义它
{
//...//做一些事前工作
int retVal = doHealthValue();//作真正工作
//...//做一些事后工作
return retVal;
}
//...
private:
virtual int doHealthValue() const//derived class可重新定义它
{
//...//缺省算法,计算健康指数
}
};
这一基本设计,也就是“令客户通过public non-virtual成员函数间接调用private virtual函数”,称为non-virtual interface(NVI)手法。
它是所谓template method设计模式(与C++ template并无关联)的一个独特表现形式。
将这个non-virtual函数(healthValue)称为virtual函数的外覆器(wrapper)。
(一)NVI手法的优点
NVI手法的一个优点隐身在上述代码注释“做一些事前工作”和“做一些事后工作”之中。那些注释用来告诉你当时的代码保证在“virtual函数进行真正工作之前和之后”被调用。
这意味外覆器确保得以在一个virtual函数被调用之前设定好适当场景,并在调用结束之后清理场景。
“事前工作”可以包括锁定互斥器、制造运转日志记录项、验证class约束条件、验证函数先决条件等。
“事后工作”可以包括互斥器解除锁定、验证函数的事后条件、再次验证class的约束条件等。
若你让客户直接调用virtual函数,就没有任何好办法可以做这些事。
(二)NVI手法注意
有件事实可能会妨碍你跃跃欲试的心:NVI手法涉及在derived class内重新定义private virtual函数。
“重新定义virtual函数”表示某些事“如何”被完成,“调用virtual函数”则表示它“何时”被完成。这些事情都是各自独立互不相干的。
NVI手法允许derived class重新定义virtual函数,从而赋予它们“如何实现机能”的控制能力,但base class保留诉说“函数何时被调用”的权利。一开始这些听起来似乎诡异,但C++的这种“derived class可重新定义继承而来的private virtual函数”的规则完全合情合理。
在NVI手法下其实没有必要让virtual函数一定得是private。某些class继承体系要求derived class在virtual函数的实现内必须调用其base class的对应兄弟,而为了让这样的调用合法,virtual函数必须是protected,不能是private。有时virtual函数甚至一定得是public(例如具备多态性质的base class的析构函数),这边一来就不能实施NVI手法了。
二、藉由function pointer实现Strategy模式
NVI手法对public virtual函数而言是一个有趣的替代方案,但从某种设计角度来看,它只比窗饰花样更强一些。毕竟我们还是使用virtual函数来计算每个人的健康指数。
另一个更戏剧性的设计主张“人物健康指数的计算与人物类型无关”,这样的计算完全不需要“人物”这个成分。
例如,我们可能会要求每个人物的构造函数接受一个指针,指向一个健康计算函数,而我们可以调用该函数进行实际计算:
class GameCharacter;//前置声明
//以下函数是计算健康指数的缺省算法
int defaultHealthCalc(const GameCharacter& gc);
class GameCharacter {
public:
typedef int (*HealthCalcFunc)(const GameCharacter&);
explicit GameCharacter(HealthCalcFunc hcf=defaultHealthCalc):healthFunc(hcf){}
int healthValue() const//derived class不重新定义它
{
return healthFunc(*this);
}
//...
private:
HealthCalcFunc healthFunc;
};
这个做法是常见的Strategy设计模式的简单应用。拿它和“根基于GameCharacter继承体系内的virtual函数”的做法比较,它提供了某些有趣弹性:
(一)Strategy设计模式提供的弹性
1.同一人物类型的不同实体可以有不同的健康计算函数
例如:
class EvilBadGuy : public GameCharacter {
public:
explicit EvilBadGuy(HealthCalcFunc hcf=defaultHealthCalc)
:GameCharacter(hcf){/*...*/ }
//...
};
int loseHealthQuickly(const GameCharacter&);//健康指数计算函数1
int loseHealthSlowly(const GameCharacter&);//健康指数计算函数2
EvilBadGuy ebg1(loseHealthQuickly);//相同类型的人物搭配
EvilBadGuy ebg2(loseHealthSlowly);//不同的健康计算方式
2.某已知人物的健康指数计算函数可在运行期变更
例如,GameCharacter可提供一个成员函数setHealthCalculator,用来替换当前的健康指数计算函数。
换句话说,“健康指数计算函数不再是GameCharacter继承体系内的成员函数”这一事实意味着,这些计算函数并未特别访问“即将被计算健康指数”的那个对象的内部成分。
例如,defaultHealthCalc并未访问EvilBadGuy的non-public成分。
若人物的健康可存粹根据人物public接口得来的信息加以计算,这就没有任何问题,但如果需要non-public信息进行精确计算,就有问题了。实际上,任何时候当你将class内的某个机能(也许取道自某个成员函数)替换为class外部的某个等价机能(也许取道自某个non-member non-friend函数或另一个class的non-friend成员函数),这都是潜在争议点。
一般而言,唯一能够解决“需要以non-member函数访问class的non-public成员”的办法就是:弱化class的封装。
例如,class可声明那个non-member函数为friend,或是为其实现的某一部分提供public访问函数(其他部分则宁可隐藏起来)。运用函数指针替换virtual函数,其优点(像是“每个对象可各自拥有自己的健康计算函数”和“可在运行期改变计算函数”)是否足以弥补缺点(例如可能必须降低GameCharacter封装性),是你必须根据每个设计情况的不同而抉择的。
三、藉由tr1::function完成Strategy模式
一旦习惯了template以及它们对隐式接口的使用,基于函数指针的做法看起来便过分苛刻而死板了。
为什么要求“健康指数的计算”必须是个函数,而不能是某种“像函数的东西”(例如函数对象)呢?如果一定得是函数,为什么不能够是个成员函数?为什么一定得返回int而不是任何可被转换为int的类型呢?
若我们不再使用函数指针(如前例的healthFunc),而是改用一个类型为tr1::function的对象,这些约束就全都挥发不见了。这样的对象可持有(保存)任何可调用物(callable entity,也就是函数指针、函数对象或成员函数指针),只要其签名式兼容于需求端。
(一)tr1::function完成Strategy模式的实现
以下将刚才的设计改为使用tr1::function:
class GameCharacter;//前置声明
//以下函数是计算健康指数的缺省算法
int defaultHealthCalc(const GameCharacter& gc);
class GameCharacter {
public:
//HealthCalcFunt可以是任何“可调用物”,可被调用并接受
//任何兼容于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是个typedef,用来表现tr1::function的某个具现体,意味该具现体的行为像一般的函数指针。
现在来具体看看HealthCalcFunc是个什么样的typedef。int (const GameCharacter&)>代表的函数是“接受一个reference指向const GameCharacter,并返回int”。
这个tr1::function类型(即定义的HeathCalcFunc类型)产生的对象可以持有(保存)任何与此签名式兼容的可调用物。所谓兼容,意思是这个可调用物的参数可被隐式转换为
const GameCharacter&,而其返回类型可被隐式转换为int。
与前一个设计(其GameCharacter持有的是函数指针)比较,这个设计几乎相同。唯一不同的是如今GameCharacter持有一个tr1::function对象,相当于一个指向函数的泛化指针。
(二)tr1::function带来的改变
这个改变很细小,几乎没有什么外显影响,除非客户在“指定健康计算函数”这件事上更需要更惊人的弹性:
short calcHealth(const GameCharacter&);//健康计算函数,注意其返回类型为non-int
struct HealthCalculator {//为计算健康而设计的函数对象
int operator()(const GameCharacter&) const{/*...*/ }
};
class GameLevel {
public:
//成员函数,用以计算健康;注意其non-int返回类型
float health(const GameCharacter&) const;
//...
};
class EvilBadGuy : public GameCharacter {//同前
//...
};
//另一个人物类型,假设其构造函数与EvilBadGuy相同
class EyeCandyCharacter : public GameCharacter {
//...
};
//人物1,使用某个函数计算健康指数
EvilBadGuy ebg1(calcHealth);
//人物2,使用某个函数对象计算健康指数
EyeCandyCharacter ecc1(HealthCalculator());
GameLevel currentLevel;
//...
//人物3,使用某个成员函数计算健康指数
EvilBadGuy ebg2(std::tr1::bind(&GameLevel::health),currentLevel,_1){}
对于上述代码,为计算ebg2的健康指数,应该使用GameLevel class的成员函数health。GameLevel::health宣称它自己接受一个参数(那是个reference,指向GameCharacter),但它实际上接受两个参数,因为它也获得一个隐式参数GameLevel,也就是this所指的那个。
然而,GameCharacter的健康计算函数只接受单一参数:GameCharacter(这个对象将被计算出健康指数)。若我们使用GameLevel::health作为ebg2的健康计算函数,我们必须以某种方式转换它,使它不再接受两个参数(一个GameCharacter和一个GameLevel),转而接受单一参数(一个GameCharacter)。
在这个例子中我们必然会想要使用currentLevel作为“ebg2的健康计算函数所需的那个GameLevel对象”,于是我们将currentLevel绑定为GameLevel对象,让它在“每次GameLevel::health被调用以计算ebg2的健康”时被使用。那正是tr1::bind的作为:它指出ebg2的健康计算函数应该总是以currentLevel作为GameLevel对象。
上述分析跳过了一大堆细节,像是为什么“_1”意味“当为ebg2调用GameLevel::health时以currentLevel作为GameLevel对象”。
这样的细节不难阐述,但它们会妨碍要讲的根本重点:
若以tr1::function替换函数指针,我们将因此允许客户在计算人物健康指数时使用任何兼容的可调用物。
四、古典的Strategy模式
传统(典型)的Strategy做法会将健康计算函数做成一个分离的继承体系中的virtual成员函数。
设计结果看起来像这样:
上图表示GameCharacter是某个继承体系的根类,体系中的EvilBadGuy和EyeCandyCharacter都是derived class,HealthCalcFunc是另一个继承体系的根类,体系中的SlowHealthLoser和FastHealthLoser都是derived class,每一个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;
};
这个解法的吸引力在于,熟悉标准Strategy模式的人很容易辨认它,而且它还提供“将一个既有的健康算法纳入使用”的可能性——只要为HealthCalcFunc继承体系添加一个derived class即可。
五、总结
本条款的根本忠告是,当你为解决问题而寻找某个设计方法时,不妨考虑virtual函数的替代方案。
(一)virtual函数的替代方案
1.使用non-virtual interface(NVI)手法,那是Template Method设计模式的一种特殊形式。它以public non-virtual成员函数包裹较低访问性(private或protected)的virtual函数。
2.将virtual函数替换为“函数指针成员变量,这是Strategy设计模式的一种分解表现形式。
3.以tr1::function成员变量替换virtual函数,因而允许使用任何可调用物搭配一个兼容于需求的签名式。这也是Strategy设计模式的某种形式。
4.将继承体系内的virtual函数替换为另一个继承体系内的virtual函数。这是Strategy设计模式的传统实现手法。
(二)注意
1.virtual函数的替代方案包括NVI手法及Strategy设计模式的多种形式。NVI手法自身是一个特殊形式的Template Method设计模式。
2.将机能从成员函数移到class外部函数,带来的一个缺点是,非成员函数无法访问class的non-public成员。
3.tr1::function对象的行为就像一般函数指针。这样的对象可接纳“与之给定的目标签名式兼容”的所有可调用物。