跳转到内容

引入特例

引入特例(Introduce Special Case)把“这个值是某种特殊情况”的判断,换成一个专门表示这种情况的对象。这个对象和普通对象有一样的接口,调用方不用再检查,直接用就行。 它解决的痛是:同一个检查(if customer == "unknown"、if user is None)散落在几十个调用方,每处都写一遍相同的应对,漏写一处就是线上的空指针错误。

小孙在一家燃气公司维护收费系统。每个用气地址(Site)对应一个客户。有些房子刚卖掉、新业主还没去营业厅登记,系统里这个地址的客户就记成字符串 "unknown"。可是账单、催缴单、营业厅屏幕这些地方都要显示客户信息,于是每个用到客户的函数开头都有一句“如果是未知客户就……”:名字显示“住户”,套餐按基础套餐,欠费周数当作 0,不寄催缴单,营业厅屏幕上提示去登记。

"""燃气收费:重构前。"""
from dataclasses import dataclass
BASIC_PLAN = "基础套餐"
@dataclass
class PaymentHistory:
weeks_delinquent_in_last_year: int
@dataclass
class 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 = "基础套餐"
@dataclass
class PaymentHistory:
weeks_delinquent_in_last_year: int
@dataclass
class 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 周|不寄催缴单

输出不变,三个测试照样通过。

Site 持有一个 Customer;UnknownCustomer 是 Customer 的子类,覆盖 is_unknown,构造时填好住户、基础套餐和 PaymentHistory(0);Site 遇到 unknown 时返回它

印联系电话的需求,现在是给 Customer 加 phone 字段,在 UnknownCustomer 的构造里填上服务热线。调用方直接写 site.customer.phone,不需要知道有未知客户这回事。新人不知道未知客户的存在也不会出错,因为他拿到的永远是一个 Customer。

“未知客户该怎么对待”的规则现在只有一处,就是 UnknownCustomer 这个类。运营要把“住户”改成“尊敬的用户”,改一行;营业厅屏幕、短信模板只要都从 Site.customer 取客户,就一起变了。

魔法字符串 "unknown" 只剩 Site 里的一处比较。写错的风险从五处降到一处,而且这一处有测试守着。

有一个调用方还保留了判断:greeting。营业厅屏幕对未知客户说的话和对已知客户完全不同,不是换一个值就能表达的,这时用 is_unknown 明确地问一句,比让特例对象假装有个名字更诚实。注意它问的是对象,而不是拿字符串比较,以后未知客户的表示方式变了,这里也不用改。should_send_reminder 则连判断都不需要了:特例对象的付款记录是欠费 0 周,0 > 2 自然是 False。

每一步之后跑测试。

  1. 在 Customer 上加一个 is_unknown 属性,返回 False。
  2. 新建 UnknownCustomer 子类,is_unknown 返回 True。
  3. 把所有 customer == "unknown" 的检查提炼成一个函数 is_unknown(customer),它同时认得字符串和新属性:return customer == "unknown" or customer.is_unknown。把五个调用方的比较都换成对这个函数的调用。这一步把散落的检查收到一处(第 4 章提炼函数,IDE 可自动完成)。
  4. 改 Site.customer:遇到 "unknown" 时返回 UnknownCustomer()。此时调用方的 is_unknown(customer) 走的是新属性那条路,字符串那条路不再被走到。
  5. 改 is_unknown 函数,只看属性:return customer.is_unknown。
  6. 挑一个调用方,看它在未知时返回的值能不能由特例对象直接给出。customer_name 返回“住户”,那就让 UnknownCustomer 的 name 是“住户”,然后把调用方里的检查删掉,直接返回 site.customer.name。逐个调用方地做,每改一个跑一次测试。
  7. 剩下的调用方如果确实需要区别对待(如 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 要根据地区查热线),就用类。

引入特例:当一个数据结构的使用者都在检查某个特殊值,并且遇到时做相同的处理,就创建一个特例对象,让它表现出这些处理,用它替换掉特殊值。

最常见的特殊值是“没有”:None、null、空字符串、魔法字符串 "unknown"、ID 为 0 的占位记录。这时的特例对象叫空对象(Null Object),本章的 UnknownCustomer 就是一个。但特例不只是“没有”,“已注销的账号”“系统管理员”“试用期用户”也可以是特例,只要很多地方都在对它做同样的判断。

判断要不要引入特例,数一下调用方:同一个检查出现在三处以上,并且多数调用方遇到特殊值时做的是同样的事(返回一个默认值),就值得做。如果各个调用方对特殊值的处理都不一样,特例对象给不出统一的默认值,就没有意义。

引入特例的结果就是空对象(Null Object)模式:Customer 定义了共同接口,普通的 Customer 实例是真实对象,UnknownCustomer 是空对象,它实现同样的接口,但每个方法都给出“什么也不做”或“默认值”的安全行为;Site.customer 是负责在两者之间选择的地方,调用方只和这个共同接口打交道。空对象不在 GoF 的 23 个模式里,它由 Bobby Woolf 在《Pattern Languages of Program Design 3》里整理成文,在实践中和 GoF 模式一样常用,《设计模式》书第 30 章“走出 GoF”会讲它。空对象常和策略模式一起出现:一个“什么也不做”的策略,比如下面的空日志器,就是空对象。

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
@dataclass
class 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
@dataclass
class 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 的位置:特例在数据进入系统的入口处一次造好,下游不再关心。

库和服务常允许调用方传入一个日志器,不传就不记日志。不用特例的写法是到处 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 客户端)都以“默认什么也不做的日志器”作为可选参数的缺省值。

各个调用方对特殊值的处理不一样时,别用。如果“未知客户”在账单上显示“住户”、在催缴单上要直接跳过整条记录、在统计报表里要单独计数,特例对象给不出一个对所有人都对的默认值。硬做一个,调用方只好写 if customer.is_unknown,等于换了个写法继续检查,还多了一个类。

特殊值本身就是一个需要被注意的错误时,别用。“订单的收货地址是空的”不该被一个“空地址对象”悄悄吸收,让流程走到发货环节才发现寄不出去。这种情况应该尽早报错,或者用第 37 章的卫语句在入口处拒绝。空对象的危险正在于它太安静:它把本该暴露的问题藏了起来,等到暴露时已经离源头很远了。

只有一两个调用方检查时,也不值得。一个 if user is None 加一个默认值,比一个新类加一个工厂逻辑简单得多。等第三个调用方写出同样的检查再说。

手法 它关心什么 引入特例关心什么
以卫语句取代嵌套条件表达式(第 37 章) 一个函数内部的特殊情况先返回 消除散在很多调用方的同一种检查
以多态取代条件表达式(第 38 章) 每种类型一个子类,分支变成覆盖的方法 只为“特殊值”这一种情况建一个子类,是多态的一个特例
引入断言(第 40 章) 特殊值不该出现,出现就是 bug 特殊值是合法的,要被妥善对待

下面是博客系统里文章作者的显示逻辑。作者注销账号后,author 变成 None,三个函数都在检查。用本章手法引入一个“已注销作者”的特例,让三个函数不再检查 None。改完测试应当照常通过。

"""练习:文章作者的显示。"""
from dataclasses import dataclass
@dataclass
class Author:
name: str
avatar: str
followers: int
@dataclass
class 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,不要再拿魔法值比较。
  • 特例对象要不可变;做成共享单例时尤其如此。
  • 特殊值本身是错误时别用特例掩盖它,该报错就报错。结果对应空对象模式。