聊设计模式聊到第四篇,还能点进来的,基本都是真有耐心把这件事啃完的人了。行为型模式一共十一个,前面三篇把策略、模板方法、观察者、状态、责任链、命令、迭代器这些"日用款"讲得差不多了,剩到最后的这四位,恰恰是知名度很高、但日常开发里真正敢用、会用的人很少的"硬骨头":解释器、中介者、备忘录、访问者。
为什么说它们是硬骨头?因为它们解决的从来不是"某一段代码怎么写"的问题,而是"代码结构怎么组织才能扛住变化"的问题。解释器管的是"把表达交给一门小语言",中介者管的是"把交互收拢到中心",备忘录管的是"把状态变成可回退的时间线",访问者管的是"在对象结构稳定的前提下,让操作自由生长"。理念单独拎出来都挺漂亮,但在真实项目里,这四位被误用的概率和被记住的概率一样高。
这一篇我换个聊法,不按教科书一条条念定义,每个模式都从我实际遇到过或者最常见到的场景切入,把结构、代码、坑位、替代思路放在一起说。示例代码用 Java 写,但思路是通用的,看完想抄到 C++ 或者 Python 项目里也没什么障碍。
1. 先说一句:这一篇是行为型模式里最容易被翻白眼的一批
关于本系列的阅读顺序,我还是建议如果没有看过前三篇,先回去把策略、模板方法、观察者、状态、责任链、命令、迭代器过一遍。那七个模式是行为型里真正的高频主力,日常编码里遇到"行为需要切换"、"流程骨架不变但要留扩展点"、"一处的变化要通知多处"这类需求,它们基本是第一反应。而今天这四个,属于"低频高价值"选手——大部分时间你根本用不上它们,可一旦场景对上了,它们往往是唯一优雅的解。
我见过很多开发者对这四位的态度分两派:一派是学的时候觉得太抽象,干脆跳过;另一派是学完特别兴奋,到处找地方硬套,把代码搞得很复杂。这四篇内容最大的价值,就是帮这两种人都踩一脚刹车:前者可以通过几个贴近业务的例子真正理解它们,后者则需要看清楚它们的适用边界和真实代价。
先给四个模式一人一句话定个性,后面展开的时候你心里有个锚点:
- 解释器模式:把规则写进字符串,让系统长出一门小语言。
- 中介者模式:谁也别直接找谁,有事儿都找我,我来传递。
- 备忘录模式:给对象的状态拍快照,后悔了随时回退。
- 访问者模式:数据结构懒得动,想让新操作自己送上门来。
这四个模式还有一个共同点,它们都体现了"封装变化"这个设计模式的底层思想,但是各自封装的变化点完全不同:解释器封装的是一门语言的文法变化,中介者封装的是对象之间的交互关系变化,备忘录封装的是状态的时间维度变化,访问者封装的是作用于对象结构上的操作变化。记住这个角度,后面选型的时候会非常有用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 解释器模式:给自己造一门"小语言",但别上头
2.1 一个真实会遇到的场景:动态计算表达式
先别急着想编译器或编程语言,那都是解释器模式的大号应用。真正让这个模式在业务代码里露脸的,往往是下面这种需求:
运营后台要做一个灵活的优惠规则配置,产品经理提的需求是"满300减50,但是排除生鲜类目,同时叠加会员95折"。这块规则如果写死在代码里,每次调整都要发版,运营受不了。更合理的做法是,把规则当作一段字符串存在数据库或者配置中心里,运行时解析执行。于是系统需要支持类似这样的表达式:
code复制(order.amount >= 300 && order.category != "fresh")
? order.amount - 50
: order.amount
或者更简单一点,报表系统里经常见到的时间筛选条件,比如 last30d && region=east。它们本质上都是"为某一小片领域专门设计出来的微型语言"。你为这种微型语言写的解析和执行器,就是在用解释器模式。
我最早接触这个模式,是做游戏里的战斗数值系统。策划需要配置技能伤害,又不希望每次调数值都找开发改代码,于是搞了一套允许写 atk * 1.2 + def * 0.3 这种公式的配置表。那一整个模块,核心就是解释器。
2.2 解释器模式的标准骨架:表达式树与递归求值
理解解释器模式,最关键的一步是理解"表达式树"。拿数学表达式来说,2 * (3 + 4) 可以画成一棵树,根是 *,左孩子是数字 2,右孩子是 +,+ 的左右孩子分别是数字 3 和 4。如果把这棵树落到代码里,就是一个个对象:
- 叶子节点:数字、变量
- 非叶子节点:运算符,它们持有一棵或两棵子树
解释器模式就是把这棵树表达成一套类结构,然后让每个节点都实现同一个 interpret 方法。求值的时候从根节点开始递归,每个运算符节点把自己的子树算完,再组合出结果。
类结构也很标准:
AbstractExpression(抽象表达式):定义interpret接口TerminalExpression(终结符表达式):树上的叶子,比如数字、变量NonterminalExpression(非终结符表达式):树上的分支节点,比如加法、乘法Context(上下文):解释时需要的全局信息,最常见的就是变量表
想一想,这其实和递归算一棵树没有本质区别。你在算法题里写过二叉树的后序遍历,那么解释器的求值过程对你而言就已经不陌生了——先算左右子树,再算自己。
2.3 完整代码示例:一个支持变量与四则运算的小解释器
直接上一段可以跑的代码,支持变量查值和加减乘除。语法不完整,没有括号,但可以把解释器模式的骨架完整体会到。
先定义抽象表达式:
java复制import java.util.Map;
public interface Expression {
int interpret(Map<String, Integer> context);
}
然后是终结符,对应数字:
java复制public class NumberExpression implements Expression {
private final int value;
public NumberExpression(int value) {
this.value = value;
}
@Override
public int interpret(Map<String, Integer> context) {
return value;
}
}
对应变量:
java复制public class VariableExpression implements Expression {
private final String name;
public VariableExpression(String name) {
this.name = name;
}
@Override
public int interpret(Map<String, Integer> context) {
if (!context.containsKey(name)) {
throw new IllegalArgumentException("未定义的变量: " + name);
}
return context.get(name);
}
}
接下来是加法,非终结符的代表:
java复制public class AddExpression implements Expression {
private final Expression left;
private final Expression right;
public AddExpression(Expression left, Expression right) {
this.left = left;
this.right = right;
}
@Override
public int interpret(Map<String, Integer> context) {
return left.interpret(context) + right.interpret(context);
}
}
减法、乘法、除法结构完全一样,只是 interpret 里的运算符号不同。除法这里,记得在解释时做一下除零保护:
java复制public class DivideExpression implements Expression {
private final Expression left;
private final Expression right;
public DivideExpression(Expression left, Expression right) {
this.left = left;
this.right = right;
}
@Override
public int interpret(Map<String, Integer> context) {
int divisor = right.interpret(context);
if (divisor == 0) {
throw new ArithmeticException("除零错误");
}
return left.interpret(context) / divisor;
}
}
测试一下,表达式 (a + b) * 2 - c / 4,手动把这棵语法树搭出来:
java复制import java.util.HashMap;
import java.util.Map;
public class InterpreterDemo {
public static void main(String[] args) {
Map<String, Integer> context = new HashMap<>();
context.put("a", 10);
context.put("b", 6);
context.put("c", 8);
// (a + b) * 2 - c / 4
Expression expression = new SubtractExpression(
new MultiplyExpression(
new AddExpression(
new VariableExpression("a"),
new VariableExpression("b")),
new NumberExpression(2)),
new DivideExpression(
new VariableExpression("c"),
new NumberExpression(4)));
int result = expression.interpret(context);
System.out.println(result); // 30
}
}
跑一下,输出 30。这里的要点是:语法树的构造和业务没有关系,它只负责表达"规则是什么";变量表 context 负责提供"当前环境下的值"。如果运营改规则,我们只需要改变这棵树的构造逻辑,或者干脆写一个 Parser 把字符串解析成这棵树。字符串解析属于编译原理的内容,这里不展开,但你可以把它看成"把 (a + b) * 2 - c / 4 这串字符,通过递归下降拆成上面这棵树"。
2.4 解释器模式的代价与替代方案
解释器模式看起来巧妙,实际上代价非常大,我这些年见过太多在项目里把它用崩的,所以必须认真说说。
首先,类数量爆炸。每增加一种语法规则,就要增加一个类。加减乘除只是最基础的四种运算,如果再加上括号、一元负号、比较、逻辑与或非,类的数量会迅速增长。小语言一旦发展到几十上百条规则,维护这几十个类本身就是个噩梦。
其次是调试困难。解释器把业务规则抽象成了对象树,报错信息往往是一个很深的调用栈,而不是"配置的第5行有语法错误"。业务方配置出错的时候,他看不到这棵树,也没法通过肉眼排查,这是解释器在业务系统里落地时最大的隐性成本。
第三个问题是性能。每个表达式的解释都是一次递归遍历,如果配置的规则本身很复杂、调用量又大,解释器模式很容易成为性能热点。我在一个报表筛选系统里就见过,一个 last90d && region=... && channel=... 的组合查询被反复解释执行,后来只能把表达式预编译成语法树缓存起来,才勉强压下去。
所以工程上,真正的通用规则引擎很少从零写解释器。优先考虑几个替代方向:
- 如果只是表达式求值,引入成熟的表达式引擎,比如 Aviator、Drools、ANTLR 生成的解析器,站在别人肩膀上。
- 如果规则比较复杂,考虑把表达式源先解析成抽象语法树(AST),这棵树用组合模式保存,求值的时候再用 Visitor(访问者模式)去遍历。AST + Visitor 的组合,比纯解释器模式更抗折腾。
- 尽量设计一个"预编译 + 缓存"的机制,不要让同一条规则每次都重新解析。
那什么时候该用解释器模式呢?我的判断标准有三个:语法规则极少变化、表达式数量可控、业务方有强烈的自主配置诉求。满足这三条,自己写一个 100 到 200 行的小解释器是完全合理的,比如某个特定领域内固定的 DSL,JSONPath 的解析,模板引擎的占位符语法。一旦规则规模开始膨胀,第一件事就是换方案,别硬撑。
3. 中介者模式:别让每个对象都认识所有同事
3.1 从一次智能家居联动说起的耦合困境
假设你在做一个智能家居控制 app,场景是这样的:门铃响了,客厅灯要自动亮,空调要自动切到节能模式,同时智能门锁要记录一条日志。最直观的写法是什么?DoorBell 直接依赖 Light、AirConditioner、Lock,在 ring() 方法里挨个调用。刚开始只有四五个设备,问题不大。等设备变成二十个——门铃、摄像头、温度计、加湿器、窗帘、扫地机、各种传感器——每个设备都可能在某个事件发生时需要联动其他设备,你的代码会迅速变成一张蜘蛛网。
设备 A 要知道设备 B、C、D 的存在,设备 B 又知道 A、D、E、F 的存在。任何新增设备,都要去改所有和它有关联的设备类,牵一发动全身。这张蜘蛛网,就是"多个对象之间存在复杂交互"的典型症状。
中介者模式给的治疗方案是:禁止对象之间直接打招呼,所有交互都通过一个中心对象来转达。设备之间互相不知道对方的存在,它们只认识中介者,这也符合那句经典的话——"别找我,找中介"。
3.2 中介者的核心结构:同事、中介者接口与具体中介者
结构上分三个部分:
Mediator(中介者接口):定义对象之间通信的方法,比如notify(sender, event)ConcreteMediator(具体中介者):实现具体的协调逻辑,知道有哪些同事,事件来了怎么安排Colleague(同事类):业务对象,持有中介者的引用,发生状态变化时通知中介者
这里的核心取舍是:把"谁和谁交互、交互顺序是什么"的逻辑从各个同事类中抽离出来,集中到中介者里。同事类只负责一件事——我自己的状态变了,我告诉中介者;中介者决定接下来该通知谁、该做什么。
这一层抽离带来的好处非常直接:新增一个设备时,只需要修改中介者,让它知道有这么一个新设备可以联动;原有的所有设备类一行都不用动。对象之间的依赖从 M 对 N 变成 M 对 1 加 1 对 N,维护成本降了不止一个量级。
3.3 代码示例:一个最小可用的家居联动中介者
用一个简化但不失完整的例子说明。定义一个设备接口,同时定义中介者接口:
java复制public interface Device {
void setMediator(HomeMediator mediator);
String name();
}
public interface HomeMediator {
void notify(Device sender, String event);
}
设备实现类,注意设备之间完全不引用对方,只通过中介者发通知。门铃类:
java复制public class DoorBell implements Device {
private HomeMediator mediator;
@Override
public void setMediator(HomeMediator mediator) {
this.mediator = mediator;
}
@Override
public String name() {
return "DoorBell";
}
public void ring() {
System.out.println("[DoorBell] 门铃响了");
mediator.notify(this, "ring");
}
}
灯和空调类似,它们自己关心的事件由中介者来通知:
java复制public class Light implements Device {
private HomeMediator mediator;
@Override
public void setMediator(HomeMediator mediator) {
this.mediator = mediator;
}
@Override
public String name() {
return "Light";
}
public void turnOn() {
System.out.println("[Light] 开灯");
}
}
public class AirConditioner implements Device {
private HomeMediator mediator;
@Override
public void setMediator(HomeMediator mediator) {
this.mediator = mediator;
}
@Override
public String name() {
return "AirConditioner";
}
public void energySavingMode() {
System.out.println("[AirConditioner] 切换到节能模式");
}
}
关键的中介者实现:
java复制public class SmartHomeMediator implements HomeMediator {
private DoorBell bell;
private Light light;
private AirConditioner ac;
public void setBell(DoorBell bell) {
this.bell = bell;
bell.setMediator(this);
}
public void setLight(Light light) {
this.light = light;
light.setMediator(this);
}
public void setAc(AirConditioner ac) {
this.ac = ac;
ac.setMediator(this);
}
@Override
public void notify(Device sender, String event) {
if ("ring".equals(event)) {
System.out.println("[Mediator] 收到门铃声,开始联动");
light.turnOn();
ac.energySavingMode();
}
}
}
演示:
java复制public class MediatorDemo {
public static void main(String[] args) {
SmartHomeMediator mediator = new SmartHomeMediator();
DoorBell bell = new DoorBell();
Light light = new Light();
AirConditioner ac = new AirConditioner();
mediator.setBell(bell);
mediator.setLight(light);
mediator.setAc(ac);
// 按门铃,所有联动都由中介者统一调度
bell.ring();
}
}
输出:
code复制[DoorBell] 门铃响了
[Mediator] 收到门铃声,开始联动
[Light] 开灯
[AirConditioner] 切换到节能模式
这段代码里,如果你再加一个摄像头、窗帘,唯一改的地方就是 SmartHomeMediator,设备类全都原封不动。这就是中介者模式的核心收益。
3.4 中介者模式真正的坑:变成上帝对象
中介者模式用不好的时候,会出现一个比蜘蛛网更糟糕的情况:中介者变成上帝对象。
当交互逻辑全部收拢到中介者之后,它会逐渐膨胀。今天加一个设备联动,明天加一个事件流转,后天加一个特例判断,很快这个类就上千行了。它知道所有设备、管理所有调用顺序、包含了所有业务规则,变成整个系统里最复杂、最难测试、最容易出 bug 的类。原始的多对多耦合,变成了"大家都依赖一个巨型类",耦合并没有消失,只是换了个位置。
我自己的经验是,中介者模式要配合三个原则来用:
第一,中介者内部按业务域拆分,不要一个中介者管天下。家居联动拆成"安防联动"、"舒适度联动"、"能耗联动"等多个小中介者,各自管自己的一摊事。
第二,中介者的方法尽量收敛,别暴露所有内部状态。每次通知都定义清晰的入参和返回,别让中介者变成谁都能摸一把的公共变量池。
第三,谨慎地考虑事件总线或发布订阅模型。如果交互本质上只是"事件发生 -> 若干订阅者响应",不涉及复杂的流程编排和状态流转,那么观察者模式或者一个成熟的消息中间件比中介者更轻、更合适。中介者的优势在于它对交互过程有完全的控制权——这意味着它也一样容易变成中心化的瓶颈。
3.5 中介者与观察者的边界在哪里
这两个模式经常被拿来对比,因为它们解决的问题确实接近:都是处理多对象之间的通信。但它们的出发点和适用场景有明显区别。
观察者模式是 1 对 N,一个被观察对象的状态变化通知所有订阅者,订阅者之间互相不知道。中介者模式是 M 对 N 但是通过中心协调,中介者知道所有同事,也决定通信的逻辑。
如果业务只是一对多、单纯广播,用观察者;如果业务是多方之间来回周旋,比如 A 触发 B,B 的结果又触发 C,C 反过来影响 A,这种交互流程本身就是业务逻辑的一部分,用中介者更合适。
对了,还记得系统设计里的消息队列吗?那也是"中介者"思想的一种大规模应用。生产者不关心消费者是谁,消费者也不关心消息从哪来,中间挂一个 Broker 把双方解耦。设计模式里的中介者,就是消息中间件在单机面向对象代码里的微缩版。
4. 备忘录模式:把状态变成一条可回退的时间线
4.1 从编辑器撤销谈起:为什么撤回不是删掉最后一个字符
用编辑器写文档的时候按 Ctrl+Z,系统就像时光倒流一样回到上一步。实现撤销功能,第一反应可能是"记录每一步的操作,然后逆操作",比如记录"用户删了一个字符",撤销时再插回去。这其实是对命令模式的应用思路,如果你的操作列表足够简单,这也行得通。
但在很多场景里,记录操作本身比想象中复杂得多。一个文档可能同时被格式化、批量替换、脚本处理,操作之间的依赖非常强,单靠逆操作很难保证状态完全还原。这时候就需要一条更朴素的路:不管中间做了什么,每一步都把自己的完整状态拍个快照存下来。撤销时,直接把状态恢复成某个历史快照就行了。这,就是备忘录模式。
这个概念放到项目里,比编辑器更常见的场景是:游戏存档、流程引擎中的流程实例快照、配置中心回滚、表单草稿自动保存。你会发现它们的共同点都是我说的那句——把状态变成一条可回退的时间线。
4.2 备忘录的三个角色与一个关键细节:谁有权访问快照
标准的备忘录模式有三个角色:
Originator(发起人):需要被保存状态的对象,它能生成备忘录,也能从备忘录恢复Memento(备忘录):保存发起人某一时刻状态的不可变对象Caretaker(负责人):持有备忘录,但永远不修改备忘录的内容,只在需要时把备忘录交还给发起人恢复
这里有一个经常被忽略、但在真实工程里非常重要的细节——备忘录的访问权限控制。
一种实现是"白箱",备忘录把状态字段完全公开,Caretaker 可以直接读,但这破坏了封装,任何持有备忘录的人都能修改历史快照,在多人协作里这几乎等于埋雷。另一种是"黑箱",备忘录对 Caretaker 完全黑盒,只有 Originator 自己才能读写其内部状态。Java 里最自然的做法就是用嵌套类,把备忘录定义为 Originator 的 private static 内部类,这样外部类根本无法直接访问它的字段。
4.3 代码示例:带历史栈的文本编辑器
写一个带撤销功能的文本编辑器,重点看嵌套类怎么保护备忘录的封装性。
发起人:
java复制public class TextEditor {
private StringBuilder content = new StringBuilder();
public void write(String text) {
content.append(text);
}
public void deleteLast(int n) {
int len = content.length();
if (n > len) {
n = len;
}
content.delete(len - n, len);
}
public String getContent() {
return content.toString();
}
// 生成快照
public Memento save() {
return new Memento(content.toString());
}
// 从快照恢复
public void restore(Memento memento) {
this.content = new StringBuilder(memento.content);
}
// 备忘录使用 static 嵌套类,构造和读取方法都是 private
// 外部类 TextEditor 可以访问它的私有成员,Caretaker 则不能
public static class Memento {
private final String content;
private Memento(String content) {
this.content = content;
}
private String getContent() {
return content;
}
}
}
负责人,只负责保存和弹出快照:
java复制import java.util.ArrayDeque;
import java.util.Deque;
public class History {
private final Deque<TextEditor.Memento> undoStack = new ArrayDeque<>();
public void push(TextEditor.Memento memento) {
undoStack.push(memento);
}
public TextEditor.Memento pop() {
return undoStack.isEmpty() ? null : undoStack.pop();
}
}
演示,注意快照的保存时机:
java复制public class MementoDemo {
public static void main(String[] args) {
TextEditor editor = new TextEditor();
History history = new History();
// 第一次输入,保存快照
editor.write("Hello ");
history.push(editor.save());
// 第二次输入,不保存快照
editor.write("World");
System.out.println(editor.getContent()); // Hello World
// 撤销:恢复到上一次保存的快照
editor.restore(history.pop());
System.out.println(editor.getContent()); // Hello
}
}
这段代码最需要注意的地方是 push 的时机。真实的编辑器,通常是在每次修改之前把修改前的状态压栈,或者每次修改之后把修改后的状态压栈,两种策略对应的撤销语义略有区别。上面演示的是"每次操作后保存",也就是说恢复的时候回到的是上一个操作完成后的状态。如果你想要更细粒度的回退,可以考虑"每个字符的输入都视为一次操作",但那样内存开销会很大,所以在实现里往往会按操作批次来存,而不是按字符存。
另外有一个非常隐蔽的坑:restore 的时候必须用 new StringBuilder(memento.content) 重新构造,否则历史快照引用了可变对象,后面 editor.write() 会把已经入栈的快照内容也一起改了。这就是备忘录和可变对象之间最常见的坑,我在实际项目里踩过一次之后,快照对象里的字段一律只允许不可变类型,或者拷贝一份再存。
4.4 快照的内存难题与工程替代方案
备忘录模式的痛点不用多说,就是内存。文档越大,快照越占内存,撤销历史保存几百步,内存轻松爆炸。实际工程里的应对策略大概有这么几层:
- 一是压缩快照。编辑器类应用很少每步都存全量,而是存差异块,每若干步生成一个完整基线,中间步骤存增量。
- 二是限制历史深度,超过 N 步的旧快照直接丢掉。
- 三是延迟序列化。快照对象只在内存里保留引用,内容写入临时文件或者数据库,回收时靠 GC 兜底,而不是撑在 JVM 堆里。
如果你的系统需要的不只是"回到上一步",而是"了解每一步到底发生了什么、谁改了什么",那么备忘录就有点不够用了,事件溯源(Event Sourcing)是更合适的思路。它不保存状态快照,只保存一系列不可变事件,当前状态由事件序列重放得到。这样既支持回退到任意时间点,又天然自带审计日志。代价是重放有成本、模型更复杂、对系统设计的要求也更高。所以我现在遇到"回滚需求"时,会先问一句:只是状态退回,还是要审计追溯?前者备忘录够用,后者直接上事件溯源。
备忘录模式很多时候不是一个显眼的主角,它更像一个慢变量。游戏里的自动存档、表单编辑器的草稿恢复、流程引擎里每个实例的 checkpoint,都是它。它做的事情很朴素,但"后悔药"这个能力,在产品体验上往往是决定性的一环。
5. 访问者模式:当数据稳定、操作多变时,换一种写法
5.1 双分派才是访问者模式的灵魂
访问者模式是行为型模式里公认最难懂的一个,难点不在代码量,而在它背后的"双分派"概念。
先理解什么是单分派。Java、C++、Python 这类主流面向对象语言里,调用一个虚函数,具体执行哪个实现,由对象的运行时类型决定。这就是一次动态分派,比如 Animal a = new Dog(); a.speak();,实际执行的是 Dog.speak。
但如果你有两个对象参与——比如要执行 visitor.visit(node),最终执行哪个逻辑,既要看 visitor 的运行时类型,也要看 node 的运行时类型——大多数语言是不支持一次调用里做两次动态分派的。方法重载是在编译期根据静态类型决定的,动态绑定只会对调用者(visitor)做一次分派。
访问者模式的做法是用两次调用来模拟双分派:
- 先调用
node.accept(visitor),发生第一次动态分派,会定位到node真实类型对应的 accept 方法。 - 在
node.accept内部,又调用visitor.visit(this),此时this就是node的真实类型,由此发生了第二次动态分派。
两次分派合在一起,最终执行的 visit 方法就同时由节点类型和访问者类型共同决定。这就是访问者模式看起来绕,但确实有独到价值的原因。
5.2 代码示例:文件系统统计器,两种 Visitor 一口气加上
访问者的经典应用是编译器操作抽象语法树,但那个例子对不熟悉编译原理的人来说太劝退。换一个大家都有体感的例子:遍历一个文件目录树,统计文件类型数量和累计大小。
定义节点接口和两个实现,File 和 Directory。Directory 内部持有子节点列表,这就用到了组合模式的结构,但统计逻辑完全不写在节点里,而是通过 accept 转交给 Visitor:
java复制import java.util.ArrayList;
import java.util.List;
public interface FileSystemNode {
void accept(FileVisitor visitor);
}
public class File implements FileSystemNode {
private final String name;
private final long size;
private final String extension;
public File(String name, long size, String extension) {
this.name = name;
this.size = size;
this.extension = extension;
}
public String getName() { return name; }
public long getSize() { return size; }
public String getExtension() { return extension; }
@Override
public void accept(FileVisitor visitor) {
visitor.visit(this);
}
}
public class Directory implements FileSystemNode {
private final String name;
private final List<FileSystemNode> children = new ArrayList<>();
public Directory(String name) {
this.name = name;
}
public String getName() { return name; }
public void add(FileSystemNode node) {
children.add(node);
}
@Override
public void accept(FileVisitor visitor) {
visitor.visit(this);
// 关键:对子节点逐个 accept,形成递归遍历
for (FileSystemNode child : children) {
child.accept(visitor);
}
}
}
然后定义 Visitor 接口:
java复制public interface FileVisitor {
void visit(File file);
void visit(Directory directory);
}
现在来加第一个操作:统计每种扩展名文件的个数,同时统计目录数。
java复制import java.util.HashMap;
import java.util.Map;
public class TypeCountVisitor implements FileVisitor {
private final Map<String, Integer> countByType = new HashMap<>();
private int dirCount = 0;
@Override
public void visit(File file) {
String ext = file.getExtension().toLowerCase();
countByType.merge(ext, 1, Integer::sum);
}
@Override
public void visit(Directory directory) {
dirCount++;
}
public void printResult() {
System.out.println("目录数: " + dirCount);
countByType.forEach((ext, count) ->
System.out.println("." + ext + ": " + count + " 个"));
}
}
第二个操作:统计目录树总大小。Directory 本身不占空间,只累加文件大小:
java复制public class SizeVisitor implements FileVisitor {
private long totalSize = 0;
@Override
public void visit(File file) {
totalSize += file.getSize();
}
@Override
public void visit(Directory directory) {
// 目录本身不计算大小
}
public long getTotalSize() {
return totalSize;
}
}
写法上注意一个细节:Directory.accept 里要先 visitor.visit(this),再遍历子节点。先处理目录本身,再递归子级。在这个例子里顺序影响不大,但如果你以后要做"先统计后汇总"之类的逻辑,这个顺序就关键了。
跑一遍:
java复制public class VisitorDemo {
public static void main(String[] args) {
Directory root = new Directory("root");
Directory src = new Directory("src");
src.add(new File("Main.java", 2048, "java"));
src.add(new File("Util.java", 1024, "java"));
src.add(new File("README.md", 512, "md"));
root.add(src);
root.add(new File("build.gradle", 256, "gradle"));
TypeCountVisitor typeCountVisitor = new TypeCountVisitor();
root.accept(typeCountVisitor);
typeCountVisitor.printResult();
SizeVisitor sizeVisitor = new SizeVisitor();
root.accept(sizeVisitor);
System.out.println("总大小: " + sizeVisitor.getTotalSize() + " bytes");
}
}
输出:
code复制目录数: 2
.java: 2 个
.md: 1 个
.gradle: 1 个
总大小: 3840 bytes
现在体会一下访问者的妙处:加一个 SizeVisitor、TypeCountVisitor,甚至再加一个 NameSearchVisitor,核心节点类 File 和 Directory 一行都不用改。数据结构的类保持稳定,操作无限生长,这就是访问者模式的真正威力。
5.3 访问者模式的使用边界:什么时候用,什么时候千万别用
访问者模式有它完美的适用场景,也有它致命的软肋。
适用场景的特征非常清晰:对象结构非常稳定,新增元素类型的概率极低;而作用于结构上的操作经常变化。编译器就是最典型的例子,语法树的节点类型就那么几十种,基本不会变,但编译器要做的操作——类型检查、代码生成、优化、格式化——却在持续增加和完善。访问者模式让这些操作变成一个个独立的 Visitor 类,每个类聚焦一件事。
反过来,如果你面对的结构本身不稳定,经常要新增元素类型,那就千万别用访问者。因为每次新增一个元素类型,所有已有的 Visitor 接口都要加一个 visit(新增类型) 方法,所有 Visitor 实现类都要跟着改一遍。这比直接改数据结构的代价还大。判断方向很简单:结构稳定 + 操作多变,用访问者;操作稳定 + 结构多变,把方法写在对象结构内部或组合里,别用访问者。
另外还要提一个成本:为了让结构支持访问者,每个元素类都得加一个 accept(visitor) 方法。这等于是在数据结构里为"未来可能出现的操作"预留了接口,如果这个操作只有一两个,那根本没这个必要,直接给对象加方法就够了。
5.4 现代化语言里的替代品:模式匹配与函数式思维
时代在变,设计模式也在被语言能力替代或缩短。用 Kotlin、Scala、Java 17+(switch 表达式模式匹配)这类现代语言,访问者模式想解决的问题,很大一部分可以用模式匹配直接表达。
还是拿文件系统来说,如果语言支持模式匹配,你甚至不需要 accept(visitor) 这套结构,只需要一个函数,参数是节点,函数体里用模式匹配分别处理 Node.File 和 Node.Directory 就行。新增操作就是在文件里新增一个顶层函数,同样不改原始类。
所以现在我的建议是:在支持模式匹配的语言里,优先用模式匹配,代码更短、更清晰。访问者模式更多出现在老代码维护、需要保持 Java 8 兼容性、或者在框架/编译器内部这些"代码结构高度稳定、性能要求苛刻"的领域。它不该是日常第一选择,但当你遇到"结构稳定、操作频繁扩展"的场景时,知道了它,就比硬把一个个方法堆在元素类里优雅得多。
6. 把四个模式放回同一张桌子:选型与学习心得
四个模式都过完了,是时候放到一起横向看一眼。这四位解决的是四种完全不同的"变化",把它们放在一张表里看,选型的逻辑马上就清楚了:
| 模式 | 核心意图 | 封装的变化点 | 主要代价 | 典型常见误用 |
|---|---|---|---|---|
| 解释器 | 定义一门小语言表达规则 | 文法规则的扩展 | 类爆炸、性能损耗、调试困难 | 复杂场景硬写解释器,不用成熟引擎 |
| 中介者 | 集中管理对象间的复杂交互 | 对象间的交互关系和流程 | 中介者膨胀成上帝对象 | 简单一对多通信也非要用中介者 |
| 备忘录 | 捕获并恢复对象状态 | 状态在时间维度上的变化 | 快照内存大、深拷贝成本高 | 快照持有可变对象引用,历史被污染 |
| 访问者 | 在稳定结构上开放新操作 | 作用于结构的算法的扩展 | 新增元素类型时全访客改一遍 | 结构频繁变化还硬用,得不偿失 |
这张表里你注意到没有,每个模式都有它"封装的变"和"不变的壳":解释器封装的是规则文法,壳是表达式树和递归求值的骨架;中介者封装的是交互关系,壳是同事类和中介者接口的约定;备忘录封装的是状态存储与恢复方式,壳是发起人与负责人对快照的处理协议;访问者封装的是算法的扩展方式,壳是节点类和 accept 方法的稳定结构。判断一个场景适不适合用某个模式,本质上就是在问:这里要变的东西是不是正好是它能封装的?不变的东西是不是正好是它的壳?
这种"找到变化点、封装变化"的思路,恰恰就是设计模式最底层的设计思想。你在写代码的时候多问一句"这部分未来大概率会怎么变",选型就不会跑偏。
最后结合现在的技术热点再聊一点。最近多 Agent 架构很热,很多人讨论主从模式,把 subagent 当作另一种 tool 来调用。这个设计其实挺有意思,它在本质上做了一件事:把"能力不同的执行体"统一收敛成一种可调用接口,让上层开发者不需要关心它到底是一个子代理、一个外部工具还是一个普通函数。这种"统一交互入口、隐藏内部协调细节"的思路,你和中介者模式对照着看,会发现它们在抽象层面完全一致——中介者收的是对象间交互的复杂性,多 Agent 框架里的主从模式收的是异构执行体调度的复杂性。
用老设计模式的眼光去看新的系统设计,你会发现很多新概念底层就是那二十几个模式的变体或组合。这也是为什么到今天,设计模式依然值得认认真真啃一遍——它教给你的不是二十几个类图,而是一种识别"变化点"的思维方式。行为型模式这一整个系列读下来,贯穿始终的就是这句话:找到变化,封装它。
