image.png

访问者模式是 GoF 23 种经典设计模式中最难理解的模式之一。因为它难理解、难实现,应用它可能会导致代码的可读性、可维护性变差,所以,访问者模式在实际的软件开发中很少被用到,在没有特别必要的情况下,建议不要使用访问者模式。

访问者模式的诞生过程

image.png

让元素打印自身的信息

假设现在存在两个元素类:

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()); // aaaaa
elements.add(new ConcreteElementB()); // bbbbb
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); // Cannot resolve method 'printInfo(Element)"
}
}

以上代码无法通过编译

  • 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); // visitor 中的重载函数能被正确调用
}
}

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 中的重载函数能被成功调用的关键,在于每个 ConcreteElementaccept() 方法接收了访问者,并将自身 this 传递进去。连同着 ConcreteElement 的类型信息传给 Visitor 后,编译器自然会知道应当调用 Visitor 的哪个重载函数。

不要因为看到每个 ConcreteElementaccept() 代码似乎都一样就把它提到父类中。如果这样的话, 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 角色)声明一个用于访问 Xxxxxvisit(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;

/**
* @author uuanqin
*/
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
  • 实现类 ElementAInfoTypePrinterElementBInfoTypePrinter

双分派的语言不需要访问者模式

单分派和双分派:

单分派 Single Dispatch 双分派 Double Dispatch
执行哪个对象 根据对象 运行时类型 决定 根据对象的 运行时类型 来决定
执行对象 A 的哪个方法 根据方法参数的 编译时类型 来决定 根据方法参数的 运行时类型 来决定
语言 Java、C++、C#

在面向对象编程语言中,我们可以把方法调用理解为一种消息传递,也就是「分派 Dispatch」。一个对象调用另一个对象的方法,就相当于给它发送一条消息。这条消息起码要包含对象名、方法名、方法参数。

具体到访问者模式中,element 接受 visitor,而 visitor 又访问 elementConcreteElementConcreteVisitor 这两个角色共同决定了实际进行的处理。这种消息分发的方式一般被称为双重分发(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().

访问者模式使用了双分派的技巧,让单分派的语言选择正确的类并执行正确的方法。尽管访问者模式基于双分派的原则创建,但这并不是其主要目的。访问者的目的是让你能为整个类层次结构添加「外部」操作,而无需修改这些类的已有代码。

本文参考