Chapter 01 · 地基
地基:为什么所有模式都在做同一件事
起点给了你一句话本质:读意图,不读类图。这一章把它拆开——先讲清楚「封装变化」这台引擎,看它为什么让模式的结构到处重复,从而推出「只能读意图」。读完,你手里会有第一个真正的模式:策略。
本章你将建立的 schema
- 「封装变化」是什么:找到一处会变的代码,隔离到稳定接口后面。
- 两种落地机制:面向接口编程、组合优于继承——几乎所有模式都靠这两招。
- 为什么这两招让模式的结构到处重复,从而逼出「读意图」这条主线。
- 第一个模式:策略(Strategy)如何从一处会变的需求自然长出来。
1.1从一处会变的需求说起
设计模式不是凭空发明的语法,而是对一类反复出现的痛点的固定回应。要理解任何模式,先得看清它回应的那个痛点。来看一个具体的:订单计费里的折扣规则。
第一版需求只有一条——VIP 打 85 折。代码直白:
function priceOf(order):
total = sum(order.items)
if order.customer.isVip:
total = total * 0.85 // VIP 85 折
return total
三个月后,折扣规则变成:VIP 85 折、夏季券满减 20、新客 9 折、其余不打折。同一个函数被改成:
function priceOf(order):
total = sum(order.items)
if order.customer.isVip:
total = total * 0.85
else if order.coupon == "SUMMER":
total = total - 20
else if order.isFirstOrder:
total = total * 0.9
// …下一个规则继续往这条链上加
return total
① 每加一条折扣规则,就要改这一个函数——它成了所有规则的交汇点,改动风险随规则数线性上升。
② 这个函数被迫知道每一条规则——计费逻辑和折扣逻辑搅在一起,违反「一个函数只负责一件事」。
③ 想单测「夏季券满减」,得先构造一整个 order——规则没法被单独拿出来测试或复用。
这三件事的根都在一处:「折扣怎么算」是会变的,但它和「不变的计费骨架」(取出商品、求和、返回)焊死在了同一个函数里。会变的部分一动,不变的部分跟着遭殃。
1.2引擎:封装变化
封装变化:找到代码里一处将来会改的地方,把它隔离到一个稳定的接口后面,让它能独立替换、不波及其余代码。
软件里唯一确定的事是需求会变。封装变化把系统切成两半:一半稳定(计费骨架),一半易变(折扣算法)。变化被关进一个明确的盒子,改动只发生在盒子里,盒子外的代码不知情、也不受影响。这正是「关注点分离」原则在面向对象里的落地。
落地封装变化,几乎所有 GoF 模式都靠同样的两招。记住这两招,因为下文每一个模式都是它们的组合:
机制一:面向接口编程
不变的那部分,只依赖一个接口(抽象契约),而不是某个具体类。计费骨架不写「调用 VIP 折扣」,而写「调用某个 DiscountStrategy」。具体是哪种折扣,骨架不关心。
这里的「接口」指抽象契约 / 角色,不限于某语言的 interface 关键字——抽象类、甚至动态语言里的鸭子类型,都算。它的本质是:一组「能做什么」的约定,不绑定「具体怎么做」。
机制二:组合优于继承
不变的那部分,通过持有一个对象的引用来获得会变的行为(组合),而不是通过继承一个父类把行为写死(继承)。计费对象「持有」一个折扣策略对象,运行时可以换;若改成「计费对象继承 VIP 计费类」,折扣就在编译期被钉死,换不了。
继承在编译期固定,一个类只能继承一条链;组合在运行时可换,且能自由搭配。GoF 原书第 1 章把「优先使用对象组合,而非类继承」列为两条核心原则之一。后面的装饰器、策略、桥接全建立在这条原则上;而模板方法、工厂方法是故意反过来用继承的少数派——记住这个例外,第 04 章会回到它。
1.3第一个模式:策略
把 1.1 的折扣烂摊子用这两招重写一遍,得到的就是策略模式(Strategy)——本教程的第一个模式,也是最纯粹地体现「封装变化」的那个。
策略:把一族「可互换的算法」各自封装成对象,让它们能在运行时被替换。分离出去的,是「算法这一处变化」。
// ① 把"会变的折扣算法"抽成一个稳定接口(机制一:面向接口)
interface DiscountStrategy:
apply(total): number
// ② 每条折扣规则 = 一个独立的策略对象,互不知晓彼此
class VipDiscount implements DiscountStrategy:
apply(total): return total * 0.85
class SummerCoupon implements DiscountStrategy:
apply(total): return total - 20
class NoDiscount implements DiscountStrategy:
apply(total): return total
// ③ 不变的计费骨架(Context),持有一个策略,委托给它(机制二:组合)
class Checkout:
strategy: DiscountStrategy
constructor(strategy): this.strategy = strategy
price(order):
total = sum(order.items)
return this.strategy.apply(total) // 骨架不知道是哪种折扣
// ④ 由客户端挑选用哪个策略
checkout = new Checkout(new VipDiscount())
print(checkout.price(order)) // 按 VIP 折扣算
逐行 → 概念(不是逐行 → 语法)
① 这个接口就是「会变的部分」与「不变的部分」之间那道墙。墙左边(骨架)只认这堵墙,不认墙后是谁。
② 每个策略是一个独立对象。新增「新客 9 折」= 新写一个类,不碰 Checkout、也不碰其他策略。1.1 的失败模式 ① ② 同时消失。
③ Checkout 通过持有(组合)而非继承拿到折扣行为,所以运行时能换策略。这一行 this.strategy.apply(total) 就是「面向接口编程」的样子——调用契约,不调用具体。
④ 谁来决定用哪个策略?客户端。策略自己不会决定「之后该换成另一个策略」。记住这一点——下一节它会成为区分策略和状态的唯一钥匙。
Checkout 只有一条线连到接口,从不直接连任何一个具体策略——这就是「面向接口编程」,也是它能在运行时换策略的原因。如果折扣链里只有「VIP 85 折」这一种规则,而且可预见的将来都不会再加第二种,这时引入 DiscountStrategy 接口 + 策略类,是好设计吗?
展开答案(先停 10 秒再点)
不是。封装变化的前提是真的存在变化。只有一种、且不会增加的规则,引入接口只是凭空多一层间接,徒增阅读成本而无收益——这是「为一个永远只有一种实现的算法引入策略」这一典型过度设计。正确做法:先把那行 ×0.85 内联写着,等第二种规则真的出现,再重构成策略。
这指向一条贯穿全教程的判断:模式是对已出现或高度可预期的变化的回应,不是预先铺设的脚手架。第 05 章会把「什么时候不该用模式」讲透。
1.4为什么结构会重复 → 为什么必须读意图
策略的结构可以抽象成一句话:一个 Context 对象,持有一个接口,把会变的行为委托给接口背后的实现。这正是「面向接口 + 组合」两招拼出来的形状。
问题来了:既然几乎所有模式都用这两招封装变化,那么它们拼出来的形状必然彼此相似。看一个例子——状态模式(State)。订单有「待支付 → 已支付 → 已发货」几个状态,每个状态下行为不同:
// 接口 + 一族实现 + Context 持有它 —— 和策略一模一样的骨架
interface OrderState:
next(order): OrderState // 注意返回值:下一个状态
class PendingState implements OrderState:
next(order): return new PaidState() // 状态自己决定转移到谁
class PaidState implements OrderState:
next(order): return new ShippedState()
class Order:
state: OrderState
advance(): this.state = this.state.next(this) // 委托给当前状态
把 Order-持有-OrderState 和 Checkout-持有-DiscountStrategy 摆在一起:类图完全相同——一个 Context、一个接口、一族实现、委托调用。如果靠类图认模式,这两个模式在你眼里就是同一个,你永远分不清眼前该用哪个。
| 维度 | 策略 | 状态 |
|---|---|---|
| 类图 | Context + 接口 + 一族实现 | Context + 接口 + 一族实现(相同) |
| 意图 | 同一件事的多种算法,可互换 | 对象在生命周期里的多个状态,行为随之变 |
| 谁切换实现 | 客户端选定,运行中通常不自变 | 状态对象自己触发转移到下一个 |
| 实现间是否知晓彼此 | 不知道(VIP 折扣不认识满减) | 知道(待支付知道下一步是已支付) |
这一节就是全教程的主线落地:
结构(类图)是「封装变化」两招拼出来的副产物,会在不同模式间重复;意图才是模式的身份证。读模式 = 读「它分离出什么、为了什么」,不是读类图。
后面三章(创建型、结构型、行为型)每讲一个模式,都会先问同一个问题:它分离出去的是什么?意图是什么?遇到「双胞胎」时,答案永远在意图里。
1.5一个例外:不是所有模式都靠组合
诚实地补一句:「组合优于继承」是主流,但有少数模式故意用继承。最典型的是模板方法(Template Method)——父类把算法骨架和步骤顺序定死,留几个「钩子」方法让子类填:
abstract class ReportBuilder:
// 骨架定死顺序,这个方法子类不许改
build():
header()
body() // ← 钩子,子类填
footer()
header(): print("=== 报表 ===")
abstract body() // 留给子类
footer(): print("=== 完 ===")
class SalesReport extends ReportBuilder:
body(): print("本月销售额 …")
这里没有「持有一个接口」,而是「子类继承骨架、重写钩子」。控制权在父类手里——父类决定何时调用你的 body(),你不主动调它。这条原则叫好莱坞原则:不要主动向上调用,框架会在合适时机回调你。
模板方法分离出去的仍是「会变的一处」——算法中的某几步。只是它用继承(编译期固定)而非组合(运行期可换)来隔离。主线「封装变化 + 读意图」依然成立,变的只是隔离的手段。第 04 章会把它和策略正面对比:同样是「换算法」,一个用继承换、一个用组合换。
§本章 self-check
先合上教程,把答案写在纸上或编辑器里,写完再展开对照。直接展开等于把这一节当再读一遍。
- 用一句话说出「封装变化」是什么,以及它依赖的两种机制各自的名字。
- 1.1 第四版那个
priceOf函数有三个失败模式。引入策略后,其中哪两个直接消失?为什么第三个(单测某条规则)也被解决? - 策略和状态的类图相同。只给你一段「Context 持有接口、委托调用」的代码,看哪一个信号就能判断它是策略还是状态?
- 模板方法和策略都用来「替换算法」。它们隔离变化的手段有什么本质不同?
答案(先做完再展开)
- 封装变化 = 把一处将来会改的代码隔离到稳定接口后,使其能独立替换。两种机制:面向接口编程(只依赖抽象契约)、组合优于继承(持有对象而非继承父类)。
- 消失的两个:① 加规则不再改计费函数(各规则是独立类);② 计费逻辑与折扣逻辑解耦(骨架只认接口)。第三个(单测)被解决,是因为每条规则成了独立对象,可以单独
new VipDiscount().apply(100)来测,不必构造整个 order。 - 看「谁决定切换到下一个实现」:若实现彼此不知道、由客户端选 → 策略;若实现自己触发转移到下一个(代码里通常有
next()返回另一个实现 /context.setState(...)) → 状态。这个信号不在类图上,在意图里。 - 策略用组合(运行时持有、可换),模板方法用继承(编译期由子类重写钩子,顺序被父类锁死)。一个换的是整个算法对象,一个换的是算法里的某几步。
把一个 if-else 链识别成「该上哪个模式」
你在一份遗留代码里看到:一个 render(node) 函数,内部 if node.type == "text" … else if node.type == "image" … else if node.type == "list" …,每个分支渲染逻辑不同,且分支还在增加。这像 1.1 的折扣链。问题:把它重构成策略,和重构成「每个 node 类型自己有个 render 方法」(多态),两条路都行得通——选哪条?依据是什么?
提示(卡住再展开)
问一个问题:这些渲染逻辑是「node 的固有行为」,还是「可以脱离 node 独立替换的算法」?如果渲染方式本身也会变(同一个 text node,有时渲染成 HTML、有时渲染成 Markdown),那「渲染算法」是独立于 node 的另一条变化轴——这是策略的信号。如果每种 node 永远只有一种渲染法,把 render 放进各自的类里(多态)更简单。判断依据始终是:会变的到底是哪一处。