Chapter 01 · 地基

地基:为什么所有模式都在做同一件事

起点给了你一句话本质:读意图,不读类图。这一章把它拆开——先讲清楚「封装变化」这台引擎,看它为什么让模式的结构到处重复,从而推出「只能读意图」。读完,你手里会有第一个真正的模式:策略。

本章你将建立的 schema

  • 「封装变化」是什么:找到一处会变的代码,隔离到稳定接口后面。
  • 两种落地机制:面向接口编程、组合优于继承——几乎所有模式都靠这两招。
  • 为什么这两招让模式的结构到处重复,从而逼出「读意图」这条主线。
  • 第一个模式:策略(Strategy)如何从一处会变的需求自然长出来。

1.1从一处会变的需求说起

设计模式不是凭空发明的语法,而是对一类反复出现的痛点的固定回应。要理解任何模式,先得看清它回应的那个痛点。来看一个具体的:订单计费里的折扣规则。

第一版需求只有一条——VIP 打 85 折。代码直白:

naive-checkout · 第一版伪代码
function priceOf(order):
    total = sum(order.items)
    if order.customer.isVip:
        total = total * 0.85          // VIP 85 折
    return total

三个月后,折扣规则变成:VIP 85 折、夏季券满减 20、新客 9 折、其余不打折。同一个函数被改成:

naive-checkout · 第四版伪代码
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)——本教程的第一个模式,也是最纯粹地体现「封装变化」的那个。

策略:把一族「可互换的算法」各自封装成对象,让它们能在运行时被替换。分离出去的,是「算法这一处变化」。

strategy-checkout · worked example伪代码
// ① 把"会变的折扣算法"抽成一个稳定接口(机制一:面向接口)
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 不变:算 total,委托折扣 持有(组合) «interface» DiscountStrategy VipDiscount ×0.85 SummerCoupon −20 NoDiscount 原样 实现 · 可独立替换
图 1.1策略模式:朱红色的接口是「会变」与「不变」之间的墙。注意:Checkout 只有一条线连到接口,从不直接连任何一个具体策略——这就是「面向接口编程」,也是它能在运行时换策略的原因。
想一想

如果折扣链里只有「VIP 85 折」这一种规则,而且可预见的将来都不会再加第二种,这时引入 DiscountStrategy 接口 + 策略类,是好设计吗?

展开答案(先停 10 秒再点)

不是。封装变化的前提是真的存在变化。只有一种、且不会增加的规则,引入接口只是凭空多一层间接,徒增阅读成本而无收益——这是「为一个永远只有一种实现的算法引入策略」这一典型过度设计。正确做法:先把那行 ×0.85 内联写着,等第二种规则真的出现,再重构成策略。

这指向一条贯穿全教程的判断:模式是对已出现或高度可预期的变化的回应,不是预先铺设的脚手架。第 05 章会把「什么时候不该用模式」讲透。

1.4为什么结构会重复 → 为什么必须读意图

策略的结构可以抽象成一句话:一个 Context 对象,持有一个接口,把会变的行为委托给接口背后的实现。这正是「面向接口 + 组合」两招拼出来的形状。

问题来了:既然几乎所有模式都用这两招封装变化,那么它们拼出来的形状必然彼此相似。看一个例子——状态模式(State)。订单有「待支付 → 已支付 → 已发货」几个状态,每个状态下行为不同:

state-order · 对照策略看结构伪代码
// 接口 + 一族实现 + 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、一个接口、一族实现、委托调用。如果靠类图认模式,这两个模式在你眼里就是同一个,你永远分不清眼前该用哪个。

策略 Checkout (Context) DiscountStrategy 具体策略 × N ≈ 类图全等 状态 Order (Context) OrderState 具体状态 × N 看不见于类图的区别:策略由【客户端】选,实现互不相识 状态【自己】通过 next() 决定转移到下一个状态
图 1.2策略与状态结构全等,中间一个 ≈。注意:唯一的区别在底部那条带里,它不出现在任何类图上——策略的实现彼此不知道对方存在,由客户端切换;状态自己决定转移到哪个状态。这就是「读意图」的字面含义。
表 1.1 · 同结构,靠意图区分:策略 vs 状态
维度策略状态
类图Context + 接口 + 一族实现Context + 接口 + 一族实现(相同)
意图同一件事的多种算法,可互换对象在生命周期里的多个状态,行为随之变
谁切换实现客户端选定,运行中通常不自变状态对象自己触发转移到下一个
实现间是否知晓彼此不知道(VIP 折扣不认识满减)知道(待支付知道下一步是已支付)

这一节就是全教程的主线落地:

结构(类图)是「封装变化」两招拼出来的副产物,会在不同模式间重复;意图才是模式的身份证。读模式 = 读「它分离出什么、为了什么」,不是读类图。

后面三章(创建型、结构型、行为型)每讲一个模式,都会先问同一个问题:它分离出去的是什么?意图是什么?遇到「双胞胎」时,答案永远在意图里。

1.5一个例外:不是所有模式都靠组合

诚实地补一句:「组合优于继承」是主流,但有少数模式故意用继承。最典型的是模板方法(Template Method)——父类把算法骨架和步骤顺序定死,留几个「钩子」方法让子类填:

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. 用一句话说出「封装变化」是什么,以及它依赖的两种机制各自的名字。
  2. 1.1 第四版那个 priceOf 函数有三个失败模式。引入策略后,其中哪两个直接消失?为什么第三个(单测某条规则)也被解决?
  3. 策略和状态的类图相同。只给你一段「Context 持有接口、委托调用」的代码,看哪一个信号就能判断它是策略还是状态?
  4. 模板方法和策略都用来「替换算法」。它们隔离变化的手段有什么本质不同?
答案(先做完再展开)
  1. 封装变化 = 把一处将来会改的代码隔离到稳定接口后,使其能独立替换。两种机制:面向接口编程(只依赖抽象契约)、组合优于继承(持有对象而非继承父类)。
  2. 消失的两个:① 加规则不再改计费函数(各规则是独立类);② 计费逻辑与折扣逻辑解耦(骨架只认接口)。第三个(单测)被解决,是因为每条规则成了独立对象,可以单独 new VipDiscount().apply(100) 来测,不必构造整个 order。
  3. 看「谁决定切换到下一个实现」:若实现彼此不知道、由客户端选 → 策略;若实现自己触发转移到下一个(代码里通常有 next() 返回另一个实现 / context.setState(...)) → 状态。这个信号不在类图上,在意图里。
  4. 策略用组合(运行时持有、可换),模板方法用继承(编译期由子类重写钩子,顺序被父类锁死)。一个换的是整个算法对象,一个换的是算法里的某几步。
进阶挑战 · 刚好够不着

把一个 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 放进各自的类里(多态)更简单。判断依据始终是:会变的到底是哪一处。