本文是【GoF设计模式】系列第23篇

前言
为什么需要访问者模式?
假设一个文档系统里有段落、图片、表格三类元素,要导出 HTML 和 Markdown 两种格式。最直接的写法是把导出逻辑塞进每个元素类:
java
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(对象结构):持有元素集合,遍历并让每个元素接受访问
#mermaid-svg-ynCysYSye594jVfX{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ynCysYSye594jVfX .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ynCysYSye594jVfX .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ynCysYSye594jVfX .error-icon{fill:#552222;}#mermaid-svg-ynCysYSye594jVfX .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ynCysYSye594jVfX .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ynCysYSye594jVfX .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ynCysYSye594jVfX .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ynCysYSye594jVfX .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ynCysYSye594jVfX .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ynCysYSye594jVfX .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ynCysYSye594jVfX .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ynCysYSye594jVfX .marker.cross{stroke:#333333;}#mermaid-svg-ynCysYSye594jVfX svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ynCysYSye594jVfX p{margin:0;}#mermaid-svg-ynCysYSye594jVfX g.classGroup text{fill:#9370DB;stroke:none;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:10px;}#mermaid-svg-ynCysYSye594jVfX g.classGroup text .title{font-weight:bolder;}#mermaid-svg-ynCysYSye594jVfX .cluster-label text{fill:#333;}#mermaid-svg-ynCysYSye594jVfX .cluster-label span{color:#333;}#mermaid-svg-ynCysYSye594jVfX .cluster-label span p{background-color:transparent;}#mermaid-svg-ynCysYSye594jVfX .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ynCysYSye594jVfX .cluster text{fill:#333;}#mermaid-svg-ynCysYSye594jVfX .cluster span{color:#333;}#mermaid-svg-ynCysYSye594jVfX .nodeLabel,#mermaid-svg-ynCysYSye594jVfX .edgeLabel{color:#131300;}#mermaid-svg-ynCysYSye594jVfX .edgeLabel .label rect{fill:#ECECFF;}#mermaid-svg-ynCysYSye594jVfX .label text{fill:#131300;}#mermaid-svg-ynCysYSye594jVfX .labelBkg{background:#ECECFF;}#mermaid-svg-ynCysYSye594jVfX .edgeLabel .label span{background:#ECECFF;}#mermaid-svg-ynCysYSye594jVfX .classTitle{font-weight:bolder;}#mermaid-svg-ynCysYSye594jVfX .node rect,#mermaid-svg-ynCysYSye594jVfX .node circle,#mermaid-svg-ynCysYSye594jVfX .node ellipse,#mermaid-svg-ynCysYSye594jVfX .node polygon,#mermaid-svg-ynCysYSye594jVfX .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ynCysYSye594jVfX .divider{stroke:#9370DB;stroke-width:1;}#mermaid-svg-ynCysYSye594jVfX g.clickable{cursor:pointer;}#mermaid-svg-ynCysYSye594jVfX g.classGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-ynCysYSye594jVfX g.classGroup line{stroke:#9370DB;stroke-width:1;}#mermaid-svg-ynCysYSye594jVfX .classLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-ynCysYSye594jVfX .classLabel .label{fill:#9370DB;font-size:10px;}#mermaid-svg-ynCysYSye594jVfX .relation{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-ynCysYSye594jVfX .dashed-line{stroke-dasharray:3;}#mermaid-svg-ynCysYSye594jVfX .dotted-line{stroke-dasharray:1 2;}#mermaid-svg-ynCysYSye594jVfX #compositionStart,#mermaid-svg-ynCysYSye594jVfX .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-ynCysYSye594jVfX #compositionEnd,#mermaid-svg-ynCysYSye594jVfX .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-ynCysYSye594jVfX #dependencyStart,#mermaid-svg-ynCysYSye594jVfX .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-ynCysYSye594jVfX #dependencyStart,#mermaid-svg-ynCysYSye594jVfX .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-ynCysYSye594jVfX #extensionStart,#mermaid-svg-ynCysYSye594jVfX .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-ynCysYSye594jVfX #extensionEnd,#mermaid-svg-ynCysYSye594jVfX .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-ynCysYSye594jVfX #aggregationStart,#mermaid-svg-ynCysYSye594jVfX .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-ynCysYSye594jVfX #aggregationEnd,#mermaid-svg-ynCysYSye594jVfX .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-ynCysYSye594jVfX #lollipopStart,#mermaid-svg-ynCysYSye594jVfX .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-ynCysYSye594jVfX #lollipopEnd,#mermaid-svg-ynCysYSye594jVfX .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-ynCysYSye594jVfX .edgeTerminals{font-size:11px;line-height:initial;}#mermaid-svg-ynCysYSye594jVfX .classTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ynCysYSye594jVfX .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ynCysYSye594jVfX .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ynCysYSye594jVfX :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 实现
实现
实现
实现
accept 调 visit
持有并遍历
接受
<<interface>>
Visitor
+visit(ConcreteElementA)
+visit(ConcreteElementB)
ConcreteVisitorA
+visit(ConcreteElementA)
+visit(ConcreteElementB)
ConcreteVisitorB
+visit(ConcreteElementA)
+visit(ConcreteElementB)
<<interface>>
Element
+accept(Visitor)
ConcreteElementA
+accept(Visitor)
ConcreteElementB
+accept(Visitor)
ObjectStructure
-elements: List<Element>
+accept(Visitor)
图中各类之间的关系: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 判断,违反开闭原则。访问者用两次方法调用绕过这个限制:
java
// 第一次分派:运行时按 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。
java
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,每种导出格式做一个访问者,新增格式只是加访问者。
java
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。去掉访问者模式,把计算塞进图形类,每加一种计算所有图形类都要改,违反开闭原则。
java
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。
java
interface ASTNode {
void accept(ASTVisitor visitor);
}
interface ASTVisitor {
void visit(BinaryExpr expr); // 二元表达式节点
void visit(LiteralExpr expr); // 字面量节点
}
// 类型检查访问者、代码生成访问者各自实现 ASTVisitor
文件系统统计与检查
对文件系统中的文件和目录执行不同操作(统计大小、查找重复、权限检查),每种操作封装为一个访问者。访问者可以在遍历过程中积累状态--SizeCalculatorVisitor 用一个 totalSize 字段累加所有文件大小,目录节点递归 accept 让子节点也被访问。这种"遍历中积累结果"的能力是访问者模式的重要优势,统计逻辑不必污染文件节点类。
java
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 各自迭代,商品类不动。
java
interface ProductVisitor {
void visit(NormalProduct product);
void visit(DiscountProduct product);
}
// 价格访问者、税费访问者各自实现,访问者内部按商品类型套不同公式
UI 组件树渲染
UI 框架的组件类型多样(按钮、文本框、面板),需要对组件执行多种操作(渲染、序列化、事件绑定)。每种操作做成一个访问者,组件只实现 accept。组件类型稳定(框架定下后不常加),但渲染策略、序列化格式会扩展,适合访问者模式。新增"无障碍模式渲染"只是加一个 AccessibilityVisitor。
java
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 文件数量的访问者如下:
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 按节点类型分派回调的用法如下:
java
// 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);