Chapter 04 · 行为型
行为型:把会变的行为、状态、通知抽出来
第 03 章讲的是在对象外面套一层;这一章把对象内部会变的那部分行为抽出来,交给独立对象。它也兑现 01 埋下的伏笔:策略和状态的类图一模一样,到底靠什么分开。本章交付这道王牌辨析。
本章你将建立的 schema
- 策略(换算法)vs 状态(随状态变行为、状态自己触发转移)——本章的王牌辨析,兑现 01 的承诺。
- 观察者:对象状态变化时,自动通知一对多的订阅者;与中介者的「中心 hub」划清界线。
- 模板方法:用继承换步骤,正面对照策略用组合换算法——回到 01.5 的好莱坞原则。
- 判断钥匙:可互换的对象之间会不会互相切换?会→状态;不会、由外部选→策略。
4.1策略:归位到行为型(简短回顾)
第 01 章 §1.3 已经把策略深讲过一遍——它是最纯粹地体现「封装变化」的模式。这里不重开,只把它正式归位到行为型,并钉住三个点,作为下面状态辨析的对照基准。
策略:把一族「可互换的算法」各自封装成对象,让它们能在运行时被替换。分离出去的,是「算法这一处变化」。
① Context 委托。 Checkout 持有一个 DiscountStrategy,把会变的计算委托给它;骨架不知道背后是哪种折扣。
② 客户端选策略。 用哪个策略由外部客户端 new Checkout(new VipDiscount()) 决定。
③ 策略互不相识。 VipDiscount 不认识 SummerCoupon,任何一个策略都不决定「下一步该换成谁」。
这三点——尤其第 ③ 点——是下文区分策略和状态的唯一钥匙。把它们记牢,4.3 节的状态会逐条把它们翻转过来。
4.2状态:行为随状态改变,仿佛换了个类
状态(State):让一个对象的行为随其内部状态改变——状态一变,对象看上去就像换了一个类。分离出去的,是「对象处在不同生命周期阶段时的不同行为」。
一个对象在生命周期里会经过多个阶段(订单:待支付 → 已支付 → 已发货),每个阶段下,同一个操作的行为不同——已支付的订单能发货,待支付的不能。不用状态模式时,这套逻辑会塌缩成一大坨 if (status == PAID) … else if (status == SHIPPED) … 散落在每个方法里。状态模式把每个阶段的行为各自封装成一个状态对象,把那坨条件判断换成多态。
机制:类图和策略全等,但状态对象彼此知晓、自己触发转移
这是比文档深一层的关键。状态的类图——一个 Context、一个状态接口、一族具体状态、Context 委托给当前状态——和策略完全相同(01 的图 1.2 已经并排画过)。区别全在文档不画、类图不显示的两件事上:
- 状态对象彼此知晓。
PendingState知道下一步是PaidState——而策略里VipDiscount绝不认识SummerCoupon。 - 转移逻辑住在状态对象里,不在客户端。 由当前状态自己决定下一个状态(典型写法:
next()返回另一个状态,或状态内部调context.setState(next))。客户端只管触发动作,不挑下一个状态。
用订单状态机走一遍。需求:Pending(待支付)收到 pay() 后变 Paid;Paid 收到 ship() 后变 Shipped;在错误的阶段做错误的动作要被拒绝(待支付的订单不能发货)。
// ① 状态接口:每个状态都能响应这两个动作
interface OrderState:
pay(order): void // 在本状态下「支付」会发生什么
ship(order): void // 在本状态下「发货」会发生什么
// ② 待支付:只有 pay() 有意义,且它自己决定转移到 Paid
class PendingState implements OrderState:
pay(order):
print("收款成功")
order.setState(new PaidState()) // ← 状态自己触发转移
ship(order):
throw Error("未支付,不能发货") // 错误阶段的动作被拒绝
// ③ 已支付:只有 ship() 有意义,转移到 Shipped
class PaidState implements OrderState:
pay(order): throw Error("已支付,勿重复")
ship(order):
print("已发货")
order.setState(new ShippedState()) // ← 同样由状态自己转移
// ④ 已发货:终态,两个动作都到头了
class ShippedState implements OrderState:
pay(order): throw Error("早已支付")
ship(order): throw Error("已发货,勿重复")
// ⑤ Context:持有当前状态,把动作委托给它
class Order:
state: OrderState = new PendingState() // 初始态
setState(s): this.state = s // 供状态回调,完成转移
pay(): this.state.pay(this) // 委托:行为取决于当前是哪个状态
ship(): this.state.ship(this)
// ⑥ 客户端只触发动作,从不挑选下一个状态
order = new Order()
order.pay() // Pending → Paid,打印"收款成功"
order.ship() // Paid → Shipped,打印"已发货"
order.ship() // 抛错:已发货,勿重复
逐行 → 概念(不是逐行 → 语法)
② PendingState.pay() 里那一行 order.setState(new PaidState()) 是状态模式的心脏:转移逻辑写在状态对象内部,由它自己发起。对照 01 §1.3 的策略——策略对象的方法里绝不会出现「把 Context 换成另一个策略」这种代码。
②③④ 错误阶段抛错:每个状态只让「该阶段合法的动作」生效。原本散在各方法里的 if (status == …) 判断,被这三个类的多态吃掉了。
⑤ Order 通过持有 state 拿到行为(组合),和 Checkout 持有 strategy 写法一致——这正是为什么两者类图全等。
⑥ 客户端只调 pay() / ship(),从不写 order.setState(...)。谁决定下一个状态?状态自己。 这一行对比 01 §1.3 的「客户端 new Checkout(new VipDiscount()) 选策略」,就是策略与状态的全部分野。
状态少、转移简单时别上。 只有两三个状态、转移一目了然,一个 enum 字段加几个 if 往往更清楚。状态模式的代价有两条:① 类爆炸——每个状态一个类,十个状态就是十个类;② 转移逻辑分散——「下一步去哪」散落在各个状态对象里,想看全状态机的整体转移图,得把所有类翻一遍(图 4.1 这种全局视图,代码里不存在)。状态多且转移复杂时,这两条代价换来的清晰才划算。
一个文本编辑器,支持「左对齐 / 居中 / 右对齐」三种排版方式,用户在工具栏点按钮切换。三种排版各是一段独立算法。这里该用策略还是状态?如果需求改成「文档处于草稿 / 评审 / 已发布,不同阶段能做的编辑操作不同,且评审通过会自动进入已发布」,答案变不变?
展开答案(先停 10 秒再点)
第一问:策略。 三种排版是可互换的算法,由用户(客户端)从外部选;「左对齐」不会自己决定切到居中——排版方式之间互不相识。符合策略三特征(参见 4.1)。
第二问:变,改用状态。 草稿/评审/已发布是文档生命周期里的阶段,而且关键信号出现了——「评审通过自动进入已发布」是状态自己触发的转移,客户端没有挑下一个状态。可互换的对象之间会互相切换 → 状态。
同一个判断,钥匙都是 4.4 节那句:可互换的对象之间会不会互相切换?
4.3王牌辨析:策略 vs 状态
这是 01 §1.4 承诺要兑现的辨析,也是全教程行为型的核心。前提先说死:两者类图全等——Context、接口、一族实现、委托调用,一个零件不差。靠类图永远分不开。区别只在意图,而意图落到一个可操作的信号上。
| 维度 | 策略(Strategy) | 状态(State) |
|---|---|---|
| 类图 | Context + 接口 + 一族实现 | Context + 接口 + 一族实现(完全相同) |
| 意图 | 同一件事的多种算法,可互换 | 对象在生命周期里的多个状态,行为随之变 |
| 谁切换实现 | 客户端从外部选定,运行中通常不自变 | 状态对象自己在方法内触发转移到下一个 |
| 实现间是否知晓彼此 | 不知道(VIP 折扣不认识满减) | 知道(待支付知道下一步是已支付) |
| 代码里的信号 | 无 next() / setState();构造时注入 | 实现内部出现 context.setState(next) 或返回下一个状态 |
一句话判定法(把这句背下来):
可互换的对象之间,会不会互相切换?
会(实现 A 自己决定切到实现 B)→ 状态;不会、由外部客户端选定 → 策略。这个信号不在类图上,在「谁触发转移」这个意图里——正是 01 主线「读意图,不读类图」在最难一组双胞胎上的兑现。
回到 01 §1.2 的两招:策略和状态都用「面向接口 + 组合」把一处变化隔离到 Context 背后。用一样的两招,拼出来的形状当然一样。结构是手段的副产物、会重复;意图(分离的是算法还是状态、谁来切换)才是身份证。这就是为什么背类图必然在这里翻车。
4.4观察者:状态一变,自动通知一对多
观察者(Observer):当一个对象(subject)的状态变化时,自动通知所有订阅它的对象(observer)。分离出去的,是「谁关心这次变化、变化后做什么」。
一处数据变了,多处需要跟着响应:订单状态一更新,要发短信、要刷新看板、要记日志。把这些响应硬写进订单代码,订单就被迫认识短信、看板、日志三个模块——发送方和接收方焊死。观察者把这层关系反过来:订单只持有一个「订阅者名单」,变化时挨个通知;订单不知道、也不关心名单上具体是谁。发送方与接收方解耦,新增一个订阅者不碰订单代码。
机制:subject 持有订阅者列表,变化时 push 通知
subject 内部维护一个 observer 列表,提供 subscribe() / unsubscribe();状态一变,遍历列表逐个调用每个 observer 的 update(),把变化推过去。
// ① 观察者契约:被通知时要做什么
interface Observer:
update(event): void
// ② Subject:持有订阅者名单,变化时 push 通知
class OrderSubject:
observers: List<Observer> = []
subscribe(o): this.observers.add(o)
unsubscribe(o): this.observers.remove(o) // ← 不取消 = 内存泄漏来源
notify(event):
for o in this.observers: // 顺序 = 加入顺序,不可依赖
o.update(event) // 把变化推给每个订阅者
setShipped():
// …更新内部状态…
this.notify("SHIPPED") // 状态一变,广播
// ③ 三个互不相识的订阅者,各自决定收到通知后做什么
class SmsService implements Observer:
update(event): print("发短信:订单 " + event)
class Dashboard implements Observer:
update(event): print("刷新看板:" + event)
class AuditLog implements Observer:
update(event): print("记日志:" + event)
// ④ 客户端把订阅者挂上;subject 不知道它们具体是谁
order = new OrderSubject()
order.subscribe(new SmsService())
order.subscribe(new Dashboard())
order.subscribe(new AuditLog())
order.setShipped() // 一次状态变化,三个订阅者各自被通知
notify() 把变化推给多个 observer。注意:箭头全是单向的(subject → observer),三个 observer 彼此不认识——这正是它和中介者的分界,见 4.4 末尾。① 通知顺序不可预测。 notify() 按订阅者加入名单的顺序逐个调用,但这个顺序属于实现细节。任何依赖「短信一定比看板先收到」的逻辑都是定时炸弹——订阅顺序一改就坏。
② 失效监听器 → 内存泄漏(lapsed listener)。 订阅了却忘了 unsubscribe(),subject 的名单会一直握着那个 observer 的引用,垃圾回收无法回收它。长生命周期的 subject 上,这是经典的内存泄漏源。
③ 级联 / 环形通知。 observer 在 update() 里又触发了另一个 subject 的变化,后者再通知回来——通知链会层层放大,甚至形成环,导致重复触发或无限循环。
对比中介者(Mediator,○):单向一对多 vs 中心 hub
观察者常和中介者混淆,但意图不同:
- 观察者是单向的 pub → sub 一对多:一个 subject 广播,多个 observer 接收;observer 之间不通信(图 4.2 的箭头全朝一个方向)。
- 中介者处理的是组件间的多对多通信:把本该互相直连的 N 个组件,全部改成只和一个中心 hub 对话,让组件彼此互不直接认识。hub 负责把消息路由到该去的组件。
一句话:观察者解耦的是「一个变化源 → 多个响应方」;中介者解耦的是「多个组件 ↔ 多个组件」那张乱麻网,办法是塞一个交换机进去。
4.5模板方法:用继承换步骤
第 01 章 §1.5 用它当「组合优于继承」的例外预览过,并点出了好莱坞原则。这里正式展开。
模板方法(Template Method):父类把算法的骨架与步骤顺序定死,把其中会变的某几步留成钩子方法,交给子类重写。分离出去的,是「算法里会变的那几步」。
多个流程骨架相同、个别步骤不同:导出报表都是「打开 → 写表头 → 写正文 → 写表尾 → 关闭」,只有正文格式因报表种类而异。把整套流程在每个子类里抄一遍,骨架就被复制了 N 份,改一处顺序要改 N 个地方。模板方法把不变的骨架提到父类、只留会变的步骤当钩子,骨架只此一份。
机制:用继承(编译期)而非组合隔离变化
这是它和策略最深的分野。父类的模板方法定死调用顺序且不允许子类覆盖;子类只能填指定的钩子。控制权在父类——父类决定何时调用你的钩子,你不主动调它。这就是 01.5 的好莱坞原则:控制权在父类,父类在需要时回调子类的钩子,子类不主动调骨架。
abstract class ReportExporter:
// 模板方法:骨架 + 顺序写死,子类不许覆盖它
final export():
open()
writeHeader()
writeBody() // ← 钩子:唯一留给子类填的步骤
writeFooter()
close()
open(): print("打开文件")
writeHeader(): print("=== 报表 ===")
abstract writeBody() // 强制子类实现
writeFooter(): print("=== 完 ===")
close(): print("关闭文件")
class SalesReport extends ReportExporter:
writeBody(): print("本月销售额 …") // 只改这一步
class InventoryReport extends ReportExporter:
writeBody(): print("当前库存 …") // 各填各的正文
// 客户端调骨架;何时调 writeBody 由父类决定,不是客户端
new SalesReport().export()
步骤顺序被父类锁死,只能在指定钩子定制。 子类无法插入新步骤、无法调换顺序、无法跳过某步——能动的只有父类预留的那几个钩子。继承是编译期绑定,一个子类只能继承一条骨架链;想在运行时换骨架,模板方法做不到(那是策略/组合的活)。骨架本身要改时,所有子类一起受影响。
对照:策略(组合换算法)vs 模板方法(继承换步骤)
两者目标相同——都让「算法」可变;手段相反。这正是 01 §1.5 预告要正面对比的那一对。
一个 HttpClient,请求流程固定为「建连 → 加认证头 → 发送 → 解析响应」,其中只有「加认证头」会变(Bearer Token / API Key / 无认证),且同一个 client 在运行时需要切换认证方式。用模板方法把「加认证头」做成钩子,合适吗?
展开答案(先停 10 秒再点)
不合适。模板方法用继承,认证方式在编译期由子类钉死;而需求明确要运行时切换认证方式——继承换不了。这是策略的活:把「认证方式」抽成一个策略接口,HttpClient 组合持有它,运行时 setAuth(new BearerAuth()) 即可换。
判断钥匙(图 4.3):会变的那一处需要运行时替换 → 组合 → 策略;只在编译期由子类定制、且共享一套固定骨架 → 继承 → 模板方法。01 §1.2「组合优于继承」的那条线在这里直接给出答案。
§本章 self-check
先合上教程,把答案写在纸上或编辑器里,写完再展开对照。直接展开等于把这一节当再读一遍。
- 策略和状态类图全等。只给你一段「Context 持有接口、委托调用」的代码,看哪一个信号就能判断它是策略还是状态?用一句判定法回答。
- 状态模式有两条代价。说出它们,并解释为什么「想看全状态机的整体转移图」在代码里做不到。
- 观察者的「失效监听器(lapsed listener)」为什么会造成内存泄漏?一行代码能避免它,是哪行?
- (回调 01)策略和模板方法都用来「换算法」。它们隔离变化的手段有何本质不同?这和 01 §1.2 的哪条原则直接相关?
答案(先做完再展开)
- 看「可互换的对象之间会不会互相切换」:实现 A 自己触发切到实现 B(代码里有
context.setState(next)或返回下一个状态)→ 状态;实现互不相识、由外部客户端构造时选定 → 策略。信号不在类图,在「谁触发转移」的意图里。 - 两条代价:① 类爆炸(每状态一个类);② 转移逻辑分散在各状态对象内部。看全局转移图做不到,是因为「下一步去哪」被拆散写在每个状态的方法里,没有任何一处汇总——图 4.1 那种全景视图代码里并不存在,得把所有状态类翻一遍才能在脑子里拼出来。
- subject 的订阅者名单一直持有 observer 的引用;订阅后忘记调
unsubscribe(),这个引用就不释放,GC 无法回收该 observer,长生命周期的 subject 上累积成泄漏。避免它的那行:在 observer 不再需要时调用order.unsubscribe(o)。 - 策略用组合(运行时持有、换整个算法对象);模板方法用继承(编译期由子类重写钩子,步骤顺序被父类锁死、换的是算法里的某几步)。直接对应 01 §1.2「组合优于继承」——策略是主流的组合派,模板方法是故意用继承的少数派。
把一个布尔标志位 + if 链识别成状态机
遗留代码里有个 Document 类,带三个布尔字段 isDraft、isInReview、isPublished,每个方法开头都是 if (isPublished) { … } else if (isInReview) { … } else { … },而且偶尔出现 isDraft = false; isPublished = true; 这种多个字段同时翻转的赋值,已经出过「既是草稿又已发布」的非法组合 bug。问题:把它重构成状态模式,能消除哪种 bug?重构后「评审通过自动发布」这条转移规则,应该写在哪里——客户端、Document、还是某个状态对象里?
提示(卡住再展开)
多个布尔位表达「当前阶段」,本质是用 N 个标志位编码一个本应互斥的状态,非法组合(既草稿又发布)就是这么来的。状态模式用「当前持有哪一个状态对象」表达阶段——同一时刻有且仅有一个,非法组合从根上无法出现。
「评审通过自动发布」是一条状态转移规则。按本章 4.2、4.3 的机制,转移逻辑住在状态对象自己里:InReviewState 的 approve() 内部调 doc.setState(new PublishedState())。不在客户端(客户端只触发 approve()),也不散在 Document 的 if 链里。这恰好是「状态自己触发转移」(对比策略由客户端选)的字面体现——01 读意图主线落到一次真实重构上。