第 1 章 · 什么是设计模式
这一章先不讲定义。我们从一段 20 行的代码开始,给它加一条新需求,看它在哪里开始吃力,再换一种写法,看同样的需求变成了什么样。走完这一遍,你已经用过一个设计模式了,之后再看定义、原则和全书的模式总表就有了落脚点。
1.1 先看一个 20 行的例子
Section titled “1.1 先看一个 20 行的例子”假设你在一家电商公司写下单功能。用户下单时要选配送方式,目前有三种:顺丰、普通快递、到店自提。运费规则由运营定好了:
- 顺丰:首重 1 公斤 12 元,超出部分每公斤加 2 元。
- 普通快递:3 公斤以内 6 元,超出部分每公斤加 1 元。
- 到店自提:免运费。
运费在两个地方要算:一是下单页上,用户选完配送方式就显示一个“预估运费”;二是结算时,正式算出这一单的运费写进账单。这两处在真实项目里通常属于不同的模块,甚至不同的人维护。
大多数人的第一版会写成一个函数,用 if/elif 按配送方式分支,两个调用点都去调它:
def shipping_fee(method: str, weight: float) -> float: if method == "sf": # 顺丰 return 12 + 2 * max(0, weight - 1) elif method == "standard": # 普通快递 return 6 if weight <= 3 else 6 + (weight - 3) elif method == "pickup": # 到店自提 return 0 else: raise ValueError(f"未知配送方式:{method}")
# 调用点一:下单页预估def estimate_on_order_page(method: str, weight: float) -> str: return f"预估运费:{shipping_fee(method, weight):.2f} 元"
# 调用点二:结算时计算def charge_at_checkout(order_id: str, method: str, weight: float) -> str: return f"订单 {order_id} 运费:{shipping_fee(method, weight):.2f} 元"
if __name__ == "__main__": print(estimate_on_order_page("sf", 2.5)) print(charge_at_checkout("A001", "standard", 4))
# 输出:# 预估运费:15.00 元# 订单 A001 运费:7.00 元TypeScript 版结构完全一样:
function shippingFee(method: string, weight: number): number { if (method === "sf") { // 顺丰 return 12 + 2 * Math.max(0, weight - 1); } else if (method === "standard") { // 普通快递 return weight <= 3 ? 6 : 6 + (weight - 3); } else if (method === "pickup") { // 到店自提 return 0; } throw new Error(`未知配送方式:${method}`);}
// 调用点一:下单页预估function estimateOnOrderPage(method: string, weight: number): string { return `预估运费:${shippingFee(method, weight).toFixed(2)} 元`;}
// 调用点二:结算时计算function chargeAtCheckout(orderId: string, method: string, weight: number): string { return `订单 ${orderId} 运费:${shippingFee(method, weight).toFixed(2)} 元`;}
console.log(estimateOnOrderPage("sf", 2.5));console.log(chargeAtCheckout("A001", "standard", 4));
// 输出:// 预估运费:15.00 元// 订单 A001 运费:7.00 元这段代码没有毛病。三种方式、三个分支,规则一眼就能对上,测试也好写。如果配送方式永远只有这三种,它就是最好的写法。
1.2 需求变了
Section titled “1.2 需求变了”运营找过来:新增“同城闪送”,起步价 10 元,每公里加 1.5 元,和重量无关。
闪送按距离计费,而 shipping_fee 现在只收方式和重量两个参数,所以距离得一路传进来。要改的地方有四处:
- 函数签名:加一个参数
distance_km。为了不让另外三种方式的调用报错,只能给它一个默认值 0。 - 分支本身:加一个
elif method == "flash"。 - 调用点一:下单页预估要拿到用户地址和仓库的距离,传进去。
- 调用点二:结算时同样要算距离、传进去。
def shipping_fee(method: str, weight: float, distance_km: float = 0) -> float: ... elif method == "flash": # 同城闪送 return 10 + 1.5 * distance_km
estimate_on_order_page("flash", 1.2, distance_km=6) # 调用点一跟着改charge_at_checkout("A002", "flash", 1.2, distance_km=6) # 调用点二跟着改TS 版要改的是同样的四处:shippingFee 加参数 distanceKm = 0,加一个 method === "flash" 分支,estimateOnOrderPage 和 chargeAtCheckout 都加参数并往下传。
这里最容易出事的是第 4 处。假如负责结算的同事漏改了,distance_km 会悄悄取默认值 0,闪送订单在结算时一律收 10 元,程序不报错,测试只测了下单页也发现不了,直到对账时才看出少收了钱。
再往后看一步。下个月要加“海外直邮”,按重量和目的国家计费,那就再加一个参数 country,再改一遍这四处。到时候函数签名会变成 shipping_fee(method, weight, distance_km=0, country=""):顺丰用不上距离和国家,闪送用不上重量,到店自提三个参数都用不上。其实 weight 对自提一直是个没用的参数,只是之前只有一个参数,没人在意。每加一种方式,参数就多一个,每个调用点都要知道所有方式需要的全部信息。
问题的形状是:每加一种配送方式,就要改同一个函数和它的全部调用点,参数还越来越长。
看图里标红的四个框,编号和上面的 ①–④ 一一对应:一个函数、两个调用点,新需求把它们全碰了一遍。
1.3 换个写法
Section titled “1.3 换个写法”换个思路:既然“每种配送方式怎么算钱”是经常变的部分,就把每种方式单独做成一个类,它们都提供同一个方法 fee。订单 Order 不再记一个字符串 method,而是直接持有一个“配送方式对象”,算运费时交给它去算。闪送需要的距离,在创建闪送对象时就交给它,不必经过公共的函数签名。
from dataclasses import dataclassfrom typing import Protocol
class ShippingStrategy(Protocol): def fee(self, weight: float) -> float: ...
class SfExpress: # 顺丰 def fee(self, weight: float) -> float: return 12 + 2 * max(0, weight - 1)
class StandardDelivery: # 普通快递 def fee(self, weight: float) -> float: return 6 if weight <= 3 else 6 + (weight - 3)
class StorePickup: # 到店自提 def fee(self, weight: float) -> float: return 0
class SameCityFlash: # 同城闪送:新需求只加了这一个类 def __init__(self, distance_km: float) -> None: self.distance_km = distance_km
def fee(self, weight: float) -> float: return 10 + 1.5 * self.distance_km
@dataclassclass Order: order_id: str weight: float shipping: ShippingStrategy
def shipping_fee(self) -> float: return self.shipping.fee(self.weight)
# 调用点一:下单页预估def estimate_on_order_page(order: Order) -> str: return f"预估运费:{order.shipping_fee():.2f} 元"
# 调用点二:结算时计算def charge_at_checkout(order: Order) -> str: return f"订单 {order.order_id} 运费:{order.shipping_fee():.2f} 元"
if __name__ == "__main__": print(estimate_on_order_page(Order("A001", 2.5, SfExpress()))) print(charge_at_checkout(Order("A002", 1.2, SameCityFlash(distance_km=6))))
# 输出:# 预估运费:15.00 元# 订单 A002 运费:19.00 元TS 版用 interface 表达同一个约定:
interface ShippingStrategy { fee(weight: number): number;}
class SfExpress implements ShippingStrategy { // 顺丰 fee(weight: number): number { return 12 + 2 * Math.max(0, weight - 1); }}
class StandardDelivery implements ShippingStrategy { // 普通快递 fee(weight: number): number { return weight <= 3 ? 6 : 6 + (weight - 3); }}
class StorePickup implements ShippingStrategy { // 到店自提 fee(weight: number): number { return 0; }}
class SameCityFlash implements ShippingStrategy { // 同城闪送:新需求只加了这一个类 constructor(private readonly distanceKm: number) {}
fee(weight: number): number { return 10 + 1.5 * this.distanceKm; }}
class Order { constructor( readonly orderId: string, readonly weight: number, public shipping: ShippingStrategy, ) {}
shippingFee(): number { return this.shipping.fee(this.weight); }}
// 调用点一:下单页预估function estimateOnOrderPage(order: Order): string { return `预估运费:${order.shippingFee().toFixed(2)} 元`;}
// 调用点二:结算时计算function chargeAtCheckout(order: Order): string { return `订单 ${order.orderId} 运费:${order.shippingFee().toFixed(2)} 元`;}
console.log(estimateOnOrderPage(new Order("A001", 2.5, new SfExpress())));console.log(chargeAtCheckout(new Order("A002", 1.2, new SameCityFlash(6))));
// 输出:// 预估运费:15.00 元// 订单 A002 运费:19.00 元看标红的 SameCityFlash:加一种配送方式,图上只是多一个框,其余的框和线都不动。
逐条对照 1.2 的四处改动:
- 函数签名:不用改。
fee只收所有方式都有的重量,闪送特有的距离在SameCityFlash(distance_km=6)这一步就交给了闪送对象自己。 - 分支:没有分支了。加闪送是新增
SameCityFlash这一个类,前三个类一行没动。 - 两个调用点:不用改。它们只收一个
Order,不关心里面是哪种配送方式,也不需要知道距离。 - 漏传距离:不会再悄悄变成 0。创建
SameCityFlash时不给距离,Python 运行到这一行就报错,TS 在编译时就报错。
下个月加海外直邮,同样是新增一个 OverseasDirect 类,构造时传入国家,别的文件都不动。代价是类变多了:原来一个函数,现在一个接口加四个类。只有三种方式、而且不会再变的时候,这个代价不值得付;配送方式还会继续加的时候,它很快就能收回来。
这就是一种设计模式。它有一个名字,第 17 章会正式讲它,在那之前你只需要记住这个形状:把会变的算法各自装进一个类,让使用它的对象只认一个共同的接口。
1.4 设计模式是什么、不是什么
Section titled “1.4 设计模式是什么、不是什么”上一节做的事情,并不是我们的发明。“把一族可以互换的算法分别封装起来,让使用方只依赖它们的共同接口”,这个做法在无数项目里被人独立地写出来过,后来有人给它起了名字、写清了它适用于什么情况、有什么代价。这样一个被命名、被记录下来的解法,就是一个设计模式(Design Pattern)。
GoF 在书里说,设计模式描述的是一组相互通信的对象和类,它们经过定制,用来在特定场景中解决一个一般性的设计问题。GoF 是“四人帮”(Gang of Four)的缩写,指写下这本书的 Gamma、Helm、Johnson 和 Vlissides 四人。换成更好记的说法:设计模式是在特定场景下,对某类反复出现的问题的一种经过验证的解法。这句话里有三个关键词。“特定场景”说明它有前提,1.3 的写法在配送方式会继续增加时才划算;“反复出现”说明它不是为某个项目量身定做的;“经过验证”说明很多人用过,知道它在哪里好用、在哪里会出问题。GoF 认为一个模式包含四个要素:名字、问题、解法和效果,本书每章的小节也基本按这个顺序展开。
弄清它不是什么同样重要:
- 不是库。你没法
pip install一个设计模式。1.3 的代码没有引用任何东西,模式只是代码的组织方式,每次都要按眼前的业务自己写出来。 - 不是框架。框架规定你的代码放在哪、何时被调用;模式只解决一个局部问题,一个项目里可以同时用好几个,也可以一个都不用。
- 不是必须套用的模板。1.1 的
if/elif在配送方式不再变化时就是最好的写法。如果硬把它改成一个接口加三个类,以后读代码的人要在四个地方来回跳才能看清运费怎么算,却换不来任何好处。
模式的概念最早来自建筑学。建筑师 Christopher Alexander 在 1977 年的《A Pattern Language》里,记录了城镇、街道、房屋里反复出现的设计问题和对应的解法,并给每个解法起了名字。后来有程序员把这个思路搬进软件设计,1994 年 GoF 出版了《Design Patterns: Elements of Reusable Object-Oriented Software》,收录 23 个面向对象设计中的模式。本书讲的就是这 23 个。模式的名字还有一个实际好处:同事说一句“这里加个观察者”,你立刻知道代码大概长什么样,不用他再画一遍图。
1.5 四条贯穿全书的原则
Section titled “1.5 四条贯穿全书的原则”23 个模式的写法各不相同,背后却反复用到同样几条原则。先认识它们,后面每读一章,你都能看出这一章用了哪几条。
封装变化(Encapsulate What Varies)。找出代码里会变的部分,把它和不变的部分分开,放到一个地方。这样需求变了,你只改那一个地方。
# 税率可能调整,只在这一处定义;三个算价函数都调 tax(),调整时改一行def tax(amount: float) -> float: return round(amount * 0.06, 2)在 1.3 里,会变的是“每种方式怎么算钱”,它被关进了各自的类;不变的是“订单拿重量去问配送方式要运费”,它留在 Order.shipping_fee 里。
组合优于继承(Favor Composition over Inheritance)。想让一个对象具备某种能力时,优先让它持有一个提供这种能力的对象,而不是继承一个父类。
# 继承:2 种周期 × 2 种格式,要写 4 个子类,再加一种格式就是 6 个class PdfDailyReport(DailyReport): ...# 组合:周期和格式各管各的,加一种格式只写 1 个类report = Report(period=Daily(), exporter=PdfExporter())在 1.3 里,Order 持有一个 shipping 对象,而不是写出 SfOrder、PickupOrder 这样的子类。所以同一单可以在下单后改配送方式,只要给 shipping 换一个对象。
面向接口编程(Program to an Interface, not an Implementation)。使用方只依赖“能做什么”的约定,不依赖“具体是谁”。
class Notifier(Protocol): def send(self, text: str) -> None: ...
def notify_paid(order_id: str, notifier: Notifier) -> None: notifier.send(f"订单 {order_id} 已付款") # 短信换成邮件,这个函数不用改在 1.3 里,Order.shipping 的类型是 ShippingStrategy,不是 SfExpress。Order 只知道对方有一个 fee 方法,所以闪送类加进来时,Order 不用改一个字。
开闭原则(Open/Closed Principle)。对扩展开放,对修改关闭:加新功能靠增加新代码,而不是修改已经测好、上线的旧代码。
# exporters.py:写好、测好之后不再改EXPORTERS = {"csv": CsvExporter(), "json": JsonExporter()}def export(data, fmt): return EXPORTERS[fmt].export(data)# xlsx_exporter.py:加一种导出格式只新增这个文件,类和注册都写在这里(还得有地方 import 这个模块,注册才会执行)EXPORTERS["xlsx"] = XlsxExporter()在 1.3 里,加闪送是新写一个 SameCityFlash,原来三个类、Order 和两个调用点都没有改动,这些代码也就不会因为这次需求被改出新问题。1.1 的写法正好相反,每加一种方式都要打开同一个函数改分支。
这四条原则不是四条规定,而是四个角度:它们都在回答“下次需求变的时候,我要改多少地方”。
1.6 23 个模式总表
Section titled “1.6 23 个模式总表”GoF 把 23 个模式分成三类:创建型(Creational)5 个,结构型(Structural)7 个,行为型(Behavioral)11 个。下面三张表按本书章节顺序排列,后面各章的标题都用这里的中文译名。
表中的流行度是本书作者的判断,依据是这个模式在主流框架和日常业务代码里出现的频率:★★★ 是几乎每个项目都会碰到的,建议优先学;★★ 在特定类型的系统里常见;★ 用得少,知道它解决什么问题、遇到时能认出来即可。星级低不代表模式不重要,只说明你在日常代码里遇到它的机会少。
创建型:对象怎么来
| 中文名 | 英文名 | 一句话 | 典型场合 | 流行度 |
|---|---|---|---|---|
| 工厂方法 | Factory Method | 让子类决定创建哪种对象 | 按配置创建不同的数据库连接、日志输出 | ★★★ |
| 抽象工厂 | Abstract Factory | 成套创建一组相互配合的对象 | 换肤时整套切换按钮、输入框、对话框 | ★★ |
| 建造者 | Builder | 分步骤组装一个参数很多的对象 | 拼 SQL 查询、构造 HTTP 请求 | ★★★ |
| 单例 | Singleton | 保证一个类全局只有一个实例 | 全局配置、连接池 | ★★★ |
| 原型 | Prototype | 复制一个现有对象来得到新对象 | 复制文档模板、游戏里批量生成同类敌人 | ★ |
结构型:对象怎么组合
| 中文名 | 英文名 | 一句话 | 典型场合 | 流行度 |
|---|---|---|---|---|
| 适配器 | Adapter | 把一个接口转换成调用方期望的接口 | 接入第三方支付、旧系统接口 | ★★★ |
| 装饰器 | Decorator | 在不改原对象的前提下层层叠加功能 | 给请求加日志、缓存、重试 | ★★★ |
| 外观 | Facade | 给一组复杂的子系统提供一个简单入口 | 一个“下单”接口背后调库存、支付、物流 | ★★★ |
| 代理 | Proxy | 用一个替身控制对真实对象的访问 | 延迟加载、权限检查、远程调用 | ★★★ |
| 组合 | Composite | 把对象组织成树,单个和整体一样对待 | 文件夹与文件、菜单与子菜单 | ★★ |
| 桥接 | Bridge | 把两个独立变化的维度拆开再组合 | 消息类型 × 发送渠道 | ★ |
| 享元 | Flyweight | 共享大量重复的细粒度对象以省内存 | 编辑器里的字符、地图上的树木 | ★ |
行为型:对象怎么协作
| 中文名 | 英文名 | 一句话 | 典型场合 | 流行度 |
|---|---|---|---|---|
| 策略 | Strategy | 把一族可互换的算法各自封装 | 运费计算、折扣规则、排序方式 | ★★★ |
| 观察者 | Observer | 一个对象变化时自动通知所有关注者 | 事件监听、界面数据绑定 | ★★★ |
| 命令 | Command | 把一个请求封装成对象 | 撤销与重做、任务队列 | ★★★ |
| 模板方法 | Template Method | 父类定流程骨架,子类填具体步骤 | 数据导入流程、测试框架的准备与清理 | ★★★ |
| 状态 | State | 对象的行为随内部状态切换 | 订单状态流转、播放器 | ★★★ |
| 迭代器 | Iterator | 不暴露内部结构地逐个访问元素 | 遍历集合、分页读取 | ★★★ |
| 责任链 | Chain of Responsibility | 请求沿一条处理者链传递 | 审批流、Web 中间件 | ★★ |
| 中介者 | Mediator | 用一个中间对象协调多个对象的交互 | 表单里联动的控件、聊天室 | ★★ |
| 备忘录 | Memento | 保存并恢复对象的内部状态 | 编辑器撤销、游戏存档 | ★ |
| 访问者 | Visitor | 不改元素类就给它们加新操作 | 语法树分析、报表导出 | ★ |
| 解释器 | Interpreter | 为一种简单语言定义文法并解释执行 | 规则表达式、简单查询语言 | ★ |
数一下,★★★ 有 13 个,★★ 有 4 个,★ 有 6 个。1.3 那个运费的写法,就是行为型表里的第一行。
1.7 三大分类和怎么用本书
Section titled “1.7 三大分类和怎么用本书”三类模式分别回答三个问题。创建型回答“对象怎么来”:什么时候创建、创建哪一种、由谁来创建,让使用方不必直接写死某个具体类。结构型回答“对象怎么组合”:怎样把已有的类和对象拼成更大的结构,比如给它套一层、给一组对象一个统一入口。行为型回答“对象怎么协作”:职责怎么分给不同的对象、对象之间怎么传递消息和请求。拿到一个设计问题时,先判断它属于哪个问题,就能把要翻的范围从 23 个缩到几个。
本书第二、三、四部分就按这三类排列,每部分开头有一章导读。如果你时间有限,建议先读 13 个 ★★★ 的模式:它们在你接下来一两年的工作里几乎都会遇到,读完之后,很多框架里的常见结构你也会觉得眼熟。★★ 的 4 个可以等项目里真碰到类似问题时再读;★ 的 6 个先翻一遍每章开头的两行题记,知道它们解决什么问题就够了。
每读一章,都可以像本章这样做一遍:先自己按直觉写出第一版,再把那一章的新需求加上去,数一数要改几处,最后对照模式版本看少改了哪些地方。下一章是创建型模式的导读,从“对象怎么来”开始。