13.1 Visitor 模式
在数据结构中保存着许多元素,我们会对元素进行 “处理”。这时, “处理” 代码放在哪里比较好呢?通常的做法是将它们放在表示数据结构的类中。但是,如果 “处理” 有许多种呢?这种情况下,每当增加一种处理,我们就不得不去修改表示数据结构的类。
在 Visitor 模式中,数据结构与处理被分离开来。我们编写一个表示 “访问者” 的类来访问数据结构中的元素,并把对各元素的处理交给访问者类。这样,当需要增加新的处理时,我们只需要编写新的访问者,然后让数据结构可以接受访问者的访问即可。
13.2 示例程序
在 Visitor 模式的示例程序中,我们使用 Composite 模式中用到的那个文件和文件夹的例子作为访问者要访问的数据结构。访问者会访问文件和文件夹构成的数据结构,然后显示出文件和文件夹的一览。
|| Visitor 类
Visitor 类是代表访问者的抽象类。访问者依赖于它所访问的数据结构(即 File 和 Directory 类)。
Visitor 类中定义了两个方法,名字都叫 Visit,不过两者接受的参数不同。利用方法的重载,可以根据传入的参数来自动选择和执行相应的方法。
在 Visitor 模式中,各个类之间的访问和相互调用非常复杂,单看 Visitor 类是无法整体理解该模式的。
/**
* 访问者的抽象类.
*/
public abstract class Visitor {
public abstract void visit(File file);
public abstract void visit(Directory directory);
}
|| Element 接口
Visitor 类是表示访问者的类,而 Element 接口则是接受访问者的访问的接口。Element 接口中声明了 accept 方法。该方法的参数是访问者 Visitor 类。
/**
* 接受访问者的访问的接口.
*/
public interface Element {
void accept(Visitor visitor);
}
|| Entry 类
Entry 类在本质上与 Composite 模式中的 Entry 类是一样的,但是在这里额外实现了 Element 接口。这是为了让 Entry 类适用于 Visitor 模式。实际上实现 Element 接口中声明的抽象方法 accept 的是 Entry 类的子类 - File 类和 Directory 类。
public abstract class Entry implements Element {
public abstract String getName();
public abstract int getSize();
public Entry add(Entry entry) throws FileTreatmentException {
throw new FileTreatmentException();
}
public Iterator iterator() throws FileTreatmentException {
throw new FileTreatmentException();
}
@Override
public String toString() {
return getName() + " (" + getSize() + ")";
}
}
|| File 类
File 类与 Composite 模式中的 File 类一样。在 Visitor 模式中需要解决它是如何实现 accept 接口的。 accept 方法的参数是 Visitor 类,然后 accept 方法的内部处理是 “visit.visit(this);”,即调用了 Visitor 类的 visit 方法。因为 visit 方法被重载过,所以这里实际调用的是 visit(File)。
public class File extends Entry {
private String name;
private int size;
public File(String name, int size) {
this.name = name;
this.size = size;
}
@Override
public String getName() {
return name;
}
@Override
public int getSize() {
return size;
}
@Override
public void accept(Visitor visitor) {
visitor.visit(this);
}
}
|| Directory 类
Directory 类是表示文件夹的类。与 Composite 模式中的相比,这里的 Directory 类中增加了两个方法。
iterator 方法会返回 Iterator,我们可以使用它遍历文件夹加中的所有目录条目。
accept 方法,与 File 中的该方法一样,在 Directory 中的 accept 方法调用了 visit(Directory) 方法。
public class Directory extends Entry{
private String name;
private List<Entry> dir = new ArrayList<>();
public Directory(String name) {
this.name = name;
}
@Override
public Entry add(Entry entry) throws FileTreatmentException {
dir.add(entry);
return this;
}
@Override
public String getName() {
return name;
}
@Override
public int getSize() {
int size = 0;
for (Entry e : dir) {
size += e.getSize();
}
return size;
}
@Override
public Iterator iterator() throws FileTreatmentException {
return dir.iterator();
}
@Override
public void accept(Visitor visitor) {
visitor.visit(this);
}
}
|| ListVisitor 类
ListVisitor 是 Visitor 类的子类,它的作用是访问数据结构并显示元素一览。因为是 Visitor 的子类,其实现了父类的两个 visit 方法。
currentDir 字段保存的是现在正在访问的文件夹的名字。visit(File) 方法在访问者访问文件时会被 File 类的 accept 调用,参数 file 是所访问的 File 类的实例。也就是说 visit(File) 方法是用来实现 “对 File 类的实例要进行的处理” 的。在这里我们实现的处理是先显示当前文件夹的名字,然后显示间隔符号 “/”,最后显示文件名。
visit(Directory) 方法在访问者访问文件夹时会被 Directory 类的 accept 方法调用,参数 directory 是所访问的 Directory 类的实例,实现了 “对 Directory 类的实例要进行的处理”。
与 visit(File) 方法一样,我们先显示当前文件夹的名字,接着调用 iterator 方法获取文件夹的 Iterator,然后通过 Iterator 遍历文件夹中的所有目录条目并调用它们各自的 accept 方法。由于文件夹中可能存在着许多目录条目,逐一访问会非常困难。
accept 方法调用 visit 方法,visit 方法又调用 accept 方法,形成了非常复杂的递归调用。通常的递归是某个方法调用自身,在 Visitor 模式中,则是 accept 方法与 visit 方法之间互相递归调用。
public class ListVisitor extends Visitor{
private String currentDir = "";
@Override
public void visit(File file) {
System.out.println(currentDir + "/" + file);
}
@SuppressWarnings("unchecked")
@Override
public void visit(Directory directory) {
System.out.println(currentDir + "/" + directory);
String saveDir = currentDir;
currentDir = currentDir + "/" + directory.getName(); // 替换为当前dir + diectotry
// 递归相互调用 accept - visit 方法
Iterator<Entry> it = directory.iterator();
while (it.hasNext()) {
Entry entry = it.next();
entry.accept(this);
}
currentDir = saveDir; // 一次遍历结束后需要还原
}
}
|| FileTreatmentException 类
与 Composite 模式中的类完全一致。
public class FileTreatmentException extends RuntimeException{
public FileTreatmentException(){}
public FileTreatmentException(String msg) {
super(msg);
}
}
|| Main 类
Main 类使用了访问者 ListVisitor 类的实例来显示 Directory 中的内容。
在 Visitor 中是在访问者中显示文件夹中的内容,因为显示文件夹中的内容也属于对数据结构中的各元素进行的处理。
public class Main {
public static void main(String[] args) {
System.out.println("Making root entries...");
Directory rootDir = new Directory("root");
Directory binDir = new Directory("bin");
Directory tmpDir = new Directory("tmp");
Directory usrDir = new Directory("usr");
rootDir.add(binDir);
rootDir.add(tmpDir);
rootDir.add(usrDir);
binDir.add(new File("vi", 10000));
binDir.add(new File("latex", 20000));
rootDir.accept(new ListVisitor());
System.out.println(" ");
System.out.println("Making user entries...");
Directory yuki = new Directory("yuki");
Directory hanako = new Directory("hanako");
Directory tomura = new Directory("tomura");
usrDir.add(yuki);
usrDir.add(hanako);
usrDir.add(tomura);
yuki.add(new File("diary.html", 100));
yuki.add(new File("Composite.java", 200));
hanako.add(new File("memo.tex", 300));
hanako.add(new File("game.doc", 400));
tomura.add(new File("junk.mail", 500));
rootDir.accept(new ListVisitor());
}
}
|| Visitor 与 Element 之间的相互调用
初次接触 Visitor 模式,accept 方法和 visit 方法的调用关系是一片混乱的。因此这里我们再结合时序图来理解一下示例程序的处理流程。
为了方便理解,我们展示了当一个文件夹下有两个文件时,示例程序的处理流程。
1.首先,Main 类生成 ListVisitor 的实。示例程序中, Main 类还生成了其他的 Directory 类和 File 类的实例,这里省略。
2.Main 类调用 Directory 类的 accept 方法,这时传递的参数是 ListVistor 的实例,但我们在图中省略了。
3.Directory 类的实例调用接收到的参数 ListVisitor 的 visit(Directory) 方法。
4.接下来,ListVisitor 类的实例会访问文件夹,并调用找到的第一个文件的 accept 方法。传递的参数是自身(this)。
5.File 的实例调用接收到的参数 ListVisitor 的 visit(File) 方法。注意,此时的 ListVisitor 的 visit(Directory) 方法还在执行中(并非多线程执行,而是表示 visit(Directory) 还存在于调用堆栈中的意思)。在时序图中,表示生命周期的长方形右侧发生了重叠就说明了这一点。
6.从 visit(File) 返回到 accept,接着又从 accept 也返回出来(先进后出)。然后调用另外一个 File 的实例的 accept 方法。传递的参数是 ListVisitor 的实例 this。
7.与前面一样,File 的实例调用 visit(File) 方法。所有的处理完成后,逐步返回,最后回到 Main 类中的调用 accept 方法的地方。
- 对于 Directory 类的实例和 File 类的实例,我们调用了它们的 accept 方法
- 对于每一个 Directory 类的实例和 File 类的实例,我们只调用了一次它们的 accept 方法
- 对于 ListVisitor 的实例,我们调用了它的 visit(Directory) 和 visit(File) 方法
- 处理 visit(Directory) 和 visit(File) 的是同一个 ListVisitor 的实例
通过以上的认识,我们可以知道,在 Visitor 模式中,visit 方法将 “处理” 都集中在 ListVisitor 里面了。
13.3 Visitor 模式中的登场角色
在 Visitor 模式中有以下登场角色。
◆ Visitor (访问者)
Visitor 角色负责对数据结构中的每个具体的元素(ConcreteElement 角色)声明一个用于访问 XXXX 的 visit(XXXX) 方法。visit(XXXX) 是用于处理 XXXX 的方法,负责实现该方法的是 ConcreteVisitor 角色。在示例程序中,由 Visitor 类扮演此角色。
◆ ConcreteVisitor (具体的访问者)
ConcreteVisitor 角色负责实现 Visitor 角色所定义的接口(API)。它要实现所有的 visit(XXXX) 方法,即实现如何处理每个 ConcreteElement 角色。在示例程序中,由 ListVisitor 类扮演此角色。如同在 ListVisitor 中, currentDir 字段的值不断发生变化一样,随着 visit(XXXX) 处理的进行,ConcreteVisitor 角色的内部状态也会不断地发生变化。
◆ Element(元素)
Element 角色表示 Visitor 角色的访问对象。它声明了接受访问者的 accept 方法。accept 方法接收到的参数是 Visitor 角色。在示例程序中,由 Element 接口扮演此角色。
◆ ConcreteElement
ConcreteElement 角色负责实现 Element 角色所定义的接口(API)。在示例程序中,由 File 类和 Directory 类扮演此角色。
◆ ObjectStucture(对象结构)
ObjectStucture 角色负责处理 Element 角色的集合。ConcreteVisitor 角色为每个 Element 角色都准备了处理方法。在示例程序中,由 Directory 类扮演此角色(一人分饰两角)。为了让 ConcreteVisitor 角色可以遍历处理每个 Element 角色,在示例程序中,我们在 Directory 类中实现了 iterator 方法。
13.4 拓展思路的要点
|| 双重分发
在 Visitor 模式中方法的调用关系。accept(接受)方法的调用方式如下。
element.accept(visitor);
而 visit(访问)方法的调用方式如下。
visitor.visit(element);
对比一下这两个方法会发现,它们是相反的关系。element 接受 visitor,而 visitor 又访问 element。
在 Visitor 模式中,ConcreteElement 和 ConcreteVisitor 这两个角色共同决定了实际进行的处理。这种消息分发的方式一般被称为 双重分发。
|| 为什么要弄得这么复杂
看到上面的处理过程时,可能会感觉到 “Visitor 模式是不是把简单问题复杂化了吗?”,如果需要循环处理,在数据结构的类中直接编写循环语句不就解决了吗?为什么要搞出 accept 方法和 visit 方法之间那样复杂的调用关系呢?
Visitor 模式的目的是将处理从数据结构中分离出来。数据结构很重要,它将元素集合和关联在一起。但是,需要注意的是,保存数据结构与以数据结构为基础进行处理是两种不同的东西。
通常,ConcreteVisitor 角色的开发可以独立于 File 类和 Directory 类。也就是说,Visitor 模式提高了 File 类和 Directory 类作为组件的独立性。如果将进行处理的方法定义在 File 类和 Directory 类中,当每次要扩展功能,增加新的 “处理” 时,就不得不去修改 File 类和 Directory 类。
|| 开闭原则 - 对扩展开放,对修改关闭
开闭原则(OCP),主张类应当是下面这样的。
- 对扩展(extension)是开放(open)的
- 对修改(modification)是关闭(close)的
在设计类时,若无特殊理由,必须要考虑到将来可能会扩展类。绝不能毫无理由的禁止扩展类。这就是对扩展是开放的意思。
但是,如果在每次扩展类时都需要修改现有的类就太麻烦了。所以我们需要在不用修改现有类的前提下能够扩展类,这就是 “对修改是关闭的” 的意思。
我们提倡扩展,但是如果需要修改现有的代码,那就不行了。在不修改现有代码的前提下进行扩展,这就是开闭原则。
功能需求总是在不断变化,而且这些功能需求大都是 “希望扩展某个功能”。因此,如果不能比较容易的扩展类,开发过程将会变得非常困难。另一方面,如果要修改已经编写和测试完成的类,又可能会导致软件产品的质量降低。
对扩展开放,对修改关闭的类具有高可复用性,可作为组件复用。设计模式和面向对象的目的正是为我们提供一种结构,可以帮我们设计出这样的类。
|| 易于增加的 ConcreteVisitor 角色
使用 Visitor 模式可以很容易地增加 ConcreteVisitor 角色。因为具体的处理被交给了 ConcreteVisitor 角色负责,因此完全不用修改 ConcretetElement 角色。
|| 难以增加 ConcreteElement 角色
虽然使用 Visitor 模式可以很容易的增加 ConcreteVisitor 角色。不过它却很难应对 ConcreteElement 角色的增加。
例如,我们需要增加一个 Entry 类的子类 Device 类。这时,我们需要 在 Visitor 类中声明一个 visit(Device)方法,并在所有的 Visitor 类的子类中都实现这个方法。
|| Visitor 工作所需的条件
“在 Visitor 模式中,对数据结构中的元素进行处理的任务被分离出来,交给 Visitor 类负责。这样,就实现了对数据结构与处理的分离” 这个主题。但是要达到这个目的是有条件的,那就是 Element 角色必须向 Visitor 角色公开足够对的信息。
例如,在示例程序中,visit(Directory)方法需要调用每个目录条目的 accept 方法。为此,Directory 类必须提供用于获取每个目录条目的 iterator 方法。
访问者只有从数据结构中获取了足够多的信息后才能工作,如果无法获取到这些信息,它就无法工作。这样做的缺点是,如果公开了不应当被公开的信息,将来对数据结构的改良就会变得非常困难。