架构师内功心法,23种设计模式中最为复杂的访问者模式详解

访问者模式(Visitor Pattern)是一种将数据结构和数据操作分离的设计模式。是指封装一些作用于某种数据结构中的各种元素的操作,它可以在不改变数据结构的前提下定义作用于这些元素的新的操作。

访问者模式被称为最复杂的设计模式,并且使用频率不高,设计模式的作者也评价为:大多情况下,你不需要使用访问者模式,但是一旦需要使用它时,那就真的需要使用了。访问者模式的基本思想是,针对系统中拥有固定类型数的对象结构(元素),在其内提供一个accept()方法用来接受访问者对象的访问。不同的访问者对同一元素的访问内容不同,使得相同的元素集合可以产生不同的数据结果。accept()方法可以接收不同的访问者对象,然后在内部将自己(元素)转发到接收到的访问者对象的visit()方法内。访问者内部对应类型的visit()方法就会得到回调执行,对元素进行操作。也就是通过两次动态分发(第一次是对访问者的分发accept()方法,第二次是对元素的分发visit()方法),才最终将一个具体的元素传递到一个具体的访问者。如此一来,就解耦了数据结构与操作,且数据操作不会改变元素状态。

一、访问者模式的应用场景

访问者模式在生活场景中也是非常当多的,例如每年年底的KPI考核,KPI考核标准是相对稳定的,但是参与KPI考核的员工可能每年都会发生变化,那么员工就是访问者。我们平时去食堂或者餐厅吃饭,餐厅的菜单和就餐方式是相对稳定的,但是去餐厅就餐的人员是每天都在发生变化的,因此就餐人员就是访问者。


访问者模式的核心是,解耦数据结构与数据操作,使得对元素的操作具备优秀的扩展性。可以通过扩展不同的数据操作类型(访问者)实现对相同元素的不同的操作。简而言之就是对集合中的不同类型数据(类型数量稳定)进行多种操作,则使用访问者模式。

访问者模式的应用场景适用于以下几个场景:

  • 数据结构稳定,作用于数据结构的操作经常变化的场景;
  • 需要数据结构与数据操作呢分离的场景;
  • 需要对不同数据类型(元素)进行操作,而不使用分支判断具体类型的场景。

访问者模式主要包含五种角色:

  • 抽象访问者(Visitor):接口或抽象类,该类地冠以了对每一个具体元素(Element)的访问行为visit()方法,其参数就是具体的元素(Element)对象。理论上来说,Visitor的方法个数与元素(Element)个数是相等的。如果元素(Element)个数经常变动,会导致Visitor的方法也要进行变动,此时,该情形并不适用访问者模式;
  • 具体访问者(ConcreteVisitor):实现对具体元素的操作;
  • 抽象元素(Element):接囗或抽象类,定义了一个接受访问者访问的方法accept()表示所有元素类型都支持被访问者访问;
  • 具体元素(ConcreteElement):具体元素类型,提供接受访问者的具体实现。通常的实现都为:visitor.visit(this);
  • 结构对象(ObjectStructure):内部维护了元素集合,并提供方法接受访问者对该集合所有元素进行操作。

1.1 利用访问者模式实现公司KPI考核

每到年底,公司的管理层就要开始评定员工一年的工作绩效了,管理层有CEO和CTO,那么CEO关注的是工程师的KPI和经理的KPI以及新产品的数量,而CTO关心的是工程师的代码量、经理的新产品数量。

由于CEO和CTO对于不同员工的关注点是不一样的,这就需要对不同的员工类型进行不同的处理。此时访问者模式就派上用场了。下面来看下具体的代码实现,首先创建员工Employee类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public abstract class Employee {

private String name;

private int kpi;

public Employee(String name) {
this.name = name;
this.kpi = new Random().nextInt(10);
}

/**
* 接收访问者的访问
* @param visitor
*/
public abstract void accept(IVisitor visitor);
}

Employee类的accept()方法表示接受访问者的访问,由具体的子类实现。访问者是一个接口,传入不同的实现类,可以访问不同的数据。 分别创建工程师Engineer类和经理Manager类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Engineer extends Employee {
public Engineer(String name) {
super(name);
}

@Override
public void accept(IVisitor visitor) {
visitor.visit(this);
}

public int getCodeLines() {
return new Random().nextInt(10 * 10000);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Manager extends Employee {
public Manager(String name) {
super(name);
}

@Override
public void accept(IVisitor visitor) {
visitor.visit(this);
}

public int getPrducts() {
return new Random().nextInt(10);
}
}

工程师考核的是代码数量,经理考核的是产品数量,二者的职责不一样。也正是因为有这样的差异性,才使得访问模式能够在这个场景下发挥作用。将这些员工添加到一个业务报表类中,公司高层可以通过该报表类的showReport()方法查看所有员工的绩效,创建BusinessReport类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class BusinessReport {

private List<Employee> employeeList = new LinkedList<>();

public BusinessReport() {
employeeList.add(new Engineer("工程师1"));
employeeList.add(new Engineer("工程师2"));
employeeList.add(new Engineer("工程师3"));
employeeList.add(new Engineer("工程师4"));

employeeList.add(new Manager("产品经理1"));
employeeList.add(new Manager("产品经理2"));
}

/**
*
* @param visitor 公司高层,如CEO,CTO
*/
public void showReport(IVisitor visitor) {
for(Employee employee : employeeList) {
employee.accept(visitor);
}
}
}

定义访问者类型,创建接口IVisitor,访问者声明了两个visit()方法,分别针对工程师和经理,代码如下:

1
2
3
4
5
6
7
public interface IVisitor {

void visit(Engineer engineer);

void visit(Manager manager);

}

具体访问者CEOVisitor和CTOVisitor类:

1
2
3
4
5
6
7
8
9
10
11
12
public class CEOVisitor implements IVisitor {
@Override
public void visit(Engineer engineer) {
System.out.println("工程师:" + engineer.name + ", KPI:" + engineer.kpi);
}

@Override
public void visit(Manager manager) {
System.out.println("经理:" + manager.name + ", KPI:" + manager.kpi +
", 新产品数量" + manager.getPrducts() );
}
}
1
2
3
4
5
6
7
8
9
10
11
12
public class CTOVisitor implements IVisitor {
@Override
public void visit(Engineer engineer) {
System.out.println("工程师:" + engineer.name + ", 代码数量:" + engineer.getCodeLines());
}

@Override
public void visit(Manager manager) {
System.out.println("经理:" + manager.name +
", 新产品数量" + manager.getPrducts() );
}
}

测试main方法:

1
2
3
4
5
public static void main(String[] args) {
BusinessReport businessReport = new BusinessReport();
businessReport.showReport(new CEOVisitor());
businessReport.showReport(new CTOVisitor());
}

运行结果如下:


在上述的案例中,Employee扮演了Element角色,而Engineer和Manager都是ConcreteElement;CEOVisitor和CTOVisitor都是具体的Visitor对象;而BusinessReport就是ObjectStructure。

访问者模式最大的优点就是增加访问者非常容,我们从代码中可以看到,如果要增加一访问者,只要新实现一个访问者接口的类,从而达到数据对象与数据操作相分离的效果。如果不实用访问者模式而又不想对不同的元素进行不同的操作,那么必定需要使用if-else和类型转换,这使得代码唯以升级维护。

我们要根据具体情况来评估是否适合使用访问者模式,例如,我们的对象结构是否足够稳定是否需要经常定义新的操作,使用访问者模式是否能优化我们的代码而不是使我们的代码变得更复杂。

1.2 从静态分派到动态分派

变量被声明时的类型叫做变量的静态类型(Static Type),有些人把静态类型叫做明显类型(Apparent Type);而变量所引用的对象的真是类型又叫做变量的实际类型(Actual Type)。 比如:

1
2
List list = null;
list = new ArrayList();

上面的代码声明了一个list,它的静态类型(也叫明显类型)是List,而它的实际类型是ArrayList。根据对象的类型而对方法进行的选择,就是分派(Dispatch)。分派又分为两种,即动态分派和静态分派。

1.2.1 静态分派

静态分派(Static Dispatch)就是按照变量的静态类型进行分派,从而确定方法的执行版本,静态分派在编译时期就可以确定方法的版本。而静态分配最经典的就是方法重载,请看下面的这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class StaticDispatch {

public void test(String string) {
System.out.println("string");
}

public void test(Integer integer) {
System.out.println("integer");
}

public static void main(String[] args) {
String string = "1";
Integer integer = 1;
StaticDispatch staticDispatch = new StaticDispatch();
staticDispatch.test(string);
staticDispatch.test(integer);
}

}

在静态分派判断的时候,我们根据多个判断依据(即参数类型和个数)判断出了方法的版本,那么这个就是多分派的概念,因为有一个以上的考量标准。所以Java语言是静态多分派语言。

1.2.2 动态分派

动态分派,与静态相反,它不是在编译期间确定方法的版本,而是在运行时确定的。Java是动态单分派语言。

1.2.3 访问者模式中的伪动态双分派

通过前面分析,我们知道Java是静态多分派、动态单分派的语言。Java底层不支持动态的双分派。但是通过使用设计模式,也可以在Java语言里实现伪动态双分派。在访问者模式中使用的就是伪动态双分派。所谓动态双分派就是在运行时依据两个实际类型去判断一个方法的运行行为,而访问者模式实现的手段是进行了两次动态单分派来达到这个效果。
还是回到前面的公司KPI考核业务场景当中,BusinessReport类中的showReport()方法:

1
2
3
4
5
6
7
8
9
 /**
*
* @param visitor 公司高层,如CEO,CTO
*/
public void showReport(IVisitor visitor) {
for(Employee employee : employeeList) {
employee.accept(visitor);
}
}

这里就是依据Employee和IVisitor两个实际类型决定了showReport()方法的执行结果从而决定了accept()方法的动作。

分析accept()方法的调用过程
1、当调用accept()方法时,根据Employee的实际类型决定是调用Engineer还是Manager的accept()方法。

2、这时accept()方法的版本已经确定,假如是Engineer,它的accept()方去是调用下面这行代码。

1
2
3
public void accept(IVisitor visitor) {
visitor.visit(this);
}

此时的this是Engineer类型,所以对应的IVisitor接口的visit(Engineer enginner)方法,此时需要再根据访问者的实际类型确定visit()方法的版本,这样一来,就完成了动态分派的过程。

以上的过程就是通过两次动态双分派,第一次对accept()方法进行动态分派,第二次访问者的visit()方法进行动态分派,从而到达了根据两个实际类型确定一个方法的行为结果。

二、访问者模式在源码中的体现

2.1 NIO中的FileVisitor接口

JDK中的NIO模块下的FileVisitor接口,它提供递归遍历文件树的支持。来看下源码:

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
71
72
73
74
75
76
77
78
79
80
public interface FileVisitor<T> {

/**
* Invoked for a directory before entries in the directory are visited.
*
* <p> If this method returns {@link FileVisitResult#CONTINUE CONTINUE},
* then entries in the directory are visited. If this method returns {@link
* FileVisitResult#SKIP_SUBTREE SKIP_SUBTREE} or {@link
* FileVisitResult#SKIP_SIBLINGS SKIP_SIBLINGS} then entries in the
* directory (and any descendants) will not be visited.
*
* @param dir
* a reference to the directory
* @param attrs
* the directory's basic attributes
*
* @return the visit result
*
* @throws IOException
* if an I/O error occurs
*/
FileVisitResult preVisitDirectory(T dir, BasicFileAttributes attrs)
throws IOException;

/**
* Invoked for a file in a directory.
*
* @param file
* a reference to the file
* @param attrs
* the file's basic attributes
*
* @return the visit result
*
* @throws IOException
* if an I/O error occurs
*/
FileVisitResult visitFile(T file, BasicFileAttributes attrs)
throws IOException;

/**
* Invoked for a file that could not be visited. This method is invoked
* if the file's attributes could not be read, the file is a directory
* that could not be opened, and other reasons.
*
* @param file
* a reference to the file
* @param exc
* the I/O exception that prevented the file from being visited
*
* @return the visit result
*
* @throws IOException
* if an I/O error occurs
*/
FileVisitResult visitFileFailed(T file, IOException exc)
throws IOException;

/**
* Invoked for a directory after entries in the directory, and all of their
* descendants, have been visited. This method is also invoked when iteration
* of the directory completes prematurely (by a {@link #visitFile visitFile}
* method returning {@link FileVisitResult#SKIP_SIBLINGS SKIP_SIBLINGS},
* or an I/O error when iterating over the directory).
*
* @param dir
* a reference to the directory
* @param exc
* {@code null} if the iteration of the directory completes without
* an error; otherwise the I/O exception that caused the iteration
* of the directory to complete prematurely
*
* @return the visit result
*
* @throws IOException
* if an I/O error occurs
*/
FileVisitResult postVisitDirectory(T dir, IOException exc)
throws IOException;
}

这个接口上面定义的方法表示了遍历文件的关键过程,允许在文件被访问、目录被访问、目录已被访问、放生错误过程中进行控制整个流程。调用接口中的方法,会返回访问结果FileVisitResult对象值,用于决定当前操作完成后接下来该如何处理。FileVisitResult的标准返回值存放到枚举类型中:

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
public enum FileVisitResult {
/**
* Continue. When returned from a {@link FileVisitor#preVisitDirectory
* preVisitDirectory} method then the entries in the directory should also
* be visited.
*/
//当前的遍历过程将会继续
CONTINUE,
/**
* Terminate.
*/
//表示当前的遍历过程将会停止
TERMINATE,
/**
* Continue without visiting the entries in this directory. This result
* is only meaningful when returned from the {@link
* FileVisitor#preVisitDirectory preVisitDirectory} method; otherwise
* this result type is the same as returning {@link #CONTINUE}.
*/
//当前的遍历过程将会继续,但是要忽略当前目录下的所有节点
SKIP_SUBTREE,
/**
* Continue without visiting the <em>siblings</em> of this file or directory.
* If returned from the {@link FileVisitor#preVisitDirectory
* preVisitDirectory} method then the entries in the directory are also
* skipped and the {@link FileVisitor#postVisitDirectory postVisitDirectory}
* method is not invoked.
*/
//当前的遍历过程将会继续,但是要忽略当前文件/目录的兄弟节点
SKIP_SIBLINGS;
}

2.2 Spring中的BeanDefinitionVisitor类

在Spring的Ioc中有个BeanDefinitionVisitor类,它有一个visitBeanDefinition()方法,看下源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public void visitBeanDefinition(BeanDefinition beanDefinition) {
visitParentName(beanDefinition);
visitBeanClassName(beanDefinition);
visitFactoryBeanName(beanDefinition);
visitFactoryMethodName(beanDefinition);
visitScope(beanDefinition);
if (beanDefinition.hasPropertyValues()) {
visitPropertyValues(beanDefinition.getPropertyValues());
}
if (beanDefinition.hasConstructorArgumentValues()) {
ConstructorArgumentValues cas = beanDefinition.getConstructorArgumentValues();
visitIndexedArgumentValues(cas.getIndexedArgumentValues());
visitGenericArgumentValues(cas.getGenericArgumentValues());
}
}

在其方法中分别访问了其它的数据,比如父类的名字、自己的类名、在Ioc容器中的名称等各种信息。

三、访问者模式的优缺点

优点

  • 解耦了数据结构与数据操作,使得操作集合可以独立变化;
  • 扩展性好:可以通过扩展访问者角色,实现对数据集的不同操作;
  • 元素具体类型并非单一,访问者均可操作;
  • 各角色职责分离,符合单一职责原则。

缺点

  • 无法增加元素类型:若系统数据结构对象另于变化,经常有新的数据对象增加进来,则访问者类必须增加对应元素类型的操作,违背了开闭原则。