您的位置:首页 > 其它

关于双分派(Double Dispatch)的一点探讨

2006-05-05 10:11 197 查看
提纲

背景
我不认识你,就让你来处理
增加一个识别函数,避免无限循环
真正的增量式开发
非对称性处理
上升到设计模式层次
结论

背景

Scott Meyers在More Effective C++ 的第31个条款中,提到了双分派(Double Dispatch)的问题,简述如下:

假设你想写一个游戏软件,场景发生在外层空间,涉及宇宙飞船、太空站、小行星等天体,这些天体在空中飞行时会发生碰撞,任何两种不同的天体碰撞的结果都不一样,比如:

如果宇宙飞船和太空站以低速碰撞,宇宙飞船会泊进太空站内。否则宇宙飞船和太空站收到的损害与其碰撞速度成正比。

至于其他的碰撞结果,本文不一一列出,这不是本文的重点。这里的重点是“任何两种不同的天体碰撞的结果都不一样”。

假设你设计的继承体系如下图所示:



class GameObject {…};
class SpaceShip: public GameObject {…};
class SpaceStation: public GameObject {…};
class Asteroid: public GameObject {…};

而处理碰撞的函数可能是这样的:

void processCollision(GameObject& object1, GameObject& object2)
{

}

于是问题出来了。如果碰撞的结果只依赖于object1的动态类型,我们可以把processCollision作为GameObject的虚函数,然后调用object1. processCollision(object2)。如果碰撞的结果只依赖于object2的动态类型,则处理方法类似,只不过现在调用object2. processCollision(object1)。但是事实上,碰撞的结果视object1和object2两个参数,所以无法通过上述的单分派(Single Dispatch)来达成目标。这就是所谓的双分派(Double Dispatch)问题。

(如果您没有读过Scott Meyers的More Effective C++,建议您先读一遍,如果您曾经读过但现在已经忘得差不多了,也请您重温一遍,因为在下面的叙述中,我完全利用了原文中的例子,我不会给出所有的示例代码,只给出我做了修改的部分。)

接下来作者给出了很多种解决方案,但没有一种能够解决“当新型对象加入时,需要修改已有代码”的问题。其中,“使用非成员函数的碰撞处理函数”一法,虽然看上去当新型对象加入时,无需修改已有的类的申明与定义,但是仍然需要修改那些非成员的碰撞处理函数(至少必须添加新的碰撞处理函数),不能算是完全不用修改已有代码;何况把碰撞处理函数写成非成员函数,本身已经不符合封装的原则。本文尝试提供一个新的解决方案,当增加新型的对象时,完全不需要修改已有的代码。

首先说明一下,为什么我们要追求“当增加新型的对象时完全不修改已有的代码”这样一个目标。必须承认,如果旧有的系统是你自己设计的,或者你拥有原有系统的源码,那么增加新型的对象时,直接修改原来的代码,也许可读性更好,更易于理解。但是,实际情形中,原来的系统通常都是别人以二进制形式提供的,我们通常无法修改源代码,这时要想加进自己的东西,就不得不在不修改已有代码的基础上,无缝地加入自己的代码。

接下来我们来分析一下这种可能是否存在。当增加一个新的天体类型时,新的天体类型与已有的天体类型碰撞的规则不一样(这意味着碰撞处理函数不一样)。从一个方面看,这些碰撞处理函数牵涉到已有的天体类型,似乎一定要在已有的类中增加与新型天体碰撞的处理。但是,从另一个方面上看,这些新的碰撞处理,只有当新的天体类型存在时,它们才有意义,没有新的天体类型,这些碰撞处理就不会存在,换句话说,他们与新的天体类型是紧密相依的,所以没有理由一定要把这些碰撞处理写在已有的类中。所以,从感觉上看,当增加新类型的对象时,完全不需要修改已有的代码是有可能而且是应该的。

为了简化问题,我们假设碰撞是对称的(Scott Meyers 也做过这样的假设),也就是说 A撞击B的规则与B撞击A的规则是一样的。这就为我们在处理碰撞函数时提供了一个契机:当一个对象面对不认识的对象时(这个对象可能就是将来新增加的类型),就调用这个不认识的对象的碰撞处理函数。我把它归纳成:

我不认识你,就让你来处理

我不认识你,就让你来处理,这看起来有点像推卸责任,但在这里却有着非常奇妙的作用。是的,既然碰撞是对称的,我跟你的碰撞处理就等于你跟我的碰撞处理,现在,我不认识你,我不知道我碰撞你之后,会是什么结果,于是我就让你来处理你与我的碰撞,而虚函数的特性恰恰可以保证做到这一点。

下面以 SpaceShip为例给出示例代码。为了叙述方便,这里使用虚函数+基于运行期类型识别的if-then-else 链,尽管我知道Meyers 对这种方法并不赞成,但这不是本文的重点,改用其他的方法,一样可以采用本文叙述的方案。此时,GameObject中已经申明了一个虚函数 collide如下:

virtual void collide(GameObject& otherObject) = 0;

SpaceShip 的collide函数实现如下:

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& ss = static_cast<Asteroid&>(otherObject);

//process a SpaceShip-Asteroid collision;
}

else{
otherObject.collide(*this); //这里是关键
}
}

注意上面最后一行代码。原文中这里是抛出一个异常,这里改成了调用otherObject的碰撞处理函数,虽然只是一行之差,结果却大不相同。

每一个类的写法都是这样,它只处理与它认识的天体类型的碰撞,不认识的交给对方处理(细心的读者可能已经发现,这里有一个隐患,我将在后面说明)。当增加一个新的天体类型时,新类型的写法也是这样,但它必须处理与所有已经存在的天体类型的碰撞,这是不难做到的,新增的类型总是认识已有的类型。

采用这种写法后,总的碰撞处理函数是非常简单的,它看起来是这样:

void processCollision (GameObject& object1,GameObject& object2)
{
object1.collide(object2);
}

详细的分析我就不做了,读者一眼就能看出,当增加新的天体类型时,已有的代码是不需要修改的(除非作者想把新的类型包含进来)。

可能有人已经看出,Meyers早就在使用这种方法了。但是,我将在下一节的末尾指出,Meyers并没有把“调用对方的碰撞处理函数”用于此一目的,而且Meyers的这个调用似乎还有一些问题。

上述代码看上去确乎非常完美,仅仅改变了一行代码,就改变了整个代码的命运。但是我刚才说过,这里有一个隐患。在上述SpaceShip的 collide函数中,我把原先抛出异常的地方改成了调用对方的碰撞处理函数,这是否意味着在新的代码下,永远不会抛出异常了呢?这只要想一下原先抛出异常的原因就知道了。原先当一个对象不认识另一个对象时就会抛出一个异常,而新的代码并不能排除这种可能性,所以总是可能抛出异常。现在的代码没有地方抛出异常,就说明代码有错误。

事实确实如此。虽然我曾经要求当增加一个新的天体类型时,新类型必须处理与所有已经存在的天体类型的碰撞,但是,一旦有人产生疏忽,比如在一个新的天体类型的碰撞处理函数中遗漏了一个已有的天体类型,那么就会进入“你让我处理,我让你处理”的无限循环(就像推卸责任一样!J)。

增加一个识别函数,避免无限循环

产生无线循环的根源在于在每个类的碰撞处理的函数的尾部,可能调用对方的碰撞处理函数,而对方自然有可能反过来调用自己的碰撞处理函数。所以,为了避免无限循环,我们把这个“调用对方的碰撞处理函数”的步骤挪到总的碰撞处理函数中。但是,总的碰撞处理函数怎么知道何时调用object1的碰撞处理函数,何时调用object2的碰撞处理函数呢?为此,我为每个类添加一个recognize方法,此方法表示一个类型是否认识另一个类型,它的申明看起来是这样(申明在抽象类中):

virtual bool recognize(GameObject& otherObject) = 0;

它在SpaceShip 中的实现应该是这样:

bool SpaceShip:: recognize (GameObject& otherObject)
{
const type_info& objectType = typeid(otherObject);

if(objectType == typeid(SpaceShip)){
return true;
}

else if(objectType == typeid(SpaceStation)){
return true;
}

else if(objectType == typeid(Asteroid)){
return true;
}

else{
return false;
}
}

此时,SpaceShip中collide 函数的实现应该是这样:

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& ss = static_cast<Asteroid&>(otherObject);

//process a SpaceShip-Asteroid collision;
}
}

当SpaceShip遇到不认识的天体类型时,什么也不做。

这时,总的碰撞处理函数就要做一些改变,它现在看起来应该是这样:

void processCollision(GameObject& object1,GameObject& object2)
{
if(object1.recognize(object2))
object1.collide(object2);
else if(object2.recognize(object1))
object2.collide(object1);
else
throw UnknownCollison(object1,object2);
}

注意,修改后的总的碰撞处理函数在双方都不认识时,抛出了一个异常,这正是前面可能会导致无限循环的情形。当然,正如我前面所说,实际情形中,不应该出现这种情况,因为每个类型应该认识它自己,而新增的类型应该认识已有的类型,所以不应该出现“我不认识你,你也不认识我”的情况。上述写法只是增加程序的健壮性,以防万一有遗漏的情况。

上面的做法虽然解决了无限循环的问题,但是从实现上看,并不完美。第一:增加了一个函数,这意味着处理一次碰撞,至少要进行两次类型比较;第二:比较是否认识与实际的碰撞处理分布在两个函数中,这就可能造成不一致,即在recognize函数返回了 true,而在collide函数中却没有具体的处理,这种情况下,就会发生“两个认识的天体类型碰撞却什么也没有发生”的结果(假设实际上应该会有结果)。

为此,我对原来的collide函数稍做修改,令其返回 bool 类型,表示是否处理了碰撞,而 recognize函数去掉。修改后的collide函数的申明应该是这样:

virtual bool collide(GameObject& otherObject) = 0;

而SpaceShip中collide 函数的实现应该是这样:

bool 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;
return true;
}

else if(objectType == typeid(SpaceStation)){
SpaceStation& ss = static_cast<SpaceStation&>(otherObject);

//process a SpaceShip-SpaceStation collision;
return true;
}

else if(objectType == typeid(Asteroid)){
Asteroid& ss = static_cast<Asteroid&>(otherObject);

//process a SpaceShip-Asteroid collision;
return true;
}

return false;
}

这时,总的碰撞处理函数就应该是这样:

void processCollision(GameObject& object1,GameObject& object2)
{
if(!object1.collide(object2))
if(!object2.collide(object1))
throw UnknownCollison(object1,object2);
}

这样的代码也许可读性差了一些,必须搞清楚谁认识谁,谁不认识谁,有点像歪门邪道。但是如果我们确实面临这样一种选择,即我们希望别人可以在我们的系统中无缝地加入自己的代码,那么这样设计确实是可以达到目的的。

这种做法的本质,就是“自己不认识对方时,交给对方处理”。前面提出,也许有人认为,Meyers早就在使用这种方法了。的确,Meyers在他的第二种解决方法中提到了类似的做法。他对每一个类定义了一组 Collide 函数,其中第一个以基类GameObject& 为参数,其实现代码就是反过来调用对方的 Collide函数。但这里有两个问题。

第一,我本来以为这个方法就是用来达成“添加新的类型,无需修改已有代码”的目标的,但Meyers并没有仔细说明这个函数的作用,并且接下去Meyers还说,如果有新的类加入,代码就必须修改。显然,Meyers没有把这个函数用于达成“添加新的类型,无需修改已有代码”的目的。如果是这样的话,那么这个函数有什么作用呢?它的作用就变成像上面最后一行抛出一个异常那样,仅仅是为了代码的健壮性而保留的一个不大可能会被调用到的保险函数而已。

第二,如果这个函数的作用真的只是一个保险函数(即类型匹配时,如果其他的三个都不符合,就调用这个函数),它就应该什么也不做(或者抛出一个异常),而不是调用对方的 collide函数,因为如此的行为会导致这样一个结果:一旦新增加了一些类型而没有扩充collide函数族,那么两个新增加的类型碰撞,就会像本文前面分析的那样,进入无限循环。

举例来说,假设新增加一个Satellite和 Planet类,而原先的collide函数族并没有扩充,看看下面的代码会发生什么情形:

Satellite aSatellite;
Planet aPlanet;
processCollision(aSatellite, aPlanet);

processCollision函数调用aSatellite.collide(aPlanet)。由于aPlanet既不是SpaceShip,也不是SpaceStation,也不是 Asteroid,所以只能调用Satellite 类的collide(GameObject &)函数,根据该函数的定义,它调用aPlanet.collide(aSatellite)。同样的分析,可以发现,aPlanet.collide(aSatellite)最终又调用aSatellite.collide(aPlanet),于是进入无限循环。

也许正是因为Meyers看到了这一点,所以他才特别强调:如果有新的 classes 加入,代码就必须修改,collide函数族就必须扩充。但是,既然这样,为什么不让collide(GameObject &)函数什么也不做(或者抛出一个异常)呢?为什么不让这个函数的作用延伸一下,从而可以达到“添加新的类型,无需修改已有代码”的目标呢?真不知道大师在这里是怎样考虑的,是“智者千虑,必有一失”呢,还是大师对程序的设计要求、对代码的可读性要求都很高,不允许前述的这种“歪门邪道”呢?

真正的增量式开发

上面的这种做法,不但解决了“当增加新类型的对象时,完全不需要修改已有的代码”的问题,而且可以做到真正的增量式开发。

如果采用原先的写法,在三个非抽象类中,都要实现与自己以及其他两个类型的碰撞处理,这实际上是有冗余的,因为碰撞是对称的,一对天体的碰撞处理应该只需要在其中一个类中实现就可以了。

采用了本文介绍的方法之后,冗余的代码即可消除。假设SpaceShip是最先加入到这个体系中的,接下来是 SpaceStation和Asteroid(尽管实际上,这三个类型是一起加入进来的,但我们总是可以做这样的假设)。这时,SpaceShip只要实现与它自己的碰撞处理,而SpaceStation只要实现与SpaceShip以及它自己的碰撞处理,只有Asteroid才要实现与三种类型的碰撞处理。实际上,越是后加入到这个体系中的对象,实现的碰撞处理越多,因为它要实现与所有现存类型的碰撞处理,而所有现存的代码都不需要改变。

精简后的代码可能会是这样:

bool 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;
return true;
}

return false;
}

bool SpaceStation::collide(GameObject& otherObject)
{
const type_info& objectType = typeid(otherObject);

if(objectType == typeid(SpaceShip)){
SpaceShip& ss = static_cast<SpaceShip&>(otherObject);

//process a SpaceStation-SpaceShip collision;
return true;
}

else if(objectType == typeid(SpaceStation)){
SpaceStation& ss = static_cast<SpaceStation&>(otherObject);

//process a SpaceStation-SpaceStation collision;
return true;
}

return false;
}

bool Asteroid::collide(GameObject& otherObject)
{
const type_info& objectType = typeid(otherObject);

if(objectType == typeid(SpaceShip)){
SpaceShip& ss = static_cast<SpaceShip&>(otherObject);

//process a Asteroid-SpaceShip collision;
return true;
}

else if(objectType == typeid(SpaceStation)){
SpaceStation& ss = static_cast<SpaceStation&>(otherObject);

//process a Asteroid-SpaceStation collision;
return true;
}

else if(objectType == typeid(Asteroid)){
Asteroid& ss = static_cast<Asteroid&>(otherObject);

//process a Asteroid-Asteroid collision;
return true;
}

return false;
}

现在假设有一个新的天体Satellite 加入到这个体系中,它的碰撞处理函数只要写成这样:

bool Satellite::collide(GameObject& otherObject)
{
const type_info& objectType = typeid(otherObject);

if(objectType == typeid(SpaceShip)){
SpaceShip& ss = static_cast<SpaceShip&>(otherObject);

//process a Satellite-SpaceShip collision;
return true;
}

else if(objectType == typeid(SpaceStation)){
SpaceStation& ss = static_cast<SpaceStation&>(otherObject);

//process a Satellite-SpaceStation collision;
return true;
}

else if(objectType == typeid(Asteroid)){
Asteroid& ss = static_cast<Asteroid&>(otherObject);

//process a Satelite-Asteroid collision;
return true;
}

else if(objectType == typeid(Satellite)){
Satellite & ss = static_cast<Satellite&>(otherObject);

//process a Satellite-Satellite collision;
return true;
}

return false;
}

这样的代码是不是更像增量式开发呢?

非对称性处理

截至目前为止,我们的讨论都是基于一个前提:碰撞是对称的。这对于真实的天体碰撞来说也许是正确的,但对于大多数其它的对象来说,也许是不正确的(通常情况下正是这样)。比如,在设计模式中,有一种模式叫 State,其中各种状态之间的转换就不是对称的,A状态转换成B状态与B状态转换成A状态是完全不一样的。这种情况下,上述方法完全不适用了(也许这正是大多数情况下,我们无法把责任推卸给别人的原因J)。

那么,这是否意味着我们前面的讨论都是白费口舌呢?我们基于了一个不总是正确的前提,找到了一个很好的解决方案,这是否意味着这个解决方案其实只是空中楼阁?是否意味着“当增加新型的对象时,完全不需要修改已有的代码”只是痴人说梦?

我猜想大多数人都曾经想到过上述的方法,毕竟这种方法并没有用到什么特别的技巧。但是,在遇到非对称性问题时就停止不前了。难道上述方法对于非对称性问题,一点借鉴意义也没有吗?

让我们把问题再来仔细地分析一下。A碰撞B不等于B碰撞 A,所以 A 的与B的碰撞处理函数不能简单地调用B的与A的碰撞处理函数。但是,A碰撞B难道不是等于 B被 A碰撞吗?我们是否可以增加一个“被碰撞”的辅助处理函数,当A 不认识B时,把A碰撞B的处理换成B被A碰撞的处理呢?

事实说明这是可行的。我们仍然以上面的继承体系为例,现在我们为基类增加一个被碰撞函数,它的申明是这样的:

virtual bool beCollided(GameObject& otherObject) = 0;

每一个非抽象类除了要实现collide函数外,现在还要实现beCollided函数。实现beCollided函数要注意两个问题。第一,只有对方不认识自己时,才需要处理被对方的碰撞,否则返回假就行了,这同时意味着新增加的对象总是应该处理处理被已有对想的碰撞。第二,处理被对方的碰撞时,不能简单地调用对方的collide函数(即使不认识对方),因为beCollided是用来辅助完成collide函数的,一个类要处理某个类的碰撞,是因为那个类不认识这个类,那个类没有处理与这个类的碰撞,所以才要用这个类被那个类的碰撞处理代替,所以如果beCollided函数只是简单地调用对方的collide函数,那么很有可能什么也没有做。

同样的道理,collide函数也不能简单地调用对方的beCollided函数,即使不认识对方。如果真的不认识对方,只要返回假就行了,“调用对方的beCollided函数”这个工作交给总的碰撞处理函数去做(还记得吗,这是为了避免无限循环)。

下面以Asteroid为例,给出示例代码:

bool Asteroid::beCollided(GameObject& otherObject)
{
const type_info& objectType = typeid(otherObject);

if(objectType == typeid(SpaceShip)){
SpaceShip& ss = static_cast<SpaceShip&>(otherObject);

//process a SpaceShip-Asteroid collision,注意是SpaceShip碰撞Asteroid;
return true;
}

else if(objectType == typeid(SpaceStation)){
SpaceStation& ss = static_cast<SpaceStation&>(otherObject);

//process a SpaceStation-Asteroid collision;
return true;
}

else if(objectType == typeid(Asteroid)){
Asteroid& ss = static_cast<Asteroid&>(otherObject);

ss.collide(*this);
return true;
}

return false;
}

再次,请注意上面的倒数第三行ss.collide(*this),这里由于双方都是Asteroid,所以可以简单地调用ss 的collide函数。

这时,总的碰撞处理函数必须修改成这样(此时processCollision函数表示object1碰撞object2,而不是两者互相碰撞):

void processCollision(GameObject& object1,GameObject& object2)
{
if(!object1.collide(object2))
if(!object2.beCollided(object1))
throw UnknownCollison(object1,object2);
}

只有当object1的collide函数返回假时,即object1不认识object2时才会调用object2的beCollided函数,而当object2的beCollided函数也返回假时,即object2也不认识object1时,则会抛出一个异常。

至此,我们通过增加一个函数的方法,解决了非对称性的问题。表面上看,代码似乎有冗余,实际上一点都没有,因为现在 A撞B与B撞 A不一样了,本来就需要有不同的处理。

上升到设计模式层次

由于上面的方法解决了非对称性问题,所以这个解法现在已经非常一般化了,我们完全可以把它上升到设计模式的层次。

在上面的解法中,beCollided函数是collide函数的孪生函数(或者叫兄弟函数、姊妹函数、伴侣函数),所以我暂且把这种模式定义为“孪生”模式。

实际上,双分派问题在Visitor模式中有所讨论。在Visitor模式中,针对不同的元素的不同操作会产生不同结果,即一个动作,不但取决于元素的类型,也取决于操作的类型,于是出现了双分派问题。Visitor模式的解决方法是,为所有的操作定义一个抽象基类,称为 Visitor,为所有的元素也定义一个抽象基类,称为 Element。一个Visitor类有一组 Visist方法,分别表示对不同元素的访问(也就是操作)(如果用if-then-else链来写,就变成了一个方法),一个Element类有一个 Accept方法,表示接受一个Visitor的访问。如果我们把“接受访问”看成是“被访问”,从而把Accept方法改叫BeVisited方法,那么,与上面提到的collide函数与beCollided函数就很相似了(本文没有把方法与函数区隔开来,本文认为方法与函数是一样的)。

当然,Visitor模式解决的问题是如何针对已有的元素添加新的操作(而不修改元素类的定义部分的代码),乍看与本文的问题似乎不一样。但是,如果把“一个新的天体类型(从而对应一种新的碰撞类型)”看成是一个新的操作的话,我们就会发现,Visitor模式其实更具有一般性。不过,Visitor模式并没有强调说,增加一个新的元素,不需要修改已有Visitor的代码,尽管实际上是可以的(新元素的Accept方法不能简单地调用Visitor类的VisistXX方法,因为不存在这样的方法,而是应该写出具体的实现,可能要用if-then-else链)。

结论

尽量实现代码重用,新类增加时不要修改已有代码,是面向对象设计中的一个原则。不幸的是,在很多情况下,我们无法完全做到这一点,双分派(Double Dispatch)是一个典型的例子。本文以一种非常简单的方法,实现了双分派(Double Dispatch)下,增加新型对象时无需修改已有代码的目标。这种做法的本质,就是“自己不认识对方时,交给对方处理”。

由于本人水平有限以及时间仓促之关系,文中难免出现错误与偏差之处,欢迎各位读者批评指正,您有任何建议或意见,亦欢迎与我交流,我的电子信箱是victor_8509@sina.com
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: