《Effective C++ 中文版 第三版》读书笔记
条款 35:考虑 virtual 函数以外的其他选择
假设你整在写一个视频游戏软件,由于不同的人物可能以不同的方式计算它们的健康指数,将 healthValue 声明为 virtual 似乎再明白不过的做法:
class GameCharacter {
public:
virtual int healthValue() const;
...
};
由于这个设计如此明显,你可能没有认真考虑其他替代方案。为了帮助你跳脱面向对象设计路上的常轨,让我们考虑其他一些解法:
藉由 Non-virtual interface 手法实现 Template Method 模式
有个思想流派主张 virtual 函数应该几乎总是 private。他们建议,较好的设计是保留 healthValue 为 public 成员函数,但让它成为 non-virtual,并调用一个 private virtual 函数(例如doHealthValue)进行实际工作:
class GameCharacter {
public:
int healthValue() const
{
...//做一些事前工作
int retVal = doHealthValue();
...//做一些事后工作
return retVal;
}
private:
virtual int doHealthValue() const //derived class 可以重新定义它。
{
...//缺省计算,计算健康指数
}
};
这一基本设计,“令客户通过 public non-virtual 成员函数间接调用 private virtual 函数”,称为 non-virtual interface(NVI)手法。是所谓 template Method 设计模式的一个独特表现形式。把这个 non-virtual 函数(healthValue)称为 virtual 函数的外覆器(wrapper)。
NVI 的优点在上述代码注释 “做一些事前工作” 和 “做一些事后工作” 之中。这意味着外覆器(wrapper)确保得以在一个 virtual 函数被调用之前设定好适当场景,并在调用结束之后清理场景。“事前工作” 可以包括锁定互斥器、制造运转日志记录项、验证 class 约束条件、验证函数先决条件等等。
NVI 手法涉及在 derived class 内重新定义 private virtual 函数。重新定义若干个 derived class 并不调用的函数!这里并不矛盾。“重新定义 virtual 函数” 表示某些事 “如何” 被完成,“调用 virtual 函数” 则表示它 “何时” 被完成。NVI 允许 derived class重新定义 virtual 函数,从而赋予它们 “如何实现机能” 的控制能力,但 base class 保留诉说 “函数何时被调用” 的权力。
NVI 手法其实没必要让 virtual 函数一定是 private。有时必须是 protected。还有时候甚至是 public,这么一来就不能实施 NVI 手法了。
藉由 Function Pointers 实现 Strategy 模式
另一个设计主张是 “人物健康指数的计算与人物类型无关”,这样的计算完全不需要 “人物” 这个成分。例如我们可能会要求每个人物的构造函数接受一个指针,指向一个健康计算函数,而我们可以调用该函数进行实际计算:
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
{
return healthFunc(*this);
}
private:
HealthCalcFunc healthFunc;
};
这个做法是常见的 Strategy 设计模式的简单应用。和 “GameCharacter 继承体系内的 virtual 函数” 的做法比较,它提供了某些有趣弹性:
同一人物类型不同实体可以有不同的健康计算函数。例如:
class EvilBadGuy: public GameCharacter {
public:
explicit EvilBadGuy(HealthCalcFunc hcf = defaultHealthCalc)
: GameCharacter(hcf)
{...}
...
};
int loseHealthQuickly(const GameCharacter&);
int loseHealthSlowly(const GameCharacter&);
EvilBadGuy ebg1(loseHealthSlowly);//相同类型的人物搭配
EvilBadGuy ebg2(loseHealthQuickly);//不同的健康计算方式
某已知人物健康指数计算函数可以在运行期变更:例如 GameCharacter 可提供一个成员函数 setHealthCalculator,用来替换当前的健康指数计算函数。
这些计算函数并未特别访问 “即将被计算健康指数” 的那个对象内部成分。例如 defaultHealthCalc 并未访问 EvilBadGuy 的 non-public 成分。如果需要 non-public 信息进行精确计算,就有问题了。唯一能解决 “需要以 non-member 函数访问 class 的 non-public 成分” 的办法就是:弱化 class 的封装。例如 class 可以声明那个 non-member 函数为 friends,或是为其实现的某一部分提供 public 访问函数。利用函数指针替换 virtual 函数,其优点(像是 “每个对象可各自拥有自己的健康计算函数” 和 “可在运行期间改变计算函数”)是足以弥补缺点(例如可能必须降低 GameCharacter 封装性)。
藉由 tr1::function 完成 Strategy 模式
我们不再用函数指针,而是用一个类型为 tr1::function 的对象,这样的对象可持有(保存)任何可调用物(callable entity,也就是函数指针、函数对象、或成员数函数指针),只要其签名式兼容于需求端。
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;
};
这里我们把 tr::function 的具现体的目标签名式以不同颜色强调出来。那个签名代表的函数是 “接受一个 reference 指向 const GameCharacter,并返回 int”
std::tr1::function<int (const GameCharacter&)>
所谓兼容,意思是这个可调用物的参数可被隐式转换为 const GameCharacter&,而其返回类型可被隐式转换成 int。
客户在 “指定健康计算函数” 这件事上有更惊人的弹性:
short calcHealth(const GameCharacter&); //函数return non-int
struct HealthCalculator {//为计算健康而设计的函数对象
int operator() (const GameCharacter&) const
{
...
}
};
class GameLevel {
public:
float health(const GameCharacter&) const;//成员函数,用于计算健康
...
};
class EvilBadGuy : public GameCharacter {
...
};
class EyeCandyCharacter : public GameCharacter {
...
};
EvilBadGuy ebg1(calcHealth);//函数
EyeCandyCharacter ecc1(HealthCalculator());//函数对象
GameLevel currentLevel;
...
EvilBadGuy ebg2(std::tr1::bind(&GameLevel::health, currentLevel, _1));//成员函数
GameLevel::health 宣称它接受两个参数,但实际上接受两个参数,因为它也获得一个隐式参数 GameLevel,也就是 this 所指的那个。然而 GameCharacter 的健康计算函数只接受单一参数:GameCharacter。如果我们使用 GameLevel::health 作为 ebg2 的健康计算函数,我们必须以某种方式转换它,使它不再接受两个参数(一个 GameCharacter 和一个 GameLevel),转而接受单一参数(GameCharacter)。于是我们将 currentLevel 绑定为 GameLevel 对象,让它在 “每次 GameLevel::health 被调用以计算 ebg2 的健康” 时被使用。那正是 tr1::bind 的作为。
古典的 Strategy 模式将健康计算函数做成一个分离的继承体系中的 virtual 成员函数。
class GameCharacter;
class HealthCalcFunc {
...
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;
};
每一个 GameCharacter 对象都内含一个指针,指向一个来自 HealthCalcFunc 继承体系的对象。