设计模式预计阅读 15 分钟5892 字2450 次浏览

设计模式修炼之路 ㉔ 访问者模式——结构与操作分离

双分派、结构与操作分离、visit(this) 不能省、FileVisitor 与 ASM

目录

行为型第十一站。

上一篇的 Member 有 VIP 与普通之分,折扣各不相同——好在行为简单,直接长在每个类自己身上,干净利落。这一篇情况反过来:一群类型稳定的对象,要面对频繁变化的一堆操作。

拿本文正在用的文档举例子:段落、表格、图片——这哥仨十年不变。可"要对它们做的事"天天出新:字数统计、导出 Markdown、导出 HTML、导出 PDF、SEO 分析、无障碍校验……把每种操作做成每个类的方法,加一种操作,九个类全体手术;写在类外面,instanceof 分支满天飞,还一点类型安全都没有。

把"对一群对象做什么"从对象家里抽出来,装进访问者自己家,双分派穿针引线——访问者模式,GoF 二十三式的倒数第三位狠角色。

一、访问者模式是什么

GoF 的原始定义:

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. (表示一个作用于某对象结构中各元素的操作。访问者使你可以在不改变各元素的类的前提下,定义作用于这些元素的新操作。)

定义的后半句就是它的使命宣言:新增操作,元素类零改动。这是全系列唯一把"对某类东西开闭"写得这么露骨的模式。

五个角色:

角色 干什么 本文对应
Visitor(抽象访问者) 对每种具体元素各声明一个重载入口 DocVisitor
ConcreteVisitor(具体访问者) 一个访问者 = 一个完整的操作 CountVisitor / MarkdownExportVisitor / HtmlExportVisitor
Element(抽象元素) 声明 accept,接待访问者 DocElement
ConcreteElement(具体元素) 实现 accept,把自己递给访问者 Paragraph / Table / Image
ObjectStructure(对象结构) 能枚举所有元素的高层容器 List\

二、优点与缺点

优点:

  1. 新增操作零侵入:加一种操作 = 加一个 Visitor 类,五个元素类一行不改——开闭原则教科书级兑现。
  2. 操作内聚:一种操作的全部代码住在一个访问者类里,而不是切成碎片散落在九个类中。Markdown 导出的所有细节,MarkdownExportVisitor 一个文件看完。
  3. 类型安全:新增元素类型时,所有访问者会被编译器挨个揪出来强制补充实现——instanceof 链只会默默漏掉新类型,访问者不会。
  4. 操作可携带状态:访问者是普通对象,可以积累中间结果(CountVisitor 边走边数),这是多态方法做不到的。

缺点:

  1. 破坏封装:访问者要干活就得调元素的 getter——Paragraph 得暴露 getText,Table 得暴露 getCells。"操作住在外面"的代价就是元素的结构细节必须可见。
  2. 加元素类型是灾难:新增一种元素(比如音频),DocVisitor 接口加方法、所有现存访问者全体手术——它把"加操作的痛"原样转嫁给了"加元素类型"。
  3. 双分派晦涩:accept(visitor) + visit(this) 的接力赛,初读每个字都认识,连起来看不懂,必须理解两次分派的机制。
  4. 适用面窄:押注"结构稳定、操作常变"——押错了(两边都常变)就是双重灾难。

三、什么时候用

判断标准:对象结构稳定不常加类型,但作用于结构的操作频繁新增,且操作种类 > 元素种类、或操作属于跨切面的"外挂"逻辑。

  • 文档/报表生成:结构(段落/表格/图)稳定,导出格式(MD/HTML/PDF/Word)天天加——本文场景
  • 编译器语法树:AST 节点类型由语言规范锁死,而遍历操作(类型检查、优化、代码生成)层出不穷——访问者的出生地
  • 字节码处理:类结构由 JVM 规范锁死,改字节码的工具却百花齐放
  • 文件系统遍历:目录/文件两类元素万年不变,对它们的操作千变万化

反面对照:元素类型预期会增长(业务模型还在演化)——访问者会把每次扩容变成全体手术,改回多态方法;只有一两种操作——老实写方法或工具类,别上访问者;操作和元素强绑定、属于元素的本职(段落渲染自己的文本)——那本来就该是它的方法。

四、实现方式:文档导出

4.1 反面教材:instanceof 链

操作写在结构外面,最直觉的做法就是类型判断:

public class BadDocTool {

    // 操作一:字数统计——instanceof 链一号
    public static void countWords(List<DocElement> doc) {
        for (DocElement e : doc) {
            if (e instanceof Paragraph) {
                chars += ((Paragraph) e).getText().length();
            } else if (e instanceof Table) {
                // 嵌套两层 for 数格子里的字……
            } else if (e instanceof Image) {
                images++;
            }
        }
    }

    // 操作二:导出 Markdown——instanceof 链二号,和一号结构一模一样
    public static String exportMarkdown(List<DocElement> doc) { /* 同款链再来一遍 */ }
}

运行:

========== 反面教材:instanceof 链 ==========
  [统计] 字符 62 个,表格 1 张,图片 1 张
  [导出 Markdown]
    设计模式修炼之路:访问者篇

    访问者模式把操作从对象结构中抽离出来。

    | 元素 | 访问者 |
    |---|---|
    | Paragraph | visit(Paragraph) |

    ![双分派示意图](双分派示意图.png)

功能没毛病,病在结构:N 种元素 × M 种操作 = N×M 个分支盒子,全靠人肉对齐。更阴险的是漏分支不报错——哪天加了 Audio 元素,两条链谁也不提醒你补 instanceof (Audio),统计悄悄少算、导出悄悄吞音频,又是一场"聚餐事故"式的灵异现场(㉒ 老朋友了)。

4.2 正解:双分派

先看访问者接口——对每种元素一个重载入口,这就是"操作 × 元素"行为矩阵的声明处:

public interface DocVisitor {

    void visit(Paragraph paragraph);

    void visit(Table table);

    void visit(Image image);
}

元素这边只接待,不干活:

public class Paragraph implements DocElement {

    @Override
    public void accept(DocVisitor visitor) {
        visitor.visit(this);   // this 的静态类型是 Paragraph——关键伏笔
    }
}

具体访问者一:统计——一个类装下完整操作:

public class CountVisitor implements DocVisitor {

    private int chars;
    private int tables;
    private int images;

    @Override
    public void visit(Paragraph paragraph) {
        chars += paragraph.getText().length();
    }

    @Override
    public void visit(Table table) {
        tables++;
        for (String[] row : table.getCells()) {
            for (String cell : row) {
                chars += cell.length();
            }
        }
    }

    @Override
    public void visit(Image image) {
        images++;
    }

    public void report() { /* 打印统计结果 */ }
}

Markdown 导出、HTML 导出同款套路各一个类。测试里三路访问者一起上:

CountVisitor counter = new CountVisitor();
MarkdownExportVisitor markdownExport = new MarkdownExportVisitor();
HtmlExportVisitor htmlExport = new HtmlExportVisitor();

for (DocElement e : doc) {
    e.accept(counter);
    e.accept(markdownExport);
    e.accept(htmlExport);
}

运行:

========== 正解:双分派 ==========
  [统计] 字符 62 个,表格 1 张,图片 1 张
  [导出 Markdown]
    设计模式修炼之路:访问者篇

    访问者模式把操作从对象结构中抽离出来。

    | 元素 | 访问者 |
    |---|---|
    | Paragraph | visit(Paragraph) |

    ![双分派示意图](双分派示意图.png)

  [导出 HTML]
    <html><body>
    <p>设计模式修炼之路:访问者篇</p>
    <p>访问者模式把操作从对象结构中抽离出来。</p>
    <table border="1">
      <tr><td>元素</td><td>访问者</td></tr>
      <tr><td>Paragraph</td><td>visit(Paragraph)</td></tr>
    </table>
    <img src="双分派示意图.png" alt="双分派示意图"/>
    </body></html>
  —— 三个操作全程只有一层 for,元素类零 if、零 instanceof ——

注意最后的含金量:HtmlExportVisitor 是事后新增的操作——写它的时候,Paragraph、Table、Image 九个类一个都没碰。这就是定义里"不改变元素的类,定义新操作"的兑现现场。

4.3 三处细节

细节一:双分派解剖。 e.accept(counter) 表面是一行,实际是两棒接力:第一棒,虚方法分派按元素的真实类型(Paragraph)进入 Paragraph.accept;第二棒,visitor.visit(this) 中 this 的静态类型是 Paragraph,触发重载解析进入 visit(Paragraph)。两棒连跑,"元素类型 × 访问者类型"两个运行期维度共同定位了唯一的行为——这正是反面教材里 instanceof 在人肉完成的事,双分派把它下沉成了语言机制。

细节二:visit(this) 那行"废话"不能省。 有人会想:把 accept 干掉,遍历时直接 visitor.visit(e) 不行吗?不行——e 的静态类型是 DocElement,而 Java 的重载是编译期静态分派,visit(e) 根本匹配不到 visit(Paragraph),编译直接报错。所以每个具体元素里都得有那行看似重复的 visitor.visit(this):只有在那里,this 的编译期类型才是具体类。双分派不是炫技,是被 Java 分派规则逼出来的生存智慧。

细节三:结构稳定是生死线。 加操作:加一个类,天下太平(本节已演示)。加元素:比如新增 Audio——DocVisitor 接口加 visit(Audio),CountVisitor、MarkdownExportVisitor、HtmlExportVisitor 全体强制手术。注意这里编译器是帮手不是敌人:它会把每一个没实现新 visit 的访问者揪出来标红,强制你处理,绝不静默漏掉——这正是访问者比 instanceof 链安全的地方,也是它把痛苦前置换来的尊严。

五、在框架里找它

  1. java.nio.file.FileVisitor:Files.walkFileTree() 的核心接口——preVisitDirectory、visitFile、postVisitDirectory、visitFileFailed 四个回调就是"目录树结构 × 你的操作"。顺带一个彩蛋:配套的 SimpleFileVisitor 把四个方法全实现成空操作,让你只覆写关心的那个——㉓ 空对象模式在 JDK 里的联袂出演。

  2. javax.lang.model.ElementVisitor:注解处理器的核心 API。javac 处理 @Override、Lombok 改写字节码,背后都在用访问者遍历语法树元素——TypeElement、ExecutableElement 各有对应的 visit 重载。编译器世界就是访问者的出生地。

  3. ASM 的 ClassVisitor / MethodVisitor / FieldVisitor:字节码操作库——"类结构(JVM 规范锁定)× 操作(增强、审计、插桩)"完美命中访问者的适用条件。Spring CGLIB 动态代理底层就是 ASM:ClassVisitor 的 visitMethod 里织入拦截逻辑,动态生成子类字节码。你在 ⑬ 代理模式里看到的运行期魔法,引擎室里烧的是访问者。

六、操作应该住在哪——三方终审

同一道题(N 种元素 × M 种操作)的三种解法摊开对账:

方案 加一种操作 加一种元素类型 安全性
操作做成元素的方法(纯多态) 所有元素类各加一个方法 加一个类,互不干扰 编译期安全
外部工具类 + instanceof 链 再抄一条链 每条链都要改,**漏了不报错** 无保护
访问者模式 加一个 Visitor 类,元素零改动 接口 + 所有访问者强制改,**漏了编译报错** 编译期安全

看清了吗?访问者和 instanceof 链的行为矩阵完全一样,差别只在变化的成本由谁承担:instanceof 把结构变化的成本藏起来(静默漏掉),访问者把它摆上台面(编译报错)。它和多态方案则是镜像互补:多态对"加类"开闭、对"加操作"关门;访问者对"加操作"开闭、对"加类"关门。开闭原则的"开"永远只对一个变化方向开放——先判断你的系统里哪个方向变得勤,再决定把哪个方向写进开闭,这是本系列从 ⑨ 组合模式一路埋到这里的终极答案。

写在最后

  • 结构稳定、操作常变,是访问者模式唯一的准入证
  • 双分派两棒接力:accept 按元素真实类型分派,visit(this) 按静态类型触发重载
  • visit(this) 那行不能省——Java 重载是编译期分派,被逼出来的生存智慧
  • 加操作零侵入是糖,加元素全体手术是药——糖药同源,看清再吞
  • 比 instanceof 链安全的本质:变化成本摆在编译期,而不是藏进生产事故

㉔ 的文档能把结构导出成 Markdown 文本——这是"结构到文本"的单行道。反过来呢?给程序一段文本,比如促销规则 "vip && age > 18",让它自己解析出结构、算出 true / false,把规则写成人话、让代码读懂它——自己实现一个小规则解释器——解释器模式,下一篇见。

花

花开空白

开发者 · 西安

相关文章

评论(0)

还没有评论,来抢沙发吧

发表评论