1. 为什么还要研究“其它模式”
先把结论放在前面:GoF 23 种设计模式里,行为型模式占了 11 种,除了大家张口就来的观察者、策略、模板方法之外,剩下的那一批经常被人忽略,它们就是标题里说的“其它模式”。这批模式包括状态模式、备忘录模式、中介者模式、职责链模式、迭代器模式、解释器模式、访问者模式,有时候空对象模式也会被放进这个袋子里。
我见过太多人学设计模式,学完工厂、单例、观察者就觉得自己掌握了,结果一遇到稍微复杂一点的状态流转、多步骤审批、多算法切换、对象历史回滚,就开始疯狂写 if-else 和 switch-case,代码膨胀到连自己都不想看。而那些被忽略的“其它模式”,恰恰就是解决这类问题最优雅的工具。
我自己的经验是:这些模式单独看好像都有点“绕”,但放到真实业务里,它们的价值比那些高频模式更硬核。比如状态模式能根治状态判断的 if 地狱,中介者模式能避免多对象之间网状依赖,职责链模式天生适配审批流和中间件管道。这篇文章我会把这 8 个模式逐一拆开,结合 Java/C++ 的工程实践,把核心思路、代码骨架、选型对比、踩坑经验一次讲透。
这篇文章适合这几类人:准备面试的开发者(这些模式是面试高频追问点)、写业务代码但觉得 if-else 已经失控的人、正在做框架设计或中间件开发的人、还有被“设计模式大作业”折磨的学生。不管哪一类,读完之后你应该能回答三个问题:这个模式解决什么问题、为什么这样解决、什么时候千万别用它。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 行为型“其它模式”的整体分类与设计思路
2.1 这个“其它”里到底装了哪些模式
行为型模式的核心关注点是“对象之间的职责分配”和“算法交互”。观察者、策略、模板方法之所以被单列出去,是因为它们在日常开发中出现频率实在太高,以至于一提到行为型模式,很多人脑子里只有这三个。但 GoF 的书里,剩下这批同样是正式成员,它们按解决问题方向可以分成四类:
- 状态行为管理:状态模式(State)、备忘录模式(Memento)。一个管“当前状态下的行为切换”,一个管“状态的历史保存与恢复”。
- 对象间通信协调:中介者模式(Mediator)、职责链模式(Chain of Responsibility)。一个管“集中式协调”,一个管“链式传递处理”。
- 遍历与解析:迭代器模式(Iterator)、解释器模式(Interpreter)。一个管“集合的遍历方式”,一个管“自定义语法的解析执行”。
- 结构扩展与默认处理:访问者模式(Visitor)、空对象模式(Null Object)。一个管“在不修改类的前提下增加操作”,一个管“用默认对象消除空指针判断”。
这个分类不是学院派的死记硬背,而是我实际做架构设计时习惯用的分组方式。原因很简单:当你面对一个具体问题的时候,你不会想“我要用哪个 GoF 模式”,你想的是“我的问题属于哪一类”。状态混乱就找状态类模式,对象之间通信太乱就找协调类模式,遍历逻辑侵入业务就找迭代器,语法解析就找解释器。
2.2 这些模式的设计哲学:把变化封装到独立对象里
这 8 个模式——加上空对象模式——背后有一条统一的思路:找出变化点,把它封装成独立的对象或独立的方法,让原有类的结构不随之震荡。
拿状态模式举例。如果你在一个类里用 if-else 判断订单状态然后执行不同逻辑,新增一个状态意味着你要改这个类里所有的判断点,这是“面向修改开放”而不是“面向扩展开放”。状态模式的解法是把每个状态封装成独立类,状态类自己决定“下一个状态是谁”,这样新增状态就变成了新增类,原类代码一行都不用动。
再比如中介者模式。如果 5 个对象需要互相通信,直接互相持有引用的话就是 5×5 的网状依赖,改一个对象可能要牵连四个对象。中介者模式把通信行为集中到一个中介者对象里,对象之间不认识彼此,只认识中介者。这本质上是把“多对多”的关系重新建模成“多对一+一对多”,核心价值是降低耦合、减少编译期依赖。
理解了这条统一思路,你就不会再把设计模式当成需要背的分类目录,而是能看出它们都是“封装变化”这个原则在不同场景下的具体呈现。后面每个模式的拆解,我都会先讲它封装的变化是什么、代价是什么、什么时候不值得付这个代价。
2.3 什么时候值得用这些模式:成本与收益的朴素账
很多人学设计模式最大的毛病是“拿着锤子看什么都像钉子”。我想说一个反直觉的观点:大部分情况下,如果你的 if-else 只有两三个分支、改动频率一个月不到一次,那就别用状态模式,直接 if-else 反而更清晰。设计模式的本质是用“更多的类”和“更间接的调用”换取“更低的修改成本”和“更清晰的结构”,这是有成本的。
我自己的判断标准是三条:
- 分支是否真的会频繁扩展。状态模式适合状态数量多、转移规则复杂、经常要加新状态的场景。一个只有草稿→审核→通过三个状态的流程,用状态模式纯属过度设计。
- 耦合是否已经带来实际维护痛苦。如果你改一个对象要连带测试五个对象,或者对象之间互相持有引用已经出现循环依赖,这时候中介者模式才值得引入。
- 是否需要支持未来的扩展。访问者模式的好处是“加操作容易、加类型难”,如果你能预见到类型相对稳定、操作会持续增加,那它就很划算;反过来如果你连有哪些操作都不知道,访问者模式会把你锁死。
后面每个模式讲完,我都会再补一段“别用它”的场景。记住:设计模式不是奖状,是工具,工具用错了场合比不用更糟。
3. 状态模式与备忘录模式:状态管理的两个侧面
3.1 状态模式:把 if-else 状态判断重构成状态类流转
状态模式的核心意图是:允许一个对象在其内部状态改变时改变它的行为,对象看起来似乎修改了它的类。这句话的精华在最后一句——“看起来像换了一个类”。实际编码时,就是把状态相关的行为分散到各个具体状态类里,所有状态类实现同一个状态接口。
以一个非常经典的订单流程为例,状态包括待支付、已支付、已发货、已完成、已取消。用户操作可能是支付、发货、确认收货、取消订单。如果用 if-else 写,代码大概是:
java复制public class Order {
private String state;
public void pay() {
if ("PENDING_PAYMENT".equals(state)) {
System.out.println("支付成功,进入已支付状态");
state = "PAID";
} else if ("CANCELLED".equals(state)) {
System.out.println("已取消订单不能支付");
} else {
System.out.println("当前状态不支持支付");
}
}
// 发货、确认收货、取消订单... 每个方法都来一遍 if-else
}
这样写的问题不是不能跑,而是每来一个新状态(比如“退款中”“已关闭”),你就要打开 Order 类修改所有涉及状态判断的方法,漏改一处就是线上 bug,而且方法越长越容易漏。
用状态模式重构后的骨架是这样的:
java复制public interface OrderState {
void pay(OrderContext context);
void ship(OrderContext context);
void confirm(OrderContext context);
void cancel(OrderContext context);
}
public class PendingPaymentState implements OrderState {
@Override
public void pay(OrderContext context) {
System.out.println("支付成功");
context.setState(new PaidState());
}
@Override
public void ship(OrderContext context) {
System.out.println("请先支付");
}
// 其它操作实现...
}
public class OrderContext {
private OrderState state;
public OrderContext() {
this.state = new PendingPaymentState();
}
public void setState(OrderState state) {
this.state = state;
}
public void pay() { state.pay(this); }
public void ship() { state.ship(this); }
}
这个重构的关键点有两个。第一,状态流转逻辑被内聚到了每个状态类自己的方法里,比如 PaidState.ship() 里直接让 context 切换到 ShippedState。第二,调用方根本感知不到状态的存在,OrderContext 对外暴露的还是 pay()、ship() 这些业务方法。
我在工程里最喜欢的用法,是把状态类配合 Spring 的 @Component 和枚举类型一起用,避免每次 new 一个状态对象。具体做法是状态接口增加一个 OrderStateType getType() 方法,然后用一个 Map<OrderStateType, OrderState> 把状态类型和实例对应起来,context 里存类型而不是存对象实例,切换状态时从 Map 里取实例。这样既能保证状态类是单例,又方便依赖注入。
状态模式的坑主要有两个:一是状态类之间如果互相 new,类与类之间就产生了编译期依赖,容易形成环,所以最好通过 context 来解耦,或者用工厂来创建状态实例;二是状态类如果太大,会把状态流转逻辑和业务逻辑混在一起,这时候要把纯状态流转和具体的业务操作拆开,状态类只负责决定“下一个状态是什么”,具体的库存扣减、消息通知交给别的服务。
3.2 备忘录模式:快照备份与回滚的正确姿势
备忘录模式的原意是:在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态,以后可以将该对象恢复到原先保存的状态。
这个模式最容易被误解的地方是:“不就是存个快照吗?直接深拷贝一个对象不就行了?”问题就出在“不破坏封装性”这六个字上。很多业务类有私有字段、有内部引用、有不允许外部直接访问的状态,如果外部代码为了做快照而把私有字段全部暴露出来,就破坏了封装。备忘录模式的解法是:让业务类自己生产快照、自己从快照恢复,外部只负责保存这个“黑盒快照”。
核心结构是三个角色:发起人(Originator,业务对象)、备忘录(Memento,快照对象)、管理者(Caretaker,负责保存和恢复)。一个简化到极致的代码示例:
java复制public class Originator {
private String state;
public void setState(String state) {
this.state = state;
}
public Memento saveStateToMemento() {
return new Memento(state);
}
public void getStateFromMemento(Memento memento) {
this.state = memento.getState();
}
public static class Memento {
private final String state;
public Memento(String state) { this.state = state; }
private String getState() { return state; }
}
}
Memento 用静态内部类的原因很直接:内聚且可以控制访问级别。外部代码拿到 Memento 对象只能保存和传递,不能直接读里面的状态,这就保证了封装不被破坏。
备忘录模式在真实项目里最常见的应用是草稿回滚、历史版本恢复、游戏存档、表单填写的 undo 功能。但要注意,快照的序列化方式直接决定这个模式的上限。保存一个小对象到内存里没问题,但如果你要保存的是一个几十 MB 的大文档快照,每保存一次就深拷贝一次,内存和 CPU 开销会非常可观。我的建议是:优先用对象序列化(比如 JSON)而不是 Java 原生序列化,因为前者有版本兼容性;快照要区分“全量快照”和“增量快照”,如果是连续的操作序列,每步只保存变化的部分能省下大量内存。
这个模式最容易踩的坑是“备忘录里存了不该存的引用”。如果你保存的是一个包含 List 成员的对象,直接保存 list 的引用,外部把 list 改了,你的“快照”也跟着变了,这就不叫快照了。务必确保快照和原始对象不共享任何可变引用。
3.3 两者的联动场景:可撤销的多状态编辑器
状态模式和备忘录模式经常被同时提起,因为它们解决的是同一枚硬币的两面:状态模式管“现在处于什么状态、这个状态下该干什么”,备忘录模式管“怎么把状态退回去”。实际项目里,需要支持回滚的状态机,基本都会把这两个模式配合用。
举个例子,一个复杂的多步骤配置页:用户填写基本信息(DRAFT)→ 校验通过(VALIDATED)→ 发布(PUBLISHED),每一步还可以撤销回上一步。用状态模式管理流程流转和每一步的校验逻辑,用备忘录模式保存每一步的现场数据,用户点“撤销”时,直接从备忘录里取出上一个状态的快照,把 context 状态和数据一起恢复。
这个联动本身已经超出了“背模式”的范畴,属于组合应用。很多讲设计模式的书不写这种组合,但真实业务里这种组合特别常见。我自己在做一个审批流引擎的时候,就是状态模式驱动流转方向,备忘录模式保存每个节点的快照,既支持正常审批,也支持驳回重新修改。两者各管一摊,互不干扰,代码结构非常干净。
4. 中介者模式与职责链模式:重新解构对象间的沟通方式
4.1 中介者模式:从网状通信降维到星型通信
中介者模式的核心意图:用一个中介对象来封装一系列的对象交互。中介者使各对象不需要显式地相互引用,从而使其耦合松散,而且可以独立地改变它们之间的交互。
为什么要引入它?我直接说痛感。在聊天室系统里,如果多个用户对象之间要互发消息,每个用户都要持有其他用户的引用,新增一个用户就要改动所有用户类,这就是典型的网状耦合。引入聊天室这个“中介者”之后,用户对象之间不再直接通信,而是把消息发给聊天室,聊天室负责转发给其他用户。新增用户不再影响已有用户,用户类只需要依赖聊天室接口。
代码骨架大概是:
java复制public interface ChatMediator {
void sendMessage(String message, User user);
void addUser(User user);
}
public class ChatRoom implements ChatMediator {
private List<User> users = new ArrayList<>();
@Override
public void sendMessage(String message, User user) {
for (User u : users) {
if (u != user) {
u.receive(message);
}
}
}
// addUser 省略...
}
public abstract class User {
protected ChatMediator mediator;
protected String name;
public User(ChatMediator mediator, String name) {
this.mediator = mediator;
this.name = name;
}
public abstract void send(String message);
public abstract void receive(String message);
}
这个模式的核心价值集中在“控制交互逻辑的集中化”。没有中介者的时候,交互规则散落在各个对象里,谁给谁发消息、消息如何过滤、权限如何校验,你要到每个对象里找;有了中介者,这些规则全部集中到一个类里,改规则只改一处。这在做复杂表单联动(比如一个参数改变后多个控件要联动变化)、GUI 事件中心、航班订票系统这样的场景里非常有用。
中介者模式最大的坑是“中介者上帝化”。所有逻辑都往中介者里塞,最后中介者变成一个大杂烩类,比原来的网状结构还难维护。我的应对方式:把中介者的职责看成一个“编排层”,它只负责协调对象之间的交互顺序和条件,绝大部分业务逻辑仍然留在业务对象内部,中介者只是调用它们的方法。
还有一个实操经验:中介者模式很适合跟事件总线(Event Bus)放在一起理解。发布订阅模型本质上是中介者模式的一种变体,只是通信媒介从“点对点方法调用”变成了“消息队列/事件广播”。如果你写的类已经依赖了一个事件总线,不要再强行套中介者模式,那是重复造轮子。
4.2 职责链模式:把处理者串成一条管道
职责链模式的核心意图:使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系。将这些对象连成一条链,并沿着这条链传递该请求,直到有一个对象处理它为止。
比起中介者模式的“集中式协调”,职责链走的是另一条路线:每个处理者只关心自己能不能处理,不能处理就往后传。这种模式在真实工程里出现频率极高,只是很多人没意识到自己用的就是职责链——过滤器链、拦截器链、审批流、日志多级输出、异常处理链,全是职责链的变体。
一个常见的需求是“订单风控校验”,校验项目包括:金额上限校验、用户黑名单校验、频率限制校验。每个校验是一个处理器,失败就中断,成功就传给下一个:
java复制public abstract class RiskHandler {
protected RiskHandler next;
public void setNext(RiskHandler next) {
this.next = next;
}
public abstract boolean handle(Order order);
protected boolean nextHandle(Order order) {
if (next == null) {
return true;
}
return next.handle(order);
}
}
public class AmountLimitHandler extends RiskHandler {
@Override
public boolean handle(Order order) {
if (order.getAmount() > LIMIT) {
System.out.println("风控拒绝:金额超限");
return false;
}
return nextHandle(order);
}
}
调用方负责把 Handler 串成链:amountHandler.setNext(blacklistHandler),然后直接调用第一个 handler 的 handle 方法。
这里有一个非常关键的工程决策:处理链是提前串好(静态)还是运行时动态调整(动态)。静态串链简单直接,适合校验链路相对固定的场景;动态链路则适合工作流引擎里节点可配置、可插拔的场景,每个节点从配置中心读取,框架代码不感知具体节点。我自己做规则引擎时,倾向于动态装配职责链,因为业务方经常要调整校验顺序,把“链的形状”配到数据库里,代码只需要递归执行 nextHandler 就完事。
职责链模式的坑有三个。第一是链路断掉之后没有日志,请求莫名其妙就“消失”了,排查问题时非常痛苦,所以每个 handler 的拒绝原因一定要记录;第二是链路过长会导致请求延迟,我见过有些系统把链路拉了三四十个节点,请求每次多走几十次方法调用,在高并发下就是浪费,可以考虑把纯校验类节点合并;第三是循环依赖,动态装配链路时如果配置成 A→B→A 这种环,递归就会永远跑不完,所以装配好后要做一个环检测。
4.3 两种沟通方式,怎么选
中介者和职责链经常被拿来对比。我在选型时有一个简单的经验法则:
- 如果你能提前确定“这个请求一定由一个明确的对象处理”,而且交互规则复杂、需要多对象协同配合,选中介者。
- 如果你希望“请求按顺序经过多个处理者,每个处理者都有机会处理/中断”,且处理者之间不应该互相知道,选职责链。
这个边界在具体业务里很常用:表单校验、登录鉴权、参数清洗这类“按顺序跑一遍、任一环节拒绝就停”的场景,职责链是标配;而一个订单状态变化时需要通知库存系统、通知财务系统、通知用户系统,且这些系统之间不需要知道彼此存在,中介者更合适。两种模式的核心差别其实一句话就能说清:职责链是“谁行谁上”,中介者是“大家都别说话,由我来协调”。
5. 迭代器模式与解释器模式:遍历与控制流的另类打开方式
5.1 迭代器模式:把遍历行为从集合实现里剥离出来
迭代器模式的核心意图:提供一种方法顺序访问一个聚合对象中各个元素,而又不需暴露该对象的内部表示。
很多开发者看到迭代器模式总是不以为然——“Java 的 List.iterator() 用了多少年了,这还用学吗?”但关键在于,迭代器模式的价值从 Java 集合框架里早就体现出来了:调用方只依赖 Iterator 接口,不依赖底层是数组还是链表还是树。这就是它存在的意义:把“怎么遍历”和“遍历什么”分开。
自己写一个集合类时,最容易犯的错误是让集合类内部实现迭代逻辑,比如在集合里加一个 get(int index),外部遍历时自然就成了针对具体实现的写法,换一个集合类型就要改遍历代码。正确的做法是集合类实现 Iterable 接口,提供一个返回 Iterator 的迭代器对象。这样做的好处是:外部代码一行都不用改,就能在 ArrayList 和 LinkedList 之间自由切换;甚至可以实现多个不同规则的迭代器(正序迭代器、逆序迭代器、过滤迭代器),不影响集合本身。
一个简易的自定义迭代器示例:
java复制public class NameRepository implements Iterable<String> {
private String[] names = {"A", "B", "C"};
@Override
public Iterator<String> iterator() {
return new Iterator<String>() {
private int index = 0;
@Override
public boolean hasNext() {
return index < names.length;
}
@Override
public String next() {
return names[index++];
}
};
}
}
工程里真正让我体会到迭代器价值的是一个自定义数据结构项目。那是一个树形菜单结构,业务方需要“先序遍历”“按层级遍历”“只遍历叶子节点”三种遍历方式。如果把这些遍历逻辑全写进树节点类,树节点类会爆炸。用迭代器模式,为树结构提供三个不同的 Iterator 实现,调用方用 for (Node n : tree) 的时候传进不同的迭代器策略就行,树本身不知道也不关心自己是被怎么遍历的。
迭代器模式要注意的问题是“弱一致性”和“fail-fast”。Java 集合的迭代器遇到集合结构被修改(add/remove)会抛 ConcurrentModificationException,这是一把双刃剑:它让你的代码在并发修改下快速失败而不是静默出错,但也要求你在遍历时不要随便改集合。如果确实需要边遍历边删除,建议用迭代器自己的 remove() 方法,或者收集要删除的条目最后统一删。
5.2 解释器模式:为特定领域构建迷你语法
解释器模式的核心意图:给定一个语言,定义它的文法的一种表示,并定义一个解释器,这个解释器使用该表示来解释语言中的句子。
坦白讲,解释器模式是 GoF 23 种设计模式里最容易被误解、也最容易被滥用的一个。很多教材一上来就讲算术表达式解析、SQL 解析、正则表达式解析,把学习者唬得一愣一愣,好像写了几个递归类就叫解释器模式了。实际上,解释器模式的应用底线是:当一种语言出现的频次足够高、并且可以用简单的文法规则描述时,才值得为它写解释器。如果只是偶尔一次的解析,直接写个递归函数就行,没必要上解释器模式。
一个贴合实际的案例是“规则表达式解析”。比如业务方要配置优惠券的使用条件:年龄 > 18 AND 等级 IN (VIP, SVIP) OR 注册时间 > 2020-01-01。你不想为这套规则写死代码,而是希望把规则文本化、可配置化,那就需要一个解释器来解析这种规则表达式。
简化版的结构是这样的:
java复制// 抽象表达式
public interface Expression {
boolean interpret(String context);
}
// 终结符表达式:具体条件判断
public class AgeExpression implements Expression {
@Override
public boolean interpret(String context) {
// 解析 context,判断年龄是否满足
}
}
// 非终结符表达式:AND OR NOT
public class AndExpression implements Expression {
private Expression left;
private Expression right;
// 构造略...
@Override
public boolean interpret(String context) {
return left.interpret(context) && right.interpret(context);
}
}
这种设计的价值是:规则语法做到“可组合”。业务人员配置了一个新规则,程序员不需要改代码,只需要提供一个新规则表达式的解析函数,剩下的交给解释器组合执行。
解释器模式最大的坑是“复杂度失控”。文法规则稍微复杂一点,类数量就会爆炸式增长,而且递归解析的性能不好。我的实际建议是:如果你要解析的语法只是简单的函数调用或条件表达式,优先考虑现成的表达式引擎(比如 Aviator、QLExpress),它们本身就是解释器模式的成熟产品,你直接用它就好,不要自己造轮子。只有当领域语言非常特殊、完全无法用现成引擎表达时,才考虑自己写解释器。
我自己的经验是:解释器模式在“规则引擎、报表系统、工作流引擎的表达式配置”里是标配,在普通 CRUD 项目里几乎用不到。学它的意义更多在于理解“词法解析→语法树→递归求值”这条链路,这能帮你理解很多现代框架的底层原理,但别在业务代码里轻易手搓解释器。
5.3 遍历与解析:少写重复逻辑的统一思路
迭代器和解释器看起来八竿子打不着,但它们背后的统一思路是一样的:把某种通用操作从业务逻辑中抽离出来,让调用方以统一方式使用,让底层实现自由替换。
迭代器管的是数据结构的遍历,解释器管的是语言规则的求值。都是“定义一个统一入口,内部封装复杂逻辑”。如果你在设计一个系统时发现有大量重复的遍历代码、或者大量重复的规则判断代码,这俩模式就是候选方案。
这两个模式还有个共同点:它们都容易“用早”。如果遍历逻辑很简单(一个数组的遍历),或者规则判断很少(个位数的 if),就不要硬套模式。迭代器的复杂度来自集合结构的复杂,解释器的复杂度来自语法规则的复杂,复杂度不够,模式就是负担。
6. 访问者模式与空对象模式:操作与缺省的优雅处理
6.1 访问者模式:双分派的高级玩法
访问者模式的核心意图:表示一个作用于某对象结构中的各元素的操作。它使你可以在不改变各元素类的前提下定义作用于这些元素的新操作。
一句话提炼就是:“类型相对稳定,操作经常变化”时,把操作从类型对象中剥离出来,放到访问者类里。每个类型对象(元素)只需要实现一个 accept 方法,而这个方法把自身“传递给访问者”,由访问者决定做具体什么事。
一个非常经典的例子是公司的薪资系统。假设公司有 Manager 和 Engineer 两种员工类型,现在要给所有员工做年底调薪。如果不使用访问者模式,你要在 Manager 类和 Engineer 类里各写一个 adjustSalary 方法;等明年改成发年终奖,又要在两个类里各加一个 grantBonus 方法。类越来越多,操作越来越多,类也跟着膨胀。访问者模式把“调薪”“发年终奖”这些操作全部放到一个访问者类里,员工类只暴露 accept 方法。
代码骨架:
java复制public interface Employee {
void accept(DepartmentVisitor visitor);
}
public class Manager implements Employee {
@Override
public void accept(DepartmentVisitor visitor) {
visitor.visit(this);
}
}
public interface DepartmentVisitor {
void visit(Manager manager);
void visit(Engineer engineer);
}
public class SalaryAdjustVisitor implements DepartmentVisitor {
@Override
public void visit(Manager manager) {
// 管理岗调薪逻辑
}
@Override
public void visit(Engineer engineer) {
// 技术岗调薪逻辑
}
}
这套设计一跑起来,“新增操作”就变得非常干净:再想加一个年终奖发放,只需要新增一个 BonusVisitor 实现 DepartmentVisitor,Manager 和 Engineer 类一行都不用改。这就是访问者模式的核心收益。
为什么能达成这个效果?关键是“双分派”。调用 employee.accept(visitor) 时,第一次分派由 employee 的实际类型决定进入哪个 accept 方法;在 accept 方法里调用 visitor.visit(this) 时,第二次分派由 this 的实际类型决定进入 visit 的哪个重载。两个变量(元素类型 + 操作类型)共同决定行为,这就是双分派。
访问者模式的限制和坑也很明显。最大的一个:往元素继承体系里新增一个类型,需要修改所有访问者接口和所有访问者实现类。也就是说它把“加操作容易”的好处建立在“加类型困难”的代价上。如果元素类型频繁变化,访问者模式就会变成你的噩梦。另外,访问者模式需要元素类暴露内部状态,不然访问者拿不到数据做操作,这在某些场景下会被视为破坏封装。
我实际项目里用访问者模式最少,但用它的地方都是真正值钱的——比如编译器和抽象语法树分析工具:AST 节点类型非常稳定(加减乘除、变量声明、条件分支),但操作千变万化(代码混淆、静态检查、行数统计、语法高亮),访问者模式是这类系统的最佳选择。
6.2 空对象模式:干掉空指针的默认行为方案
空对象模式不在 GoF 23 种里,它是后来被广泛接受的补充模式。核心思想极其简单:用一个“什么都不做”的默认对象,替代 null 返回,从而让调用方不用再做空指针判断。
以日志系统为例。系统有一个 Logger 接口,正常业务日志用 FileLogger,在某些测试场景里你想把日志丢掉又不希望调用方加 if(logger != null) 判断,那就提供一个 NullLogger 实现 Logger 接口,方法体全部为空。调用方拿到 logger 之后只管调用,永远不用担心 NPE。
java复制public interface Logger {
void info(String msg);
void error(String msg);
}
public class NullLogger implements Logger {
@Override
public void info(String msg) {
// do nothing
}
@Override
public void error(String msg) {
// do nothing
}
}
空对象模式的价值不是省几行 if 判断,而是消除调用方对被调用方返回值的隐式假设。你写接口文档承诺“绝不返回 null,找不到时返回空对象”,所有下游代码就能省去空值判断逻辑,减少分支覆盖的测试面,也减少了漏判空指针的可能。这在大型团队里特别有用——因为它把“约定”变成了“类型系统强制”的东西。
不过这个模式真正的应用场景往往是在框架设计里,而不是业务代码。比如策略模式中某个策略不存在时,返回一个默认策略而不是 null;职责链中处理链尽头,放置一个不做任何处理的终止节点;访问者模式中所有元素都不匹配时,返回一个空访问者。它本质上是一个“兜底”设计。
坑也很明显:空对象如果“什么都不做”反而掩盖了错误。比如查用户信息没查到,返回一个空 User 对象,调用方以为成功拿到用户,继续往下走,最后稀里糊涂地基于空数据操作。这种场景下抛异常或者返回 Optional 可能更合适。空对象模式的适用前提是“默认行为真的无害”,需要慎重判断。
6.3 两个模式组合使用:AST 静态检查器的经典结构
访问者模式和空对象模式在编译器/静态分析器里经常配合出现。AST 节点类型稳定,适合访问者模式;分析器遍历 AST 时,节点上可能没有附加信息,用空对象模式提供一个默认的“无类型”节点,避免所有访问方法都要判空。这两个组合让遍历和分析代码保持极低的嵌套层级,代码读起来像流水一样顺畅。
另外一个组合场景是策略模式 + 空对象模式:策略找不到时返回默认策略,避免调用方判断策略是否为空。这类组合应用的思路,我认为比单独背每个模式更重要——设计模式只有在组合里才能体现真正的威力。
7. 8 个模式横向对比与选型速查
7.1 一张表看透所有“其它模式”
我整理了一张选型速查表,把你最关心的信息集中在一起。这张表是我自己在做架构评审时常用的,不是教科书照抄,而是根据踩坑经验总结出来的。
| 模式 | 封装的变化 | 核心结构 | 典型应用场景 | 主要代价 | 不适用场景 |
|---|---|---|---|---|---|
| 状态模式 | 状态相关的行为 | 状态接口+具体状态类+上下文 | 订单流转、工单处理、审批流 | 类数量多、状态类间耦合 | 状态少、流转简单的场景 |
| 备忘录模式 | 状态历史的保存与恢复 | 发起人+备忘录+管理者 | 撤销操作、草稿回滚、版本恢复 | 内存开销大、易忽略深拷贝 | 状态过大、恢复频率低的场景 |
| 中介者模式 | 对象之间的交互规则 | 中介者接口+具体中介者+同事类 | 聊天室、GUI 事件中心、复杂表单联动 | 中介者容易膨胀成上帝类 | 交互简单、对象数量少 |
| 职责链模式 | 请求的处理者 | 抽象处理者+具体处理者+链 | 审批流、过滤器链、风控校验、日志链 | 链路长导致延迟、请求“静默消失” | 处理顺序固定不变的简单校验 |
| 迭代器模式 | 集合的遍历方式 | 迭代器接口+具体迭代器+聚合类 | 自定义集合、树形结构遍历 | 集合结构变化时迭代器要同步改 | 底层就是简单数组/列表 |
| 解释器模式 | 特定语法的解析规则 | 抽象表达式+终结符+非终结符 | 规则引擎、报表公式、工作流表达式 | 类爆炸、性能差、复杂度高 | 语法简单、解析频率低的场景 |
| 访问者模式 | 对象结构上的操作 | 元素接口+具体元素+访问者接口 | 编译器的 AST、文件类型处理、报表统计 | 新增类型需要改所有访问者 | 元素类型频繁变化的场景 |
| 空对象模式 | 对 null 的默认处理 | 抽象接口+真实实现+空实现 | 日志、配置中心、策略兜底 | 掩盖错误、静默失败 | 找不到对象时必须明确报错的场景 |
7.2 面试官最爱问的“模式区别”辨析题
面试里有一类问题非常高频:“状态模式和策略模式有什么区别?”“中介者模式和观察者模式有什么区别?”“访问者模式和迭代器模式的区别是什么?”我用自己的理解把这些辨析写清楚,你可以直接当回答模板用。
状态模式和策略模式的本质区别在于:策略模式的各个算法是平级且互不知晓的,客户端在选择策略;状态模式的各个状态是彼此关联且可以互相转换的,状态类自己决定下一个状态是谁,调用方通常不感知状态流转。从代码形态上,策略模式通常不持有上下文引用,状态模式的状态类通常要持有或接收上下文引用。
中介者模式和观察者模式很容易混。观察者模式是“一对多”的通知关系,被观察者只负责发通知,并不知道谁会响应;中介者模式是“多对多”的协调关系,中介者知道所有同事类,交互规则集中在中介者里。如果事件种类多、事件之间还有联动关系,中介者更合适;如果只是简单的“状态变化→通知”,观察者就够用。
访问者模式和迭代器模式的差别在于,迭代器是“带你遍历所有元素”,访问者是“在遍历时对每个元素执行特定操作”。你可以理解成迭代器是遍历框架,访问者是遍历之上的策略操作。
7.3 面试和工作中怎么用这套速查逻辑
面试时如果被问“请讲讲你用过哪些设计模式”,千万不要背概念。最好的回答方式是:说出场景 + 为什么选它 + 不选别的模式的理由。比如你可以说:“我们在订单系统中用了状态模式,因为订单状态多、流转规则复杂,之前用 if-else 导致每次加状态都很痛苦。选择状态模式而不是策略模式,是因为各个状态之间有明确的流转关系,而策略模式各算法之间是相互独立的。”
工作中更重要的其实是“意识到这里该用模式”。我建议在代码 review 时对照这张表问自己三个问题:这个类是不是承担了太多职责?这个类的分支是不是会经常扩展?这段代码是否已经在多处重复出现?只要有一个答案是“是”,你就应该考虑用对应的设计模式重构。
8. 多 Agent 架构里的“主从模式”与这些经典模式的呼应
8.1 主从模式本质上是把子 Agent 当作“另一类工具”来调用
最近跟读者聊到一个很有意思的话题:最新多 Agent 设计里热门的“主从模式”。它的典型做法是有一个主 Agent 负责任务编排,多个子 Agent(SubAgent)负责具体执行,而主 Agent 在调用子 Agent 时,本质上是把子 Agent 当成“另类的工具(Tool)”来使用——跟调用普通的函数工具没有本质差别,只是这个工具的输入输出是自然语言、并且拥有独立的推理能力。
从设计模式的角度看,这套思路很像几种经典模式的当代变体。主 Agent 里的工具注册表本质上是策略模式的运行时版本,主 Agent 负责把用户意图路由到具体子 Agent,又有一点中介者模式的影子。把子 Agent 当作 Tool 调用的思想,则是“面向接口编程”的极端实践:主 Agent 不关心子 Agent 内部是怎么推理的,只关心输入输出 schema。
8.2 主 Agent 编排子 Agent 的经典模式映射
我把多 Agent 架构里常见的设计方式、对应的设计模式、以及为什么这样对应列出来,供做 Agent 开发的同学参考:
| 多 Agent 设计方式 | 对应的经典模式 | 映射逻辑 |
|---|---|---|
| 主 Agent 按需调用不同子 Agent | 策略模式 + 工厂模式 | 主 Agent 根据任务类型从工厂中选取对应子 Agent,就像客户端选择策略 |
| 请求按固定顺序经过多个子 Agent 处理 | 职责链模式 | 每个子 Agent 只处理自己擅长的一段,处理不了就传给下一个 |
| 多个子 Agent 之间需要共享状态和消息,但不直接互相引用 | 中介者模式 | 主 Agent 充当协调中枢,各个子 Agent 之间不互相知晓 |
| 子 Agent 的能力封装成统一 Tool 接口 | 适配器模式 + 门面模式 | 无论子 Agent 内部实现多复杂,暴露给主 Agent 的就是统一接口 |
| 子 Agent 执行任务时保存上下文快照,便于回溯重放 | 备忘录模式 | 任务的输入输出快照允许主 Agent 在异常后恢复重跑 |
这里不是生搬硬套,而是想说明一件事:经典设计模式没有过时,它们只是在新的“对象”形态下换了一种存在方式。多 Agent 里的“对象”变成了“Agent”,“方法调用”变成了“工具调用”,“依赖关系”变成了“消息路由”,但底层的控制权反转、依赖抽象、单一职责这些思想是一脉相承的。
8.3 设计模式在新场景下仍然有效的原因
为什么这些几十年前总结出来的模式,放在 Agent 这种新场景里依然适用?我的理解是:设计模式之所以能沉淀下来,不是因为它写了某个具体的类结构,而是因为它抽象出了控制流和依赖关系的本质规律。无论你的计算单元是对象、函数还是 Agent,只要存在“多个参与者、需要协作完成一个目标”的场景,就必然面对“谁控制、谁依赖、谁通信”这三个问题,而设计模式恰恰是这三类问题的成熟答案。
所以我的观点是:与其追新框架、追新架构,不如先把经典模式吃透。做 Agent 开发的同学如果能理解策略模式背后的“运行时替换算法思想”,就会自然而然地设计出“主 Agent 按任务类型路由子 Agent”的方案;如果能理解职责链模式的“请求按序经过多个处理者”,就能设计出多 Agent 流水线处理任务。这不是巧合,而是同一个思想在不同时代的不同投影。
9. 常见误用与踩坑实录
9.1 最容易犯的四个模式误用
我见过太多把模式用“歪”的代码,归纳下来最典型的四类误用是:
第一,状态模式被用成了“状态类互踩”。 状态类的常见实现错误是 A 状态直接 new B 状态、B 状态直接 new A 状态,两个类出现循环依赖。解决办法是让状态类不再互相 new,而是通过 context.setState 或者状态工厂来切换。
第二,职责链模式被用成了“没有终点的死循环”。 某些动态装配的职责链,next 指针配错或者新增节点时忘记设置终点,导致请求永远在链里循环。我的习惯是:抽象处理者里增加一个“链路长度上限”控制,超过阈值直接拒绝,并且每次装配完成后做一次环检测。
第三,备忘录模式被用成了“浅拷贝大坑”。 快照保存的是引用而不是值,外部改了就导致快照失效。这个我在前面已经强调过,但这里我想补一句:如果快照包含嵌套对象,一定要做深拷贝或序列化,别偷懒。
第四,空对象模式被用成了“错误遮羞布”。 找不到配置时返回一个默认的空对象,导致系统静默失败,后面排查问题的时候连日志都没有。空对象模式虽然“不做操作”,但通常还是要记录一条 debug 日志,方便问题追踪。
9.2 实操中总结的五条预防措施
基于上面的坑,我总结了五条对谁都适用的预防措施:
- 每个模式引入前,先问“这个模式换来的灵活性是否真的用得上”,不要为了“优雅”而设计。设计模式是重构的工具,不是编码的起点。
- 一旦决定用某个模式,立刻把模式的约束条件(比如职责链的链不能成环、备忘录必须深拷贝)作为一种编程规范写进团队的代码规范文档。避免队友后来在不知情的情况下破坏这些约束。
- 优先使用语言和框架已有的模式实现,比如 Java 的 Iterator、Spring 的 HandlerInterceptor、Netty 的 ChannelPipeline,都已经把职责链实现得很成熟,不要自己重复造轮子。
- 给模式使用点加日志和监控。职责链在哪里被中断、状态机卡在哪个状态、备忘录存储多大了,这些都应该有可观测性指标。设计模式不是魔法,出了 bug 一样要查日志。
- 每个模式的引入都要配一个最小可运行的 Demo。我之前带团队时定过一条规矩:新引入的设计模式,必须带着一个 20 行以内的最小示例进代码库,防止后续成员误解用法。
9.3 设计模式面试与课程设计的高分策略
如果是为了应付面试或者设计模式期末、设计模式大作业,我的建议是不要贪多,选两个模式深入吃透,比 8 个模式都背一遍要有用得多。面试官问“你用过哪些设计模式”,你说“我用过状态模式重构了一个订单模块,原来 3 个方法、每个方法 4 个 if-else,重构后变成 4 个状态类加 1 个上下文类,新增状态只需要加一个类”——这个回答比背出 20 个模式的定义有说服力得多。
课程设计(大作业)也一样,关键是展示“为什么这么做”。如果你交的作业里用了状态模式,写清楚“哪个类承担了状态上下文、哪个接口是状态接口、每个状态类负责什么”,再配上重构前后代码对比和类图,基本就是高分作业的水平。要展示思维过程,不要只展示最终结果。
10. 个人实操中的一点体会
把这些“其它模式”全部过一遍之后,我最想分享的体会是:设计模式不是用来背的,是用来当你写代码写得难受的时候突然想起的。你写 if-else 写到吐的时候想起状态模式,你发现对象之间互相持有引用乱成一锅粥的时候想起中介者模式,你要做一个规则配置功能的时候想起解释器模式。真正的掌握不是能默写概念,而是能在遇到问题的瞬间“想起来有这个工具”。
我自己回顾这几年,真正在业务里频繁用到的是状态模式、职责链模式和备忘录模式,迭代器是 Java 集合框架帮我用掉了,访问者只在编译器类项目里用过,解释器几乎没自己写过(都用现成引擎)。但理解每一个模式的代价都很小,收益却很大——它们能让你看懂框架源码,能在技术方案评审时提出更合理的建议,甚至能让你在写多 Agent 编排的时候自然而然地借鉴主从模式和职责链的思想。
如果你现在正在学设计模式,我的建议是:先找个真实业务里最恶心的 if-else 块,用状态模式重写一遍;再找个多步骤校验流程,用职责链重写一遍。两个项目做完,你对设计模式的感受绝对会不一样。代码结构这件事,真不是多写几行就完了,而是多想一步“这个变化将来往哪走”。
