25|访问者:在稳定元素结构上持续增加操作
配套代码:GitHub 仓库 · 本篇完整源码 · 行为测试。使用 JDK 25 与 Gradle,包名为
com.hanserwei.patterns.visitor。
系列导航:Java 25 设计模式学习指南
博客文档包含段落和代码块。现在要导出 Markdown,随后还要生成摘要。如果每个元素都不断增加 exportMarkdown、summarize、validate 等方法,元素类会逐渐混入许多用途不同的操作。
当元素种类较稳定、操作经常增加时,可以让元素提供 accept 入口,再把每一种操作集中到访问者对象中。关键机制是两次分派,不是让访问者写一长串 instanceof。
从问题提炼设计意图
访问者把作用于一组元素的操作封装为独立对象,使新增操作时可以不修改元素类;代价是新增元素种类通常要修改全部访问者。
对象职责与协作关系
| 示例角色 | 职责 |
|---|---|
ContentElement |
元素接口,提供 accept |
Paragraph、CodeBlock |
具体元素,在 accept 中调用匹配的 visit 重载 |
ContentVisitor |
访问者接口,为每种元素声明一个 visit |
MarkdownVisitor、SummaryVisitor |
两个独立操作 |
classDiagram
ContentElement <|.. Paragraph
ContentElement <|.. CodeBlock
ContentVisitor <|.. MarkdownVisitor
ContentVisitor <|.. SummaryVisitor
Paragraph ..> ContentVisitor : visit this
CodeBlock ..> ContentVisitor : visit this
代码思路:变化应该落在哪个对象上
调用方持有 ContentElement,调用 accept(visitor) 时,第一次动态分派选择 Paragraph.accept 或 CodeBlock.accept。在具体元素的方法内部,this 的静态类型已经确定,于是编译器可以选择对应的 visit(Paragraph) 或 visit(CodeBlock) 重载。
选定重载后,第二次动态分派再根据 visitor 的实际类型执行 MarkdownVisitor 或 SummaryVisitor 的方法。不要直接给 ContentVisitor 增加 visit(ContentElement) 并期望 Java 在运行时自动选择更具体重载;重载选择与覆写分派发生在不同阶段。
MarkdownVisitor 集中负责 Markdown 操作,SummaryVisitor 集中负责长度摘要。代码块围栏会比内容中的连续反引号更长,避免示例在遇到内含三个反引号时提前闭合;摘要使用 Unicode 码点计数。不同操作可以独立维护,不必进入元素内部修改。
关键实现与独立运行
配套仓库中的包名是 com.hanserwei.patterns.visitor,源码目录为 src/main/java/com/hanserwei/patterns/visitor/。仓库地址统一见系列导航。以下展示关键文件的完整内容;其余角色和测试在同一仓库中,每个顶级类型各占一个文件。
ContentVisitor.java:
package com.hanserwei.patterns.visitor;
/** 为每种元素声明独立操作的访问者. */
public interface ContentVisitor {
/** 处理普通段落. */
String visit(Paragraph paragraph);
/** 处理代码块. */
String visit(CodeBlock codeBlock);
}
Paragraph.java:
package com.hanserwei.patterns.visitor;
import java.util.Objects;
/** 内容结构中的具体元素. */
public final class Paragraph implements ContentElement {
/** 元素保存的不可变内容. */
private final String text;
/** 创建非空内容元素. */
public Paragraph(String text) {
this.text = Objects.requireNonNull(text, "text");
}
/** 返回元素内容. */
public String text() {
return text;
}
/** 以当前具体类型调用 visit 重载,完成第二次分派. */
@Override
public String accept(ContentVisitor visitor) {
return visitor.visit(this);
}
}
MarkdownVisitor.java:
package com.hanserwei.patterns.visitor;
/** 把不同元素转换为 Markdown 的操作集合. */
public final class MarkdownVisitor implements ContentVisitor {
/** 将段落作为普通文本输出. */
@Override
public String visit(Paragraph paragraph) {
return paragraph.text();
}
/** 为代码内容选择足够长的反引号围栏,避免内含围栏提前闭合. */
@Override
public String visit(CodeBlock codeBlock) {
String fence = "```";
while (codeBlock.code().contains(fence)) {
fence += "`";
}
return fence + "\n" + codeBlock.code() + "\n" + fence;
}
}
Demo.java 展示调用方如何装配这些对象:
package com.hanserwei.patterns.visitor;
import java.util.List;
/** 演示本章对象的装配方式和可观察结果. */
public final class Demo {
/** 禁止实例化演示入口. */
private Demo() {}
/** 运行独立示例;args 为未使用的命令行参数. */
public static void main(String[] args) {
List<ContentElement> elements = List.of(new Paragraph("Java"), new CodeBlock("int n = 25;"));
ContentVisitor visitor = new SummaryVisitor();
for (ContentElement element : elements) {
System.out.println(element.accept(visitor));
}
}
}
在配套代码仓库根目录运行;Windows 使用 gradlew.bat 替换 ./gradlew:
./gradlew runVisitor
./gradlew test --tests 'com.hanserwei.patterns.visitor.PatternTest'
示例的业务输出如下,省略 Gradle 自身的任务提示:
paragraph:4
code:11
用测试确认模式的行为
通过 ContentElement 引用访问时仍进入正确重载;两个访问者产生不同结果;反引号内容不会破坏代码围栏,补充平面字符摘要计为一个码点。
对应测试位于 src/test/java/com/hanserwei/patterns/visitor/PatternTest.java。建议先运行现有测试,再改动一个协作环节,观察哪个断言能够发现问题。
常见用法
- 编译器 AST、文档树等元素种类稳定但操作持续增加。
- 同一对象结构需要导出、检查、统计等互相独立的操作。
- 希望一个操作对多种元素的处理集中在同一个类中。
适用边界与容易踩的坑
新增 ImageElement 时,ContentVisitor 要增加 visit(ImageElement),所有具体访问者都要决定如何处理它。这是访问者明确的扩展成本;如果元素种类变化频繁,普通多态或其他调度方式可能更合适。
访问者可能需要读取较多元素数据,因此要避免为了方便操作而暴露内部可变集合。元素应提供适量只读查询,让封装保持可用,而不是让访问者任意修改字段。
本例返回 String,避免把累计结果存进访问者可变字段。若改为状态累积访问者,需要规定实例能否复用、是否可重入以及如何重置。本例 Markdown 只覆盖段落与代码块,不是通用富文本转换器。
与相近模式比较
迭代器负责遍历,访问者负责访问到元素后的操作;组合负责树结构,访问者可以在这棵树上定义独立操作。策略通常针对一种共同输入替换算法,访问者还关心不同元素类型的专门处理。
动手练习
先增加 PlainTextVisitor,保持元素类不变。再增加 ImageElement,记录所有需要修改的访问者。用这两次改动解释“新增操作容易,新增元素昂贵”的具体含义。
系列导航:Java 25 设计模式学习指南