引入特例
引入特例(Introduce Special Case)把“这个值是某种特殊情况”的判断,换成一个专门表示这种情况的对象。这个对象和普通对象有一样的接口,调用方不用再检查,直接用就行。
它解决的痛是:同一个检查(if customer == "unknown"、if user is None)散落在几十个调用方,每处都写一遍相同的应对,漏写一处就是线上的空指针错误。
小孙在一家燃气公司维护收费系统。每个用气地址(Site)对应一个客户。有些房子刚卖掉、新业主还没去营业厅登记,系统里这个地址的客户就记成字符串 "unknown"。可是账单、催缴单、营业厅屏幕这些地方都要显示客户信息,于是每个用到客户的函数开头都有一句“如果是未知客户就……”:名字显示“住户”,套餐按基础套餐,欠费周数当作 0,不寄催缴单,营业厅屏幕上提示去登记。
"""燃气收费:重构前。"""from dataclasses import dataclass
BASIC_PLAN = "基础套餐"
@dataclassclass PaymentHistory: weeks_delinquent_in_last_year: int
@dataclassclass Customer: name: str billing_plan: str payment_history: PaymentHistory
class Site: def __init__(self, customer: Customer | str) -> None: self._customer = customer
@property def customer(self) -> Customer | str: return self._customer
def greeting(site: Site) -> str: customer = site.customer if customer == "unknown": return "新住户请到营业厅登记" return f"{customer.name},您好"
def customer_name(site: Site) -> str: customer = site.customer if customer == "unknown": return "住户" return customer.name
def billing_plan(site: Site) -> str: customer = site.customer return BASIC_PLAN if customer == "unknown" else customer.billing_plan
def weeks_delinquent(site: Site) -> int: customer = site.customer if customer == "unknown": return 0 return customer.payment_history.weeks_delinquent_in_last_year
def should_send_reminder(site: Site) -> bool: customer = site.customer if customer == "unknown": return False return customer.payment_history.weeks_delinquent_in_last_year > 2
SITES = [Site(Customer("张三", "居民阶梯", PaymentHistory(3))), Site("unknown")]
if __name__ == "__main__": for site in SITES: reminder = "寄催缴单" if should_send_reminder(site) else "不寄催缴单" print(greeting(site)) print(f"{customer_name(site)}|{billing_plan(site)}|欠费 {weeks_delinquent(site)} 周|{reminder}")
# 输出:# 张三,您好# 张三|居民阶梯|欠费 3 周|寄催缴单# 新住户请到营业厅登记# 住户|基础套餐|欠费 0 周|不寄催缴单// 燃气收费:重构前。export const BASIC_PLAN = "基础套餐";
export interface PaymentHistory { weeksDelinquentInLastYear: number;}
export class Customer { constructor( readonly name: string, readonly billingPlan: string, readonly paymentHistory: PaymentHistory, ) {}}
export class Site { constructor(private readonly _customer: Customer | "unknown") {}
get customer(): Customer | "unknown" { return this._customer; }}
export function greeting(site: Site): string { const customer = site.customer; if (customer === "unknown") return "新住户请到营业厅登记"; return `${customer.name},您好`;}
export function customerName(site: Site): string { const customer = site.customer; if (customer === "unknown") return "住户"; return customer.name;}
export function billingPlan(site: Site): string { const customer = site.customer; return customer === "unknown" ? BASIC_PLAN : customer.billingPlan;}
export function weeksDelinquent(site: Site): number { const customer = site.customer; if (customer === "unknown") return 0; return customer.paymentHistory.weeksDelinquentInLastYear;}
export function shouldSendReminder(site: Site): boolean { const customer = site.customer; if (customer === "unknown") return false; return customer.paymentHistory.weeksDelinquentInLastYear > 2;}
const SITES = [new Site(new Customer("张三", "居民阶梯", { weeksDelinquentInLastYear: 3 })), new Site("unknown")];
for (const site of SITES) { const reminder = shouldSendReminder(site) ? "寄催缴单" : "不寄催缴单"; console.log(greeting(site)); console.log(`${customerName(site)}|${billingPlan(site)}|欠费 ${weeksDelinquent(site)} 周|${reminder}`);}
// 输出:// 张三,您好// 张三|居民阶梯|欠费 3 周|寄催缴单// 新住户请到营业厅登记// 住户|基础套餐|欠费 0 周|不寄催缴单测试对已知客户和未知客户各检查一遍五个调用方:
import unittest
from target import Customer, PaymentHistory, Site, billing_plan, customer_name, greeting, should_send_reminder, weeks_delinquent
KNOWN = Site(Customer("张三", "居民阶梯", PaymentHistory(3)))UNKNOWN = Site("unknown")
class SiteTest(unittest.TestCase): def test_known_customer(self): self.assertEqual(greeting(KNOWN), "张三,您好") self.assertEqual((customer_name(KNOWN), billing_plan(KNOWN), weeks_delinquent(KNOWN)), ("张三", "居民阶梯", 3)) self.assertTrue(should_send_reminder(KNOWN))
def test_unknown_customer(self): self.assertEqual(greeting(UNKNOWN), "新住户请到营业厅登记") self.assertEqual((customer_name(UNKNOWN), billing_plan(UNKNOWN), weeks_delinquent(UNKNOWN)), ("住户", "基础套餐", 0)) self.assertFalse(should_send_reminder(UNKNOWN))
def test_known_customer_without_debt_gets_no_reminder(self): site = Site(Customer("李四", "居民阶梯", PaymentHistory(1))) self.assertFalse(should_send_reminder(site))
if __name__ == "__main__": unittest.main()import test from "node:test";import assert from "node:assert/strict";import { Customer, Site, billingPlan, customerName, greeting, shouldSendReminder, weeksDelinquent } from "./target";
const KNOWN = new Site(new Customer("张三", "居民阶梯", { weeksDelinquentInLastYear: 3 }));const UNKNOWN = new Site("unknown");
test("已知客户", () => { assert.equal(greeting(KNOWN), "张三,您好"); assert.deepEqual([customerName(KNOWN), billingPlan(KNOWN), weeksDelinquent(KNOWN)], ["张三", "居民阶梯", 3]); assert.equal(shouldSendReminder(KNOWN), true);});
test("未知客户", () => { assert.equal(greeting(UNKNOWN), "新住户请到营业厅登记"); assert.deepEqual([customerName(UNKNOWN), billingPlan(UNKNOWN), weeksDelinquent(UNKNOWN)], ["住户", "基础套餐", 0]); assert.equal(shouldSendReminder(UNKNOWN), false);});
test("没欠费的已知客户不寄催缴单", () => { const site = new Site(new Customer("李四", "居民阶梯", { weeksDelinquentInLastYear: 1 })); assert.equal(shouldSendReminder(site), false);});如果现在要加一个需求:账单上要印客户的联系电话,未知客户印营业厅的服务热线。
小孙要写第六个函数,开头照例是 if customer == "unknown"。这还算好的,他至少知道要写。真正的风险是下一个接手的人不知道:他写 site.customer.phone,在测试数据里一切正常,因为测试数据里的客户都是登记过的;上线后第一个遇到未登记地址的账单任务就抛出 AttributeError: 'str' object has no attribute 'phone',整批账单生成失败。Python 不会在写代码时提醒他,TS 的联合类型会,但 TS 只能逼他写检查,不能替他写对检查里的内容。
更隐蔽的问题是“未知客户该怎么对待”的规则被抄了五遍。“未知客户的名字显示住户”这条规则,如果运营决定改成“尊敬的用户”,要在所有显示名字的地方挨个改。而这些地方不只在本文件里:营业厅的屏幕、短信模板、客服后台,每个团队各自写了一份 if customer == "unknown": name = "住户"。改了三处漏了两处,同一个地址在短信里叫“尊敬的用户”、在账单上叫“住户”。
第三个问题是那个魔法字符串。"unknown" 写错成 "Unknown" 或 "unknow",Python 里的 == 比较只会默默返回 False,然后去读一个字符串的 .name。
用第 2 章的名字说,这是重复代码:同一个特殊情况的判断和处理,散落在每一个使用客户的地方。
新建一个 UnknownCustomer 类,它是 Customer 的子类,名字、套餐、付款记录都给出未知客户该有的值。Site.customer 遇到 "unknown" 时返回一个 UnknownCustomer,调用方不再检查。
"""燃气收费:重构后。"""from dataclasses import dataclass
BASIC_PLAN = "基础套餐"
@dataclassclass PaymentHistory: weeks_delinquent_in_last_year: int
@dataclassclass Customer: name: str billing_plan: str payment_history: PaymentHistory
@property def is_unknown(self) -> bool: return False
class UnknownCustomer(Customer): def __init__(self) -> None: super().__init__("住户", BASIC_PLAN, PaymentHistory(0))
@property def is_unknown(self) -> bool: return True
class Site: def __init__(self, customer: Customer | str) -> None: self._customer = customer
@property def customer(self) -> Customer: if self._customer == "unknown": return UnknownCustomer() return self._customer
def greeting(site: Site) -> str: if site.customer.is_unknown: return "新住户请到营业厅登记" return f"{site.customer.name},您好"
def customer_name(site: Site) -> str: return site.customer.name
def billing_plan(site: Site) -> str: return site.customer.billing_plan
def weeks_delinquent(site: Site) -> int: return site.customer.payment_history.weeks_delinquent_in_last_year
def should_send_reminder(site: Site) -> bool: return site.customer.payment_history.weeks_delinquent_in_last_year > 2
SITES = [Site(Customer("张三", "居民阶梯", PaymentHistory(3))), Site("unknown")]
if __name__ == "__main__": for site in SITES: reminder = "寄催缴单" if should_send_reminder(site) else "不寄催缴单" print(greeting(site)) print(f"{customer_name(site)}|{billing_plan(site)}|欠费 {weeks_delinquent(site)} 周|{reminder}")
# 输出:# 张三,您好# 张三|居民阶梯|欠费 3 周|寄催缴单# 新住户请到营业厅登记# 住户|基础套餐|欠费 0 周|不寄催缴单// 燃气收费:重构后。export const BASIC_PLAN = "基础套餐";
export interface PaymentHistory { weeksDelinquentInLastYear: number;}
export class Customer { constructor( readonly name: string, readonly billingPlan: string, readonly paymentHistory: PaymentHistory, ) {}
get isUnknown(): boolean { return false; }}
export class UnknownCustomer extends Customer { constructor() { super("住户", BASIC_PLAN, { weeksDelinquentInLastYear: 0 }); }
get isUnknown(): boolean { return true; }}
export class Site { constructor(private readonly _customer: Customer | "unknown") {}
get customer(): Customer { return this._customer === "unknown" ? new UnknownCustomer() : this._customer; }}
export function greeting(site: Site): string { if (site.customer.isUnknown) return "新住户请到营业厅登记"; return `${site.customer.name},您好`;}
export function customerName(site: Site): string { return site.customer.name;}
export function billingPlan(site: Site): string { return site.customer.billingPlan;}
export function weeksDelinquent(site: Site): number { return site.customer.paymentHistory.weeksDelinquentInLastYear;}
export function shouldSendReminder(site: Site): boolean { return site.customer.paymentHistory.weeksDelinquentInLastYear > 2;}
const SITES = [new Site(new Customer("张三", "居民阶梯", { weeksDelinquentInLastYear: 3 })), new Site("unknown")];
for (const site of SITES) { const reminder = shouldSendReminder(site) ? "寄催缴单" : "不寄催缴单"; console.log(greeting(site)); console.log(`${customerName(site)}|${billingPlan(site)}|欠费 ${weeksDelinquent(site)} 周|${reminder}`);}
// 输出:// 张三,您好// 张三|居民阶梯|欠费 3 周|寄催缴单// 新住户请到营业厅登记// 住户|基础套餐|欠费 0 周|不寄催缴单输出不变,三个测试照样通过。
印联系电话的需求,现在是给 Customer 加 phone 字段,在 UnknownCustomer 的构造里填上服务热线。调用方直接写 site.customer.phone,不需要知道有未知客户这回事。新人不知道未知客户的存在也不会出错,因为他拿到的永远是一个 Customer。
“未知客户该怎么对待”的规则现在只有一处,就是 UnknownCustomer 这个类。运营要把“住户”改成“尊敬的用户”,改一行;营业厅屏幕、短信模板只要都从 Site.customer 取客户,就一起变了。
魔法字符串 "unknown" 只剩 Site 里的一处比较。写错的风险从五处降到一处,而且这一处有测试守着。
有一个调用方还保留了判断:greeting。营业厅屏幕对未知客户说的话和对已知客户完全不同,不是换一个值就能表达的,这时用 is_unknown 明确地问一句,比让特例对象假装有个名字更诚实。注意它问的是对象,而不是拿字符串比较,以后未知客户的表示方式变了,这里也不用改。should_send_reminder 则连判断都不需要了:特例对象的付款记录是欠费 0 周,0 > 2 自然是 False。
怎么一步步做
Section titled “怎么一步步做”每一步之后跑测试。
- 在
Customer上加一个is_unknown属性,返回 False。 - 新建
UnknownCustomer子类,is_unknown返回 True。 - 把所有
customer == "unknown"的检查提炼成一个函数is_unknown(customer),它同时认得字符串和新属性:return customer == "unknown" or customer.is_unknown。把五个调用方的比较都换成对这个函数的调用。这一步把散落的检查收到一处(第 4 章提炼函数,IDE 可自动完成)。 - 改
Site.customer:遇到"unknown"时返回UnknownCustomer()。此时调用方的is_unknown(customer)走的是新属性那条路,字符串那条路不再被走到。 - 改
is_unknown函数,只看属性:return customer.is_unknown。 - 挑一个调用方,看它在未知时返回的值能不能由特例对象直接给出。
customer_name返回“住户”,那就让UnknownCustomer的name是“住户”,然后把调用方里的检查删掉,直接返回site.customer.name。逐个调用方地做,每改一个跑一次测试。 - 剩下的调用方如果确实需要区别对待(如
greeting),保留检查,直接写site.customer.is_unknown;所有调用方都改完后,第 3 步的is_unknown函数如果没人用了,删掉它。
特例对象要设计成不可变的。如果系统里有“修改客户套餐”的功能,有人对一个未知客户调用 customer.billing_plan = "工商业",这个修改该怎么办?对特例对象来说,正确的行为通常是忽略,因为“未知客户”没有可以保存的地方。Fowler 的原版例子就让未知客户的设值函数什么也不做。本章 Python 版每次返回一个新的 UnknownCustomer(),改了也不会影响别处;如果为了省对象把它做成全局共享的单例,就必须保证它不能被改,否则一个调用方改了它的套餐,所有未知客户都会跟着变。
TS 里也可以不建子类,用一个冻结的字面量对象充当特例,前提是 Customer 是接口而不是类:
const UNKNOWN_CUSTOMER: Customer = Object.freeze({ name: "住户", billingPlan: BASIC_PLAN, paymentHistory: Object.freeze({ weeksDelinquentInLastYear: 0 }), isUnknown: true,});字面量对象适合特例只提供数据、没有行为的情况;特例要有自己的方法(比如未知客户的 phone 要根据地区查热线),就用类。
定义与适用场景
Section titled “定义与适用场景”引入特例:当一个数据结构的使用者都在检查某个特殊值,并且遇到时做相同的处理,就创建一个特例对象,让它表现出这些处理,用它替换掉特殊值。
最常见的特殊值是“没有”:None、null、空字符串、魔法字符串 "unknown"、ID 为 0 的占位记录。这时的特例对象叫空对象(Null Object),本章的 UnknownCustomer 就是一个。但特例不只是“没有”,“已注销的账号”“系统管理员”“试用期用户”也可以是特例,只要很多地方都在对它做同样的判断。
判断要不要引入特例,数一下调用方:同一个检查出现在三处以上,并且多数调用方遇到特殊值时做的是同样的事(返回一个默认值),就值得做。如果各个调用方对特殊值的处理都不一样,特例对象给不出统一的默认值,就没有意义。
相关的设计模式
Section titled “相关的设计模式”引入特例的结果就是空对象(Null Object)模式:Customer 定义了共同接口,普通的 Customer 实例是真实对象,UnknownCustomer 是空对象,它实现同样的接口,但每个方法都给出“什么也不做”或“默认值”的安全行为;Site.customer 是负责在两者之间选择的地方,调用方只和这个共同接口打交道。空对象不在 GoF 的 23 个模式里,它由 Bobby Woolf 在《Pattern Languages of Program Design 3》里整理成文,在实践中和 GoF 模式一样常用,《设计模式》书第 30 章“走出 GoF”会讲它。空对象常和策略模式一起出现:一个“什么也不做”的策略,比如下面的空日志器,就是空对象。
真实系统里的例子
Section titled “真实系统里的例子”Django 的匿名用户
Section titled “Django 的匿名用户”Django 的 request.user 永远是一个对象:登录了就是 User,没登录是 AnonymousUser。AnonymousUser 的 is_authenticated 是 False,username 是空字符串,has_perm() 永远返回 False。所以视图和模板可以直接写 request.user.has_perm("blog.edit"),不用先判断是不是 None。下面模拟这个设计,v1 是没登录时 request.user 为 None 的写法,v2 是引入 AnonymousUser 之后的写法。
"""模拟 Django 的 request.user 和 AnonymousUser。"""from dataclasses import dataclass, field
@dataclassclass User: username: str perms: set[str] = field(default_factory=set) is_authenticated: bool = True
def has_perm(self, perm: str) -> bool: return perm in self.perms
class AnonymousUser(User): """特例:没登录的用户。"""
def __init__(self) -> None: super().__init__(username="", is_authenticated=False)
def has_perm(self, perm: str) -> bool: return False
@dataclassclass Request: user: User | None
def toolbar_v1(request: Request) -> str: """改造前:没登录时 user 是 None,每处都要先判断。""" name = request.user.username if request.user is not None else "游客" can_edit = request.user is not None and request.user.has_perm("blog.edit") return f"{name}|{'编辑' if can_edit else '只读'}"
def toolbar_v2(request: Request) -> str: """改造后:user 永远是对象,没登录就是 AnonymousUser。""" user = request.user if request.user is not None else AnonymousUser() name = user.username if user.is_authenticated else "游客" return f"{name}|{'编辑' if user.has_perm('blog.edit') else '只读'}"
if __name__ == "__main__": requests = [Request(User("alice", {"blog.edit"})), Request(User("bob")), Request(None)] print(all(toolbar_v1(r) == toolbar_v2(r) for r in requests)) for r in requests: print(toolbar_v2(r))
# 输出:# True# alice|编辑# bob|只读# 游客|只读真实的 Django 里,把 None 换成 AnonymousUser() 的那一行写在认证中间件里,视图拿到的 request.user 已经是对象了,toolbar_v2 第一行那样的转换不会出现在业务代码中。这正是本章 Site.customer 的位置:特例在数据进入系统的入口处一次造好,下游不再关心。
TS 里的空日志器
Section titled “TS 里的空日志器”库和服务常允许调用方传入一个日志器,不传就不记日志。不用特例的写法是到处 if (this.logger) this.logger.info(...),或者用可选链 this.logger?.info(...)。用特例的写法是不传时给一个什么也不做的 NullLogger。下面用一个记录到数组的日志器模拟。
// 模拟一个可选日志器的服务。interface Logger { info(message: string): void;}
class ArrayLogger implements Logger { readonly lines: string[] = []; info(message: string): void { this.lines.push(message); }}
// 特例:什么也不做的日志器。class NullLogger implements Logger { info(_message: string): void {}}
// 改造前:logger 可能不存在,每次记日志都要判断。class ImporterV1 { constructor(private readonly logger?: Logger) {}
run(rows: string[]): number { if (this.logger) this.logger.info(`开始导入 ${rows.length} 行`); const valid = rows.filter((r) => r.trim() !== ""); if (this.logger) this.logger.info(`跳过 ${rows.length - valid.length} 个空行`); return valid.length; }}
// 改造后:没传就用 NullLogger,方法体里不再判断。class ImporterV2 { private readonly logger: Logger;
constructor(logger?: Logger) { this.logger = logger ?? new NullLogger(); }
run(rows: string[]): number { this.logger.info(`开始导入 ${rows.length} 行`); const valid = rows.filter((r) => r.trim() !== ""); this.logger.info(`跳过 ${rows.length - valid.length} 个空行`); return valid.length; }}
const rows = ["a", " ", "b", ""];const log1 = new ArrayLogger();const log2 = new ArrayLogger();console.log(new ImporterV1(log1).run(rows) === new ImporterV2(log2).run(rows));console.log(JSON.stringify(log1.lines) === JSON.stringify(log2.lines));console.log(new ImporterV2().run(rows));console.log(log2.lines.join(";"));
// 输出:// true// true// 2// 开始导入 4 行;跳过 2 个空行可选链 this.logger?.info(...) 已经让判断短到几个字符,为什么还要空日志器?因为判断的数量没有变:类里每一个记日志的地方都要记得写 ?.。TS 开着 strict 时漏写会编译报错,至少能被发现;纯 JS 或 Python 里漏一个,就是运行时才暴露的错误。空日志器把“没有日志器”这件事在构造函数里一次处理掉,之后的代码根本不知道这种情况存在。Node 生态里很多库(比如数据库驱动和 HTTP 客户端)都以“默认什么也不做的日志器”作为可选参数的缺省值。
什么时候别用
Section titled “什么时候别用”各个调用方对特殊值的处理不一样时,别用。如果“未知客户”在账单上显示“住户”、在催缴单上要直接跳过整条记录、在统计报表里要单独计数,特例对象给不出一个对所有人都对的默认值。硬做一个,调用方只好写 if customer.is_unknown,等于换了个写法继续检查,还多了一个类。
特殊值本身就是一个需要被注意的错误时,别用。“订单的收货地址是空的”不该被一个“空地址对象”悄悄吸收,让流程走到发货环节才发现寄不出去。这种情况应该尽早报错,或者用第 37 章的卫语句在入口处拒绝。空对象的危险正在于它太安静:它把本该暴露的问题藏了起来,等到暴露时已经离源头很远了。
只有一两个调用方检查时,也不值得。一个 if user is None 加一个默认值,比一个新类加一个工厂逻辑简单得多。等第三个调用方写出同样的检查再说。
容易混淆的手法
Section titled “容易混淆的手法”| 手法 | 它关心什么 | 引入特例关心什么 |
|---|---|---|
| 以卫语句取代嵌套条件表达式(第 37 章) | 一个函数内部的特殊情况先返回 | 消除散在很多调用方的同一种检查 |
| 以多态取代条件表达式(第 38 章) | 每种类型一个子类,分支变成覆盖的方法 | 只为“特殊值”这一种情况建一个子类,是多态的一个特例 |
| 引入断言(第 40 章) | 特殊值不该出现,出现就是 bug | 特殊值是合法的,要被妥善对待 |
下面是博客系统里文章作者的显示逻辑。作者注销账号后,author 变成 None,三个函数都在检查。用本章手法引入一个“已注销作者”的特例,让三个函数不再检查 None。改完测试应当照常通过。
"""练习:文章作者的显示。"""from dataclasses import dataclass
@dataclassclass Author: name: str avatar: str followers: int
@dataclassclass Article: title: str author: Author | None
def byline(article: Article) -> str: if article.author is None: return "作者:已注销用户" return f"作者:{article.author.name}"
def avatar_url(article: Article) -> str: if article.author is None: return "/static/default-avatar.png" return article.author.avatar
def follower_text(article: Article) -> str: if article.author is None: return "" return f"{article.author.followers} 人关注"
if __name__ == "__main__": for a in [Article("重构笔记", Author("李雷", "/u/li.png", 120)), Article("旧文", None)]: print(f"{a.title}|{byline(a)}|{avatar_url(a)}|{follower_text(a)}")
# 输出:# 重构笔记|作者:李雷|/u/li.png|120 人关注# 旧文|作者:已注销用户|/static/default-avatar.png|import unittest
from target import Article, Author, avatar_url, byline, follower_text
LIVE = Article("重构笔记", Author("李雷", "/u/li.png", 120))GONE = Article("旧文", None)
class ArticleTest(unittest.TestCase): def test_live_author(self): self.assertEqual((byline(LIVE), avatar_url(LIVE), follower_text(LIVE)), ("作者:李雷", "/u/li.png", "120 人关注"))
def test_deleted_author(self): self.assertEqual( (byline(GONE), avatar_url(GONE), follower_text(GONE)), ("作者:已注销用户", "/static/default-avatar.png", ""), )
if __name__ == "__main__": unittest.main()// 练习:文章作者的显示。export interface Author { name: string; avatar: string; followers: number;}
export class Article { constructor( readonly title: string, readonly author: Author | null, ) {}}
export function byline(article: Article): string { if (article.author === null) return "作者:已注销用户"; return `作者:${article.author.name}`;}
export function avatarUrl(article: Article): string { if (article.author === null) return "/static/default-avatar.png"; return article.author.avatar;}
export function followerText(article: Article): string { if (article.author === null) return ""; return `${article.author.followers} 人关注`;}
for (const a of [new Article("重构笔记", { name: "李雷", avatar: "/u/li.png", followers: 120 }), new Article("旧文", null)]) { console.log(`${a.title}|${byline(a)}|${avatarUrl(a)}|${followerText(a)}`);}
// 输出:// 重构笔记|作者:李雷|/u/li.png|120 人关注// 旧文|作者:已注销用户|/static/default-avatar.png|import test from "node:test";import assert from "node:assert/strict";import { Article, avatarUrl, byline, followerText } from "./target";
const LIVE = new Article("重构笔记", { name: "李雷", avatar: "/u/li.png", followers: 120 });const GONE = new Article("旧文", null);
test("作者在", () => { assert.deepEqual([byline(LIVE), avatarUrl(LIVE), followerText(LIVE)], ["作者:李雷", "/u/li.png", "120 人关注"]);});
test("作者已注销", () => { assert.deepEqual([byline(GONE), avatarUrl(GONE), followerText(GONE)], ["作者:已注销用户", "/static/default-avatar.png", ""]);});提示:follower_text 对已注销作者返回空字符串,而不是“0 人关注”。光靠给特例对象填一个 followers = 0 做不到,需要把“关注文案”变成作者对象自己的方法,让特例对象覆盖它。
- 同一个特殊值的检查散在三处以上、而且多数调用方的应对相同,就引入特例对象。
- 特例对象和普通对象接口相同,由数据的入口(这里是
Site.customer)负责在两者之间选择,下游不再检查。 - 确实需要区别对待的调用方,问对象的
is_unknown,不要再拿魔法值比较。 - 特例对象要不可变;做成共享单例时尤其如此。
- 特殊值本身是错误时别用特例掩盖它,该报错就报错。结果对应空对象模式。