洛译小筑

别来无恙,我的老友…
随笔 - 46, 文章 - 0, 评论 - 172, 引用 - 0
数据加载中……

[ECPP读书笔记 条目35] 虚函数的替代方案

假设你正在设计一款游戏软件,你需要为游戏中的各种角色设计一个层次结构。你游戏的剧情中充满了风险刺激,那么游戏中的角色就会经常遇到受伤或者生命值降低的情况。于是你决定为角色类提供一个成员函数: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层次结构解决方案相比而言,本方案为我们带来了一些有趣的灵活性:

同一角色类型的不同实例可以使用不同的生命值计算函数。比如:

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);            // 使用不同类型的

                                              // 生命值计算方法

对特定的角色的生命值计算函数可以在运行时更改。比如说,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是一个继承层次结构中的基类,EvilBadGuyEyeCandyCharacter是派生类;HealthCalcFunc是另一个继承层次结构的基类,其下包含SlowHealthLoserFastHealthLoser等派生类。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层次结构中添加一个派生类的方式,使对现有的生命值算法作出改进成为可能。

小节

本条目中最基本的建议是:在你为当前的问题设计解决方案时,不妨考虑一下虚函数以外的其他替代方案。以下是对上述替代方案的概括:

使用“非虚拟接口”惯用法(NVI惯用法),它是模板方法设计模式的一种形式,它将公有非虚成员函数包装成为更低访问权限的虚函数。

使用函数指针数据成员代替虚函数。策略设计模式一个简装的表现形式。

使用tr1::function数据成员代替虚函数。可以使用任意可调用实体,只要实体特征与所期待的相兼容即可。同样是策略设计模式的一种形式。

使用另一个层次结构中的虚函数代替同一层次结构内的虚函数。这是策略设计模式的传统实现形式。

以上并不是虚函数替代方案的完整列表,但是这足以说服你虚函数存在替代方案的。此外,这些方案的比较优势和劣势,清楚的告诉你在应用的过程中应该对它们加以考虑。

为了避免陷入面向对象设计的思维定势,我们不妨不失时机的另辟蹊径,所谓“条条大路通罗马”。花些时间来研究这些替代方案还是颇有裨益的。

时刻牢记

虚函数的替代方案包括:NVI惯用法和策略模式的不同实现方式。NVI惯用法是模板方法设计模式的一个实例。

将成员函数的功能挪至类外部存在着以下缺点:非成员函数对类的非公有成员没有访问权限。

tr1::function对象就像更一般化的函数指针。这类对象支持给定特征的任意可调用实体。

posted on 2011-12-25 00:59 ★ROY★ 阅读(2861) 评论(2)  编辑 收藏 引用 所属分类: Effective C++

评论

# re: 【读书笔记】[Effective C++中文版第3版][第35条]为虚函数寻求替代方案   回复  更多评论   

不错,刚一看还以为是楼主写的,那不得了的。
好多年没看过effective c++了,想不到这第3版确实改进不少。
2011-12-25 12:01 | 春秋十二月

# re: 【读书笔记】[Effective C++中文版第3版][第35条]为虚函数寻求替代方案   回复  更多评论   

用非虚函数来包装虚函数这个做法虽然早就听过, 但是我一直没明白什么场合需要用. 到使用虚函数包装非虚函数的"代理"技术, 倒是时不常的能用到.
2011-12-25 19:08 | 欲三更

只有注册用户登录后才能发表评论。
【推荐】超50万行VC++源码: 大型组态工控、电力仿真CAD与GIS源码库
网站导航: 博客园   IT新闻   BlogJava   知识库   博问   管理