【设计模式精讲】29.行为型模式总结与对比(Behavioral Patterns Summary)
【摘要】:行为型模式常被压缩成「一堆回调技巧」,十一种模式背起来像十一道独立考题。其实按「各自要解决的问题」聚类,四个问题就装下了:一组对象怎么对话(观察者/中介者/责任链)、变化的行为装进哪里(策略/模板方法/状态)、过去怎么留住(命令/备忘录)、结构怎么加工(迭代器/访问者/解释器)。同组是近亲,结构最像、最易混淆,本文逐组细说区别与联系,重点辨析「通信三兄弟」;跨组模式几乎不会认错,却常串成流水线。再以横向对比表、决策树、组合案例、常见误判与评审清单收口。读完你能对着一段交互代码说出:问题属于哪一组,组内该选谁。
【关键词】:行为型模式、模式聚类、通信模型、职责分配、模式协作、决策树
【代码基准】:C++17
1. 十一种模式都在分配职责,为什么不能随便换
行为型模式常被压缩成一句话:「把行为做成对象。」这句话只描述了表面动作,没有说明每种模式把什么做成了对象、又要保护什么:责任链保护「发送者不知道谁会认领」,命令保护「请求留痕可回放」,备忘录保护「当时」那份数据的封装,观察者保护「发布者对订阅者的无知」,访问者保护「加操作不改结构」——十一种模式,十一种保护对象,这正是它们不能互相替换的原因。
把模式当成可互换的回调语法,设计很快走偏:用观察者做本该中介者做的事(事件之后需要按规则指挥多个对象,广播出去就失控了);用命令装策略(撤销栈里堆满被反复调用的算法对象);用备忘录扛命令的活(大状态深历史,内存先崩);把策略写成状态、状态写成策略(第 25/26 篇的镜像对互换);树形结构上用walk硬扛类型分派(第 28 篇早已给过升级路线)。细看这些走偏案例,几乎全是近亲冒充——用回答同一个问题的甲模式,去做乙模式的活;混组的错反而少见,没人会用迭代器去冒充命令。最容易认错的,永远是挤在同一个问题里的那几个模式,这正是下一节聚类的依据。
因此,不要先问「这里怎样加一层回调」,而要先问「压力属于哪一类问题」。若直接调用、直接返回、一个 if 就能表达,就保留简单设计。行为型模式比结构型更依赖意图判别——同一张类图,换个意图就是另一个模式,这是 25/26、19/26 两组「镜像对」反复证明过的。
2. 按问题聚类:四个问题装下十一种模式
十一个名字不必当十一个独立知识点背。按「要解决的问题」聚类,它们聚成四组:
| 组 | 共同的问题 | 成员 |
|---|---|---|
| 通信 | 一组对象之间,请求与事件怎么流动 | 观察者、中介者、责任链 |
| 行为替换 | 会变的行为装进哪里、由谁来换 | 策略、模板方法、状态 |
| 历史回溯 | 发生过的事怎么留、怎么回 | 命令、备忘录 |
| 结构加工 | 站在结构外面,按什么规则加工它 | 迭代器、访问者、解释器 |
分组的第一个用处是收缩候选:认出问题属于哪一组,候选立刻从十一个缩到两三个。第二个用处是集中辨析:容易混淆的模式几乎都在组内,近亲们类图相似、术语相通,都有「接口 + 实现 + 委托」的骨架;跨组的模式形态差得远,几乎不会认错,反而经常合作——辨析的火力因此全部投向组内,下面逐组展开。
2.1 通信组:观察者、中介者、责任链
三者都在发送者与接收者之间架一条通道,让发送者不必指名道姓——这是行为型「解耦」的原始形态。区别全在对话的结构:
| 模式 | 通信方向 | 中间结构的意志 | 终止性 | 一句话 |
|---|---|---|---|---|
| 观察者 | 一对多广播 | 无:送达即结束 | 不终止,各自反应 | 「我变了,诸位自便」 |
| 中介者 | 多对多经中心 | 有:听报告、下指令 | 中心裁决每次交互 | 「都听我调度」 |
| 责任链 | 一对一接力 | 无:只有认领与否 | 认领即停 | 「不归我,交下一位」 |
判别只剩一问:事件之后谁做决定?没人做决定(各自反应)是观察者,一个中心做决定(指挥多方)是中介者,由认领者自己决定(处理即终点)是责任链。
联系比区别更值得记,三者常互相成全:中介者订阅同事的事件,是把观察者当通信底座(第 22 篇的回调槽装配正是此形);中介者把无人认领的请求转交专家链,是给责任链留外挂。观察者名单是「无序群发」,责任链是「有序、只出一个奖的抽奖」——给观察者名单加上顺序与短路语义,它就滑向责任链,GUI 事件冒泡正站在两者中间。
2.2 行为替换组:策略、模板方法、状态
三者都把「会变的行为」从使用方搬进独立单元,让不变的部分不必陪葬。区别在换什么、谁来换:
- 策略换的是整个算法:由客户端注入(换不换是使用者的事),运行期可换,各策略是彼此陌生的平行解法。
- 模板方法换的是骨架里的若干步骤:由子类填空,编译期定(注入版除外),骨架锁死在基类,子类是族成员。
- 状态换的是随生涯阶段整套切换的行为:对象在操作里自己迁移(自己换自己),状态之间互为迁移目标、彼此认识。
两两对照是三场经典对决。策略 vs 模板方法是「委托 vs 继承」在行为型的正面清算,GoF 的比喻是一道菜的两种做法——整块替换还是磨碎成钩子;流程必须全族统一、只想定制个别步骤,用模板方法;连算法的内部结构都不想暴露给调用方,用策略。策略 vs 状态是全系列最著名的镜像对,两张 UML 几乎重叠,分野全在语义:谁驱动切换(外部注入 vs 自己迁移)、参与者是否互识(平行陌生 vs 互为迁移目标)、建模对象(算法插头 vs 对象生涯)。模板方法 vs 状态这对常被忽略:前者一次绑定,选定子类就定了填法;后者反复迁移,运行中随事件一换再换——前者固化流程,后者切换人格。
组内也常合作而非竞争:模板方法的钩子可以注入一个策略(第 27 篇注入版),状态的某个阶段内部也可以持一个策略——三者可以在不同层次各司其职。
2.3 历史回溯组:命令、备忘录
两者都在跟「过去」打交道:把现在发生的事保存成实体,让系统以后能撤销、回滚、重放、审计。区别在记什么:
- 命令记操作——「做了什么」:动作、参数与逆操作随身携带;粒度细,可排队、可重放、可宏录制;撤销就是执行逆操作。
- 备忘录记状态——「当时什么样」:一份密封快照,看守者只管保管不看内容;粒度粗,不关心操作序列;撤销就是恢复快照。
选择有现成口诀(第 23 篇三条路线):操作规整可逆用命令,状态小、操作杂用快照,深历史用「快照锚点 + 命令回放」的混合——数据库 WAL 与游戏 checkpoint 是同一配方的工业形态。两者也常合体:命令的undo()内部持一份操作前小快照,细账粗账一起记。
还要与邻组的策略划一条线:命令是一次请求的物化,生命周期与那次请求绑定、一次性消费;策略是长命算法,被反复调用、随时替换——「撤销栈里堆满算法对象」就是认错了这条线,第 6 节误判三正面展开。
2.4 结构加工组:迭代器、访问者、解释器
三者都站在对象结构外面加工它,把「怎么加工」从结构自己的类里搬出来——组合树不必为每种统计、导出、校验各长一个方法。区别在按什么规则加工:
- 迭代器按位置:顺序交付元素、不问类型,客户端拿到元素后自己决定做什么。它交付的是「第几个」。
- 访问者按类型:每个元素分派到对应的重载,一类元素一类动作、不问位置。它处理的是「是哪种」。
- 解释器按语义:结构本身就是程序(文法树),「加工」就是执行它,变化的是句子而非文法。它执行的是「写了什么」。
这组内部与其说竞争,不如说流水线:迭代器找位置、访问者按类型分派(第 21/28 篇的「迭代 + 分派」);解释器与访问者是同一谱系的两端——「操作写在节点内」的 GoF 式解释器与「操作外置」的访问者在第 20 篇就已会师,而解释器的文法树本身就是组合模式(第 12 篇)。这组的赌注也最清楚:访问者赌元素类型封闭,解释器赌文法稳定,迭代器赌失效契约写得明白。
真实系统里四组同场:UI 框架中事件广播与控件联动是通信组,折扣与后端切换是行为替换组,操作历史是历史回溯组,渲染遍历是结构加工组——四组各答各的问题,互不抢戏;说不清压力落在哪一组,才是问题。
3. 十一种模式横向对比表
| 模式 | 分组 | 意图 | 核心角色 | 典型所有权 | 主要优点 | 主要代价 | 识别信号 |
|---|---|---|---|---|---|---|---|
| 观察者 | 通信 | 一对多依赖,变化自动通知 | Subject、Observer | 名单只借不拥,令牌或 weak 护生命周期 | 运行时增删订阅,广播免轮询 | 通知风暴、顺序无保证、生命周期三坑 | 一对多、订阅者动态、主题不问后果 |
| 中介者 | 通信 | 封装一组对象的交互 | Mediator、Colleague | 中介者创建并持有同事 | 网状收星型,规则集中可单测 | 中枢易成上帝对象,单点高扇入 | 一组对等对象按复杂规则互相联动 |
| 责任链 | 通信 | 请求沿链传递,直到有对象处理 | Handler、ConcreteHandler、Client | 链节点常不拥有后继,装配方管理 | 发送者与处理者解耦,增删处理者只动装配 | 请求可能无人认领,长链空转,认领隐式难调试 | 多个候选者按序给机会、通常一个认领 |
| 策略 | 行为替换 | 算法族各自封装可互换 | Strategy、ConcreteStrategy、Context | 策略自带配置,注入后借用或持有 | 运行时换算法,消除条件语句 | 选择负担,接口分母过肥 | 平行解法要增、要换、要组合 |
| 模板方法 | 行为替换 | 骨架固化,步骤留给子类 | AbstractClass、ConcreteClass | 骨架与横切逻辑归基类 | 仪式只写一次,扩展点可枚举 | 继承硬耦合,骨架演化伤全员 | 一族流程顺序相同、个别步骤不同 |
| 状态 | 行为替换 | 状态一变、行为整套换 | State、ConcreteState、Context | 状态对象零数据全共享,字段归上下文 | switch 消除,迁移出边局部化 | 类数随状态膨胀,全景图要拼装 | 行为随阶段显著变化、状态较多 |
| 命令 | 历史回溯 | 把请求封装为对象 | Command、Invoker、Receiver、Client | 命令入队后归调用者,自足携带接收者 | 排队、日志、撤销、宏、序列化全白拿 | 类数量膨胀,不可逆操作的 undo 是真功夫 | 请求要留痕、重放、撤销或跨线程投递 |
| 备忘录 | 历史回溯 | 封装内化对象状态供恢复 | Originator、Memento、Caretaker | 快照归看守者保管,解释权归原发器 | 封装不破,状态恢复集中,快照可搬运 | 全量快照内存大,含句柄状态要立约 | 撤销、事务、存档、回溯 |
| 迭代器 | 结构加工 | 顺序访问聚合而不暴露表示 | Iterator、Aggregate、Client | 迭代器是未托管句柄,容器定义失效契约 | 遍历与表示解耦,接入 STL 上百算法 | 失效规则是文档契约,样板重 | 自定义容器、多遍历序、惰性流式数据 |
| 访问者 | 结构加工 | 不改元素类为结构加操作 | Visitor、Element、ObjectStructure | 操作及其状态归访问者,遍历常归访问者 | 加操作零改动结构,加类型编译点名 | 加元素全访问者返工,循环依赖 | 结构稳定、操作持续疯长 |
| 解释器 | 结构加工 | 给文法一个表示和解释器 | Expression、Terminal、Nonterminal、Context | 树归解析产物,非终结符拥有子树 | 规则进数据,句子可变而文法稳定 | 类爆炸,解析报错版本兼容是隐形工程量 | 规则以字符串到达、文法小而稳 |
这张表不是关键词匹配器。「有回调」不能证明观察者,「有 switch」不能证明策略或状态,「有个中间类」不能证明中介者。反向核验的永远是角色与所有权:谁持有谁、谁决定谁、状态与历史归谁、失败与终止谁负责。
4. 选择决策树
下面的决策树按第 2 节的分组逐层过滤,四层就是四个问题:
四层的顺序不是重要性排序:先问通信,因为通信结构影响全局拓扑,改错最疼;再问行为替换,它牵动调用方与实现方的接口关系;再问历史回溯,撤销与状态机的错误一旦定型返工最贵;最后是结构加工,它们最像库代码,替换成本最低。走到「不需要模式」依然是理想结果——一个 if 能分清的两个候选者、一个从不撤销的操作、一个只有两个订阅者的通知,直接写就是最好的行为型模式。
5. 模式怎样组合
分组解决「选谁」,组合解决「怎么一起用」。同组的搭档靠分工:一个做底座一个做大脑、一个记细账一个记粗账;跨组的搭档靠流水线:各管一段,互不知情。
命令 + 备忘录(同组搭档)是撤销的完整答案:命令记操作与参数,快照定锚点,深历史下「回滚到锚点 + 重放」;第 19、23 篇各自收尾时都指向这条混合路线,数据库 WAL 与游戏 checkpoint 是同一配方。
观察者 + 中介者(同组分层)是事件系统的标准分层:同事发事件(观察者底座),中枢订阅并按规则指挥(中介者大脑)——第 22 篇改进一的回调槽装配正是它。
组合 + 访问者 + 迭代器(跨族流水线)是结构加工的完整链路:组合提供树,迭代器按位置交付,访问者按类型分派——第 12、21、28 篇三笔账在 AST 上一次结清。
下面的小例子跨三组串成一条告警流水线:传感器样本经观察者总线广播(通信),策略判定是否越限(行为替换),越限动作物化为命令进异步队列(历史回溯):
#include<functional>#include<memory>#include<queue>#include<string>#include<vector>// ---- 观察者:样本总线(第 24 篇槽位版)----classSampleBus{public:usingSlot=std::function<void(double)>;usingId=int;Idsubscribe(Slot s){slots_.emplace_back(next_,std::move(s));returnnext_++;}voidpublish(doublev){autosnap=slots_;// 副本抗重入for(auto&[id,s]:snap)if(s)s(v);}private:std::vector<std::pair<Id,Slot>>slots_;intnext_=0;};// ---- 策略:越限判定(第 26 篇)----usingAlarmRule=std::function<bool(double)>;AlarmRuleoverThreshold(doublelimit){return[limit](doublev){returnv>limit;};}// ---- 命令:告警动作(第 19 篇)----structAlertAction{std::function<void()>execute;std::string reason;// 可审计的凭据};classAlertQueue{public:voidpush(AlertAction a){pending_.push(std::make_unique<AlertAction>(std::move(a)));}voiddrainAll(){while(!pending_.empty()){pending_.front()->execute();pending_.pop();}}private:std::queue<std::unique_ptr<AlertAction>>pending_;};// ---- 装配:三种模式在组合根相遇 ----classMonitor{public:explicitMonitor(SampleBus&bus):bus_(bus){}voidwireUp(AlarmRule rule,AlertQueue&alerts){rule_=std::move(rule);queue_=&alerts;sub_=bus_.subscribe([this](doublev){onSample(v);});}private:voidonSample(doublev){if(rule_(v)){std::string why="value "+std::to_string(v)+" over limit";queue_->push({[why]{/* 发短信/写库/呼号 */},why});}}SampleBus&bus_;AlarmRule rule_;AlertQueue*queue_=nullptr;SampleBus::Id sub_=0;};intmain(){SampleBus bus;AlertQueue alerts;Monitormon(bus);mon.wireUp(overThreshold(100.0),alerts);bus.publish(42.0);// 判定未过,bus.publish(131.4);// 命令入队alerts.drainAll();// 后台批量执行return0;}三种模式各守边界:总线不知道谁在听(观察者),规则是一段可替换的可调用体(策略),动作是带凭据的对象、进队后随取随放(命令)。想换「滑动窗口判定」只换AlarmRule;想加「告警落盘」再订一个总线槽;想把告警改成「合并五分钟一条」只动AlertQueue——三个问题各有自己的落点,这是行为型组合的全部意义。
6. 常见误判
回看高频误判,认错都发生在近亲之间——形态差得远的模式,没人会认错。
误判一:「加了个回调」就叫观察者。观察者的成立要件是可增减的订阅名单与发布者对订阅者的无知。单一固定回调是依赖注入或策略槽;写死的两个listener->on()调用只是间接层。反之,明明有五方关心事件、名单要运行时增减,还硬用字段回调,就是欠下的观察者。
误判二:状态与策略傻傻分不清。三问定案:谁驱动切换(外部注入 vs 自己迁移)、参与者是否互识(平行陌生 vs 互为迁移目标)、建模对象(算法 vs 生涯)。把策略写成状态,算法对象之间会冒出不该有的迁移耦合;把状态写成策略,迁移规则会散回调用方、switch 借尸还魂。
误判三:命令当策略、策略当命令。一次请求(入栈、撤销、回放、跨线程投递)用命令;长命算法(反复调用、随时替换、作为库的定制点)用策略。撤销栈里堆算法对象、或给每个函数调用包一层命令类,都是两头不讨好。
误判四:结构不稳定就上访问者。访问者的赌注是「元素类型封闭」。类型清单还会长的(配置模型、协议版本演进),用variant保持编译期显式,或干脆 switch 加单测兜底;硬上访问者,每次加类型都是全族访问者的返工日。反过来,操作只有一两个也犯不上双分派——直接成员函数最便宜。
还要警惕把「接口有多个实现」叫策略,「有个 switch」叫状态机,「有事件」叫发布订阅。行为型的识别永远看角色与所有权:谁决定、谁认领、谁保管历史、谁负责终止——答不清这些,模式名称再精确也没有意义。
7. 代码评审清单与下一部分
评审交互代码时,可以依次检查以下问题:
- 问题定位:压力属于通信、行为替换、历史回溯还是结构加工?组内若同时出现两个模式,分工(底座/大脑、细账/粗账)说得出吗?
- 谁决定:事件之后谁做决定——各自反应、中心指挥、认领即停?通信结构与业务语义一致吗?
- 时间维度:请求需要留痕或撤销吗?状态需要存档吗?撤销走命令、快照还是混合?「无人认领」「恢复失败」的兜底在哪里?
- 生命周期:回调对象可能先死吗?订阅有令牌或 weak 护体吗?notify 中的增删被重入护栏挡住了吗?
- 顺序依赖:观察者之间、过滤器之间是否藏着隐式顺序假设?顺序变化时谁来暴露这个假设?
- 过度设计:单实现配了策略接口、两状态配了状态类、单订阅配了总线、从不撤销配了命令栈——哪个抽象的扩展点从未被使用?
十一种行为型模式最终都在回答「职责怎样分配」。分组把选择变成两步:先认出问题属于哪一组,再在组内两三个近亲里挑出最小的机制;需要组合时,让每种模式只守住自己的边界。
下一部分是全专栏的收官:把创建、结构、行为三族 23 种模式放到一个真实小项目里串起来,展示它们如何协作而非堆砌;然后是避坑指南与反模式速览——包括那个贯穿全系列的追问:什么时候不该用模式。
本文的模式定义与取舍参考了 Refactoring Guru《设计模式》中文版相应各章,分组归纳与后果清单参考了 GoF《Design Patterns》第 5 章各模式小节,并综合了本系列第 18–28 篇的工程化表述。