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

访问者模式诞生的思维过程

假设我们有一批文件,包括 PDF、PPT、Word 这三种格式,我们现在要开发一个工具来处理这批文件,这个工具的其中一个功能是,把这些资源文件中的文本内容抽取出来放到 txt 文件中。实现这个功能并不难,比较常用的一种方法实现如下:

  1. public abstract class ResourceFile {
  2. protected String filePath;
  3. public ResourceFile(String filePath) {
  4. this.filePath = filePath;
  5. }
  6. // 文本内容抽取函数,交由不同类型的子类实现
  7. public abstract void extract2txt();
  8. }
  9. public class PPTFile extends ResourceFile {
  10. public PPTFile(String filePath) {
  11. super(filePath);
  12. }
  13. @Override
  14. public void extract2txt() {
  15. System.out.println("Extract PPT.");
  16. }
  17. }
  18. public class PdfFile extends ResourceFile {
  19. public PdfFile(String filePath) {
  20. super(filePath);
  21. }
  22. @Override
  23. public void extract2txt() {
  24. System.out.println("Extract PDF.");
  25. }
  26. }
  27. public class WordFile extends ResourceFile {
  28. public WordFile(String filePath) {
  29. super(filePath);
  30. }
  31. @Override
  32. public void extract2txt() {
  33. System.out.println("Extract WORD.");
  34. }
  35. }
  36. public class ToolApplication {
  37. public static void main(String[] args) {
  38. List<ResourceFile> resourceFiles = new ArrayList<>();
  39. resourceFiles.add(new PdfFile("a.pdf"));
  40. resourceFiles.add(new WordFile("b.word"));
  41. resourceFiles.add(new PPTFile("c.ppt"));
  42. for (ResourceFile resourceFile : resourceFiles) {
  43. resourceFile.extract2txt();
  44. }
  45. }
  46. }

其中,ResourceFile 是一个抽象类,包含一个抽象函数 extract2txt()。PdfFile、PPTFile、WordFile 都继承了 ResourceFile 类,并且重写了 extract2txt() 函数。在 ToolApplication 中,我们可以利用多态特性,根据对象的实际类型,来决定执行哪个方法。

但如果工具的功能不停地扩展,不仅要能抽取文本内容,还要支持压缩、提取文件元信息等一系列功能,那如果我们继续按照上面的实现思路,就会存在这样几个问题:

  • 违背开闭原则,添加一个新的功能,所有类的代码都要修改;
  • 虽然功能增多,每个类的代码都不断膨胀,可读性和可维护性都变差了;
  • 把所有比较上层的业务逻辑都耦合到 PdfFile、PPTFile、WordFile 类中,导致这些类的职责不够单一。

针对上面的问题,我们常用的解决方法就是拆分解耦,把业务操作跟具体的数据结构解耦,设计成独立的类。这里我们按照访问者模式的演进思路来对上面的代码进行重构。重构后的代码如下所示。

  1. public abstract class ResourceFile {
  2. protected String filePath;
  3. public ResourceFile(String filePath) {
  4. this.filePath = filePath;
  5. }
  6. }
  7. public class PdfFile extends ResourceFile {
  8. public PdfFile(String filePath) {
  9. super(filePath);
  10. }
  11. }
  12. //...PPTFile、WordFile代码省略
  13. // 文本内容抽取类,抽取逻辑与具体数据结构解耦
  14. public class Extractor {
  15. public void extract2txt(PPTFile pptFile) {
  16. System.out.println("Extract PPT.");
  17. }
  18. public void extract2txt(PdfFile pdfFile) {
  19. System.out.println("Extract PDF.");
  20. }
  21. public void extract2txt(WordFile wordFile) {
  22. System.out.println("Extract WORD.");
  23. }
  24. }
  25. public class ToolApplication {
  26. public static void main(String[] args) {
  27. List<ResourceFile> resourceFiles = new ArrayList<>();
  28. resourceFiles.add(new PdfFile("a.pdf"));
  29. resourceFiles.add(new WordFile("b.word"));
  30. resourceFiles.add(new PPTFile("c.ppt"));
  31. Extractor extractor = new Extractor();
  32. for (ResourceFile resourceFile : resourceFiles) {
  33. // ERROR 这里编译不通过,因为静态类型不匹配
  34. extractor.extract2txt(resourceFile);
  35. }
  36. }
  37. }

这其中最关键的一点设计是,我们把抽取文本内容的操作,设计成了三个重载函数。但很遗憾,上面的代码是编译通过不了的,第 37 行会报错。因为函数重载是一种静态绑定,在编译时并不能获取对象的实际类型,而是根据声明类型执行声明类型对应的方法。而上面代码中的 resourceFiles 对象的声明类型都是 ResourceFile,而我们并没有在 Extractor 类中定义参数类型是 ResourceFile 的 extract2txt() 重载函数,所以在编译阶段就通过不了,更别说在运行时根据对象的实际类型执行不同的重载函数了。

那如何解决这个问题呢?我们先来看改进后的代码实现:

  1. public abstract class ResourceFile {
  2. protected String filePath;
  3. public ResourceFile(String filePath) {
  4. this.filePath = filePath;
  5. }
  6. abstract public void accept(Extractor extractor);
  7. }
  8. public class PdfFile extends ResourceFile {
  9. public PdfFile(String filePath) {
  10. super(filePath);
  11. }
  12. @Override
  13. public void accept(Extractor extractor) {
  14. extractor.extract2txt(this);
  15. }
  16. }
  17. //...PPTFile、WordFile代码省略,Extractor代码不变
  18. public class ToolApplication {
  19. public static void main(String[] args) {
  20. List<ResourceFile> resourceFiles = new ArrayList<>();
  21. resourceFiles.add(new PdfFile("a.pdf"));
  22. resourceFiles.add(new WordFile("b.word"));
  23. resourceFiles.add(new PPTFile("c.ppt"));
  24. Extractor extractor = new Extractor();
  25. for (ResourceFile resourceFile : resourceFiles) {
  26. resourceFile.accept(extractor);
  27. }
  28. }
  29. }

在执行第 29 行的时候,根据多态特性,程序会调用实际类型的 accept 函数,比如 PdfFile 的 accept 函数,也就是第 15 行代码。而 15 行代码中的 this 类型是 PdfFile 的,这在编译的时候就确定了,所以会调用 extractor 的 extract2txt(PdfFile pdfFile) 这个重载函数。这个实现思路就是理解访问者模式的关键所在。

现在,如果要继续添加新的功能,比如前面提到的压缩功能,根据不同的文件类型,使用不同的压缩算法来压缩资源文件,那我们该如何实现呢?我们需要实现一个类似 Extractor 类的新类 Compressor 类,在其中定义三个重载函数,实现对不同类型资源文件的压缩。此外,还要在每个资源文件类中定义新的 accept 重载函数。

  1. public abstract class ResourceFile {
  2. protected String filePath;
  3. public ResourceFile(String filePath) {
  4. this.filePath = filePath;
  5. }
  6. abstract public void accept(Extractor extractor);
  7. abstract public void accept(Compressor compressor);
  8. }
  9. public class PdfFile extends ResourceFile {
  10. public PdfFile(String filePath) {
  11. super(filePath);
  12. }
  13. @Override
  14. public void accept(Extractor extractor) {
  15. extractor.extract2txt(this);
  16. }
  17. @Override
  18. public void accept(Compressor compressor) {
  19. compressor.compress(this);
  20. }
  21. }

上面代码还存在一些问题,添加一个新的业务,还是需要修改每个资源文件类,违反了开闭原则。针对这个问题,我们抽象出来一个 Visitor 接口,包含是三个命名非常通用的 visit() 重载函数,分别处理三种不同类型的资源文件。具体做什么业务处理,由实现这个 Visitor 接口的具体的类来决定,比如 Extractor 负责抽取文本内容,Compressor 负责压缩。这样当我们新添加一个业务功能的时候,资源文件类不需要做任何修改了。按照这个思路我们对代码进行重构,重构后的代码如下所示:

  1. public abstract class ResourceFile {
  2. protected String filePath;
  3. public ResourceFile(String filePath) {
  4. this.filePath = filePath;
  5. }
  6. abstract public void accept(Visitor vistor);
  7. }
  8. public class PdfFile extends ResourceFile {
  9. public PdfFile(String filePath) {
  10. super(filePath);
  11. }
  12. @Override
  13. public void accept(Visitor visitor) {
  14. visitor.visit(this);
  15. }
  16. }
  17. //...省略PPTFile、WordFile
  18. public interface Visitor {
  19. void visit(PdfFile pdfFile);
  20. void visit(PPTFile pdfFile);
  21. void visit(WordFile pdfFile);
  22. }
  23. public class Extractor implements Visitor {
  24. @Override
  25. public void visit(PPTFile pptFile) {
  26. System.out.println("Extract PPT.");
  27. }
  28. @Override
  29. public void visit(PdfFile pdfFile) {
  30. System.out.println("Extract PDF.");
  31. }
  32. @Override
  33. public void visit(WordFile wordFile) {
  34. System.out.println("Extract WORD.");
  35. }
  36. }
  37. public class Compressor implements Visitor {
  38. @Override
  39. public void visit(PPTFile pptFile) {
  40. System.out.println("Compress PPT.");
  41. }
  42. @Override
  43. public void visit(PdfFile pdfFile) {
  44. System.out.println("Compress PDF.");
  45. }
  46. @Override
  47. public void visit(WordFile wordFile) {
  48. System.out.println("Compress WORD.");
  49. }
  50. }
  51. public class ToolApplication {
  52. public static void main(String[] args) {
  53. List<ResourceFile> resourceFiles = new ArrayList<>();
  54. resourceFiles.add(new PdfFile("a.pdf"));
  55. resourceFiles.add(new WordFile("b.word"));
  56. resourceFiles.add(new PPTFile("c.ppt"));
  57. // 文本内容提取
  58. Extractor extractor = new Extractor();
  59. for (ResourceFile resourceFile : resourceFiles) {
  60. resourceFile.accept(extractor);
  61. }
  62. // 文本压缩
  63. Compressor compressor = new Compressor();
  64. for(ResourceFile resourceFile : resourceFiles) {
  65. resourceFile.accept(compressor);
  66. }
  67. }
  68. }

访问者模式原理

访问者者模式的英文翻译是 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.

翻译成中文就是:允许一个或者多个操作应用到一组对象上,解耦操作和对象本身。定义比较简单,结合上面的例子也不难理解。对于访问者模式的代码实现,实际上,在上面例子中,经过层层重构之后的最终代码,就是标准的访问者模式的实现代码。总结的类图如下所示:
image.png
总结下访问者模式的应用场景。一般来说,访问者模式针对的是一组类型不同的对象(PdfFile、PPTFile、WordFile)。不过,尽管这组对象的类型是不同的,但是,它们继承相同的父类(ResourceFile)或实现相同的接口。在不同的应用场景下,我们需要对这组对象进行一系列不相关的业务操作(抽取文本、压缩等),但为了避免不断添加功能导致类(PdfFile、PPTFile、WordFile)不断膨胀,职责越来越不单一,以及避免频繁地添加功能导致频繁的代码修改,我们使用访问者模式,将对象与操作解耦,将这些业务操作抽离出来,定义在独立细分的访问者类(Extractor、Compressor)中。