30:Proxy classes 代理类
在C++中使用变量作为数组大小是违法的,也不允许在堆上分配多维数组:
int data[dim1][dim2]; int *data = new int[dim1][dim2]; // error!
为了弥补上述缺点,可以设计一个二维数组类:
template<class T> class Array2D { public: Array2D(int dim1, int dim2); ... }; Array2D<int> data(10, 20); Array2D<float> *data = new Array2D<float>(10, 20); void processInput(int dim1, int dim2) { Array2D<int> data(dim1, dim2); ... }
因为没有operator[][]这样的操作符,为了能以data[3][6]的形式访问该二维数组,所以,这里需要使用proxy类:
template<class T> class Array2D { public: Array2D(int dim1, int dim2){ m_dim1 = dim1; m_dim2 = dim2; data = new T[dim1*dim2]; } ~Array2D(){delete data;} class Array1D { public: Array1D(T *data, int begin){ innerdata = data+begin; } T& operator[](int index); const T& operator[](int index) const; private: T *innerdata; }; Array1D operator[](int index); const Array1D operator[](int index) const; private: T *data; int m_dim1, m_dim2; }; template<class T> typename Array2D<T>::Array1D Array2D<T>::operator[](int index){ return Array2D::Array1D(data, index * m_dim2); } template<class T> const typename Array2D<T>::Array1D Array2D<T>::operator[](int index) const{ return Array2D::Array1D(data, index * m_dim2); } template<class T> T& Array2D<T>::Array1D::operator[](int index){ return innerdata[index]; } template<class T> const T& Array2D<T>::Array1D::operator[](int index) const{ return innerdata[index]; } int dim1 = 3, dim2 = 4; Array2D<int> array(dim1, dim2); array[0][1] = 1; array[0][2] = 2; array[1][0] = 10; array[2][3] = 23;
每个Array1D对象表示一个一维数组,而 Array2D的用户不需要知道Array1D的存在。注意上述模板内定义嵌套类的函数定义写法。
利用proxy类,还可以实现区分operator[]读写操作。上一章讲述的引用计数的String类,其operator[]可以用来读字符,也可以用来写字符。读操作是所谓的右值引用;写操作是左值引用。虽然编译器无法告诉我们operator[]到底是用来读还是写,但是只要将所要处理的动作放缓,直到operator[]的返回结果被使用为止。可以修改operator[],使其返回字符串中字符的proxy,就可以看见该proxy如何被使用:
class String { public: String(const char *value = ""); class CharProxy { public: CharProxy(String& str, int index); CharProxy& operator=(const CharProxy& rhs); // 左值引用 CharProxy& operator=(char c); operator char() const; // 右值引用 private: String& theString; int charIndex; }; const CharProxy operator[](int index) const; CharProxy operator[](int index); friend class CharProxy; private: struct StringValue: public RCObject { char *data; StringValue(const char *initValue); StringValue(const StringValue& rhs); void init(const char *initValue); ~StringValue(); }; RCPtr<StringValue> value; };
有了上面的代码,考虑这条语句:cout<<s1[5],s1[5]产生一个CharProxy对象,但是该对象没有定义output操作符,所以编译器需要找一个合适的隐式类型转换:将CharProxy隐式转换为char,然后进行输出即可。
而对于s1[5]=’x’,s1[5]返回CharProxy,调用CharProxy::operator=函数即可,这时,被赋值的CharProxy对象被用来作为一个左值。同样的,s1[5]=s1[8]也是一样。
String的operator[]代码如下:
const String::CharProxy String::operator[](int index) const{ return CharProxy(const_cast<String&>(*this), index); } String::CharProxy String::operator[](int index){ return CharProxy(*this, index); }
注意const operator[]返回一个const CharProxy,由于CharProxy::operator=不是一个 const成员函数,const CharProxy因此不能被用来做为赋值的目标物。因此不论是const operator[] 所传回的 proxy,或是该proxy所代表的字符,都不能被用来做为左值。这正是我们希望 const operator[]所具备的行为。
另外,当const operator[] 返回CharProxy对象时,需要对其进行const_cast转换,去除const属性,这是因为CharProxy的构造函数只接受non-const String。
operator[]返回的每一个CharProxy都会记住它所附属的字符串,以及它所在的索引位置,以便将来进行读取或赋值:
String::CharProxy::CharProxy(String& str, int index) : theString(str), charIndex(index) {}
CharProxy的char转换函数如下:
String::CharProxy::operator char() const{ return theString.value->data[charIndex]; }
该函数以by value的方式返回一个字符,由于C++ 限制只能在右值情境下使用这样的返回值,所以这个转换函数只能用于右值合法之处。
接下来是CharProxy的operator=操作符:
String::CharProxy& String::CharProxy::operator=(const CharProxy& rhs){ if (theString.value->isShared()) { theString.value = new StringValue(theString.value->data); } theString.value->data[charIndex] = rhs.theString.value->data[rhs.charIndex]; return *this; } String::CharProxy& String::CharProxy::operator=(char c){ if (theString.value->isShared()) { theString.value = new StringValue(theString.value->data); } theString.value->data[charIndex] = c; return *this; }
与上一章中的non-const String::operator[]比较,会发现它们非常类似。在上一章中悲观地假设所有non-const operator[]的调用都是为了写操作,此处们把完成“写操作”的代码转移到CharProxy的operator=中,避免了non-const operator[]在右值情境下也需付出写操作的昂贵代价。
尽管我们希望proxy对象能够无间隙的代表它所表示的对象,但是这种想法很难达成,因为除了赋值之外,对象还有很多其他操作,比如:char *p = &s1[5],这条语句有两个报错,s1[5]返回一个临时CharProxy对象,既不能取临时对象的地址,也无法将CharProxy*赋值给char *,这种情况下,需要重载operator&:
const char * String::CharProxy::operator&() const{ return &(theString.value->data[charIndex]); } char * String::CharProxy::operator&(){ if (theString.value->isShared()) { theString.value = new StringValue(theString.value->data); } theString.value->markUnshareable(); return &(theString.value->data[charIndex]); }
non-const版本需要返回一个指针,指向一个可能被修改的字符,因此,需要将StringValue成为其专属副本。
这还仅仅是取地址,原始对象可能还支持+=,++等操作,这些需要左值的操作想要成功,必须一一为proxy类定义相应的操作符(CharProxy转换为char的操作符,返回的是右值,因此无法使用这些操作符)。
另外,如果proxy代表的对象具有成员函数,则为了能使proxy看起来像原始对象一样能调用相应的成员函数,也需要在proxy中定义相应的函数。
另外,proxy虽然能隐式转换为它所代表的对象,但是这种隐式转换只能发生一次,如果原始对象A能隐式转换为B,则在需要参数B的函数调用中可以使用A,但不能使用A的proxy。
31:让函数根据一个以上的对象类型来决定如何虚化
假设你在写一个游戏,游戏中需要处理宇宙飞船、太空站、小行星等的碰撞问题,不同物体之间的碰撞需要做不同的处理。于是有下面的代码:
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的具体类型做不同的处理。这就是所谓的double-dispatching问题。在面向对象程序设计社区,人们把一个“虚函数调用动作”称为一个"message dispatch"。因此某个函数调用如果根据两个参数而虚化,自然而然地就被称为 "double dispatch"。更广泛的情况则被称为multiple dispatch。
最一般化的double-dispatching实现,就是利用虚函数和RTTI:
class GameObject { public: virtual void collide(GameObject& otherObject) = 0; ... }; class SpaceShip: public GameObject { public: virtual void collide(GameObject& otherObject); ... }; 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函数必须知道其每一个兄弟类(所有继承自GameObject的那些类)。如果有新的类型加入了这个游戏,就必须修改程序中每一个可能遭遇新对象的RTTI-based if-then-else链,这会造成程序难以维护。
还可以只用虚函数就解决这个问题:
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); ... }; void SpaceShip::collide(GameObject& otherObject){ otherObject.collide(*this); } 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; }
比较有意思的是接收GameObject&的那个collide函数,当对象的静态类型是GameObject时调用该函数,函数内部,根据参数的动态类型决定调用该类型的哪个虚函数,而函数参数为*this,也就是个SpaceShip类型。
这种做法的缺点跟上面使用RTTI的类似,每个类都必须知道其兄弟类,一旦有新的类加入,代码就必须修改。而这里的修改又与RTTI解法不同,RTTI解法中,只需要修改每个类的实现部分,也就是collide中的if-else-then,而此处需要修改类的定义,增加一个新的虚函数。然而你并不一定有机会或者权利去修改类的定义式。
简而言之,如果你需要在你的程序中实现double-dispatching,最好的方向就是修改设计,消除此项需求。如果不能,那么,虚拟函式法比RTTI 法安全一些,但是如果你对头文件的权力不够,这种作法会束缚你的系统扩充性。至于RTTI法,虽不需要重新编译,却往往导至软件难以维护。你总是得付出代价,才能获得机会。
之前说过,虚函数是通过vtbl实现的。在这个double- dispatching场景中,我们可以自己实现一个vtbl,这比RTTI-based会更有效率,而且可以将RTTI的使用集中在vtbl初始化的地方。
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); ... private: typedef void (SpaceShip::*HitFunctionPtr)(GameObject&); static HitFunctionPtr lookup(const GameObject& whatWeHit); }; void SpaceShip::collide(GameObject& otherObject){ HitFunctionPtr hfp = lookup(otherObject); if (hfp) { (this->*hfp)(otherObject); } else { throw CollisionWithUnknownObject(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; }
这里没有将collide重载,而是用函数名区分不同对象之间的碰撞,原因稍后就会解释。
在SpaceShip::collide函数内,调用lookup函数,根据GameObject参数查找合适的成员函数指针,一旦找到则调用即可,找不到的时候抛出异常。
现在考虑lookup函数的实现,在lookup中,需要一个关系型数组,lookup查找该数组,根据对象类型得到某个成员函数指针。这个关系型数组应该在使用之前就产生并初始化了,并在不再需要时进行销毁,可以使用new和delete来产生和销毁数组,但那样容易发生错误。为了保证数组会在使用之前进行初始化,比较好的解决办法就是让关系型数组成为 lookup内的static对象,只有在lookup第一次调用时它才会被产生,而在main结束之后它才会被摧毁:
class SpaceShip: public GameObject { private: 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; }
这里根据typeid(whatWeHit).name()查找collisionMap,但是标准并未明确规定type_info::name的返回值,不同的编译器可能会有不同的行为,比如对于SpaceShip类,有的编译器可能就会返回"class SpaceShip"。一个更好的设计是,以type_info对象的地址来识别class,因为它绝对是独一无二的,此时HitMap类型应该是map<const type_info*, HitFunctionPtr>。
现在,唯一的问题就是collisionMap的初始化问题了,可以将其初始化放进一个名为initializeCollisionMap的private static成员函数中:
SpaceShip::HitFunctionPtr SpaceShip::lookup(const GameObject& whatWeHit) { static HitMap collisionMap = initializeCollisionMap(); ... }
但是这种方法会有map的复制成本,因此,这里最高改为指针,为了不操心delete指针的问题,可以使用智能指针:
SpaceShip::HitFunctionPtr SpaceShip::lookup(const GameObject& whatWeHit){ static unique_ptr<HitMap> collisionMap(initializeCollisionMap()); ... } SpaceShip::HitMap * SpaceShip::initializeCollisionMap(){ HitMap *phm = new HitMap; (*phm)["SpaceShip"] = &hitSpaceShip; (*phm)["SpaceStation"] = &hitSpaceStation; (*phm)["Asteroid"] = &hitAsteroid; return phm; }
这个初始化代码还有最后一个问题,map中的value类型是:typedef void (SpaceShip::*HitFunctionPtr)(GameObject&),这种函数的参数为GameObject,但是hitSpaceShip、hitSpaceStation、hitAsteroid它们的参数分别为 SpaceShip、SpaceStation、Asteroid。虽然它们都可以隐式转换为GameObject,但是函数指针之前却不存在这种转换。
你可能觉得下面的方法可以解决这个问题:
// 一个坏主意… 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; }
这是个坏主意,因为它欺骗了编译器,一旦满足某种条件,编译器就会对这种欺骗进行报复:如果SpaceStation、SpaceShip或Asteroid有GameObject之外的其他基类,你可能会发现,你在collide中对碰撞处理函式的呼叫,会导至相当粗鲁的行为。比如下面是一个具有菱形继承的类D:
D对象内的四个“基类成份”,每一个都有不同地址。虽然指针和引用的行为不同,但编译器通常是以指针来实现引用的。因此,当对象拥有多个基类,并以引用的方式传递给函数时,编译器是否传递了正确的地址(此地址对应于被调用函数的参数类型),将是非常重要的关键。
如果你欺骗编译器,告诉它你的函数期望获得一个GameObject,而其实它真正期望获得的是个SpaceShip,当你调用那个函数,编译器就会传递错误的地址,导至执行时期可怕的大屠杀。这种问题很难找出原因。转型令人沮丧,原因有许多个,这是其中之一。
因此,只能改变hitSpaceShip这些成员函数的原型,使他们接受GameObject对象:
class SpaceShip: public GameObject { public: virtual void collide(GameObject& otherObject); virtual void hitSpaceShip(GameObject& spaceShip); virtual void hitSpaceStation(GameObject& spaceStation); virtual void hitAsteroid(GameObject& asteroid); ... };
这就是为什么没有重载collide函数的原因。因为参数都一样了。
下面是剩下的代码:
SpaceShip::HitMap * SpaceShip::initializeCollisionMap(){ HitMap *phm = new HitMap; (*phm)["SpaceShip"] = &hitSpaceShip; (*phm)["SpaceStation"] = &hitSpaceStation; (*phm)["Asteroid"] = &hitAsteroid; return phm; } 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; }
在hitSpaceShip这样的函数中,需要将参数对象使用dynamic_cast强制转换为真正的类型。
实际上,上面这种自行实现vtbl的解法依然有类似的问题,因为针对每一个兄弟类,都需要有一个成员函数处理碰撞。一旦有新的GameObject类型加入到这个游戏中,还是需要修改类的定义。
如果关系型数组内含的指针指向的是non-member functions,重新编译的问题便可消除:
#include "SpaceShip.h" #include "SpaceStation.h" #include "Asteroid.h" 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); }
这里使用了匿名namespace,匿名namespace内的所有东西对其所在的编译单元而言都是私有的,也就是说,其效果好像是在文件中将函数声明为static一样,更推荐使用匿名namespace。
剩下的代码如下:
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 unique_ptr<HitMap> collisionMap(initializeCollisionMap()); HitMap::iterator mapEntry = collisionMap->find(make_pair(class1, class2)); if (mapEntry == collisionMap->end()) return 0; return (*mapEntry).second; } }
现在,一旦有新的GameObject子类加进这个继承体系中,原有的类不再需要重新编译,也不需维护纠葛混乱的“以 RTTI 为基础的”switch 或 if-then-else。如果新的类加入到GameObject的继承体系中,只要其本身定义良好;我们的系统只需在initializeCollisionMap 内调整代码,并在“与processCollision相应的那个匿名namespace”内增加新碰撞处理函数。
还有一个问题,如果需要满足inheritance-based类型转换,比如现在宇宙飞船需要区分商业宇宙飞船和军事宇宙飞船:SpaceShip现在派生出了CommercialShip和MilitaryShip,而他们与原有对象的碰撞处理与SpaceShip完全相同,因此如果有一个MilitaryShip和一个Asteroid碰撞,我们希望调用的是void shipAsteroid(GameObject& spaceShip, GameObject& asteroid)。但是就目前的代码而言,这种情况下实际上会抛出一个UnknownCollision异常,因为lookup中会根据”MilitaryShip”和”Asteroid”寻找对应的函数,而map中没有这样的函数。
没有什么简单的办法解决这个问题,如果需要实现double-dispatching,而且需要支持 inheritance-based 参数转换,只能使用最早介绍的“双虚拟函数调用”机制。
以上的代码中,collisionMap是静态的,一旦初始化便不再改动,如果需要动态处理,也就是能增加、删除、修改collisionMap中内容,则需要重新设计一个CollisionMap类:
class CollisionMap { public: typedef void (*HitFunctionPtr)(GameObject&, GameObject&); void addEntry(const string& type1, const string& type2, HitFunctionPtr collisionFunction, bool symmetric = true); void removeEntry(const string& type1, const string& type2); HitFunctionPtr lookup(const string& type1, const string& type2); static CollisionMap& theCollisionMap(); private: CollisionMap(); CollisionMap(const 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); ...
addEntry的symmetric参数,主要是为了能对称处理条目而设,也就是增加<T1,T2>时也会增加<T2,T1>。
为了确保这些map条目在其对应的任何撞击发生之前就被加入map,可以使用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函数之前,就已经将所需的条目加入到了map中。
稍后如果有新的派生类加入,无需改动原有代码,只需新增代码:
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);