第 29 章 · 现代语言里的模式
GoF 的书出版于 1994 年,例子用 C++ 和 Smalltalk 写成。那时的 C++ 里,函数不能随手当值传来传去,也没有生成器和模式匹配,想“把一段可换的算法交给别人”,只能写一个接口、一个类,再 new 一个对象。Python 和 TypeScript 不一样:函数是值,类是值,模块只执行一次,yield 能暂停函数。前面有 8 章在“更简洁的写法”一节里当场演示过这种差别,那几节分散在各章,这一章把它们收到一起,按语言特性归类:每节先回指那一节、引用里面的代码,再补一个新例子。最后讲反过来的问题:什么时候仍然该把类写出来,什么样的结构是多余的。
图里每个模式后面是章号,读完本章可以拿它当索引。
29.1 一等函数和闭包
Section titled “29.1 一等函数和闭包”函数是一等值(first-class function):能存进变量、放进列表、当参数传、当返回值。闭包(closure)是函数记住了它被创建时外层的变量。这两样合起来,替掉的是“只有一个方法的接口”。17.6 把这句话说得最直接:只有一个方法的接口,和一个函数类型没有区别。前面四章各演示了一次:
| 模式 | 回指 | 原来的类 | 变成了什么 |
|---|---|---|---|
| 策略 | 17.6 | DiscountStrategy 和各个优惠类 |
Discount = Callable[[Order], int];带参数的 full_reduction(200, 30) 把门槛和减额关进闭包 |
| 观察者 | 18.6 | Subscriber 接口、各订阅者类、EventBus |
subscribers: list[Handler] = [],登记是 append,退订是 remove;TS 直接用 EventTarget |
| 命令 | 19.6 | Command 接口和每个命令类 |
partial(set_price, "A1", 79, 99) 相当于一个 ChangePrice 对象;要撤销就返回一对 (do, undo),快照放在闭包里 |
| 模板方法 | 20.6 | Importer 基类和每种格式的子类 |
run_import(content, parse_json, "JSON"),变的那一步当参数传进流程函数 |
还有第 10 章的装饰器。10.6 用 log_calls(retry(fetch_report)) 把重试和日志套在一个函数外面:装饰的东西只有一个方法时,“实现同一个接口”就是“签名相同的函数”,“持有下一层”就是闭包里的 fn。
闭包记住的变量,就是原来那个类的字段。full_reduction(200, 30) 返回的函数里,threshold 和 off 的地位和一个 FullReduction 对象的两个属性一样,只是外面看不见。20.6 还指出了一件事:模板方法把可变的一步改成参数传进来以后,结构就成了第 17 章的策略,run_import 是上下文,parse_csv 是策略。函数写法常常让两个模式之间的界线消失。
10.6 留了一个尾巴:@ 在函数定义时就套好了,“想按租户换一种包法,得回到手写 log_calls(retry(f)) 的写法”。下面就按租户手写。重试次数也做成参数,retry(3) 先返回一个装饰器,再由它去包函数;每个租户包哪几层,写在一张配置表里:
import functoolsfrom collections.abc import Callable
Fetch = Callable[[str], str]Wrapper = Callable[[Fetch], Fetch]
def log_calls(fn: Fetch) -> Fetch: # 和 10.6 相同 @functools.wraps(fn) def wrapper(month: str) -> str: print(f"[日志] 参数 {month}") result = fn(month) print(f"[日志] 返回:{result}") return result return wrapper
def retry(times: int) -> Wrapper: # 带参数的装饰器:多包一层函数,把 times 关进闭包 def decorate(fn: Fetch) -> Fetch: @functools.wraps(fn) def wrapper(month: str) -> str: for attempt in range(1, times + 1): try: return fn(month) except ConnectionError as error: print(f"[重试] 第 {attempt} 次失败:{error}") raise ConnectionError(f"重试 {times} 次仍然失败") return wrapper return decorate
def compose(fn: Fetch, wrappers: list[Wrapper]) -> Fetch: # 列表里排在前面的包在最外层 for wrap in reversed(wrappers): fn = wrap(fn) return fn
def flaky(failures: int) -> Fetch: # 模拟报表服务:前 failures 次超时 calls = 0
def fetch_report(month: str) -> str: nonlocal calls calls += 1 if calls <= failures: raise ConnectionError("报表服务超时") return f"{month} 月报,共 400 行" return fetch_report
TENANT_WRAPPERS: dict[str, list[Wrapper]] = { # 每个租户包哪几层、按什么顺序,写在配置里 "A": [log_calls, retry(3)], "B": [retry(2)],}
if __name__ == "__main__": for tenant, wrappers in TENANT_WRAPPERS.items(): print(f"--- 租户 {tenant}") fetch_report = compose(flaky(2), wrappers) try: fetch_report("2025-01") except ConnectionError as error: print(f"[失败] {error}")
# 输出:# --- 租户 A# [日志] 参数 2025-01# [重试] 第 1 次失败:报表服务超时# [重试] 第 2 次失败:报表服务超时# [日志] 返回:2025-01 月报,共 400 行# --- 租户 B# [重试] 第 1 次失败:报表服务超时# [重试] 第 2 次失败:报表服务超时# [失败] 重试 2 次仍然失败TS 版用 reduceRight 从最里层往外套:
type Fetch = (month: string) => string;type Wrapper = (fn: Fetch) => Fetch;
function withLogging(fn: Fetch): Fetch { // 和 10.6 相同 return (month) => { console.log(`[日志] 参数 ${month}`); const result = fn(month); console.log(`[日志] 返回:${result}`); return result; };}
function withRetry(times: number): Wrapper { // 带参数:先收 times,返回一个包函数的函数 return (fn) => (month) => { for (let attempt = 1; attempt <= times; attempt++) { try { return fn(month); } catch (error) { console.log(`[重试] 第 ${attempt} 次失败:${(error as Error).message}`); } } throw new Error(`重试 ${times} 次仍然失败`); };}
function compose(fn: Fetch, wrappers: Wrapper[]): Fetch { // 列表里排在前面的包在最外层 return wrappers.reduceRight((inner, wrap) => wrap(inner), fn);}
function flaky(failures: number): Fetch { // 模拟报表服务:前 failures 次超时 let calls = 0; return (month) => { calls += 1; if (calls <= failures) throw new Error("报表服务超时"); return `${month} 月报,共 400 行`; };}
const TENANT_WRAPPERS: Record<string, Wrapper[]> = { // 每个租户包哪几层、按什么顺序,写在配置里 A: [withLogging, withRetry(3)], B: [withRetry(2)],};
for (const [tenant, wrappers] of Object.entries(TENANT_WRAPPERS)) { console.log(`--- 租户 ${tenant}`); const fetchReport = compose(flaky(2), wrappers); try { fetchReport("2025-01"); } catch (error) { console.log(`[失败] ${(error as Error).message}`); }}
// 输出:// --- 租户 A// [日志] 参数 2025-01// [重试] 第 1 次失败:报表服务超时// [重试] 第 2 次失败:报表服务超时// [日志] 返回:2025-01 月报,共 400 行// --- 租户 B// [重试] 第 1 次失败:报表服务超时// [重试] 第 2 次失败:报表服务超时// [失败] 重试 2 次仍然失败这就是第 10 章“要哪几层、按什么顺序,在创建对象的地方一层套一层拼起来”,只是每一层都是函数。retry(3) 有三层函数:最外层收配置,中间一层收被包的函数,最里层是每次调用真正执行的 wrapper;要是写成第 10 章那样的装饰器类,三层分别是构造函数里的配置、self.inner 指向的下一层,以及 load 这样的方法本身。租户 B 只配了 2 次重试,服务恰好失败 2 次,于是第二段输出以失败结束。配置不同,行为不同,compose 和两个包装函数一行没改。
代价也在输出里:两个租户拿到的函数都叫 fetch_report(functools.wraps 把原函数的名字沿用了下来),打印它看不出外面包了几层、重试几次;TS 版 compose 返回的是箭头函数,连这个名字都没有。这件事 29.8 再说。
29.2 生成器
Section titled “29.2 生成器”22.6 把生成器(generator)说成“编译器替你写好的迭代器类”:OrderIterator 的字段变成局部变量,__next__ 里“缓冲空了没有、是不是最后一页”的判断,变成函数体从上往下的顺序,停在哪个 yield 就是状态。那一节用 yield from items 一页页交出订单。
生成器在树上更省事。22.10 的练习提示说过,类写的迭代器没法递归,因为 __next__ 交出一个就得返回,只能自己维护一个栈;生成器可以直接递归,每层 yield from 下一层。下面把第 13 章的年货礼盒压平成一串单品,每件带上它在树里的路径,做一张拣货单:
from collections.abc import Iteratorfrom dataclasses import dataclass
@dataclassclass Product: # 第 13 章的单品,这里只留数据 name: str price: int # 元 weight: int # 克
type Item = Product | Bundle
@dataclassclass Bundle: # 第 13 章的套餐 name: str items: list[Item] percent: int = 100 # 整体折扣,90 就是九折
def leaves(item: Item, path: str = "") -> Iterator[tuple[str, Product]]: # 深度优先,只交出单品 if isinstance(item, Product): yield path + item.name, item return for child in item.items: yield from leaves(child, f"{path}{item.name}/") # 递归:下一层交出的,原样往外交
def new_year_box() -> Bundle: # 和第 13 章同一棵树 nuts = Bundle("坚果套装", [Product("碧根果", 68, 500), Product("夏威夷果", 58, 500), Product("巴旦木", 44, 400)]) tea = Bundle("茶叶套装", [Product("龙井", 128, 250), Product("普洱", 92, 357)]) return Bundle("年货礼盒", [nuts, tea, Product("贺卡", 10, 50)], percent=90)
if __name__ == "__main__": box = new_year_box() for path, p in leaves(box): print(f"{path} {p.weight} 克") print(f"共 {sum(1 for _ in leaves(box))} 件,重 {sum(p.weight for _, p in leaves(box))} 克") print(f"单品价格加起来 {sum(p.price for _, p in leaves(box))} 元") for _, p in leaves(box): if p.price > 100: print(f"第一件单价过百的:{p.name}") break
# 输出:# 年货礼盒/坚果套装/碧根果 500 克# 年货礼盒/坚果套装/夏威夷果 500 克# 年货礼盒/坚果套装/巴旦木 400 克# 年货礼盒/茶叶套装/龙井 250 克# 年货礼盒/茶叶套装/普洱 357 克# 年货礼盒/贺卡 50 克# 共 6 件,重 2057 克# 单品价格加起来 400 元# 第一件单价过百的:龙井TS 的递归写成 yield*:
class Product { // 第 13 章的单品,这里只留数据 constructor(readonly name: string, readonly price: number, readonly weight: number) {}}
type Item = Product | Bundle;
class Bundle { // 第 13 章的套餐 constructor(readonly name: string, readonly items: Item[], readonly percent = 100) {}}
function* leaves(item: Item, path = ""): Generator<[string, Product]> { // 深度优先,只交出单品 if (item instanceof Product) { yield [path + item.name, item]; return; } for (const child of item.items) { yield* leaves(child, `${path}${item.name}/`); // 递归:下一层交出的,原样往外交 }}
function newYearBox(): Bundle { // 和第 13 章同一棵树 const nuts = new Bundle("坚果套装", [ new Product("碧根果", 68, 500), new Product("夏威夷果", 58, 500), new Product("巴旦木", 44, 400), ]); const tea = new Bundle("茶叶套装", [new Product("龙井", 128, 250), new Product("普洱", 92, 357)]); return new Bundle("年货礼盒", [nuts, tea, new Product("贺卡", 10, 50)], 90);}
const box = newYearBox();for (const [path, p] of leaves(box)) { console.log(`${path} ${p.weight} 克`);}const all = [...leaves(box)];console.log(`共 ${all.length} 件,重 ${all.reduce((s, [, p]) => s + p.weight, 0)} 克`);console.log(`单品价格加起来 ${all.reduce((s, [, p]) => s + p.price, 0)} 元`);for (const [, p] of leaves(box)) { if (p.price > 100) { console.log(`第一件单价过百的:${p.name}`); break; }}
// 输出:// 年货礼盒/坚果套装/碧根果 500 克// 年货礼盒/坚果套装/夏威夷果 500 克// 年货礼盒/坚果套装/巴旦木 400 克// 年货礼盒/茶叶套装/龙井 250 克// 年货礼盒/茶叶套装/普洱 357 克// 年货礼盒/贺卡 50 克// 共 6 件,重 2057 克// 单品价格加起来 400 元// 第一件单价过百的:龙井重量 2057 克,和第 13 章的图对得上。价格却是 400 元,不是 13.1 算出的 360 元:压平以后,“礼盒打九折”这一层信息没了。生成器替掉的是迭代器的类,替不掉组合,“套餐怎样回答 price()“仍要写在 Bundle 上。22.9 说的”组合搭好树,迭代器走树“在这里照样成立,只是走树的那一半缩成了一个六行的函数。另外,代码里每次都重新调用 leaves(box),因为 22.6 提醒过,生成器对象只能走一遍。
29.3 模块
Section titled “29.3 模块”6.6 说 Python 的模块本身就是单例:第一次导入时执行顶层代码,模块对象存进 sys.modules,之后再导入直接拿缓存。config.py 里写一行 settings = fetch_remote_config() 就够了;要惰性,就把它放进 @lru_cache(maxsize=None) 修饰的 get_settings()。TS 的 ES 模块一样只执行一次,惰性版本用 6.6 那个几行的 once。语言省掉的是 _instance 字段和 instance() 里那个 if。
模块单例不够用的情况,6.3、6.6、6.8 列出了几种,下面归纳并补两种:
- 测试要换掉它。6.4 的
Config.set_instance(...)一行就换;模块变量没有这个入口,6.6 的写法二要先get_settings.cache_clear(),再用 mock 替换fetch_remote_config,测试得知道实现里调了哪个函数。 - 创建要用启动时才知道的参数。模块顶层代码在
import时就跑,那时main()还没解析命令行、没读环境变量。配置中心的地址要从命令行来,模块级的settings = ...就拿不到它。 - 以后可能不止一份。按租户各一份配置时,模块只能有一个;6.7 的
logging.getLogger用的是“每个名字一个”的注册表,已经是另一种结构。 - 多进程。模块单例和单例类一样,“唯一”只在一个进程里成立(6.8 第 4 条),这一点哪种写法都改变不了。
这几种情况下,6.8 末尾的建议仍然最稳:在程序入口创建一次,往下当参数传。第 30 章的依赖注入就是把这件事做成机制。
29.4 类是一等值:字典当登记表
Section titled “29.4 类是一等值:字典当登记表”3.6 的出发点是:在 Python 里调用 EmailSender() 和调用一个返回 EmailSender 实例的函数没有区别。于是简单工厂用不着一串 if,一张表就够了:
SENDERS: dict[str, Callable[[], Sender]] = {"email": EmailSender, "sms": SmsSender}TS 里类也是值,表的值类型写成构造签名:Record<string, new () => Sender>。加微信只在表里加一行。注意 3.6 给表的值类型是 Callable[[], Sender] 而不是“某个类”,所以表里放什么都行,只要调用它能得到一个 Sender:一个类、一个 lambda、一个绑好参数的 functools.partial 都可以。
工厂方法什么时候仍然需要,3.6 自己给了标准:创建的旁边有没有公共流程。第 3 章的 Notifier.notify() 先加前缀,再调 check(),再 create_sender().send(...),最后记日志;SmsNotifier 还覆写了 check(),往流程里插入“不超过 30 字”的规则。这时基类有东西可装,子类也不只是一行 return SmsSender()。只是“按名字拿一个对象”,用表;两样都有,入口用 NOTIFIERS 表选出 Notifier 子类,3.6 末尾说两种写法常常这样搭配。
抽象工厂也类似。可以给支付、退款、验签各建一张表,可第 4 章要的是“同一个渠道的三件拿齐”:三张表各查一次,写错一个键就会拿到支付宝的支付配微信的退款。PaymentGateway 把三个创建方法放在同一个对象上,选一次渠道,三件一起换,这是一张表给不了的。
29.5 关键字参数、dataclass 与对象字面量
Section titled “29.5 关键字参数、dataclass 与对象字面量”5.7 在“什么时候别用”里说:Python 有关键字参数和默认值,调用方只能按名字传时,5.3 的错位问题直接消失;配合 @dataclass(frozen=True),产品也不可变,必要时还能在 __post_init__ 里写几行检查;TS 的对象字面量加上接口类型,同样按名字传。下面把第 5 章那封邮件照这个思路写一遍,规则和输出与 5.4 的 EmailBuilder 版相同:
from collections.abc import Callablefrom dataclasses import dataclassfrom datetime import datetimefrom typing import Literal
Priority = Literal["normal", "high"]
@dataclass(frozen=True, kw_only=True) # kw_only:所有字段只能按名字传;frozen:造好不能改class Email: to: tuple[str, ...] subject: str = "" cc: tuple[str, ...] = () bcc: tuple[str, ...] = () reply_to: str | None = None text_body: str | None = None html_body: str | None = None attachments: tuple[str, ...] = () priority: Priority = "normal" send_at: datetime | None = None
def __post_init__(self) -> None: # 5.4 build() 里的三条规则,搬到产品自己身上 if not self.to: raise ValueError("至少要有一个收件人") if self.text_body is None and self.html_body is None: raise ValueError("纯文本正文和 HTML 正文至少要有一个") if self.attachments and not self.subject: raise ValueError("有附件时必须有主题")
def send(email: Email) -> None: # 和 5.4 相同 parts = [f"收件人:{', '.join(email.to)}", f"主题:{email.subject}"] if email.cc: parts.append(f"抄送:{', '.join(email.cc)}") if email.bcc: parts.append(f"密送:{', '.join(email.bcc)}") if email.reply_to: parts.append(f"回复到:{email.reply_to}") parts.append("正文:HTML" if email.html_body else "正文:纯文本") if email.attachments: parts.append(f"附件:{', '.join(email.attachments)}") if email.priority != "normal": parts.append(f"优先级:{email.priority}") if email.send_at: parts.append(f"定时:{email.send_at:%Y-%m-%d %H:%M}") print("发送 | " + " | ".join(parts))
if __name__ == "__main__": # 调用点一:购课回执 send(Email(to=("li@example.com",), subject="购课成功", text_body="你已购买《Python 入门》", attachments=("receipt.pdf",))) # 调用点二:上课提醒,定时在开课前一小时发出 send(Email(to=("wang@example.com",), subject="今晚 8 点开课", html_body="<p>今晚 8 点开课</p>", priority="high", send_at=datetime(2026, 10, 8, 19, 0))) # 调用点三:销售周报,附件个数每周不同,先在列表里攒,再一次传进去 files: list[str] = [] for filename in ["weekly.xlsx", "chart.png"]: files.append(filename) send(Email(to=("boss@example.com",), cc=("ops@example.com",), subject="本周销售周报", reply_to="sales@example.com", text_body="数据见附件", attachments=tuple(files))) # 不合规则的邮件在创建这一步就被拦下 rejected: list[Callable[[], Email]] = [ lambda: Email(to=("li@example.com",), subject="没有正文"), lambda: Email(to=("li@example.com",), text_body="见附件", attachments=("invoice.pdf",)), ] for make in rejected: try: send(make()) except ValueError as error: print(f"拒绝 | {error}")
# 输出:# 发送 | 收件人:li@example.com | 主题:购课成功 | 正文:纯文本 | 附件:receipt.pdf# 发送 | 收件人:wang@example.com | 主题:今晚 8 点开课 | 正文:HTML | 优先级:high | 定时:2026-10-08 19:00# 发送 | 收件人:boss@example.com | 主题:本周销售周报 | 抄送:ops@example.com | 回复到:sales@example.com | 正文:纯文本 | 附件:weekly.xlsx, chart.png# 拒绝 | 纯文本正文和 HTML 正文至少要有一个# 拒绝 | 有附件时必须有主题TS 里调用方写的对象字面量是一种类型,产出的 Email 是另一种:前者大多是可选字段,后者字段齐全、全部 readonly。中间一个校验函数 makeEmail 补默认值、查规则、冻结:
type Priority = "normal" | "high";
interface EmailFields { // 调用方写的对象字面量:只有 to 必填 to: string[]; subject?: string; cc?: string[]; bcc?: string[]; replyTo?: string; textBody?: string; htmlBody?: string; attachments?: string[]; priority?: Priority; sendAt?: Date;}
interface Email { // 产品:和 5.4 相同,造好之后字段不能再改 readonly to: readonly string[]; readonly subject: string; readonly cc: readonly string[]; readonly bcc: readonly string[]; readonly replyTo: string | null; readonly textBody: string | null; readonly htmlBody: string | null; readonly attachments: readonly string[]; readonly priority: Priority; readonly sendAt: Date | null;}
function makeEmail(f: EmailFields): Email { // 5.4 build() 里的三条规则 const attachments = f.attachments ?? []; if (f.to.length === 0) throw new Error("至少要有一个收件人"); if (f.textBody === undefined && f.htmlBody === undefined) throw new Error("纯文本正文和 HTML 正文至少要有一个"); if (attachments.length > 0 && !f.subject) throw new Error("有附件时必须有主题"); return Object.freeze({ to: Object.freeze([...f.to]), subject: f.subject ?? "", cc: Object.freeze([...(f.cc ?? [])]), bcc: Object.freeze([...(f.bcc ?? [])]), replyTo: f.replyTo ?? null, textBody: f.textBody ?? null, htmlBody: f.htmlBody ?? null, attachments: Object.freeze([...attachments]), priority: f.priority ?? "normal", sendAt: f.sendAt ?? null, });}
const pad = (n: number): string => String(n).padStart(2, "0");
function send(email: Email): void { // 和 5.4 相同 const parts = [`收件人:${email.to.join(", ")}`, `主题:${email.subject}`]; if (email.cc.length > 0) parts.push(`抄送:${email.cc.join(", ")}`); if (email.bcc.length > 0) parts.push(`密送:${email.bcc.join(", ")}`); if (email.replyTo) parts.push(`回复到:${email.replyTo}`); parts.push(email.htmlBody ? "正文:HTML" : "正文:纯文本"); if (email.attachments.length > 0) parts.push(`附件:${email.attachments.join(", ")}`); if (email.priority !== "normal") parts.push(`优先级:${email.priority}`); if (email.sendAt) { const t = email.sendAt; parts.push(`定时:${t.getFullYear()}-${pad(t.getMonth() + 1)}-${pad(t.getDate())} ${pad(t.getHours())}:${pad(t.getMinutes())}`); } console.log("发送 | " + parts.join(" | "));}
// 调用点一:购课回执send(makeEmail({ to: ["li@example.com"], subject: "购课成功", textBody: "你已购买《Python 入门》", attachments: ["receipt.pdf"] }));// 调用点二:上课提醒,定时在开课前一小时发出send(makeEmail({ to: ["wang@example.com"], subject: "今晚 8 点开课", htmlBody: "<p>今晚 8 点开课</p>", priority: "high", sendAt: new Date(2026, 9, 8, 19, 0),}));// 调用点三:销售周报,附件个数每周不同,先在数组里攒,再一次传进去const files: string[] = [];for (const filename of ["weekly.xlsx", "chart.png"]) { files.push(filename);}send(makeEmail({ to: ["boss@example.com"], cc: ["ops@example.com"], subject: "本周销售周报", replyTo: "sales@example.com", textBody: "数据见附件", attachments: files,}));// 不合规则的邮件在 makeEmail 这一步就被拦下const noBody: EmailFields = { to: ["li@example.com"], subject: "没有正文" };const noSubject: EmailFields = { to: ["li@example.com"], textBody: "见附件", attachments: ["invoice.pdf"] };for (const fields of [noBody, noSubject]) { try { send(makeEmail(fields)); } catch (error) { console.log(`拒绝 | ${(error as Error).message}`); }}
// 输出:// 发送 | 收件人:li@example.com | 主题:购课成功 | 正文:纯文本 | 附件:receipt.pdf// 发送 | 收件人:wang@example.com | 主题:今晚 8 点开课 | 正文:HTML | 优先级:high | 定时:2026-10-08 19:00// 发送 | 收件人:boss@example.com | 主题:本周销售周报 | 抄送:ops@example.com | 回复到:sales@example.com | 正文:纯文本 | 附件:weekly.xlsx, chart.png// 拒绝 | 纯文本正文和 HTML 正文至少要有一个// 拒绝 | 有附件时必须有主题和 5.4 对照:
- 调用处按名字传。
kw_only=True和 5.7 说的“把参数放到*后面”效果一样,写成Email(("li@example.com",), "购课成功")当场抛TypeError,提示只接受 1 个位置参数(self)却给了 3 个。TS 里对象字面量直接传给makeEmail时,字段名拼错(htmlbody)编译不过。 - 规则住进了产品。5.4 末尾承认建造者“拦不住有人绕过建造者,直接调用
Email(...)”。Python 版的规则在__post_init__里,任何创建Email的路径都要过它,连dataclasses.replace改一个字段复制一份也会重新检查。TS 版没做到这一步:任何形状对得上的对象都能当Email用,绕开makeEmail仍然办得到,要堵住得把Email写成构造函数私有的类。 - 一步步攒的部分变成了局部变量。周报的附件先放进
files,最后一次传进去。5.4 说用构造函数就得“先在外面把列表攒好”,现在正是这样,只是代码不长。 - 省掉的是
EmailBuilder整个类:十个私有字段、十个方法和build()里那一长串传值。
建造者仍然值得写的情况,5.7 已经说了:对象要在好几步、跨好几个函数、在循环和分支里攒起来,中途还要能传来传去,一个半成品的建造者比一堆局部变量好管。
29.6 可辨识联合与模式匹配
Section titled “29.6 可辨识联合与模式匹配”访问者绕了一个大圈:节点上留一个 accept,由它回调访问者里对应的 visit_ 方法,为的是“在节点类外面,按节点类型分开写一张报表”。26.7 指出,有模式匹配(pattern matching)的语言不必绕:Python 3.10 起有 match,case Product(stock=0, name=n) 一行就能按类型分支并拆出字段;TS 用可辨识联合(discriminated union)加 switch,在 default 里把节点赋给一个 never 类型的变量,有类型没处理到就报错。下面用这个写法给第 26 章的商品树出税额和缺货两张报表:
from dataclasses import dataclassfrom typing import assert_never
@dataclassclass Product: # 单品:只有数据,没有 accept name: str price: int # 元 tax_rate: int # 税率,9 就是 9% stock: int # 库存
type Item = Product | Bundle # 节点一共有哪几种,写在这一处
@dataclassclass Bundle: # 套餐 name: str items: list[Item] percent: int = 100 # 整体折扣,90 就是九折
def tax_fen(item: Item) -> int: # 报表一:税额,单位分(编号沿用第 26 章)。一张报表就是一个函数 match item: case Product(price=price, tax_rate=rate): return price * rate case Bundle(items=items, percent=percent): return sum(tax_fen(i) for i in items) * percent // 100 case _: assert_never(item) # 类型检查器在这里核对:上面的 case 是否覆盖了 Item 的每一种
def out_of_stock(item: Item) -> list[str]: # 报表三:缺货检查 match item: case Product(stock=0, name=n): return [n] case Product(): return [] case Bundle(items=items): return [n for i in items for n in out_of_stock(i)] case _: assert_never(item)
def yuan(fen: int) -> str: return f"{fen // 100}.{fen % 100:02d}"
def new_year_box() -> Bundle: # 和第 26 章同一棵树 nuts = Bundle("坚果套装", [Product("碧根果", 68, 9, 30), Product("夏威夷果", 58, 9, 0), Product("巴旦木", 44, 9, 12)]) tea = Bundle("茶叶套装", [Product("龙井", 128, 9, 8), Product("普洱", 92, 9, 5)]) return Bundle("年货礼盒", [nuts, tea, Product("贺卡", 10, 13, 200)], percent=90)
if __name__ == "__main__": cart: list[Item] = [new_year_box(), Product("保温杯", 99, 13, 0)] print(f"税额:{yuan(sum(tax_fen(i) for i in cart))} 元") print("缺货:" + "、".join(n for i in cart for n in out_of_stock(i)))
# 输出:# 税额:45.63 元# 缺货:夏威夷果、保温杯TS 版的节点是普通对象,kind 字段就是“可辨识”的那个标签:
interface Product { // 单品:只有数据,没有 accept readonly kind: "product"; readonly name: string; readonly price: number; // 元 readonly taxRate: number; // 税率,9 就是 9% readonly stock: number; // 库存}
interface Bundle { // 套餐 readonly kind: "bundle"; readonly name: string; readonly items: readonly Item[]; readonly percent: number; // 整体折扣,90 就是九折}
type Item = Product | Bundle; // 节点一共有哪几种,写在这一处
const product = (name: string, price: number, taxRate: number, stock: number): Product => ({ kind: "product", name, price, taxRate, stock });const bundle = (name: string, items: Item[], percent = 100): Bundle => ({ kind: "bundle", name, items, percent });
function taxFen(item: Item): number { // 报表一:税额,单位分(编号沿用第 26 章)。一张报表就是一个函数 switch (item.kind) { case "product": return item.price * item.taxRate; // 在这个分支里,item 的类型已经收窄成 Product case "bundle": return Math.floor((item.items.reduce((s, i) => s + taxFen(i), 0) * item.percent) / 100); default: { const unreachable: never = item; // 有哪种节点没写 case,这一行就编译不过 throw new Error(`没处理的节点:${JSON.stringify(unreachable)}`); } }}
function outOfStock(item: Item): string[] { // 报表三:缺货检查 switch (item.kind) { case "product": return item.stock === 0 ? [item.name] : []; case "bundle": return item.items.flatMap(outOfStock); default: { const unreachable: never = item; throw new Error(`没处理的节点:${JSON.stringify(unreachable)}`); } }}
function yuan(fen: number): string { return `${Math.floor(fen / 100)}.${String(fen % 100).padStart(2, "0")}`;}
function newYearBox(): Bundle { // 和第 26 章同一棵树 const nuts = bundle("坚果套装", [product("碧根果", 68, 9, 30), product("夏威夷果", 58, 9, 0), product("巴旦木", 44, 9, 12)]); const tea = bundle("茶叶套装", [product("龙井", 128, 9, 8), product("普洱", 92, 9, 5)]); return bundle("年货礼盒", [nuts, tea, product("贺卡", 10, 13, 200)], 90);}
const cart: Item[] = [newYearBox(), product("保温杯", 99, 13, 0)];console.log(`税额:${yuan(cart.reduce((s, i) => s + taxFen(i), 0))} 元`);console.log("缺货:" + cart.flatMap(outOfStock).join("、"));
// 输出:// 税额:45.63 元// 缺货:夏威夷果、保温杯数字和第 26 章一样:45.63 元,夏威夷果和保温杯缺货。Visitor、accept、TaxReport、StockCheck 都没了,加一张报表就是加一个函数,节点类不动,这一点和访问者相同。
关键在加节点类型的时候。假设店里开始卖礼品卡,在 TS 的联合里加一项 GiftCard,带 kind: "giftCard",别处不改。tsc 会在 taxFen 和 outOfStock 的 default 里各报一次:
error TS2322: Type 'GiftCard' is not assignable to type 'never'.每一张没处理礼品卡的报表都被点名。Python 这边,type Item = Product | Bundle | GiftCard 一改,mypy、pyright 这类类型检查器会在每个 assert_never(item) 那一行报错。mypy 的说法是:
error: Argument 1 to "assert_never" has incompatible type "GiftCard"; expected "Never" [arg-type]pyright 也会报,措辞不同。Python 解释器本身不做这个检查,没跑类型检查器的话,要等到程序真走到 case _ 才抛 AssertionError。即使这样,也比 26.3 那种“继承了默认的 return []、悄悄漏报”强:它会出声。
注意这个检查只在没写兜底分支时才有。在 switch 里写一个 default: return 0,或在 match 里用 case _: return [] 收掉剩下的,编译器就再也不会提醒,又回到了 26.3。
第 21 章 21.6 末尾留下的状态也是同一个思路。订单状态写成一个联合,每个操作是一个 switch:
type OrderState = | { kind: "pendingPayment" } | { kind: "awaitingDeposit" } | { kind: "depositPaid" } | { kind: "paid" } | { kind: "partiallyRefunded" } | { kind: "shipped" } | { kind: "completed" } | { kind: "cancelled" } | { kind: "refunded" };
function ship(state: OrderState): OrderState { switch (state.kind) { case "paid": case "partiallyRefunded": return { kind: "shipped" }; case "pendingPayment": case "awaitingDeposit": case "depositPaid": case "shipped": case "completed": case "cancelled": case "refunded": throw new Error(`${state.kind} 的订单不能发货`); default: { const unreachable: never = state; // 加了第十种状态,没写进上面任何一个 case,这里编译不过 return unreachable; } }}和 21.4 的类写法比,取舍正好相反。类写法里,OrderState 基类给每个操作一个“不允许”的默认实现,新状态类只覆写自己允许的那几个,漏了也能跑,结果是“不允许”。联合写法要求每个操作都把新状态明确放进某个 case,五个操作就要改五处,但编译器会一处处指出来,没人能忘。状态要带数据(比如已退了多少钱)时,放进联合里对应的那一项就行。哪种好,取决于你更怕“默认不允许”掩盖了漏写,还是更怕五处改动的工作量。
29.7 结构类型:形状对就行
Section titled “29.7 结构类型:形状对就行”Python 的 Protocol 和 TS 的 interface 都按结构判断类型(structural typing),只看对象有没有这些方法、签名对不对,不看它声明过继承谁。书里很多代码已经在依赖这一点:
- 9.4 的
SfAdapter类定义里没写(Tracker),只因为它有一个签名对得上的track,类型检查器就认它是Tracker。测试里的FakeTracker也一样,在测试文件里写一个有track方法的类,生产代码一行不碰。 - 12.4 的
CachingProxy只持有一个inner: ImageSource,自己也有load,就能套在任何图片服务外面。 - TS 里
class SfAdapter implements Tracker的implements是写给读者和编译器核对用的,不写也行。一个对象字面量{ track: (orderNo) => ({ status: "派送中", lastEvent: "测试轨迹", updatedAt: 1736589600 }) }就能传给要Tracker的参数。
所以在这两种语言里,适配器常常不用是一个类:一个函数、一个对象字面量,形状对就行。代理更进一步,12.6 的 SimpleLazyObject 用 __getattr__ 把任何属性读取都转过去,JS 的 Proxy 用 trap 拦截读写,连“逐个方法写转发”都省了。
代价是没有人在运行时核对形状。Protocol 只给类型检查器看,SfAdapter 把 track 拼成 trace,不跑 mypy 的话,要到调用那一刻才抛 AttributeError。结构类型还有其他边界(两个形状碰巧相同的类型可以互相赋值等),属于类型系统的话题,这里不展开。
29.8 什么时候仍然把结构写出来
Section titled “29.8 什么时候仍然把结构写出来”前面几章的“更简洁的写法”多数附了“什么时候类仍然值得写”,理由反复出现的是四条:
- 名字要出现在日志里。17.6 的
full_reduction(199, 30)返回的函数打印出来只有函数名和内存地址,看不出门槛是 199 还是 200;19.6 的命令要写“改价 A1:现价 79”,得从partial的.args里翻;29.1 的两个租户拿到的函数都叫fetch_report。客服、运维要从日志里查“为什么”时,一个带名字和属性的对象省他们一次读源码。 - 不止一个方法。17.6 的
describe要和discount放在一起,18.6 的订阅者要声明失败时要不要重试,20.6 的TsvImporter既要解析又要覆写before_save。写成函数就是一个参数变成两三个,每个调用处都得一起改,几个函数之间还没有地方放共享状态。 - 要带配置、要被看见。闭包能记住
threshold,可外面读不到;类把它露成属性,后台能显示“当前满 199 减 30”,测试能直接断言。 - 给团队一个共同的词。代码评审里说“这是个适配器”,大家就知道它只该做翻译(9.7);说“这是个
StorageDecorator”,就知道它和被包的对象接口相同。三四个人维护同一块代码时,类名和模式名是一种约定,函数套函数没有这种提示。19.6 还提了一条:命令由好几个人写时,接口提醒每个人都得实现undo()。
反过来,下面这些是结构写多了的信号,看到一个就该问一句“去掉它会怎样”:
- 只有一个实现的接口。
Sender协议下只有EmailSender,没有第二个渠道,测试里也没有假的实现。28.5 和 3.8 都说过,等第二种真来了再抽。注意测试用的假实现也算一个实现:9.4 的FakeTracker就是Tracker存在的理由之一。 - 只转发不加东西的包装。一个类的每个方法都是
return self.inner.xxx(...),既不翻译参数,也不加缓存、日志、权限。28.5 举过 9.7 和 19.8 的例子。 - 只被调用一次的工厂。整个程序只在
main()里写了一次create_sender("email"),参数还是常量,直接写EmailSender()读的人少跳一层。 - 一个方法、没有字段的类。
class FullReductionStrategy里只有__call__或apply,也不需要名字进日志,它就是一个函数。 - 没有子类覆写的钩子。基类里写了
before_save、after_save两个空方法,等着“以后”有人覆写,可从来没人覆写过。
判断时不要只数类的个数。要问:这个结构挡住了什么具体的麻烦?答得出“加一种渠道只加一个类”,它就值得;答案是“以后可能”,就先删。
29.9 小结
Section titled “29.9 小结”- 一等函数和闭包替掉了只有一个方法的接口:策略(17.6)、观察者(18.6)、命令(19.6)、模板方法的可变步骤(20.6)、装饰器(10.6)都可以写成函数,闭包里的变量就是原来的字段。
- 生成器替掉了迭代器的类(22.6),递归的树也只要几行;它替不掉组合,压平以后礼盒的九折就没了。
- 模块和
lru_cache替掉了单例的样板(6.6),但测试要换、要启动参数、要多份时,回到在入口创建、往下传参。 - 类是一等值,简单工厂成了一张表(3.6);关键字参数、dataclass 和对象字面量替掉了大多数建造者(5.7),规则可以住进产品本身。
- 模式匹配和可辨识联合替掉了访问者的双分派(26.7),也能表示状态(21.6),加节点类型时由类型检查器点名每一处;结构类型让适配器、代理不必是类。
- 名字要进日志、方法不止一个、要带配置、要给团队一个共同词汇时,仍然把结构写出来;只有一个实现的接口、只转发的包装、只调一次的工厂,是写多了的信号。
本章只碰了与模式有关的几种语言特性。函数、闭包、生成器、模式匹配、结构类型在 Python、JS/TS、C#、Go 里各自怎样实现、为什么不同,见《程序设计语言特性》。