跳到主要内容

访问者模式-Visitor Pattern

序言​

一个文件系统里有文件和目录:文件有大小,目录可以包含文件和子目录。现在有两个需求——统计所有文件的总大小、把整个目录树导出成树状文本。这两个操作遍历的都是相同的元素结构(File / Directory),只是对每个元素执行的操作不同。

如果直接在每个元素类里实现"计算大小""导出文本",元素类会被各种不相关的操作塞满,而且每新增一个操作(比如"统计文件数量")都要修改所有元素类——违反开闭原则。

访问者模式(Visitor Pattern)给出了优雅的解法:把"操作"从元素类中分离出来,封装成独立的访问者对象;元素类只提供一个 accept(visitor) 方法,让访问者"进入"元素执行操作。这样,新增操作只需要新增一个访问者类,元素类一个字节都不用改。

定义​

Represent an operation to be performed on the elements of an object structure. Visitor lets you define a new operation without changing the classes of the elements on which it operates.

表示一个作用于某对象结构中的各元素的操作。它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作。

两个关键点:

  1. 操作与元素分离:操作封装在访问者中,元素只负责"接待"访问者;
  2. 不改元素类:新增操作不需要修改任何元素类,只需要新增访问者。

结构​

让我们从一个问题开始​

假设文件系统中有 File(文件)和 Directory(目录)两类元素。要计算总大小,最直接的方式是在元素类中实现:

public class File {

private final long size;

public long calculateSize() {
return size;
}
}

public class Directory {

private final List<Element> children = new ArrayList<>();

public long calculateSize() {
long total = 0;
for (Element child : children) {
total += child.calculateSize();
}
return total;
}
}

然后需求又来了:要导出树状文本、要统计文件个数、要找出最大的文件……每个需求都要往元素类里加方法。元素类越来越多与"文件系统"无关的操作,而且每加一个操作都要动所有元素类。

访问者模式的结构​

把操作放进访问者,元素只提供 accept 方法:

各个角色的职责如下:

  • Visitor(抽象访问者):为每种元素声明一个访问方法(visitFile、visitDirectory),对应图中的 Visitor。
  • ConcreteVisitor(具体访问者):实现每种元素上的具体操作,对应图中的 SizeCalculatorVisitor、TreeExportVisitor。
  • Element(元素):声明 accept(visitor) 方法,接受访问者访问,对应图中的 Element。
  • ConcreteElement(具体元素):实现 accept,在方法内部调用 visitor.visitXxx(this),对应图中的 File、Directory。
  • ObjectStructure(对象结构):持有元素集合,供访问者遍历,对应图中的 Directory(树结构本身)。

双分派(Double Dispatch)​

访问者模式的关键机制是双分派:

  1. 第一次分派:客户端调用 element.accept(visitor),由元素的动态类型决定进入哪个元素的 accept(File 还是 Directory);
  2. 第二次分派:accept 内部调用 visitor.visitXxx(this),由访问者的动态类型决定执行哪个操作(SizeCalculatorVisitor 还是 TreeExportVisitor)。

正是双分派让"在不知道具体元素类型的情况下,也能对每种元素执行正确操作"成为可能。

代码实现​

我们来实现文件系统的"计算大小"和"导出树"两个访问者。

定义元素接口和具体元素​

public interface Element {
void accept(Visitor visitor);
}
import java.util.ArrayList;
import java.util.List;

public class File implements Element {

private final String name;
private final long size;

public File(String name, long size) {
this.name = name;
this.size = size;
}

@Override
public void accept(Visitor visitor) {
// 第一次分派在这里完成:this 是 File
visitor.visitFile(this);
}

public String getName() {
return name;
}

public long getSize() {
return size;
}
}
public class Directory implements Element {

private final String name;
private final List<Element> children = new ArrayList<>();

public Directory(String name) {
this.name = name;
}

public void add(Element element) {
children.add(element);
}

@Override
public void accept(Visitor visitor) {
// 第一次分派:this 是 Directory
visitor.visitDirectory(this);
}

public String getName() {
return name;
}

public List<Element> getChildren() {
return children;
}
}

定义访问者接口和具体访问者​

public interface Visitor {
void visitFile(File file);

void visitDirectory(Directory directory);
}
public class SizeCalculatorVisitor implements Visitor {

private long totalSize = 0;

@Override
public void visitFile(File file) {
// 文件:累加大小
totalSize += file.getSize();
}

@Override
public void visitDirectory(Directory directory) {
// 目录:遍历子元素(子元素会再次触发 accept 分派)
for (Element child : directory.getChildren()) {
child.accept(this);
}
}

public long getTotalSize() {
return totalSize;
}
}
public class TreeExportVisitor implements Visitor {

private final StringBuilder builder = new StringBuilder();
private int depth = 0;

@Override
public void visitFile(File file) {
builder.append(" ".repeat(depth))
.append("📄 ").append(file.getName())
.append(" (").append(file.getSize()).append(" bytes)\n");
}

@Override
public void visitDirectory(Directory directory) {
builder.append(" ".repeat(depth))
.append("📁 ").append(directory.getName()).append("/\n");
depth++;
for (Element child : directory.getChildren()) {
child.accept(this);
}
depth--;
}

public String getTree() {
return builder.toString();
}
}

客户端使用​

public class Main {

public static void main(String[] args) {
// 构建目录结构
Directory root = new Directory("root");
root.add(new File("readme.md", 1200));

Directory src = new Directory("src");
src.add(new File("Main.java", 2048));
src.add(new File("Util.java", 4096));
root.add(src);

Directory docs = new Directory("docs");
docs.add(new File("guide.md", 8192));
root.add(docs);

// 操作一:计算总大小(新增操作,元素类无需修改)
SizeCalculatorVisitor sizeVisitor = new SizeCalculatorVisitor();
root.accept(sizeVisitor);
System.out.println("总大小:" + sizeVisitor.getTotalSize() + " bytes");

System.out.println("=========================");

// 操作二:导出目录树
TreeExportVisitor treeVisitor = new TreeExportVisitor();
root.accept(treeVisitor);
System.out.println(treeVisitor.getTree());
}
}

输出结果:

总大小:15536 bytes
=========================
📁 root/
📄 readme.md (1200 bytes)
📁 src/
📄 Main.java (2048 bytes)
📄 Util.java (4096 bytes)
📁 docs/
📄 guide.md (8192 bytes)

核心收益:

  1. 新增"统计文件个数""找最大文件"等操作,只需要新增一个访问者类,File、Directory 完全不用动;
  2. 元素类保持纯粹:只包含自身属性结构和 accept 方法;
  3. 同一结构上的多个操作被清晰隔离,互不影响。

在开源框架中的应用​

java.nio.file:FileTreeWalker 的源码遍历​

JDK 的 Files.walkFileTree 用于递归遍历文件目录树,其内部类 FileTreeWalker 是访问者模式的完整实现。从源码层面看:

访问者接口:FileVisitor​

// java.nio.file.FileVisitor(源码)
public interface FileVisitor<T> {

// 访问一个目录之前调用
FileVisitResult preVisitDirectory(T dir, BasicFileAttributes attrs) throws IOException;

// 访问一个文件时调用
FileVisitResult visitFile(T file, BasicFileAttributes attrs) throws IOException;

// 访问文件失败时调用
FileVisitResult visitFileFailed(T file, IOException exc) throws IOException;

// 访问完一个目录(及其所有子项)之后调用
FileVisitResult postVisitDirectory(T dir, IOException exc) throws IOException;
}

遍历引擎:FileTreeWalker 如何驱动访问者​

Files.walkFileTree 内部创建 FileTreeWalker 并调用 walk(start)。FileTreeWalker.visit 是核心:它判断节点是文件还是目录,然后回调访问者的对应方法,并根据 FileVisitResult 返回值决定遍历流程(继续、跳过子树、终止):

// java.nio.file.FileTreeWalker.visit(源码骨架)
private Event visit(Path entry, boolean ignoreSecurityException) throws IOException {
...
// 读取节点属性,判断是文件还是目录
BasicFileAttributes attrs = ...;

if (attrs.isDirectory()) {
// 目录:先回调 preVisitDirectory(访问者决定是否进入)
FileVisitResult result = visitor.preVisitDirectory(entry, attrs);
if (result != FileVisitResult.CONTINUE) {
// 访问者要求 SKIP_SUBTREE / TERMINATE:不再遍历子项
return new Event(result, ...);
}
...
// 遍历该目录的每个子项,递归调用 visit(深度优先)
try (DirectoryStream<Path> stream = Files.newDirectoryStream(entry)) {
for (Path child : stream) {
... visit(child, false) ...
}
}
// 子项遍历完,回调 postVisitDirectory
visitor.postVisitDirectory(entry, ioe);
} else {
// 文件:直接回调 visitFile
visitor.visitFile(entry, attrs);
}
...
}

访问者的流程控制:FileVisitResult​

// java.nio.file.FileVisitResult(源码)
public enum FileVisitResult {
CONTINUE, // 继续遍历
TERMINATE, // 终止遍历
SKIP_SUBTREE, // 跳过当前目录的子树(不进入)
SKIP_SIBLINGS // 跳过兄弟节点
}

与访问者模式的对应:

  • Element(元素) = 文件系统中的 Path(文件/目录),JDK 从未修改过 Path/文件类本身;
  • Visitor(访问者) = FileVisitor,四个回调对应四种"操作";
  • ObjectStructure(对象结构) = 目录树,遍历引擎 FileTreeWalker 负责深度优先遍历并驱动回调;
  • 双分派的替代:FileTreeWalker 通过 attrs.isDirectory() 判断元素类型后选择回调,达到与"accept → visitXxx"等价的分派效果。

ASM:ClassReader 事件驱动的访问者遍历​

ASM 是 Java 字节码操作框架,它的核心设计就是访问者模式:字节码结构(Class/Method/Field)保持不动,所有操作(插桩、分析、生成)都以访问者形式提供。从源码层面看:

访问者接口:ClassVisitor​

// org.objectweb.asm.ClassVisitor(源码骨架)
public abstract class ClassVisitor {

// 类开始:版本、修饰符、类名、签名……
public void visit(int version, int access, String name, String signature, ...) {}

// 注解:返回注解访问者
public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) { ... }

// 字段:返回字段访问者(可继续访问字段注解/属性)
public FieldVisitor visitField(int access, String name, String descriptor, ...) { ... }

// 方法:返回方法访问者(可继续访问方法指令)
public MethodVisitor visitMethod(int access, String name, String descriptor, ...) { ... }

// 类结束
public void visitEnd() { ... }
}

遍历引擎:ClassReader.accept 驱动回调​

ClassReader.accept(ClassVisitor) 是核心引擎:它解析 class 文件的字节序列,按字节码结构的出现顺序触发访问者的回调:

// org.objectweb.asm.ClassReader.accept(源码骨架)
public void accept(final ClassVisitor classVisitor, final Attribute[] attributePrototypes, final int parsingOptions) {
...
// 1. 解析类头,触发 visit:类版本、修饰符、类名
classVisitor.visit(readInt(cpInfoOffsets[0] - 7), readUnsignedShort(accessFlagsOffset), ...);

// 2. 遍历注解,触发 visitAnnotation(返回 AnnotationVisitor 继续访问)
int numAnnotations = ...;
while (numAnnotations-- > 0) {
... classVisitor.visitAnnotation(descriptor, visible);
}

// 3. 遍历字段,触发 visitField(返回 FieldVisitor)
for (i = 0; i < numFields; ++i) {
FieldVisitor fieldVisitor = classVisitor.visitField(access, name, descriptor, signature, ...);
...
fieldVisitor.visitEnd();
}

// 4. 遍历方法,触发 visitMethod(返回 MethodVisitor,接着访问每条字节码指令)
for (i = 0; i < numMethods; ++i) {
MethodVisitor methodVisitor = classVisitor.visitMethod(access, name, descriptor, ...);
// 逐条指令回调:visitInsn / visitVarInsn / visitMethodInsn / visitLdcInsn ...
... methodVisitor.visitCode();
... methodVisitor.visitMethodInsn(...);
... methodVisitor.visitInsn(...);
... methodVisitor.visitMaxs(...);
... methodVisitor.visitEnd();
}

// 5. 类结束
classVisitor.visitEnd();
}

与访问者模式的对应:

  • Element(元素) = class 字节码中的类、字段、方法、指令——它们不是对象,而是字节流中的结构,ASM 通过解析字节码"虚拟出"这些元素;
  • Visitor(访问者) = ClassVisitor/MethodVisitor/FieldVisitor——想对字节码做什么(打印、插桩、改写)就覆写对应回调;
  • ObjectStructure(对象结构) = class 文件本身,ClassReader.accept 是遍历引擎,按结构顺序触发回调;
  • 新增操作零改动:新增一种字节码处理逻辑只需要新增一个 ClassVisitor 子类,ASM 的解析引擎完全不动。

Spring Boot 的 @Configuration 处理、字节码增强的 AOP 框架(CGLIB 时代)、各种 Java 代理工具,底层都离不开 ASM 的这套访问者设计。

适用场景​

  • 当对象结构稳定(元素类型很少变化)但操作经常增加时,访问者模式让新增操作不需要修改任何元素类。
  • 当需要在同一元素结构上执行多种不同的、彼此无关的操作(统计、导出、校验、渲染)时,访问者模式按操作组织代码,结构清晰。
  • 当元素结构本身不需要暴露内部细节,但又需要执行复杂遍历操作时,访问者可以集中处理遍历逻辑。
  • 注意:如果元素类型本身经常变化(新增一种元素),访问者模式需要修改访问者接口和所有访问者实现,此时应避免使用;访问者模式也依赖元素类提供内部数据(getter),会破坏一定的封装性。

与其他设计模式的关系​

  • 访问者模式常用于**组合模式(Composite)**的对象树(文件系统、AST、UI 组件树)上,遍历树结构并对每个节点执行操作。
  • 访问者对对象结构的遍历可以借助**迭代器模式(Iterator)**完成元素集合的访问。
  • 编译器/解释器场景中,**解释器模式(Interpreter)**的 AST 遍历经常用访问者实现语法树上的各种操作(求值、打印、类型检查)。
  • 访问者里的 accept 分派逻辑与**模板方法模式(Template Method)**有相似之处,但访问者关注的是"给每种元素加操作",模板方法关注的是"固定算法骨架"。

总结​

访问者模式通过双分派把"操作"从元素结构中剥离:

  • 元素类只提供 accept 方法,保持纯粹;
  • 操作封装在访问者中,新增操作只需新增访问者类;
  • 对象结构稳定、操作频繁变化时,完美符合开闭原则。

访问者模式的缺点是:元素类型变化会导致所有访问者联动修改;访问者需要访问元素的内部数据,削弱封装;双分派机制也增加了理解成本。因此在实践中,访问者模式主要用于结构稳定、操作多变的场景——文件系统遍历(Files.walkFileTree)、字节码操作(ASM)、编译器 AST 处理是它的三大主场。