跳转到内容

第 29 章 · 现代语言里的模式

GoF 的书出版于 1994 年,例子用 C++ 和 Smalltalk 写成。那时的 C++ 里,函数不能随手当值传来传去,也没有生成器和模式匹配,想“把一段可换的算法交给别人”,只能写一个接口、一个类,再 new 一个对象。Python 和 TypeScript 不一样:函数是值,类是值,模块只执行一次,yield 能暂停函数。前面有 8 章在“更简洁的写法”一节里当场演示过这种差别,那几节分散在各章,这一章把它们收到一起,按语言特性归类:每节先回指那一节、引用里面的代码,再补一个新例子。最后讲反过来的问题:什么时候仍然该把类写出来,什么样的结构是多余的。

语言特性和它简化的模式:实线是常能整个省掉那几个类,虚线是写起来更轻或替换有取舍

图里每个模式后面是章号,读完本章可以拿它当索引。

函数是一等值(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 functools
from 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 再说。

22.6 把生成器(generator)说成“编译器替你写好的迭代器类”:OrderIterator 的字段变成局部变量,__next__ 里“缓冲空了没有、是不是最后一页”的判断,变成函数体从上往下的顺序,停在哪个 yield 就是状态。那一节用 yield from items 一页页交出订单。

生成器在树上更省事。22.10 的练习提示说过,类写的迭代器没法递归,因为 __next__ 交出一个就得返回,只能自己维护一个栈;生成器可以直接递归,每层 yield from 下一层。下面把第 13 章的年货礼盒压平成一串单品,每件带上它在树里的路径,做一张拣货单:

from collections.abc import Iterator
from dataclasses import dataclass
@dataclass
class Product: # 第 13 章的单品,这里只留数据
name: str
price: int # 元
weight: int # 克
type Item = Product | Bundle
@dataclass
class 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 提醒过,生成器对象只能走一遍。

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 章的依赖注入就是把这件事做成机制。

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 Callable
from dataclasses import dataclass
from datetime import datetime
from 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=("[email protected]",), subject="购课成功",
text_body="你已购买《Python 入门》", attachments=("receipt.pdf",)))
# 调用点二:上课提醒,定时在开课前一小时发出
send(Email(to=("[email protected]",), 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=("[email protected]",), cc=("[email protected]",), subject="本周销售周报",
reply_to="[email protected]", text_body="数据见附件", attachments=tuple(files)))
# 不合规则的邮件在创建这一步就被拦下
rejected: list[Callable[[], Email]] = [
lambda: Email(to=("[email protected]",), subject="没有正文"),
lambda: Email(to=("[email protected]",), text_body="见附件", attachments=("invoice.pdf",)),
]
for make in rejected:
try:
send(make())
except ValueError as error:
print(f"拒绝 | {error}")
# 输出:
# 发送 | 收件人:[email protected] | 主题:购课成功 | 正文:纯文本 | 附件:receipt.pdf
# 发送 | 收件人:[email protected] | 主题:今晚 8 点开课 | 正文:HTML | 优先级:high | 定时:2026-10-08 19:00
# 发送 | 收件人:[email protected] | 主题:本周销售周报 | 抄送:[email protected] | 回复到:[email protected] | 正文:纯文本 | 附件: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: ["[email protected]"], subject: "购课成功", textBody: "你已购买《Python 入门》", attachments: ["receipt.pdf"] }));
// 调用点二:上课提醒,定时在开课前一小时发出
send(makeEmail({
to: ["[email protected]"], 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: ["[email protected]"], cc: ["[email protected]"], subject: "本周销售周报",
replyTo: "[email protected]", textBody: "数据见附件", attachments: files,
}));
// 不合规则的邮件在 makeEmail 这一步就被拦下
const noBody: EmailFields = { to: ["[email protected]"], subject: "没有正文" };
const noSubject: EmailFields = { to: ["[email protected]"], textBody: "见附件", attachments: ["invoice.pdf"] };
for (const fields of [noBody, noSubject]) {
try {
send(makeEmail(fields));
} catch (error) {
console.log(`拒绝 | ${(error as Error).message}`);
}
}
// 输出:
// 发送 | 收件人:[email protected] | 主题:购课成功 | 正文:纯文本 | 附件:receipt.pdf
// 发送 | 收件人:[email protected] | 主题:今晚 8 点开课 | 正文:HTML | 优先级:high | 定时:2026-10-08 19:00
// 发送 | 收件人:[email protected] | 主题:本周销售周报 | 抄送:[email protected] | 回复到:[email protected] | 正文:纯文本 | 附件:weekly.xlsx, chart.png
// 拒绝 | 纯文本正文和 HTML 正文至少要有一个
// 拒绝 | 有附件时必须有主题

和 5.4 对照:

  • 调用处按名字传。kw_only=True 和 5.7 说的“把参数放到 * 后面”效果一样,写成 Email(("[email protected]",), "购课成功") 当场抛 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 已经说了:对象要在好几步、跨好几个函数、在循环和分支里攒起来,中途还要能传来传去,一个半成品的建造者比一堆局部变量好管。

访问者绕了一个大圈:节点上留一个 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 dataclass
from typing import assert_never
@dataclass
class Product: # 单品:只有数据,没有 accept
name: str
price: int # 元
tax_rate: int # 税率,9 就是 9%
stock: int # 库存
type Item = Product | Bundle # 节点一共有哪几种,写在这一处
@dataclass
class 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,五个操作就要改五处,但编译器会一处处指出来,没人能忘。状态要带数据(比如已退了多少钱)时,放进联合里对应的那一项就行。哪种好,取决于你更怕“默认不允许”掩盖了漏写,还是更怕五处改动的工作量。

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。结构类型还有其他边界(两个形状碰巧相同的类型可以互相赋值等),属于类型系统的话题,这里不展开。

前面几章的“更简洁的写法”多数附了“什么时候类仍然值得写”,理由反复出现的是四条:

  • 名字要出现在日志里。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 两个空方法,等着“以后”有人覆写,可从来没人覆写过。

判断时不要只数类的个数。要问:这个结构挡住了什么具体的麻烦?答得出“加一种渠道只加一个类”,它就值得;答案是“以后可能”,就先删。

  • 一等函数和闭包替掉了只有一个方法的接口:策略(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 里各自怎样实现、为什么不同,见《程序设计语言特性》。