跳转到内容

第 5 章 · 建造者(Builder)

一句话:用一个建造者对象一步步收集参数,最后由 build() 统一校验,产出一个造好就不再变的对象。 解决的痛:构造函数的参数越加越多,调用处一排 None 靠位置对应,传错位置只要类型对得上就不报错;校验规则没有一个固定的地方放。

还是那家做在线课程的公司。系统要发各种邮件:用户买完课发购课回执,开课前发上课提醒,每周一给老板发销售周报。

一封邮件有这些内容:

  • 收件人 to:可以有好几个,必填。
  • 主题 subject:必填。
  • 抄送 cc 和密送 bcc:可选。抄送的地址所有收件人都看得见,密送的看不见。
  • 纯文本正文 text_body 和 HTML 正文 html_body:可选。HTML 正文能排版、放链接,纯文本正文给不显示 HTML 的邮件客户端看。
  • 附件列表 attachments:可选,本章只存文件名。
  • 优先级 priority:可选,默认 "normal",紧急的设成 "high"。

你要写一个 Email 类装下这些内容,再写一个 send(email) 把它发出去。真实项目里 send 背后是 SMTP 或者邮件服务商的 API,本章用 print 打一行摘要代替。发邮件的代码分散在三个文件里:orders.py 发回执,reminders.py 发提醒,reports.py 发周报。和前两章一样,为了能直接运行,本章把它们放在一个文件里。

最直接的写法是给 Email 一个构造函数,8 项内容对应 8 个参数,必填的在前,可选的给默认值:

from typing import Literal
class Email:
def __init__(self, to: list[str], subject: str, cc: list[str] | None = None,
bcc: list[str] | None = None, text_body: str | None = None,
html_body: str | None = None, attachments: list[str] | None = None,
priority: Literal["normal", "high"] = "normal") -> None:
self.to = to
self.subject = subject
self.cc = cc or []
self.bcc = bcc or []
self.text_body = text_body
self.html_body = html_body
self.attachments = attachments or []
self.priority = priority
def send(email: Email) -> None: # 真实项目里这里调用 SMTP 或邮件服务商的 API
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)}")
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}")
print("发送 | " + " | ".join(parts))
if __name__ == "__main__":
# 调用点一:购课回执(真实项目里在 orders.py)
send(Email(["[email protected]"], "购课成功", None, None, "你已购买《Python 入门》", None, ["receipt.pdf"]))
# 调用点二:上课提醒(真实项目里在 reminders.py)
send(Email(["[email protected]"], "今晚 8 点开课", None, None, None, "<p>今晚 8 点开课</p>"))
# 调用点三:销售周报(真实项目里在 reports.py)
send(Email(["[email protected]"], "本周销售周报", ["[email protected]"], None, "数据见附件", None, ["weekly.xlsx"]))
# 输出:
# 发送 | 收件人:[email protected] | 主题:购课成功 | 正文:纯文本 | 附件:receipt.pdf
# 发送 | 收件人:[email protected] | 主题:今晚 8 点开课 | 正文:HTML
# 发送 | 收件人:[email protected] | 主题:本周销售周报 | 抄送:[email protected] | 正文:纯文本 | 附件:weekly.xlsx

TypeScript 里更常见的写法是不写构造函数,把 Email 定义成接口,调用处直接写一个对象字面量,可选的字段加 ?:

interface Email {
to: string[];
subject: string;
cc?: string[];
bcc?: string[];
textBody?: string;
htmlBody?: string;
attachments?: string[];
priority?: "normal" | "high";
}
function send(email: Email): void { // 真实项目里这里调用 SMTP 或邮件服务商的 API
const parts = [`收件人:${email.to.join(", ")}`, `主题:${email.subject}`];
if (email.cc && email.cc.length > 0) parts.push(`抄送:${email.cc.join(", ")}`);
if (email.bcc && email.bcc.length > 0) parts.push(`密送:${email.bcc.join(", ")}`);
parts.push(email.htmlBody ? "正文:HTML" : "正文:纯文本");
if (email.attachments && email.attachments.length > 0) parts.push(`附件:${email.attachments.join(", ")}`);
if (email.priority && email.priority !== "normal") parts.push(`优先级:${email.priority}`);
console.log("发送 | " + parts.join(" | "));
}
// 调用点一:购课回执(真实项目里在 orders.ts)
send({ to: ["[email protected]"], subject: "购课成功", textBody: "你已购买《Python 入门》", attachments: ["receipt.pdf"] });
// 调用点二:上课提醒(真实项目里在 reminders.ts)
send({ to: ["[email protected]"], subject: "今晚 8 点开课", htmlBody: "<p>今晚 8 点开课</p>" });
// 调用点三:销售周报(真实项目里在 reports.ts)
send({ to: ["[email protected]"], subject: "本周销售周报", cc: ["[email protected]"], textBody: "数据见附件", attachments: ["weekly.xlsx"] });
// 输出:
// 发送 | 收件人:[email protected] | 主题:购课成功 | 正文:纯文本 | 附件:receipt.pdf
// 发送 | 收件人:[email protected] | 主题:今晚 8 点开课 | 正文:HTML
// 发送 | 收件人:[email protected] | 主题:本周销售周报 | 抄送:[email protected] | 正文:纯文本 | 附件:weekly.xlsx

两种写法都很常见。Python 版的调用点也可以写成关键字参数,但这个项目里三处调用都是从最早写的那一处复制来的,就一直沿用了位置参数,没用到的位置填 None。TS 版每个值前面带着字段名,读起来更清楚。字段不多、也没有什么规则要检查时,它们都够用。

两件事一起来。运营要做定时发送和统一回复:邮件要能设定时发送时间 send_at 和回复地址 reply_to,比如周报的收件人点“回复”,回信要发到 [email protected],而不是发件用的通知邮箱。另外,上个月出了两次事故:一封上课提醒没有正文,用户收到一封空白邮件;一封周报的主题是空字符串、只带一个附件,老板以为是垃圾邮件直接删了。于是加两条规则:纯文本正文和 HTML 正文至少要有一个;有附件时主题不能为空。构造函数要求必须传主题,但传 "" 也能过。

要改的地方有四处:

  1. Email.__init__ 的签名:加 reply_to 和 send_at 两个参数。第一个问题是放在哪儿。reply_to 是地址,按意思应该挨着 cc、bcc;放到最后,则不影响旧的调用点。
  2. 三个调用点的位置参数。如果按意思插到 bcc 后面,三处的参数全部错开一位。假设这次提交漏改了其中两处:
# 新签名:Email(to, subject, cc, bcc, reply_to, text_body, html_body, attachments, priority, send_at)
# orders.py:漏改。"你已购买…"落进了 reply_to,["receipt.pdf"] 落进了 html_body
Email(["[email protected]"], "购课成功", None, None, "你已购买《Python 入门》", None, ["receipt.pdf"])
# reminders.py:漏改。"<p>…</p>" 落进了 text_body
Email(["[email protected]"], "今晚 8 点开课", None, None, None, "<p>今晚 8 点开课</p>")

orders.py 这一处,跑类型检查(mypy)会报 Argument 7 to "Email" has incompatible type "list[str]"; expected "str | None",还有机会发现;项目没配类型检查的话,它也照常运行:回执的正文成了 ['receipt.pdf'] 这串字符,附件没了,回复地址是一句“你已购买…”。reminders.py 这一处,类型检查也查不出来:错开之后每个值的类型恰好都对得上,HTML 被当成了纯文本正文,新规则“正文至少要有一个”也照样通过。程序正常运行,日志里只有一行:

发送 | 收件人:[email protected] | 主题:今晚 8 点开课 | 正文:纯文本

用户收到的提醒,正文就是原样的 <p>今晚 8 点开课</p> 这串字符。同样的事也会发生在 cc 和 bcc 之间:两个参数类型一样,写反了没有任何提示,本该密送的地址出现在所有人都看得见的抄送栏里。

如果把两个新参数放到最后,旧调用点不受影响,但要用新参数的地方,得把前面的位置全部填满。上课提醒要定时发送,就得写成:

Email(["[email protected]"], "今晚 8 点开课", None, None, None, "<p>今晚 8 点开课</p>",
None, "normal", None, datetime(2026, 10, 8, 19, 0))

第 9 个位置是 reply_to,第 10 个才是 send_at,写的人和审代码的人都得数着逗号对。

  1. 两条新规则写在哪。Python 版最自然的位置是 __init__:
def __init__(self, to, subject, cc=None, bcc=None, reply_to=None, text_body=None,
html_body=None, attachments=None, priority="normal", send_at=None):
if text_body is None and html_body is None:
raise ValueError("纯文本正文和 HTML 正文至少要有一个")
if attachments and not subject:
raise ValueError("有附件时必须有主题")
self.to = to
# ……后面还有九行赋值

构造函数前一半是校验、后一半是赋值,以后每加一条规则都往这里塞。更麻烦的是,校验只在构造那一刻跑一次,之后字段谁都能改:先造一封主题为空、还没有附件的邮件,校验通过;再 email.attachments.append("weekly.xlsx"),第二条规则就被绕过去了。

  1. TS 版:接口里加 replyTo? 和 sendAt? 两个可选字段,调用点一个都不用动,按名字传值也不会错位,这是 TS 写法的长处。它的问题全在校验上:对象字面量没有构造函数,规则只能放在两个地方。放在 send() 里,定时邮件要到发送那一刻才检查:一封下午 2 点排进队列、晚上 7 点发送的提醒,没有正文,异常要到 7 点才出现在后台发送任务的日志里,而下午排队的那个请求早就返回了“成功”。放在调用点,就要复制三份,第四个调用点忘了写也没有任何提示。对象字面量造好之后同样能改,和 Python 版一样能绕过规则。

问题的形状是:参数一多,位置要靠数,传错了只要类型对得上就不报错;校验没有一个“对象造出来之前”的固定位置,造出来之后字段又还能改。

朴素写法:四处要改的位置

图中 ①–④ 是上面列的四处,三个调用点都按位置连着同一个构造函数。

换个思路:不再一次性把十个值塞给构造函数,而是先拿一个专门负责攒参数的对象 EmailBuilder(邮件建造者)。每一项内容对应它的一个方法,调一个设一项;每个方法都返回建造者自己,所以可以接着往下点,这种写法叫链式调用(method chaining)。全部设完调用 build(),它检查所有规则,通过了才产出 Email。Email 本身做成不可变(immutable)的:Python 用 @dataclass(frozen=True),列表换成元组;TS 的字段全部 readonly,再用 Object.freeze 冻住。

from dataclasses import dataclass
from datetime import datetime
from typing import Literal, Self
Priority = Literal["normal", "high"]
@dataclass(frozen=True)
class Email: # 产品:造好之后字段不能再改
to: tuple[str, ...]
subject: str
cc: tuple[str, ...]
bcc: tuple[str, ...]
reply_to: str | None
text_body: str | None
html_body: str | None
attachments: tuple[str, ...]
priority: Priority
send_at: datetime | None
class EmailBuilder: # 建造者:一步步记下每一项,build() 时统一检查
def __init__(self) -> None:
self._to: list[str] = []
self._subject = ""
self._cc: list[str] = []
self._bcc: list[str] = []
self._reply_to: str | None = None
self._text: str | None = None
self._html: str | None = None
self._attachments: list[str] = []
self._priority: Priority = "normal"
self._send_at: datetime | None = None
def to(self, address: str) -> Self:
self._to.append(address)
return self
def subject(self, subject: str) -> Self:
self._subject = subject
return self
def cc(self, address: str) -> Self:
self._cc.append(address)
return self
def bcc(self, address: str) -> Self:
self._bcc.append(address)
return self
def reply_to(self, address: str) -> Self: # 新选项:加一个方法
self._reply_to = address
return self
def text(self, body: str) -> Self:
self._text = body
return self
def html(self, body: str) -> Self:
self._html = body
return self
def attach(self, filename: str) -> Self:
self._attachments.append(filename)
return self
def priority(self, level: Priority) -> Self:
self._priority = level
return self
def send_at(self, when: datetime) -> Self: # 新选项:加一个方法
self._send_at = when
return self
def build(self) -> Email: # 所有规则都在这里
if not self._to:
raise ValueError("至少要有一个收件人")
if self._text is None and self._html is None:
raise ValueError("纯文本正文和 HTML 正文至少要有一个")
if self._attachments and not self._subject:
raise ValueError("有附件时必须有主题")
return Email(to=tuple(self._to), subject=self._subject, cc=tuple(self._cc),
bcc=tuple(self._bcc), reply_to=self._reply_to, text_body=self._text,
html_body=self._html, attachments=tuple(self._attachments),
priority=self._priority, send_at=self._send_at)
def send(email: Email) -> None:
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(EmailBuilder().to("[email protected]").subject("购课成功")
.text("你已购买《Python 入门》").attach("receipt.pdf").build())
# 调用点二:上课提醒,定时在开课前一小时发出
send(EmailBuilder().to("[email protected]").subject("今晚 8 点开课")
.html("<p>今晚 8 点开课</p>").priority("high").send_at(datetime(2026, 10, 8, 19, 0)).build())
# 调用点三:销售周报,附件个数每周不同,在循环里一个个加
report = EmailBuilder().to("[email protected]").cc("[email protected]").subject("本周销售周报")
report.reply_to("[email protected]").text("数据见附件")
for filename in ["weekly.xlsx", "chart.png"]:
report.attach(filename)
send(report.build())
# 不合规则的邮件在 build() 这一步就被拦下
no_body = EmailBuilder().to("[email protected]").subject("没有正文")
no_subject = EmailBuilder().to("[email protected]").text("见附件").attach("invoice.pdf")
for builder in (no_body, no_subject):
try:
send(builder.build())
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 版的方法返回类型写 this,产品 Email 是一个字段全部 readonly 的接口,build() 返回冻结过的对象:

type Priority = "normal" | "high";
interface Email { // 产品:造好之后字段不能再改
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;
}
class EmailBuilder { // 建造者:一步步记下每一项,build() 时统一检查
private _to: string[] = [];
private _subject = "";
private _cc: string[] = [];
private _bcc: string[] = [];
private _replyTo: string | null = null;
private _text: string | null = null;
private _html: string | null = null;
private _attachments: string[] = [];
private _priority: Priority = "normal";
private _sendAt: Date | null = null;
to(address: string): this {
this._to.push(address);
return this;
}
subject(subject: string): this {
this._subject = subject;
return this;
}
cc(address: string): this {
this._cc.push(address);
return this;
}
bcc(address: string): this {
this._bcc.push(address);
return this;
}
replyTo(address: string): this { // 新选项:加一个方法
this._replyTo = address;
return this;
}
text(body: string): this {
this._text = body;
return this;
}
html(body: string): this {
this._html = body;
return this;
}
attach(filename: string): this {
this._attachments.push(filename);
return this;
}
priority(level: Priority): this {
this._priority = level;
return this;
}
sendAt(when: Date): this { // 新选项:加一个方法
this._sendAt = when;
return this;
}
build(): Email { // 所有规则都在这里
if (this._to.length === 0) throw new Error("至少要有一个收件人");
if (this._text === null && this._html === null) throw new Error("纯文本正文和 HTML 正文至少要有一个");
if (this._attachments.length > 0 && !this._subject) throw new Error("有附件时必须有主题");
return Object.freeze({
to: Object.freeze([...this._to]), subject: this._subject, cc: Object.freeze([...this._cc]),
bcc: Object.freeze([...this._bcc]), replyTo: this._replyTo, textBody: this._text,
htmlBody: this._html, attachments: Object.freeze([...this._attachments]),
priority: this._priority, sendAt: this._sendAt,
});
}
}
const pad = (n: number): string => String(n).padStart(2, "0");
function send(email: Email): void {
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(new EmailBuilder().to("[email protected]").subject("购课成功")
.text("你已购买《Python 入门》").attach("receipt.pdf").build());
// 调用点二:上课提醒,定时在开课前一小时发出
send(new EmailBuilder().to("[email protected]").subject("今晚 8 点开课")
.html("<p>今晚 8 点开课</p>").priority("high").sendAt(new Date(2026, 9, 8, 19, 0)).build());
// 调用点三:销售周报,附件个数每周不同,在循环里一个个加
const report = new EmailBuilder().to("[email protected]").cc("[email protected]").subject("本周销售周报");
report.replyTo("[email protected]").text("数据见附件");
for (const filename of ["weekly.xlsx", "chart.png"]) {
report.attach(filename);
}
send(report.build());
// 不合规则的邮件在 build() 这一步就被拦下
const noBody = new EmailBuilder().to("[email protected]").subject("没有正文");
const noSubject = new EmailBuilder().to("[email protected]").text("见附件").attach("invoice.pdf");
for (const builder of [noBody, noSubject]) {
try {
send(builder.build());
} 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 正文至少要有一个
// 拒绝 | 有附件时必须有主题

建造者版:EmailBuilder 的方法链

看标红的 reply_to() 和 send_at():新选项各加一个方法,链一路点到 build()。

逐条对照 5.3 的四处改动:

  • 第 1 处,签名:reply_to 和 send_at 各是建造者上的一个方法,再加上建造者 __init__ 里的一行初值、Email 里的一个字段、build() 里传值的一项,都在同一个文件里,上下挨着。不存在“插在哪个位置”的问题,因为调用处根本不按位置传。
  • 第 2 处,调用点:回执那一处一个字没改。用到新选项的提醒和周报,各多写一个 .send_at(...)、.reply_to(...)。HTML 正文只能通过 .html(...) 设,调用处写着的就是要设哪一项,不可能“落进”纯文本正文。.cc() 误写成 .bcc() 照样能跑,但这次错误写在明处,代码评审时看得见,不用数逗号。
  • 第 3、4 处,校验:三条规则都在 build() 里,Python 和 TS 是同一份规则,加第四条规则只改这一个方法。各个调用点都通过 build() 拿 Email,规则不用在调用点各写一份。定时邮件在排进队列之前就要 build(),没有正文当场抛错,抛给的是发起请求的那段代码,不会拖到晚上 7 点。
  • 造好再改:Email 是冻结的。Python 里给字段赋值会抛 FrozenInstanceError,附件是元组,没有 append;TS 里给 readonly 字段赋值编译不过,Object.freeze 让运行时也改不动。过了 build() 的邮件,到发送时还是那封邮件。

周报那一段还多了一个好处:附件在循环里一个个加,攒齐了再 build()。用构造函数的写法,得先在外面把列表攒好,再一次传进去。

也要说清楚建造者没解决什么。它不会让你少写值:调用处该设的每一项还得设,代码比位置参数还长。每个字段在 Email、建造者的私有字段、建造者的方法、build() 里各写一遍,这个文件比 before 长了一倍多。它也拦不住有人绕过建造者,直接调用 Email(...) 造一个不合规的对象,这里只能靠约定。另外,“调用处写出字段名”这一条,Python 的关键字参数本来就能做到,5.7 会再说。

GoF 对建造者的定义是:将一个复杂对象的构建与它的表示分离,使同样的构建过程可以创建不同的表示。

换成本章的话说:“构建”是一步步设值、最后检查的过程,交给 EmailBuilder;“表示”是最终得到的东西,就是 Email。两者分开后,Email 只管装数据,造好就不变;怎么一步步凑齐、凑齐了合不合规,全在建造者里。

GoF 原书的结构比本章多两个角色:一个抽象的建造者接口,和一个导演(Director)。导演知道“按什么顺序调哪些步骤”,但不知道造出来的是什么。比如写一个 compose_receipt(builder, user, course),按固定顺序调用 to、subject、text、attach:传进去一个 EmailBuilder,得到一封邮件;传进去一个实现了同样方法的 PreviewBuilder,得到后台用的预览网页。定义里“同样的构建过程创建不同的表示”,说的就是这种用法。现在代码里见到的建造者,大多只有一个具体建造者、没有导演,调用方自己决定调哪些步骤,本书以这种链式写法为主。

建造者的结构:Director 按顺序调 Builder 的各步,ConcreteBuilder 组装出 Product

图里是 GoF 的角色,下表对应到本章的类。

角色 在本章案例里是谁 职责
建造者(Builder) 本章没有单独的接口,只有一种产品时用不上 声明每个构建步骤
具体建造者(ConcreteBuilder) EmailBuilder 记下每一步设的值,build() 时检查规则并产出产品
导演(Director) 本章没有,由各个调用点自己决定调哪些步骤 按固定顺序调用构建步骤
产品(Product) Email 构建的结果,造好之后不再改变

常见场景:

  • 参数多、大部分可选的对象:邮件、HTTP 请求(5.9 的练习)、带一长串选项的客户端配置。
  • 产品造出来之前要集中检查几条跨字段的规则,比如“有附件就必须有主题”,并且希望检查过的对象之后不会再变。
  • 要分好几步、在条件分支或循环里攒出来的对象:SQL 查询、命令行参数的定义,5.6 的两个例子都是这样。
  • 测试数据:UserBuilder().verified().with_courses(3).build(),每个测试只写和自己有关的那几项,其余全用建造者里的默认值。

例一:argparse 的 ArgumentParser(Python)

Section titled “例一:argparse 的 ArgumentParser(Python)”

Python 标准库的 argparse 用来解析命令行参数。先造一个 ArgumentParser,然后每个参数调用一次 add_argument()。每次调用会新建一个 Action 对象(普通取值的是 _StoreAction,开关用的是 _StoreTrueAction),追加到解析器的 _actions 列表里,这时候什么都还没解析。最后调用 parse_args(),它才拿着攒下来的全部 Action 去读命令行、检查必填项,产出一个 Namespace 对象,每个参数是它的一个属性。必填参数缺了,真实版会把用法说明和错误信息打到标准错误,以状态码 2 退出。简化版:

from typing import Callable
class Namespace: # 真实版也只是一个把参数值存成属性的简单对象
def __init__(self, **kwargs: object) -> None:
self.__dict__.update(kwargs)
def __repr__(self) -> str:
return "Namespace(" + ", ".join(f"{k}={v!r}" for k, v in self.__dict__.items()) + ")"
class Action: # 真实版按 action= 分成 _StoreAction、_StoreTrueAction 等子类
def __init__(self, flag: str, type_: Callable[[str], object], default: object,
required: bool, store_true: bool) -> None:
self.flag, self.type, self.required, self.store_true = flag, type_, required, store_true
self.dest = flag.lstrip("-").replace("-", "_") # --dry-run 存成 dry_run
self.default = False if store_true else default
class ArgumentParser:
def __init__(self, prog: str) -> None:
self.prog = prog
self._actions: list[Action] = [] # 真实版还会先放进一个 -h/--help
def add_argument(self, flag: str, type: Callable[[str], object] = str, default: object = None,
required: bool = False, action: str = "store") -> Action:
act = Action(flag, type, default, required, action == "store_true")
self._actions.append(act) # 只记下来,不解析
return act
def parse_args(self, args: list[str]) -> Namespace: # 真实版不传 args 时读 sys.argv[1:]
values = {a.dest: a.default for a in self._actions}
by_flag = {a.flag: a for a in self._actions}
seen: set[str] = set() # 真实版也是记下出现过的参数,不看值是不是 None
i = 0
while i < len(args):
act = by_flag[args[i]] # 真实版遇到不认识的参数会报错退出
seen.add(act.dest)
if act.store_true:
values[act.dest], i = True, i + 1
else:
values[act.dest], i = act.type(args[i + 1]), i + 2
missing = [a.flag for a in self._actions if a.required and a.dest not in seen]
if missing: # 真实版先打印用法说明,再以状态码 2 退出
raise SystemExit(f"{self.prog}: error: the following arguments are required: {', '.join(missing)}")
return Namespace(**values)
parser = ArgumentParser(prog="send_mail")
parser.add_argument("--to", required=True)
parser.add_argument("--subject", default="")
parser.add_argument("--retries", type=int, default=3)
parser.add_argument("--dry-run", action="store_true")
print(parser.parse_args(["--to", "[email protected]", "--retries", "5", "--dry-run"]))
try:
parser.parse_args(["--subject", "测试"])
except SystemExit as error:
print(error)
# 输出:
# Namespace(to='[email protected]', subject='', retries=5, dry_run=True)
# send_mail: error: the following arguments are required: --to

把最后几行里的 ArgumentParser 换成 import argparse 的 argparse.ArgumentParser,打印出的 Namespace 一模一样,错误信息那一行也一样(真实版是打到标准错误)。ArgumentParser 对应本章的 EmailBuilder,每次 add_argument() 是一个构建步骤,parse_args() 对应 build():必填检查集中在这里,产出的 Namespace 对应 Email。和本章不同的是,它不是链式调用,add_argument() 返回新建的 Action,不是解析器自己;同一个解析器可以 parse_args() 很多次,每次读一组不同的命令行;产出的 Namespace 也是普通的可变对象。

例二:Knex 查询构造器(TypeScript)

Section titled “例二:Knex 查询构造器(TypeScript)”

Knex 是 Node.js 里常用的 SQL 查询构造器(query builder)。knex("users") 返回一个构造器,select、where、orderBy、limit 每个方法往里加一部分条件,并返回构造器本身,所以能一路链下去;也可以中途停下,在 if 里再补一个 where。调用 toSQL() 时,它才把攒下来的各部分按数据库方言拼成 SQL,返回一个对象:sql 是带 ? 占位符的语句,bindings 是按顺序对应占位符的值。值不直接拼进 SQL 字符串,而是交给数据库驱动去绑定,这样能防住 SQL 注入(SQL injection)。toSQL() 也会检查:某个条件的值是 undefined 时,它抛出 “Undefined binding(s) detected” 错误,不会生成一句条件落空的 SQL。真实的构造器还可以直接 await,那会去执行查询;简化版只模拟 toSQL(),按 PostgreSQL 方言给表名和列名加双引号:

interface SqlAndBindings { sql: string; bindings: unknown[] } // 真实返回值还有 method、options 等字段
class QueryBuilder {
private columns: string[] = [];
private wheres: { column: string; op: string; value: unknown }[] = [];
private orders: string[] = [];
private limitValue: number | null = null;
constructor(private readonly table: string) {}
select(...columns: string[]): this { this.columns.push(...columns); return this; }
where(column: string, ...rest: [unknown] | [string, unknown]): this { // 两个参数时运算符是 =
const [op, value]: [string, unknown] = rest.length === 1 ? ["=", rest[0]] : rest;
this.wheres.push({ column, op, value });
return this;
}
orderBy(column: string, direction: "asc" | "desc" = "asc"): this {
this.orders.push(`"${column}" ${direction}`); return this;
}
limit(n: number): this { this.limitValue = n; return this; }
toSQL(): SqlAndBindings { // 到这一步才检查、才拼 SQL
const undefinedCols = this.wheres.filter((w) => w.value === undefined).map((w) => w.column);
if (undefinedCols.length > 0) {
throw new Error(`Undefined binding(s) detected when compiling SELECT. Undefined column(s): [${undefinedCols.join(", ")}]`);
}
const q = (id: string): string => `"${id}"`; // PostgreSQL 方言用双引号,MySQL 方言用反引号
let sql = `select ${this.columns.length > 0 ? this.columns.map(q).join(", ") : "*"} from ${q(this.table)}`;
const bindings: unknown[] = this.wheres.map((w) => w.value);
if (this.wheres.length > 0) sql += " where " + this.wheres.map((w) => `${q(w.column)} ${w.op} ?`).join(" and ");
if (this.orders.length > 0) sql += ` order by ${this.orders.join(", ")}`;
if (this.limitValue !== null) { sql += " limit ?"; bindings.push(this.limitValue); }
return { sql, bindings };
}
}
const knex = (table: string): QueryBuilder => new QueryBuilder(table); // 真实的 knex 要先传连接配置初始化
const onlyAdults = true;
const query = knex("users").select("id", "email").where("status", "active");
if (onlyAdults) query.where("age", ">=", 18); // 中途在分支里接着加条件
const result = query.orderBy("created_at", "desc").limit(10).toSQL();
console.log(result.sql);
console.log(JSON.stringify(result.bindings));
try {
knex("users").where("id", undefined).toSQL();
} catch (error) {
console.log((error as Error).message);
}
// 输出:
// select "id", "email" from "users" where "status" = ? and "age" >= ? order by "created_at" desc limit ?
// ["active",18,10]
// Undefined binding(s) detected when compiling SELECT. Undefined column(s): [id]

前两行输出和真实 Knex 在 client: "pg" 配置下生成的一样;真实的错误信息后面还附带了出错的那句 SQL。QueryBuilder 对应 EmailBuilder,where、orderBy 这些方法是构建步骤,toSQL() 对应 build(),检查和拼装都在这一步,产出的 { sql, bindings } 对应 Email。和本章不同的是,Knex 的构造器在 toSQL() 之后还能接着加条件、再生成一次,构造器本身就是一个可以反复使用的半成品。

  • 参数只有三四个。建造者让每个字段写四遍(产品字段、建造者私有字段、建造者方法、build() 里的一项),本章的 after 比 before 长了一倍多。三四个参数时,调用处 Email(to, subject, body) 一眼就能看懂,加一个建造者只是让读代码的人多翻一个类。
  • 语言已经替你做了,又不需要集中校验。Python 有关键字参数和默认值,把可选参数放到 * 后面,调用方就只能按名字传:Email(to=[...], subject="购课成功", html_body="..."),5.3 的错位问题直接消失;配合 @dataclass(frozen=True),产品也不可变了,必要时还能在 __post_init__ 里写几行检查。TS 的对象字面量加上接口类型,同样是按名字传、拼错字段名编译就报错。这时候再写一个建造者,多出来的只有一个类。它真正多做的,是 5.4 周报那样分好几步、在循环和分支里攒对象,以及把规则集中在一个 build() 里。这两样都用不上时,就用语言自带的写法。哪些模式被语言特性替掉了、替掉了多少,第 29 章会集中讨论。
  • 产品本来就该是可变的。比如用户在编辑器里写了三天的邮件草稿,每次保存都要改主题、加附件。硬套“建造者加不可变产品”,每改一个字都得重新 build() 一封新邮件。这种对象直接做成普通的可变类,在真正发送之前调一次 validate() 就行。
模式 意图 区别
抽象工厂(Abstract Factory) 成套创建一组相互配合的对象 调一次创建方法就立刻拿到一个完整的产品,关心的是几个产品配不配套;建造者是一步步造一个复杂的产品,最后才拿结果
工厂方法(Factory Method) 父类定流程,子类决定创建哪一种产品 解决的是“造哪一个类”,参数通常不多,一次调用就完成;建造者的类是定的,解决的是“这一个对象的十项内容怎么一步步填齐、填得对不对”

一句话区分:工厂方法和抽象工厂决定“造哪一种”,建造者决定“怎么一步步把这一个造出来”。两者也能一起用:抽象工厂的某个创建方法,内部可以用建造者去组装它返回的那个复杂产品。

后台要调用各种第三方 HTTP 接口。一个请求有方法(本题只要求 GET)、URL、若干请求头、若干查询参数、超时时间(秒,默认 10)。请用建造者实现:

  • RequestBuilder().get(url).header(k, v).query(k, v).timeout(s).build(),header 和 query 可以调多次,按调用顺序保留。
  • build() 检查两条规则:URL 必填;timeout 必须为正数。不合规时抛出异常,信息里说清是哪一条。
  • 产品命名为 HttpRequest(TS 里全局已经有一个 Request 类型,避免重名),造好之后不可变。
  • 写一个 describe(request),打印一行摘要,例如 GET https://api.example.com/courses?page=2&size=20 | 请求头:Authorization=Bearer abc | 超时:5 秒。再分别演示“没有 URL”和“timeout 为 0”两种被拒绝的情况。Python 和 TS 各写一版。

提示:查询参数先存成键值对列表,到 describe 时再拼到 URL 后面,Python 可以用 urllib.parse.urlencode,TS 可以用 URLSearchParams。做完想一想:如果要加“POST 请求必须有请求体”这条规则,要改几个地方?

  • 建造者用一个专门的对象一步步收集参数,每一项一个方法,最后由 build() 检查规则并产出产品。
  • 它换来三样东西:调用处写着每一项设的是什么,不会因为位置错开而传错;所有跨字段的规则集中在 build() 一处;产品可以做成不可变的,过了检查就不会再被改坏。
  • 加一个可选项是建造者上加一个方法(外加产品一个字段),不需要它的调用点不动。
  • 它不会让你少写值,代码量还会明显变多。参数少、或者语言的关键字参数和对象字面量已经够用、又不需要集中校验时,别用它。
  • GoF 原版还有导演和抽象建造者,用同一套步骤造出不同的表示;日常见得多的是没有导演的链式建造者。