本文是【GoF设计模式】系列第23篇,更多内容欢迎关注公众号:咖啡八杯

前言
为什么需要访问者模式?
假设一个文档系统里有段落、图片、表格三类元素,要导出 HTML 和 Markdown 两种格式。最直接的写法是把导出逻辑塞进每个元素类:
class Paragraph {String toHtml() { return "<p>" + text + "</p>"; }String toMarkdown() { return text; }
}class Image {String toHtml() { return "<img src=\"" + url + "\"/>"; }String toMarkdown() { return ""; }
}
看起来能跑,但问题藏在后面--每加一种导出格式(PDF、Word、纯文本),段落、图片、表格三个类都要各加一个方法;格式越多,元素类越臃肿。更糟的是,导出逻辑散落在各个元素类里,想统一维护一套 HTML 规则都做不到。
问题症结在于把"操作"和"数据结构"焊在了一起--元素类既要存数据又要管各种操作,每新增一种操作就得改所有元素类,违反开闭原则。访问者模式把操作从元素中剥离出来:每种操作做成一个访问者对象,元素只保留一个 accept 方法接待访问者,新增操作只是新增访问者,元素类一行不改。
概念
访问者模式(Visitor Pattern)是一种行为型设计模式,核心思想是将操作从对象结构中分离出来,在不修改元素类的前提下定义新的操作。
可以把它想象成医院的分诊流程:病人(元素)按科室排队,不同科室的医生(访问者)对同类病人有相同的诊断流程。医生不需要改病人,换一个医生就换一套检查--骨科医生拍片、内科医生听诊,病人只管"接受检查"。
访问者模式涉及五个角色:
- Visitor(抽象访问者):为每种具体元素声明一个
visit方法 - ConcreteVisitor(具体访问者):实现每个
visit,封装一种具体操作 - Element(抽象元素):声明
accept方法,接收访问者 - ConcreteElement(具体元素):实现
accept,是访问的目标 - ObjectStructure(对象结构):持有元素集合,遍历并让每个元素接受访问
图中各类之间的关系:Visitor 为每种元素声明独立的 visit 重载,ConcreteVisitorA/ConcreteVisitorB 各封装一套操作。Element 声明 accept,ConcreteElementA/ConcreteElementB 实现它。ObjectStructure 持有元素集合,遍历时调 el.accept(visitor) 把访问者派给每个元素。注意 Element 和 Visitor 之间是双向依赖--元素调访问者的 visit,访问者又依赖具体元素类型,这正是双重分派的结构基础。
访问者模式的核心是双重分派(Double Dispatch)。Java 是单分派语言--方法重载按编译时参数的声明类型决定。直接调 visitor.visit(element) 时,若 element 声明为 Element 接口类型,编译器匹配不到 visit(具体类型),只能退回 instanceof 判断,违反开闭原则。访问者用两次方法调用绕过这个限制:
// 第一次分派:运行时按 element 实际类型调 accept
element.accept(visitor);// 第二次分派:accept 内 visitor.visit(this),this 类型确定,编译器精确匹配 visit 重载
public void accept(Visitor visitor) {visitor.visit(this); // this 是 ConcreteElementA,匹配 visit(ConcreteElementA)
}
第一次让元素"自报家门"(运行时确定实际类型),第二次让 this 携带精确类型信息传给访问者(编译时匹配重载)。这样实现了"元素类型 × 访问者类型"的双重匹配,无需在元素里写 if-else 判断类型。
⚠️ Java 17+ 引入的
sealedclass 配合pattern matching for switch正在逐步替代访问者的双重分派机制:用sealed限定元素类型集合,switch按模式匹配就能实现按类型分派,编译器还能校验分支是否穷尽。这是访问者模式在 Java 中的演进方向,但pattern matching for switch直到 Java 21 才正式定稿(Java 17 为预览特性),老项目和生产环境仍以传统访问者实现为主。
实现
访问者模式的核心是"双重分派 + 操作外置"--元素只写 accept 调 visitor.visit(this),操作逻辑全部集中在访问者里。新增操作加访问者,新增元素类型则要改所有访问者。
标准实现
定义 Visitor 接口为每种元素声明 visit 重载;Element 接口声明 accept。具体元素的 accept 内调 visitor.visit(this),靠 this 的精确类型触发第二次分派。ObjectStructure 持有元素集合,遍历调 accept。
import java.util.*;// 抽象访问者:为每种元素声明一个 visit 方法
interface Visitor {void visit(ConcreteElementA elementA);void visit(ConcreteElementB elementB);
}// 具体访问者A
class ConcreteVisitorA implements Visitor {public void visit(ConcreteElementA elementA) {System.out.println("ConcreteVisitorA 访问 ConcreteElementA");}public void visit(ConcreteElementB elementB) {System.out.println("ConcreteVisitorA 访问 ConcreteElementB");}
}// 具体访问者B
class ConcreteVisitorB implements Visitor {public void visit(ConcreteElementA elementA) {System.out.println("ConcreteVisitorB 访问 ConcreteElementA");}public void visit(ConcreteElementB elementB) {System.out.println("ConcreteVisitorB 访问 ConcreteElementB");}
}// 抽象元素
interface Element {void accept(Visitor visitor);
}// 具体元素A
class ConcreteElementA implements Element {public void accept(Visitor visitor) {visitor.visit(this); // this 类型确定,触发第二次分派}
}// 具体元素B
class ConcreteElementB implements Element {public void accept(Visitor visitor) {visitor.visit(this);}
}// 对象结构:持有元素集合,遍历并触发访问
class ObjectStructure {private List<Element> list = new ArrayList<>();public void attach(Element element) {list.add(element);}public void accept(Visitor visitor) {for (Element el : list) {el.accept(visitor); // 第一次分派:按元素实际类型调 accept}}
}// 客户端
class Client {public static void main(String[] args) {ObjectStructure obj = new ObjectStructure();obj.attach(new ConcreteElementA());obj.attach(new ConcreteElementB());obj.accept(new ConcreteVisitorA()); // A 访问所有元素obj.accept(new ConcreteVisitorB()); // B 访问所有元素}
}
角色对照:
- Visitor(抽象访问者):
Visitor,为每种元素声明visit重载 - ConcreteVisitor(具体访问者):
ConcreteVisitorA、ConcreteVisitorB - Element(抽象元素):
Element,声明accept - ConcreteElement(具体元素):
ConcreteElementA、ConcreteElementB - ObjectStructure(对象结构):
ObjectStructure,遍历元素触发访问
关键点:accept 内的 visitor.visit(this) 是整个模式的关键--this 在每个具体元素类中类型是确定的,编译器据此精确匹配 visit(ConcreteElementA) 而非泛化的 visit(Element)。新增操作(如打印日志)只需加一个 ConcreteVisitorC,所有元素类不改一行代码;但新增元素类型(如 ConcreteElementC)要改 Visitor 接口和所有访问者实现--这是访问者模式"易加操作、难加元素"的特性。
引入一个生活比喻:体检中心的医生对排队的人逐个检查。每个医生是一类访问者,病人是元素,分诊台是对象结构。换一批医生(新增操作)不用改病人,但若新增一种"机器人病人"(新增元素类型),所有医生都得学怎么检查机器人。这正是访问者模式扩展方向的不对称。
同样的思路换到业务场景:文档系统要把段落、图片导出成 HTML 和 Markdown。每种文档元素实现 accept,每种导出格式做一个访问者,新增格式只是加访问者。
import java.util.*;// 抽象元素:文档元素
interface DocElement {void accept(DocVisitor visitor);
}// 具体元素:段落
class Paragraph implements DocElement {private String text;public Paragraph(String text) { this.text = text; }public String getText() { return text; }public void accept(DocVisitor visitor) {visitor.visit(this);}
}// 具体元素:图片
class Image implements DocElement {private String url;private String alt;public Image(String url, String alt) {this.url = url;this.alt = alt;}public String getUrl() { return url; }public String getAlt() { return alt; }public void accept(DocVisitor visitor) {visitor.visit(this);}
}// 抽象访问者:为每种文档元素声明一个 visit
interface DocVisitor {void visit(Paragraph p);void visit(Image img);
}// 具体访问者:HTML 导出
class HtmlExportVisitor implements DocVisitor {public void visit(Paragraph p) {System.out.println("<p>" + p.getText() + "</p>");}public void visit(Image img) {System.out.println("<img src=\"" + img.getUrl()+ "\" alt=\"" + img.getAlt() + "\"/>");}
}// 具体访问者:Markdown 导出
class MarkdownExportVisitor implements DocVisitor {public void visit(Paragraph p) {System.out.println(p.getText());}public void visit(Image img) {System.out.println(" + ")");}
}// 对象结构:文档
class Document {private List<DocElement> elements = new ArrayList<>();public void add(DocElement e) { elements.add(e); }public void export(DocVisitor visitor) {for (DocElement e : elements) {e.accept(visitor);}}
}// 客户端
class DocClient {public static void main(String[] args) {Document doc = new Document();doc.add(new Paragraph("Hello World"));doc.add(new Image("logo.png", "Logo"));doc.export(new HtmlExportVisitor()); // HTML 格式doc.export(new MarkdownExportVisitor()); // Markdown 格式}
}
角色对照:
- Visitor(抽象访问者):
DocVisitor,为Paragraph、Image声明visit - ConcreteVisitor(具体访问者):
HtmlExportVisitor、MarkdownExportVisitor - Element(抽象元素):
DocElement - ConcreteElement(具体元素):
Paragraph、Image - ObjectStructure(对象结构):
Document
关键点:新增 PDF 导出只需加一个 PdfExportVisitor,Paragraph 和 Image 一行不改--这正是前言里 toHtml/toMarkdown 写法的解法。导出逻辑集中在访问者里,HTML 的规则统一维护,不再散落各处。代价是元素要暴露 getText/getUrl 等 getter 供访问者读取,封装性有所牺牲。
总结
本质:把操作从对象结构中分离出来,通过双重分派在不改元素类的前提下新增操作。
什么时候用:
- 元素类型稳定(不常新增),但操作频繁扩展(元素数 × 操作数都多)
- 需要对多种类型的元素执行多种不相关的操作(类型检查、代码生成、多格式导出)
- 操作需要在遍历中积累状态(如统计结果),又不想污染元素类
什么时候不用:
- 元素类型经常变化--每加一种元素要改所有访问者,维护成本高
- 只有少数操作,直接在元素类加方法更简单
- 元素之间有复杂的组合或继承关系,双重分派会难以维护
- 元素内部状态不方便暴露,访问者会破坏封装
简单记忆:操作外置访客来,元素只管 accept 开;易加操作难加件,双重分派选对人。
相似模式区分
总览
| 模式 | 核心意图 | 典型场景 |
|---|---|---|
| 访问者 | 对结构中的多种元素执行多种操作 | 编译器 AST、多格式导出 |
| 策略 | 对同一上下文切换不同的算法实现 | 排序算法、支付方式 |
| 迭代器 | 顺序访问集合元素,不暴露内部结构 | 集合遍历、for-each |
| 命令 | 将请求封装为对象,支持撤销、队列 | 事务回滚、任务队列 |
简单记忆:访问者管"谁对谁做什么"(多对多),策略管"怎么做"(一对多),迭代器管"怎么走",命令管"把活打包带走"。
访问者 vs 策略模式
两者都把行为外置,但匹配维度不同:
| 维度 | 访问者模式 | 策略模式 |
|---|---|---|
| 核心意图 | 对多种类型的元素执行多种操作 | 对同一上下文切换不同的算法实现 |
| 结构差异 | 元素有 accept,访问者有多个 visit 重载,双重分派 |
策略接口只有一个方法,由 Context 持有调用 |
| 关注点 | 操作与数据结构的解耦 | 算法与使用方的解耦 |
| 典型场景 | 编译器语法树遍历、文档多格式导出 | 排序算法切换、支付方式选择 |
逐步区分法:
- 如果需要对多种不同类型的对象执行多种不同操作 -> 选访问者
- 如果需要对同一类对象切换不同的行为实现 -> 选策略
- 如果对象类型经常变化 -> 选策略(访问者新增元素成本高)
简单记忆口诀:"访问者多对多,策略一对多"。
推荐:只有一种操作的不同实现用策略更简单;多类型元素 × 多操作才值得访问者的复杂度。
访问者 vs 迭代器模式
两者都遍历对象结构,但一个管操作、一个管路径:
| 维度 | 访问者模式 | 迭代器模式 |
|---|---|---|
| 核心意图 | 对元素执行按类型分派的复杂操作 | 顺序访问集合元素,不暴露内部结构 |
| 结构差异 | 元素主动接受访问者(双重分派) | 迭代器被动遍历集合(单向遍历) |
| 关注点 | 操作的扩展性 | 遍历的统一性 |
| 典型场景 | 需要对不同元素执行不同操作 | 只需要遍历所有元素 |
逐步区分法:
- 如果遍历时需要根据元素类型执行不同操作 -> 选访问者
- 如果只需要逐个访问元素且操作相同 -> 选迭代器
- 两者可组合:用迭代器遍历集合,用访问者处理每个元素
简单记忆口诀:"迭代器管怎么走,访问者管到了之后干什么"。
推荐:纯遍历用迭代器就够了;遍历中还要按类型分派不同逻辑才用访问者。访问者内部其实也常借用迭代器遍历对象结构。
访问者 vs 命令模式
两者都把操作封装成对象,但封装的内容不同:
| 维度 | 访问者模式 | 命令模式 |
|---|---|---|
| 核心意图 | 对对象结构中的元素执行多种操作 | 将请求封装为对象,支持撤销、队列、日志 |
| 结构差异 | 访问者依赖具体元素类型(visit(X)) |
命令只依赖接收者接口(execute()) |
| 关注点 | 操作与数据结构的解耦 | 请求的参数化和队列化 |
| 典型场景 | 编译器、文档导出 | 事务回滚、任务队列、宏命令 |
逐步区分法:
- 如果需要对多种类型的对象执行不同类型的操作 -> 选访问者
- 如果需要将操作本身作为对象传递、存储、撤销 -> 选命令
简单记忆口诀:"访问者人到现场干活,命令把活打包带走"。
推荐:需要撤销/排队/日志记录操作用命令模式;只按元素类型分派逻辑用访问者。命令关注操作的"生命周期",访问者关注操作的"类型分派"。
练习题目
图形的属性计算
题目描述:一个图形系统中有圆形和矩形两种图形。为方便后续扩展不同的计算操作,使用访问者模式实现面积计算访问者和周长计算访问者。
图形的计算规则如下:
- 圆形的面积 = 3.14 × 半径 × 半径
- 圆形的周长 = 2 × 3.14 × 半径
- 矩形的面积 = 长 × 宽
- 矩形的周长 = 2 ×(长 + 宽)
输入描述:第一行是一个整数 n(1 ≤ n ≤ 100),表示图形的数量。接下来的 n 行,每行描述一个图形,格式为 Circle r 或 Rectangle width height,其中 r、width、height 为正整数且不超过 1000。
输出描述:先输出一行 Areas:,接下来 n 行依次输出每个图形的面积;然后输出一行 Perimeters:,接下来 n 行依次输出每个图形的周长。
注意:圆形的面积和周长可能为小数,矩形的结果为整数。小数输出时保留有效数字(如 78.5、12.56),不输出多余的末尾零。
输入示例:
3
Circle 5
Rectangle 3 4
Circle 2
输出示例:
Areas:
78.5
12
12.56
Perimeters:
31.4
14
12.56
解题思路:题目是访问者模式的典型应用--图形类型(Circle、Rectangle)是稳定的元素,计算操作(面积、周长)是可扩展的访问者。定义 Visitor 接口声明对每种图形的 visit,AreaVisitor 和 PerimeterVisitor 各实现一套计算逻辑。图形通过 accept 触发双重分派,让访问者按类型执行对应公式。未来新增"缩放比例计算"只需加一个访问者,无需改 Circle 和 Rectangle。去掉访问者模式,把计算塞进图形类,每加一种计算所有图形类都要改,违反开闭原则。
import java.util.*;
import java.text.DecimalFormat;public class Main {public static void main(String[] args) {Scanner sc = new Scanner(System.in);int n = sc.nextInt();ObjectStructure obj = new ObjectStructure();while (n-- > 0) {String type = sc.next();int a = sc.nextInt();Shape shape = new Circle(a);if ("Rectangle".equals(type)) {int b = sc.nextInt();shape = new Rectangle(a, b);}obj.attach(shape);}System.out.println("Areas:");obj.accept(new AreaVisitor());System.out.println("Perimeters:");obj.accept(new PerimeterVisitor());}
}interface Visitor {void visit(Circle c);void visit(Rectangle r);
}// 面积访问者
class AreaVisitor implements Visitor {private DecimalFormat df = new DecimalFormat("#.##");public void visit(Circle c) {int r = c.getRadius();System.out.println(df.format(3.14 * r * r));}public void visit(Rectangle r) {System.out.println(r.getLength() * r.getWidth());}
}// 周长访问者
class PerimeterVisitor implements Visitor {private DecimalFormat df = new DecimalFormat("#.##");public void visit(Circle c) {int r = c.getRadius();System.out.println(df.format(3.14 * r * 2));}public void visit(Rectangle r) {System.out.println((r.getLength() + r.getWidth()) * 2);}
}interface Shape {void accept(Visitor v);
}class Circle implements Shape {private int radius;public Circle(int r) { this.radius = r; }public int getRadius() { return this.radius; }public void accept(Visitor v) {v.visit(this); // this 是 Circle,触发第二次分派}
}class Rectangle implements Shape {private int length;private int width;public Rectangle(int l, int w) {this.length = l;this.width = w;}public int getLength() { return this.length; }public int getWidth() { return this.width; }public void accept(Visitor v) {v.visit(this);}
}class ObjectStructure {private List<Shape> list = new ArrayList<>();public void attach(Shape s) { this.list.add(s); }public void accept(Visitor v) {for (Shape s : list) {s.accept(v); // 第一次分派:按图形实际类型调 accept}}
}
代码分析:AreaVisitor 和 PerimeterVisitor 各封装一套计算逻辑,互不干扰。Circle.accept 和 Rectangle.accept 内的 v.visit(this) 让访问者精确匹配到对应重载--Circle 调 visit(Circle)、Rectangle 调 visit(Rectangle),无需 instanceof。ObjectStructure 遍历图形集合,把同一个访问者派给每个图形。新增"体积计算"只需加一个 VolumeVisitor,Circle、Rectangle 一行不改,这正是访问者模式"易加操作"的体现。
扩展:实际项目中的访问者模式
编译器语法树遍历
编译器中语法树包含多种节点(表达式、语句、声明),需要对语法树执行多种操作(类型检查、代码优化、字节码生成)。访问者模式让每种操作独立封装,新增操作(如代码格式化)无需修改节点类。AST 节点类型相对稳定,但分析变换操作频繁扩展,正是访问者模式的经典战场。TypeCheckVisitor、CodeGenVisitor 各管一套,节点只负责 accept。
interface ASTNode {void accept(ASTVisitor visitor);
}
interface ASTVisitor {void visit(BinaryExpr expr); // 二元表达式节点void visit(LiteralExpr expr); // 字面量节点
}
// 类型检查访问者、代码生成访问者各自实现 ASTVisitor
文件系统统计与检查
对文件系统中的文件和目录执行不同操作(统计大小、查找重复、权限检查),每种操作封装为一个访问者。访问者可以在遍历过程中积累状态--SizeCalculatorVisitor 用一个 totalSize 字段累加所有文件大小,目录节点递归 accept 让子节点也被访问。这种"遍历中积累结果"的能力是访问者模式的重要优势,统计逻辑不必污染文件节点类。
class SizeCalculatorVisitor implements FileVisitor {private long totalSize = 0;public void visit(FileNode file) { totalSize += file.getSize(); }public void visit(DirectoryNode dir) {for (FileSystemNode child : dir.getChildren()) {child.accept(this); // 递归让子节点也被访问}}public long getTotalSize() { return totalSize; }
}
购物车价格计算
电商系统中商品类型多样(普通商品、折扣商品、满减商品),需要计算总价、税费、积分。每种计算逻辑封装为一个访问者,新增计算维度(如碳排放)只需添加新访问者。商品类型发布后不常变,但促销、税费规则频繁调整,访问者模式让计算逻辑与商品数据分离--PriceVisitor、TaxVisitor 各自迭代,商品类不动。
interface ProductVisitor {void visit(NormalProduct product);void visit(DiscountProduct product);
}
// 价格访问者、税费访问者各自实现,访问者内部按商品类型套不同公式
UI 组件树渲染
UI 框架的组件类型多样(按钮、文本框、面板),需要对组件执行多种操作(渲染、序列化、事件绑定)。每种操作做成一个访问者,组件只实现 accept。组件类型稳定(框架定下后不常加),但渲染策略、序列化格式会扩展,适合访问者模式。新增"无障碍模式渲染"只是加一个 AccessibilityVisitor。
interface UIComponent {void accept(UIVisitor visitor);
}
interface UIVisitor {void visit(Button button);void visit(TextBox textBox);
}
// 渲染访问者、序列化访问者各自实现,遍历组件树时统一处理
Java标准库:FileVisitor
java.nio.file.Files.walkFileTree() 是访问者模式在 JDK 中最经典的应用,日常清理临时文件、统计代码行数都会用到。FileVisitor 接口定义了四个回调:preVisitDirectory(进入目录前)、visitFile(访问文件)、visitFileFailed(访问失败)、postVisitDirectory(离开目录后),Files.walkFileTree(path, visitor) 遍历时按"目录/文件"节点类型分派到对应方法。开发者只实现关心的回调,遍历流程由 JDK 托管,比自己写递归更清晰。这正是"节点类型稳定(目录/文件)、操作多变(统计、删除、查找)"的典型场景。统计目录下 .java 文件数量的访问者如下:
FileVisitor<Path> visitor = new SimpleFileVisitor<>() {public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {if (file.toString().endsWith(".java")) {count++; // 只关心文件节点,其余回调用 SimpleFileVisitor 默认实现}return FileVisitResult.CONTINUE;}
};
Files.walkFileTree(Paths.get("src"), visitor); // 遍历时按节点类型分派到 visitFile 等
ASM字节码操作
ASM 是 Java 字节码操作框架的标杆,Spring AOP 动态代理、MyBatis 延迟加载等底层都依赖它生成或改写字节码。核心入口 ClassReader.accept(ClassVisitor) 就是标准的访问者模式:ClassReader 解析 .class 文件,遇到字段、方法、注解时回调 ClassVisitor 对应方法。ClassVisitor 声明了 visitField、visitMethod、visitAnnotation 等重载,分别对应字节码中不同的节点类型。字节码节点类型由 JVM 规范定死(稳定),但分析、改写操作千变万化(AOP 织入、字段统计、方法耗时统计),访问者模式让这些操作各自封装、互不干扰。ClassReader 按节点类型分派回调的用法如下:
// ClassReader.accept(ClassVisitor) 解析 .class,按节点类型分派到对应 visitXxx
classReader.accept(new ClassVisitor(ASM9) {public FieldVisitor visitField(...) { return null; } // 字段节点回调public MethodVisitor visitMethod(...) { count++; return null; } // 方法节点回调public AnnotationVisitor visitAnnotation(...) { return null; } // 注解节点回调
}, 0);
技术交流 & 更多原创内容,关注公众号:咖啡八杯
