1. 行为型模式的整体版图与下半场定位
行为型模式在二十三种经典设计模式里占了十一个席位,是整个模式体系中最庞大也最贴近业务逻辑的一类。上半场我们聊过了责任链、命令、解释器、迭代器和中介者这五个,它们更多聚焦在“请求如何传递”“对象如何遍历”“多方如何解耦”这些偏结构调度的问题上。而下半场要讲的六个模式——备忘录、观察者、状态、策略、模板方法和访问者——则把重心放在了“对象行为如何随内外部条件变化”“算法如何灵活替换”“状态如何安全流转”这些更动态的层面。
我刚开始接触这些模式的时候,最大的困惑是分不清策略和状态,也搞不懂观察者和中介者到底差在哪。后来在真实项目里反复踩坑才明白,这些模式的名字虽然抽象,但它们解决的其实都是日常开发中反复出现的痛点。比如一个订单系统要支持多种支付方式,你写一堆 if-else 也能跑,但每加一种支付方式就要改核心代码,这就是策略模式要解决的问题。再比如一个 UI 组件需要在数据变化时自动刷新,你手动调刷新方法也能用,但一旦订阅方多了就容易漏调,这就是观察者模式的用武之地。
这篇文章我会按照“先讲清楚每个模式解决什么问题,再给出可直接运行的代码实例,最后补充我在实际项目里踩过的坑”这个思路来展开。每个模式我都会配一个贴近真实业务的场景,而不是那种猫叫狗叫的玩具例子。代码以 Python 和 Java 为主,因为这两种语言在业务开发中最常见,读者可以直接复制到自己的项目里改改用。如果你正在准备面试、正在重构一坨祖传代码,或者单纯想让自己写的模块更好扩展,这篇内容应该能帮到你。
注意:设计模式不是银弹,过度使用会让代码变得更复杂。我见过一个项目把简单的数据转换硬套了访问者模式,结果新增一种数据类型要改五个文件。模式的价值在于应对变化,如果某个维度根本不会变,那就别为它设计扩展点。
2. 备忘录模式:让对象拥有“后悔药”能力
2.1 备忘录模式到底解决什么问题
备忘录模式的核心思想用一句话概括就是:在不破坏封装的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态,以便日后可以将该对象恢复到原先保存的状态。听起来有点绕,我用一个实际场景来解释。
假设你在做一个在线文档编辑器,用户编辑到一半想撤销上一步操作。最朴素的做法是在每次编辑前把整个文档对象深拷贝一份存起来,撤销时直接替换。但这样做有两个问题:第一,文档对象可能很大,每次全量拷贝内存吃不消;第二,文档内部有些字段是私有的,外部不应该直接访问。备忘录模式就是来解决这两个矛盾的——它让文档对象自己负责生成一个“快照”,这个快照只包含需要恢复的字段,而且快照的创建和恢复都由文档对象自己控制,外部只负责保管快照,不关心快照里有什么。
这个模式有三个角色:发起人(Originator)就是需要被保存状态的对象,备忘录(Memento)是那个快照,管理者(Caretaker)负责存储和传递备忘录但不能修改它。我刚开始学的时候觉得这不就是把对象序列化一下存起来吗?后来才理解,备忘录模式的关键在于“窄接口”和“宽接口”的区分——管理者只能看到备忘录的窄接口,只能把它当做一个黑盒传递,而发起人可以看到宽接口,能读取里面的所有状态。这种访问控制才是备忘录模式的精髓。
2.2 一个可撤销的文本编辑器实例
下面这个例子用 Python 实现一个简易的文本编辑器,支持输入文字和撤销操作。我特意把备忘录设计成只保存必要字段,而不是整个对象。
class EditorMemento: def __init__(self, content, cursor_pos): self._content = content self._cursor_pos = cursor_pos def get_content(self): return self._content def get_cursor_pos(self): return self._cursor_pos class Editor: def __init__(self): self._content = "" self._cursor_pos = 0 def type_text(self, text): self._content += text self._cursor_pos += len(text) def save(self): return EditorMemento(self._content, self._cursor_pos) def restore(self, memento): self._content = memento.get_content() self._cursor_pos = memento.get_cursor_pos() def show(self): print(f"内容: {self._content}, 光标位置: {self._cursor_pos}") class History: def __init__(self): self._mementos = [] def push(self, memento): self._mementos.append(memento) def pop(self): if not self._mementos: return None return self._mementos.pop() if __name__ == "__main__": editor = Editor() history = History() editor.type_text("你好") history.push(editor.save()) editor.type_text(",世界") history.push(editor.save()) editor.type_text("!") editor.show() editor.restore(history.pop()) editor.show() editor.restore(history.pop()) editor.show()运行结果会依次显示输入后的状态、撤销一步后的状态、再撤销一步后的状态。这个实现里,History 类就是管理者,它只负责存取 EditorMemento,完全不知道备忘录里有什么内容。Editor 类自己决定保存哪些字段、如何恢复,封装性没有被破坏。
2.3 备忘录模式的实操心得与避坑指南
在实际项目里用备忘录模式,有几个点特别容易踩坑。第一个是内存问题。如果你的对象状态很大,而且保存频率很高,备忘录会迅速吃光内存。我做过一个图形编辑器,每次拖动图形都保存一次备忘录,结果拖一个复杂图形时内存直接飙到几个 G。后来改成只在鼠标松开时保存,并且对备忘录做了压缩存储,才把内存降下来。所以备忘录的保存时机和存储策略一定要根据业务场景仔细设计。
第二个坑是备忘录的深拷贝问题。如果你的对象内部有引用类型的字段,保存备忘录时一定要做深拷贝,否则恢复的时候会发现状态根本没变——因为备忘录里存的还是同一个引用。我见过一个同事写的备忘录模式,恢复后数据完全没变化,排查了半天才发现是浅拷贝导致的。Python 里可以用copy.deepcopy,Java 里需要自己实现clone或者用序列化来做深拷贝。
第三个经验是关于备忘录的版本管理。如果系统需要支持多级撤销和重做,光靠一个栈是不够的,需要两个栈分别管理撤销和重做。而且当用户执行新操作时,重做栈要清空。这个逻辑听起来简单,但实际写的时候很容易漏掉边界条件,比如撤销到最底部时再点撤销应该没反应,重做栈为空时点重做也应该没反应。这些边界情况一定要写单元测试覆盖到。
提示:备忘录模式常和命令模式配合使用。命令模式负责封装操作,备忘录模式负责保存操作前的状态,两者结合就能实现完整的撤销重做功能。如果你正在做编辑器、绘图工具或者任何需要撤销功能的系统,这个组合非常值得掌握。
3. 观察者模式:数据变化时谁该被通知
3.1 从一对多依赖说起
观察者模式定义了一种一对多的依赖关系,让多个观察者对象同时监听某一个主题对象。当主题对象的状态发生变化时,所有观察者都会收到通知并自动更新。这个模式在 GUI 编程、事件驱动系统、消息推送等场景里无处不在,可以说是行为型模式里应用最广泛的一个。
我最早接触观察者模式是在做 Android 开发的时候,那时候还不懂什么设计模式,只是觉得setOnClickListener这种写法很神奇——我只需要注册一个监听器,按钮被点击时我的代码就会被调用。后来学了观察者模式才明白,这就是典型的一对多通知机制。再后来做后端开发,消息队列的发布订阅、Spring 的事件机制、Redis 的键空间通知,本质上都是观察者模式的变体。
观察者模式的价值在于解耦。主题对象不需要知道具体有哪些观察者,只需要维护一个观察者列表,状态变化时遍历通知即可。观察者也不需要知道主题的具体实现,只需要实现一个统一的更新接口。这样新增观察者时完全不用改主题的代码,符合开闭原则。但这里有个关键点:通知的顺序是不确定的,如果你的业务逻辑依赖观察者的执行顺序,那就不能依赖观察者模式,或者需要在观察者内部再做一层排序。
3.2 股票价格监控系统的完整实现
下面用 Java 实现一个股票价格监控系统。当股票价格变化时,所有订阅了该股票的投资者都会收到通知。这个例子贴近真实业务,也方便你理解观察者模式的实际价值。
import java.util.ArrayList; import java.util.List; interface Observer { void update(String stockName, double price); } interface Subject { void registerObserver(Observer o); void removeObserver(Observer o); void notifyObservers(); } class Stock implements Subject { private List<Observer> observers = new ArrayList<>(); private String name; private double price; public Stock(String name) { this.name = name; } public void setPrice(double price) { this.price = price; notifyObservers(); } @Override public void registerObserver(Observer o) { observers.add(o); } @Override public void removeObserver(Observer o) { observers.remove(o); } @Override public void notifyObservers() { for (Observer o : observers) { o.update(name, price); } } } class Investor implements Observer { private String investorName; public Investor(String name) { this.investorName = name; } @Override public void update(String stockName, double price) { System.out.println(investorName + " 收到通知:" + stockName + " 当前价格 " + price); } } public class Main { public static void main(String[] args) { Stock stock = new Stock("某科技股"); Investor a = new Investor("投资者A"); Investor b = new Investor("投资者B"); stock.registerObserver(a); stock.registerObserver(b); stock.setPrice(100.5); stock.setPrice(102.3); stock.removeObserver(a); stock.setPrice(99.8); } }这个实现里,Stock 是主题,Investor 是观察者。当价格变化时,所有注册的投资者都会收到通知。移除投资者 A 之后,后续的价格变化就只有 B 能收到。这个结构非常清晰,新增投资者类型时只需要实现 Observer 接口即可,Stock 的代码完全不用动。
3.3 观察者模式的常见问题与优化思路
观察者模式用起来简单,但有几个问题在实际项目中经常遇到。第一个是内存泄漏。如果观察者被销毁了但没有从主题的观察者列表里移除,主题就会一直持有观察者的引用,导致观察者无法被垃圾回收。在 Java 里这个问题特别常见,因为开发者容易忘记调用removeObserver。解决办法是使用弱引用(WeakReference)来持有观察者,或者在观察者的生命周期结束时主动取消订阅。我个人的习惯是在观察者的close或destroy方法里统一取消所有订阅,避免遗漏。
第二个问题是通知风暴。如果主题频繁变化,而观察者又很多,每次变化都全量通知会导致性能问题。我做过一个实时数据大屏,数据每秒更新十次,观察者有几十个,结果每次更新都要遍历通知一遍,CPU 直接跑满。后来改成批量通知加节流,把一秒内的多次变化合并成一次通知,性能立刻降下来了。所以如果你的场景是高频变化,一定要考虑通知的频率控制。
第三个问题是观察者的异常处理。如果某个观察者在update方法里抛了异常,后面的观察者就收不到通知了。这个行为在大多数场景下是不合理的——一个观察者出错不应该影响其他观察者。解决办法是在通知循环里对每个观察者做 try-catch,把异常隔离在单个观察者内部。当然,如果业务上要求所有观察者要么都成功要么都失败,那就需要引入事务机制,这就超出观察者模式本身的范畴了。
| 问题类型 | 典型表现 | 解决思路 |
|---|---|---|
| 内存泄漏 | 观察者已销毁但仍收到通知 | 使用弱引用或主动取消订阅 |
| 通知风暴 | 高频变化导致 CPU 飙升 | 批量通知、节流、合并变化 |
| 异常中断 | 一个观察者出错导致后续观察者收不到通知 | 每个观察者独立 try-catch |
| 顺序依赖 | 业务逻辑依赖观察者执行顺序 | 不依赖观察者模式,改用责任链或显式排序 |
提示:观察者模式在分布式系统里会演变成发布订阅模式,中间多了一个消息代理(Broker)。如果你在做跨进程的事件通知,不要直接用观察者模式,应该引入消息队列。观察者模式适合进程内的对象间通知,跨进程场景要考虑网络延迟、消息丢失、重复消费等问题。
4. 状态模式:把状态流转从 if-else 里解放出来
4.1 状态模式的核心思想与适用场景
状态模式允许一个对象在其内部状态改变时改变它的行为,看起来就像对象修改了自己的类。这个模式的核心是把每个状态封装成一个独立的类,每个状态类负责处理该状态下的行为,以及定义从该状态可以转移到哪些其他状态。上下文对象持有一个当前状态的引用,所有请求都委托给当前状态对象处理。
我见过太多项目用一大坨 if-else 或者 switch-case 来管理状态流转,代码长到需要滚动好几屏才能看完。每次新增一个状态,就要在所有相关的方法里加分支,改漏一处就出 bug。状态模式就是来治这个病的。它把每个状态的行为局部化到各自的类里,新增状态时只需要加一个新类,修改状态转移逻辑时也只需要改对应的状态类,不会影响到其他状态。
状态模式特别适合那些状态数量有限、状态之间转移规则明确、且每个状态下行为差异较大的场景。比如订单系统(待支付、已支付、已发货、已完成、已取消)、工作流引擎(草稿、审批中、已通过、已驳回)、游戏角色(站立、行走、跳跃、攻击)等等。但如果你的状态很少而且行为差异不大,用状态模式反而会让代码更复杂,这时候简单的枚举加 switch 就够了。
4.2 订单状态流转的完整代码实现
下面用 Python 实现一个订单状态流转系统。订单有四个状态:待支付、已支付、已发货、已完成。每个状态下可以执行的操作不同,比如待支付状态可以支付和取消,已支付状态可以发货,已发货状态可以确认收货。
from abc import ABC, abstractmethod class OrderState(ABC): @abstractmethod def pay(self, order): pass @abstractmethod def ship(self, order): pass @abstractmethod def confirm(self, order): pass @abstractmethod def cancel(self, order): pass class PendingPaymentState(OrderState): def pay(self, order): print("支付成功,订单进入已支付状态") order.set_state(PaidState()) def ship(self, order): print("订单未支付,无法发货") def confirm(self, order): print("订单未支付,无法确认收货") def cancel(self, order): print("订单已取消") order.set_state(CancelledState()) class PaidState(OrderState): def pay(self, order): print("订单已支付,请勿重复支付") def ship(self, order): print("发货成功,订单进入已发货状态") order.set_state(ShippedState()) def confirm(self, order): print("订单未发货,无法确认收货") def cancel(self, order): print("已支付订单取消中,退款处理") order.set_state(CancelledState()) class ShippedState(OrderState): def pay(self, order): print("订单已支付,请勿重复支付") def ship(self, order): print("订单已发货,请勿重复发货") def confirm(self, order): print("确认收货,订单完成") order.set_state(CompletedState()) def cancel(self, order): print("已发货订单无法直接取消,请联系客服") class CompletedState(OrderState): def pay(self, order): print("订单已完成") def ship(self, order): print("订单已完成") def confirm(self, order): print("订单已完成") def cancel(self, order): print("订单已完成,无法取消") class CancelledState(OrderState): def pay(self, order): print("订单已取消") def ship(self, order): print("订单已取消") def confirm(self, order): print("订单已取消") def cancel(self, order): print("订单已取消") class Order: def __init__(self): self._state = PendingPaymentState() def set_state(self, state): self._state = state def pay(self): self._state.pay(self) def ship(self): self._state.ship(self) def confirm(self): self._state.confirm(self) def cancel(self): self._state.cancel(self) if __name__ == "__main__": order = Order() order.pay() order.ship() order.confirm() order.cancel()这个实现里,每个状态类只关心自己状态下能做什么、不能做什么,以及做完之后转移到哪个状态。Order 类只负责把请求委托给当前状态,完全不包含任何状态判断逻辑。新增一个“退款中”状态时,只需要加一个 RefundingState 类,并在相关状态类里加上转移到它的逻辑,Order 类一行都不用改。
4.3 状态模式的实操经验与常见误区
状态模式用起来很优雅,但有几个误区需要特别注意。第一个误区是把状态模式当成万能药。如果你的状态转移逻辑非常简单,比如只有两三个状态且转移规则固定,那用状态模式就是杀鸡用牛刀。我见过一个项目只有“开”和“关”两个状态,硬是写了两个状态类加一个上下文类,代码量比直接写 if-else 多了三倍。所以用之前先评估一下:状态数量是否超过三个?状态之间的转移是否复杂?每个状态下的行为差异是否明显?如果答案都是否,那就别用。
第二个误区是状态类之间的循环依赖。如果状态 A 的转移逻辑里需要创建状态 B,状态 B 的转移逻辑里又需要创建状态 A,就会形成循环导入。在 Python 里这会直接导致 ImportError,在 Java 里虽然不会报错但代码结构会很乱。解决办法是把状态转移的逻辑集中到一个状态机工厂里,或者用字符串标识状态而不是直接引用类。我个人的习惯是用一个字典来映射状态标识和状态类,状态类内部只返回下一个状态的标识,由上下文负责实例化。
第三个经验是关于状态持久化。如果你的订单状态需要存数据库,那状态类本身是不能直接序列化的,你需要在数据库里存一个状态标识字符串,读取时再根据标识恢复成对应的状态对象。这个转换过程最好封装成一个工厂方法,避免在业务代码里到处写 if-else 来判断状态标识。另外,状态转移的历史记录也很重要,很多业务场景需要追溯订单经历了哪些状态、每次转移的时间是什么。这个可以在上下文类的set_state方法里统一记录,不需要每个状态类自己处理。
注意:状态模式和策略模式在类结构上几乎一模一样,都是上下文持有一个抽象接口的引用,把请求委托给它。区别在于意图:策略模式是让客户端选择算法,算法之间通常没有依赖关系;状态模式是对象自己根据内部状态选择行为,状态之间通常有明确的转移关系。面试时如果被问到这两个模式的区别,从意图和转移关系入手回答就能说到点子上。
5. 策略模式:算法替换的艺术
5.1 策略模式解决的核心痛点
策略模式定义了一系列算法,把它们各自封装起来,并且使它们可以互相替换。策略模式让算法的变化独立于使用算法的客户端。这个模式的核心价值在于把“做什么”和“怎么做”分开——客户端知道要完成什么任务,但不需要知道具体用哪种算法来完成。
我在实际项目里最常用策略模式的场景是支付方式选择。一个电商系统需要支持微信支付、支付宝、银行卡等多种支付方式,每种支付方式的调用逻辑完全不同。如果用 if-else 来写,每加一种支付方式就要改支付核心类,而且所有支付逻辑挤在一个方法里,维护起来非常痛苦。用策略模式之后,每种支付方式是一个独立的策略类,支付核心类只负责根据用户选择调用对应的策略,新增支付方式时只需要加一个策略类并注册到工厂里。
策略模式和状态模式经常被拿来比较,因为它们的类结构确实很像。但策略模式的策略之间通常是平等的、可互换的,客户端主动选择用哪个策略;而状态模式的状态之间通常有转移关系,对象自己根据当前状态决定行为。简单说,策略模式是“我选一个工具来用”,状态模式是“我到了某个阶段就自动做对应的事”。
5.2 多支付方式切换的代码实例
下面用 Java 实现一个支付策略系统,支持微信、支付宝和银行卡三种支付方式。代码里还加了一个策略工厂,方便根据用户选择获取对应的策略。
interface PaymentStrategy { void pay(double amount); } class WeChatPay implements PaymentStrategy { @Override public void pay(double amount) { System.out.println("使用微信支付 " + amount + " 元"); } } class AliPay implements PaymentStrategy { @Override public void pay(double amount) { System.out.println("使用支付宝支付 " + amount + " 元"); } } class BankCardPay implements PaymentStrategy { @Override public void pay(double amount) { System.out.println("使用银行卡支付 " + amount + " 元"); } } class PaymentFactory { public static PaymentStrategy getStrategy(String type) { switch (type) { case "wechat": return new WeChatPay(); case "alipay": return new AliPay(); case "bank": return new BankCardPay(); default: throw new IllegalArgumentException("不支持的支付方式: " + type); } } } class PaymentContext { private PaymentStrategy strategy; public void setStrategy(PaymentStrategy strategy) { this.strategy = strategy; } public void executePayment(double amount) { if (strategy == null) { throw new IllegalStateException("请先选择支付方式"); } strategy.pay(amount); } } public class Main { public static void main(String[] args) { PaymentContext context = new PaymentContext(); context.setStrategy(PaymentFactory.getStrategy("wechat")); context.executePayment(199.0); context.setStrategy(PaymentFactory.getStrategy("alipay")); context.executePayment(299.0); context.setStrategy(PaymentFactory.getStrategy("bank")); context.executePayment(399.0); } }这个实现里,PaymentContext 是上下文,它持有一个 PaymentStrategy 的引用。客户端通过工厂获取策略并设置到上下文中,然后调用executePayment执行支付。新增一种支付方式时,只需要实现 PaymentStrategy 接口并在工厂里注册即可,PaymentContext 完全不用改。
5.3 策略模式的进阶用法与避坑要点
策略模式在实际项目中有几个进阶用法值得掌握。第一个是用枚举来实现策略。如果你的策略数量有限且不会频繁增加,可以用枚举来实现策略接口,这样既能保证策略的唯一性,又能方便地做遍历和查找。Java 里枚举可以实现接口,每个枚举常量提供不同的实现,这种写法比单独的类更简洁。
第二个是用 Lambda 表达式简化策略。如果策略接口只有一个方法(函数式接口),那就可以用 Lambda 表达式来创建策略,不需要为每个策略单独写一个类。这在策略逻辑比较简单的时候特别方便。比如排序策略,你可以直接传(a, b) -> a - b表示升序,传(a, b) -> b - a表示降序,代码量大幅减少。
第三个坑是策略的初始化成本。如果策略对象的创建成本很高(比如需要加载配置文件、建立数据库连接),那就不要在每次使用时都新建策略,而应该把策略缓存起来复用。我做过一个风控系统,每个风控策略都需要加载规则模型,创建一次要好几秒,如果每次请求都新建策略,系统根本扛不住。后来改成策略单例加懒加载,性能问题就解决了。
| 策略实现方式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 独立类 | 策略逻辑复杂、数量多 | 结构清晰、易维护 | 类文件多 |
| 枚举 | 策略数量固定、逻辑简单 | 简洁、可遍历 | 扩展需要改枚举 |
| Lambda | 函数式接口、逻辑简单 | 代码量少 | 可读性稍差 |
| 工厂+缓存 | 策略创建成本高 | 性能好 | 需要管理缓存生命周期 |
提示:策略模式常和简单工厂模式配合使用,工厂负责创建策略,上下文负责执行策略。但要注意,工厂里的 switch-case 并没有消除,只是把变化点集中到了一个地方。如果策略数量非常多,可以考虑用配置文件或注解来注册策略,实现完全动态的加载。
6. 模板方法模式:把骨架定下来,把细节留出去
6.1 模板方法模式的设计哲学
模板方法模式在一个方法中定义一个算法的骨架,而将一些步骤延迟到子类中实现。模板方法使得子类可以在不改变算法结构的情况下,重新定义算法中的某些步骤。这个模式的核心是“父类定流程,子类填细节”。
我在做数据处理系统的时候大量使用了模板方法模式。一个典型的数据处理流程是:读取数据、校验数据、转换数据、写入数据、记录日志。这五步的顺序是固定的,但每一步的具体实现可能因数据源和目标不同而不同。如果每个数据处理器都从头写这五步,会有大量重复代码。用模板方法模式之后,父类定义这五步的调用顺序,子类只需要实现各自的数据读取、校验、转换和写入逻辑,流程控制完全由父类负责。
模板方法模式的关键在于“钩子方法”(Hook Method)。钩子方法是在父类中声明但实现为空的方法,子类可以选择性地覆盖它。钩子方法给了子类更大的灵活性——子类可以决定是否要在某个步骤插入额外的逻辑,而不是被迫实现所有步骤。比如在数据处理流程里,父类可以在写入数据前后各留一个钩子,子类如果需要做数据备份就在钩子里实现,不需要就保持空实现。
6.2 数据处理流程的模板方法实现
下面用 Python 实现一个数据处理框架,父类定义处理流程,子类实现具体的数据读取和写入逻辑。
from abc import ABC, abstractmethod class DataProcessor(ABC): def process(self): self.connect() data = self.read_data() if self.validate(data): transformed = self.transform(data) self.write_data(transformed) self.disconnect() self.log() def connect(self): print("建立连接") def disconnect(self): print("断开连接") def log(self): print("记录处理日志") @abstractmethod def read_data(self): pass @abstractmethod def transform(self, data): pass @abstractmethod def write_data(self, data): pass def validate(self, data): return True class CsvProcessor(DataProcessor): def read_data(self): print("从 CSV 文件读取数据") return ["row1", "row2", "row3"] def transform(self, data): print("转换 CSV 数据") return [item.upper() for item in data] def write_data(self, data): print(f"写入 CSV 数据: {data}") def validate(self, data): if not data: print("CSV 数据为空,跳过处理") return False return True class JsonProcessor(DataProcessor): def read_data(self): print("从 JSON 接口读取数据") return {"key": "value"} def transform(self, data): print("转换 JSON 数据") return {"key": data["key"].upper()} def write_data(self, data): print(f"写入 JSON 数据: {data}") if __name__ == "__main__": csv_processor = CsvProcessor() csv_processor.process() print("---") json_processor = JsonProcessor() json_processor.process()这个实现里,process方法是模板方法,它定义了整个处理流程的骨架。connect、disconnect、log是具体方法,所有子类共用。read_data、transform、write_data是抽象方法,子类必须实现。validate是钩子方法,默认返回 True,子类可以覆盖它来实现自定义校验逻辑。CsvProcessor 覆盖了validate来检查数据是否为空,JsonProcessor 则使用默认实现。
6.3 模板方法模式的实战经验与注意事项
模板方法模式在实际项目中有几个点需要特别注意。第一个是继承的局限性。模板方法模式依赖继承,而继承是一种强耦合关系。如果父类的流程需要修改,所有子类都会受到影响。所以在设计父类时,流程的稳定性非常重要。我个人的经验是,只有那些确实不会变的流程才适合用模板方法模式,如果流程本身还在频繁调整,那就不要急着抽象。
第二个是抽象方法的粒度。抽象方法定义得太细,子类实现起来会很繁琐;定义得太粗,子类又无法精确控制流程。我一般会把一个完整的业务步骤作为一个抽象方法,而不是把步骤内部的每个小操作都拆成抽象方法。比如“读取数据”是一个抽象方法,而不是把“打开文件”“读取内容”“关闭文件”都拆成抽象方法。这样既给了子类足够的实现自由度,又不会让子类负担过重。
第三个经验是关于钩子方法的使用。钩子方法不要定义太多,否则子类会不知道哪些该覆盖哪些不该覆盖。我一般只在流程的关键节点留钩子,比如“处理前”“处理后”“异常时”这几个位置。而且钩子方法的命名要清晰,让人一看就知道是什么时机被调用的。比如beforeProcess、afterProcess、onError这样的命名就比hook1、hook2好得多。
注意:模板方法模式和策略模式都可以用来封装算法,但它们的实现方式不同。模板方法用继承,策略模式用组合。一般来说,组合比继承更灵活,所以如果能用策略模式就用策略模式。但模板方法模式在流程固定、步骤可变的场景下更直观,因为它把流程控制权留在了父类,子类只需要关心自己的步骤实现。
7. 访问者模式:在不改类的前提下增加新操作
7.1 访问者模式的适用场景与双分派机制
访问者模式表示一个作用于某对象结构中的各元素的操作,它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作。这个模式的核心是“双分派”——第一次分派是客户端调用访问者的visit方法,第二次分派是元素对象调用访问者的具体方法,把自身作为参数传进去。
访问者模式是我认为所有行为型模式里最难理解的一个,也是在实际项目中最容易被误用的一个。它的适用场景非常特定:对象结构中的元素类很少变化,但需要经常在这些元素上定义新的操作。比如一个编译器需要对着 AST(抽象语法树)做类型检查、代码生成、优化等多种操作,AST 的节点类型相对稳定,但操作会不断增加,这就非常适合访问者模式。
但如果元素类经常变化,访问者模式就是灾难。因为每新增一个元素类,所有访问者都要加一个对应的visit方法。我见过一个项目用访问者模式处理订单,结果每加一种订单类型就要改五个访问者类,维护成本极高。所以用访问者模式之前一定要想清楚:元素类是否稳定?操作是否经常增加?如果答案是否,那就别用。
7.2 文档导出功能的访问者实现
下面用 Java 实现一个文档导出功能。文档由段落和图片两种元素组成,需要支持导出为 HTML 和 Markdown 两种格式。用访问者模式之后,新增导出格式只需要加一个访问者类,文档元素类完全不用改。
interface DocumentElement { void accept(Visitor visitor); } class Paragraph implements DocumentElement { private String text; public Paragraph(String text) { this.text = text; } public String getText() { return text; } @Override public void accept(Visitor visitor) { visitor.visit(this); } } class Image implements DocumentElement { private String url; public Image(String url) { this.url = url; } public String getUrl() { return url; } @Override public void accept(Visitor visitor) { visitor.visit(this); } } interface Visitor { void visit(Paragraph paragraph); void visit(Image image); } class HtmlExportVisitor implements Visitor { @Override public void visit(Paragraph paragraph) { System.out.println("<p>" + paragraph.getText() + "</p>"); } @Override public void visit(Image image) { System.out.println("<img src=\"" + image.getUrl() + "\" />"); } } class MarkdownExportVisitor implements Visitor { @Override public void visit(Paragraph paragraph) { System.out.println(paragraph.getText()); } @Override public void visit(Image image) { System.out.println(" + ")"); } } class Document { private java.util.List<DocumentElement> elements = new java.util.ArrayList<>(); public void addElement(DocumentElement element) { elements.add(element); } public void export(Visitor visitor) { for (DocumentElement element : elements) { element.accept(visitor); } } } public class Main { public static void main(String[] args) { Document doc = new Document(); doc.addElement(new Paragraph("这是一段文字")); doc.addElement(new Image("https://example.com/img.png")); doc.addElement(new Paragraph("这是另一段文字")); System.out.println("--- HTML 导出 ---"); doc.export(new HtmlExportVisitor()); System.out.println("--- Markdown 导出 ---"); doc.export(new MarkdownExportVisitor()); } }这个实现里,DocumentElement 是元素接口,Paragraph 和 Image 是具体元素。Visitor 是访问者接口,HtmlExportVisitor 和 MarkdownExportVisitor 是具体访问者。Document 是对象结构,它持有一个元素列表,export方法接收一个访问者并遍历所有元素。新增导出格式时,只需要实现一个新的 Visitor 类,Document、Paragraph、Image 都不用改。
7.3 访问者模式的代价与替代方案
访问者模式虽然强大,但它的代价也不小。第一个代价是理解成本高。双分派机制对不熟悉的人来说很绕,代码读起来需要跳来跳去。我团队里新来的同事第一次看访问者模式的代码时,花了半天才理清调用关系。所以如果你的团队对设计模式不熟悉,用访问者模式之前最好先做一次内部分享,把原理讲清楚。
第二个代价是扩展元素类困难。前面说过,新增元素类要改所有访问者。如果你的元素类可能会增加,那访问者模式就不合适。这时候可以考虑用策略模式加类型判断来替代,虽然会有一些 if-else,但扩展起来更简单。或者用反射机制来动态调用访问者方法,避免修改访问者接口,但反射会带来性能损耗和类型安全问题。
第三个经验是关于访问者的状态管理。如果访问者需要在遍历过程中累积状态(比如统计段落数量、收集所有图片链接),那访问者对象就不能是单例的,每次遍历都要新建一个访问者实例。我见过一个同事把访问者做成了单例,结果多次导出时统计数据累加了,排查了好久才发现问题。所以访问者如果是有状态的,一定要每次遍历新建实例。
| 模式 | 适用场景 | 扩展元素类 | 扩展操作 |
|---|---|---|---|
| 访问者模式 | 元素类稳定、操作多变 | 困难 | 容易 |
| 策略模式 | 算法多变、客户端选择 | 不涉及 | 容易 |
| 模板方法 | 流程固定、步骤可变 | 通过继承扩展 | 需要改父类 |
提示:访问者模式在编译器、代码分析工具、报表生成系统里应用广泛。如果你在做类似的项目,访问者模式值得深入学习。但如果你只是做普通的业务开发,访问者模式的使用频率其实很低,不必强行套用。
8. 六个模式的横向对比与选型建议
8.1 从变化维度看模式选型
行为型模式的下半场六个模式,每个都针对特定的变化维度。备忘录模式应对的是“状态需要回滚”的变化,观察者模式应对的是“通知对象需要动态增减”的变化,状态模式应对的是“对象行为随状态变化”的变化,策略模式应对的是“算法需要灵活替换”的变化,模板方法模式应对的是“流程固定但步骤可变”的变化,访问者模式应对的是“操作需要在不改类的前提下增加”的变化。
理解了这个对应关系,选型就变得清晰了。当你发现代码里有一大坨 if-else 在判断状态时,考虑状态模式;当你发现每次新增一种算法都要改核心类时,考虑策略模式;当你发现多个对象需要同时感知某个变化时,考虑观察者模式;当你发现需要撤销功能时,考虑备忘录模式;当你发现多个类有相同的流程但步骤实现不同时,考虑模板方法模式;当你发现需要在不改类的前提下频繁增加操作时,考虑访问者模式。
但这里要强调一点:模式不是越多越好。我见过一个项目把六个行为型模式全用上了,结果代码复杂度爆炸,新人根本看不懂。设计模式是为了降低复杂度,而不是增加复杂度。如果一个简单的 if-else 就能解决的问题,就不要硬套模式。模式的价值在于应对变化,如果某个维度根本不会变,那就别为它设计扩展点。
8.2 常见组合使用场景
在实际项目中,这些模式经常组合使用。备忘录模式和命令模式组合可以实现撤销重做,观察者模式和中介者模式组合可以实现事件总线,状态模式和策略模式组合可以实现可配置的工作流引擎,模板方法模式和策略模式组合可以实现可插拔的数据处理管道。
我做过一个审批工作流系统,就同时用到了状态模式、策略模式和模板方法模式。状态模式管理审批单的状态流转,策略模式处理不同审批类型的审批规则,模板方法模式定义审批流程的骨架。三个模式各司其职,代码结构非常清晰。但我也要提醒一句,组合使用模式时要注意职责边界,不要让一个类同时承担多个模式的角色,否则会变成“上帝类”。
| 组合场景 | 涉及模式 | 解决的问题 |
|---|---|---|
| 撤销重做 | 备忘录+命令 | 操作可回滚 |
| 事件总线 | 观察者+中介者 | 多对多通知解耦 |
| 工作流引擎 | 状态+策略 | 状态流转+规则可配置 |
| 数据处理管道 | 模板方法+策略 | 流程固定+步骤可插拔 |
| 编译器 | 访问者+解释器 | AST 遍历+多操作 |
8.3 我个人的模式使用心得
最后分享几点我在实际项目中使用这些模式的心得。第一,先写能跑的代码,再重构出模式。不要一开始就想着用什么模式,先把功能实现出来,等发现代码有坏味道了再重构。我见过太多人一开始就过度设计,结果需求一变,设计全白费。
第二,模式的名字不重要,重要的是理解它背后的思想。面试时能说出模式的名字当然好,但实际工作中更重要的是知道什么时候该解耦、什么时候该封装、什么时候该留扩展点。这些思想比模式本身更有价值。
第三,多读优秀开源项目的源码。设计模式在书本上很抽象,但在真实项目里你能看到它们是怎么被使用的。比如 Spring 框架里大量使用了模板方法模式和策略模式,Java 的集合框架里使用了迭代器模式,这些源码比任何教程都更有说服力。
第四,不要为了用模式而用模式。我职业生涯早期特别热衷于在项目里套模式,觉得用得越多越显得专业。后来才明白,最好的代码是简单直接的代码,模式只是在你需要应对变化时的工具。如果变化还没来,就不要提前为它设计。
提示:如果你正在准备面试,建议把每个模式的“意图”“适用场景”“优缺点”和“一个真实例子”这四点准备好。面试官通常不会让你默写代码,而是给你一个场景让你判断该用什么模式。所以理解场景比背诵定义更重要。
这个系列写到这里,行为型模式的十一个模式就全部覆盖完了。从上半场的责任链、命令、解释器、迭代器、中介者,到下半场的备忘录、观察者、状态、策略、模板方法、访问者,每一个模式都是前人经验的结晶。但记住,模式是死的,业务是活的。真正的高手不是能把所有模式倒背如流的人,而是能根据具体场景选择最合适方案的人。我在实际项目里最常用的其实就三四个模式,其他的更多是知道有这么个东西,需要的时候能想起来去查。这就够了。