☰
访问者模式:数据结构与操作分离,双分派机制与实战避坑指南
2026/10/20 8:48:15 网站建设 项目流程

1. 别急着给核心对象堆动作:访问者模式要解决的真实痛点

1.1 从一个“加一个功能就要改三处”的崩溃现场说起

你有没有遇到过这种情况:系统里有一棵结构稳定的数据树,比如文件目录树、组织架构树、编译期生成的语法树,或者说一套订单-商品-优惠券的数据模型。这套结构从一开始就定得死死的,一年下来几乎没有变过。但需求呢?每天都在变。今天要“给每个节点加个权限标记”,下周要“按某种规则过滤节点”,再过一个月要“把所有节点导出成JSON”。如果你是直接在这些数据对象上疯狂显式地加方法,那么两周后你大概率会陷入“结构性GAD”——加一个小属性要同时改动五六个类,每次构建都要回归一大片。

我在某公司做支付核心时遇到过一模一样的状况。当时有个标准的“交易”数据结构,包含订单、退款、优惠、支付流水几种实体。第一版很干净,各自只管状态、金额、关联关系。半年之后,这个结构上挂了二十多个业务方法:风控评分要遍历,对账要遍历,报表要遍历,营销要筛选,渠道成本要分摊。给订单对象加一个业务动作,就得挨个打开订单、退款、优惠类去修改。更难受的是,这些动作彼此没有关联,全部挤在实体里,越维护越想重写。

后来我们把这一批“跟结构无关、跟业务策略强相关”的操作从数据对象里搬了出来,用访问者模式重新组织。表面看只是多了几个“访问者”类,实际是把类职责和结构边界彻底划清了。数据对象只保留一个方法:accept,其余的一概不管。每个业务动作收敛到它自己的访问者类里,想加新能力,不动老代码,新增一个访问者即可。

1.2 为什么“给数据对象加技能”这个惯性这么难戒

人脑有个惰性:看到一个类,就想往里加方法。尤其是测试方便、IDE补全又顺手的场景下,“直接在类里写业务方法”简直是肌肉记忆。但你细想一下,这个做法在接口设计层面有三个结构性缺陷。

第一,单一职责被破坏。数据对象的核心职责是描述属性、表达状态、维护基本关系。而“计算风控分”“导出报表”“批量打标”这类东西是行为、是策略、是会流动变化的业务规则。把这两类东西揉在同一个类里,类会越来越重,测试时也要为一个实体同时构造数据和场景,成本和噪音都会陡增。

第二,对外暴露面失控。实体类一旦承担业务操作,就不得不把内部细节暴露给操作方。最典型的例子是一个统计节点大小的需求,你必须让目录节点把子节点列表打开、让文件节点把自己的大小暴露出来。本来这些字段是内部实现细节,被你这么一搞,全部变成公开getter,以后重构内部存储结构时改动面巨大。

第三,加新操作的成本是O(N)。数据对象体系里有N个稳定类型,加一个跨类型操作就要改动N个类。更麻烦的是如果操作本身还依赖类型组合,例如“目录下面有文件”“文件下面有链接”,就要写一大堆类型判断。这正是“开闭原则”的反面:对扩展不开放,只对复制粘贴开放。

访问者模式的核心思想,说白了就是一句话:把操作从对象身上剥下来,让操作自己带着自己的逻辑去“访问”对象。数据结构负责稳定,行为负责变化,二者通过一个统一的入口交互。这样加新操作只需要新增一个访问者类,数据结构类一行都不用改;数据结构要保持稳定,就不允许频繁增加新类型。

1.3 这个模式适合谁看、能解决什么问题

访问者模式适用于这样一群人:源代码结构稳定但业务规则频繁变化的同学,模块边界不清晰想要回归“数据结构只做数据结构”的同学,以及面试时被问到“说一下访问者模式的双分派”但只背了定义却讲不清为什么这么设计的同学。

它的效果在于:把“对一组对象的操作集合”从一个一个散落在各类里的方法,变成“一个独立的对象”。每个对象只保留一个accept方法,把访问者放进来;访问者则携带具体算法,对不同类型的对象执行不同的处理。最终你在一次编译内就能看清“一种业务能力的所有处理逻辑”,不用再跳跃地看七个类。

这个模式尤其适合固定的对象层级 + 频繁变化的操作集合。文件扫描、报表导出、要素渲染、功能点遍历、语法树处理、UI组件事件分发,几乎都是标准的访问者模式应用场景。你理解了这一点之后,再看框架源码里那些Visitor接口,就不再是一头雾水了。

2. 数据结构与操作分离:访问者模式的核心结构拆解

2.1 访问者模式里到底有哪几个角色

访问者模式的结构相当清晰,一共四个角色,理解透这四个角色就能把这个模式用明白。

第一个角色是“抽象访问者”(Visitor)。它声明了一组访问方法,每个方法对应一个具体元素类型。比如你有一个FileNode、一个DirectoryNode、一个LinkNode,Visitor接口里就会有visit(FileNode)、visit(DirectoryNode)、visit(LinkNode)三个重载。方法名通常就是visit,靠参数类型来区分。

第二个角色是“具体访问者”(ConcreteVisitor)。它实现抽象访问者,把对每种具体元素的处理逻辑写进对应的visit方法里。一个业务场景对应一个具体访问者。统计文件夹大小、导出目录树、找超大文件,就是三个不同的具体访问者。

第三个角色是“被访问对象”(Element)。这是数据结构里的稳定类型,通常是一个抽象类或接口,定义一个accept方法,入参是Visitor。每个具体元素实现accept时,本质上做一件事:调用visitor里对应自己类型的那个visit重载,把自己传进去。

第四个角色是“客户端”(Client)。它负责把访问者传给数据结构的入口。通常入口是根节点,比如你想统计整棵目录树的大小,就new一个SizeCounter,然后root.accept(sizeCounter)。访问请求会从根节点开始,按数据结构自身的遍历规则跑到所有子节点。

这几个角色之间的关系,用一句话概括就是:Element的实体类自稳,Visitor的实体类自变,二者通过accept方法绑定。

2.2 为什么需要一个accept方法而不是直接用类型判断

很多初次接触访问者模式的同学会问:为什么不直接在客户端写一个foreach循环,在循环里用instanceof判断类型然后调不同方法?那样不更简单吗?

这样写的问题在于“类型判断与业务逻辑耦合在客户端”。一旦新增类型,所有循环和if-else都要跟着改,哪怕只是加一种新文件类型,也要把所有之前的业务操作重新过一遍。而且这些业务操作散落在客户端代码里,你只能看到一个一个零散的判断片段,无法把它们当作一个整体能力来维护和测试。

accept方法的本质,是把“你是哪种类型”这个问题从客户端搬回对象自身。调用者不判断文件类型,直接调visitable.accept(visitor),具体的Element对象知道自己是谁,会自己去调用visitor.visit(this)。这里最精妙的地方在于,this的静态类型和动态类型都指向同一个具体类,Java和C++这类语言会直接在编译期选到visit的重载函数,根本不需要运行类型判断。这就是“双分派”的由来:客户端分派一次给accept,Element内部再分派一次给visit的正确重载。

2.3 一套标准的访问者模式骨架代码

我直接用Java写一套最小可用的骨架,代码不复杂,但每个角色都能对上号。

先定义被访问的稳定数据结构。以文件系统为例,有三种类型:抽象节点、文件节点、目录节点。

// 抽象节点:稳定数据结构的关键 public interface Node { void accept(Visitor visitor); }
// 文件节点:叶子节点,有自己的大小和名称 public class FileNode implements Node { private String name; private long size; public FileNode(String name, long size) { this.name = name; this.size = size; } public String getName() { return name; } public long getSize() { return size; } @Override public void accept(Visitor visitor) { visitor.visit(this); } }
// 目录节点:包含子节点列表,相当于一个容器 public class DirectoryNode implements Node { private String name; private List<Node> children; public DirectoryNode(String name, List<Node> children) { this.name = name; this.children = children; } public String getName() { return name; } public List<Node> getChildren() { return children; } @Override public void accept(Visitor visitor) { visitor.visit(this); } }

每个Element的accept都长得差不多,都是visitor.visit(this)。关键点在于this的类型已经被编译器锁定,即使有多个visit重载,也绝不会调错。

然后是抽象的Visitor接口。

// 访问者接口:每一种Element类型都要有一个visit重载 public interface NodeVisitor { void visit(FileNode fileNode); void visit(DirectoryNode directoryNode); }

接着可以写一个具体的访问者,比如统计目录总大小。

// 具体访问者:统计目录总大小 public class SizeCounter implements NodeVisitor { private long totalSize = 0L; @Override public void visit(FileNode fileNode) { totalSize += fileNode.getSize(); } @Override public void visit(DirectoryNode directoryNode) { // 目录自身不算大小,遍历子节点 for (Node child : directoryNode.getChildren()) { child.accept(this); } } public long getTotalSize() { return totalSize; } }

最后是客户端调用。

public class Client { public static void main(String[] args) { DirectoryNode root = new DirectoryNode("root", List.of( new FileNode("readme.md", 1200), new DirectoryNode("src", List.of( new FileNode("Main.java", 2048), new FileNode("Utils.java", 4096) )) )); SizeCounter counter = new SizeCounter(); root.accept(counter); System.out.println("total = " + counter.getTotalSize()); } }

这个运行结果是多少呢?1200 + 2048 + 4096 = 7344。整个遍历过程里,客户端一次类型判断都没有写,全部靠对象自身把正确的visit重载选了出来。

2.4 为什么这套结构能稳定:开闭原则在里面的体现

你观察一下整个骨架,如果现在要新增一个“导出目录树”的功能,只需要新写一个ExporterVisitor implements NodeVisitor,在visit(FileNode)和visit(DirectoryNode)里分别决定输出什么格式,代码一行都不用动FileNode和DirectoryNode。这就是开闭原则:对扩展开放,对修改关闭。

但这里有个前置条件,被访问的数据结构不能频繁增加新类型。假如明天加一个LinkNode(软链接),那么NodeVisitor接口必须增加visit(LinkNode),而所有已存在的具体访问者也都要强制实现这个新方法。这意味着数据结构维度的每一次新增,都会导致所有访问者类的联动修改。所以访问者模式的适用性从来不是万能的,它只适合结构稳定、行为活跃的场景。很多文章只说优点不说这个前提,导致有人用它改组结构频繁变动的系统,最后反而比硬编码if-else还难受。

3. 从“一坨 if-else”到访问者模式:一个完整重构案例

3.1 重构前的代码长什么样

我拿一个真实经历过的场景来做演示:一套广告投放的数据模型,有三种稳定的投放物:图片广告、视频广告、Carousel轮播广告。需求来自四面八方——风控部门要统计投放物风险,运营要按类型打标签,财务要分摊成本,报表要导出投放明细。

如果直接用if-else硬写,一个“获取投放详情”的方法大概长这样:

public String buildReport(AdUnit adUnit) { StringBuilder sb = new StringBuilder(); if (adUnit instanceof ImageAd imageAd) { sb.append("图片广告,尺寸=").append(imageAd.getWidth()) .append("x").append(imageAd.getHeight()) .append(",URL=").append(imageAd.getImageUrl()); } else if (adUnit instanceof VideoAd videoAd) { sb.append("视频广告,素材ID=").append(videoAd.getVideoId()) .append(",时长=").append(videoAd.getDuration()) .append("秒"); } else if (adUnit instanceof CarouselAd carouselAd) { sb.append("轮播广告,图片列表=").append(carouselAd.getImageUrls()); } return sb.toString(); }

再看风险审核、成本分摊、标签计算,每一个方法内部都是同样的if-else大杂烩。如果新增一个“音频广告”类型,不止buildReport要加分支,风险审核、成本分摊、标签计算全部都要加分支。每处分支还各自读不同的字段,代码散落、重复、易漏。一个广告类型变化,至少牵连五六个方法,我是在一个项目里真正吃过这个亏的。

3.2 重构:数据对象只留accept,业务逻辑全部搬进访问者

第一步,给三个广告类型建统一接口,让它们都实现accept。

public interface AdUnit { void accept(AdVisitor visitor); }
public class ImageAdUnit implements AdUnit { private int width; private int height; private String imageUrl; // 构造方法、getter省略 @Override public void accept(AdVisitor visitor) { visitor.visit(this); } }

VideoAdUnit和CarouselAdUnit同理,各自实现accept,一行调用。

第二步,定义访问者接口,指向这二三种具体类型。

public interface AdVisitor { void visit(ImageAdUnit imageAdUnit); void visit(VideoAdUnit videoAdUnit); void visit(CarouselAdUnit carouselAdUnit); }

第三步,把原有散落在各处的业务逻辑收拢到具体的访问者里。比如报表导出:

public class ReportExporter implements AdVisitor { private StringBuilder sb = new StringBuilder(); @Override public void visit(ImageAdUnit imageAdUnit) { sb.append("图片广告,尺寸=").append(imageAdUnit.getWidth()) .append("x").append(imageAdUnit.getHeight()) .append(",URL=").append(imageAdUnit.getImageUrl()); } @Override public void visit(VideoAdUnit videoAdUnit) { sb.append("视频广告,素材ID=").append(videoAdUnit.getVideoId()) .append(",时长=").append(videoAdUnit.getDuration()) .append("秒"); } @Override public void visit(CarouselAdUnit carouselAdUnit) { sb.append("轮播广告,图片列表=").append(carouselAdUnit.getImageUrls()); } public String getReport() { return sb.toString(); } }

调用方变成:

ReportExporter exporter = new ReportExporter(); adUnit.accept(exporter); String report = exporter.getReport();

第四个类型的动作同理。风险审核写一个RiskScoreVisitor,成本分摊写一个CostAllocator,打标签写一个TaggingVisitor。每个Visitor都实现了整套visit,各管各的业务逻辑。

注意到区别没有?以前你理解一个“报表导出”能力,要在五个类里反复跳转,累,而且容易漏。现在一个ReportExporter类里是全流程的处理逻辑:图片走图片逻辑,视频走视频逻辑,轮播走轮播逻辑,内聚性拉满。

3.3 组合型的处理逻辑:一个访问者内部怎么协作遍历

广告场景里的三种类型可能是平级的,但很多场景下数据结构是树形层级。比如文件系统的目录里有文件,文件系统又可能包含链接。DirectoryNode的visit方法里要主动遍历子节点,这就是访问者模式处理树形结构的方式。

我做一个实际的例子。假设我们的文件系统有三种节点:普通文件FileNode、软链接LinkNode、目录DirectoryNode。目录里可能嵌套文件、链接、子目录。现在要做一个“清理失效软链接”的访问者,它要做两件事:找到失效的目标路径,然后标记删除。

public class BrokenLinkCleaner implements NodeVisitor { private List<LinkNode> brokenLinks = new ArrayList<>(); @Override public void visit(FileNode fileNode) { // 文件节点不处理,空实现 } @Override public void visit(DirectoryNode directoryNode) { // 目录:遍历所有子节点 for (Node child : directoryNode.getChildren()) { child.accept(this); } } @Override public void visit(LinkNode linkNode) { if (!Files.exists(linkNode.getTargetPath())) { brokenLinks.add(linkNode); } } public List<LinkNode> getBrokenLinks() { return brokenLinks; } }

注意一个细节,DirectoryNode的visit里为什么要调child.accept(this),而不是直接用child.getType()做if-else?因为child的静态类型是Node,不能静态确定具体类型。通过调用child.accept(this),运行时child会根据自己的实际类型,回调this里对应的visit重载。这就是前面说的双分派机制在树形遍历中的实际作用。没有这个机制,我们就要在visit(DirectoryNode)方法里写一堆类型判断,而访问者模式最讨厌的就是类型判断。

3.4 重构完之后,新增一个类型会发生什么

回过头来看这个重构的收益。假设业务方突然要求支持“音频广告”类型。第一,你需要新增一个AudioAdUnit类,实现AdUnit和accept。第二,你需要给AdVisitor接口增加visit(AudioAdUnit)方法。第三,所有已存在的访问者——ReportExporter、RiskScoreVisitor、CostAllocator、TaggingVisitor——都必须补上visit(AudioAdUnit)的实现,否则编译都过不了。

放在以前用if-else硬写的方案里,这四个业务逻辑也要手动改四遍,但更麻烦的是漏改。因为if-else不强制要求你新增分支,编译器根本发现不了遗漏,运行时走到音频广告时会直接静默跳过或者抛异常。而访问者模式直接把“必须实现的类型处理逻辑”变成了编译期约束,你没实现编译就过不去。从这个角度看,访问者模式带来的不只是组织代码的便利,还买了一份编译器级的强制清单。

4. 核心机制与设计判据:何时该请外援,何时别迷信访问者模式

4.1 双分派到底是怎么发生的,为什么它让访问者模式“能打”

很多人认识访问者模式是从“双分派”这个词开始的,但真要讲清楚它到底怎么工作,很多人又含糊了。我尝试用一个更直白的例子解释。

想象你是一个医院的体检医生,现在要检查三个不同科室的病人:内科病人、外科病人、儿科病人。如果只看“病人”这个抽象概念,体检流程是统一的,单分派就够了。但如果你想知道更多细节,比如内科病人要量血压、外科病人要查伤口、儿科病人要称体重,那么检查动作的选择既取决于“医生”这个角色,也取决于“病人”的具体类型。要真正让检查动作匹配病人类型,必须发生两次分派。

第一次分派发生在客户端:你拿到一个病人,把它交给对应的科室检查器。第二次分派发生在检查器内部:每个病人的具体类(内科病人、外科病人)回调检查器对应的重载方法,告诉检查器“我是内科,你查血压;我是外科,你查伤口”。这就是双分派——两个对象(病人和检查器)的运行时共同决定了最终执行的方法。

映射到代码里:客户端调用“element.accept(visitor)”,这是第一次运行时确认——确认给哪个element传哪个visitor;element对象内部执行“visitor.visit(this)”,这是第二次运行时确认——element把自己的动态类型交出去,触发visit的正确重载。两次多态共同完成方法选择。

这个机制的巧妙之处在于:只要对象自己实现了accept,不管它在继承结构中藏得多深,最终都能把正确的visit重载调出来。这比客户端用instanceof逐层判断要优雅得多,也不用拿一个巨大的switch-case去接住所有类型。

4.2 访问者模式的结构代价:为什么它看起来“类数量爆炸”

访问者模式受到诟病最多的一点是类数量爆炸。一个简单的需求,如果用if-else写,可能只需要一个方法;换成访问者模式,要新增一个Visitor接口、一个具体Visitor类,还要给每个Element加一个accept方法。一个小项目里三四类业务,搞下来二十多个类,确实吓人。

但你要冷静想一下这二十多个类是从哪来的。接口约定了“你有哪几种访问”,具体Visitor约定了“这几种访问你分别怎么处理”。以前这些散在各种工具方法、Service类里的if-else转换成了类文件,数量看起来增加了,但单个类的内聚度反而更高了,也更容易测了。以SizeCounter为例,它的四个visit方法对应四种节点类型的处理逻辑,所有状态totalSize封装在一个实例里。拿JUnit测试这个类时,只需要构造树形数据,不需要启动Spring容器、不用mock数据库,测试成本远低于以前那种满屏instanceof的写法。

所以我的观点是:类多不是问题,职责混乱才是问题。访问者模式的“类多”,本质上是职责单一化之后的自然产物。如果一个系统稳定类型少于三个、业务操作少于两个,那就别上访问者模式;如果类型和操作都同时多点,访问者模式才会表现出明显优势。

4.3 什么时候用,什么时候不该用:一张判断表

我根据自己的实践整理了一张场景判断表,能帮你快速决定要不要上访问者模式。

业务条件是否适合访问者模式为什么不/为什么能
数据结构稳定,类型不会频繁增加非常适合访问者模式对“类型数量稳定”极其敏感
操作集合频繁变化,每次新增操作就改一堆类非常适合新增访问者不改动数据结构
数据类型只有两三种且彼此差异不大一般化,偏不适合用接口默认方法或普通多态更简洁
数据结构本身频繁加新类型非常不适合每次加类型都要改Visitor接口与所有实现
元素类型之间是多层继承关系,并非扁平慎用访问者接口里要为每个叶子类型定义visit方法
需要自定义遍历顺序或过滤条件可以在Visitor里做,但合理遍历逻辑可由容器自行承担,也可以放进Visitor
团队对双分派机制不熟悉需要评估能写出运行错误的代码,但难以定位

这里重点提一下“多层继承”的情况。如果Element有抽象父类和多个子类,而Visitor接口只按抽象父类的粒度声明visit方法,那visit(this)会收到什么?会把this当作父类类型传入,子类特有字段就访问不了;如果按每个子类声明visit方法,那Visitor接口就会因为继承层级不断膨胀。这是访问者模式最不舒服的区域。所以实际项目里,访问者模式的Element类型最好保持扁平结构。

4.4 面访者模式与策略模式、模板方法模式的区别在哪

经常有人把访问者模式与策略模式混在一起,因为两者都是“把算法提取到外部类”。我直接说区别:策略模式处理的是“一个对象在运行时可以选择不同的算法”,前提是一个操作对象,算法之间可以互相替换;访问者模式处理的是“一组对象与一组算法之间的稳定绑定”,算法往往不是互相替换关系,而是各自处理不同对象类型时各司其职。

模板方法模式则完全是另一个维度,它把算法的骨架写在基类里,子类覆盖细节。访问者模式里没有“骨架继承”这回事,每个visit重载负责一个具体类型的行为,它们之间是平行关系,不存在必须按固定顺序执行的模板。

如果你面临“多个类型,多个操作,类型固定操作多变”,优先考虑访问者模式。如果你的“多个操作”可以定义在同一个接口上,各自独立替换,那用策略模式组合会简单得多。别一碰到“算法”就想套访问者模式,先问一问:要不要区分不同对象类型?要不要加新算法时不改老对象?

4.5 枚举Elephant:访问者模式其实是一种变相的“双重分派confirm”

不少框架在内部也大量使用访问者模式,只是不对外暴露Visitor接口这个词。比如ANTLR、编译器AST处理、IDE里对语法树的各种“重构操作”,几乎全是访问者模式的变体。Spring的BeanPostProcessor也算一种“访问者”思路:进入前后生命周期时,不同Bean会被不同PostProcessor处理。

我的一个习惯是给Visitor接口取名叫xxxVisitor,不叫xxxHandler。因为在语言习惯里Handler更强调单一事件的处理,而Visitor强调的是对多个对象类型的全面覆盖。如果你的接口明明有visitFile、visitDirectory、visitLink,却叫FileHandler,别人第一次看会以为只处理文件。名称直接决定团队对这个类职责的第一印象。

5. 实操要点与避坑经验:访问者模式开发中的高频雷区

5.1 给每种Element类型定义visit重载,别偷懒

访问者模式里最常见的错误是:Visitor接口偷懒,只定义visit(ElementTypeA),不定义visit(ElementTypeB)。或者想用一个visit(Object)通吃,然后靠类型判断。这个做法正式抹杀了访问者模式存在的意义。

我的经验是:接口层面宁可多写一个visit重载,也不要试图用Abstract父类来减少方法数量。每次在visitor接口里写针对具体子类的visit方法,都相当于把“调度逻辑”交给语言本身的类型系统,你无须自己写if-else。如果某个具体类型暂时没有处理逻辑,visit重载方法体留空,也比方案上少一个重载好。留空意味着“我知道这个类型存在,但我暂时不需要处理”;少一个重载则等于“我压根没发现这个类型”,编译器也会保持沉默,将来它悄悄没被处理,问题会去运行时爆。

5.2 accept方法必须回调visit(this),别自己写逻辑

刚开始写访问者模式的小伙很容易在accept里加逻辑,比如:

public void accept(Visitor visitor) { System.out.println("收到访问"); visitor.visit(this); }

这种写法把打印动作绑死在所有访问流程里,以后任何Visitor访问它都会打印,那天如果日志系统变慢,影响反而被放大。accept的语义只有一个:把访问者接进来,然后把控制权交给visitor.visit(this)。任何业务逻辑都不要写进accept,它连日志都不该打。

还有另一个极端:有人为了让accept“通用”,会加一个Object参数,然后自己进行类型转换。这等于把双分派机制拆了,回到手写instanceof的老路。accept只接收Visitor,里面只做重载分发,别的都别想。

5.3 遍历逻辑放Visitor还是放Element?我的建议

访问者模式最容易被问到的实务问题是:遍历这棵树的操作放哪一边。放Visitor的visit(DirectoryNode)方法里,意味着目录节点自己不需要知道如何遍历,它只需接待Visitor;放Element内部,则要在DirectoryNode里实现一个遍历子节点的方法,把Visitor逐一传给儿子节点。

如果遍历逻辑只跟数据结构本身有关(不管哪种操作来都一样),我会把它放在DirectoryNode里。比如提供一个默认循环子节点的实现,各Visitor复用。如果遍历逻辑跟具体操作有关系——比如“这个Visitor只查一层,不递归”,那就在Visitor内部控制递归深度,不要把这种业务决策绑架到Element上。

经验是两者可以配合:DirectoryNode的accept方法里只做visit(this),同时它单独暴露getChildren()给Visitor用,由Visitor决定要不要继续递归。这样一来,数据结构的遍历能力保留在结构本身,而“如何遍历、多深、要不要中断”等业务决策由Visitor控制。

5.4 如果一个visitor要复用多个结构,怎么办

有时候一个Visitor需要同时访问两类数据结构。比如一个“跨模块分析Visitor”既要访问广告节点,又要访问投放策略节点。这时不要把两个Visitor接口强行合并,会让结构发臭。

更稳妥的做法是定义两个Visitor接口,让同一个实现类实现两个接口。不过Java里这种写法可读性很差,我实际经验里更推荐组合:单独一个类持有两个Visitor内部类,分别实现不同接口。这样每个内部类聚焦一种数据结构的处理,职责边界更清晰。

如果两个数据结构有共同底座,比如都继承自统一Node接口,那可以让Visitor接口统一面向Node,再定义visit(SpecialNode)重载。注意这里要保证SpecialNode是Node的具体子类,否则双分派会匹配不上。

5.5 测试访问者模式时的几个坑

单元测试访问者模式比较简单:构造一堆Element树,new一个Visitor,从根节点开始accept,然后断言Visitor统计出来的结果。

但有一个坑非常常见:如果遍历逻辑放在Visitor的visit(DirectoryNode)方法里,那么测试一个叶子文件节点时不会触发目录遍历,这没问题;但你测试目录节点时,如果没注意子节点类型包含LinkNode,而Visitor没有实现对LinkNode的visit,就会漏掉整个链路。所以测试目录节点时记得构造全类型的子节点混合树,包括文件、目录、以及各种可能的特殊节点。

另一个坑是并发。多个线程共享同一个Visitor实例,会导致状态互相污染。如果你在一个并发场景里使用访问者模式,注意给每个线程new一个Visitor实例,或者将Visitor设计成无状态的、所有处理结果都通过不可变返回值返回。我在做文件扫描工具时,就因为复用了一个共享的SizeCounter,导致并发统计结果千奇百怪,排查了两天才发现是共享状态的问题。

还有一个经验:把Visitor做成无状态的会很别扭,因为访问者模式天然就是“把处理过程中的状态存在Visitor实例里”。既然要存状态,就要在启动遍历前明确reset方法或者直接创建新实例。我更喜欢后一种方式:每次遍历都new一个Visitor,不用reset,不共享东西。

6. 访问者模式的延伸玩法:不只是报表导出,还可以这么用

6.1 类型安全的判空与属性检查

访问者模式不只用于树形结构的遍历。很多代码里“根据对象类型做不同校验”的需求,最适合用访问者模式梳理。比如一个订单状态机里,订单有创建态、支付态、发货态、完成态。校验规则完全不同。你可以在每种状态类里定义一个accept,把校验逻辑做成不同的Visitor:校验可取消性、校验可退款性、校验可发货性。以后新增一种订单状态,只需新增状态类并在Visitor接口里加一个重载;新增一种校验规则,只需新增一个Visitor。

这比大量if (orderState instanceof XxxState)要安全,因为它把“每个状态必须实现哪些规则“变成了编译期强制约束。设计一个“StateValidator”接口,要求每个订单状态类都实现accept(OrderValidator),同时OrderValidator里定义了对所有状态类型的具体校验重载。之后新增状态,编译能立刻告诉你哪些校验没覆盖。

6.2 组合第三方对象与自定义适配

有时候我们无法修改第三方库的数据结构,但需要给它的不同类型挂不同的操作。访问者模式的“wrap方式”就能派上用场:为第三方类型定义一套轻量的适配接口,通过accept把操作分派到各个适配访问者去。

比如对接上游系统,返回一个统一JsonNode,里面有object、array、field、value等类型。你不想在业务代码里到处判断node类型,就可以定义JsonVisitor,在每种节点类型下写自己的访问逻辑,把结构层次的遍历过程收拢进各个visit方法。上游如果新增一种类型,你就加一个visit重载,保证每处处理的完整性。

6.3 与函数式接口结合:把Visitor变成可复用组件

如果用Java 8及以上,你可以把Visitor接口中的每个visit方法定义成函数式接口的action,让调用方通过lambda快速生成一个临时Visitor,而不用单独开发类。我贴一段示例:

// 定义一个可以装配lambda的通用Visitor public interface LambdaVisitor { void visit(FileNode fileNode); void visit(DirectoryNode directoryNode); // 提供一个快速构建工具 static LambdaVisitor of( Consumer<FileNode> fileAction, Consumer<DirectoryNode> dirAction ) { return new LambdaVisitor() { @Override public void visit(FileNode fileNode) { fileAction.accept(fileNode); } @Override public void visit(DirectoryNode directoryNode) { dirAction.accept(directoryNode); } }; } }

使用的时候:

LambdaVisitor printer = LambdaVisitor.of( fileNode -> System.out.println("文件: " + fileNode.getName()), dirNode -> System.out.println("目录: " + dirNode.getName()) ); root.accept(printer);

这种写法适合临时场景或原型验证。如果业务逻辑比较多、需要复用,还是定义成独立类更合适。lambda的好处是代码量小、读起来直接在调用处看到处理逻辑,坏处是如果逻辑太多,lambda会迅速膨胀失去可读性。

6.4 复杂遍历顺序的安排:深度优先、广度优先、剪枝

文件扫描这种场景,访问者模式常见的遍历顺序是深度优先,但很多业务需要自定义遍历顺序。比如我要统计一个目录树里哪些文件最近7天没被访问,我只关心叶子文件,不需要深入目录;或者我统计一个超大目录时只想统计前两层,第三层不进。

这类需求如果用if-else硬编码,遍历逻辑和业务逻辑会纠缠在一起。把遍历顺序逻辑放到Visitor内部是最自然的:DirectoryNode的accept只负责把visitor交给DirectoryNode自身;visitor的visit(DirectoryNode)内部自行决定是否遍历子节点、用什么顺序遍历、是否剪枝。

我做过一个磁盘空间分析工具,遍历目录时必须跳过隐藏目录、跳过系统链接目录,否则可能进入无限循环或者是扫描超时。各类过滤条件全塞在DirectoryScannerVisitor的visit(DirectoryNode)方法里,未来想加“跳过build目录”也只是一行判断的事。这在传统编码方式里,你需要在每个递归方法传入过滤参数,扩散面大得多。

7. 常见问题速查表:访问者模式使用中的独门经验

问题现象可能原因解决思路
子类型走到visit(Object)而不是visit(具体类型)Visitor接口没有为具体子类型定义重载给每个具体类型定义visit重载
新增类型后,编译期没有报错但运行时不处理Visitor接口缺失对应重载,方法被静默忽略把所有Element类型的visit方法定义为强约束,且不留默认实现
目录遍历总漏掉某些节点visit(DirectoryNode)里忘了调用child.accept(this)每个容器节点都必须主动把visitor传给所有子节点
多线程并发结果混乱多个线程共享同一个Visitor实例每线程创建自己的Visitor实例,避免共享可变状态
测试通过但生产上某些类型没被处理测试数据没覆盖到全部Element类型测试里构造包含所有类型节点的混合树
一个Visitor要同时访问多类数据接口设计不合理,强行合并拆成多个内部Visitor,用组合或Adapter方式
每次加新Element类型,Visitor全部编译失败这是访问者模式的已知代价评估类型稳定度,不稳定就不该用访问者模式
Visitor里无法拿到二字母字段数据结构封装得太死,getter不足提前梳理数据结构对外暴露哪些字段,必要时增加方法入口

单独讲一下“测试数据没覆盖全部Element类型”这个坑。比如文件系统有FileNode、DirectoryNode、LinkNode,你在测试SizeCounter时只构造了FileNode和DirectoryNode,没放LinkNode。编译能过,测试能过,但LinkNode的实际visit方法是空的,统计时它的size被漏掉了。生产环境只要遇到LinkNode,结果就错。这个问题的根源是访问者模式把类型处理分散到了多个visit重载里,而测试者容易偷懒只测部分重载。建议准备一个固定的测试数据工厂,每次都生成包含全部类型的标准树,所有Visitor测试都复用这棵树。

还有一个小经验:如果要用访问者模式做大数据量遍历,注意Stack OverFlow问题。树形结构很深时,visit(DirectoryNode)内部的递归可能爆栈。遇到这种场景,要么在Visitor内部维护显式栈,要么把递归改成迭代。这里不是访问者模式的核心问题,但它是所有递归方案的通病,至少要有心理预期。

8. 把访问者模式当习惯,而不是当玩具

关于访问者模式,我个人的经验是它在真实项目里出现概率被大大低估了。很多人觉得它“结构复杂、类太多”,真正搭一次之后,你会发现它其实是一种思维的转换:数据结构的职责边界,操作的聚合方式,被双分派机制极大地优化了。

我实际用得最多的场景是报表导出工具。一个系统几十个稳定实体类型,报表模板换了一版又一版。以前每次加报表都要修改所有实体类,项目后期变成“加模板不改实体”的爽快体验。团队新人来了,给他们看某个模板的Visitor实现,两三分钟就能找到对应类型的处理逻辑,比在一堆Service方法里翻if-else快得多。

最后再分享一个个人习惯:写Visitor接口时,我会把文档注释写全,逐个方法说明这是处理哪种类型的。这个接口本身就是团队里的一张“对象类型地图”,维护好后,后来的同事一眼就能知道系统里有哪几类Element、各自月处理哪些操作。访问者模式不只是代码组织方式,也是架构里的一层“目录”。

回到文章标题,别再给你数据对象乱加“技能”了。数据结构应保持纯净,操作交给外援。如果是稳定类型加多变操作,请放心使用访问者模式;如果类型本身也在频繁变化,请收好这念想,还是老老实实用策略模式或接口分派吧。这套判断标准,就是访问者模式真正的使用说明书。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询