读书笔记 effective c++ Item 35 考虑虚函数的替代者
1. 突破思维——不要将思维限定在面向对象方法上
你正在制作一个视频游戏,你正在为游戏中的人物设计一个类继承体系。你的游戏处在农耕时代,人类很容易受伤或者说健康度降低。因此你决定为其提供一个成员函数,healthValue,返回一个整型值来表明一个人物的健康度。因为不同的人物会用不同的方式来计算健康度,将healthValue声明为虚函数看上去是一个比较明显的设计方式:
1 class GameCharacter { 2 public: 3 4 virtual int healthValue() const; // return character’s health rating; 5 6 ... // derived classes may redefine this 7 8 };
healthValue没有被声明为纯虚函数的事实表明了会有一个默认的算法来计算健康度(Item 34)。
这的确是设计这个类的一个明显的方式,在某种意义上来说,这也是它的弱点。因为这个设计是如此明显,你可能不会对其他的设计方法有足够的考虑。为了让你逃离面向对象设计之路的车辙,让我们考虑一些处理这个问题的其它方法。
2. 替换虚函数的四种设计方法
2.1 通过使用非虚接口(non-virtual interface(NVI))的模板方法模式
一个很有意思的学派认为虚函数几乎应该总是private的。这个学派的信徒建议一个更好的设计方法是仍然将healthValue声明成public成员函数但是使其变为非虚函数,然后让它调用一个做实际工作的private虚函数,也就是,doHealthValue:
1 class GameCharacter { 2 public: 3 int healthValue() const // derived classes do not redefine 4 { // this — see Item 36 5 6 ... // do “before” stuff — see below 7 8 int retVal = doHealthValue(); // do the real work 9 10 ... // do “after” stuff — see below 11 12 return retVal; 13 14 15 } 16 ... 17 private: 18 virtual int doHealthValue() const // derived classes may redefine this 19 { 20 ... // default algorithm for calculating 21 } // character’s health 22 };
在上面的代码中(这个条款中剩余的代码也如此),我在类定义中展示了成员函数体。正如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虚函数的一个很有意思的替换者,但是从设计的角度来说,有一点弄虚作假的嫌疑。毕竟,我们仍然使用了虚函数计算每个人物的健康度。一个更加引人注目的设计方法是将计算一个人物的健康度同这个人物的类型独立开来——这种计算不必作为这个人物的一部分。举个例子,我们可以使用每个人物的构造函数来为健康计算函数传递一个函数指针,然后在函数指针所指的函数中进行实际的运算:
1 class GameCharacter; // forward declaration 2 3 // function for the default health calculation algorithm 4 5 int defaultHealthCalc(const GameCharacter& gc); 6 7 class GameCharacter { 8 9 public: 10 11 typedef int (*HealthCalcFunc)(const GameCharacter&); 12 13 explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc) 14 15 : healthFunc(hcf ) 16 17 {} 18 19 int healthValue() const 20 21 { return healthFunc(*this); } 22 23 ... 24 25 private: 26 27 HealthCalcFunc healthFunc; 28 29 };
这个方法是另外一种普通设计模式的简单应用,也就是策略模式。同在GameCharacter继承体系中基于虚函数的方法进行对比,它能提供了一些有意思的灵活性:
- 相同人物类型的不同实例能够拥有不同的健康度计算函数。举个例子:
-
1 class EvilBadGuy: public GameCharacter { 2 3 public: 4 5 explicit EvilBadGuy(HealthCalcFunc hcf = defaultHealthCalc) 6 7 : GameCharacter(hcf ) 8 9 { ... } 10 11 ... 12 13 }; 14 15 16 int loseHealthQuickly(const GameCharacter&); // health calculation 17 int loseHealthSlowly(const GameCharacter&); // funcs with different 18 // behavior 19 EvilBadGuy ebg1(loseHealthQuickly); // same-type charac 20 EvilBadGuy ebg2(loseHealthSlowly); // ters with different 21 // health-related 22 // 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:
1 class GameCharacter; // as before 2 int defaultHealthCalc(const GameCharacter& gc); // as before 3 class GameCharacter { 4 public: 5 // HealthCalcFunc is any callable entity that can be called with 6 // anything compatible with a GameCharacter and that returns anything 7 // compatible with an int; see below for details 8 typedef std::tr1::function<int (const GameCharacter&)> HealthCalcFunc; 9 10 explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc) 11 : healthFunc(hcf ) 12 {} 13 int healthValue() const 14 { return healthFunc(*this); } 15 ... 16 private: 17 HealthCalcFunc healthFunc; 18 };
正如你所看到的,HealthCalcFunc是对一个实例化tr1::function的typedef。这意味着它的行为像一个泛化函数指针类型。看看HealthCalcFunc对什么进行了typedef:
1 std::tr1::function<int (const GameCharacter&)>
这里我对这个tr1::function实例的“目标签名”(target signature)做了字体加亮。这个目标签名是“函数带了一个const GameCharacter&参数,并且返回一个int类型”。这个tr1::function类型的对象可以持有任何同这个目标签名相兼容的可调用实体。相兼容的意思意味着实体的参数要么是const GameCharacter&,要么可以转换成这个类型,实体的返回值要么是int,要么可以隐式转换成int。
同上一个设计相比我们看到(GameCharacter持有一个函数指针),这个设计基本上是相同的。唯一的不同是GameCharacter现在持有一个tr1::function对象——一个指向函数的泛化指针。这个改动是小的,但是结果是客户现在在指定健康计算函数上有了更大的灵活性:
1 short calcHealth(const GameCharacter&); // health calculation 2 // function; note 3 // non-int return type 4 5 struct HealthCalculator { // class for health 6 7 8 int operator()(const GameCharacter&) const // calculation function 9 { ... } // objects 10 }; 11 class GameLevel { 12 public: 13 14 float health(const GameCharacter&) const; // health calculation 15 16 ... // mem function; note 17 18 }; // non-int return type 19 20 21 22 class EvilBadGuy: public GameCharacter { // as before 23 24 ... 25 26 }; 27 28 class EyeCandyCharacter: public GameCharacter { // another character 29 ... // type; assume same 30 31 }; // constructor as 32 // EvilBadGuy 33 34 EvilBadGuy ebg1(calcHealth); // character using a 35 // health calculation 36 // function 37 38 EyeCandyCharacter ecc1(HealthCalculator()); // character using a 39 // health calculation 40 // function object 41 42 GameLevel currentLevel; 43 ... 44 EvilBadGuy ebg2( // character using a 45 46 47 std::tr1::bind(&GameLevel::health, // health calculation 48 49 currentLevel, // member function; 50 51 _1) // see below for details 52 53 54 );
你会因为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继承体系对象的指针。
下面是代码的骨架:
1 class GameCharacter; // forward declaration 2 3 class HealthCalcFunc { 4 5 public: 6 7 ... 8 9 virtual int calc(const GameCharacter& gc) const 10 11 { ... } 12 13 ... 14 15 }; 16 17 HealthCalcFunc defaultHealthCalc; 18 19 class GameCharacter { 20 21 public: 22 23 explicit GameCharacter(HealthCalcFunc *phcf = &defaultHealthCalc) 24 25 : pHealthCalc(phcf) 26 27 {} 28 29 int healthValue() const 30 31 { return pHealthCalc->calc(*this); } 32 33 ... 34 35 private: 36 37 HealthCalcFunc *pHealthCalc; 38 39 };
很容易识别出来这是人们所熟知的”标准”策略模式的实现,它也为现存的健康计算算法的调整提供了可能性,你只需要添加一个HealthCalcFunc的派生类就可以了。
2.5 替换方法总结
这个条款的基本建议是当为你所要解决的问题寻找一个设计方法时,考虑一下虚函数设计的替代方法。下面是我们介绍的设计方法回顾:
- 使用非虚接口用法(NVI idiom),这是模板方法设计模式(Template Method design pattern),它用public非虚成员函数来包裹更低访问权的虚函数来实现。
- 用函数指针成员函数来替代虚函数,这是策略设计模式的分解表现形式。
- 用tr1::function数据成员来代替虚函数,它可以使用同目标签名(signature)相兼容的任何可调用实体。这也是策略设计模式的一种形式。
- 将一个继承体系中的虚函数替换为另外一个继承体系的虚函数。这是策略设计模式的传统实现方法。
这并不是替换虚函数的所有设计方法,但是应该足够使你确信这些方法是确实存在的。进一步来说,它们的优缺点使你更加清楚你应该考虑使用它们。
为了避免在面向对象设计的路上被卡住,你需要时不时的拉一把。有很多其他的方法。值得我们花时间去研究它们。
7. 总结
- 虚函数的替换方法包括NVI用法和策略设计模式的其他不同的形式。NVI用法本身是模板方法设计模式的一个例子。
- 将功能从成员函数移到类外函数的一个缺点是非成员函数不能再访问类的非public成员。
- Tr1::function对象的行为就像一个泛化函数指针。这种对象支持同给定目标签名相兼容的所有可调用实体。
作者:
HarlanC
博客地址:
http://www.cnblogs.com/harlanc/
个人博客:
http://www.harlancn.me/
本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出,
原文链接