More Effective C++ 条款31 让函数根据一个以上的对象类型来决定如何虚化
1. 假设要编写一个发生在太空的游戏,其中有飞船(spaceship),太空站(space station)和小行星(ssteroid),使它们继承自一个抽象基类GameObject,整个继承体系像这样:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
class GameObject { ... }; class SpaceShip: public GameObject { ... }; class SpaceStation: public GameObject { ... }; class Asteroid: public GameObject { ... };
不同的对象相撞有不同的规则,处理碰撞的函数声明像这样:
void checkForCollision(GameObject& object1,GameObject& object2);
这就产生了一个问题,要处理object1和object2的碰撞,必须知道这两个引用的动态类型,但C++支持的虚函数只支持single-dispatch(虚函数调用常被称为"message dispacth"——消息分派,函数调用如果根据两个参数而虚化,就被称为double dispatch,multiple dispatch同理).以下就是自行实现C++ double dispatch的方法.
(注:CLOS(Common Lisp Object System)支持multi-method,即可以根据任意多的参数虚化的函数)
2. 虚函数+RTTI(运行时类型识别)
最朴素的方法是使用if-else语句结合RTTI,像这样:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
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); } }
这种方法表面上实现简单,但实际上很不便于维护:如果要加入新的类型,那么继承体系中每一个类的collide函数可能都需要添加处理新型碰撞的代码.而且使用RTTI实现double-dispatching,也将根据参数动态类型采取不同行为的负担加在了程序员身上.
3. 只使用虚函数
这种方法的基本思想是通过对虚函数collide的重载,将double dispatch以两个single dispatch实现,以SpaceShip为例:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
class SpaceShip; // 前置声明 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); ... };
其中,virtual void collide(GameObject& otherObject)的实现像这样:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
void SpaceShip::collide(GameObject& otherObject) { otherObject.collide(*this); }
看起来有些像递归调用,其实并非这样,在该函数内部,*this实际上已经对应该函数的动态类型,因此otherObject调用的将不再是collide(GameObject& otherObject),而是
collide(SpaceShip& otherObject),collide(SpaceStation& otherObject),collide(Asteroid& otherobject),SpaceShip::collide的其它重载版本像这样:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
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一样的缺点:一旦有新的class假如,代码就必须修改——含入一个新的虚函数,这涉及到类定义得修改,然而修改类定义会引起包含这些类定义的文件的重新编译,在很多情况下成本相当大.
4.
1). 自行仿真虚函数表格
由编译器使用虚函数表实现动态绑定的策略启发,可以自行仿真一个虚函数表格,它保存类名和对应碰撞处理函数指针的"键-值"对,并进行类名到碰撞处理函数的映射,达到double dispatch的目的,修改SpaceShip的定义如下:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
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; }
以上定义中,SpaceShip不再重载collide,且碰撞处理函数hitSpaceShip,htiSpaceStation,hitAeteroid的参数都为GameObject&,这是由于不论用数组还是map容器还是其他方法,要把所有的碰撞处理函数指针放到同一个表内,就要求每个碰撞处理函数指针的类型相同,因此参数必须都为GameObject&,由于参数相同,因此也就不符合重载的要求.
可以再为SpaceShip定义一个lookup函数,进行类到相应碰撞处理函数的映射,lookup的声明像这样:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
class SpaceShip: public GameObject { private: typedef void (SpaceShip::*HitFunctionPtr)(GameObject&); //碰撞处理函数指针 static HitFunctionPtr lookup(const GameObject& whatWeHit); ... };
collide函数要使用lookup,像这样:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
void SpaceShip::collide(GameObject& otherObject) { HitFunctionPtr hfp =lookup(otherObject); if (hfp) { (this->*hfp)(otherObject); // call it } //处理未知碰撞 else { throw CollisionWithUnknownObject(otherObject); } }
现在就只剩下虚函数表和lookup的实现问题,虚函数表可以用STL内的map容器实现,它还要保证在第一次调用lookup函数时就已被初始化并在程序结束后被释放,比较好的选择是把它声明为lookup内的static对象,虚函数表和lookup的大体实现像这样:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
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; //查找 HitMap::iterator mapEntry=collisionMap.find(typeid(whatWeHit).name()); //未知碰撞 if (mapEntry == collisionMap.end()) return 0; return (*mapEntry).second; }
2). 将自行仿真的虚函数表格(Virtual Function Table)初始化
现在面临collisionMap的初始化问题,由于collisionMap是函数内的static对象,因此像以下这样的初始化是不恰当的:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
SpaceShip::HitFunctionPtr SpaceShip::lookup(const GameObject& whatWeHit) { static HitMap collisionMap; collisionMap["SpaceShip"] = &hitSpaceShip; collisionMap["SpaceStation"] = &hitSpaceStation; collisionMap["Asteroid"] = &hitAsteroid; ... }
它会造成每次调用lookup时都将hitSpaceShip,hitSpaceStation,hitAsteriod插入collisionMap内,要使得member function的插入动作只执行一次,可以将初始化动作提取到一个static 成员函数内,使用该static成员函数将collisionMap初始化,像这样:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
class SpaceShip: public GameObject { private: static HitMap initializeCollisionMap(); ... }; SpaceShip::HitFunctionPtr SpaceShip::lookup(const GameObject& whatWeHit) { static HitMap collisionMap = initializeCollisionMap(); ... }
initializeCollisionMap使用按值传递,这意味着要付出构造和析构临时HitMap对象的成本,可以考虑使用标准库auto_ptr智能指针:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
class SpaceShip: public GameObject { private: static HitMap * initializeCollisionMap(); ... }; SpaceShip::HitFunctionPtrSpaceShip::lookup(const GameObject& whatWeHit) { static auto_ptr<HitMap> collisionMap(initializeCollisionMap()); ... }
initializeCollisionMap的实现像这样:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
SpaceShip::HitMap * SpaceShip::initializeCollisionMap() { HitMap *phm = new HitMap; //正是为了以下操作,之前collide才放弃重载以使得hitSpaceShip,hitSpaceStation,hitAsteriod可以有相同参数 (*phm)["SpaceShip"] = &hitSpaceShip; (*phm)["SpaceStation"] = &hitSpaceStation; (*phm)["Asteroid"] = &hitAsteroid; return phm; }
由于hitSpaceShip,hitSpaceStation,hitAsteriod的参数是GameObject&,因此它们需要在函数内部运用dynamic_cast:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
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; }
3). 使用"非成员(non-member)函数"的碰撞处理函数
到此为止,仿真虚函数表的方法仍然不能解决2,3提出的策略共同的问题:当有新的class加入时,继承体系的每个类都需要添加处理新型碰撞的代码.这是因为此前的策略都是将处理碰撞的任务交由碰撞的某一方来执行,仿真虚函数表策略也不例外——每个class内含一个仿真的虚函数表,内含的指针也都指向成员函数.将碰撞处理函数设为non-member,就可以使得class定义式不包含碰撞处理函数,当需要添加碰撞处理函数时也就不需要修改class定义,自行设计的processCollision函数实现如下:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
#include "SpaceShip.h" #include "SpaceStation.h" #include "Asteroid.h" //使用匿名命名空间使得名称只对本单元可见,作用等同于将名称声明为static namespace { void shipAsteroid(GameObject& spaceShip,GameObject& asteroid); void shipStation(GameObject& spaceShip,GameObject& spaceStation); void asteroidStation(GameObject& asteroid,GameObject& spaceStation); ... //对称碰撞 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); } ... 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); } //命名空间结束 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); }
以上实现和之前有细微差异:HitFunctionPtr是一个typedef,表示指向non-member function的指针;exception class CollisionWithUnknownObject被重命名为UnKnownCollision并使用接受两个GameObject对象的构造函数;lookup必须接获两个GameObject名称并执行double dispatch.为了使得map的键含有两份信息,需要使用标准库pair类模板.makeStringPair,initializeCollisionMap,lookup的实现像这样:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
namespace { pair<string,string> makeStringPair(const char *s1,const char *s2){ return pair<string,string>(s1, s2); } } namespace { HitMap * initializeCollisionMap() { HitMap *phm = new HitMap; (*phm)[makeStringPair("SpaceShip","Asteroid")] =&shipAsteroid; (*phm)[makeStringPair("SpaceShip", "SpaceStation")] =&shipStation; ... return phm; } } namespace { HitFunctionPtr lookup(const string& class1,const string& class2) { static auto_ptr<HitMap> collisionMap(initializeCollisionMap()); HitMap::iterator mapEntry=collisionMap->find(make_pair(class1, class2)); if (mapEntry == collisionMap->end()) return 0; return (*mapEntry).second; } }
由于makeStringPair,initializationCollisionMap,lookup都被声明于一个匿名namespace内,因此它们也必须实现于相同的namespace内,使得链接器能正确将其定义和声明关联.
通过将碰撞处理函数从类中分离,实现了即使新的GameObject被添加,原有的class也不需要重新编译,只需要在initializeCollisionMap中增加对应的键-值对,并在processCollision所在的匿名命名空间中申明一个新的碰撞处理函数即可.
4). "继承"+"自行仿真的虚函数表格"
自行仿真虚函数表格,并使用typeid通过类名匹配的方式进行查找的方法也有其缺点——它没有使用多态而是使用死板的名称字符串匹配来进行查找函数,结果是继承体系中的派生关系无法对函数匹配产生作用.假设继承体系中增加了商业飞船(CommercialShip)和军事飞船(MilitaryShip),令它们继承自抽象基类SpaceShip,整个继承体系像这样:
其中CommercialShip和MilitaryShip的碰撞行为完全相同,因此如果MilitrayShip和Asteroid碰撞,可能企图通过调用:
void shipAsteroid(GameObject& spaceShip,GameObject& asteroid);
来处理它们的碰撞,但这是错误的,因为lookup通过类名字符串匹配的方式查找对应函数,即使CommercialShip和MilitaryShip可被视为SpaceShip,lookup也无法得知.在此情况下,可能还是要回到3所提出的"双虚函数调用"机制.
5). 将自行仿真的虚函数表格初始化(再度讨论)
截至目前,整个设计是静态的:虚函数表在程序开始就生成,此后不再发生改变.但整个设计还有发挥空间:增加对仿真虚函数表做新增,删除,修改动作的功能.由于对虚函数表可以做多种操作,因此可以考虑定义一个CollisionMap类用于管理仿真虚函数表,并提供新增,删除,修改虚函数表的接口,CollisionMap的设计像这样:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
class CollisionMap { public: typedef void (*HitFunctionPtr)(GameObject&, GameObject&); void addEntry(const string& type1,const string& type2,HitFunctionPtr collisionFunction,bool symmetric = true);//symmetric用于标记是否产生两个顺序不同的碰撞处理函数,默认为true void removeEntry(const string& type1,const string& type2); HitFunctionPtr lookup(const string& type1,const string& type2); //产生唯一的CollisionMap static CollisionMap& theCollisionMap(); private: //构造函数声明为private从而限制CollisionMap只能产生一个 CollisionMap(); CollisionMap(const CollisionMap&); };
对CollisionMap的使用像这样:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
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); ...
目前为止仅剩的问题就是确保在碰撞发生之前对应的函数条目就被加入到了map之中,办法之一是令GameObject的subclass的constructor进行检查,这需要性能的开销,另一个方法就是使用一个RigisterCollisionFunction class进行管理:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
class RegisterCollisionFunction { public: RegisterCollisionFunction(const string& type1,const string& type2,CollisionMap::HitFunctionPtr collisionFunction,bool symmetric = true) { CollisionMap::theCollisionMap().addEntry(type1, type2,collisionFunction,symmetric); } };
用户使用RigisterCollisionFunction进行"注册"就像这样:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
RegisterCollisionFunction cf1(typeid(SpaceShip).name(), typeid(Asteroid).name(),&shipAsteroid); RegisterCollisionFunction cf2(typeid(SpaceShip).name(), typeid(SpaceStation).name(),&shipStation); RegisterCollisionFunction cf3(typeid(Asteroid), typeid(SpaceStation).name(),&asteroidStation); ... int main(int argc, char * argv[]) { ... }
由于这些全局对象在main函数调用之前就产生,因此它们的constructor所注册的条目也会在main被调用之前加入map,如果有新的derived class如Satellite和相应的碰撞函数产生:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
class Satellite: public GameObject { ... }; void satelliteShip(GameObject& satellite,GameObject& spaceShip); void satelliteAsteroid(GameObject& satellite,GameObject& asteroid);
函数可以使用类似方法加入到map之中而不需要改变原有代码:
RegisterCollisionFunction cf4(typeid(Satellite).name(),typeid(SpaceShip).name(),&satelliteShip);
RegisterCollisionFunction cf5(typeid(Satellite).name(),typeid(Asteroid).name(),&satelliteAsteroid);
注:C++标准并没有规定type_info::name的返回值,因此不同编译器会有不同实现,如有的编译器的name函数对于SpaceShip返回"class SpaceShip",因此需要用到类名的地方不能采取硬编码,应该统统使用typeid(classname).name(),否则在某些编译器下会因为classname!=typeid(classname).name()产生错误.