访问者模式
访问者模式
Rif设计模式笔记:访问者模式 (Visitor Pattern)
一、一句话概括
将数据结构和作用于该结构上的操作相分离。它允许你在不修改数据结构类的前提下,为这些类添加新的操作。
二、为什么需要它 (Why - The Pain Point)
想象一下,你有一个表示文档结构的对象模型(一个组合模式的应用),包含 Paragraph(段落)和 Image(图片)等元素。
1 | interface DocumentElement { /* ... */ } |
现在,你需要对这个文档执行多种操作:
- 导出为 HTML:
Paragraph需要被转换为<p>标签,Image需要被转换为<img>标签。 - 提取纯文本:需要忽略
Image,只提取Paragraph的文本内容。 - 计算字数:需要遍历所有
Paragraph并统计字数。
糟糕的设计(在元素类中添加操作):
最直接的想法是把这些操作直接加到 DocumentElement 接口和它的实现类中。
1 | interface DocumentElement { |
痛点:
- 违反单一职责原则:
Paragraph和Image类现在不仅要负责表示数据,还要负责各种处理逻辑(HTML导出、文本提取等),它们的职责变得非常臃肿。 - 违反开闭原则:这是最致命的问题。每当你需要增加一个新的操作(比如“导出为PDF”、“语法检查”),你就必须修改
DocumentElement接口和所有实现该接口的类。这对于一个稳定且复杂的对象结构来说是不可接受的。 - 代码分散:同一个操作(如HTML导出)的逻辑被分散在各个不同的元素类中,不利于集中管理和维护。
核心问题:如何在一个稳定的对象结构上,方便地增加新的操作,而不需要修改这个对象结构本身?
三、它是什么 (What - The Solution)
访问者模式通过一个巧妙的“双重分派”(Double Dispatch)机制解决了这个问题。它引入了一个访问者(Visitor) 对象,这个对象封装了一个完整的操作。
核心思想和角色:
- **访问者接口 (Visitor)**:
- 为对象结构中的每一种具体元素都声明一个
visit方法。例如visitParagraph(Paragraph p)和visitImage(Image i)。 - 这个接口定义了一个“操作集合”。
- 为对象结构中的每一种具体元素都声明一个
- **具体访问者 (Concrete Visitor)**:
- 实现了
Visitor接口,代表一个具体的操作。 - 例如,
HtmlExportVisitor实现了visitParagraph来生成<p>标签,PlainTextVisitor实现了visitParagraph来提取文本。
- 实现了
- **元素接口 (Element)**:
- 定义了一个
accept(Visitor v)方法。这个方法是连接元素和访问者的桥梁。
- 定义了一个
- **具体元素 (Concrete Element)**:
- 实现了
Element接口。 accept(Visitor v)方法的实现非常关键且固定:v.visit(this);。它把自己(this)传递给了访问者。
- 实现了
- **对象结构 (Object Structure)**:
- 通常是一个组合结构(如
List<Element>),它包含了所有的元素对象。 - 它提供一个方法,可以遍历所有元素,并对每个元素调用其
accept方法。
- 通常是一个组合结构(如
双重分派(Double Dispatch)的工作原理:
当客户端想要用一个 HtmlExportVisitor 来处理一个 Document 时:
document.process(htmlVisitor)被调用。document遍历其内部的元素(比如一个Paragraph对象p)。p.accept(htmlVisitor)被调用。这是一个第一次分派。Java 根据p的实际类型(Paragraph)来决定调用哪个accept方法。- 在
Paragraph的accept方法内部,执行htmlVisitor.visit(this),即htmlVisitor.visit(p)。这是一个第二次分派。Java 根据htmlVisitor的实际类型(HtmlExportVisitor)和p的静态类型(Paragraph,因为方法签名是visitParagraph(Paragraph p)) 来决定调用HtmlExportVisitor中的visitParagraph方法。
通过这两次分派,我们成功地将一个具体的操作(HtmlExportVisitor)应用到了一个具体的数据类型(Paragraph)上,而它们之间没有任何静态的耦合。
四、怎么做 (How - The Blueprint)
- 定义
Element接口,包含accept(Visitor v)方法。 - 创建
ConcreteElement类(Paragraph,Image),实现accept方法。 - 定义
Visitor接口,为每个ConcreteElement提供一个visit方法。 - 创建
ConcreteVisitor类(HtmlExportVisitor),实现所有visit方法。 - 客户端创建一个对象结构,创建一个具体访问者,然后让结构中的每个元素接受该访问者的访问。
Java 示例代码
1 | // --- 1 & 2. Element 接口和 ConcreteElement 类 --- |
五、UML 类图
1 | @startuml |
六、优缺点分析
优点 (Pros):
- 极易增加新操作:增加一个新的操作只需要添加一个新的具体访问者类,完全符合开闭原则。
- 将操作相关的代码集中:一个操作的所有逻辑都封装在一个访问者类中,而不是分散在各个元素类里。
- 可以对复杂结构进行操作:访问者可以跨越不同的元素,并在访问过程中积累状态(如
HtmlExportVisitor中的StringBuilder)。
缺点 (Cons):
- 难以增加新的元素类:这是该模式最主要的缺点。每当你想在对象结构中增加一个新的具体元素类(比如
VideoElement),你就必须修改Visitor接口,增加一个新的visit(VideoElement v)方法,这将导致所有已有的具体访问者类都需要被修改。这严重违反了开闭原则。 - 破坏封装:为了让访问者能够执行操作,元素类可能需要暴露一些其内部状态的
getter方法,这在一定程度上破坏了它们的封装性。 - 循环依赖:
Element和Visitor之间存在相互依赖。
结论:访问者模式适用于数据结构相对稳定,但操作易于频繁变化的场景。
七、在 Java 中的应用
- 编译器(Compiler):
- 抽象语法树(AST)是一个非常稳定的对象结构(包含
VariableDeclaration,MethodCall,IfStatement等节点)。 - 而对 AST 的操作有很多种:类型检查、代码优化、代码生成等。每一种操作都可以实现为一个访问者。当需要支持一门新的目标语言时,只需添加一个新的代码生成访问者即可,无需修改 AST 的结构。
- 抽象语法树(AST)是一个非常稳定的对象结构(包含
- **Java Annotation Processing (JSR 269)**:
javax.lang.model.element.ElementVisitor就是一个访问者接口,它有visitPackage,visitType,visitVariable等方法。你可以实现这个接口来处理编译时扫描到的不同类型的程序元素。
java.nio.file.FileVisitor:- 当你使用
Files.walkFileTree(path, visitor)时,你传入的FileVisitor对象就是一个访问者,它会被用来“访问”文件树中的每一个文件和目录。
- 当你使用