访问者模式

设计模式笔记:访问者模式 (Visitor Pattern)

一、一句话概括

数据结构和作用于该结构上的操作相分离。它允许你在不修改数据结构类的前提下,为这些类添加新的操作

二、为什么需要它 (Why - The Pain Point)

想象一下,你有一个表示文档结构的对象模型(一个组合模式的应用),包含 Paragraph(段落)和 Image(图片)等元素。

1
2
3
4
interface DocumentElement { /* ... */ }
class Paragraph implements DocumentElement { /* ... */ }
class Image implements DocumentElement { /* ... */ }
// Document is a collection of DocumentElement

现在,你需要对这个文档执行多种操作:

  1. 导出为 HTMLParagraph 需要被转换为 <p> 标签,Image 需要被转换为 <img> 标签。
  2. 提取纯文本:需要忽略 Image,只提取 Paragraph 的文本内容。
  3. 计算字数:需要遍历所有 Paragraph 并统计字数。

糟糕的设计(在元素类中添加操作):

最直接的想法是把这些操作直接加到 DocumentElement 接口和它的实现类中。

1
2
3
4
5
6
7
8
9
10
11
12
interface DocumentElement {
String toHtml();
String toPlainText();
int countWords();
}

class Paragraph implements DocumentElement {
public String toHtml() { /* ... */ }
public String toPlainText() { /* ... */ }
public int countWords() { /* ... */ }
}
// ... Image 类也一样

痛点:

  1. 违反单一职责原则ParagraphImage 类现在不仅要负责表示数据,还要负责各种处理逻辑(HTML导出、文本提取等),它们的职责变得非常臃肿。
  2. 违反开闭原则:这是最致命的问题。每当你需要增加一个新的操作(比如“导出为PDF”、“语法检查”),你就必须修改 DocumentElement 接口和所有实现该接口的类。这对于一个稳定且复杂的对象结构来说是不可接受的。
  3. 代码分散:同一个操作(如HTML导出)的逻辑被分散在各个不同的元素类中,不利于集中管理和维护。

核心问题:如何在一个稳定的对象结构上,方便地增加新的操作,而不需要修改这个对象结构本身?

三、它是什么 (What - The Solution)

访问者模式通过一个巧妙的“双重分派”(Double Dispatch)机制解决了这个问题。它引入了一个访问者(Visitor) 对象,这个对象封装了一个完整的操作。

核心思想和角色:

  1. **访问者接口 (Visitor)**:
    • 为对象结构中的每一种具体元素都声明一个 visit 方法。例如 visitParagraph(Paragraph p)visitImage(Image i)
    • 这个接口定义了一个“操作集合”。
  2. **具体访问者 (Concrete Visitor)**:
    • 实现了 Visitor 接口,代表一个具体的操作。
    • 例如,HtmlExportVisitor 实现了 visitParagraph 来生成 <p> 标签,PlainTextVisitor 实现了 visitParagraph 来提取文本。
  3. **元素接口 (Element)**:
    • 定义了一个 accept(Visitor v) 方法。这个方法是连接元素和访问者的桥梁。
  4. **具体元素 (Concrete Element)**:
    • 实现了 Element 接口。
    • accept(Visitor v) 方法的实现非常关键且固定:v.visit(this);。它把自己(this)传递给了访问者。
  5. **对象结构 (Object Structure)**:
    • 通常是一个组合结构(如 List<Element>),它包含了所有的元素对象。
    • 它提供一个方法,可以遍历所有元素,并对每个元素调用其 accept 方法。

双重分派(Double Dispatch)的工作原理:

当客户端想要用一个 HtmlExportVisitor 来处理一个 Document 时:

  1. document.process(htmlVisitor) 被调用。
  2. document 遍历其内部的元素(比如一个 Paragraph 对象 p)。
  3. p.accept(htmlVisitor) 被调用。这是一个第一次分派。Java 根据 p实际类型Paragraph)来决定调用哪个 accept 方法。
  4. Paragraphaccept 方法内部,执行 htmlVisitor.visit(this),即 htmlVisitor.visit(p)。这是一个第二次分派。Java 根据 htmlVisitor实际类型HtmlExportVisitor)和 p静态类型Paragraph,因为方法签名是 visitParagraph(Paragraph p)) 来决定调用 HtmlExportVisitor 中的 visitParagraph 方法。

通过这两次分派,我们成功地将一个具体的操作(HtmlExportVisitor)应用到了一个具体的数据类型(Paragraph)上,而它们之间没有任何静态的耦合。

四、怎么做 (How - The Blueprint)

  1. 定义 Element 接口,包含 accept(Visitor v) 方法。
  2. 创建 ConcreteElementParagraph, Image),实现 accept 方法。
  3. 定义 Visitor 接口,为每个 ConcreteElement 提供一个 visit 方法。
  4. 创建 ConcreteVisitorHtmlExportVisitor),实现所有 visit 方法。
  5. 客户端创建一个对象结构,创建一个具体访问者,然后让结构中的每个元素接受该访问者的访问。

Java 示例代码

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
// --- 1 & 2. Element 接口和 ConcreteElement 类 ---
interface DocumentElement {
void accept(Visitor visitor);
}

class Paragraph implements DocumentElement {
private String text;
public Paragraph(String text) { this.text = text; }
public String getText() { return text; }

@Override
public void accept(Visitor visitor) {
visitor.visit(this); // 第二次分派
}
}

class Image implements DocumentElement {
@Override
public void accept(Visitor visitor) {
visitor.visit(this); // 第二次分派
}
}

// --- 3. Visitor 接口 ---
interface Visitor {
void visit(Paragraph paragraph);
void visit(Image image);
}

// --- 4. ConcreteVisitor 类 ---
class HtmlExportVisitor implements Visitor {
private StringBuilder html = new StringBuilder();

@Override
public void visit(Paragraph paragraph) {
html.append("<p>").append(paragraph.getText()).append("</p>\n");
}

@Override
public void visit(Image image) {
html.append("<img src='image.jpg' />\n");
}

public String getHtml() { return html.toString(); }
}

class PlainTextVisitor implements Visitor {
private StringBuilder text = new StringBuilder();

@Override
public void visit(Paragraph paragraph) {
text.append(paragraph.getText()).append("\n");
}

@Override
public void visit(Image image) {
// 提取纯文本时忽略图片
}

public String getPlainText() { return text.toString(); }
}

// --- 5. Object Structure & Client ---
import java.util.ArrayList;
import java.util.List;

public class VisitorPatternDemo {
public static void main(String[] args) {
// 对象结构
List<DocumentElement> document = new ArrayList<>();
document.add(new Paragraph("This is the first paragraph."));
document.add(new Image());
document.add(new Paragraph("This is the second paragraph."));

// 使用 HTML 导出访问者
HtmlExportVisitor htmlVisitor = new HtmlExportVisitor();
for (DocumentElement element : document) {
element.accept(htmlVisitor); // 第一次分派
}
System.out.println("--- HTML Export ---");
System.out.println(htmlVisitor.getHtml());

// 使用纯文本提取访问者
PlainTextVisitor textVisitor = new PlainTextVisitor();
for (DocumentElement element : document) {
element.accept(textVisitor);
}
System.out.println("--- Plain Text Export ---");
System.out.println(textVisitor.getPlainText());
}
}

五、UML 类图

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
@startuml
skinparam classAttributeIconSize 0
hide empty members

interface Visitor {
+ void visit(Paragraph p)
+ void visit(Image i)
}
class HtmlExportVisitor implements Visitor
class PlainTextVisitor implements Visitor

interface DocumentElement {
+ void accept(Visitor v)
}
class Paragraph implements DocumentElement {
+ void accept(Visitor v)
}
class Image implements DocumentElement {
+ void accept(Visitor v)
}

class VisitorPatternDemo {
' Object Structure '
- List<DocumentElement> document
}

' Relationships
VisitorPatternDemo ..> Visitor : uses
VisitorPatternDemo ..> DocumentElement : uses

' Double Dispatch Link
Paragraph ..> Visitor : "v.visit(this)"
Image ..> Visitor : "v.visit(this)"

@enduml

六、优缺点分析

优点 (Pros):

  1. 极易增加新操作:增加一个新的操作只需要添加一个新的具体访问者类,完全符合开闭原则。
  2. 将操作相关的代码集中:一个操作的所有逻辑都封装在一个访问者类中,而不是分散在各个元素类里。
  3. 可以对复杂结构进行操作:访问者可以跨越不同的元素,并在访问过程中积累状态(如 HtmlExportVisitor 中的 StringBuilder)。

缺点 (Cons):

  1. 难以增加新的元素类:这是该模式最主要的缺点。每当你想在对象结构中增加一个新的具体元素类(比如 VideoElement),你就必须修改 Visitor 接口,增加一个新的 visit(VideoElement v) 方法,这将导致所有已有的具体访问者类都需要被修改。这严重违反了开闭原则。
  2. 破坏封装:为了让访问者能够执行操作,元素类可能需要暴露一些其内部状态的 getter 方法,这在一定程度上破坏了它们的封装性。
  3. 循环依赖ElementVisitor 之间存在相互依赖。

结论:访问者模式适用于数据结构相对稳定,但操作易于频繁变化的场景。

七、在 Java 中的应用

  • 编译器(Compiler)
    • 抽象语法树(AST)是一个非常稳定的对象结构(包含 VariableDeclaration, MethodCall, IfStatement 等节点)。
    • 而对 AST 的操作有很多种:类型检查、代码优化、代码生成等。每一种操作都可以实现为一个访问者。当需要支持一门新的目标语言时,只需添加一个新的代码生成访问者即可,无需修改 AST 的结构。
  • **Java Annotation Processing (JSR 269)**:
    • javax.lang.model.element.ElementVisitor 就是一个访问者接口,它有 visitPackage, visitType, visitVariable 等方法。你可以实现这个接口来处理编译时扫描到的不同类型的程序元素。
  • java.nio.file.FileVisitor:
    • 当你使用 Files.walkFileTree(path, visitor) 时,你传入的 FileVisitor 对象就是一个访问者,它会被用来“访问”文件树中的每一个文件和目录。