|
|
More Effective C++ Item M31:让函数根据一个以上的对象来决定怎么虚拟 |
|
|
作者:未知 来源:月光软件站 加入时间:2005-2-28 月光软件站 |
1.3 Item M31:让函数根据一个以上的对象来决定怎么虚拟 有时,借用一下Jacqueline Susann的话:一次是不够的。例如你有着一个光辉形象、崇高声望、丰厚薪水的程序员工作,在Redmond,Wshington的一个著名软件公司--当然,我说的就是任天堂。为了得到经理的注意,你可能决定编写一个video game。游戏的背景是发生在太空,有宇宙飞船、太空站和小行星。 在你构造的世界中的宇宙飞船、太空站和小行星,它们可能会互相碰撞。假设其规则是: * 如果飞船和空间站以低速接触,飞船将泊入空间站。否则,它们将有正比于相对速度的损坏。 * 如果飞船与飞船,或空间站与空间站相互碰撞,参与者均有正比于相对速度的损坏。 * 如果小行星与飞船或空间站碰撞,小行星毁灭。如果是小行星体积较大,飞船或空间站也毁坏。 * 如果两个小行星碰撞,将碎裂为更小的小行星,并向各个方向溅射。 这好象是个无聊的游戏,但用作我们的例子已经足够了,考虑一下怎么组织C++代码以处理物体间的碰撞。 我们从分析飞船、太空站和小行星的共同特性开始。至少,它们都在运动,所以有一个速度来描述这个运动。基于这一点,自然而然地设计一个基类,而它们可以从此继承。实际上,这样的类几乎总是抽象基类,并且,如果你留心我在Item M33中的警告,基类总是抽象的。所以,继承体系是这样的: GameObject | | | / | \ / | \ / | \ / | \ SpaceShip SpaceStation Asteroid class GameObject { ... }; class SpaceShip: public GameObject { ... }; class SpaceStation: public GameObject { ... }; class Asteroid: public GameObject { ... }; 现在,假设你开始进入程序内部,写代码来检测和处理物体间的碰撞。你会提出这样一个函数: void checkForCollision(GameObject& object1, GameObject& object2) { if (theyJustCollided(object1, object2)) { processCollision(object1, object2); } else { ... } } 问题来了。当你调用processCollision()时,你知道object1和object2正好相撞,并且你知道发生的结果将取决于object1和object2的真实类型,但你并不知道其真实类型;你所知道的就只有它们是GameObject对象。如果碰撞的处理过程只取决于object1的动态类型,你可以将processCollision()设为虚函数,并调用object1.processColliion(object2)。如果只取决于object2的动态类型,也可以同样处理。但现在,取决于两个对象的动态类型。虚函数体系只能作用在一个对象身上,它不足以解决问题。 你需要的是一种作用在多个对象上的虚函数。C++没有提供这样的函数。可是,你必须要实现上面的要求。现在怎么办呢? 一种办法是扔掉C++,换种其它语言。比如,你可以改用CLOS(Common Lisp Object System)。CLOS支持绝大部分面向对象的函数调用体系中只能想象的东西:multi-method。multi-method是在任意多的参数上虚拟的函数,并且CLOS更进一步的提供了明确控制“被重载的multi-method将如何调用”的特性。 让我们假设,你必须用C++实现,所以必须找到一个方法来解决这个被称为“二重调度(double dispatch)”的问题。(这个名字来自于object-oriented programming community,在那里虚函数调用的术语是“message dispatch”,而基两个参数的虚调用是通过“double dispatch”实现的,推而广之,在多个参数上的虚函数叫“multiple dispatch”。)有几个方法可以考虑。但没有哪个是没有缺点的,这不该奇怪。C++没有直接提供“double dispatch”,所以你必须自己完成编译器在实现虚函数时所做的工作(见Item M24)。如果容易的话,我们可能就什么都自己做了,并用C语言编程了。我们没有,而且我们也不能够,所以系紧你的安全带,有一个坎途了。 * 用虚函数加RTTI 虚函数实现了一个单一调度,这只是我们所需要的一半;编译器为我们实现虚函数,所以我们在GameObject中申明一个虚函数collide。这个函数被派生类以通常的形式重载: class GameObject { public: virtual void collide(GameObject& otherObject) = 0; ... }; class SpaceShip: public GameObject { public: virtual void collide(GameObject& otherObject); ... }; 我在这里只写了派生类SpaceShip的情况,SpaceStation和Asteroid的形式完全一样的。 实现二重调度的最常见方法就是和虚函数体系格格不入的if...then...else链。在这种刺眼的体系下,我们首先是发现otherObject的真实类型,然后测试所有的可能: // if we collide with an object of unknown type, we // throw an exception of this type: class CollisionWithUnknownObject { public: CollisionWithUnknownObject(GameObject& whatWeHit); ... }; void SpaceShip::collide(GameObject& otherObject) { const type_info& objectType = typeid(otherObject); if (objectType == typeid(SpaceShip)) { SpaceShip& ss = static_cast<SpaceShip&>(otherObject); process a SpaceShip-SpaceShip collision; } else if (objectType == typeid(SpaceStation)) { SpaceStation& ss = static_cast<SpaceStation&>(otherObject); process a SpaceShip-SpaceStation collision; } else if (objectType == typeid(Asteroid)) { Asteroid& a = static_cast<Asteroid&>(otherObject); process a SpaceShip-Asteroid collision; } else { throw CollisionWithUnknownObject(otherObject); } } 注意,我们需要检测的只是一个对象的类型。另一个是*this,它的类型由虚函数体系判断。我们现在处于SpaceShip的成员函数中,所以*this肯定是一个SpaceShip对象,因此我们只需找出otherObject的类型。 这儿的代码一点都不复杂。它很容易实现。也很容易让它工作。RTTI只有一点令人不安:它只是看起来无害。实际的危险来自于最后一个else语句,在这儿抛了一个异常。 我们的代价是几乎放弃了封装,因为每个collide函数都必须知道所以其它同胞类中的版本。尤其是,如果增加一个新的类时,我们必须更新每一个基于RTTI的if...then...else链以处理这个新的类型。即使只是忘了一处,程序都将有一个bug,而且它还不显眼。编译器也没办法帮助我们检查这种疏忽,因为它们根本不知道我们在做什么(参见Item E39)。 这种类型相关的程序在C语言中已经很有一段历史了,而我们也知道,这样的程序本质上是没有可维护性的。扩充这样的程序最终是不可想象的。这是引入虚函数的主意原因:将产生和维护类型相关的函数调用的担子由程序员转给编译器。当我们用RTTI实现二重调度时,我们正退回到过去的苦日子中。 这种过时的技巧在C语言中导致了错误,它们C++语言也仍然导致错误。认识到我们自己的脆弱,我们在collide函数中加上了最后的那个else语句,以处理如果遇到一个未知类型。这种情况原则上说是不可能发生的,但在我们决定使用RTTI时又怎么知道呢?有很多种方法来处理这种未曾预料的相互作用,但没有一个令人非常满意。在这个例子里,我们选择了抛出一个异常,但无法想象调用者对这个错误的处理能够比我们好多少,因为我们遇到了一个我们不知道其存在的东西。 * 只使用虚函数 其实有一个方法可以将用RTTI实现二重调度固有风险降到最低的,不过在此之前让我们看一下怎么只用虚函数来解决二重调度问题。这个方法和RTTI方法有这同样的基本构架。collide函数被申明为虚,并被所有派生类重定义,此外,它还被每个类重载,每个重载处理一个派生类型: class SpaceShip; // forward declarations class SpaceStation; class Asteroid; class GameObject { public: virtual void collide(GameObject& otherObject) = 0; virtual void collide(SpaceShip& otherObject) = 0; virtual void collide(SpaceStation& otherObject) = 0; virtual void collide(Asteroid& otherobject) = 0; ... }; class SpaceShip: public GameObject { public: virtual void collide(GameObject& otherObject); virtual void collide(SpaceShip& otherObject); virtual void collide(SpaceStation& otherObject); virtual void collide(Asteroid& otherobject); ... }; 其基本原理就是用两个单一调度实现二重调度,也就是说有两个单独的虚函数调用:第一次决定第一个对象的动态类型,第二次决定第二个对象动态类型。和前面一样,第一次虚函数调用带的是GameObject类型的参数。其实现是令人吃惊地简单: void SpaceShip::collide(GameObject& otherObject) { otherObject.collide(*this); } 粗一看,它象依据参数的顺序进行循环调用,也就是开始的otherObject变成了调用成员函数的对象,而*this成了它的参数。但再仔细看一下啦,它不是循环调用。你知道的,编译器根据参数的静态类型决定调那一组函数中的哪一个。在这儿,有四个不同的collide函数可以被调用,但根据*this的静态类型来选中其中一个。现在的静态类型是什么?因为是在SpaceShip的成员函数中,所以*this肯定是SpaceShip类型。调用的将是接受SpaceShip参数的collide函数,而不是带GameOjbect类型参数的collide函数。 所有的collide函数都是虚函数,所以在SpaceShip::collide中调用的是otherObject真实类型中实现的collide版本。在这个版本中,两个对象的真实类型都是知道的,左边的是*this(实现这个函数的类的类型),右边对象的真实类型是SpaceShip(申明的形参类型)。 看了SpaceShip类中的其它collide的实现,就更清楚了: void SpaceShip::collide(SpaceShip& otherObject) { process a SpaceShip-SpaceShip collision; } void SpaceShip::collide(SpaceStation& otherObject) { process a SpaceShip-SpaceStation collision; } void SpaceShip::collide(Asteroid& otherObject) { process a SpaceShip-Asteroid collision; } 你看到了,一点都不混乱,也不麻烦,没有RTTI,也不需要为意料之外的对象类型抛异常。不会有意料之外的类型的,这就是使用虚函数的好处。实际上,如果没有那个致命缺陷的话,它就是实现二重调度问题的完美解决方案。 这个缺陷是,和前面看到的RTTI方法一样:每个类都必须知道它的同胞类。当增加新类时,所有的代码都必须更新。不过,更新方法和前面不一样。确实,没有if...then...else需要修改,但通常是更差:每个类都需要增加一个新的虚函数。就本例而言,如果你决定增加一个新类Satellite(继承于GameObjcet),你必须为每个现存类增加一个collide函数。 修改现存类经常是你做不到的。比如,你不是在写整个游戏,只是在完成程序框架下的一个支撑库,你可能无权修改GameObject类或从其经常的框架类。此时,增加一个新的成员函数(虚的或不虚的),都是不可能的。也就说,你理论上有操作需要被修改的类的权限,但实际上没有。打个比方,你受雇于Nitendo,使用一个包含GameObject和其它需要的类的运行库进行编程。当然不是只有你一个人在使用这个库,全公司都将震动于每次你决定在你的代码中增加一个新类型时,所有的程序都需要重新编译。实际中,广被使用的库极少被修改,因为重新编译所有用了这个库的程序的代价太大了。(参见Item M34,以了解怎么设计将编译依赖度降到最低的运行库。) 总结一下就是:如果你需要实现二重调度,最好的办法是修改设计以取消这个需要。如果做不到的话,虚函数的方法比RTTI的方法安全,但它限制了你的程序的可控制性(取决于你是否有权修改头文件)。另一方面,RTTI的方法不需要重编译,但通常会导致代码无法维护。自己做抉择啦! * 模拟虚函数表 有一个方法来增加选择。你可以回顾Item M24,编译器通常创建一个函数指针数组(vtbl)来实现虚函数,并在虚函数被调用时在这个数组中进行下标索引。使用vtbl,编译器避免了使用if...then...else链,并能在所有调用虚函数的地方生成同样的代码:确定正确的vtbl下标,然后调用vtbl这个位置上存储的指针所指向的函数。 没理由说你不能这么做。如果这么做了,不但使得你基于RTTI的代码更具效率(下标索引加函数指针的反引用通常比if...then...else高效,产生的代码也少),同样也将RTTI的使用范围限定在一处:你初始化函数指针数组的地方。提醒一下,看下面的内容前最好做一下深呼吸( I should mention that the meek may inherit the earth, but the meek of heart may wish to take a few deep breaths before reading what follows)。 对GameObjcet继承体系中的函数作一些修改: class GameObject { public: virtual void collide(GameObject& otherObject) = 0; ... }; class SpaceShip: public GameObject { public: virtual void collide(GameObject& otherObject); virtual void hitSpaceShip(SpaceShip& otherObject); virtual void hitSpaceStation(SpaceStation& otherObject); virtual void hitAsteroid(Asteroid& otherobject); ... }; void SpaceShip::hitSpaceShip(SpaceShip& otherObject) { process a SpaceShip-SpaceShip collision; } void SpaceShip::hitSpaceStation(SpaceStation& otherObject) { process a SpaceShip-SpaceStation collision; } void SpaceShip::hitAsteroid(Asteroid& otherObject) { process a SpaceShip-Asteroid collision; } 和开始时使用的基于RTTI的方法相似,GameObjcet类只有一个处理碰撞的函数,它实现必须的二重调度的第一重。和后来的基于虚函数的方法相似,每种碰撞都由一个独立的函数处理,不过不同的是,这次,这些函数有着不同的名字,而不是都叫collide。放弃重载是有原因的,你很快就要见到的。注意,上面的设计中,有了所有其它需要的东西,除了没有实现Spaceship::collide(这是不同的碰撞函数被调用的地方)。和以前一样,实现了SpaceShip类,SpaceStation类和Asteroid类也就出来了。 在SpaceShip::collide中,我们需要一个方法来映射参数otherObject的动态类型到一个成员函数指针(指向一个适当的碰撞处理函数)。一个简单的方法是创建一个映射表,给定的类名对应恰当的成员函数指针。直接使用一个这样的映射表来实现collide是可行的,但如果增加一个中间函数lookup时,将更好理解。lookup函数接受一个GameObject参数,返回相应的成员函数指针。 这是lookup的申明: class SpaceShip: public GameObject { private: typedef void (SpaceShip::*HitFunctionPtr)(GameObject&); static HitFunctionPtr lookup(const GameObject& whatWeHit); ... }; 函数指针的语法不怎么优美,而成员函数指针就更差了,所以我们作了一个类型重定义。 既然有了lookup,collide的实现如下: void SpaceShip::collide(GameObject& otherObject) { HitFunctionPtr hfp = lookup(otherObject); // find the function to call if (hfp) { // if a function was found (this->*hfp)(otherObject); // call it } else { throw CollisionWithUnknownObject(otherObject); } } 如果我们能保持映射表和GameObject的继承层次的同步,lookup就总能找到传入对象对应的有效函数指针。人终究只是人,就算再仔细,错误也会钻入软件。这就是我们为什么检查lookup的返回值并在其失败时抛异常的原因。 剩下的就是实现lookup了。提供了一个对象类型到成员函数指针的映射表后,lookup自己很容易实现,但创建、初始化和析构这个映射表是个有意思的问题。 这样的数组应该在它被使用前构造和初始化,并在不再被需要时析构。我们可以使用new和delete来手工创建和析构它,但这时怎么保证在初始化以前不被使用呢?更好的解决方案是让编译器自动完成,在lookup中把这个数组申明为静态就可以了。这样,它在第一次调用lookup前构造和初始化,在main退出后的某个时刻被自动析构(见Item E47)。 而且,我们可以使用标准模板库提供的map模板来实现映射表,因为这正是map的功能: class SpaceShip: public GameObject { private: typedef void (SpaceShip::*HitFunctionPtr)(GameObject&); typedef map<string, HitFunctionPtr> HitMap; ... }; SpaceShip::HitFunctionPtr SpaceShip::lookup(const GameObject& whatWeHit) { static HitMap collisionMap; ... } 此处,collisionMap就是我们的映射表。它映射类名(一个string对象)到一个Spaceship的成员函数指针。因为map<string, HitFunctionPtr>太拗口了,我们用了一个类型重定义。(开个玩笑,试一下不用HitMap和HitFunctionPtr这两个类型重定义来写collisionMap的申明。大部分人不会做第二次的。) 给出了collisionMap后,lookup的实现有些虎头蛇尾。因为搜索工作是map类直接支持的操作,并且我们在typeid()的返回结果上总可以调用的(可移植的)一个成员函数是name()(可以确定(注11),它返回对象的动态类型的名字)。于是,实现lookup,仅仅是根据形参的动态类型在collisionMap中找到它的对应项、 lookup的代码很简单,但如果不熟悉标准模板库的话(再次参见Item M35),就不会怎么简单了。别担心,程序中的注释解释了每一步在做什么。 SpaceShip::HitFunctionPtr SpaceShip::lookup(const GameObject& whatWeHit) { static HitMap collisionMap; // we'll see how to // initialize this below // look up the collision-processing function for the type // of whatWeHit. The value returned is a pointer-like // object called an "iterator" (see Item 35). HitMap::iterator mapEntry= collisionMap.find(typeid(whatWeHit).name()); // mapEntry == collisionMap.end() if the lookup failed; // this is standard map behavior. Again, see Item 35. if (mapEntry == collisionMap.end()) return 0; // If we get here, the search succeeded. mapEntry // points to a complete map entry, which is a // (string, HitFunctionPtr) pair. We want only the // second part of the pair, so that's what we return. return (*mapEntry).second; } 最后一句是return (*mapEntry).second而不是习惯上的mapEntry->second以满足STL的奇怪行为。具体原因见Item M18。 * 初始化模拟虚函数表 现在来看collisionMap的初始化。我们很想这么做: // An incorrect implementation SpaceShip::HitFunctionPtr SpaceShip::lookup(const GameObject& whatWeHit) { static HitMap collisionMap; collisionMap["SpaceShip"] = &hitSpaceShip; collisionMap["SpaceStation"] = &hitSpaceStation; collisionMap["Asteroid"] = &hitAsteroid; ... } 但,这将在每次调用lookup时都将成员函数指针加入了collisionMap,这是不必要的开销。而且它不会编译通过,不过这是将要讨论的第二个问题。 我们需要的是只将成员函数指针加入collisionMap一次,在collisionMap构造时。这很容易完成;我们只需写一个私有的静态成员函数initializeCollisionMap来构造和初始化我们的映射表,然后用其返回值来初始化collisionMap: class SpaceShip: public GameObject { private: static HitMap initializeCollisionMap(); ... }; SpaceShip::HitFunctionPtr SpaceShip::lookup(const GameObject& whatWeHit) { static HitMap collisionMap = initializeCollisionMap(); ... } 不过这意味着我们要付出拷贝赋值的代价(见Item M19和M20)。我们不想这么做。如果initializeCollisionMap()返回指针的话,我们就不需要付出这个代价,但这样就需要担心指针指向的map对象是否能在恰当的时候被析构了。 幸好,有个两全的方法。我们可以将collisionMap改为一个灵巧指针(见Item M28)它将在自己被析构时delete所指向的对象。实际上,标准C++运行库提供的模板auto_ptr,正是这样的一个灵巧指针(见Item M9)。通过将lookup中的collisionMap申明为static的auto_ptr,我们可以让initializeCollisionMap返回一个指向初始化了的map对象的指针了,不用再担心资源泄漏了;collisionMap指向的map对象将在collisinMap自己被析构时自动析构。于是: class SpaceShip: public GameObject { private: static HitMap * initializeCollisionMap(); ... }; SpaceShip::HitFunctionPtr SpaceShip::lookup(const GameObject& whatWeHit) { static auto_ptr<HitMap> collisionMap(initializeCollisionMap()); ... } 实现initializeCollisionMap的最清晰的方法看起来是这样的: SpaceShip::HitMap * SpaceShip::initializeCollisionMap() { HitMap *phm = new HitMap; (*phm)["SpaceShip"] = &hitSpaceShip; (*phm)["SpaceStation"] = &hitSpaceStation; (*phm)["Asteroid"] = &hitAsteroid; return phm; } 但和我在前面指出的一样,这不能编译通过。因为HitMap被申明为包容一堆指向成员函数的指针,它们全带同样的参数类型,也就是GameObject。但,hitSpaceShip带的是一个spaceShip参数,hitSpaceStation带的是SpaceStation,hitAsteroid带的是Asteroid。虽然SpaceShip、SpaceStation和Asteroid能被隐式的转换为GameObject,但对带这些参数类型的函数指针可没有这样的转换关系。 为了摆平你的编译器,你可能想使用reinterpret_casts(见Item M2),而它在函数指针的类型转换中通常是被舍弃的: // A bad idea... SpaceShip::HitMap * SpaceShip::initializeCollisionMap() { HitMap *phm = new HitMap; (*phm)["SpaceShip"] = reinterpret_cast<HitFunctionPtr>(&hitSpaceShip); (*phm)["SpaceStation"] = reinterpret_cast<HitFunctionPtr>(&hitSpaceStation); (*phm)["Asteroid"] = reinterpret_cast<HitFunctionPtr>(&hitAsteroid); return phm; } 这样可以编译通过,但是个坏主意。它必然伴随一些你绝不该做的事:对你的编译器撒谎。告诉编译器,hitSpaceShip、hitSpaceStation和hitAsteroid期望一个GameObject类型的参数,而事实不是这样的。hitSpaceShip期望一个SpaceShip,hitSpaceStation期望一个SpaceStation,hitAsteroid期望一个Asteroid。这些cast说的是其它东西,它们撒谎了。 不只是违背了原则,这儿还有危险。编译器不喜欢被撒谎,当它们发现被欺骗后,它们经常会找出一个报复的方法。这此处,它们很可能通过产生错误的代码来报复你,当你通过*phm调用函数,而相应的GameObject的派生类是多重继承的或有虚基类时。如果SpaceStation。SpaceShip或Asteroid除了GameObject外还有其它基类,你可能会发现当你调用你在这儿搜索到的碰撞处理函数时,其行为非常的粗暴。 再看一下Item M24中描述的A-B-C-D的继承体系以及D的对象的内存布局。 A B Data Members / \ vptr / \ Pointer to virtual base clss B C C Data Members \ / vptr \ / Pointer to virtual base class D D Data Members A Data Members vptr D中的四个类的部分,其地址都不同。这很重要,因为虽然指针和引用的行为并不相同(见Item M1),编译器产生的代码中通常是通过指针来实现引用的。于是,传引用通常是通过传指针来实现的。当一个有多个基类的对象(如D的对象)传引用时,最重要的就是编译器要传递正确的地址--匹配于被调函数申明的形参类型的那个。 但如果你对你的编译器撒谎说你的函数期望一个GameObject而实际上要的是一个SpaceShip或一个SpaceStation时,发生什么?编译器将传给你错误的地址,导致运行期错误。而且将非常难以定位错误的原因。有很多很好的理由说明为什么不建议使用类型转换,这是其中之一。 OK,不使用类型转换。但函数指针类型不匹配的还没解决只有一个办法:将所有的函数都改为接受GameObject类型: class GameObject { // this is unchanged public: virtual void collide(GameObject& otherObject) = 0; ... }; class SpaceShip: public GameObject { public: virtual void collide(GameObject& otherObject); // these functions now all take a GameObject parameter virtual void hitSpaceShip(GameObject& spaceShip); virtual void hitSpaceStation(GameObject& spaceStation); virtual void hitAsteroid(GameObject& asteroid); ... }; 我们基于虚函数解决二重调度问题的方法中,重载了叫collide的函数。现在,我们理解为什么这儿没有照抄而使用了一组成员函数指针。所有的碰撞处理函数都有着相同的参数类型,所以必要给它们以不同的名字。 现在,我们可以以我们一直期望的方式来写initializeCollisionMap函数了: SpaceShip::HitMap * SpaceShip::initializeCollisionMap() { HitMap *phm = new HitMap; (*phm)["SpaceShip"] = &hitSpaceShip; (*phm)["SpaceStation"] = &hitSpaceStation; (*phm)["Asteroid"] = &hitAsteroid; return phm; } 很遗憾,我们的碰撞函数现在得到的是一个更基本的CameObject参数而不是期望中的派生类类型。要想得到我们所期望的东西,必须在每个碰撞函数开始处采用dynamic_cast(见Item M2): void SpaceShip::hitSpaceShip(GameObject& spaceShip) { SpaceShip& otherShip= dynamic_cast<SpaceShip&>(spaceShip); process a SpaceShip-SpaceShip collision; } void SpaceShip::hitSpaceStation(GameObject& spaceStation) { SpaceStation& station= dynamic_cast<SpaceStation&>(spaceStation); process a SpaceShip-SpaceStation collision; } void SpaceShip::hitAsteroid(GameObject& asteroid) { Asteroid& theAsteroid = dynamic_cast<Asteroid&>(asteroid); process a SpaceShip-Asteroid collision; } 如果转换失败,dynamic_cast会抛出一个bad_cast异常。当然,它们从不会失败,因为碰撞函数被调用时不会带一个错误的参数类型的。只是,谨慎一些更好。 * 使用非成员的碰撞处理函数 我们现在知道了怎么构造一个类似vtbl的映射表以实现二重调度的第二部分,并且我们也知道了怎么将映射表的实现细节封装在lookup函数中。因为这张表包含的是指向成员函数的指针,所以在增加新的GameObject类型时仍然需要修改类的定义,这还是意味着所有人都必须重新编译,即使他们根本不关心这个新的类型。例如,如果增加了一个Satellite类型,我们不得不在SpaceShip类中增加一个处理SpaceShip和Satellite对象间碰撞的函数。所有SpaceShip的用户不得不重新编译,即使他们根本不在乎Satellite对象的存在。这个问题将导致我们否决只使用虚函数来实现二重调度,解决方法是只需做小小的修改。 如果映射表中包含的指针指向非成员函数,那么就没有重编译问题了。而且,转到非成员的碰撞处理函数将让我们发现一个一直被忽略的设计上的问题,就是,应该在哪个类里处理不同类型的对象间的碰撞?在前面的设计中,如果对象1和对象2碰撞,而正巧对象1是processCollision的左边的参数,碰撞将在对象1的类中处理;如果对象2正巧是左边的参数,碰撞就在对象2的类中处理。这个有特别的含义吗?是不是这样更好些:类型A和类型B的对象间的碰撞应该既不在A中也不在B中处理,而在两者之外的某个中立的地方处理? 如果将碰撞处理函数从类里移出来,我们在给用户提供类定义的头文件时,不用带上任何碰撞处理函数。我们可以将实现碰撞处理函数的文件组织成这样: #include "SpaceShip.h" #include "SpaceStation.h" #include "Asteroid.h" namespace { // unnamed namespace - see below // primary collision-processing functions void shipAsteroid(GameObject& spaceShip, GameObject& asteroid); void shipStation(GameObject& spaceShip, GameObject& spaceStation); void asteroidStation(GameObject& asteroid, GameObject& spaceStation); ... // secondary collision-processing functions that just // implement symmetry: swap the parameters and call a // primary function void asteroidShip(GameObject& asteroid, GameObject& spaceShip) { shipAsteroid(spaceShip, asteroid); } void stationShip(GameObject& spaceStation, GameObject& spaceShip) { shipStation(spaceShip, spaceStation); } void stationAsteroid(GameObject& spaceStation, GameObject& asteroid) { asteroidStation(asteroid, spaceStation); } ... // see below for a description of these types/functions typedef void (*HitFunctionPtr)(GameObject&, GameObject&); typedef map< pair<string,string>, HitFunctionPtr > HitMap; pair<string,string> makeStringPair(const char *s1, const char *s2); HitMap * initializeCollisionMap(); HitFunctionPtr lookup(const string& class1, const string& class2); } // end namespace void processCollision(GameObject& object1, GameObject& object2) { HitFunctionPtr phf = lookup(typeid(object1).name(), typeid(object2).name()); if (phf) phf(object1, object2); else throw UnknownCollision(object1, object2); } 注意,用了无名的命名空间来包含实现碰撞处理函数所需要的函数。无名命名空间中的东西是当前编译单元(其实就是当前文件)私有的--很象被申明为文件范围内static的函数一样。有了命名空间后,文件范围内的static已经不赞成使用了,你应该尽快让自己习惯使用无名的命名空间(只要编译器支持)。 理论上,这个实现和使用成员函数的版本是相同的,只有几个轻微区别。第一,HitFunctionPtr现在是一个指向非成员函数的指针类型的重定义。第二,意料之外的类CollisionWithUnknownObject被改叫UnknownCollision,第三,其构造函数需要两个对象作参数而不再是一个了。这也意味着我们的映射需要三个消息了:两个类型名,一个HitFunctionPtr。 标准的map类被定义为只处理两个信息。我们可以通过使用标准的pair模板来解决这个问题,pair可以让我们将两个类型名捆绑为一个对象。借助makeStringPair的帮助,initializeCollisionMap的实现如下: // we use this function to create pair<string,string> // objects from two char* literals. It's used in // initializeCollisionMap below. Note how this function // enables the return value optimization (see Item 20). namespace { // unnamed namespace again - see below pair<string,string> makeStringPair(const char *s1, const char *s2) { return pair<string,string>(s1, s2); } } // end namespace namespace { // still the unnamed namespace - see below HitMap * initializeCollisionMap() { HitMap *phm = new HitMap; (*phm)[makeStringPair("SpaceShip","Asteroid")] = &shipAsteroid; (*phm)[makeStringPair("SpaceShip", "SpaceStation")] = &shipStation; ... return phm; } } // end namespace lookup函数也必须被修改以处理pair<string,string>对象,并将它作为映射表的第一部分: namespace { // I explain this below - trust me HitFunctionPtr lookup(const string& class1, const string& class2) { static auto_ptr<HitMap> collisionMap(initializeCollisionMap()); // see below for a description of make_pair HitMap::iterator mapEntry= collisionMap->find(make_pair(class1, class2)); if (mapEntry == collisionMap->end()) return 0; return (*mapEntry).second; } } // end namespace 这和我们以前写的代码几乎一样。唯一的实质性不同就是这个使用了make_pair函数的语句: HitMap::iterator mapEntry= collisionMap->find(make_pair(class1, class2)); make_pair只是标准运行库中的一个转换函数(模板)(见Item E49和Item M35),它使得我们避免了在构造pair对象时需要申明类型的麻烦。我们本来要这样写的: HitMap::iterator mapEntry= collisionMap->find(pair<string,string>(class1, class2)); 这样写需要多敲好多字,而且为pair申明类型是多余的(它们就是class1和class2的类型),所以make_pair的形式更常见。 因为makeStringPair、initializeCollisionMap和lookup都是申明在无名的命名空间中的,它们的实现也必须在同一命名空间中。这就是为什么这些函数的实现在上面被写在了一个无名命名空间中的原因(必须和它们的申明在同一编译单元中):这样链接器才能正确地将它们的定义(或说实现)与它们的前置申明关联起来。 我们最终达到了我们的目的。如果增加了新的GaemObject的子类,现存类不需要重新编译(除非它们用到了新类)。没有了RTTI的混乱和if...then...else的不可维护。增加一个新类只需要做明确定义了的局部修改:在initializeCollisionMap中增加一个或多个新的映射关系,在processCollision所在的无名的命名空间中申明一个新的碰撞处理函数。我们花了很大的力气才走到这一步,但至少努力是值得的。是吗?是吗? 也许吧。 * 继承与模拟虚函数表 我们还有最后一个问题需要处理。(如果,此时你奇怪老有最后一个问题要处理,你将认识到设计一个虚函数体系的难度。)我们所做的一切将工作得很好,只要我们不需要在调用碰撞处理函数时进行向基类映射的类型转换。假设我们开发的这个游戏某些时刻必须区分贸易飞船和军事飞船,我们将对继承体系作如下修改,根据Item M33的原则,将实体类CommercialShip和MilitaryShip从抽象类SpaceShip继承。 GameObject | | | / | \ / | \ / | \ / | \ SpaceShip SpaceStation Asteroid / \ / \ Commercial Ship Military Ship 假设贸易飞船和军事飞船在碰撞过程中的行为是一致的。于是,我们期望可以使用相同的碰撞处理函数(在增加这两类以前就有的那个)。尤其是,在一个MilitaryShip对象和一个Asteroid对象碰撞时,我们期望调用 void shipAsteroid(GameObject& spaceShip, GameObject& asteroid); 它不会被调用的。实际上,抛了一个UnknownCollision的异常。因为lookup在根据类型名“MilitaryShip”和“Asteroid”在collisionMap中查找函数时没有找到。虽然MilitaryShip可以被转换为一个SpaceShip,但lookup却不知道这一点。 而且,没有没有一个简单的办法来告诉它。如果你需要实现二重调度,并且需要这儿的向上类型映射,你只能采用我们前面讨论的二次虚函数调用的方法(同时也意味着增加新类的时候,所有人都必须重新编译)。 * 初始化模拟虚函数表(再次讨论) 这就是关于二重调度的所有要说的,但是,用如此悲观的条款来结束是令人很不愉快的。因此,让我们用概述初始化collisionMap的两种方法来结束。 按目前情况来看,我们的设计完全是静态的。每次我们注册一个碰撞处理函数,我们就不得不永远留着它。如果我们想在游戏运行过程中增加、删除或修改碰撞处理函数,将怎么样?不提供。 但是是可以做到的。我们可以将映射表放入一个类,并由它提供动态修改映射关系的成员函数。例如: class CollisionMap { public: typedef void (*HitFunctionPtr)(GameObject&, GameObject&); void addEntry(const string& type1, const string& type2, HitFunctionPtr collisionFunction, bool symmetric = true); // see below void removeEntry(const string& type1, const string& type2); HitFunctionPtr lookup(const string& type1, const string& type2); // this function returns a reference to the one and only // map - see Item 26 static CollisionMap& theCollisionMap(); private: // these functions are private to prevent the creation // of multiple maps - see Item 26 CollisionMap(); CollisionMap(const CollisionMap&); }; 这个类允许我们在映射表中进行增加和删除操作,以及根据类型名对查找相应的碰撞处理函数。它也使用了Item E26中讲的技巧来限制CollisionMap对象的个数为1,因为我们的系统中只有一个映射表。(更复杂的游戏需要多张映射表是可以想象到的。)最后,它允许我们简化在映射表中增加对称性的碰撞(也就是说,类型T1的对象撞击T2的对象和T2的对象撞击T1的对象,其效果是相同的。)的过程,它自动增加对称的映射关系,只要addEntry被调用时可选参数symmetric 被设为true。 借助于CollisionMap类,每个想增加映射关系的用户可以直接这么做: void shipAsteroid(GameObject& spaceShip, GameObject& asteroid); CollisionMap::theCollisionMap().addEntry("SpaceShip", "Asteroid", &shipAsteroid); void shipStation(GameObject& spaceShip, GameObject& spaceStation); CollisionMap::theCollisionMap().addEntry("SpaceShip", "SpaceStation", &shipStation); void asteroidStation(GameObject& asteroid, GameObject& spaceStation); CollisionMap::theCollisionMap().addEntry("Asteroid", "SpaceStation", &asteroidStation); ... 必须确保在发生碰撞前就将映射关系加入了映射表。一个方法是让GameObject的子类在构造函数中进行确认。这将导致在运行期的一个小小的性能开销。另外一个方法是创建一个RegisterCollisionFunction 类: class RegisterCollisionFunction { public: RegisterCollisionFunction( const string& type1, const string& type2, CollisionMap::HitFunctionPtr collisionFunction, bool symmetric = true) { CollisionMap::theCollisionMap().addEntry(type1, type2, collisionFunction, symmetric); } }; 用户于是可以使用此类型的一个全局对象来自动地注册他们所需要的函数: RegisterCollisionFunction cf1("SpaceShip", "Asteroid", &shipAsteroid); RegisterCollisionFunction cf2("SpaceShip", "SpaceStation", &shipStation); RegisterCollisionFunction cf3("Asteroid", "SpaceStation", &asteroidStation); ... int main(int argc, char * argv[]) { ... } 因为这些全局对象在main被调用前就构造了,它们在构造函数中注册的函数也在main被调用前就加入映射表了。如果以后增加了一个派生类 class Satellite: public GameObject { ... }; 以及一个或多个碰撞处理函数 void satelliteShip(GameObject& satellite, GameObject& spaceShip); void satelliteAsteroid(GameObject& satellite, GameObject& asteroid); 这些新函数可以用同样方法加入映射表而不需要修改现存代码: RegisterCollisionFunction cf4("Satellite", "SpaceShip", &satelliteShip); RegisterCollisionFunction cf5("Satellite", "Asteroid", &satelliteAsteroid); 这不会改变实现多重调度没有完美解决方法的事实。但它使得容易提供数据给基于map的实现,如果我们认为这种实现最接近我们的需要的话。 * 注11: 要指出的是,不是那么可完全确定的。C++标准并没有规定type_info::name的返回值,不同的实现,其行为会有区别。(例如,对于类Spaceship,type_info::name的一个实现返回“class SpaceShip”。)更好的设计是通过它所关联的type_info对象的地址了鉴别一个类,因为每个类关联的type_info对象肯定是不同的。HitMap于是应该被申明为map<cont type_info *, HitFunctionPtr>。

|
|
相关文章:相关软件: |
|