10.14 多重派遣
在处理多个类交互作用的情况时,程序会变得特别散乱。例如,考虑一个解析和执行数学表达式的系统。在系统中希望使用Number+Number、Number*Number等方式表达,其中Number是一族数值对象的基类。但是如果给出a+b,并且不知道a或b的准确的类型,那么怎样才能让这二者适当地进行交互作用呢?
刚开始回答时,有一些事情可能没有考虑:C++只执行单重派遣(single dispatching)。这就是说,如果在多个不知道类型的对象之间操作,C++只能在其中一个类型上激发动态绑定机制。这不能解决这里描述的问题,因此程序员只能手工发现一些类型并且有效地制造自己的动态绑定行为。
这种解决方法被称为多重派遣(Multiple dispatching)(GoF在访问者模式的语境下描述了这种方法,访问者模式将在下节介绍)。这里只有两个派遣,被称为双重派遣(double dispatching)。读者可能会记得,多态只能通过虚函数调用来实现,所以如果想要发生多重派遣,必须有一个虚函数调用以确定每个未知的类型。因此,如果处理的是不同层次结构的两个类型的交互作用,则每个层次结构都必须有一个虚函数调用。通常,将设立这样一种结构,使得一个成员函数的调用导致多个虚函数调用,并且因此在该过程中确定多个类型:对于每个派遣都需要一个虚函数调用。下面例子中被调用的虚函数是compete()和eval(),二者都是同一类型的成员函数(对于多重派遣这并不是必要条件):[1]
Outcome将函数compete()返回的不同结果进行分类,operator<<简化了显示特定Outcome的过程。
Item是将被多重派遣的那些类型的基类。Compete:operator()有两个Item*类型的参数(并不知道两者的确切类型),并且调用virtual Item:compete()函数开始双重派遣过程。虚拟机制决定了a的类型,因此它激发了在函数compete()内部的a的具体类型的产生。在保留该类型的基础之上,函数compete()调用eval()执行第2次派遣。将其自身(this指针)作为一个参数传递给函数eval(),从而产生一个对重载的eval()函数的调用,因此保存了第1次派遣的类型信息。在完成第2次派遣时,两个Item对象的确切类型就都知道了。
在main()函数中,STL算法generate()生成vector v中的元素内容,然后transform()在两个范围上应用Compete:operator()。这个版本的transform()产生第1个范围的起始和末尾点(包含双重派遣中使用的左边Item);第2个范围的起始点,这个范围持有从双重派遣中所使用的右边的Item;目标迭代器在这个例子中是标准输出;以及用于为每个对象调用的函数对象(一个临时的Compete类型)。
建立多重派遣需要做许多工作,但是请记住这样做的好处是在调用的时候能够以简洁的句法表达方式达到预期的效果—而不是编写出笨拙的代码在调用的时候决定一个或多个对象的类型,可以说:“你们两个!不管是什么类型,彼此之间可以适当地进行交互作用。”然而,在编写多重派遣的程序代码之前,确保这种简洁性是非常重要的。
注意,利用表查找来进行多重派遣是有效的。在这里使用虚函数来进行查找,用来代替进行杂乱的表查找。如果有较多的派遣(并且有增加和修改的可能),表查找也许是更好的解决问题的方法。
用访问者模式进行多重派遣
访问者模式(Visitor, GoF中最后一个也是最复杂的一个模式)的目标是将类继承层次结构上的操作与这个层次结构本身分开。这是一个相当古怪的动机,因为在面向对象编程中所做的大部分工作是将数据和操作组合在一起来形成对象,并利用多态性根据对象的确切类型自动选择操作的正确变化。
利用访问者模式将操作从类的继承层次结构中提取出来置入一个独立的外部层次结构。“主层次结构”包含一个函数visit(),该函数接受任何来自操作层次结构的对象。结果得到了两个类继承层次结构而不是一个。此外,可以看到,“主层次结构”变得很脆弱—如果要增加一个新类,也要强制改动第2个层次结构。因此,GoF认为主层次结构应该“很少地变化”。这个限制非常有限,从而更进一步降低了这种模式的可应用性。
为了便于讨论,假定主类层次结构是固定的;也许它是由其他供应商提供的,不能对该层次结构进行改动。如果有这个库的源代码就可以在基类中增加新的虚函数,但是,由于某些原因这是不可行的。一个更可能的方案就是增加新的虚函数,这样做很笨拙,或者说是难以维护的。GoF主张“在跨越不同的节点类上分配所有这些操作,将导致系统难以理解、维护和修改”。(读者将会看到,这样做将导致访问者模式更加难以理解、维护和修改。)GoF的另外一个主张是,要避免由于使用过多的操作而“玷污”了主层次结构的接口(但是,如果接口太“臃肿”了,应该问一下这个对象的要做的事情是否太多了)。
然而,库的创建者必定已预见到,用户将需要加入新的操作到层次结构中去,因此他们将函数visit()包含了进去。
因此(假定实际上需要这么做)两难的窘境就是,用户需要向基类中添加新的成员函数,但是由于某种原因用户不能接触到基类。那么该如何处理这种情况呢?
访问者模式建立于前一节内容所示的双重派遣方案之上。访问者模式允许创建一个独立的类层次结构Visitor而有效地对主类的接口进行扩展,这个独立的类层次结构将主类上的各种操作“虚化”。主类对象仅“接受”访问者,然后调用访问者的动态绑定的成员函数。因此,创建一个访问者,并将其传递给主层次结构,便可以获得和虚函数一样的效果。举例如下:
Flower是主层次结构,Flower的各个子类通过函数accept()得到一个Visitor。Flower主层次结构除了函数accept()外没有别的操作,因此Flower层次结构的所有功能都将包含在Visitor层次结构中。注意,Visitor类必须要了解Flower的所有具体类型,如果添加一个Flower的新类型,整个Visitor层次结构必须重新工作。
每个Flower中的accept()函数开始一个双重派遣,如上一节所述的双重派遣。第1次派遣决定了Flower的准确类型,第2次派遣决定了Visitor的准确类型。一旦知道了它们的准确类型,就可以对这两者执行恰当的操作。
因为其不寻常的动机以及显得愚笨的约束,使得人们极不可能使用访问者模式。GoF的例子是难以令人信服的—首先是编译器(编写编译器的人不是很多,似乎极少有人将访问者模式用于这些编译器中),他们也不适用于其他一些例子,认为用户实际上不可能像这样使用访问者模式来解决问题。为了使用访问者模式,用户将面临比在GoF中表现出来更大的压力从而抛弃普通的面向对象结构—这样做实际上获得了什么益处而值得换来如此多的复杂性和限制呢?当发现需要多个新的虚函数时为何不可以在基类中仅添加它们?或者,如果实际上需要添加新函数到现存的层次结构中而又不能修改那个层次结构,在这种情况下为什么不先考虑尝试使用多重继承呢?(尽管如此,用这种方法“挽救”现存的层次结构的可能性还是很小的。)出于同样的考虑,为了使用访问者模式,现存的层次结构必须一开始就将函数visit()包括进来,因为如果它在后面添加进来的话就意味着可以修改这个层次结构,这样就能在该层次结构中添加需要的普通虚函数了。不!访问者模式从开始就必须是体系结构的一部分,为了使用它需要有比在GoF中提到的更伟大的动机。[2]
之所以在这里介绍访问者模式,是因为看到它在不该使用的时候被使用了,正如多重继承和任何其他很多方法被不正确地使用一样。在使用访问者模式之前务必三思,多问几个为什么。比如,真的不能在基类中添加新的虚函数了吗?在主层次结构中真的需要限制添加新的类型吗?
[1]这个例子出现在其他作者的书籍中之前,用C++和Java两种语言描述的这个例子已在网站www.MindView.net存在了多年而没有归属。
[2]将访问者模式包含在GoF中的动机可能是因为它非常灵巧。在一个专题讨论会上,GoF的一个作者对我们中的一个人这样说过:“访问者是我最喜欢的模式。”