C++ 技巧 (3)
来源:互联网 发布:笛子的视频软件 编辑:程序博客网 时间:2024/05/19 04:52
代理类
虽然你和你的亲家可能住在同一地理位置,但就整个世界而言,通常不是这样的。很不幸,C++还没有认识到这个事实。至少,从它对数组的支持上可以看出一些迹象。在FORTRAN、BASIC甚至是COBOL中,你可以创二维、三维乃至n维数组(OK,FORTRAN只能创最多7维数组,但别过于吹毛求疵吧)。但在C++中呢?只是有时可以,而且也只是某种程度上的。
这是合法的:
int data[10][20]; // 2D array: 10 by 20
而相同的结构如果使用变量作维的大小的话,是不可以的:
void processInput(int dim1, int dim2)
{
int data[dim1][dim2]; // error! array dimensions
... // must be known during
}
甚至,在堆分配时都是不合法的:
int *data =
new int[dim1][dim2]; // error!
.. 实现二维数组 多维数组在C++中的有用程度和其它语言相同,所以找到一个象样的支持方法是很重要的。常用方法是C++中的标准方法:用一个类来实现我们所需要的而C++语言中并没有提供的东西。因此,我们可以定义一个类模板来实现二维数组:
template<class T>
class Array2D {
public:
Array2D(int dim1, int dim2);
...
};
现在,我们可以定义我们所需要的数组了:
Array2D<int> data(10, 20); // fine
Array2D<float> *data =
new Array2D<float>(10, 20); // fine
void processInput(int dim1, int dim2)
{
Array2D<int> data(dim1, dim2); // fine
...
}
然而,使用这些array对象并不直接了当。根据C和C++中的语法习惯,我们应该能够使用[]来索引数组:
cout << data[3][6];
但我们在Array2D类中应该怎样申明下标操作以使得我们可以这么做? 我们最初的冲动可能是申明一个operator[][]函数:
template<class T>
class Array2D {
public:
// declarations that won't compile
T& operator[][](int index1, int index2);
const T& operator[][](int index1, int index2) const;
...
};
然而,我们很快就会中止这种冲动,因为没有operator[][]这种东西,别指望你的编译器能放过它。(所有可以重载的运算符见Item M7。)我们得另起炉灶。 如果你能容忍奇怪的语法,你可能会学其它语言使用()来索引数组。这么做,你只需
重载operator():
template<class T>
class Array2D {
public:
// declarations that will compile
T& operator()(int index1, int index2);
const T& operator()(int index1, int index2) const;
...
};
用户于是这么使用数组:
cout << data(3, 6);
这很容易实现,并很容易推广到任意多维的数组。缺点是你的Array2D对象看起来和内嵌数组一点都不象。实际上,上面访问元素(3,6)的操作看起来相函数调用。 如果你拒绝让访问数组行为看起来象是从FORTRAN流窜过来的,你将再次会到使用[]上来。虽然没有operator[][],但写出下面这样的代码是合法的:
int data[10][20];
...
cout << data[3][6]; // fine
说明了什么?
说明,变量data不是真正的二维数组,它是一个10元素的一维数组。其中每一个元素又都是一个20元素的数组,所以表达式data[3][6]实际上是(data[3])[6],也就是data的第四个元素这个数组的第7个元素。简而言之,第一个[]返回的是一个数组,第二个[]从这个返回的数组中再去取一个元素。
我们可以通过重载Array2D类的operator[]来玩同样的把戏。Array2D的operator[]返回一个新类Array1D的对象。再重载Array1D的operator[]来返回所需要的二维数组中的元素:
template<class T>
class Array2D {
public:
class Array1D {
public:
T& operator[](int index);
const T& operator[](int index) const;
...
};
Array1D operator[](int index);
const Array1D operator[](int index) const;
...
};
现在,它合法了:
Array2D<float> data(10, 20);
...
cout << data[3][6]; // fine
这里,data[3]返回一个Array1d对象,在这个对象上的operator[]操作返回二维数组中(3,6)位置上的浮点数。
Array2D的用户并不需要知道Array1D类的存在。这个背后的“一维数组”对象从概念上来说,并不是为Array2D类的用户而存在的。其用户编程时就象他们在使用真正的二维数组一样。对于Array2D类的用户这样做是没有意义的:为了满足C++的反复无常,这些对象必须在语法上兼容于其中的元素是另一个一维数组的一个一维数组。 每个Array1D对象扮演的是一个一维数组,而这个一维数组没有在使用Array2D的程
序中出现。扮演其它对象的对象通常被称为代理类。在这个例子里,Array1D是一个代理类。它的实例扮演的是一个在概念上不存在的一维数组。(术语代理对象(proxy object)和代理类(proxy classs)还不是很通用;这样的对象有时被叫做surrogate。)
.. 区分通过operator[]进行的是读操作还是写操作 使用代理来实现多维数组是很通用的的方法,但代理类的用途远不止这些。例如,Item
M5中展示了代理类可以怎样用来阻止单参数的构造函数被误用为类型转换函数。在代理类的各中用法中,最神奇的是帮助区分通过operator[]进行的是读操作还是写操作。
考虑一下带引用计数而又支持operator[]的string类型。如果你还不了解引用记数背后的概念,那么现在就去熟悉Item M29中的内容将是个好
主意。
支持operator[]的string类型,允许用户些下这样的代码:
String s1, s2; // a string-like class; the
// use of proxies keeps this
// class from conforming to
// the standard string
... // interface
cout << s1[5]; // read s1
s2[5] = 'x'; // write s2
s1[3] = s2[8]; // write s1, read s2
注意,operator[]可以在两种不同的情况下调用:读一个字符或写一个字符。读是个右值操作;写是个左值操作。(这个名词来自于编译器,左值出现在赋值运算的左边,右值出现在赋值运算的右边。)通常,将一个对象做左值使用意味着它可能被修改,做右值用意味着它不能够被修改。
我们想区分将operator[]用作左值还是右值,因为,对于有引用计数的数据结构,读操作的代价可以远小于写操作的代价。如Item M29解释的,引用计数对象的写操作将导致整个数据结构的拷贝,而读不需要,只要简单地返回一个值。不幸的是,在operator[]内部,没有办法确定它是怎么被调用的,不可能区分出它是做左值还是右值。
“但,等等,”你叫道,“我们不需要。我们可以基于const属性重载operator[],这样就可以区分读还是写了。”换句话说,你建议我们这么解决问题:
class String {
public:
const char& operator[](int index) const; // for reads
char& operator[](int index); // for writes
...
};
唉,这不能工作。编译器根据调用成员函数的对象的const属性来选择此成员函数的const和非const版本,而不考虑调用时的环境。因此:
String s1, s2;
...
cout << s1[5]; // calls non-const operator[],
// because s1 isn't const
s2[5] = 'x'; // also calls non-const
// operator[]: s2 isn't const
s1[3] = s2[8]; // both calls are to non-const
// operator[], because both s1
// and s2 are non-const objects
于是,重载operator[]没能区分读还是写。
在Item M29中,我们屈从了这种不令人满意的状态,并保守地假设所有的operator[]调用都是写操作。这次,我们不会这么轻易放弃的。也许不可能在operator[]内部区分左值还是右值操作,但我们仍然想区分它们。于是我们将去寻找一种方法。如果让你自己被其可能性所限制,生命还有什么快乐?
我们的方法基于这个事实:也许不可能在operator[]内部区分左值还是右值操作,但我们仍然能区别对待读操作和写操作,如果我们将判断读还是写的行为推迟到我们知道operator[]的结果被怎么使用之后的话。我们所需要的是有一个方法将读或写的判断推迟到operator[]返回之后。的一个例子。)
proxy类可以让我们得到我们所需要的时机,因为我们可以修改operator[]让它返回一个(代理字符的)proxy对象而不是字符本身。我们可以等着看这个proxy怎么被使用。如果是读它,我们可以断定operator[]的调用是读。如果它被写,我们必须将operator[]的调用处理为写。
我们马上来看代码,但首先要理解我们使用的proxy类。在proxy类上只能做三件事:
.. 创建它,也就是指定它扮演哪个字符。 .. 将它作为赋值操作的目标,在这种情况下可以将赋值真正作用在它扮演的字符上。这样被使用时,proxy类扮演的是左值。 .. 用其它方式使用它。这时,代理类扮演的是右值。
这里是一个被带引用计数的string类用作proxy类以区分operator[]是作左值还是右值使用的例子:
class String { // reference-counted strings;
public: // see Item 29 for details
class CharProxy { // proxies for string chars
public:
CharProxy(String& str, int index); // creation
CharProxy& operator=(const CharProxy& rhs); // lvalue
CharProxy& operator=(char c); // uses
operator char() const; // rvalue
// use
private:
String& theString; // string this proxy pertains to
int charIndex; // char within that string
// this proxy stands for
};
// continuation of String class
const CharProxy
operator[](int index) const; // for const Strings
CharProxy operator[](int index); // for non-const Strings
...
friend class CharProxy;
private:
RCPtr<StringValue> value;
};
除了增加的CharProxy类(我们将在下面讲解)外,这个String类与中的最终版本相比,唯一不同之处就是所有的operator[]函数现在返回的是CharProxy对象。然而,String类的用户可以忽略这一点,并当作operator[]返回的仍然是通常形式的字符(或其引用)来编程:
String s1, s2; // reference-counted strings
// using proxies
...
cout << s1[5]; // still legal, still works
s2[5] = 'x'; // also legal, also works
s1[3] = s2[8]; // of course it's legal,
// of course it works
有意思的不是它能工作,而是它为什么能工作。
先看这条语句:
cout << s1[5];
表达式s1[5]返回的是一CharProxy对象。没有为这样的对象定义输出流操作,所以编译器努力地寻找一个隐式的类型转换以使得operator<<调用成功(见Item M5)。它们找到一个:在CahrProxy类内部申明了一个隐式转换到char的操作。于是自动调用这个转换操作,结果就是CharProxy类扮演的字符被打印输出了。这个CharProxy到char的转换是所有代理对象作右值使用时发生的典型行为。
作左值时的处理就不一样了。再看:
s2[5] = 'x';
和前面一样,表达式s2[5]返回的是一个CharProxy对象,但这次它是赋值操作的目标。由于赋值的目标是CharProxy类,所以调用的是CharProxy类中的赋值操作。这至关重要,因为在CharProxy的赋值操作中,我们知道被赋值的CharProxy对象是作左值使用的。因此,
我们知道proxy类扮演的字符是作左值使用的,必须执行一些必要的操作以实现字符的左值操作。
同理,语句
s1[3] = s2[8];
调用作用于两个CharProxy对象间的赋值操作,在此操作内部,我们知道左边一个是作左值,右边一个作右值。
“呀,呀,呀!”你叫道,“快给我看。”OK,这是String的opertator[]函数的代码:
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);
}
每个函数都创建和返回一个proxy对象来代替字符。根本没有对那个字符作任何操作:我们将它推迟到直到我们知道是读操作还是写操作。
注意,operator[]的const版本返回一个const的proxy对象。因为CharProxy::operator=是个非const的成员函数,这样的proxy对象不能作赋值的目标使用。因此,不管是从operator[]的const版本返回的proxy对象,还是它所扮演的字符都不能作左值使用。很方便啊,它正好是我们想要的const版本的operator[]的行为。
同样要注意在const的operator[]返回而创建CharProxy对象时,对*this使用的const_cast(见Item M2)。这使得它满足了CharProxy类的构造函数的需要,它的构造函数只接受一个非const的String类。类型转换通常是领人不安的,但在此处,operator[]返回的CharProxy对象自己是const的,所以不用担心String内部的字符可能被通过proxy类被修改。
通过operator[]返回的proxy对象记录了它属于哪个string对象以及所扮演的字符的下标:
String::CharProxy::CharProxy(String& str, int index)
: theString(str), charIndex(index) {}
将proxy对象作右值使用时很简单--只需返回它所扮演的字符就可以了:
String::CharProxy::operator char() const
{
return theString.value->data[charIndex];
}
如果已经忘了string对象的value成员和它指向的data成员的关系的话,请回顾一下Item M29以增强记忆。因为这个函数返回了一个字符的值,并且又因为C++限定这样通过值返回的对象只能作右值使用,所以这个转换函数只能出现在右值的位置。
回头再看CahrProxy的赋值操作的实现,这是我们必须处理proxy对象所扮演的字符作赋值的目标(即左值)使用的地方。我们可以将CharProxy的赋值操作实现如下:
String::CharProxy&
String::CharProxy::operator=(const CharProxy& rhs)
{
// if the string is sharing a value with other String objects,
// break off a separate copy of the value for this string only
if (theString.value->isShared()) {
theString.value = new StringValue(theString.value->data);
}
// now make the assignment: assign the value of the char
// represented by rhs to the char represented by *this
theString.value->data[charIndex] =
rhs.theString.value->data[rhs.charIndex];
return *this;
}
如果与Item M29中的非const的String::operator[]进行比较,你将看到它们极其相似。这是预料之中的。在Item M29中,我们悲观地假设所有非const的operator[]的调用都是写操作,所以实现成这样。现在,我们将写操作的实现移入CharProxy的赋值操作中,于是可以避免非const的operator[]的调用只是作右值时所多付出的写操作的代价。随便提一句,这个函数需要访问string的私有数据成员value。这是前面将CharProxy申明为string的友元的原因。
第二个CharProxy的赋值操作是类似的:
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;
}
作为一个资深的软件工程师, 你当然应该消除这两个赋值操作中的代码重复,应该将它们放入一个私有成员函数中供二者调用。你不是这样的人吗?
.. 局限性
使用proxy类是个区分operator[]作左值还是右值的好方法,但它不是没有缺点的。我们很喜欢proxy对象对其所扮演的对象的无缝替代,但这很难实现。这是因为,右值不只是出现在赋值运算的情况下,那时,proxy对象的行为就和实际的对象不一致了。
再看一下来自于Item M29的代码,以证明我们为什么为每个StringValue对象增加一个共享标志。如果String::operator[]返回一个CharProxy而不是char &,下面的代码将不能编译:
String s1 = "Hello";
char *p = &s1[1]; // error!
表达式s1[1]返回一个CharProxy,于是“=”的右边是一个CharProxy *。没有从CharProxy *到char *的转换函数,所以p的初始化过程编译失败了。通常,取proxy对象地址的操作与取实际对象地址的操作得到的指针,其类型是不同的。 要消除这个不同,你需要重载CharProxy类的取地址运算:
class String {
public:
class CharProxy {
public:
...
char * operator&();
const char * operator&() const;
...
};
...
};
这些函数很容易实现。const版本返回其扮演的字符的const型的指针:
const char * String::CharProxy::operator&() const
{
return &(theString.value->data[charIndex]);
}
非const版本有多一些操作,因为它返回的指针指项的字符可以被修改。非const的String::operator[]行为相似,实现也很接近:
char * String::CharProxy::operator&()
{
// make sure the character to which this function returns
// a pointer isn't shared by any other String objects
if (theString.value->isShared()) {
theString.value = new StringValue(theString.value->data);
}
// we don't know how long the pointer this function
// returns will be kept by clients, so the StringValue
// object can never be shared
theString.value->markUnshareable();
return &(theString.value->data[charIndex]);
}
其代码和CharProxy的其它成员函数有很多相同,所以我知道你将把它们封入一个私有成员函数。
char和代理它的CharProxy的第二个不同之处出现带引用计数的数组模板中如果我们想用proxy类来区分其operator[]作左值还是右值时:
template<class T> // reference-counted array
class Array { // using proxies
public:
class Proxy {
public:
Proxy(Array<T>& array, int index);
Proxy& operator=(const T& rhs);
operator T() const;
...
};
const Proxy operator[](int index) const;
Proxy operator[](int index);
...
};
看一下这个数组可能被怎样使用:
Array<int> intArray;
...
intArray[5] = 22; // fine
intArray[5] += 5; // error!
++intArray[5]; // error!
如我们所料,当operator[]作最简单的赋值操作的目标时,是成功的,但当它出现operator+=和operator++的左侧时,失败了。因为operator[]返回一个proxy对象,而它没有operator+=和operator++操作。同样的情况存在于其它需要左值的操作中,包括operator*=、operator<<=、operator--等等。如果你想让这些操作你作用在operator[]上,必须为Arrar<T>::Proxy类定义所有这些函数。这是一个极大量的工作,你可能不愿意去做的。不幸的是,你要么去做这些工作,要么没有这些操作,不能两全。
一个类似的问题必须面对:通过proxy对象调用实际对象的成员函数。想避开它是不可能的。例如,假设我们用带引用计数的数组处理有理数。我们将定义一个Rational类,然后使用前面看到的Array模板:
class Rational {
public:
Rational(int numerator = 0, int denominator = 1);
int numerator() const;
int denominator() const;
...
};
Array<Rational> array;
这是我们所期望的使用方式,但我们很失望:
cout << array[4].numerator(); // error!
int denom = array[22].denominator(); // error!
现在,不同之处很清楚了;operator[]返回一个proxy对象而不是实际的Rational对象。但成员函数numerator()和denominator()只存在于Rational对象上,而不是其proxy对象。因此,你的编译器发出了抱怨。要使得proxy对象的行为和它们所扮演的对象一致,你必须重载可作用于实际对象的每一个函数。
另一个proxy对象替代实际对象失败的情况是作为非const的引用传给函数:
void swap(char& a, char& b); // swaps the value of a and
b
String s = "+C+"; // oops, should be "C++"
swap(s[0], s[1]); // this should fix the
// problem, but it won't
// compile
String::operator[]返回一个CharProxy对象,但swap()函数要求它所参数是char &类型。一个CharProxy对象可以印式地转换为一个char,但没有转换为char &的转换函数。而它可能转换成的char并不能成为swap的char &参数,因为这个char是一个临时对象(它是operator char()的返回值),根据Item M19的解释,拒绝将临时对象绑定为非const的引用的形参是有道理的。
最后一种proxy对象不能无缝替换实际对象的情况是隐式类型转换。当proxy对象隐式转换为它所扮演的实际对象时,一个用户自定义的转换函数被调用了。例如,一个CharProxy对象可以转换为它扮演的char,通过调用operator char()函数。解释的,编译器在调用函数而将参数转换为此函数所要的类型时,只调用一个用户自定义的转换函数。于是,很可能在函数调用时,传实际对象是成功的而传proxy对象是失败的。例如,我们有一个TVStation类和一个函数watchTV():
class TVStation {
public:
TVStation(int channel);
...
};
void watchTV(const TVStation& station, float hoursToWatch);
借助于int到TVStation的隐式类型转换(见Item M5),我们可以这么做:
watchTV(10, 2.5); // watch channel 10 for
// 2.5 hours
然而,当使用那个用proxy类区分operator[]作左右值的带引用计数的数组模板时,我们就不能这么做了:
Array<int> intArray;
intArray[4] = 10;
watchTV(intArray[4], 2.5); // error! no conversion
// from Proxy<int> to
// TVStation
由于问题发生在隐式类型转换上,它很难解决。实际上,更好的设计应该是申明它的构造函数为explicit,以使得第一次的调用watchTV()的行为都编译失败。关于隐式类型转换的细节和explicit对此的影响。
.. 评述
Proxy类可以完成一些其它方法很难甚至不可能实现的行为。多维数组是一个例子,左/右值的区分是第二个,限制隐式类型转换(见Item M5)是第三个。
同时,proxy类也有缺点。作为函数返回值,proxy对象是临时对象,它们必须被构造和析构。这不是免费的,虽然此付出能从具备了区分读写的能力上得到更多的补偿。Proxy对象的存在增加了软件的复杂度,因为额外增加的类使得事情更难设计、实现、理解和维护。
最后,从一个处理实际对象的类改换到处理proxy对象的类经常改变了类的语义,因为proxy对象通常表现出的行为与实际对象有些微妙的区别。有时,这使得在设计系统时无法选择使用proxy对象,但很多情况下很少有操作需要将proxy对象暴露给用户。例如,很少有用户取上面的二维数组例子中的Array1D对象的地址,也不怎么有可能将下标索引的对象作参数传给一个期望其它类型的函数。在很多情况下,proxy对象可以完美替代实际对象。当它们可以工作时,通常也是没有其它方法可采用的情况。
让函数根据一个以上的对象来决定怎么虚拟
有时,借用一下Jacqueline Susann的话:一次是不够的。例如你有着一个光辉形象崇高声望、丰厚薪水的程序员工作,在Redmond,Wshington的一个著名软件公司--当然,我说的就是任天堂。为了得到经理的注意,你可能决定编写一个video game。游戏的背景
是发生在太空,有宇宙飞船、太空站和小行星。
在你构造的世界中的宇宙飞船、太空站和小行星,它们可能会互相碰撞。假设其规则是:
.. 如果飞船和空间站以低速接触,飞船将泊入空间站。否则,它们将有正比于相对速度的损坏。
.. 如果飞船与飞船,或空间站与空间站相互碰撞,参与者均有正比于相对速度的损坏。
.. 如果小行星与飞船或空间站碰撞,小行星毁灭。如果是小行星体积较大,飞船或空间站也毁坏。
.. 如果两个小行星碰撞,将碎裂为更小的小行星,并向各个方向溅射。
这好象是个无聊的游戏,但用作我们的例子已经足够了,考虑一下怎么组织C++代码以处理物体间的碰撞。 我们从分析飞船、太空站和小行星的共同特性开始。至少,它们都在运动,所以有一个速度来描述这个运动。基于这一点,自然而然地设计一个基类,而它们可以从此继承。实际上,这样的类几乎总是抽象基类,并且,如果你留心我在Item M33中的警告,基类总是抽象的。所以,继承体系是这样的:
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”,所以你必须自己完成编译器在实现虚函数时所做的工作()。如果容易的话,我们可能就什么都自己做了,并用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,而且它还不显眼。编译器也没办法帮助我们检查这种疏忽,因为它们根本不知道我们在做什么。
这种类型相关的程序在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和其它需要的类的运行库进行编程。当然不是只有你一个人在使用这个库,全公司都将震动于每次你决定在你的代码中增加一个新类型时,所有的程序都需要重新编译。实际中,广被使用的库极少被修改,因为重新编译所有用了这个库的程序的代价太大了。(以了解怎么设计将编译依赖度降到最低的运行库。)
总结一下就是:如果你需要实现二重调度,最好的办法是修改设计以取消这个需要。如果做不到的话,虚函数的方法比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退出后的某个时刻被自动析构。
而且,我们可以使用标准模板库提供的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的奇怪行为。
.. 初始化模拟虚函数表
现在来看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();
...
}
不过这意味着我们要付出拷贝赋值的代价。我们不想这么做。如果initializeCollisionMap()返回指针的话,我们就不需要付出这个代价,但这样就需要担心指针指向的map对象是否能在恰当的时候被析构了。
幸好,有个两全的方法。我们可以将collisionMap改为一个灵巧指针它将在自己被析构时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(),而它在函数指针的类型转换中通常是被舍弃的:
// 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外还有其它基类,你可能会发现当你调用你在这儿搜索到的碰撞处理函数时,其行为非常的粗暴。
D中的四个类的部分,其地址都不同。这很重要,因为虽然指针和引用的行为并不相同,编译器产生的代码中通常是通过指针来实现引用的。于是,传引用通常是通过传指针来实现的。当一个有多个基类的对象(如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:
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只是标准运行库中的一个转换函数(模板),它使得我们避免了在构造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继承。
假设贸易飞船和军事飞船在碰撞过程中的行为是一致的。于是,我们期望可以使用相同的碰撞处理函数(在增加这两类以前就有的那个)。尤其是,在一个MilitaryShip对象和一个Asteroid对象碰撞时,我们期望调用
void shipAsteroid(GameObject& spaceShip,
GameObject& asteroid);
它不会被调用的。实际上,抛了一个UnknownCollision的异常。因为lookup在根据类型名“MilitaryShip”和“Asteroid”在collisionMap中查找函数时没有找到。虽然.. 初始化模拟虚函数表(再次讨论) 这就是关于二重调度的所有要说的,但是,用如此悲观的条款来结束是令人很不愉快
的。因此,让我们用概述初始化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,因为我MilitaryShip可以被转换为一个SpaceShip,但lookup却不知道这一点。
而且,没有没有一个简单的办法来告诉它。如果你需要实现二重调度,并且需要这儿的向上类型映射,你只能采用我们前面讨论的二次虚函数调用的方法(同时也意味着增加新类的时候,所有人都必须重新编译)。 们的系统中只有一个映射表。(更复杂的游戏需要多张映射表是可以想象到的。)最后,它允许我们简化在映射表中增加对称性的碰撞(也就是说,类型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,
};
用户于是可以使用此类型的一个全局对象来自动地注册他们所需要的函数:
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",
symmetric);
}
&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>。
我们现在到了接近结束的部分了,这章讲述的是一些不属于前面任一章节的指导原则。开始两个是关于C++软件开发的,描述的是设计适应变化的系统。面向对象的一个强大之处是支持变化,这两个条款描述具体的步骤来增强你的软件对变化的抵抗能力。
- 澄海3C技巧
- C语言技巧(读书笔记)
- C语言(调试技巧)
- C#/Net代码精简优化技巧(3)
- C#/Net代码精简优化技巧(3)
- c技巧
- C/C++-技巧-宏
- C/C++-技巧-宏
- LINUX C编程技巧(转)
- C#JQuery学习(二)技巧总结
- C语言 格式说明符(小技巧)
- C#特殊的技巧(C#)
- codeforces 279C Ladder(数据技巧)
- codeforces 463C(对角线技巧)
- Codeforces 868 C. Qualification Rounds (技巧)
- C指针原理(93)-C应用技巧(3)
- [C\C++]读入优化【技巧】
- C/C++-技巧-获取时间
- 抽象的层次
- MMORPG开发杂谈(四):为什么要有最高等级限制
- UpcaseString
- hook简介
- 加密
- C++ 技巧 (3)
- C++5×5断想之一:C++历史上最重要的图书
- 软件测试分类
- ASP.NET first step
- 爱的无常、当下、柔软
- 真正的eclipsework插件所有文件下载地址
- 不使用中间变量交换a和b的值
- 又是别离时
- WWW核心HTTP协议