极客时间已完结课程限时免费阅读

68 | 访问者模式(上):手把手带你还原访问者模式诞生的思维过程

68 | 访问者模式(上):手把手带你还原访问者模式诞生的思维过程-极客时间

68 | 访问者模式(上):手把手带你还原访问者模式诞生的思维过程

讲述:冯永吉

时长09:11大小8.41M

前面我们讲到,大部分设计模式的原理和实现都很简单,不过也有例外,比如今天要讲的访问者模式。它可以算是 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("Extract PPT.");
}
}
public class PdfFile extends ResourceFile {
public PdfFile(String filePath) {
super(filePath);
}
@Override
public void extract2txt() {
//...
System.out.println("Extract PDF.");
}
}
public class WordFile extends ResourceFile {
public WordFile(String filePath) {
super(filePath);
}
@Override
public void extract2txt() {
//...
System.out.println("Extract WORD.");
}
}
// 运行结果是:
// Extract PDF.
// Extract WORD.
// Extract PPT.
public class ToolApplication {
public static void main(String[] args) {
List<ResourceFile> resourceFiles = listAllResourceFiles(args[0]);
for (ResourceFile resourceFile : resourceFiles) {
resourceFile.extract2txt();
}
}
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;
}
}
如果工具的功能不停地扩展,不仅要能抽取文本内容,还要支持压缩、提取文件元信息(文件名、大小、更新时间等等)构建索引等一系列的功能,那如果我们继续按照上面的实现思路,就会存在这样几个问题:
违背开闭原则,添加一个新的功能,所有类的代码都要修改;
虽然功能增多,每个类的代码都不断膨胀,可读性和可维护性都变差了;
把所有比较上层的业务逻辑都耦合到 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("Extract PPT.");
}
public void extract2txt(PdfFile pdfFile) {
//...
System.out.println("Extract PDF.");
}
public void extract2txt(WordFile wordFile) {
//...
System.out.println("Extract WORD.");
}
}
public class ToolApplication {
public static void main(String[] args) {
Extractor extractor = new Extractor();
List<ResourceFile> resourceFiles = listAllResourceFiles(args[0]);
for (ResourceFile resourceFile : resourceFiles) {
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;
}
}
这其中最关键的一点设计是,我们把抽取文本内容的操作,设计成了三个重载函数。函数重载是 Java、C++ 这类面向对象编程语言中常见的语法机制。所谓重载函数是指,在同一类中函数名相同、参数不同的一组函数。
不过,如果你足够细心,就会发现,上面的代码是编译通过不了的,第 37 行会报错。这是为什么呢?
我们知道,多态是一种动态绑定,可以在运行时获取对象的实际类型,来运行实际类型对应的方法。而函数重载是一种静态绑定,在编译时并不能获取对象的实际类型,而是根据声明类型执行声明类型对应的方法。
在上面代码的第 35~38 行中,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<ResourceFile> resourceFiles = listAllResourceFiles(args[0]);
for (ResourceFile resourceFile : resourceFiles) {
resourceFile.accept(extractor);
}
}
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;
}
}
在执行第 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<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 接口,包含是三个命名非常通用的 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("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)中。

重点回顾

好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要重点掌握的内容。
访问者模式允许一个或者多个操作应用到一组对象上,设计意图是解耦操作和对象本身,保持类职责单一、满足开闭原则以及应对代码的复杂性。
对于访问者模式,学习的主要难点在代码实现。而代码实现比较复杂的主要原因是,函数重载在大部分面向对象编程语言中是静态绑定的。也就是说,调用类的哪个重载函数,是在编译期间,由参数的声明类型决定的,而非运行时,根据参数的实际类型决定的。
正是因为代码实现难理解,所以,在项目中应用这种模式,会导致代码的可读性比较差。如果你的同事不了解这种设计模式,可能就会读不懂、维护不了你写的代码。所以,除非不得已,不要使用这种模式。

课堂讨论

实际上,今天举的例子不用访问者模式也可以搞定,你能够想到其他实现思路吗?
欢迎留言和我分享你的想法。如果有收获,也欢迎你把这篇文章分享给你的朋友。
分享给需要的人,Ta购买本课程,你将得29
生成海报并分享

赞 61

提建议

上一篇
67 | 迭代器模式(下):如何设计实现一个支持“快照”功能的iterator?
下一篇
69 | 访问者模式(下):为什么支持双分派的语言不需要访问者模式?
 写留言

精选留言(91)

  • test
    2020-04-08
    访问者模式解决的痛点主要是需要动态绑定的类型,所以调用哪个重载版本,其参数中的子类必须传入静态类型为目标子类的参数,并在方法中使用传入参数的动态绑定。如果不使用访问者模式,可以使用策略模式,使用工厂模式在map中保存type和具体子类实例的映射,在使用的时候,根据type的不同调用不同子类的方法(动态绑定)。
    共 4 条评论
    63
  • Jxin
    2020-04-08
    1.虽然策略模式也能实现,但这个场景用访问者模式其实会优雅很多。 2.因为多种类型的同个操作聚合在了一起,那么因为这些类型是同父类的,所以属于父类的一些相同操作就能抽私有共用方法。 3.而策略模式,因为各个类型的代码都分割开了,那么就只好复制黏贴公共部分了。 4.另外,写合情合理的优雅代码,然后别人看不懂,一顿吹也是极爽的。只是一般节奏都挺快,第一时间可能就是策略模式走你,然后就没有然后了。
    展开
    共 8 条评论
    40
  • 李小四
    2020-04-08
    设计模式_68: # 作业: 今天的需求,我的第一反映是策略模式。 # 感想: 挺认同文章的观点,别人写了这种模式要看得懂,自己还是不要用比较好。 给转述师提个Tip: 程序开发中常常用数字`2`代替`to`、用数字`4`代替`for`,比如文中的`extract2txt`,这时要读作`extract to txt`,而不是`extract 2(中文读音er) txt`。
    展开
    共 12 条评论
    18
  • Liam
    2020-04-08
    antlr(编译器框架)对语法树进行解析的时候就是通过visitor模式实现了扩展
    共 1 条评论
    15
  • 小晏子
    2020-04-08
    课后思考:可以使用策略模式,对于不同的处理方式定义不同的接口,然后接口中提供对于不同类型文件的实现,再使用静态工厂类保存不同文件类型和不同处理方法的映射关系。对于后续扩展的新增文件处理方法,比如composer,按同样的方式实现一组策略,然后修改application代码使用对应的策略。
    共 1 条评论
    12
  • 写代码的
    2020-09-13
    当一组重载函数的参数类型是继承同一个接口或者父类的话,如果传入的参数的静态类型是这个接口或者父类,java是无法决定使用哪个重载函数的。访问者模式的巧妙之处在于,我们可以借助多态,利用多态的动态分派特性,让这个参数暴露一个方法,使得这一组重载函数(或者说声明这组重载函数的类)能进入参数对象内部(也就是让参数暴露一个参数类型是这组重载函数所在类的方法)。一旦这组重载函数进入了参数对象内部,这组重载函数就知道了它的真实类型了,这个时候再调用重载函数,就能根据参数的具体类型找到合适的重载方法了。 用一个通俗的例子来理解。敌军有多种类型的基地,我军有一套破坏敌军各种类型基地的方案,但是敌军的基地经过伪装,看上去都一样,我军在外面是无法决定使用哪套方案来攻击基地的。幸运的是,我军了解到,敌军每天中午会允许一批物资车进入基地,于是我方军队伪装成了敌军物资车进入到了敌军内部。进入基地之后我军了解了其真实类型,于是我军根据基地真实类型选择了相应的攻击方案,将敌军基地摧毁。
    展开
    共 1 条评论
    10
  • Frank
    2020-04-11
    如果不使用访问者模式,也许这也是一种改造方法:在Extractor类种在定义一个重载的方法,形参的类型为:ResourceFile,在该方法种判断参数的实际类型后再做分派。如下所示
    共 5 条评论
    8
  • Monday
    2020-04-08
    独立细分的访问者类(Extractor、Compressor),这些类分别对应所有类型对象某一操作的实现,如果类型多了,这些访问者类也会爆炸。
    共 1 条评论
    8
  • 前端西瓜哥
    2020-04-11
    可以在 extractor.extract2txt(resourceFile); 这里,改为通过 instanceof 得到 resourceFile 的真正的对象类型,然后进行类型转换。代码大致如下: if (resourceFile instanceof PdfFile ) extractor.extract2txt((PdfFile)resourceFile); else if ...
    6
  • DFighting
    2020-11-22
    看完这个访问者模式,我第一反应是和桥接模式好相似,都是好像是操作与数据独立扩展,但是桥接模式主要使用的是组合,他的数据结构不支持扩展,进而我就理解了访问者模式和桥接模式不同的地方:桥接是对固定的数据结合进行多维度的独立扩展,每个维度的扩展可以在使用的时候随意组合,但是数据结构不支持改变,因为单纯的组合模式实现不了这种静态的“多态”;但是访问者模式最关键的地方是vist(this),这么实现就做到了数据和操作两个的独立扩展,有新增的数据类型或者操作的时候都只需要按需扩展数据结构和操作即可,虽然这会涉及到所有的操作类,但是这并未对已存在的功能做出影响,是符合开闭原则的,但是这个扩展操作因为需要支持多种数据结构,所以不适合如桥接模式这种多维度独立扩展,因为那样需要改动很多的类和代码,不合适
    展开

    作者回复: 嗯嗯

    共 3 条评论
    5
  • 梦倚栏杆
    2020-04-10
    List<Visitor> visitors = new ArrayList<>(); visitors.add(new Extractor()); visitors.add(new Compressor()); List<ResourceFile> resourceFiles = listAllResourceFiles(args[0]); for (Visitor visitor : visitors) { for (ResourceFile resourceFile : resourceFiles) { resourceFile.accept(visitor); } } 如果把main方法改成如上,是不是就相当于用到了职责链模式呀
    展开
    共 1 条评论
    5
  • L
    2020-04-08
    课后题,策略模式+抽象工厂模式
    5
  • Frank
    2020-04-08
    打卡 今日学习访问者设计模式,收获如下: 访问者模式表示允许一个或者多个操作应用到一组对象上,解耦操作和对象本身。体现了SRP原则。这个原则的代码实现比较复杂,关键要理解“函数重载是一种静态绑定,在编译时并不能获取对象的实际类型,而是根据声明类型执行声明类型对应的方法”。 文章的例子通过4个版本的迭代,从一个最小原型实现,逐渐重构成一个符合访问者模式的代码。重构过程中不断使用前面讲解到的单一职责原则,基于接口而非实现编程等原则,使用工厂模式等来使代码逐渐符合“高内聚,低耦合”的目标。在实际开发中,该模式理解起来难度大,不建议使用。
    展开
    共 2 条评论
    5
  • 梦倚栏杆
    2020-04-10
    三种模式的比较: 如果只是针对资源文件,来决定哪种场景下用哪种处理方式==>用策略模式 class PPTFile{ } interface Visitor { void visit(PPTFile pptFile); } // 策略2 class Extractor implements Visitor { @Override public void visit(PPTFile pptFile) { System.out.println("抽取文字"); } } // 策略1 class Compressor implements Visitor { @Override public void visit(PPTFile pptFile) { System.out.println("压缩文件"); } } class StrategyFactory { public Visitor getVisitor(PPTFile pptFile) { return ThreadLocalRandom.current().nextInt() % 2 == 0 ? new Extractor() : new Compressor(); } } 如果是针对某种资源,一系列操作都要用,则用职责链模式 上面的StrategyFactory 删除 class VisitorChain { private final List<Visitor> visitors = new ArrayList<>(); public void addVisitor(Visitor visitor) { visitors.add(visitor); } List<Visitor> getAllAcceptVisitor() { return visitors; } } 一组资源,支持一组功能。 如上 (好像这么解释也不充分?) 是不是就和wps里打开,关闭,保存功能似的他
    展开
    4
  • QQ怪
    2020-04-08
    问下老师,有没有哪个优秀的框架用到这个模式的?
    共 8 条评论
    3
  • 2020-04-08
    对方法绑定感兴趣的同学可以看 深入理解java虚拟机 书中的->虚拟机字节码执行引擎->方法调用->方法分派的章节,详细介绍了虚拟机是如何通过静态绑定和动态绑定,实现方法重载和方法重写的 课后题: 如果子类对象的行为是一样的,都具备同样的行为(抽取,压缩,分析),那么用模板方法模式也完全可以,修改的成本并没有多大 如果子类对象的行为是不一致的,比如对ppt只进行抽取,对word进行抽取和压缩,那么各个子类都维护自己的方法比较好,没有必要抽取出来
    展开
    共 1 条评论
    3
  • Leon Wong
    2020-09-23
    本质是操作对象本身和操作逻辑的解耦。
    2
  • 2020-05-24
    accept方法很巧妙,重载,编译器按照类型匹配,而this关键字代表“当前类型对象”
    2
  • 筱乐乐哦
    2020-04-09
    想问下这种设计模式在哪个的源码中有使用咋?先看下是怎么用的,老师写的例子还是比较简单易懂的,但感觉实际用的时候,需要根据业务抽象,就不好弄了呀
    共 2 条评论
    2
  • 陆一鸣猜不动
    2021-11-21
    感觉这个也是控制反转的一种体现,然后写法我感觉挺functional的?
    1