大白话聊访问者模式:从入门到实践

访问者模式,重点在于访问者二字。说到访问,我们脑海中必定会想起新闻访谈,两个人面对面坐在一起。从字面上的意思理解:其实就相当于被访问者(某个公众人物)把访问者(记者)当成了外人,不想你随便动。你想要什么,我弄好之后给你(调用你的方法)。
01 什么是访问者模式?
访问者模式的定义如下所示,说的是在不改变数据结构的提前下,定义新操作。
封装一些作用于某种数据结构中的各元素的操作,它可以在不改变数据结构的前提下定义作用于这些元素的新的操作。
但在实际的应用中,我发现有些例子并不是如此。有些例子中并没有稳定的数据结构,而是稳定的算法。在树义看来,访问者模式是:把不变的固定起来,变化的开放出去。
我们举生活中一个例子来聊聊:某科学家接受记者访谈。我们都知道科学家接受访问,肯定是有流程上的限制的,不可能让你随便问。我们假设这个过程是:先问科学家的学校经历,再聊你的工作经历,最后聊你的科研成果。那么在这个过程中,固定的是什么东西呢?固定的是接受采访的流程。变化的是什么呢?变化的是不同的记者,针对学校经历,可能会提不同的问题。
根据我们之前的理解,访问者模式其实就是要把不变的东西固定起来,变化的开放出去。那么对于科学家接受访谈这个事情,我们可以这么将其抽象化。
首先,我们需要有一个 Visitor 类,这里定义了一些外部(记者)可以做的事情(提学校经历、工作经历、科研成就的问题)。
public interface Visitor {
public void askSchoolExperience(String name);
public void askWorkExperience(String name);
public void askScienceAchievement(String name);
}
接着声明一个 XinhuaVisitor 类去实现 Visitor 类,这表示是新华社的一个记者(访问者)想去访问科学家。
public class XinhuaVisitor implements Visitor{
@Override
public void askSchoolExperience(String name) {
System.out.printf(“请问%s:在学校取得的最大成就是什么?\n”, name);
}
@Override
public void askWorkExperience(String name) {
System.out.printf(“请问%s:工作上最难忘的事情是什么?\n”, name);
}
@Override
public void askScienceAchievement(String name) {
System.out.printf(“请问%s:最大的科研成果是什么?”, name);
}
}
接着声明一个 Scientist 类,表明是一个科学家。科学家通过一个 accept () 方法接收记者(访问者)的访问申请,将其存储起来。科学家定义了一个 interview 方法,将访问的流程固定死了,只有教你问什么的时候,我才会让你(记者)提问。
public class Scientist {
private Visitor visitor;
private String name;
private Scientist(){}
public Scientist(String name) {
this.name = name;
}
public void accept(Visitor visitor) {
this.visitor = visitor;
}
public void interview(){
System.out.println(“————访问开始————“);
System.out.println(“—开始聊学校经历—“);
visitor.askSchoolExperience(name);
System.out.println(“—开始聊工作经历—“);
visitor.askWorkExperience(name);
System.out.println(“—开始聊科研成果—“);
visitor.askScienceAchievement(name);
}
}
最后我们声明一个场景类 Client,来模拟访谈这一过程。
public class Client {
public static void main(String[] args) {
Scientist yang = new Scientist(“杨振宁”);
yang.accept(new XinhuaVisitor());
yang.interview();
}
}
运行的结果为:
————访问开始————
—开始聊学校经历—
请问杨振宁:在学校取得的最大成就是什么?
—开始聊工作经历—
请问杨振宁:工作上最难忘的意见事情是什么?
—开始聊科研成果—
请问杨振宁:最大的科研成果是什么?
看到这里,大家对于访问者模式的本质有了更感性的认识(把不变的固定起来,变化的开放出去)。在这个例子中,不变的固定的就是访谈流程,变化的就是你可以提不同的问题。
一般来说,访问者模式的类结构如下图所示:
图片
Visitor 访问者接口。访问者接口定义了访问者可以做的事情。这个需要你去分析哪些是可变的,将这些可变的内容抽象成访问者接口的方法,开放出去。而被访问者的信息,其实就是通过访问者的参数传递过去。
ConcreteVisitor 具体访问者。具体访问者定义了具体某一类访问者的实现。对于新华社记者来说,他们更关心杨振宁科学成果方面的事情,于是他们提问的时候更倾向于挖掘成果。但对于青年报记者来说,他们的读者是青少年,他们更关心杨振宁在学习、工作中的那种精神。
Element 具体元素。这里指的是具体被访问的类,在我们这个例子中指的是 Scientist 类。一般情况下,我们会提供一个 accept () 方法,接收访问者参数,将相当于接受其范文申请。但这个方法也不是必须的,只要你能够拿到 visitor 对象,你怎么定义这个参数传递都可以。
对于访问者模式来说,最重要的莫过于 Visitor、ConcreteVisitor、Element 这三个类了。Visitor、ConcreteVisitor 定义访问者具体能做的事情,被访问者的参数通过参数传递给访问者。Element 则通过各种方法拿到被访问者对象,常用的是通过 accept () 方法,但这并不是绝对的。
需要注意的是,我们学习设计模式重点是理解类与类之间的关系,以及他们传递的信息。至于是通过什么方式传递的,是通过 accept () 方法,还是通过构造函数,都不是重点。
02 访问者模式的实际应用
前面我们用一个生活的例子帮助大家理解访问者模式,相信大家对访问者模式应该有了个感性的理解了。为了回归编程实践本身,让大家对访问者模式能有更好的实践理解。下面我们将从软件编程上讲讲访问者模式在开源框架中的应用。
文件树遍历
JDK 中有文件操作,我们自然是清楚的。有文件操作,那自然就会有文件夹的遍历操作,即访问某个文件夹下面的所有文件或文件夹。试想一下,如果我们想要打印出某个文件夹下所有文件及文件夹的名字,我们需要怎么做?
很简单的做法,其实就是直接做一个树的遍历,然后将名字打印出来呀!
没错,这确实是正确答案!
那么如果我希望统计一下所有文件及文件夹的个数呢?
那就再遍历一次,然后用一个计数器去一直加一呗!
没错,这也是正确答案!
但你是否发现了这两个过程中,我们有一个相同的操作:遍历文件树。无论是打印文件名,还是计算文件树,我们都需要去遍历文件树。而无论哪一个过程,我们最终要的其实就是访问文件。
还记得我们说过设计模式的本质是什么吗?设计模式的本质是找出不变的东西,再找出变化的东西,然后找到合适的数据结构(设计模式)去承载这种变化。
在这个例子里,不变的东西是文件树的遍历,变化的是对于文件的不同访问操作。很显然,访问者模式是比较适合承载这种变化的。我们可以把这种不变的东西(文件树的遍历)固定起来,把变化的东西(文件的具体操作)开放出去。JDK 对于文件树的遍历,其实就是使用访问者模式实现的。
JDK 中声明了一个 FileVisitor 接口,定义了遍历者可以做的操作。

public interface FileVisitor {
FileVisitResult preVisitDirectory(T dir, BasicFileAttributes attrs);
FileVisitResult visitFile(T file, BasicFileAttributes attrs)
throws IOException;
FileVisitResult visitFileFailed(T file, IOException exc)
throws IOException;
FileVisitResult postVisitDirectory(T dir, IOException exc)
throws IOException;
}
FileVisitor 中定义的 visitFile () 方法,其实就是对于文件的访问。被访问者(文件)的信息通过第一个参数 file 传递过来。这样遍历者就可以访问文件的内容了。
SimpleFileVisitor 则是对于 FileVisitor 接口的实现,该类中仅仅是做了简单的参数校验,并没有太过的逻辑。

public class SimpleFileVisitor
implements FileVisitor {
@Override
public FileVisitResult preVisitDirectory(T dir, BasicFileAttributes attrs)
throws IOException
{
Objects.requireNonNull(dir);
Objects.requireNonNull(attrs);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFile(T file, BasicFileAttributes attrs)
throws IOException
{
Objects.requireNonNull(file);
Objects.requireNonNull(attrs);
return FileVisitResult.CONTINUE;
}
//….其他省略
}
FileVisitor 类和 SimpleFileVisitor 类对应的就是 UML 类图中的 Visitor 和 ConcreteVisitor 类。而 Element 元素,对应的其实是 JDK 中的 Files 类。
图片
Files 文件中遍历文件树是通过 walkFileTree () 方法实现的。在 walkFileTree () 方法中实现了树的遍历,在遍历到文件的时候会通过 visitor 类的 visitFile 方法调用遍历者的方法,将遍历到的文件传递给遍历者,从而达到分离变化的目的。
ASM 修改字节码
ASM 是 Java 的字节码增强技术,这里面就用到了访问者模式,主要是用来进行字节码的修改。在 ASM 中与此相关的三个类分别是:ClassReader、ClassVisitor、ClassWriter。
ClassReader 类相当于访问者模式中的 Element 元素。它将字节数组或 class 文件读入内存中,并以树的数据结构表示。该类定义了一个 accept 方法用来和 visitor 交互。
图片
ClassVisitor 相当于抽象访问者接口。ClassReader 对象创建之后,需要调用 accept () 方法,传入一个 ClassVisitor 对象。在 ClassReader 的不同时期会调用 ClassVisitor 对象中不同的 visit () 方法,从而实现对字节码的修改。
图片
ClassWriter 是 ClassVisitor 的是实现类,它负责将修改后的字节码输出为字节数组。
图片
对于 ASM 这种场景而言,字节码规范是非常严格且稳定的,如果随便更改可能出问题。但我们又需要对字节码进行动态修改,从而达到某些目的。在这种情况下,ASM 的设计者采用了访问者模式将变化的部分隔离开来,将不变的部分固定下来,从而达到了灵活扩展的目的。
03 我们该如何使用?
从上面几个例子,我们大致可以明白访问者模式的使用场景:某些较为稳定的东西(数据结构或算法),不想直接被改变但又想扩展功能,这时候适合用访问者模式。
说到对于访问者模式使用场景的定义,我们会觉得模板方法模式与这个使用场景的定义很像。但它们还是有些许差别的。访问者模式的变化与非变化(即访问者与被访问者)之间,它们只是简单的包含关系,而模板方法模式的变化与非变化则是继承关系。 但它们也确实有类似的地方,即都是封装了固定不变的东西,开放了变动的东西。
访问者模式的优点很明显,即隔离了变化的东西,固定了不变的东西,使得整体的可维护性更强、具有更强的扩展性。但它也带来了设计模式通用的一些缺点,例如:
类结构变得复杂。之前我们可是简单的调用关系,现在则是多个类之间的继承和组合关系。从一定程度上,提高了对开发人员的要求,提高了研发成本。
被访问者的变更变得更加困难。例如我们上面科学家访谈的例子,如果科学家访谈希望新增一个环节,那么 Scientist 类需要修改,Visitor 类、XinhuaVisitor 类都需要修改。
有这些多优点,但也有这么多缺点,那实际工作中我们应该怎么判断是否用访问者模式呢?总的原则就是扬长避短,即当场景完全利用了访问者模式的优点,规避了访问者模式的缺点的时候,就是使用访问者模式的最佳时机。
虽然使用访问者模式会让被访问者的变更变得更加困难,但如果被访问者很稳定,基本不会变更,那这个缺点不就去除了么。例如在 ASM 的例子中,元素是 ClassReader,其存储了字节码的结构。而字节码结构完全不会轻易改变,所以在这个「被访问者的变更变得更加困难」的缺点也就不存在了。
而「类结构变得复杂」这个缺点,则是需要根据当时业务的复杂程度来看的。如果当时业务很简单,而且变化也不大,那么使用设计模式完全是多余的。但是如果当时业务很复杂了,我们还是在一个类里做修改,那么很大可能性会出大问题。这时候就需要用设计模式来承载复杂的业务结构了。