行为型-Visitor

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

一个案例

假设我们从网站上爬取了很多资源文件,它们的格式有三种:PDF、PPT、Word。我们现在要开发一个工具来处理这批资源文件。这个工具的其中一个功能是,把这些资源文件中的文本内容抽取出来放到 txt 文件中。如果让你来实现,你会怎么来做呢?

我们抽象出来一个 Visitor 接口,包含是三个命名非常通用的 visit() 重载函数,分别处理三种不同类型的资源文件。具体做什么业务处理,由实现这个 Visitor 接口的具体的类来决定,比如 Extractor 负责抽取文本内容,Compressor 负责压缩。当我们新添加一个业务功能的时候,资源文件类不需要做任何修改,只需要修改 ToolApplication 的代码就可以了。

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
81
82
83
84
85
86
87
88
89
90
91
92
public abstract class ResourceFile {
protected String filePath;
public ResourceFile(String filePath) {
this.filePath = filePath;
}
abstract public void accept(Visitor vistor);
}

public class PdfFile extends ResourceFile {
public PdfFile(String filePath) {
super(filePath);
}

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

//...
}
//...PPTFile、WordFile 跟 PdfFile 类似,这里就省略了...

public interface Visitor {
void visit(PdfFile pdfFile);
void visit(PPTFile pdfFile);
void visit(WordFile pdfFile);
}

public class Extractor implements Visitor {
@Override
public void visit(PPTFile pptFile) {
//...
System.out.println("Extract PPT.");
}

@Override
public void visit(PdfFile pdfFile) {
//...
System.out.println("Extract PDF.");
}

@Override
public void visit(WordFile wordFile) {
//...
System.out.println("Extract WORD.");
}
}

public class Compressor implements Visitor {
@Override
public void visit(PPTFile pptFile) {
//...
System.out.println("Compress PPT.");
}

@Override
public void visit(PdfFile pdfFile) {
//...
System.out.println("Compress PDF.");
}

@Override
public void visit(WordFile wordFile) {
//...
System.out.println("Compress WORD.");
}

}

public class ToolApplication {
public static void main(String[] args) {
Extractor extractor = new Extractor();
List<ResourceFile> resourceFiles = listAllResourceFiles(args[0]);
for (ResourceFile resourceFile : resourceFiles) {
resourceFile.accept(extractor);
}

Compressor compressor = new Compressor();
for(ResourceFile resourceFile : resourceFiles) {
resourceFile.accept(compressor);
}
}

private static List<ResourceFile> listAllResourceFiles(String resourceDirectory) {
List<ResourceFile> resourceFiles = new ArrayList<>();
//...根据后缀(pdf/ppt/word)由工厂方法创建不同的类对象(PdfFile/PPTFile/WordFile)
resourceFiles.add(new PdfFile("a.pdf"));
resourceFiles.add(new WordFile("b.word"));
resourceFiles.add(new PPTFile("c.ppt"));
return resourceFiles;
}
}

访问者者模式的英文翻译是 Visitor Design Pattern。在 GoF 的《设计模式》一书中,它是这么定义的:

Allows for one or more operation to be applied to a set of objects at runtime, decoupling the operations from the object structure.

翻译成中文就是:允许一个或者多个操作应用到一组对象上,解耦操作和对象本身。

访问者模式 - 类图

一般来说,访问者模式针对的是一组类型不同的对象(PdfFile、PPTFile、WordFile)。不过,尽管这组对象的类型是不同的,但是,它们继承相同的父类(ResourceFile)或者实现相同的接口。在不同的应用场景下,我们需要对这组对象进行一系列不相关的业务操作(抽取文本、压缩等),但为了避免不断添加功能导致类(PdfFile、PPTFile、WordFile)不断膨胀,职责越来越不单一,以及避免频繁地添加功能导致的频繁代码修改,我们使用访问者模式,将对象与操作解耦,将这些业务操作抽离出来,定义在独立细分的访问者类(Extractor、Compressor)中。

为什么支持双分派的语言不需要访问者模式?

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

所谓 Single Dispatch,指的是执行哪个对象的方法,根据对象的运行时类型来决定;执行对象的哪个方法,根据方法参数的编译时类型来决定。所谓 Double Dispatch,指的是执行哪个对象的方法,根据对象的运行时类型来决定;执行对象的哪个方法,根据方法参数的运行时类型来决定。

具体到编程语言的语法机制,Single Dispatch 和 Double Dispatch 跟多态和函数重载直接相关。当前主流的面向对象编程语言(比如,Java、C++、C#)都只支持 Single Dispatch,不支持 Double Dispatch。

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
public class ParentClass {
public void f() {
System.out.println("I am ParentClass's f().");
}
}

public class ChildClass extends ParentClass {
public void f() {
System.out.println("I am ChildClass's f().");
}
}

public class SingleDispatchClass {
public void polymorphismFunction(ParentClass p) {
p.f();
}

public void overloadFunction(ParentClass p) {
System.out.println("I am overloadFunction(ParentClass p).");
}

public void overloadFunction(ChildClass c) {
System.out.println("I am overloadFunction(ChildClass c).");
}
}

public class DemoMain {
public static void main(String[] args) {
SingleDispatchClass demo = new SingleDispatchClass();
ParentClass p = new ChildClass();
demo.polymorphismFunction(p);//执行哪个对象的方法,由对象的实际类型决定
demo.overloadFunction(p);//执行对象的哪个方法,由参数对象的声明类型决定
}
}

//代码执行结果:
I am ChildClass's f().
I am overloadFunction(ParentClass p).

除了访问者模式,上一节的例子还有其他实现方案吗?

我们还可以利用工厂模式来实现,定义一个包含 extract2txt() 接口函数的 Extractor 接口。PdfExtractor、PPTExtractor、WordExtractor 类实现 Extractor 接口,并且在各自的 extract2txt() 函数中,分别实现 Pdf、PPT、Word 格式文件的文本内容抽取。ExtractorFactory 工厂类根据不同的文件类型,返回不同的 Extractor。

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
public abstract class ResourceFile {
protected String filePath;
public ResourceFile(String filePath) {
this.filePath = filePath;
}
public abstract ResourceFileType getType();
}

public class PdfFile extends ResourceFile {
public PdfFile(String filePath) {
super(filePath);
}

@Override
public ResourceFileType getType() {
return ResourceFileType.PDF;
}

//...
}

//...PPTFile/WordFile跟PdfFile代码结构类似,此处省略...

public interface Extractor {
void extract2txt(ResourceFile resourceFile);
}

public class PdfExtractor implements Extractor {
@Override
public void extract2txt(ResourceFile resourceFile) {
//...
}
}

//...PPTExtractor/WordExtractor跟PdfExtractor代码结构类似,此处省略...

public class ExtractorFactory {
private static final Map<ResourceFileType, Extractor> extractors = new HashMap<>();
static {
extractors.put(ResourceFileType.PDF, new PdfExtractor());
extractors.put(ResourceFileType.PPT, new PPTExtractor());
extractors.put(ResourceFileType.WORD, new WordExtractor());
}

public static Extractor getExtractor(ResourceFileType type) {
return extractors.get(type);
}
}

public class ToolApplication {
public static void main(String[] args) {
List<ResourceFile> resourceFiles = listAllResourceFiles(args[0]);
for (ResourceFile resourceFile : resourceFiles) {
Extractor extractor = ExtractorFactory.getExtractor(resourceFile.getType());
extractor.extract2txt(resourceFile);
}
}

private static List<ResourceFile> listAllResourceFiles(String resourceDirectory) {
List<ResourceFile> resourceFiles = new ArrayList<>();
//...根据后缀(pdf/ppt/word)由工厂方法创建不同的类对象(PdfFile/PPTFile/WordFile)
resourceFiles.add(new PdfFile("a.pdf"));
resourceFiles.add(new WordFile("b.word"));
resourceFiles.add(new PPTFile("c.ppt"));
return resourceFiles;
}
}

当需要添加新的功能的时候,比如压缩资源文件,类似抽取文本内容功能的代码实现,我们只需要添加一个 Compressor 接口,PdfCompressor、PPTCompressor、WordCompressor 三个实现类,以及创建它们的 CompressorFactory 工厂类即可。唯一需要修改的只有最上层的 ToolApplication 类。基本上符合“对扩展开放、对修改关闭”的设计原则。

对于资源文件处理工具这个例子,如果工具提供的功能并不是非常多,只有几个而已,那我更推荐使用工厂模式的实现方式,毕竟代码更加清晰、易懂。相反,如果工具提供非常多的功能,比如有十几个,那我更推荐使用访问者模式,因为访问者模式需要定义的类要比工厂模式的实现方式少很多,类太多也会影响到代码的可维护性。

参考

设计模式之美_设计模式_代码重构-极客时间
https://time.geekbang.org/column/intro/250