GoF设计模式——访问者模式

本文是【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 "![" + alt + "](" + url + ")"; }
}

看起来能跑,但问题藏在后面--每加一种导出格式(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 声明 acceptConcreteElementA/ConcreteElementB 实现它。ObjectStructure 持有元素集合,遍历时调 el.accept(visitor) 把访问者派给每个元素。注意 ElementVisitor 之间是双向依赖--元素调访问者的 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+ 引入的 sealed class 配合 pattern matching for switch 正在逐步替代访问者的双重分派机制:用 sealed 限定元素类型集合,switch 按模式匹配就能实现按类型分派,编译器还能校验分支是否穷尽。这是访问者模式在 Java 中的演进方向,但 pattern matching for switch 直到 Java 21 才正式定稿(Java 17 为预览特性),老项目和生产环境仍以传统访问者实现为主。

实现

访问者模式的核心是"双重分派 + 操作外置"--元素只写 acceptvisitor.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(具体访问者)ConcreteVisitorAConcreteVisitorB
  • Element(抽象元素)Element,声明 accept
  • ConcreteElement(具体元素)ConcreteElementAConcreteElementB
  • 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("![" + img.getAlt() + "](" + img.getUrl() + ")");
    }
}

// 对象结构:文档
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,为 ParagraphImage 声明 visit
  • ConcreteVisitor(具体访问者)HtmlExportVisitorMarkdownExportVisitor
  • Element(抽象元素)DocElement
  • ConcreteElement(具体元素)ParagraphImage
  • ObjectStructure(对象结构)Document

关键点 :新增 PDF 导出只需加一个 PdfExportVisitorParagraphImage 一行不改--这正是前言里 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 rRectangle 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 接口声明对每种图形的 visitAreaVisitorPerimeterVisitor 各实现一套计算逻辑。图形通过 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
        }
    }
}

代码分析AreaVisitorPerimeterVisitor 各封装一套计算逻辑,互不干扰。Circle.acceptRectangle.accept 内的 v.visit(this) 让访问者精确匹配到对应重载--Circlevisit(Circle)Rectanglevisit(Rectangle),无需 instanceofObjectStructure 遍历图形集合,把同一个访问者派给每个图形。新增"体积计算"只需加一个 VolumeVisitorCircleRectangle 一行不改,这正是访问者模式"易加操作"的体现。

扩展:实际项目中的访问者模式

编译器语法树遍历

编译器中语法树包含多种节点(表达式、语句、声明),需要对语法树执行多种操作(类型检查、代码优化、字节码生成)。访问者模式让每种操作独立封装,新增操作(如代码格式化)无需修改节点类。AST 节点类型相对稳定,但分析变换操作频繁扩展,正是访问者模式的经典战场。TypeCheckVisitorCodeGenVisitor 各管一套,节点只负责 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; }
}

购物车价格计算

电商系统中商品类型多样(普通商品、折扣商品、满减商品),需要计算总价、税费、积分。每种计算逻辑封装为一个访问者,新增计算维度(如碳排放)只需添加新访问者。商品类型发布后不常变,但促销、税费规则频繁调整,访问者模式让计算逻辑与商品数据分离--PriceVisitorTaxVisitor 各自迭代,商品类不动。

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 声明了 visitFieldvisitMethodvisitAnnotation 等重载,分别对应字节码中不同的节点类型。字节码节点类型由 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);
相关推荐
risc1234563 小时前
通过树来理解访问者模式 访问者模式的灵魂在于数据结构的遍历 访问者访问的是数据结构的某一个元素他要觉得要不要访问这个元素或者访问哪些元素
lucene·访问者模式
谢栋_3 小时前
设计模式从入门到精通之(七)责任链模式
java·设计模式·责任链模式
葬送的代码人生17 小时前
别再让 AI 瞎写代码了!Vibe Coding 三步法教你写出靠谱代码
前端·设计模式·架构
无风听海20 小时前
Claude Agent Skills 的四种设计模式;从渐进式披露到最小权限
java·算法·设计模式
富贵冼中求21 小时前
从单向流到双向 RPC:Agent 通信协议的范式分叉与 ACP 协议实战拆解
设计模式·架构
电子科技圈1 天前
先进封装、芯粒架构和3D集成——先进异构集成亟需兼具标准化与定制化能力的互联及总线IP解决方案
tcp/ip·设计模式·架构·软件构建·代码规范·设计规范
zjun10011 天前
C++:2.工厂模式
设计模式
触底反弹1 天前
🤯 面试被问 AI Workflow 和 Agent 有啥区别?3 张图 + 2 段代码讲清楚!
人工智能·设计模式·面试
杨充2 天前
10.可测试性实战设计
设计模式·开源·代码规范