访问者模式填补单分派语言的缺陷 | 总字数: 3.8k | 阅读时长: 14分钟 | 浏览量: |
访问者模式是 GoF 23 种经典设计模式中最难理解的模式之一。因为它难理解、难实现,应用它可能会导致代码的可读性、可维护性变差,所以,访问者模式在实际的软件开发中很少被用到,在没有特别必要的情况下,建议不要使用访问者模式。
访问者模式的诞生过程
让元素打印自身的信息 假设现在存在两个元素类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 abstract class Element { abstract void printInfo () ; } class ConcreteElementA extends Element { String info = "aaaaa" ; @Override public void printInfo () { System.out.println(info); } } class ConcreteElementB extends Element { Integer info = 0xbbbbb ; @Override public void printInfo () { System.out.printf("%x\n" ,info); } }
注意到,每个 ConcreteElement
中保存的信息类型并不相同。
给定元素的列表,我们可以打印每个元素的信息:
1 2 3 4 5 6 7 8 public static void main (String[] args) { List<Element> elements = new ArrayList <>(); elements.add(new ConcreteElementA ()); elements.add(new ConcreteElementB ()); for (Element e : elements) { e.printInfo(); } }
假设现在每个元素需要在已有 printInfo()
方法的基础上,增加各种处理 info
的方法。这时候需要为每个类都实现这些新方法,这会导致:
违背 站内文章 开闭原则 ,添加一个新的功能,所有类的代码都要修改; 虽然功能增多,每个类的代码都不断膨胀,可读性和可维护性都变差了; 将一些比较上层的业务逻辑耦合到了每个 ConcreteElement
中,导致类的职责不单一。 让访问者打印元素信息的尝试 下面将使用访问者模式重构:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 abstract class Element {} class ConcreteElementA extends Element { String info = "aaaaa" ; } class ConcreteElementB extends Element { Integer info = 0xbbbbb ; } class Visitor { public void printInfo (ConcreteElementA a) { System.out.println(a.info); } public void printInfo (ConcreteElementB b) { System.out.printf("%x\n" , b.info); } }
Visitor
包揽打印元素信息的活,通过重载的方式对不同元素实现不同的打印方法。
打印元素:
1 2 3 4 5 6 7 8 9 10 public static void main (String[] args) { List<Element> elements = new ArrayList <>(); elements.add(new ConcreteElementA ()); elements.add(new ConcreteElementB ()); Visitor visitor = new Visitor (); for (Element e : elements) { visitor.printInfo(e); } }
以上代码无法通过编译 :
IDE 会在 visitor.printInfo(e);
中报 Cannot resolve method 'printInfo(Element)"
错误提示。 编译出错:java: 对于printInfo(top.uuanqin.Element), 找不到合适的方法
原因:多态是一种动态绑定,可以在运行时获取对象的实际类型,来运行实际类型对应的方法。而函数重载是一种静态绑定,在编译时并不能获取对象的实际类型,而是根据声明类型执行声明类型对应的方法。
让访问者传入元素内并打印信息 我们为每个具体元素实现接收访问者的方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 abstract class Element { abstract public void accept (Visitor visitor) ; } class ConcreteElementA extends Element { String info = "aaaaa" ; @Override public void accept (Visitor visitor) { visitor.printInfo(this ); } } class ConcreteElementB extends Element { Integer info = 0xbbbbb ; @Override public void accept (Visitor visitor) { visitor.printInfo(this ); } } class Visitor { public void printInfo (ConcreteElementA a) { System.out.println(a.info); } public void printInfo (ConcreteElementB b) { System.out.printf("%x\n" , b.info); } }
遍历元素集合,将访问者传入:
1 2 3 4 5 6 7 8 9 10 public static void main (String[] args) { List<Element> elements = new ArrayList <>(); elements.add(new ConcreteElementA ()); elements.add(new ConcreteElementB ()); Visitor visitor = new Visitor (); for (Element e: elements) { e.accept(visitor); } }
这里的 Visitor
中的重载函数能被成功调用的关键,在于每个 ConcreteElement
的 accept()
方法接收了访问者,并将自身 this
传递进去。连同着 ConcreteElement
的类型信息传给 Visitor
后,编译器自然会知道应当调用 Visitor
的哪个重载函数。
不要因为看到每个 ConcreteElement
的 accept()
代码似乎都一样就把它提到父类中。如果这样的话, Visitor
重载还是会出现和之前一样的编译错误。
不知道为什么,访问者模式让我想起三借芭蕉扇中,铁扇公主吞下孙悟空的场景。
最终版:访问者模式中添加新的功能 假设除了每个元素除了 printInfo()
外,还需要新增方法 printInfoType()
用于打印消息类型。一个想法是:
Element
新增抽象接口接收新的 visitor
。相应的,所有的 ConcreteElement
都实现这个接口。 新增 visitor
,编写重载函数 printInfoType()
。 这种想法存在一些问题,当我们需要添加新的业务时,还是会需要改动到 Element
的代码。针对这个问题,我们抽象出一个 Visitor
接口,包含一个名为 visit()
的重载函数。visit()
是一个通用化的起名方式,不同的 ConcreteVisitor
将会有不同的实现,代表不同的业务。
Visitor
的实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 interface Visitor { public void visit (ConcreteElementA a) ; public void visit (ConcreteElementB b) ; } class InfoVisitor implements Visitor { @Override public void visit (ConcreteElementA a) { System.out.println(a.info); } @Override public void visit (ConcreteElementB b) { System.out.printf("%x\n" , b.info); } } class InfoTypeVisitor implements Visitor { @Override public void visit (ConcreteElementA a) { System.out.println(a.info.getClass().getName()); } @Override public void visit (ConcreteElementB b) { System.out.println(b.info.getClass().getName()); } }
Element
的代码在业务增加时不需要改动。注意,此处的 Visitor
是个更为通用的接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 abstract class Element { abstract public void accept (Visitor visitor) ; } class ConcreteElementA extends Element { String info = "aaaaa" ; @Override public void accept (Visitor visitor) { visitor.visit(this ); } } class ConcreteElementB extends Element { Integer info = 0xbbbbb ; @Override public void accept (Visitor visitor) { visitor.visit(this ); } }
Main
中的调用方法:
1 2 3 4 5 6 7 8 9 10 11 12 public static void main (String[] args) { List<Element> elements = new ArrayList <>(); elements.add(new ConcreteElementA ()); elements.add(new ConcreteElementB ()); Visitor infoVisitor = new InfoVisitor (); Visitor infoTypeVisitor = new InfoTypeVisitor (); for (Element e: elements) { e.accept(infoVisitor); e.accept(infoTypeVisitor); } }
访问者模式 访问者模式 Visitor Design Pattern
Allows for one or more operation to be applied to a set of objects at runtime, decoupling the operations from the object structure.——GoF 允许一个或者多个操作应用到一组对象上,解耦操作和对象本身。
在访问者模式中,数据结构与处理被分离开来。我们编写一个表示「访问者」的类来访问数据结构中的元素,并把对各元素的处理交给访问者类。这样,当需要增加新的处理时,我们只需要编写新的访问者,然后让数据结构可以接受访问者的访问即可。
登场角色:
Visitor
(访问者):Visitor
角色负责对数据结构中每个具体的元素(ConcreteElement
角色)声明一个用于访问 Xxxxx
的 visit(Xxxxx)
方法。visit(Xxxxx)
是用于处理 Xxxxx
的方法,负责实现该方法的是 ConcreteVisitor
角色。 ConcreteVisitor
(具体的访问者):ConcreteVisitor
角色负责实现 Visitor
角色所定义的接口(API)。它要实现所有的 visit(Xxxxx)
方法,即实现如何处理每个 ConcreteElement
角色。具体访问者知晓具体元素。 Element
(元素):Element
角色表示 Visitor
角色的访问对象。它声明了接受访问者的 accept
方法。accept
方法接收到的参数是 Visitor
角色。 ConcreteElement
(具体元素):ConcreteElement
角色负责实现 Element
角色所定义的接口(API)。 ObjectStructure
(对象结构):ObjectStructure
角色负责处理 Element
角色的集合。ConcreteVisitor
角色为每个 Element
角色都准备了处理方法。 一般来说,访问者模式针对的是一组类型不同的对象 ConcreteElement
。不过,尽管这组对象的类型是不同的,但是,它们继承相同的父类 Element
或者实现相同的接口。在不同的应用场景下,我们需要对这组对象进行一系列不相关的业务操作,但为了避免不断添加功能导致 ConcreteElement
不断膨胀,职责越来越不单一,以及避免频繁地添加功能导致的频繁代码修改,我们使用访问者模式,将对象与操作解耦,将这些业务操作抽离出来,定义在独立细分的访问者 ConcreteVisitor
中。
Visitor
工作条件:Element
角色必须向 Visitor
角色公开足够多的信息。缺点就是,如果公开了不应当被公开的信息,将来对数据结构的改良就会变得非常困难。
优点 缺点 开闭原则。可以引入在不同类对象上执行的新行为,且无需对这些类做出修改。 难以增加 ConcreteElement
角色。每次在元素层次结构中添加或移除一个类时,你都要更新所有的访问者。 单一职责原则。可将同一行为的不同版本移到同一个类中。 在访问者同某个元素进行交互时,它们可能没有访问元素私有成员变量和方法的必要权限。如果公开了不应当被公开的信息,将来对数据结构的改良就会变得非常困难。 访问者对象可以在与各种对象交互时收集一些有用的信息。当你想要遍历一些复杂的对象结构 (例如对象树),并在结构中的每个对象上应用访问者时,这些信息可能会有所帮助。 易于增加 ConcreteVisitor
角色。
拓展思路:
扩展点在于容易拓展访问者 ConcreteVisitor
,不易扩展被访问者 ConcreteElement
。新增 ConcreteElement
后,需要在所有 ConcreteVisitor
中添加相应的重载方法。 访问者对象可以在与各种对象交互时收集一些有用的信息。 当你想要遍历一些复杂的对象结构 (例如对象树), 并在结构中的每个对象上应用访问者时, 这些信息可能会有所帮助。 相关的设计模式 其他相关设计模式:
站内文章 迭代器模式 :迭代器模式和访问者模式都是在某种数据结构上进行处理。 迭代器模式用于逐个遍历保存在数据结构中的元素。 访问者模式用于对保存在数据结构中的元素进行某种特定的处理。 站内文章 组合模式 :有时访问者所访问的数据结构会使用组合模式。 站内文章 解释器模式 :在解释器模式中,有时会使用访问者模式。例如,在生成了语法树后,可能会使用访问者模式访问语法树的各个节点进行处理。 站内文章 命令模式 :可以将访问者模式看成是命令模式的加强版,其对象可对不同类的多种对象执行操作。 在本文首章中提到的案例是访问者模式的简化版,如果业务功能并不多,我们其实可以使用 站内文章 工厂模式 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 package top.uuanqin.dispatch;import java.util.ArrayList;import java.util.HashMap;import java.util.List;import java.util.Map;public class FactoryTest { public static void main (String[] args) { List<Element> elements = new ArrayList <>(); elements.add(new ConcreteElementA ()); elements.add(new ConcreteElementB ()); for (Element e: elements) { InfoPrinter infoPrinter = InfoPrinterFactory.getInfoPrinter(e); infoPrinter.printInfo(e); } } } abstract class Element {}class ConcreteElementA extends Element { String info = "aaaaa" ; } class ConcreteElementB extends Element { Integer info = 0xbbbbb ; } interface InfoPrinter { void printInfo (Element e) ; } class ElementAInfoPrinter implements InfoPrinter { @Override public void printInfo (Element e) { ConcreteElementA a = (ConcreteElementA) e; System.out.println(a.info); } } class ElementBInfoPrinter implements InfoPrinter { @Override public void printInfo (Element e) { ConcreteElementB b = (ConcreteElementB) e; System.out.printf("%x\n" , b.info); } } class InfoPrinterFactory { public static final Map<String, InfoPrinter> INFO_PRINTERS = new HashMap <>(); static { INFO_PRINTERS.put(ConcreteElementA.class.getName(), new ElementAInfoPrinter ()); INFO_PRINTERS.put(ConcreteElementB.class.getName(), new ElementBInfoPrinter ()); } public static InfoPrinter getInfoPrinter (Element element) { return INFO_PRINTERS.get(element.getClass().getName()); } }
当需要增加功能时,如 printInfoType
,我们需要增加:
工厂类 InfoTypePrinterFactory
接口 InfoTypePrinter
实现类 ElementAInfoTypePrinter
、ElementBInfoTypePrinter
双分派的语言不需要访问者模式 单分派和双分派:
单分派 Single Dispatch 双分派 Double Dispatch 执行哪个对象 根据对象 运行时类型 决定 根据对象的 运行时类型 来决定 执行对象 A
的哪个方法 根据方法参数的 编译时类型 来决定 根据方法参数的 运行时类型 来决定 语言 Java、C++、C#
在面向对象编程语言中,我们可以把方法调用理解为一种消息传递,也就是「分派 Dispatch」。一个对象调用另一个对象的方法,就相当于给它发送一条消息。这条消息起码要包含对象名、方法名、方法参数。
具体到访问者模式中,element
接受 visitor
,而 visitor
又访问 element
。ConcreteElement
和 ConcreteVisitor
这两个角色共同决定了实际进行的处理。这种消息分发的方式一般被称为双重分发(Double dispatch)。
所谓「单」「双」,指的是执行哪个对象的哪个方法,跟几个因素的运行时类型 有关。具体到编程语言的语法机制,单分派和双分派和多态、函数重载直接相关。以 Java 为例,Java 支持多态特性,代码可以在运行时获得对象的实际类型(运行时类型),然后根据实际类型决定调用哪个方法。尽管 Java 支持函数重载,但 Java 设计的函数重载的语法规则时,并不是在运行时根据传递进函数的参数的实际类型来决定调用哪个重载函数,而是在编译时根据传递进函数的参数的声明类型(编译时类型)来决定调用哪个重载函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 public class DispatchTest { public static void main (String[] args) { SingleDispatchClass demo = new SingleDispatchClass (); ParentClass p = new ChildClass (); System.out.println("示例1:" ); demo.polymorphismFunction(p); System.out.println("\n示例2:" ); demo.overloadFunction(p); } } class ParentClass { public void f () { System.out.println("执行 ParentClass's f()." ); } } class ChildClass extends ParentClass { @Override public void f () { System.out.println("执行 ChildClass's f()." ); } } class SingleDispatchClass { public void polymorphismFunction (ParentClass p) { p.f(); } public void overloadFunction (ParentClass p) { System.out.println("进入重载函数 overloadFunction(ParentClass p)." ); p.f(); } public void overloadFunction (ChildClass c) { System.out.println("进入重载函数 overloadFunction(ChildClass c)." ); c.f(); } }
代码执行结果:
1 2 3 4 5 6 示例1: 执行 ChildClass's f(). 示例2: 进入重载函数 overloadFunction(ParentClass p). 执行 ChildClass's f().
访问者模式使用了双分派的技巧,让单分派的语言选择正确的类并执行正确的方法。尽管访问者模式基于双分派的原则创建,但这并不是其主要目的。访问者的目的是让你能为整个类层次结构添加「外部」操作,而无需修改这些类的已有代码。
本文参考