以对象取代基本类型
以对象取代基本类型(Replace Primitive with Object)把一个带着规则的字符串或数字换成一个小类,规则和行为都搬进这个类。 它解决的痛是:同一条规则(怎么比较、怎么校验、怎么显示)在每个用到这个值的地方都写一遍,写法还各不相同。
小孟维护一个工单系统。工单的优先级是字符串:"low"、"normal"、"high"、"urgent"。排序要把高优先级排前面,告警要在优先级“高于普通”时发短信,报表要统计高优先级工单的数量。
"""工单优先级:重构前。"""
def sort_tickets(tickets: list[dict]) -> list[str]: order = {"urgent": 0, "high": 1, "normal": 2, "low": 3} return [t["id"] for t in sorted(tickets, key=lambda t: order[t["priority"]])]
def needs_sms(ticket: dict) -> bool: return ticket["priority"] == "high" or ticket["priority"] == "urgent"
def count_important(tickets: list[dict]) -> int: return sum(1 for t in tickets if t["priority"] in ("high", "urgent"))
def make_ticket(ticket_id: str, priority: str) -> dict: return {"id": ticket_id, "priority": priority}
TICKETS = [make_ticket("T1", "normal"), make_ticket("T2", "urgent"), make_ticket("T3", "low"), make_ticket("T4", "high")]
if __name__ == "__main__": print(sort_tickets(TICKETS)) print([t["id"] for t in TICKETS if needs_sms(t)]) print(count_important(TICKETS))
# 输出:# ['T2', 'T4', 'T1', 'T3']# ['T2', 'T4']# 2// 工单优先级:重构前。export interface Ticket { id: string; priority: string;}
export function sortTickets(tickets: Ticket[]): string[] { const order: Record<string, number> = { urgent: 0, high: 1, normal: 2, low: 3 }; return [...tickets].sort((a, b) => order[a.priority] - order[b.priority]).map((t) => t.id);}
export function needsSms(ticket: Ticket): boolean { return ticket.priority === "high" || ticket.priority === "urgent";}
export function countImportant(tickets: Ticket[]): number { return tickets.filter((t) => t.priority === "high" || t.priority === "urgent").length;}
export function makeTicket(id: string, priority: string): Ticket { return { id, priority };}
const TICKETS = [makeTicket("T1", "normal"), makeTicket("T2", "urgent"), makeTicket("T3", "low"), makeTicket("T4", "high")];
console.log(JSON.stringify(sortTickets(TICKETS)));console.log(JSON.stringify(TICKETS.filter(needsSms).map((t) => t.id)));console.log(countImportant(TICKETS));
// 输出:// ["T2","T4","T1","T3"]// ["T2","T4"]// 2测试只通过 make_ticket 和三个函数:
import unittest
from target import count_important, make_ticket, needs_sms, sort_tickets
TICKETS = [make_ticket("T1", "normal"), make_ticket("T2", "urgent"), make_ticket("T3", "low"), make_ticket("T4", "high")]
class PriorityTest(unittest.TestCase): def test_sort_puts_urgent_first(self): self.assertEqual(sort_tickets(TICKETS), ["T2", "T4", "T1", "T3"])
def test_sms_only_above_normal(self): self.assertTrue(needs_sms(make_ticket("X", "high"))) self.assertFalse(needs_sms(make_ticket("X", "normal")))
def test_count_important(self): self.assertEqual(count_important(TICKETS), 2)
def test_unknown_priority_is_rejected(self): with self.assertRaises((ValueError, KeyError)): sort_tickets([make_ticket("X", "High"), make_ticket("Y", "low")])
if __name__ == "__main__": unittest.main()import test from "node:test";import assert from "node:assert/strict";import { countImportant, makeTicket, needsSms, sortTickets } from "./target";
const TICKETS = [makeTicket("T1", "normal"), makeTicket("T2", "urgent"), makeTicket("T3", "low"), makeTicket("T4", "high")];
test("紧急的排最前", () => { assert.deepEqual(sortTickets(TICKETS), ["T2", "T4", "T1", "T3"]);});
test("高于普通才发短信", () => { assert.equal(needsSms(makeTicket("X", "high")), true); assert.equal(needsSms(makeTicket("X", "normal")), false);});
test("统计重要工单", () => { assert.equal(countImportant(TICKETS), 2);});TS 版没有“未知优先级”那条测试:before 的 TS 版对 "High" 会算出 NaN 然后静默排错位置,测不出一个确定的行为,这正是下一节要说的问题。
如果现在要加一个需求:新增一个优先级 "critical",比 "urgent" 还高。要改三处:排序表里加一行,needs_sms 的条件加一个 or,count_important 的元组加一项。三处写法各不相同(一张表、一串 or、一个 in),很难一眼确认它们说的是同一条规则。漏掉 needs_sms,最高优先级的工单反而不发短信。
第二个问题是字符串什么都能装。有人从前端传来 "High"(大写),Python 版的排序直接 KeyError 崩掉,needs_sms 却静默返回 False,同一个值在两个函数里一个报错一个放行。TS 版更糟,排序里 order["High"] 是 undefined,减出来是 NaN,工单被排到一个不确定的位置,没有任何报错。
第三个问题在读:priority: str 这个类型说明不了它只能取四个值,也说明不了它们有大小。
这是基本类型偏执:一个有规则、有大小关系的概念,被当成普通字符串到处传。
"""工单优先级:重构后。"""
from dataclasses import dataclass
_LEVELS = ("low", "normal", "high", "urgent")
@dataclass(frozen=True)class Priority: name: str
def __post_init__(self) -> None: if self.name not in _LEVELS: raise ValueError(f"未知优先级 {self.name!r},可选:{'、'.join(_LEVELS)}")
@property def rank(self) -> int: return _LEVELS.index(self.name)
def higher_than(self, other: "Priority") -> bool: return self.rank > other.rank
NORMAL = Priority("normal")
def make_ticket(ticket_id: str, priority: str) -> dict: return {"id": ticket_id, "priority": Priority(priority)}
def sort_tickets(tickets: list[dict]) -> list[str]: return [t["id"] for t in sorted(tickets, key=lambda t: t["priority"].rank, reverse=True)]
def needs_sms(ticket: dict) -> bool: return ticket["priority"].higher_than(NORMAL)
def count_important(tickets: list[dict]) -> int: return sum(1 for t in tickets if needs_sms(t))
TICKETS = [make_ticket("T1", "normal"), make_ticket("T2", "urgent"), make_ticket("T3", "low"), make_ticket("T4", "high")]
if __name__ == "__main__": print(sort_tickets(TICKETS)) print([t["id"] for t in TICKETS if needs_sms(t)]) print(count_important(TICKETS))
# 输出:# ['T2', 'T4', 'T1', 'T3']# ['T2', 'T4']# 2// 工单优先级:重构后。const LEVELS = ["low", "normal", "high", "urgent"] as const;
export class Priority { readonly rank: number;
constructor(readonly name: string) { this.rank = LEVELS.indexOf(name as (typeof LEVELS)[number]); if (this.rank < 0) throw new Error(`未知优先级 "${name}",可选:${LEVELS.join("、")}`); }
higherThan(other: Priority): boolean { return this.rank > other.rank; }
equals(other: Priority): boolean { return this.name === other.name; }}
const NORMAL = new Priority("normal");
export interface Ticket { id: string; priority: Priority;}
export function makeTicket(id: string, priority: string): Ticket { return { id, priority: new Priority(priority) };}
export function sortTickets(tickets: Ticket[]): string[] { return [...tickets].sort((a, b) => b.priority.rank - a.priority.rank).map((t) => t.id);}
export function needsSms(ticket: Ticket): boolean { return ticket.priority.higherThan(NORMAL);}
export function countImportant(tickets: Ticket[]): number { return tickets.filter(needsSms).length;}
const TICKETS = [makeTicket("T1", "normal"), makeTicket("T2", "urgent"), makeTicket("T3", "low"), makeTicket("T4", "high")];
console.log(JSON.stringify(sortTickets(TICKETS)));console.log(JSON.stringify(TICKETS.filter(needsSms).map((t) => t.id)));console.log(countImportant(TICKETS));
// 输出:// ["T2","T4","T1","T3"]// ["T2","T4"]// 2加 "critical" 现在只改一处:在 _LEVELS 末尾加一项。排序、短信、统计全部自动跟上,因为它们都在问同一个对象。"High" 在 make_ticket 里就被拦下,报错信息还告诉你有哪些合法值,不会再出现一个函数崩、一个函数放行的情况。count_important 顺手改成了复用 needs_sms,“重要”的定义只剩一个。
Priority 是不可变的:Python 用 frozen=True,TS 的字段全是 readonly。两个 Priority("high") 是相等的,Python 的数据类自动按字段比较,TS 要自己写 equals。
值对象做成不可变、按内容比较之后,还有一个顺带的好处:它可以当字典的键、可以放进集合。Python 的 @dataclass(frozen=True) 会自动生成 __hash__,所以 {Priority("high"): "红色"} 可以直接用;可变的对象则不能当键,因为键的内容变了之后就再也找不到它了。TS 的 Map 按引用比较对象,想按内容当键,通常用 priority.name 这样的基本值做键。
怎么一步步做
Section titled “怎么一步步做”- 如果这个值还没有封装,先做封装变量(第 8 章)。本例里创建工单只有
make_ticket一个入口,相当于已经封装好了。 - 建一个最简单的值类:构造时接收原来的字符串,提供一个返回字符串的属性。先不加任何规则。跑测试。
- 改设值的地方(
make_ticket),存入新对象。改取值的地方,暂时用.name取回字符串,让所有比较逻辑原样工作。跑测试。 - 逐个把散落的比较逻辑搬进类里变成方法:排序表变成
rank,or链变成higher_than。每搬一个跑一次测试。 - 在构造函数里加校验。注意这一步改变了非法输入的行为:重构前
"High"在排序里抛KeyError、在短信判断里静默返回假,重构后在创建工单时就抛ValueError。合法输入的行为不变,非法输入从“各处表现不一”变成“一处明确拒绝”。这是有意的改进,按两顶帽子的规矩单独提交;本章的测试只要求“非法输入被拒绝”,所以两种异常都接受。跑测试。 - 给类起一个更准确的名字,如果一开始的名字只是临时的。
第 3 步故意先让调用方用 .name 取回字符串,把所有比较逻辑原样留着。这样做看起来多绕了一步,好处是包装对象和迁移逻辑分成了两次独立的改动:第一次只换数据的容器,行为一行没动,测试必然通过;第二次逐个搬比较逻辑,每搬一个都能单独验证。如果一上来就把 t["priority"] == "high" 直接改成 t["priority"].higher_than(...),三处一起改,测试失败时很难判断是包装错了还是规则搬错了。
定义与适用场景
Section titled “定义与适用场景”以对象取代基本类型:为一个基本类型的值创建一个小类,把与这个值相关的校验、比较、格式化都放进去。这样的小类通常是值对象:不可变、按内容判断相等。
它的典型对象是那些“看起来是字符串或数字,其实有规则”的东西:金额(有币种,不能直接和别的币种相加)、电话号码(有格式、要脱敏)、邮箱、优先级、温度(有单位)、坐标、日期区间。判断标准很简单:如果你发现同一条关于这个值的规则写在了两个地方,它就该是一个类。
Python 里如果规则只是“只能取几个固定值”,enum.Enum 也是一个选择;但一旦需要比较大小、格式化、带额外字段,自定义类更灵活。TS 里只有固定取值时可以用字符串字面量联合类型 "low" | "normal" | ...,它在编译时防住拼写错误,但运行时从接口来的数据仍要校验。
值对象还会碰到一个现实问题:它要存进数据库、写进 JSON、通过接口传给别的服务,而这些地方只认基本类型。通行的做法是在边界上转换:读进来的时候把字符串变成 Priority,写出去的时候把 Priority 变回字符串。转换只发生在少数几个入口和出口,程序内部全程用对象。这样数据库和接口的格式不用改,内部代码又享受到了对象的校验和行为。Django 的自定义字段、SQLAlchemy 的 TypeDecorator、TS 里接口层的解析函数,都是在做这件事。
相关的设计模式
Section titled “相关的设计模式”Priority 是一个值对象(Value Object),第 9 章的 TemperatureRange 也是,第 33 章会专门讲值对象和引用对象。构造函数里的校验让“不合法的 Priority 根本造不出来”,如果构造逻辑再复杂一些(比如从数字、从别名都能创建),下一步就是把构造函数换成有名字的工厂函数,见第 48 章以工厂函数取代构造函数,以及《设计模式》书第 3 章工厂方法。值对象能被安全地多处共享,这也是享元模式(《设计模式》书第 15 章)的前提。
真实系统里的例子
Section titled “真实系统里的例子”电商系统里最常见的基本类型偏执就是用浮点数存金额:不同币种直接相加、0.1 + 0.2 的精度问题、显示时到处 round。下面的 Money 用整数分存储,不同币种相加直接报错。
"""金额值对象:整数分存储,不同币种不能相加。"""
from dataclasses import dataclass
@dataclass(frozen=True)class Money: cents: int currency: str = "CNY"
def __add__(self, other: "Money") -> "Money": if self.currency != other.currency: raise ValueError(f"不能把 {other.currency} 加到 {self.currency} 上") return Money(self.cents + other.cents, self.currency)
def __str__(self) -> str: symbol = {"CNY": "¥", "USD": "$"}.get(self.currency, self.currency + " ") return f"{symbol}{self.cents / 100:.2f}"
if __name__ == "__main__": total = Money(10) + Money(20) print(total, total == Money(30)) print(0.1 + 0.2 == 0.3) try: Money(100) + Money(100, "USD") except ValueError as error: print(error)
# 输出:# ¥0.30 True# False# 不能把 USD 加到 CNY 上带品牌的邮箱类型
Section titled “带品牌的邮箱类型”TS 的类型系统里,string 和 string 没有区别,一个函数参数写着 email: string,传进来的可能是任何字符串。zod 这类校验库的做法是“品牌类型”:只有经过校验函数的字符串才能得到 Email 类型。下面用纯 TS 写出同样的思路。
// 品牌类型:只有校验过的字符串才是 Email。type Email = string & { readonly __brand: "Email" };
function parseEmail(input: string): Email { const trimmed = input.trim().toLowerCase(); if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(trimmed)) throw new Error(`邮箱格式不对:${input}`); return trimmed as Email;}
function sendWelcome(to: Email): string { return `已发送欢迎邮件到 ${to}`;}
try { parseEmail("not-an-email");} catch (error) { console.log((error as Error).message);}// sendWelcome("[email protected]"); // 编译错误:string 不能赋给 Email
// 输出:// 已发送欢迎邮件到 [email protected]// 邮箱格式不对:not-an-email注释掉的那一行如果打开,tsc 会报错:没经过 parseEmail 的字符串进不了 sendWelcome。这比一个类更轻,运行时它仍是普通字符串,适合只需要校验、不需要额外方法的值。
还有一种情况要谨慎:值的规则其实属于业务流程,不属于值本身。比如“VIP 用户的工单优先级自动提升一级”,这条规则依赖用户信息,放进 Priority 类里就让它知道了用户,反而越界。值对象里只放这个值自己就能判断的规则:合法取值、大小比较、格式化;涉及别的对象的规则,留在使用它的地方。
什么时候别用
Section titled “什么时候别用”一个值没有任何规则,只是被传来传去显示一下,比如备注、昵称,包成类只多了一层。也别一开始就给每个字段都建类,等同一条规则出现第二次再动手。
值对象要不可变。如果你发现自己给 Priority 加了 set_name,那它就不是值对象了,共享它的地方会被悄悄改掉,正是第 33 章要讲的问题。
如果你的项目里已经有大量 "high"、"low" 这样的字符串,不必一次全改。先在最容易出错的地方(比较大小的地方)引入 Priority,其他只做显示的地方继续用字符串,两种形态在边界上用 Priority(s) 和 p.name 互相转换。随着代码被修改,字符串会一点点被替换掉。重构不要求一步到位,要求的是每一步都不破坏行为。
容易混淆的手法
Section titled “容易混淆的手法”| 手法 | 它关心什么 | 以对象取代基本类型关心什么 |
|---|---|---|
| 封装记录(第 13 章) | 一组字段组成的记录 | 单个的值 |
| 以子类取代类型码(第 52 章) | 不同取值有不同的行为,用子类区分 | 不同取值只是大小、显示不同,一个类就够 |
| 引入参数对象(第 9 章) | 几个参数总一起出现 | 一个参数带着规则 |
用户的手机号是一个字符串,联系卡片要显示脱敏后的号码(中间四位换成星号),号码必须是 11 位、以 1 开头。把它换成 PhoneNumber 类,提供 masked() 方法,构造时校验。contact_card 的输出不变。
"""练习:联系卡片。"""
def make_user(name: str, phone: str) -> dict: if len(phone) != 11 or not phone.startswith("1") or not phone.isdigit(): raise ValueError(f"手机号不合法:{phone}") return {"name": name, "phone": phone}
def contact_card(user: dict) -> str: phone = user["phone"] return f"{user['name']} {phone[:3]}****{phone[7:]}"
if __name__ == "__main__": print(contact_card(make_user("小林", "13812345678")))
# 输出:# 小林 138****5678import unittest
from target import contact_card, make_user
class PhoneTest(unittest.TestCase): def test_card_masks_middle_digits(self): self.assertEqual(contact_card(make_user("小林", "13812345678")), "小林 138****5678")
def test_rejects_short_number(self): with self.assertRaises(ValueError): make_user("小林", "1381234")
def test_rejects_non_digits(self): with self.assertRaises(ValueError): make_user("小林", "1381234567a")
if __name__ == "__main__": unittest.main()// 练习:联系卡片。export interface User { name: string; phone: string;}
export function makeUser(name: string, phone: string): User { if (!/^1\d{10}$/.test(phone)) throw new Error(`手机号不合法:${phone}`); return { name, phone };}
export function contactCard(user: User): string { return `${user.name} ${user.phone.slice(0, 3)}****${user.phone.slice(7)}`;}
console.log(contactCard(makeUser("小林", "13812345678")));
// 输出:// 小林 138****5678import test from "node:test";import assert from "node:assert/strict";import { contactCard, makeUser } from "./target";
test("中间四位脱敏", () => { assert.equal(contactCard(makeUser("小林", "13812345678")), "小林 138****5678");});
test("位数不对报错", () => { assert.throws(() => makeUser("小林", "1381234"));});
test("含字母报错", () => { assert.throws(() => makeUser("小林", "1381234567a"));});本章的值对象很小,十几行代码,却换来了一个确定的类型、一处规则和一个能单独测试的单元。
- 同一条关于某个值的规则写在了两处,这个值就该有自己的类。
- 值类做成不可变的,按内容判断相等;Python 用
@dataclass(frozen=True),TS 自己写equals。 - 校验放进构造函数,不合法的值根本造不出来。
- 先原样包住,再把散落的比较、格式化逐个搬进类里,每步跑测试。
- 只需要校验、不需要方法时,TS 的品牌类型和 Python 的
Enum是更轻的选择。