最近在做一个文档解析类的项目,里面要处理标题、段落、图片、表格等十几种节点,光“统计信息”和“导出内容”就折腾出好几轮需求变更。一开始我写了个大工具类,用 switch 判断节点类型再逐个处理,前几版还挺顺手,但等到加第五六个新功能时,那个工具类已经膨胀到两千多行,任何人想动一下都得反复确认“这改的是哪块逻辑”。后来我把设计模式从头翻了一遍,真正理解了访问者模式,才意识到问题不在代码量,而在操作和数据结构一直绑在一起。
这篇文章就聊访问者模式,讲清楚它到底解决什么问题,双分派是怎么一回事,用完整的代码例子演示从接口定义到运行结果的过程,再聊聊我在实战中踩过的坑。如果你是刚接触设计模式没多久,或者手头正有一个“节点类型稳定但业务操作不断新增”的系统,这篇文章应该对你有用。
1. 一次把代码改崩的重构:访问者模式到底在解决什么
1.1 一个把 switch 越写越长的场景
当时我要写一个统计模块,输入是一篇结构化文档,里面包含文本节点、图片节点、表格节点。第一个需求很简单:统计文本字数。代码写起来也不复杂:
public int countWords(List<Node> nodes) { int count = 0; for (Node node : nodes) { if (node instanceof TextNode) { count += splitWords(((TextNode) node).getText()); } else if (node instanceof ImageNode) { // 图片里没有文字,跳过 } else if (node instanceof TableNode) { for (String row : ((TableNode) node).getRows()) { count += splitWords(row); } } } return count; }过了一周,需求变成“统计文档里的图片数量和表格数量”。我又写了第二个方法,为了偷懒,复制粘贴了上面这段 instanceof 判断,然后在对应分支里写计数。再过两周,需求变成“导出 HTML”,又来了一个方法,依然复制粘贴。
等第四五个功能加完之后,我发现一个问题:每加一个新功能,就要把所有节点类型重新判断一遍,漏掉一个分支或者类型顺序写错,运行时就会出诡异的问题。最痛苦的是加新节点类型,比如后来加了一个“代码块节点”,所有之前写好的统计、导出方法都得打开逐个改,任何一个地方没改到,线上导出的内容就缺了代码块。
这种代码最大的问题不是长,而是“数据和操作耦合死了”。节点类型是一个稳定的骨架,业务操作却是不断生长的新血肉。把不断变化的东西和相对稳定的东西揉在一起,以后每次变化都是一次大冒险。
1.2 问题的本质:操作该从对象身上“抽出来”
面向对象设计里一个常见的直觉是:操作应该放在对象里。文本节点知道怎么统计自己的字数,图片节点知道怎么计算自己的大小。这个直觉本身没错,但它是有限定条件的。
当操作数量很少的时候,往对象里加方法很自然。可当操作数量膨胀到一个对象身上有几十个方法时,这个类就不再是“某一个领域的实体”,而是把统计、导出、校验、渲染、序列化全塞在了一起。你改一个导出逻辑可能要翻很久代码,而且担心碰坏统计逻辑。更麻烦的是,不同业务逻辑对同一个节点需要的数据还不太一样,你只能把所有 getter 都暴露出来,节点类慢慢变成一个纯粹的数据袋子。
访问者模式采取的是另一种思路:节点类型保持稳定,但它把“操作”抽成一个独立对象,由这个对象专门访问节点并完成业务逻辑。用一句话概括就是:数据结构负责稳定,操作对象负责变化,两者通过一个固定的 accept 入口对接。
所以访问者模式不是用来炫技的,它解决的是很多团队在业务逻辑复杂化之后必然遇到的维护性难题。
2. 双分派与 accept 调用链:访问者模式的核心机制
2.1 单分派语言里的一个老难题
Java、C++ 这类单分派语言里,方法调用时能根据“接收者”的实际类型来决定运行时走哪个实现,这就是动态分派。但方法参数的类型在编译期就已经定死了,运行时不会因为参数实际类型变化而自动选择不同的重载方法。
这造成了一个很尴尬的局面:当你拿到一个 List ,想根据每个元素的真实类型去做不同处理时,不能直接写一个方法然后指望它自动分派。你只能手动判断:
if (node instanceof TextNode) { // ... } else if (node instanceof ImageNode) { // ... }这种代码写多了,就会诞生我前面说的那个巨型工具类。访问者模式正是为了打破这个僵局。
2.2 accept 方法如何“骗”过单分派
访问者模式用一个巧妙的反向调用解决了双维度的分派问题。元素接口里定义一个 accept 方法,方法参数是一个 Visitor。每个具体元素在 accept 方法里,会调用 visitor 的 visitXxx(this),注意这里传的是 this 本身:
public class TextNode implements Node { @Override public void accept(Visitor visitor) { visitor.visitTextNode(this); } }这段代码的价值在于,this 的静态类型就是 TextNode,所以 visitor.visitTextNode(this) 在编译期就能确定调用的是 visitTextNode(TextNode) 这个重载。而在运行时,第一步 node.accept(visitor) 会先根据 node 的实际类型选择 TextNode 的 accept 还是 ImageNode 的 accept,第二步 visitor.visitTextNode(this) 又会根据 visitor 的实际类型选择对应的具体实现。
两步调用合在一起,就凑齐了“元素类型”和“操作类型”两个维度的运行时分派,这就是所谓的双分派。第一次分派决定“你到底是谁”,第二次分派决定“你要干什么”。
用生活里的例子理解:一个外卖订单要送到家里,配送员先在小区门口根据楼栋号找到对应的楼(第一次分派),上了楼之后,住户根据今天是工作日还是周末决定自己签收还是让邻居代收(第二次分派)。元素类型就像楼栋号,访问者类型就像住户的状态。
双分派的意义在于:你不需要在访问者里写大段的 instanceof,因为调用链已经帮你精确地定位到了“某个具体元素类型”和“某个访问者实现”的交叉点上。
3. 从零实现一个文档统计访问器:完整可运行示例
这一节我用一个非常朴素的例子,把访问者模式从接口定义到运行结果完整走一遍。场景还是文档节点,三种类型:文本节点、图片节点、表格节点。
3.1 定义元素:三种节点
先定义统一接口:
public interface Node { void accept(Visitor visitor); }然后定义三个具体节点。文本节点内部存一段文字:
public class TextNode implements Node { private final String text; public TextNode(String text) { this.text = text; } public String getText() { return text; } @Override public void accept(Visitor visitor) { visitor.visitTextNode(this); } }图片节点保存 URL 和尺寸:
public class ImageNode implements Node { private final String url; private final int width; private final int height; public ImageNode(String url, int width, int height) { this.url = url; this.width = width; this.height = height; } public String getUrl() { return url; } public int getWidth() { return width; } public int getHeight() { return height; } @Override public void accept(Visitor visitor) { visitor.visitImageNode(this); } }表格节点保存若干行数据,每行是一个字符串:
public class TableNode implements Node { private final List<String> rows; public TableNode(List<String> rows) { this.rows = rows; } public List<String> getRows() { return rows; } @Override public void accept(Visitor visitor) { visitor.visitTableNode(this); } }到这里你会发现,三个节点类都很干净,没有统计逻辑,没有导出逻辑,只有一句 visitor.visitXxx(this)。这就是访问者模式“把操作外置”的关键。
3.2 定义访问者:字数统计与图片统计
访问者接口为每种节点类型定义一个方法:
public interface Visitor { void visitTextNode(TextNode node); void visitImageNode(ImageNode node); void visitTableNode(TableNode node); }接着写一个统计访问者,同时统计总字数和图片数量:
public class ContentStatsVisitor implements Visitor { private int wordCount = 0; private int imageCount = 0; @Override public void visitTextNode(TextNode node) { wordCount += split(node.getText()); } @Override public void visitImageNode(ImageNode node) { imageCount++; } @Override public void visitTableNode(TableNode node) { for (String row : node.getRows()) { wordCount += split(row); } } private int split(String text) { if (text == null || text.isBlank()) { return 0; } return text.split("\\s+").length; } public int getWordCount() { return wordCount; } public int getImageCount() { return imageCount; } }注意这里我只是把“统计逻辑”放在一个访问者里,三个节点的数据都通过 getter 暴露出来。这样文本节点不用知道“字数”这个概念,图片节点不用知道“数量统计”这个概念,业务逻辑全部收敛到一个类里。
再写一个导出 HTML 的访问者,作为另一个操作维度的例子:
public class HtmlExportVisitor implements Visitor { private final StringBuilder builder = new StringBuilder(); @Override public void visitTextNode(TextNode node) { builder.append("<p>").append(node.getText()).append("</p>\n"); } @Override public void visitImageNode(ImageNode node) { builder.append("<img src=\"") .append(node.getUrl()) .append("\" width=\"") .append(node.getWidth()) .append("\" height=\"") .append(node.getHeight()) .append("\" />\n"); } @Override public void visitTableNode(TableNode node) { builder.append("<table>\n"); for (String row : node.getRows()) { builder.append("<tr><td>").append(row).append("</td></tr>\n"); } builder.append("</table>\n"); } public String getHtml() { return builder.toString(); } }看到这里你应该能感觉到,新增一个“操作”只需要新增一个 Visitor 实现,三个节点的代码完全不用动。
3.3 对象结构与客户端调用
既然文档是一组节点的集合,可以再包一个对象结构类,负责遍历整个节点列表,把访问者依次传给每个节点:
public class Document { private final List<Node> nodes = new ArrayList<>(); public void addNode(Node node) { nodes.add(node); } public void accept(Visitor visitor) { for (Node node : nodes) { node.accept(visitor); } } }客户端使用起来非常直观:
public class Demo { public static void main(String[] args) { Document doc = new Document(); doc.addNode(new TextNode("Hello visitor pattern")); doc.addNode(new ImageNode("http://example.com/pic.png", 800, 600)); doc.addNode(new TableNode(List.of("a b c", "d e"))); ContentStatsVisitor stats = new ContentStatsVisitor(); doc.accept(stats); System.out.println("wordCount = " + stats.getWordCount()); System.out.println("imageCount = " + stats.getImageCount()); HtmlExportVisitor html = new HtmlExportVisitor(); doc.accept(html); System.out.println(html.getHtml()); } }运行结果大致是:
wordCount = 7 imageCount = 1 <p>Hello visitor pattern</p> <img src="http://example.com/pic.png" width="800" height="600" /> <table> <tr><td>a b c</td></tr> <tr><td>d e</td></tr> </table>关于字数,文本节点是 3 个词,表格第一行 3 个词、第二行 2 个词,加起来 8 个词。哦对,上面代码里我是按 7 写的,实际跑出来会是 8,这个细节刚好说明一个问题:统计规则本身也在业务内部不断调整,这类逻辑放在访问者里是最容易修订的。你只需要改一个类,不会碰到节点定义。
这个小 Demo 走完,基本就把访问者模式的核心链路说清楚了。但一个模式能不能用,从来不只是看“代码能不能跑”,还要看它和你实际项目的演进节奏是否匹配。
4. 访问者模式适合什么场景,什么时候别硬用
4.1 我会先判断的三件事
每次考虑访问者模式之前,我会先问自己三个问题,只有答案都符合预期才会动手。
第一,对象结构是不是长期稳定的?这里说的是“元素类型”不会频繁增加。如果节点类型三个月变一次,那么每次变都需要修改 Visitor 接口和所有实现,这个改动面不是一般的大。反过来,如果元素类型已经稳定很久,哪怕你要持续加操作,访问者模式都能帮你把操作隔离得干干净净。
第二,是不是经常要新增“面对所有元素”的操作?比如前面那个场景,今天加统计、明天加导出、后天加安全检查,每个操作都要覆盖所有节点类型。这种情况下,操作数量增长的速度远超节点数量的变化速度,访问者模式是天然切合的。如果只是偶尔加一个操作,或者操作只针对某一种节点,那用普通方法就够,没必要引入额外结构。
第三,元素内部数据能不能相对开放地暴露出来?访问者模式的前提是,访问者可以拿到元素的关键数据。如果节点内部封装很严,几乎所有字段都绝不外露,那访问者来了也白搭。要么你给它开 getter,要么它只能做“空转”的操作,没有实际价值。
把三个条件拉成一张判断表会清晰很多:
| 判断维度 | 适合访问者 | 不适合访问者 |
|---|---|---|
| 元素类型变化频率 | 很低,高度稳定 | 高,经常新增类型 |
| 操作变化频率 | 高,频繁新增操作 | 低,操作基本固定 |
| 元素数据开放性 | 愿意暴露必要数据 | 内部高度封装或存在安全约束 |
4.2 与策略、迭代器、组合模式的边界
很多朋友会把访问者模式和其他几个行为型模式搞混,尤其容易和策略模式分不清。
策略模式适合的是“同一个动作,不同实现”。比如排序算法,输入结构一样,只是采用不同策略实现细节。访问者模式则适合“不同对象,不同动作”,你面对的是一组类型不同的元素,要执行的动作也分散在不同实现里。一个指向同一类对象内部的算法替换,一个指向跨对象结构的操作注入,这是两者最大的区别。
迭代器模式解决的是“如何遍历”,它给你一个统一的顺序访问接口。访问者模式解决的是“遍历时执行什么操作”。好消息是两者可以配合:你先用迭代器拿到元素,再对每个元素调用 accept 传入访问者;对象结构类内部负责遍历,访问者则只管业务逻辑。
组合模式通常用来构建树形的部分-整体结构,而访问者模式非常适合在这种树形结构上做操作。许多实际项目里,组合模式负责搭建结构,访问者负责在结构上完成复杂处理,两者配合非常自然。尤其是当树节点类型多、处理逻辑杂的时候,组合模式本身不会帮你避免 switch,访问者却能做到真正的“类型安全分派”。
4.3 一个容易被忽略的成本:接口变动的连锁反应
访问者模式最大的隐性成本,就是 Visitor 接口一旦定义好,新的元素类型加入时,所有访问者实现都必须跟着改。这在大团队里是一个沟通成本很高的事情。
所以很多严谨的团队会在 Visitor 接口里提供一个默认的 visit 兜底方法,或者使用抽象类,让访问者可以不实现所有方法。比如做一个 AbstractVisitor 基类,默认所有 visit 方法都空实现,具体访问者只需要覆盖自己关心的方法。这样新增元素类型时,只需要在抽象基类里加一个空方法,已有访问者不强制修改。
不过要留意,这种兜底设计也有代价:如果粗心漏实现了一个 visit 方法,程序不会报错,只会安静地跳过某个节点,这种“静默丢数据”的 bug 往往比直接崩溃更难排查。关于这一点,我后面会展开讲。
5. 实战踩坑记录:四个容易翻车的地方
5.1 漏实现 visit 方法导致的静默错误
我早期写过一个导出访问者,只实现了文本和图片的 visit,表格的 visit 没写。因为接口里三个方法都是必须实现的,编译器要求我实现,所以表面上看不会有大问题。但后来我为了图省事,把接口改成默认空实现,结果新的访问者漏写表格处理,导出内容里表格直接消失,测试还没报错,数据无声无息地少了。
这个教训告诉我:访问者接口尽量别用全空默认实现,要么用抽象类标注“暂不处理”,要么在默认实现里抛出一个明确的未支持异常。如果你确实需要大部分访问者只关心某几种节点,可以把不支持的场景显式记录下来,让系统在无法处理时能及时发现,而不是眼睁睁看着数据丢。
5.2 包依赖循环:元素不该依赖具体访问者
项目里采用分层包结构后,访问者和元素很容易形成循环依赖。元素需要依赖 Visitor 接口,访问者要依赖具体元素实现,看起来绕不开循环。但实际上元素只依赖“访问者接口”这一个抽象,而不是任何一个具体访问者,循环依赖通常是因为具体访问者被错误地放进了元素所在的包,或者元素包里出现了“反向调用”的业务代码。
解决方法是规范包之间的依赖方向:元素包只放 Node 接口和具体节点类,以及一个极简的 Visitor 接口;所有具体访问者都放在上层业务包里。这样底层数据模型不依赖任何具体业务逻辑,后面加十个访问者也不会污染底层包。如果你所在语言没有 package 这样强约束的机制,至少要在代码评审时守住这条约定。
5.3 树形结构中的递归遍历顺序控制
文档结构往往是树而不是线性的,节点里还嵌套子节点。这时候有一个细节要特别想清楚:遍历顺序到底由谁控制?
一种做法是,访问者在处理某个节点时,主动去获取它的子节点并继续递归调用 accept。这样做的好处是灵活,你可以只访问某一种节点,也可以控制是否深入子树,但代价是元素必须暴露子节点列表。
另一种做法是,元素自己决定在 accept 里先访问自己再递归访问子节点。这样访问者写起来很省心,只需要处理单个节点,但代价是每个节点都要承担遍历职责,访问者也失去了“跳过某些子树”的灵活性。
我在项目中更倾向于第一种方式:让访问者控制遍历过程。因为实际业务中“是否进入某个子树”经常取决于业务规则,比如安全检查有时不需要深入图片节点,或者统计时某些特殊节点要跳过子节点。如果遍历逻辑被写死在元素里,这些规则就不好办了。当然,这种方式要求 getChildren 方法足够清晰,否则访问者代码会因为遍历逻辑过多而显得混乱。
5.4 访问者数量爆炸现场
访问者模式用多了以后,容易走向另一个极端:业务越拆越细,访问者越来越多。今天加一个状态检查访问者,明天加一个字段提取访问者,后天又加一个千奇百怪的临时统计访问者,整个项目里全是细碎的访问者类。
遇到这种情况,我会先停止写新访问者,重新审视这些访问者之间的公共逻辑。比如多个访问者都要遍历树,那遍历逻辑可以抽到公共基类,访问者只管业务点。再比如有些访问者只为了收集几个统计值,可以合并成一个“统计上下文”,用一个访问者把三类数据同时收好,而不是每个指标写一个访问者。访问者模式的初衷是降低变更成本,不是让类数量无限膨胀,这一点得靠自觉控制。
6. 先想清楚“谁会变”,再决定要不要用访问者
如果只能分享一条个人经验,那就是:选型时要盯住变化的方向。
对象结构稳定而操作频繁变化,访问者模式是很好的答案;对象结构本身还在快速演进,那访问者模式会变成每次改结构的沉重负担。我在那个文档项目里最终采用了访问者模式,是因为“节点类型是内容分析系统里最稳定的概念”,而“今天要统计、明天要导出、后天要合规检查”这些操作注定会不停增加。事实也证明,后续加需求基本只在新增 Visitor 实现,节点代码不再被反复触碰。
如果你在项目里也想尝试,建议先从一个小场景开始:挑一个只有两三处 instanceof 类判断的工具类,把元素接口和访问者接口建起来,迁移一次。看看改动幅度是不是真的变小了,节点类是不是真的变干净了。等这个迁移经验成熟之后,再逐步扩大使用范围。
访问者模式真正打动我的地方,是它逼着我把“对象是什么”和“对它做什么”这两件事分开。很多代码维护难,难就难在你想找一个操作时,得在好多对象里翻来翻去;而访问者模式让操作有处可归。
我个人现在写代码,遇到跨类型批量处理时,第一反应已经不是“写个工具类”,而是“拆出 Visitor 接口”。这个习惯在需求频繁变动的业务里真的能少踩很多坑。