1. 突破思维——不要将思维限定在面向对象方法上

你正在制作一个视频游戏,你正在为游戏中的人物设计一个类继承体系。你的游戏处在农耕时代,人类很容易受伤或者说健康度降低。因此你决定为其提供一个成员函数,healthValue,返回一个整型值来表明一个人物的健康度。因为不同的人物会用不同的方式来计算健康度,将healthValue声明为虚函数看上去是一个比较明显的设计方式:

 class GameCharacter {
public: virtual int healthValue() const; // return character’s health rating; ... // derived classes may redefine this };

healthValue没有被声明为纯虚函数的事实表明了会有一个默认的算法来计算健康度(Item 34)。

这的确是设计这个类的一个明显的方式,在某种意义上来说,这也是它的弱点。因为这个设计是如此明显,你可能不会对其他的设计方法有足够的考虑。为了让你逃离面向对象设计之路的车辙,让我们考虑一些处理这个问题的其它方法。

2. 替换虚函数的四种设计方法

2.1 通过使用非虚接口(non-virtual interface(NVI))的模板方法模式

一个很有意思的学派认为虚函数几乎应该总是private的。这个学派的信徒建议一个更好的设计方法是仍然将healthValue声明成public成员函数但是使其变为非虚函数,然后让它调用一个做实际工作的private虚函数,也就是,doHealthValue:

 class GameCharacter {
public:
int healthValue() const // derived classes do not redefine
{ // this — see Item 36 ... // do “before” stuff — see below int retVal = doHealthValue(); // do the real work ... // do “after” stuff — see below return retVal; }
...
private:
virtual int doHealthValue() const // derived classes may redefine this
{
... // default algorithm for calculating
} // character’s health
};

在上面的代码中(这个条款中剩余的代码也如此),我在类定义中展示了成员函数体。正如Item30中所解释的,将其隐式的声明为inline。我使用这种方式的目的只是使你更加容易的看到接下来会发生什么。我所描述的设计和inline之间是独立的,所以不要认为在类内部定义成员函数是有特定意义的,不是如此。、

客户通过public非虚成员函数调用private虚函数的基本设计方法被称作非虚接口(non-virtual interface(NVI))用法。它是更一般的设计模式——模板方法模式(这个设计模式和C++模板没有任何关系)的一个特定表现。我把非虚函数(healthValue)叫做虚函数的一个包装。

NVI用法的一个优点可以从代码注释中看出来,也就是“do before stuff”和“do after stuff”。这些注释指出了在做真正工作的虚函数之前或之后保证要被执行的代码。这意味着这个包装函数在一个虚函数被调用之前,确保了合适的上下文的创建,在这个函数调用结束后,确保了上下文被清除。举个例子,“before”工作可以包括lock a mutex,记录log,验证类变量或者检查函数先验条件是否满足要求,等等。”after”工作可能包含unlocking a mutex,验证函数的后验条件是否满足要求,重新验证类变量等等。如果你让客户直接调用虚函数,那么没有什么好的方法来做到这些。

你可能意识到NVI用法涉及到在派生类中重新定义private虚函数——重新定义它们不能调用的函数!这在设计上并不矛盾。重新定义一个虚函数指定如何做某事,而调用一个虚函数指定何时做某事。这些概念是相互独立的。NVI用法允许派生类重新定义一个虚函数,这使他们可以对如何实现一个功能进行控制,但是基类保有何时调用这个函数的权利。初次看起来很奇怪,但是C++中的派生类可以重新定义继承而来的private虚函数的规则是非常明智的。

对于NVI用法,虚函数并没有严格限定必须为private的。在一些类的继承体系中,一个虚函数的派生类实现需要能够触发基类中对应的部分,如果使得这种调用是合法的,虚函数就必须为protected,而不是private的。有时一个虚函数甚至必须是public的(例如,多态基类中的析构函数——Item7),但是这种情况下,NVI用法就不能够被使用了。

2.2 通过函数指针实现的策略模式

NVI用法是public虚函数的一个很有意思的替换者,但是从设计的角度来说,有一点弄虚作假的嫌疑。毕竟,我们仍然使用了虚函数计算每个人物的健康度。一个更加引人注目的设计方法是将计算一个人物的健康度同这个人物的类型独立开来——这种计算不必作为这个人物的一部分。举个例子,我们可以使用每个人物的构造函数来为健康计算函数传递一个函数指针,然后在函数指针所指的函数中进行实际的运算:

 class GameCharacter;                                                                            // forward declaration

 // function for the default health calculation algorithm                       

 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&); // health calculation
      int loseHealthSlowly(const GameCharacter&); // funcs with different
      // behavior
      EvilBadGuy ebg1(loseHealthQuickly); // same-type charac
      EvilBadGuy ebg2(loseHealthSlowly); // ters with different
      // health-related
      // behavior
  • 特定人物的健康度计算函数能够在运行时发生变化。举个例子,GameCharacter可能提供一个成员函数,setHealthCalculator,它可以对当前的健康度计算函数进行替换。

此外,健康度计算函数不再是GameCharacter继承体系中的成员函数的事实意味着它不能对正在计算健康度的对象的内部数据进行特殊访问。例如,defaultHealthCalc对EvilBadGuy的非public部分没有访问权。如果一个人物的健康度计算仅仅依赖于人物的public接口,这并没有问题,但是如果精确的健康计算需要非public信息,在任何时候当你用类外的非成员非友元函数或者另外一个类的非友元函数来替换类内部的某个功能时,这都会是一个潜在的问题。这个问题在此条款接下来的部分会一直存在,因为我们将要考虑的所有其他的设计方法都涉及到对GameCharacter继承体系外部函数的使用。

作为通用的方法,非成员函数能够对类的非public部分进行访问的唯一方法就是降低类的封装性。例如,类可以将非成员函数声明为友元函数,或者对隐藏起来的部分提供public访问函数。使用函数指针来替换虚函数的优点是否抵消了可能造成的GameCharacter的封装性的降低是你在每个设计中要需要确定的。

 

2.3 通过tr1::function实现的策略模式

 

一旦你适应了模板以及它们所使用的隐式(implicit)接口(Item 41),基于函数指针的方法看起来就非常死板了。为什么健康计算器必须是一个函数而不能用行为同函数类似的一些东西来代替(例如,一个函数对象)?如果它必须是一个函数,为什么不能是一个成员函数?为什么必须返回一个int类型而不是能够转换成Int的任意类型呢?

如果我们使用tr1::funciton对象来替换函数指针的使用,这些限制就会消失。正如Item54所解释的,这些对象可以持有任何可调用实体(也就是函数指针,函数对象,或者成员函数指针),只要它们的签名同客户所需要的相互兼容。这是我们刚刚看到的设计,这次我们使用tr1::function:

 class GameCharacter; // as before
int defaultHealthCalc(const GameCharacter& gc); // as before
class GameCharacter {
public:
// HealthCalcFunc is any callable entity that can be called with
// anything compatible with a GameCharacter and that returns anything
// compatible with an int; see below for details
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对什么进行了typedef:

 std::tr1::function<int (const GameCharacter&)>

这里我对这个tr1::function实例的“目标签名”(target signature)做了字体加亮。这个目标签名是“函数带了一个const GameCharacter&参数,并且返回一个int类型”。这个tr1::function类型的对象可以持有任何同这个目标签名相兼容的可调用实体。相兼容的意思意味着实体的参数要么是const GameCharacter&,要么可以转换成这个类型,实体的返回值要么是int,要么可以隐式转换成int。

同上一个设计相比我们看到(GameCharacter持有一个函数指针),这个设计基本上是相同的。唯一的不同是GameCharacter现在持有一个tr1::function对象——一个指向函数的泛化指针。这个改动是小的,但是结果是客户现在在指定健康计算函数上有了更大的灵活性:

 short calcHealth(const GameCharacter&);     // health calculation
// function; note
// non-int return type struct HealthCalculator { // class for health int operator()(const GameCharacter&) const // calculation function
{ ... } // objects
};
class GameLevel {
public: float health(const GameCharacter&) const; // health calculation ... // mem function; note }; // non-int return type class EvilBadGuy: public GameCharacter { // as before ... }; class EyeCandyCharacter: public GameCharacter { // another character
... // type; assume same }; // constructor as
// EvilBadGuy EvilBadGuy ebg1(calcHealth); // character using a
// health calculation
// function EyeCandyCharacter ecc1(HealthCalculator()); // character using a
// health calculation
// function object GameLevel currentLevel;
...
EvilBadGuy ebg2( // character using a std::tr1::bind(&GameLevel::health, // health calculation currentLevel, // member function; _1) // see below for details );

你会因为tr1::function的使用而感到吃惊。它一直让我很兴奋。如果你不感到兴奋,可能是因为刚开始接触ebg2的定义,并且想知道对tr1::bind的调用会发生什么。看下面的解释:

我想说为了计算ebg2的健康度,应该使用GameLevel类中的健康成员函数。现在,GameLevel::health是一个带有一个参数的函数(指向GameCharacter的引用),但是它实际上有两个参数,因为它同时还有一个隐含的GameLevel参数——由this所指向的。然而GameCharacters的健康计算函数却只有一个参数:也就是需要计算健康度的GameCharacter。如果我们对ebg2的健康计算使用GameLevel::health,我们必须做一些“适配”工作,以达到只带一个参数(GameCharacter)而不是两个参数(GameCharacter和GameLevel)的目的。在这个例子中,我们想使用GameLevel对象currentLevel来为ebg2计算健康度,所以我们每次使用”bind”到currentLevel的GameLevel::health函数来计算ebg2的健康度。这也是调用tr1::bind所能做到的:它指定了ebg2的健康计算函数应该总是使用currentLevel作为GameLevel对象。

我跳过了tr1::bind调用的很多细节,因为这样的细节不会有很多启发意义,并且会分散我要强调的基本观点:通过使用tr1::function而不是一个函数指针,当计算一个人物的健康度时我们可以允许客户使用任何兼容的可调用实体。这是不是很酷。

2.4 “典型的”策略模式

如果你对设计模式比上面的C++之酷更有兴趣,策略模式的一个更加方便的方法是将健康计算函数声明为一个独立健康计算继承体系中的虚成员函数。最后的继承体系设计会是下面的样子:

如果你对UML符号不熟悉,上面的UML图说明的意思是GameCharacter是继承体系中的root类,EvilBadGuy和EyeCandyCharacter是派生类;HealthCalcFunc是root类,SlowHealthLoser和FastHealthLoser是派生类;每个GameCharacter类型都包含了一个指向HealthCalcFunc继承体系对象的指针。

下面是代码的骨架:

 class GameCharacter;                                                                      // forward declaration

 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的派生类就可以了。

2.5 替换方法总结

这个条款的基本建议是当为你所要解决的问题寻找一个设计方法时,考虑一下虚函数设计的替代方法。下面是我们介绍的设计方法回顾:

  • 使用非虚接口用法(NVI idiom),这是模板方法设计模式(Template Method design pattern),它用public非虚成员函数来包裹更低访问权的虚函数来实现。
  • 用函数指针成员函数来替代虚函数,这是策略设计模式的分解表现形式。
  • 用tr1::function数据成员来代替虚函数,它可以使用同目标签名(signature)相兼容的任何可调用实体。这也是策略设计模式的一种形式。
  • 将一个继承体系中的虚函数替换为另外一个继承体系的虚函数。这是策略设计模式的传统实现方法。

这并不是替换虚函数的所有设计方法,但是应该足够使你确信这些方法是确实存在的。进一步来说,它们的优缺点使你更加清楚你应该考虑使用它们。

为了避免在面向对象设计的路上被卡住,你需要时不时的拉一把。有很多其他的方法。值得我们花时间去研究它们。

7. 总结

    • 虚函数的替换方法包括NVI用法和策略设计模式的其他不同的形式。NVI用法本身是模板方法设计模式的一个例子。
    • 将功能从成员函数移到类外函数的一个缺点是非成员函数不能再访问类的非public成员。
    • Tr1::function对象的行为就像一个泛化函数指针。这种对象支持同给定目标签名相兼容的所有可调用实体。

读书笔记 effective c++ Item 35 考虑虚函数的替代者的更多相关文章

  1. 读书笔记 effective c++ Item 45 使用成员函数模板来接受“所有兼容类型”

    智能指针的行为像是指针,但是没有提供加的功能.例如,Item 13中解释了如何使用标准auto_ptr和tr1::shared_ptr指针在正确的时间自动删除堆上的资源.STL容器中的迭代器基本上都是 ...

  2. 读书笔记 effective c++ Item 39 明智而谨慎的使用private继承

    1. private 继承介绍 Item 32表明C++把public继承当作”is-a”关系来对待.考虑一个继承体系,一个类Student public 继承自类Person,如果一个函数的成功调用 ...

  3. 读书笔记 effective c++ Item 9 绝不要在构造函数或者析构函数中调用虚函数

    关于构造函数的一个违反直觉的行为 我会以重复标题开始:你不应该在构造或者析构的过程中调用虚函数,因为这些调用的结果会和你想的不一样.如果你同时是一个java或者c#程序员,那么请着重注意这个条款,因为 ...

  4. 读书笔记 effective c++ Item 36 永远不要重新定义继承而来的非虚函数

    1. 为什么不要重新定义继承而来的非虚函数——实际论证 假设我告诉你一个类D public继承类B,在类B中定义了一个public成员函数mf.Mf的参数和返回类型并不重要,所以假设它们都是void. ...

  5. 读书笔记 effective c++ Item 7 在多态基类中将析构函数声明为虚析构函数

    1. 继承体系中关于对象释放遇到的问题描述 1.1 手动释放 关于时间记录有很多种方法,因此为不同的计时方法创建一个TimeKeeper基类和一些派生类就再合理不过了: class TimeKeepe ...

  6. 读书笔记 effective c++ Item 54 让你自己熟悉包括TR1在内的标准库

    1. C++0x的历史渊源 C++标准——也就是定义语言的文档和程序库——在1998被批准.在2003年,一个小的“修复bug”版本被发布.然而标准委员会仍然在继续他们的工作,一个“2.0版本”的C+ ...

  7. 读书笔记 effective c++ Item 37 永远不要重新定义继承而来的函数默认参数值

    从一开始就让我们简化这次的讨论.你有两类你能够继承的函数:虚函数和非虚函数.然而,重新定义一个非虚函数总是错误的(Item 36),所以我们可以安全的把这个条款的讨论限定在继承带默认参数值的虚函数上. ...

  8. 读书笔记 effective c++ Item 1 将c++视为一个语言联邦

    Item 1 将c++视为一个语言联邦 如今的c++已经是一个多重泛型变成语言.支持过程化,面向对象,函数式,泛型和元编程的组合.这种强大使得c++无可匹敌,却也带来了一些问题.所有“合适的”规则看上 ...

  9. 读书笔记 effective c++ Item 18 使接口容易被正确使用,不容易被误用

    1. 什么样的接口才是好的接口 C++中充斥着接口:函数接口,类接口,模板接口.每个接口都是客户同你的代码进行交互的一种方法.假设你正在面对的是一些“讲道理”的人员,这些客户尝试把工作做好,他们希望能 ...

随机推荐

  1. ECMAScript 6 笔记(六)

    编程风格 1. 块级作用域 (1)let 取代 var (2)全局常量和线程安全 在let和const之间,建议优先使用const,尤其是在全局环境,不应该设置变量,只应设置常量. const优于le ...

  2. css 样式重置

    html, body, div, span, applet, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, a, abbr, ...

  3. JavaScript嗅探执行神器-sniffer.js,你值得拥有!

    一.热身--先看实战代码 a.js 文件 // 定义Wall及内部方法 ;(function(window, FUNC, undefined){ var name = 'wall'; Wall.say ...

  4. matlab 逻辑数组及其应用

    这几天学习了matlab的逻辑数组功能,总的感觉就有两点: 第一,通过对原来的数组a进行逻辑判断后得到逻辑数组b 第二,逻辑数组进行某种运算符操作又回到原数组类型 第三,利用逻辑数组mask功能 测试 ...

  5. java Socket(TCP)编程小项目

    package 服务器端相关操作; import java.io.Serializable; /* * 创建存储需要传输信息的对象,方便客户端向服务器端传送数据 */ public class Cli ...

  6. select中的文字垂直居中的问题

    select文字在默认和边框高度增加时 是默认居中的.你要是不想居中的话可以用padding值调整.但是右侧的三角不会改变. select在高度减小时.字体是不会垂直居中的.无论你用什么他都不会垂直居 ...

  7. MongoDB学习总结(六) —— 数据库备份和恢复

    我们都知道数据库数据经常备份是多么的重要,MongoDB作为一个数据库系统,自然提供了完善,丰富而且好用的备份与恢复机制. 以下介绍三种数据库备份和恢复的方式 > 数据目录直接拷贝 数据库目录直 ...

  8. NodeJs下的测试框架Mocha

    介绍和代码下载 Mocha在2011年发布,是目前最为流行的javascript框架之一,在本文我们重点介绍它在NodeJs上的使用. 如果你需要下载实例代码,可以通过这个链接 gitClone 或者 ...

  9. repeater绑定泛型list<string>

    菜鸟D重出江湖,依然是菜鸟,囧!言归正传—— 工作中遇到一个repeater绑定的问题,数据源是一个list<string> 集合,然后在界面上使用<%#Eval()%>绑定. ...

  10. 细谈sass和less中的变量及其作用域

    博客原文地址:Claiyre的个人博客 https://claiyre.github.io/ 博客园地址:http://www.cnblogs.com/nuannuan7362/ 如需转载,请在文章开 ...