)
【设计模式精讲】29.行为型模式总结与对比Behavioral Patterns Summary【摘要】行为型模式常被压缩成「一堆回调技巧」十一种模式背起来像十一道独立考题。其实按「各自要解决的问题」聚类四个问题就装下了一组对象怎么对话观察者/中介者/责任链、变化的行为装进哪里策略/模板方法/状态、过去怎么留住命令/备忘录、结构怎么加工迭代器/访问者/解释器。同组是近亲结构最像、最易混淆本文逐组细说区别与联系重点辨析「通信三兄弟」跨组模式几乎不会认错却常串成流水线。再以横向对比表、决策树、组合案例、常见误判与评审清单收口。读完你能对着一段交互代码说出问题属于哪一组组内该选谁。【关键词】行为型模式、模式聚类、通信模型、职责分配、模式协作、决策树【代码基准】C171. 十一种模式都在分配职责为什么不能随便换行为型模式常被压缩成一句话「把行为做成对象。」这句话只描述了表面动作没有说明每种模式把什么做成了对象、又要保护什么责任链保护「发送者不知道谁会认领」命令保护「请求留痕可回放」备忘录保护「当时」那份数据的封装观察者保护「发布者对订阅者的无知」访问者保护「加操作不改结构」——十一种模式十一种保护对象这正是它们不能互相替换的原因。把模式当成可互换的回调语法设计很快走偏用观察者做本该中介者做的事事件之后需要按规则指挥多个对象广播出去就失控了用命令装策略撤销栈里堆满被反复调用的算法对象用备忘录扛命令的活大状态深历史内存先崩把策略写成状态、状态写成策略第 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 节的分组逐层过滤四层就是四个问题没有有广播出去各自反应需要中心指挥多方多个候选者按序给机会否整个算法要可换骨架固定只差步骤行为随状态整体切换否请求要留痕、撤销、回放状态要存档回滚否统一遍历不问表示稳定结构加新操作字符串规则要执行都不是交互代码出现了明确的职责分配压力吗不需要模式直接调用与返回通信一组对象怎么对话观察者中介者责任链行为替换要换的是行为本身吗策略模板方法或注入状态两态用 enum历史回溯需要回到过去吗命令深历史混第 23 篇快照备忘录结构加工要在结构上做文章吗迭代器访问者解释器或嵌入引擎四层的顺序不是重要性排序先问通信因为通信结构影响全局拓扑改错最疼再问行为替换它牵动调用方与实现方的接口关系再问历史回溯撤销与状态机的错误一旦定型返工最贵最后是结构加工它们最像库代码替换成本最低。走到「不需要模式」依然是理想结果——一个 if 能分清的两个候选者、一个从不撤销的操作、一个只有两个订阅者的通知直接写就是最好的行为型模式。5. 模式怎样组合分组解决「选谁」组合解决「怎么一起用」。同组的搭档靠分工一个做底座一个做大脑、一个记细账一个记粗账跨组的搭档靠流水线各管一段互不知情。命令 备忘录同组搭档是撤销的完整答案命令记操作与参数快照定锚点深历史下「回滚到锚点 重放」第 19、23 篇各自收尾时都指向这条混合路线数据库 WAL 与游戏 checkpoint 是同一配方。观察者 中介者同组分层是事件系统的标准分层同事发事件观察者底座中枢订阅并按规则指挥中介者大脑——第 22 篇改进一的回调槽装配正是它。组合 访问者 迭代器跨族流水线是结构加工的完整链路组合提供树迭代器按位置交付访问者按类型分派——第 12、21、28 篇三笔账在 AST 上一次结清。下面的小例子跨三组串成一条告警流水线传感器样本经观察者总线广播通信策略判定是否越限行为替换越限动作物化为命令进异步队列历史回溯#includefunctional#includememory#includequeue#includestring#includevector// ---- 观察者样本总线第 24 篇槽位版----classSampleBus{public:usingSlotstd::functionvoid(double);usingIdint;Idsubscribe(Slot s){slots_.emplace_back(next_,std::move(s));returnnext_;}voidpublish(doublev){autosnapslots_;// 副本抗重入for(auto[id,s]:snap)if(s)s(v);}private:std::vectorstd::pairId,Slotslots_;intnext_0;};// ---- 策略越限判定第 26 篇----usingAlarmRulestd::functionbool(double);AlarmRuleoverThreshold(doublelimit){return[limit](doublev){returnvlimit;};}// ---- 命令告警动作第 19 篇----structAlertAction{std::functionvoid()execute;std::string reason;// 可审计的凭据};classAlertQueue{public:voidpush(AlertAction a){pending_.push(std::make_uniqueAlertAction(std::move(a)));}voiddrainAll(){while(!pending_.empty()){pending_.front()-execute();pending_.pop();}}private:std::queuestd::unique_ptrAlertActionpending_;};// ---- 装配三种模式在组合根相遇 ----classMonitor{public:explicitMonitor(SampleBusbus):bus_(bus){}voidwireUp(AlarmRule rule,AlertQueuealerts){rule_std::move(rule);queue_alerts;sub_bus_.subscribe([this](doublev){onSample(v);});}private:voidonSample(doublev){if(rule_(v)){std::string whyvalue std::to_string(v) over limit;queue_-push({[why]{/* 发短信/写库/呼号 */},why});}}SampleBusbus_;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 篇的工程化表述。