CategoryResourceRepost/极客时间专栏/设计模式之美/设计模式与范式:行为型/68 | 访问者模式(上):手把手带你还原访问者模式诞生的思维过程.md
louzefeng d3828a7aee mod
2024-07-11 05:50:32 +00:00

400 lines
17 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<audio id="audio" title="68 | 访问者模式(上):手把手带你还原访问者模式诞生的思维过程" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/cd/e7/cd33d13c85802e56daeba1d13dbe6ae7.mp3"></audio>
前面我们讲到大部分设计模式的原理和实现都很简单不过也有例外比如今天要讲的访问者模式。它可以算是23种经典设计模式中最难理解的几个之一。因为它难理解、难实现应用它会导致代码的可读性、可维护性变差所以访问者模式在实际的软件开发中很少被用到在没有特别必要的情况下建议你不要使用访问者模式。
尽管如此,为了让你以后读到应用了访问者模式的代码的时候,能一眼就能看出代码的设计意图,同时为了整个专栏内容的完整性,我觉得还是有必要给你讲一讲这个模式。除此之外,为了最大化学习效果,我今天不只是单纯地讲解原理和实现,更重要的是,我会手把手带你还原访问者模式诞生的思维过程,让你切身感受到创造一种新的设计模式出来并不是件难事。
话不多说,让我们正式开始今天的学习吧!
## 带你“发明”访问者模式
假设我们从网站上爬取了很多资源文件它们的格式有三种PDF、PPT、Word。我们现在要开发一个工具来处理这批资源文件。这个工具的其中一个功能是把这些资源文件中的文本内容抽取出来放到txt文件中。如果让你来实现你会怎么来做呢
实现这个功能并不难不同的人有不同的写法我将其中一种代码实现方式贴在这里。其中ResourceFile是一个抽象类包含一个抽象函数extract2txt()。PdfFile、PPTFile、WordFile都继承ResourceFile类并且重写了extract2txt()函数。在ToolApplication中我们可以利用多态特性根据对象的实际类型来决定执行哪个方法。
```
public abstract class ResourceFile {
protected String filePath;
public ResourceFile(String filePath) {
this.filePath = filePath;
}
public abstract void extract2txt();
}
public class PPTFile extends ResourceFile {
public PPTFile(String filePath) {
super(filePath);
}
@Override
public void extract2txt() {
//...省略一大坨从PPT中抽取文本的代码...
//...将抽取出来的文本保存在跟filePath同名的.txt文件中...
System.out.println(&quot;Extract PPT.&quot;);
}
}
public class PdfFile extends ResourceFile {
public PdfFile(String filePath) {
super(filePath);
}
@Override
public void extract2txt() {
//...
System.out.println(&quot;Extract PDF.&quot;);
}
}
public class WordFile extends ResourceFile {
public WordFile(String filePath) {
super(filePath);
}
@Override
public void extract2txt() {
//...
System.out.println(&quot;Extract WORD.&quot;);
}
}
// 运行结果是:
// Extract PDF.
// Extract WORD.
// Extract PPT.
public class ToolApplication {
public static void main(String[] args) {
List&lt;ResourceFile&gt; resourceFiles = listAllResourceFiles(args[0]);
for (ResourceFile resourceFile : resourceFiles) {
resourceFile.extract2txt();
}
}
private static List&lt;ResourceFile&gt; listAllResourceFiles(String resourceDirectory) {
List&lt;ResourceFile&gt; resourceFiles = new ArrayList&lt;&gt;();
//...根据后缀(pdf/ppt/word)由工厂方法创建不同的类对象(PdfFile/PPTFile/WordFile)
resourceFiles.add(new PdfFile(&quot;a.pdf&quot;));
resourceFiles.add(new WordFile(&quot;b.word&quot;));
resourceFiles.add(new PPTFile(&quot;c.ppt&quot;));
return resourceFiles;
}
}
```
如果工具的功能不停地扩展,不仅要能抽取文本内容,还要支持压缩、提取文件元信息(文件名、大小、更新时间等等)构建索引等一系列的功能,那如果我们继续按照上面的实现思路,就会存在这样几个问题:
- 违背开闭原则,添加一个新的功能,所有类的代码都要修改;
- 虽然功能增多,每个类的代码都不断膨胀,可读性和可维护性都变差了;
- 把所有比较上层的业务逻辑都耦合到PdfFile、PPTFile、WordFile类中导致这些类的职责不够单一变成了大杂烩。
针对上面的问题,我们常用的解决方法就是拆分解耦,把业务操作跟具体的数据结构解耦,设计成独立的类。这里我们按照访问者模式的演进思路来对上面的代码进行重构。重构之后的代码如下所示。
```
public abstract class ResourceFile {
protected String filePath;
public ResourceFile(String filePath) {
this.filePath = filePath;
}
}
public class PdfFile extends ResourceFile {
public PdfFile(String filePath) {
super(filePath);
}
//...
}
//...PPTFile、WordFile代码省略...
public class Extractor {
public void extract2txt(PPTFile pptFile) {
//...
System.out.println(&quot;Extract PPT.&quot;);
}
public void extract2txt(PdfFile pdfFile) {
//...
System.out.println(&quot;Extract PDF.&quot;);
}
public void extract2txt(WordFile wordFile) {
//...
System.out.println(&quot;Extract WORD.&quot;);
}
}
public class ToolApplication {
public static void main(String[] args) {
Extractor extractor = new Extractor();
List&lt;ResourceFile&gt; resourceFiles = listAllResourceFiles(args[0]);
for (ResourceFile resourceFile : resourceFiles) {
extractor.extract2txt(resourceFile);
}
}
private static List&lt;ResourceFile&gt; listAllResourceFiles(String resourceDirectory) {
List&lt;ResourceFile&gt; resourceFiles = new ArrayList&lt;&gt;();
//...根据后缀(pdf/ppt/word)由工厂方法创建不同的类对象(PdfFile/PPTFile/WordFile)
resourceFiles.add(new PdfFile(&quot;a.pdf&quot;));
resourceFiles.add(new WordFile(&quot;b.word&quot;));
resourceFiles.add(new PPTFile(&quot;c.ppt&quot;));
return resourceFiles;
}
}
```
这其中最关键的一点设计是我们把抽取文本内容的操作设计成了三个重载函数。函数重载是Java、C++这类面向对象编程语言中常见的语法机制。所谓重载函数是指,在同一类中函数名相同、参数不同的一组函数。
不过如果你足够细心就会发现上面的代码是编译通过不了的第37行会报错。这是为什么呢
我们知道,多态是一种动态绑定,可以在运行时获取对象的实际类型,来运行实际类型对应的方法。而函数重载是一种静态绑定,在编译时并不能获取对象的实际类型,而是根据声明类型执行声明类型对应的方法。
在上面代码的第3538行中resourceFiles包含的对象的声明类型都是ResourceFile而我们并没有在Extractor类中定义参数类型是ResourceFile的extract2txt()重载函数,所以在编译阶段就通过不了,更别说在运行时根据对象的实际类型执行不同的重载函数了。那如何解决这个问题呢?
解决的办法稍微有点难理解,我们先来看代码,然后我再来给你慢慢解释。
```
public abstract class ResourceFile {
protected String filePath;
public ResourceFile(String filePath) {
this.filePath = filePath;
}
abstract public void accept(Extractor extractor);
}
public class PdfFile extends ResourceFile {
public PdfFile(String filePath) {
super(filePath);
}
@Override
public void accept(Extractor extractor) {
extractor.extract2txt(this);
}
//...
}
//...PPTFile、WordFile跟PdfFile类似这里就省略了...
//...Extractor代码不变...
public class ToolApplication {
public static void main(String[] args) {
Extractor extractor = new Extractor();
List&lt;ResourceFile&gt; resourceFiles = listAllResourceFiles(args[0]);
for (ResourceFile resourceFile : resourceFiles) {
resourceFile.accept(extractor);
}
}
private static List&lt;ResourceFile&gt; listAllResourceFiles(String resourceDirectory) {
List&lt;ResourceFile&gt; resourceFiles = new ArrayList&lt;&gt;();
//...根据后缀(pdf/ppt/word)由工厂方法创建不同的类对象(PdfFile/PPTFile/WordFile)
resourceFiles.add(new PdfFile(&quot;a.pdf&quot;));
resourceFiles.add(new WordFile(&quot;b.word&quot;));
resourceFiles.add(new PPTFile(&quot;c.ppt&quot;));
return resourceFiles;
}
}
```
在执行第30行的时候根据多态特性程序会调用实际类型的accept函数比如PdfFile的accept函数也就是第16行代码。而16行代码中的this类型是PdfFile的在编译的时候就确定了所以会调用extractor的extract2txt(PdfFile pdfFile)这个重载函数。这个实现思路是不是很有技巧?这是理解访问者模式的关键所在,也是我之前所说的访问者模式不好理解的原因。
现在如果要继续添加新的功能比如前面提到的压缩功能根据不同的文件类型使用不同的压缩算法来压缩资源文件那我们该如何实现呢我们需要实现一个类似Extractor类的新类Compressor类在其中定义三个重载函数实现对不同类型资源文件的压缩。除此之外我们还要在每个资源文件类中定义新的accept重载函数。具体的代码如下所示
```
public abstract class ResourceFile {
protected String filePath;
public ResourceFile(String filePath) {
this.filePath = filePath;
}
abstract public void accept(Extractor extractor);
abstract public void accept(Compressor compressor);
}
public class PdfFile extends ResourceFile {
public PdfFile(String filePath) {
super(filePath);
}
@Override
public void accept(Extractor extractor) {
extractor.extract2txt(this);
}
@Override
public void accept(Compressor compressor) {
compressor.compress(this);
}
//...
}
}
//...PPTFile、WordFile跟PdfFile类似这里就省略了...
//...Extractor代码不变
public class ToolApplication {
public static void main(String[] args) {
Extractor extractor = new Extractor();
List&lt;ResourceFile&gt; 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&lt;ResourceFile&gt; listAllResourceFiles(String resourceDirectory) {
List&lt;ResourceFile&gt; resourceFiles = new ArrayList&lt;&gt;();
//...根据后缀(pdf/ppt/word)由工厂方法创建不同的类对象(PdfFile/PPTFile/WordFile)
resourceFiles.add(new PdfFile(&quot;a.pdf&quot;));
resourceFiles.add(new WordFile(&quot;b.word&quot;));
resourceFiles.add(new PPTFile(&quot;c.ppt&quot;));
return resourceFiles;
}
}
```
上面代码还存在一些问题添加一个新的业务还是需要修改每个资源文件类违反了开闭原则。针对这个问题我们抽象出来一个Visitor接口包含是三个命名非常通用的visit()重载函数分别处理三种不同类型的资源文件。具体做什么业务处理由实现这个Visitor接口的具体的类来决定比如Extractor负责抽取文本内容Compressor负责压缩。当我们新添加一个业务功能的时候资源文件类不需要做任何修改只需要修改ToolApplication的代码就可以了。
按照这个思路我们可以对代码进行重构,重构之后的代码如下所示:
```
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(&quot;Extract PPT.&quot;);
}
@Override
public void visit(PdfFile pdfFile) {
//...
System.out.println(&quot;Extract PDF.&quot;);
}
@Override
public void visit(WordFile wordFile) {
//...
System.out.println(&quot;Extract WORD.&quot;);
}
}
public class Compressor implements Visitor {
@Override
public void visit(PPTFile pptFile) {
//...
System.out.println(&quot;Compress PPT.&quot;);
}
@Override
public void visit(PdfFile pdfFile) {
//...
System.out.println(&quot;Compress PDF.&quot;);
}
@Override
public void visit(WordFile wordFile) {
//...
System.out.println(&quot;Compress WORD.&quot;);
}
}
public class ToolApplication {
public static void main(String[] args) {
Extractor extractor = new Extractor();
List&lt;ResourceFile&gt; 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&lt;ResourceFile&gt; listAllResourceFiles(String resourceDirectory) {
List&lt;ResourceFile&gt; resourceFiles = new ArrayList&lt;&gt;();
//...根据后缀(pdf/ppt/word)由工厂方法创建不同的类对象(PdfFile/PPTFile/WordFile)
resourceFiles.add(new PdfFile(&quot;a.pdf&quot;));
resourceFiles.add(new WordFile(&quot;b.word&quot;));
resourceFiles.add(new PPTFile(&quot;c.ppt&quot;));
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.
翻译成中文就是:允许一个或者多个操作应用到一组对象上,解耦操作和对象本身。
定义比较简单,结合前面的例子不难理解,我就不过多解释了。对于访问者模式的代码实现,实际上,在上面例子中,经过层层重构之后的最终代码,就是标准的访问者模式的实现代码。这里,我又总结了一张类图,贴在了下面,你可以对照着前面的例子代码一块儿来看一下。
<img src="https://static001.geekbang.org/resource/image/c4/65/c42c636c5384da5bd5343618305db865.jpg" alt="">
最后,我们再来看下,访问者模式的应用场景。
一般来说访问者模式针对的是一组类型不同的对象PdfFile、PPTFile、WordFile。不过尽管这组对象的类型是不同的但是它们继承相同的父类ResourceFile或者实现相同的接口。在不同的应用场景下我们需要对这组对象进行一系列不相关的业务操作抽取文本、压缩等但为了避免不断添加功能导致类PdfFile、PPTFile、WordFile不断膨胀职责越来越不单一以及避免频繁地添加功能导致的频繁代码修改我们使用访问者模式将对象与操作解耦将这些业务操作抽离出来定义在独立细分的访问者类Extractor、Compressor中。
## 重点回顾
好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要重点掌握的内容。
访问者模式允许一个或者多个操作应用到一组对象上,设计意图是解耦操作和对象本身,保持类职责单一、满足开闭原则以及应对代码的复杂性。
对于访问者模式,学习的主要难点在代码实现。而代码实现比较复杂的主要原因是,函数重载在大部分面向对象编程语言中是静态绑定的。也就是说,调用类的哪个重载函数,是在编译期间,由参数的声明类型决定的,而非运行时,根据参数的实际类型决定的。
正是因为代码实现难理解,所以,在项目中应用这种模式,会导致代码的可读性比较差。如果你的同事不了解这种设计模式,可能就会读不懂、维护不了你写的代码。所以,除非不得已,不要使用这种模式。
## 课堂讨论
实际上,今天举的例子不用访问者模式也可以搞定,你能够想到其他实现思路吗?
欢迎留言和我分享你的想法。如果有收获,也欢迎你把这篇文章分享给你的朋友。