访问者模式可能是 23 个 GoF 模式里"最难一眼理解但威力最大"的一个 —— 它解决一个具体的工程难题:已经定型的对象树,如何在不改这些类的前提下给它们添加新操作。编译器的 AST 遍历、Lint 工具、序列化框架、文档结构分析,背后都靠它。这篇文章把访问者从动机讲到 AST 工具、Java NIO FileVisitor、JSON 树解析,讲清楚它和组合模式、迭代器、双分派的关系。
问题:对象层次稳定,操作种类爆炸
设想一个文件系统模型:
abstract class FsNode { ... }
class File extends FsNode { String name; long size; }
class Folder extends FsNode { String name; List<FsNode> children; }
class Symlink extends FsNode { String name; String target; }
现在需要对这棵树做很多种操作:
- 计算总大小。
- 导出为 JSON。
- 导出为 XML。
- 查找所有 .log 文件。
- 统计 各类型节点数量。
- 计算最深路径。
- 权限检查。
朴素做法:给 FsNode 加一堆方法 —— calcSize()、toJson()、toXml()、findLogs()、statTypes() ……
问题:
- 每加一种操作,所有 FsNode 子类都要加方法。File、Folder、Symlink 都要实现 toJson、toXml、findLogs ……
- 这些操作和 FsNode 的"本职"无关 —— 一个 File 类的职责是"代表文件",不是"知道怎么序列化为 XML"。
- 类越来越胖,违反单一职责。
访问者模式说:把"对每个节点做什么"封装成一个独立的 Visitor 对象,让节点把自己交给 Visitor 处理。
访问者的标准结构
// 1. 访问者接口:为每种节点类型定义一个 visit 方法
interface FsVisitor<T> {
T visitFile(File file);
T visitFolder(Folder folder);
T visitSymlink(Symlink symlink);
}
// 2. 节点接口:提供 accept 方法,把自己派发给 Visitor
abstract class FsNode {
abstract <T> T accept(FsVisitor<T> visitor);
}
class File extends FsNode {
String name; long size;
public <T> T accept(FsVisitor<T> v) { return v.visitFile(this); }
}
class Folder extends FsNode {
String name; List<FsNode> children;
public <T> T accept(FsVisitor<T> v) { return v.visitFolder(this); }
}
class Symlink extends FsNode {
String name; String target;
public <T> T accept(FsVisitor<T> v) { return v.visitSymlink(this); }
}
// 3. 具体访问者:每个代表一种"对树做的事"
class SizeVisitor implements FsVisitor<Long> {
public Long visitFile(File f) { return f.size; }
public Long visitFolder(Folder f) {
long total = 0;
for (FsNode child : f.children) total += child.accept(this);
return total;
}
public Long visitSymlink(Symlink s) { return 0L; }
}
class JsonVisitor implements FsVisitor<String> {
public String visitFile(File f) {
return String.format("{\"type\":\"file\",\"name\":\"%s\",\"size\":%d}", f.name, f.size);
}
public String visitFolder(Folder f) {
StringBuilder sb = new StringBuilder("{\"type\":\"folder\",\"name\":\"" + f.name + "\",\"children\":[");
for (int i = 0; i < f.children.size(); i++) {
if (i > 0) sb.append(',');
sb.append(f.children.get(i).accept(this));
}
return sb.append("]}").toString();
}
public String visitSymlink(Symlink s) {
return String.format("{\"type\":\"symlink\",\"name\":\"%s\",\"target\":\"%s\"}", s.name, s.target);
}
}
// 使用
FsNode root = ...;
long totalSize = root.accept(new SizeVisitor());
String json = root.accept(new JsonVisitor());
关键收获:
- 新加操作 = 写一个新 Visitor,FsNode 类层次零改动。
- 每个 Visitor 都集中处理"所有节点类型在这个操作上的逻辑",方便统一审查。
- FsNode 类只负责"派发":
accept方法 + 把 this 传给对应的 visit。
双分派(Double Dispatch):访问者的核心机制
为什么需要 accept 这层间接?为什么不直接 visitor.visit(node)?
因为 Java(和大多数语言)是单分派的 —— 调用 visitor.visit(node) 时,JVM 只根据 visitor 的运行时类型决定调哪个 visit;node 的类型只能用编译期类型(即 FsNode)。结果只能进 visit(FsNode) 一个方法,没法分发到 visitFile / visitFolder。
访问者用"两次分派"解决:
- 客户端调
node.accept(visitor)—— 第一次分派,根据 node 的运行时类型,进入 File.accept 或 Folder.accept。 - accept 内部调
visitor.visitFile(this)或visitor.visitFolder(this)—— 第二次分派,根据 visitor 的类型决定调具体哪个 visit。
这种"两步走"让分发同时考虑了"节点类型 × 访问者类型"两个维度。这是访问者模式的根本。
实战 1:编译器 AST 遍历
解析器把代码变成 AST(抽象语法树),AST 有不同节点类型:NumberLiteral、BinaryExpr、IfStmt、FuncCall。需要对 AST 做的操作很多:类型检查、字节码生成、AST 转换、静态分析、Lint。每种操作一个 Visitor:
interface AstVisitor<T> {
T visitNumber(Number n);
T visitBinary(BinaryExpr e);
T visitIf(IfStmt s);
T visitFuncCall(FuncCall c);
T visitIdent(Ident i);
// ...
}
// 求值访问者
class Evaluator implements AstVisitor<Object> {
Map<String, Object> env = new HashMap<>();
public Object visitNumber(Number n) { return n.value; }
public Object visitBinary(BinaryExpr e) {
Object l = e.left.accept(this);
Object r = e.right.accept(this);
return switch (e.op) {
case "+" -> (double)l + (double)r;
case "-" -> (double)l - (double)r;
// ...
default -> throw new RuntimeException();
};
}
public Object visitIf(IfStmt s) {
return (boolean) s.cond.accept(this)
? s.then.accept(this)
: s.elseBranch.accept(this);
}
public Object visitIdent(Ident i) { return env.get(i.name); }
public Object visitFuncCall(FuncCall c) { ... }
}
// 类型检查访问者
class TypeChecker implements AstVisitor<Type> {
public Type visitNumber(Number n) { return Type.NUMBER; }
public Type visitBinary(BinaryExpr e) {
Type l = e.left.accept(this);
Type r = e.right.accept(this);
if (l != r) reportError("type mismatch");
return l;
}
// ...
}
// Lint 访问者:找未使用的变量
class UnusedVarLinter implements AstVisitor<Void> { ... }
Babel、ESLint、Spoon、JavaCC、Roslyn 全用访问者遍历 AST。Babel 的 traverse 函数其实就是访问者模式的工厂。
实战 2:Java NIO FileVisitor
// JDK 内置的 FileVisitor 接口就是访问者模式
Files.walkFileTree(Path.of("/tmp"), new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
if (file.toString().endsWith(".log")) {
System.out.println(file);
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
if (dir.getFileName().toString().equals("node_modules")) {
return FileVisitResult.SKIP_SUBTREE; // 跳过子树
}
return FileVisitResult.CONTINUE;
}
});
看一段就明白 —— 你不需要自己写"如何遍历目录树"的代码,JDK 帮你做了,你只写"在每个文件/目录上做什么"。FileVisitResult 还让你能 SKIP_SUBTREE、TERMINATE,控制遍历流程。
实战 3:JSON 树访问
// Jackson 的 TreeNode 访问
JsonNode root = mapper.readTree(jsonStr);
void visit(JsonNode node, String path) {
if (node.isObject()) {
ObjectNode obj = (ObjectNode) node;
obj.fieldNames().forEachRemaining(name -> visit(obj.get(name), path + "." + name));
} else if (node.isArray()) {
for (int i = 0; i < node.size(); i++) visit(node.get(i), path + "[" + i + "]");
} else {
// 叶子
if (node.isTextual() && node.asText().contains("secret")) {
System.out.println("敏感字段:" + path);
}
}
}
visit(root, "");
JSON 结构访问、XPath 评估、HTML DOM 遍历、Lucene 索引段访问 —— 凡是有"层级结构 + 多种操作"的地方都用访问者。
访问者模式的痛点:节点类型变化困难
访问者模式有一个根本权衡:
- 加新操作容易:写一个新 Visitor 就行。
- 加新节点类型困难:所有 Visitor 接口都要加一个新方法,所有 Visitor 实现都要写这个方法。
这就是"表达式问题"(Expression Problem):面向对象擅长加新类型(加新 FsNode 子类不动 Visitor),函数式擅长加新操作(加新函数不动数据类型)—— 访问者用面向对象语言模拟函数式的"加新操作",代价就是"加新类型"变贵。
所以访问者适合类型层次稳定、操作种类频繁增加的场景(AST、文件系统、DOM)。如果类型也会频繁变化,访问者就不再合适,改用模式匹配或多重派发。
Java 17 模式匹配:访问者的替代
Java 14+ 的 sealed class + 模式匹配,提供了访问者的现代替代:
sealed interface FsNode permits File, Folder, Symlink {}
record File(String name, long size) implements FsNode {}
record Folder(String name, List<FsNode> children) implements FsNode {}
record Symlink(String name, String target) implements FsNode {}
long size(FsNode node) {
return switch (node) {
case File f -> f.size();
case Folder f -> f.children().stream().mapToLong(c -> size(c)).sum();
case Symlink s -> 0;
};
}
无需 Visitor 接口、无需 accept 方法,switch 表达式就完成双分派。Kotlin、Scala、Rust、Swift 的 sealed/enum 都支持这种模式。但要求语言支持模式匹配,旧 Java、JS、Go、Python 仍然得用经典访问者。
访问者 + 组合:经典搭配
访问者几乎总和组合模式一起出现 —— 组合定义树形结构,访问者定义树上的操作。visitFolder 通常会递归调用 children 的 accept,本身就是组合 + 访问者的双重应用。
访问者的几个变种
1. 反射访问者
不写 visitFile / visitFolder 一堆方法,用反射在运行时查找对应方法:
class ReflectiveVisitor {
public void visit(Object node) {
try {
Method m = getClass().getMethod("visit" + node.getClass().getSimpleName(), node.getClass());
m.invoke(this, node);
} catch (Exception e) { /* 没找到对应方法,默认处理 */ }
}
}
优点:加新节点类型时只在 Visitor 加一个新方法,不修改接口。缺点:运行时性能差,编译期检查丢失。仅适合"快速原型"或"插件化"场景。
2. 默认方法访问者
interface FsVisitor {
default void visitFile(File f) {} // 默认空
default void visitFolder(Folder f) { // 默认递归
for (FsNode c : f.children) c.accept(this);
}
default void visitSymlink(Symlink s) {}
}
// 我只关心 File,其他都不管:
new FsVisitor() {
public void visitFile(File f) { System.out.println(f.name); }
}
用接口默认方法,减少子类样板。Spring 的 BeanPostProcessor、JDK 的 SimpleFileVisitor 都用这种思路。
访问者的常见坑
坑 1:Visitor 持有可变状态。 多线程并行访问同一棵树时,SizeVisitor 累加 total 字段会有竞争。要么让 Visitor 不持状态(返回值累加),要么并行时每线程一个 Visitor。
坑 2:递归深度爆栈。 visitFolder 递归调用 child.accept,深目录(10000 层)会爆栈。改用显式栈迭代:
Deque<FsNode> stack = new ArrayDeque<>();
stack.push(root);
while (!stack.isEmpty()) {
FsNode n = stack.pop();
n.accept(visitor);
if (n instanceof Folder f) f.children.forEach(stack::push);
}
坑 3:接口被一个 Visitor 把持。 给 Visitor 接口加一个"我只用到的"方法,导致其他所有 Visitor 实现都要写一遍。要么用默认方法,要么重新考虑这个方法是不是属于该接口。
坑 4:破坏封装。 Visitor 拿到 File 后直接读 File 的私有字段(因为同包),改了 File 内部结构,所有 Visitor 都受影响。规范:节点提供必要的 getter,Visitor 通过接口访问。
什么时候用访问者
- 对象结构层次稳定(节点种类不常加)。
- 需要对结构执行多种不同操作,且操作还在增加。
- 不希望把这些操作"污染"到节点类里。
- 需要跨多种节点类型的统一处理。
什么时候不用
- 节点类型会频繁变化:加节点变成噩梦。
- 语言支持模式匹配:用 switch on type 更轻量。
- 操作只有 1-2 种且永远不会扩展:直接在节点里写就行。
写在最后
访问者模式是面向对象设计的"反直觉技巧" —— 它用看似复杂的双分派,换取"加新操作时不动节点类"的灵活性。理解了它,你也理解了为什么编译器框架(Babel、JavaParser、Spoon)能让用户写出"自定义 AST 变换"这种听起来很难的功能 —— 框架定义节点,用户写 Visitor。
给一个工程信号:当你的"领域对象树"上,代码里堆了一堆"对这个树做某种事"的工具函数,且这种函数还在增加时 —— 访问者在向你招手。把这些工具变成 Visitor,代码组织立刻清晰。即使用不上完整的 GoF 访问者,"用一个独立类来代表对树做的某个操作"这个思想本身,就是一笔可观的收获。
—— 别看了 · 2026