分类: C/C++
2009-08-26 15:34:15
l 初始化模拟虚函数表
现在来看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
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
(*phm)["SpaceStation"] =
reinterpret_cast
(*phm)["Asteroid"] =
reinterpret_cast
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的对象的内存布局。
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
process a SpaceShip-SpaceShip collision;
}
void SpaceShip::hitSpaceStation(GameObject& spaceStation)
{
SpaceStation& station=
dynamic_cast
process a SpaceShip-SpaceStation collision;
}
void SpaceShip::hitAsteroid(GameObject& asteroid)
{
Asteroid& theAsteroid =
dynamic_cast
process a SpaceShip-Asteroid collision;
}
如果转换失败,dynamic_cast会抛出一个bad_cast异常。当然,它们从不会失败,因为碰撞函数被调用时不会带一个错误的参数类型的。只是,谨慎一些更好。
l 使用非成员的碰撞处理函数
我们现在知道了怎么构造一个类似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
pair
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
// 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
const char *s2)
{ return pair
} // 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
namespace { // I explain this below — trust me
HitFunctionPtr lookup(const string& class1,
const string& class2)
{
static auto_ptr
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
这样写需要多敲好多字,而且为pair申明类型是多余的(它们就是class1和class2的类型),所以make_pair的形式更常见。
因为makeStringPair、initializeCollisionMap和lookup都是申明在无名的命名空间中的,它们的实现也必须在同一命名空间中。这就是为什么这些函数的实现在上面被写在了一个无名命名空间中的原因(必须和它们的申明在同一编译单元中):这样链接器才能正确地将它们的定义(或说实现)与它们的前置申明关联起来。
我们最终达到了我们的目的。如果增加了新的GaemObject的子类,现存类不需要重新编译(除非它们用到了新类)。没有了RTTI的混乱和if...then...else的不可维护。增加一个新类只需要做明确定义了的局部修改:在initializeCollisionMap中增加一个或多个新的映射关系,在processCollision所在的无名的命名空间中申明一个新的碰撞处理函数。我们花了很大的力气才走到这一步,但至少努力是值得的。是吗?是吗?
也许吧。
l 继承与模拟虚函数表
我们还有最后一个问题需要处理。(如果,此时你奇怪老有最后一个问题要处理,你将认识到设计一个虚函数体系的难度。)我们所做的一切将工作得很好,只要我们不需要在调用碰撞处理函数时进行向基类映射的类型转换。假设我们开发的这个游戏某些时刻必须区分贸易飞船和军事飞船,我们将对继承体系作如下修改,根据Item M33的原则,将实体类CommercialShip和MilitaryShip从抽象类SpaceShip继承。
假设贸易飞船和军事飞船在碰撞过程中的行为是一致的。于是,我们期望可以使用相同的碰撞处理函数(在增加这两类以前就有的那个)。尤其是,在一个MilitaryShip对象和一个Asteroid对象碰撞时,我们期望调用
void shipAsteroid(GameObject& spaceShip,
GameObject& asteroid);
它不会被调用的。实际上,抛了一个UnknownCollision的异常。因为lookup在根据类型名“MilitaryShip”和“Asteroid”在collisionMap中查找函数时没有找到。虽然MilitaryShip可以被转换为一个SpaceShip,但lookup却不知道这一点。