访问者模式(Visitor Pattern)是我在实际项目中又爱又恨的一个模式——爱它是因为它能让代码结构在特定场景下变得异常清晰,恨它则是因为一旦滥用,重构成本会成倍增加。今天想结合自己踩过的坑,把访问者模式的核心逻辑、实现细节和适用边界一次性说清楚,希望能帮到正在学习设计模式,或者正在为“对象结构稳定但操作频繁变化”而头疼的朋友。
这个模式本质上回答了一个问题:当一套稳定的数据结构上需要叠加大量不同操作时,如何做到不修改数据类,却能让操作无限扩展?答案就是双分派机制。它非常适合用于语法树解析、复杂报表生成、AST遍历这类场景。我最初接触它是因为要处理一个多类型工单的转派逻辑,当时用了一堆if-else,后来重构为访问者模式之后,代码的扩展性和可读性有了质的改善。
1. 访问者模式的核心设计意图与适用边界
1.1 从“分治”到“隔离”:访问者模式到底在解决什么
数据结构和操作是两个维度的东西。常规写法里,我们倾向于把操作放进数据结构内部,也就是在实体类里写业务方法。比如一个订单类,它可能有计算价格、生成报表、发送通知等一堆方法。随着业务增长,这些方法会越来越庞大,实体类会变成“上帝类”。
访问者模式采取的思路正好反过来:把操作从数据结构里抽出去,放进一个独立的“访问者”中。数据结构只需要提供一个accept方法,接待访问者的到来,把自身作为参数传递进去。至于访问者拿到这个对象之后要做什么,数据结构完全不管。这样做的好处是,当我们要新增一种操作时,只需新增一个访问者类,数据结构的代码完全不用改动。
听起来很完美对吧?但访问者模式有一个前提条件:对象的类型不能频繁变化。因为访问者接口中通常要为每一种具体类型准备一个visit方法,如果新增了一种元素类型,那么所有访问者类都必须同步修改,这会导致维护成本非常高昂。所以,这个模式只适用于元素类型相对固定、操作变化频繁的场景。
1.2 一个生活化的类比:体检套餐与科室检查的分离
为了让访问者模式更容易理解,我们可以拿体检来举例。假设一家体检中心有多个科室:内科、外科、眼科、耳鼻喉科,每个人都可以选择不同的体检套餐。如果把这套逻辑做得比较死板,一个人要做一个套餐,这个人自己就得知道怎么去所有科室,而且每增加一个科室,所有人都得改一遍——这显然不合理。
访问者模式的做法是:让这个人走到一个科室前,告诉科室“我是来做检查的”,剩下的事情由科室来完成。人的类型可能是“普通员工”“管理层”“退休人员”,科室会根据人的类型给出不同的检查项目。在这里,“人”就是被访问的元素,科室就是访问者。每个人不用关心科室内部怎么做检查,只需要把自己“交”出去就行。这种检查和被检查对象的解耦,正是访问者模式的价值所在。
1.3 什么时候不该用访问者模式
访问者模式不是万能药,以下几种情况建议规避:
第一,元素类型本身变化频繁时不要用。如果数据结构中经常增加新类型,意味着访问者接口也要跟着增加新的visit方法,所有实现类都得改,这种连锁反应破坏开闭原则。
第二,对象结构层次较深且子类复杂度高时,访问者模式的实现会变得非常笨重,需要在每个子类中维护accept逻辑,不仅代码量大,调试起来也很痛苦。
第三,如果你只有一两个操作需要扩展,直接用普通的策略模式反而更简洁,没必要引入访问者模式的双分派机制,过度设计会让团队其他成员难以理解。
2. 模式结构的四个核心角色及其协作关系
2.1 抽象访问者与具体访问者
抽象访问者(Visitor)定义了对每种元素类型的访问处理方法。比如一个接口里声明visit(ConcreteElementA element)和visit(ConcreteElementB element)两个方法——方法名相同,但参数类型不同,这是典型的“方法重载”。具体访问者(ConcreteVisitor)则是对接口的实现,它内部会针对每种元素写出具体的处理逻辑。
这里最容易犯错的地方是方法重载的匹配机制。很多初学者以为传入什么类型就自动调用对应的方法,但在Java里,方法的绑定是在编译期根据变量的静态类型决定的,而不是运行期根据对象的实际类型。这就是为什么访问者模式要靠accept方法绕一圈,触发动态分派。
2.2 抽象元素与具体元素
抽象元素(Element)通常是一个接口或抽象类,它声明一个accept(Visitor visitor)方法。具体元素(ConcreteElement)实现该方法时,做法很简单:visitor.visit(this)。这里的this是具体元素的实例,它的静态类型和动态类型都是具体的类型。正是这个调用,实现了从“访问者根据元素类型”进行方法选择的逆转——双分派的关键就在这里。
协作流程可以这样理解:外部调用者调用具体的元素对象的accept方法,传入一个访问者。元素对象在accept内部反向调用访问者的visit方法,并把自己作为参数传回去。这样一来,实际的执行逻辑就从“元素的方法体”转移到了“访问者的方法体”中。
2.3 ObjectStructure:遍历的组织者
ObjectStructure是整个结构的“组织者”,它持有元素的集合,对外提供遍历入口。在遍历时,它对每个元素调用一次accept方法,把访问者传递进去。它可能是独立的类,也可能只是客户端的一段循环代码。
需要注意的是,ObjectStructure并不是访问者模式中的必备角色,但它能大幅提升代码的整洁度。有了ObjectStructure,客户端不需要自己写循环,只要调用一个方法就能完成所有元素的访问处理。如果你的元素集合是一个树形结构,遍历逻辑还可以使用递归或迭代器来配合实现。
3. 双分派的实现原理与代码推演
3.1 先理解“静态类型”和“动态类型”的微妙差异
在讲解双分派之前,必须厘清Java中两个基本概念——编译期类型和运行期类型。对于一个变量,编译期类型由声明时决定,比如Element element = new ConcreteElementA(),编译期类型是Element,运行期类型才是ConcreteElementA。普通方法调用在运行期绑定,依赖运行期类型决定走哪个方法;但方法重载则在编译期就确定了,依赖编译期类型来决定到底匹配哪个方法签名。
这个差异是理解双分派的核心前提。如果没有accept这一层,直接用visitor.visit(element),编译器看到element的静态类型是Element,会去访问者接口中寻找visit(Element)方法;如果没有这个方法,编译直接报错。即便你传入的实际对象是ConcreteElementA,编译器也不会理会。正是由于这个原因,访问者模式必须通过accept方法在元素这一侧再分派一次,用动态类型将this交给访问者的visit方法,构成第二次分派。
3.2 第二次分派的完整调用链
假设我们有ConcreteElementA类,它实现了accept方法。外部代码调用elementA.sendTo(visitor),而这个elementA的声明类型是Element,运行类型是ConcreteElementA。第一次分派发生在accept方法调用的过程中,虚拟机会根据elementA的实际类型,调用到ConcreteElementA的accept。第二次分派则发生在accept方法内部,当它执行visitor.visit(this)时,this的静态类型已知是ConcreteElementA,于是直接匹配到具体访问者中visit(ConcreteElementA)那个重载,完成处理逻辑。
为什么需要两次分派,而不是直接写visitor.visit((ConcreteElementA) elementA)?原因在于,节点类型是多种的,客户端不可能每次都做类型判断和强转。两次分派让不同类型的节点能够按照自己的实际类型,自动找到对应的visit重载实现,客户端根本不需要操心具体类型,代码更加整洁。
3.3 伪代码展开,看清每一层在做什么
来一段简化版的伪代码理清思路。定义访问者接口:
interface Visitor { void visit(ConcreteElementA element); void visit(ConcreteElementB element); }元素接口:
interface Element { void accept(Visitor visitor); }具体元素A:
class ConcreteElementA implements Element { @Override public void accept(Visitor visitor) { visitor.visit(this); } }具体元素B的逻辑与A类似。客户端使用时,构建一个元素列表,让每个元素依次accept一个具体访问者。=这里把被访问的对象的具体类型,与访问者中对应的方法准确匹配起来=,既不用instanceof判断,也不需要强制类型转换。这套机制在编译期虽然看起来很绕,但在运行期非常清晰。
4. 一个贴近真实业务的完整Java实例:工单节点处理
4.1 业务场景设定:多类型工单节点的流程处理
假设我正在做一个工单系统,工单流转中包含多个节点:申请节点、审批节点、执行节点。不同节点需要执行的操作不同,而且随着业务发展,还可能不断有新的操作加入,例如日志记录、通知发送、状态统计、金额计算等。如果这些操作都写在节点类内部,节点类的责任会越来越重,每次增加操作都要改这几个节点类,风险极大。
借助访问者模式,节点类只需要提供accept入口,把操作逻辑全部挪到访问者类中。这样后续要增加新操作时,新增一个Visitor类即可,完全不改动节点类代码,这正是对开闭原则的遵循。
4.2 定义节点元素与访问者接口
先把节点元素接口定义出来:
public interface ProcessNode { void accept(WorkflowVisitor visitor); String getName(); }然后实现三种节点:
public class ApplyNode implements ProcessNode { private String applyUser; private double amount; // 构造方法、getter等略 @Override public void accept(WorkflowVisitor visitor) { visitor.visit(this); } @Override public String getName() { return "申请节点"; } }审批节点和执行节点的写法与申请节点类似,各自实现自己的accept方法,同时保留一些自身的业务特征字段。这里要注意,每个节点都要实现accept,而且方法体都是调用对应的visit重载,这看起来有一点重复代码,但它确保了双分派机制的完整性。
接下来定义访问者接口:
public interface WorkflowVisitor { void visit(ApplyNode node); void visit(ApproveNode node); void visit(ExecuteNode node); }4.3 实现两个具体的访问者:日志记录与统计报表
第一个访问者,负责节点处理过程中的日志记录:
public class LogVisitor implements WorkflowVisitor { @Override public void visit(ApplyNode node) { System.out.println("记录申请节点日志:" + node.getName() + ",申请人金额=" + node.getAmount()); // 模拟写入日志文件 } @Override public void visit(ApproveNode node) { System.out.println("记录审批节点日志:" + node.getName() + ",审批意见=" + node.getApprovalComment()); // 模拟写入日志文件 } @Override public void visit(ExecuteNode node) { System.out.println("记录执行节点日志:" + node.getName() + ",执行结果=" + node.getExecuteResult()); // 模拟写入日志文件 } }第二个访问者,负责生成统计报表:
public class ReportVisitor implements WorkflowVisitor { private int applyCount = 0; private int approveCount = 0; private int executeCount = 0; @Override public void visit(ApplyNode node) { applyCount++; System.out.println("报表:申请节点统计,当前数量=" + applyCount); } @Override public void visit(ApproveNode node) { approveCount++; System.out.println("报表:审批节点统计,当前数量=" + approveCount); } @Override public void visit(ExecuteNode node) { executeCount++; System.out.println("报表:执行节点统计,当前数量=" + executeCount); } public void printReport() { System.out.println("== 申请节点数量: " + applyCount + ",审批节点数量: " + approveCount + ",执行节点数量: " + executeCount + " =="); } }可以看到,新增一个操作(新的访问者类)时,完全不需要修改任何节点类。原有节点类对扩展是开放的,对修改是封闭的。报表访问者甚至可以通过内部计数器,轻松汇总各种节点的数量信息。
4.4 组织者ObjectStructure:让客户端调用更加统一
为了让客户端代码更加优雅,我们再定义一个ObjectStructure:
public class WorkflowEngine { private List<ProcessNode> nodes = new ArrayList<>(); public void addNode(ProcessNode node) { nodes.add(node); } public void accept(WorkflowVisitor visitor) { for (ProcessNode node : nodes) { node.accept(visitor); } } }客户端的使用方式就能变得非常简洁:
WorkflowEngine engine = new WorkflowEngine(); engine.addNode(new ApplyNode("张三", 1500.0)); engine.addNode(new ApproveNode("李四", "同意")); engine.addNode(new ExecuteNode("执行完毕", "成功")); WorkflowVisitor logVisitor = new LogVisitor(); WorkflowVisitor reportVisitor = new ReportVisitor(); engine.accept(logVisitor); engine.accept(reportVisitor);所有节点遍历的逻辑都被封装在WorkflowEngine里,客户端既不需要维护列表,也不需要手动调用accept。后续如果新增一种节点类型,只需实现ProcessNode接口,然后同步在WorkflowVisitor接口中新增一个visit方法,所有实现类做对应改动。虽然这种改动会牵动所有访问者,但在“节点类型稳定”的前提下,这种成本是完全可控的。
我在实际项目中用这个模式处理过工单状态流转的多个操作,确实体会到日志、通知、统计这些操作分离后,节点类清爽很多。尤其是排查问题时,光看访问者类就能定位到对应逻辑,不需要在节点类里翻来翻去。
5. 访问者模式在真实项目中的应用场景
5.1 语法树与抽象语法树的遍历
访问者模式最经典的应用之一就是编译器与解析器。在编译原理中,抽象语法树的节点类型是相对固定的——表达式、声明、赋值、循环、条件分支等,基本上不会频繁变更。但编译器需要执行的操作却非常多:语法检查、类型推导、代码生成、优化、格式化输出……这些操作如果用if-else写在每个节点类里,节点类会被塞入大量毫不相关的逻辑,基本上不可能维护。
使用访问者模式后,语法树节点只关心自己的结构和语义,而各种编译阶段的操作都被拆分为独立的访问者。每新增一个编译阶段,只是新增一个Visitor;每个Visitor内部可以按节点类型精确处理自己关心的节点,完全不污染节点类的定义。很多开源工具和语言处理器都遵循类似的架构设计。
5.2 复杂对象结构的报表与统计
访问者模式处理复杂对象结构的统计报表非常得心应手。比如一个电商系统,需要从商品类目树中统计销量、库存、利润。商品类目树中节点种类可能包括“单独商品”和“类别容器”两种,如果你的统计口径经常变化,比如今天是“按品类汇总销量”,明天是“按品牌汇总利润”,后天是“按供应商统计成本”,访问者模式会让这些统计都变成独立的访问者。
遍历一棵树时,访问者还可以通过内部状态(比如一个累加器)记录局部结果,遍历结束后一次性得出统计结论。对于团队来说,加一种统计就是加一个类,不影响原有逻辑,代码的稳定性和可读性都很好。
5.3 复杂的UI组件树操作
UI框架中组件树也是典型的稳定结构。按钮、输入框、标签、容器这些组件的类型是固定的,但针对这些组件的操作却可以多种多样——渲染、事件绑定、数据校验、状态序列化、无障碍焦点遍历等。使用访问者模式可以把这些操作从UI组件类中剥离,新增一种操作时不会改动任何组件类,这在大型组件库的维护中非常关键。
有一点需要提醒:在UI框架里,组件树可能是动态变化的,如果组件类型会频繁扩展,那么访问者模式的优势就会被抵消。只有当组件类型稳定、操作频繁更替时,这种设计才划算。
6. 访问者模式的优缺点全景分析
6.1 优点:扩展性强、操作集中、职责分离
访问者模式最突出的优点就是符合开闭原则。在元素类型稳定的前提下,新增操作不需要修改已有元素类,而是新增访问者类。所有关联的操作逻辑都集中在一个访问者类中,而不是分散在各个元素类中。例如所有与报表相关的操作集中在ReportVisitor,所有日志相关的操作集中在LogVisitor,这在排查问题、统一修改策略时非常方便。
另一个优点是数据结构和操作彻底解耦。数据结构本身只关心自己是什么,不关心别人会拿它做什么。这样的元素类职责单一、简洁,不会因为不断叠加新能力而膨胀成“上帝类”。配合上ObjectStructure之后,客户端的调用代码也会非常简洁,遍历逻辑不用散落在各个调用方。
6.2 缺点:新增元素类型代价高昂、耦合度高
访问者模式最明显的问题,是新增元素类型时需要修改访问者接口和所有实现类。比如原有一个Visitor接口里有visit(A)和visit(B),现在新增一个C,那么所有实现Visitor的类都必须增加visit(C)。这违背了开闭原则对“扩展开放”的定义——虽然对新增操作是开放的,但对新增元素是关闭的。需要评估清楚:你的系统中元素类型变更的频率高还是操作类型变更的频率高。
另一个缺点是访问者模式会增加系统的复杂性。你需要维护Element、ConcreteElement、Visitor、ConcreteVisitor、ObjectStructure等一系列类。对于小规模项目来说,这种模式带来的结构复杂度可能让人望而生畏。而且,访问者若要访问元素的一些内部状态,可能需要调用这些内部状态的getter,导致一定的封装性被破坏。如果元素内部信息敏感,应用这个模式时要格外小心。
还有一个容易忽略的问题:如果元素层级中存在组合关系,访问者可能需要自己编写递归遍历逻辑。例如树结构,Visit一个容器节点时,通常需要在visit方法里遍历其子节点,这要求访问者内部自己实现递归逻辑。一旦漏掉子节点,统计或处理就会不完整,这需要开发者对遍历逻辑有清晰的把控。
7. 常见问题、排查方法与踩坑实录
7.1 编译器报错“找不到visit(Element)”:多半是重载绑定问题
这个问题在初学者里非常常见。他们会在客户端这样写:
Visitor visitor = new XXXVisitor(); Element element = new ConcreteElementA(); visitor.visit(element); // 编译不过这里的原因是元素变量的静态类型是Element,而Visitor接口中只有visit(ConcreteElementA),没有visit(Element),所以编译器找不到匹配的方法。解决办法有两种:要么将element强转为ConcreteElementA再调用;要么使用标准的访问者模式,调用element.accept(visitor),让动态分派发挥作用。
需要说明的是,这个编译错误是访问者模式设计的必然结果。如果你真的在业务中遇到了这个错误,不要尝试在Visitor接口里加visit(Element)方法,新增一个通用的visit方法会给所有访问者实现增加负担——几乎所有的访问者方法都会命中这个公共方法,导致逻辑分派混乱。
7.2 访问者内部无法访问元素私有属性:破坏封装问题
在实际项目中,访问者通常需要读取元素对象的内部数据来完成处理。比如LogVisitor需要读取ApplyNode的amount字段,如果该字段是private且没有提供getter,访问者就无法直接获取。常见的处理方式是为这些字段提供公开的getter方法,但这会增加元素类暴露的接口面。
另一种做法是让元素在accept方法内部传入一个“数据载体”对象,由元素负责拷贝与自身相关的信息给访问者。这种做法可以在一定程度上缓解封装被破坏的问题,但也增加了元素类的额外工作。我个人的经验是:如果元素内部字段非常敏感,而且团队对访问者的权限控制有要求,就需要在接口设计阶段就约定好信息暴露的边界,不要等到后期才补救。
7.3 递归遍历时漏处理子节点:结果不完整
使用访问者模式遍历树形结构时,如果访问者在visit某个节点时忘记遍历其子节点,会导致部分节点没有被处理,统计结果自然出错。这个问题不易察觉,因为编译器不会报错,只有在运行结果不合理时才能发现。
我建议在ObjectStructure的遍历逻辑中统一使用递归,不要在访问者内部自行递归。这样所有节点都能被遍历到,访问者只需要关心“如何处理单个节点”,不用关心“如何遍历整棵树”,难度和出错率都会大幅降低。
7.4 快速问题排查速查表
| 常见现象 | 可能原因 | 排查思路与解决建议 |
|---|---|---|
| 编译报错,找不到visit方法 | 调用时使用了静态类型为父类的变量 | 改用element.accept(visitor)模式,而不是直接调用visitor.visit(element) |
| 新增元素类型后大量类报错 | Visitor接口新增了方法未在实现类中实现 | 接口变更属于正常代价,但需评估变更频率,若频繁变更建议换设计思路 |
| 访问者里无法拿到元素私有字段 | 元素没有提供公开访问方法 | 为元素增加针对性getter,或使用数据载体方式传递 |
| 树结构部分节点未被处理 | 访问者递归逻辑不完备 | 改为在ObjectStructure统一递归,不要在访问者内递归 |
| 出现ClassCastException | 手写强转来匹配类型 | 避免强转,使用accept方法触发动态分派 |
7.5 我在实际项目里踩过的坑
最开始用访问者模式时,我把遍历逻辑直接写进了访问者内部的visit方法,导致一个报表Visitor代码量巨大,而且每处理一种新容器节点都要写一次遍历子节点的代码。后来把遍历逻辑统一收敛到ObjectStructure的循环中后,代码量少了一半,排查问题时也再也没出现过“漏节点”的情况。
另一个让我印象深刻的教训是:不要为了“为了用模式而用模式”。访问者模式的结构决定了它是一种“补充手段”,而不是“默认方案”。如果你的项目里只有两三种元素类型,而且操作也就一两类,直接写普通方法完全没有问题。模式的价值在于让扩展变得容易,而不是让简单的事情变得复杂。
8. 访问者模式与其他设计模式的对比及选型建议
8.1 与策略模式的对比:二者何时互换
策略模式和访问者模式在结构上有点相似,都是将算法封装到独立类中。但二者的本质区别在于:策略模式中的算法是针对同一类型的不同策略,而访问者模式中的操作是针对不同类型的不同处理方案。
举个例子,如果只有一种节点类型ApplyNode,却有多种处理策略,那就应该用策略模式。如果节点类型有多种,每种节点都有各自不同的处理逻辑,而且这种处理逻辑还在不断变化,那就更适合访问者模式。在策略模式里,上下文通常持有策略对象;在访问者模式里,对象结构持有元素对象,访问者从外部进入。可以说访问者模式是策略模式在“多类型对象结构”上的延伸。
8.2 与迭代器模式的配合关系
迭代器模式负责按顺序访问聚合对象中的元素,但它不关心每个元素具体做什么操作。访问者模式关注的是对元素的精确处理。两者可以完美配合:ObjectStructure内部使用迭代器遍历元素,并把每个元素传递给访问者。实际上,很多框架把迭代逻辑和访问者逻辑结合在了一起,让客户端完全感知不到遍历的细节。
在Java的集合框架中,Iterable和Iterator几乎无处不在。把这些机制与访问者模式结合,你可以构建一个模板化的遍历框架,让所有访问者都只需要关心“单个元素的处理逻辑”。这也是我项目里最常用的组合方式,值得实践。
8.3 四个维度的选型自检清单
| 判断维度 | 适合访问者模式的信号 | 不适合访问者模式的信号 |
|---|---|---|
| 元素类型稳定性 | 类型品种少且长期不变 | 类型不断新增或调整 |
| 操作变化频率 | 频繁增加新操作 | 操作固定,几乎不变 |
| 对象结构复杂度 | 树形结构、组合结构为主 | 简单扁平结构 |
| 内部状态暴露 | 各节点暴露必要getter后可接受 | 各节点封装性要求极高 |
如果你在设计阶段对上述信号有过犹豫,可以把访问者模式的应用范围先限定在一个最小的模块中试点。不要一上来就全局铺开,待验证可行后再逐步推开,这个节奏我在多个项目中试过,反馈都不错。
9. 工程最佳实践与实际扩展建议
9.1 保持访问者接口的稳定,避免兼容性灾难
访问者接口是模式的核心契约,一般情况下不应频繁改动。当你预判到未来可能新增元素类型时,可以在访问者接口中预留一个默认实现方法(JDK8+的default方法),这样既不会破坏已有实现,也能让新增类型的过渡变得平滑。例如:
default void visit(NewNode node) { // 默认实现,可以留空或打印提醒 }这样现有访问者不会因为接口新增方法而编译失败。不过要提醒一下,使用默认方法会导致某些访问者“忘记”实现新节点逻辑,运行时静默跳过,需要团队在代码审查时格外注意这一点。
9.2 结合函数式接口,简化单方法访问者
如果你的访问者只需要处理一种类型的元素,完全没必要定义一个完整的访问者类——直接用Lambda表达式可能更简洁。比如Visitor接口是一个函数式接口,只有visit(ApplyNode node)一个方法,那客户端就可以这样写:
engine.accept(node -> System.out.println(node.getName()));但这样做会失去访问者模式多类型分派的意义。只在元素类型非常单一时使用这种方式,否则还是老老实实写完整类比较好。
9.3 树形结构中的递归遍历收敛策略
针对树形结构,我推荐把遍历逻辑放在ObjectStructure内部,而不是放在访问者内部,既避免了访问者处理递归时的错误,又可以让访问者保持简洁。有一种更好的做法是增加一个“按节点类型遍历”的容器方法,让客户端指定只处理某一种类型的节点,进一步减少无谓的visit调用。
如果你的元素层级是动态可变的,比如组织架构会频繁调整树的结构,访问者模式的固定类型设计就会变得吃力。此时不妨考虑用一种基于事件驱动的框架来替代访问者模式——它更灵活,但也带来更复杂的状态管理。每种选择都有取舍,关键还是要结合业务现状来判断。
9.4 一种有趣的变体:组合模式里的访问者改造
访问者模式可以顺理成章地与组合模式结合。组合模式定义了部件与容器的树状结构,访问者模式则负责对这些结构进行统一操作。以文件系统为例,文件与文件夹构成的树形结构非常适合访问者模式:文件是叶子节点,文件夹是容器节点。打印目录树、计算文件夹总大小、搜索文件等都能作为独立的访问者实现。
组合模式提供稳定结构,访问者模式提供多变的操作,两者组合后的结构非常优美。唯一要注意的是,组合模式中的子节点遍历逻辑最好交给容器节点内部的accept,或者由访问者统一编写。如果两边都做了遍历,会出现节点被重复处理或漏处理的尴尬局面。
10. 最后的经验分享
访问者模式让我深刻体会到一件事:设计模式的“好坏”往往不取决于模式本身,而取决于使用的场景和时机。它把一个很困难的问题——在不修改数据结构的前提下扩展操作——通过双分派机制解决了。但代价是元素类型的稳定性。凡是能平衡好这个代价的场景,它都会给你带来巨大的维护便利。
我在工单系统里的那次重构,让我彻底理解了一个道理:如果一个类的方法反复因为“新的操作需求”而修改,你就该考虑把这些操作搬出去了。访问者模式不是唯一的选择,但它是应对“稳定结构+多变操作”最优雅的方案之一。
如果你现在正被一堆if-else类型判断困扰,或者你的实体类里堆满了各种操作逻辑,不妨试着用访问者模式重新审视一下你的代码结构。先把元素类型和操作列表列出来,判断它们各自的变化频率,如果操作经常变化,元素类型却很少新增,那么访问者模式值得一试。依据我的经验,十有八九你能获得比现在更清晰、更易维护的代码。