封装记录
封装记录(Encapsulate Record)把一个被到处直接读写的字典或对象字面量换成一个类,所有访问都经过类的属性和方法。 它解决的痛是:字典的结构散落在每一个读写它的地方,想改一个字段的格式要改遍全部,谁把它改坏了也查不出来。
小蒋维护一个会员系统。每个合作机构的信息存成一个字典:名字和国家代码。会员报表、欢迎邮件、发票抬头三个模块都直接读这个字典,运营后台可以改名字。
"""合作机构:重构前。"""
COUNTRY_NAMES = {"CN": "中国", "JP": "日本", "US": "美国"}
def make_org(name: str, country: str) -> dict: return {"name": name, "country": country}
def report_line(org: dict) -> str: return f"{org['name']}({COUNTRY_NAMES.get(org['country'], org['country'])})"
def welcome_mail(org: dict) -> str: return f"欢迎 {org['name']} 加入,所在地:{COUNTRY_NAMES.get(org['country'], org['country'])}"
def invoice_title(org: dict) -> str: return f"抬头:{org['name']}"
def rename(org: dict, new_name: str) -> None: if not new_name: raise ValueError("机构名不能为空") org["name"] = new_name
if __name__ == "__main__": org = make_org("多练学社", "CN") print(report_line(org)) rename(org, "多练学院") print(invoice_title(org))
# 输出:# 多练学社(中国)# 抬头:多练学院// 合作机构:重构前。export interface Org { name: string; country: string;}
const COUNTRY_NAMES: Record<string, string> = { CN: "中国", JP: "日本", US: "美国" };
export function makeOrg(name: string, country: string): Org { return { name, country };}
export function reportLine(org: Org): string { return `${org.name}(${COUNTRY_NAMES[org.country] ?? org.country})`;}
export function welcomeMail(org: Org): string { return `欢迎 ${org.name} 加入,所在地:${COUNTRY_NAMES[org.country] ?? org.country}`;}
export function invoiceTitle(org: Org): string { return `抬头:${org.name}`;}
export function rename(org: Org, newName: string): void { if (!newName) throw new Error("机构名不能为空"); org.name = newName;}
const org = makeOrg("多练学社", "CN");console.log(reportLine(org));rename(org, "多练学院");console.log(invoiceTitle(org));
// 输出:// 多练学社(中国)// 抬头:多练学院rename 里已经有一行校验:机构名不能为空。测试走五个对外的函数,before 和 after 共用:
import unittest
from target import invoice_title, make_org, rename, report_line, welcome_mail
class OrgTest(unittest.TestCase): def test_report_shows_country_name(self): self.assertEqual(report_line(make_org("多练学社", "JP")), "多练学社(日本)")
def test_unknown_country_falls_back_to_code(self): self.assertEqual(report_line(make_org("某社", "FR")), "某社(FR)")
def test_welcome_mail(self): self.assertEqual(welcome_mail(make_org("多练学社", "CN")), "欢迎 多练学社 加入,所在地:中国")
def test_rename_is_seen_by_invoice(self): org = make_org("多练学社", "CN") rename(org, "多练学院") self.assertEqual(invoice_title(org), "抬头:多练学院")
def test_rename_rejects_empty_name(self): org = make_org("多练学社", "CN") with self.assertRaises(ValueError): rename(org, "")
if __name__ == "__main__": unittest.main()import test from "node:test";import assert from "node:assert/strict";import { invoiceTitle, makeOrg, rename, reportLine, welcomeMail } from "./target";
test("报表显示国家名", () => { assert.equal(reportLine(makeOrg("多练学社", "JP")), "多练学社(日本)");});
test("未知国家显示代码", () => { assert.equal(reportLine(makeOrg("某社", "FR")), "某社(FR)");});
test("欢迎邮件", () => { assert.equal(welcomeMail(makeOrg("多练学社", "CN")), "欢迎 多练学社 加入,所在地:中国");});
test("改名后发票看到新名字", () => { const org = makeOrg("多练学社", "CN"); rename(org, "多练学院"); assert.equal(invoiceTitle(org), "抬头:多练学院");});
test("改名不接受空名字", () => { const org = makeOrg("多练学社", "CN"); assert.throws(() => rename(org, ""));});如果现在要加一个需求:报表和邮件里的国家要显示中文名加国旗,比如“中国 🇨🇳”。国家代码到显示名的翻译现在写在 report_line 和 welcome_mail 两处,各自查一次 COUNTRY_NAMES、各自处理“查不到就显示代码”。改格式要改两处,以后第三个模块也要显示国家,又是复制一份。翻译规则属于机构自己,却散落在使用机构的每个地方。
第二个问题是字典谁都能改,也就谁都能改坏。rename 里那行校验是去年一次事故之后加的:运营后台把机构名提交成了空字符串,第二天所有发票抬头都是“抬头:”。可是校验只守住了 rename 这一个入口,任何一处直接写 org["name"] = "" 的代码都能绕过它。上个月一个导入脚本就这么做了,事故又来了一次。修的人得先全局搜索所有写 org["name"] 的地方,才知道哪里漏了。
第三个问题在读。函数签名写的是 org: dict,字典里有哪些键、各是什么类型,只能翻调用方的代码去猜。TS 版有 interface 好一些,但它只描述形状,放不下“名字不能为空”这样的规则,也放不下“国家显示名怎么算”这样的行为。
用第 2 章的名字说,这是纯数据类:数据和操作它的逻辑分了家;外加可变数据:谁都能改、改了没人知道。
"""合作机构:重构后。"""
COUNTRY_NAMES = {"CN": "中国", "JP": "日本", "US": "美国"}
class Organization: def __init__(self, name: str, country: str) -> None: self._country = country self.name = name
@property def name(self) -> str: return self._name
@name.setter def name(self, value: str) -> None: if not value: raise ValueError("机构名不能为空") self._name = value
@property def country(self) -> str: return self._country
@property def country_name(self) -> str: return COUNTRY_NAMES.get(self._country, self._country)
def make_org(name: str, country: str) -> Organization: return Organization(name, country)
def report_line(org: Organization) -> str: return f"{org.name}({org.country_name})"
def welcome_mail(org: Organization) -> str: return f"欢迎 {org.name} 加入,所在地:{org.country_name}"
def invoice_title(org: Organization) -> str: return f"抬头:{org.name}"
def rename(org: Organization, new_name: str) -> None: org.name = new_name
if __name__ == "__main__": org = make_org("多练学社", "CN") print(report_line(org)) rename(org, "多练学院") print(invoice_title(org))
# 输出:# 多练学社(中国)# 抬头:多练学院// 合作机构:重构后。const COUNTRY_NAMES: Record<string, string> = { CN: "中国", JP: "日本", US: "美国" };
export class Organization { private _name = "";
constructor( name: string, private readonly _country: string, ) { this.name = name; }
get name(): string { return this._name; }
set name(value: string) { if (!value) throw new Error("机构名不能为空"); this._name = value; }
get country(): string { return this._country; }
get countryName(): string { return COUNTRY_NAMES[this._country] ?? this._country; }}
export function makeOrg(name: string, country: string): Organization { return new Organization(name, country);}
export function reportLine(org: Organization): string { return `${org.name}(${org.countryName})`;}
export function welcomeMail(org: Organization): string { return `欢迎 ${org.name} 加入,所在地:${org.countryName}`;}
export function invoiceTitle(org: Organization): string { return `抬头:${org.name}`;}
export function rename(org: Organization, newName: string): void { org.name = newName;}
const org = makeOrg("多练学社", "CN");console.log(reportLine(org));rename(org, "多练学院");console.log(invoiceTitle(org));
// 输出:// 多练学社(中国)// 抬头:多练学院逐条看。国旗那个需求现在只改 country_name 一处,两个模块自动跟着变,第三个模块要显示国家直接用 org.country_name。空名字被挡在 name 的设值方法里,无论从哪里赋值都绕不过去,因为已经没有字典可以直接改了。函数签名从 dict 变成了 Organization,点进去就能看到它有哪些属性、哪些规则。
country 只有读属性没有写属性:国家代码在构造之后不该再变,这条规则以前只能靠大家自觉,现在写进了类里。
还有一个不那么显眼的收获在测试上。重构前想测“国家名怎么显示”,得调用 report_line 或 welcome_mail,再从整句话里找国家名;重构后可以直接测 Organization("某社", "FR").country_name == "FR",一行一个规则。规则有了自己的家,也就有了单独测试它的地方。
怎么一步步做
Section titled “怎么一步步做”- 对保存记录的变量做封装变量(第 8 章),让所有访问都经过函数。本章的例子里,记录是作为参数传来传去的,创建记录已经只有
make_org一个入口,相当于这一步已经做好了;如果你的代码里到处都在直接写{"name": ..., "country": ...},先补一个这样的入口。跑测试。 - 建一个类包住记录。最初它只是把字典存在一个私有字段里,提供一个返回原字典的方法,让还没改的调用方继续能用。跑测试。
- 为每个字段加读属性,有需要写的再加写属性。
- 逐个把调用方从
org["name"]改成org.name,每改一个跑一次测试。IDE 能帮你找到所有读写某个键的地方,但字典的键是字符串,找出来的结果要人工确认(IDE 可自动完成查找,确认要人工做)。 - 所有调用方都改完后,把类内部的字典换成真正的字段,删掉返回原字典的方法。跑测试。
- 看看有没有逻辑该搬进类里。
country_name就是在这一步从两个模块里收进来的。
嵌套的记录(顾客字典里套着地址字典)按同样的步骤从外到里做:先封装外层,让外层的读属性返回内层字典;再封装内层,让外层返回内层对象。返回内层对象时要想清楚调用方能不能改它:不该改就返回副本,或者把内层做成不可变的值对象(第 33 章)。
定义与适用场景
Section titled “定义与适用场景”封装记录:用一个类替换被多处直接访问的记录(字典、对象字面量、命名元组),把字段变成属性,访问逻辑放进类里。Fowler 给的判断标准是“可变的记录”:只读、生命周期很短的记录,比如一个函数内部拼出来马上用掉的字典,用不着封装;会被修改、会被传到很多地方的记录,就该封装。
它最常出现在三个地方。一是从配置文件、数据库、接口读进来的数据,刚读进来时是字典,在代码里传了一圈之后,每个读它的地方都知道它的结构。二是“派生值”开始在多处重复计算的时候,本章的国家显示名就是这样。三是需要校验的时候:一旦某个字段有了“不能为空”“必须为正”这类规则,就需要一个能拦住非法写入的地方。
Python 的 @dataclass 和 TS 的 class 都让封装记录的成本很低。如果一个记录只需要字段、不需要规则和行为,@dataclass(frozen=True) 或者 TS 的 readonly 接口就够了,这时它其实是一个值对象,见第 33 章。
真实系统里的例子
Section titled “真实系统里的例子”Django 模型的属性
Section titled “Django 模型的属性”Django 的模型类就是封装记录的产物:数据库里的一行不是以字典的形式在代码里传,而是一个对象,派生值写成 @property。下面用一个普通类模拟这种写法,把“按分显示的价格”从三个模板里收进模型。
"""模拟 Django 模型:把派生显示值收进模型的属性。"""
class Product: def __init__(self, name: str, price_cents: int, on_sale: bool = False) -> None: self.name = name self.price_cents = price_cents self.on_sale = on_sale
@property def display_price(self) -> str: cents = self.price_cents * 8 // 10 if self.on_sale else self.price_cents return f"¥{cents / 100:.2f}"
def list_template(products: list[Product]) -> list[str]: return [f"{p.name} {p.display_price}" for p in products]
def detail_template(product: Product) -> str: return f"{product.name}:现价 {product.display_price}"
if __name__ == "__main__": items = [Product("咖啡豆", 6800), Product("手冲壶", 19900, on_sale=True)] print(list_template(items)) print(detail_template(items[1]))
# 输出:# ['咖啡豆 ¥68.00', '手冲壶 ¥159.20']# 手冲壶:现价 ¥159.20在真正的 Django 项目里,display_price 这样的属性模板里直接写 {{ product.display_price }} 就能用。打折规则变了,只改模型这一处。
把接口返回的 JSON 包成类
Section titled “把接口返回的 JSON 包成类”前端从接口拿到的订单是一个 JSON 对象,最常见的做法是直接在组件里读 order.items[0].price。把它包成一个类之后,派生值有了唯一的计算处,组件也不再依赖 JSON 的嵌套结构。
// 把接口返回的订单 JSON 包成类。interface OrderJson { id: string; items: { sku: string; price: number; qty: number }[]; coupon?: { off: number };}
class Order { constructor(private readonly data: OrderJson) {}
get id(): string { return this.data.id; }
get itemCount(): number { return this.data.items.reduce((n, item) => n + item.qty, 0); }
get total(): number { const sum = this.data.items.reduce((s, item) => s + item.price * item.qty, 0); return Math.max(0, sum - (this.data.coupon?.off ?? 0)); }}
const json: OrderJson = { id: "A100", items: [ { sku: "bean", price: 68, qty: 2 }, { sku: "kettle", price: 199, qty: 1 }, ], coupon: { off: 30 },};const order = new Order(json);console.log(order.id, order.itemCount, order.total);
// 输出:// A100 3 305接口哪天把 coupon.off 改名成 coupon.amount,只有 Order 一处要改,十几个组件不受影响。
在已经用 ORM 的项目里(Django、SQLAlchemy、TypeORM),数据库的每一行本来就是对象,不太会遇到裸字典。但接口层、脚本、数据管道里仍然到处是字典:requests 返回的 JSON、csv.DictReader 读出的行、消息队列里的消息。判断要不要封装,看它离开读进来的那个函数之后还走了多远。只在一个函数里用完就丢的,留着字典;被传过三四层、被好几个模块读的,就该有一个类。
Python 3.8 之后的 TypedDict 是一个折中:它让类型检查器知道字典里有哪些键、各是什么类型,调用方仍然写 org["name"]。它解决了“签名说明不了结构”的问题,但解决不了校验和派生值,因为 TypedDict 在运行时就是普通字典,不能有方法。只需要类型提示时用它,需要规则时用类。
什么时候别用
Section titled “什么时候别用”只在一个函数里存活的临时字典不需要封装,比如拼好马上交给 json.dumps 的那种。封装的回报来自“被多处读写”和“有规则要守”,两样都没有,就是给读者多加一层。
也别为每个字段机械地写一对读写方法。Python 里如果一个字段既不需要校验也不需要派生,直接用普通属性,等有了规则再换成 @property,调用方的写法不用变,这是 Python 属性的好处。TS 同理,readonly 公共字段比一对什么都不做的 getter 和 setter 更清楚。
封装之后的类要不要做成不可变的?看记录在业务里是否会“改”。机构会改名,所以 Organization 保留了一个带校验的写属性;而地址、金额这类值,换一个就是换一个,做成不可变的更省心。拿不准时先做成不可变的,等真有修改的需求再加写属性,反过来就难多了:一个已经被到处修改的对象,想收回修改权,得先找到所有修改它的地方。
容易混淆的手法
Section titled “容易混淆的手法”| 手法 | 它关心什么 | 封装记录关心什么 |
|---|---|---|
| 封装变量(第 8 章) | 一个变量的读写入口 | 一条记录内部每个字段的读写入口 |
| 引入参数对象(第 9 章) | 散着传的几个参数合成对象 | 已经是一个整体的字典,换成类 |
| 以对象取代基本类型(第 15 章) | 单个值带着规则 | 一组字段带着规则 |
下面的分页函数接收一个设置字典。page_size 必须是正整数,make_settings 在创建时检查了,但之后任何一处直接写 settings["page_size"] = 0 都能绕过检查。把设置封装成 Settings 类:page_size 写入时校验,不合法抛 ValueError(TS 抛 Error)。render_page 的输出不变,测试照常通过。
"""练习:分页设置。"""
def make_settings(page_size: int, title: str) -> dict: if page_size <= 0: raise ValueError("page_size 必须是正整数") return {"page_size": page_size, "title": title}
def render_page(settings: dict, items: list[str], page: int) -> str: size = settings["page_size"] chunk = items[(page - 1) * size : page * size] return f"{settings['title']} 第 {page} 页:{'、'.join(chunk)}"
if __name__ == "__main__": s = make_settings(2, "商品") print(render_page(s, ["a", "b", "c", "d", "e"], 2))
# 输出:# 商品 第 2 页:c、dimport unittest
from target import make_settings, render_page
class SettingsTest(unittest.TestCase): def test_second_page(self): self.assertEqual(render_page(make_settings(2, "商品"), ["a", "b", "c", "d", "e"], 2), "商品 第 2 页:c、d")
def test_last_partial_page(self): self.assertEqual(render_page(make_settings(2, "商品"), ["a", "b", "c"], 2), "商品 第 2 页:c")
def test_rejects_zero_page_size(self): with self.assertRaises(ValueError): make_settings(0, "商品")
if __name__ == "__main__": unittest.main()// 练习:分页设置。export interface Settings { pageSize: number; title: string;}
export function makeSettings(pageSize: number, title: string): Settings { if (pageSize <= 0) throw new Error("pageSize 必须是正整数"); return { pageSize, title };}
export function renderPage(settings: Settings, items: string[], page: number): string { const size = settings.pageSize; const chunk = items.slice((page - 1) * size, page * size); return `${settings.title} 第 ${page} 页:${chunk.join("、")}`;}
console.log(renderPage(makeSettings(2, "商品"), ["a", "b", "c", "d", "e"], 2));
// 输出:// 商品 第 2 页:c、dimport test from "node:test";import assert from "node:assert/strict";import { makeSettings, renderPage } from "./target";
test("第二页", () => { assert.equal(renderPage(makeSettings(2, "商品"), ["a", "b", "c", "d", "e"], 2), "商品 第 2 页:c、d");});
test("最后一页不满", () => { assert.equal(renderPage(makeSettings(2, "商品"), ["a", "b", "c"], 2), "商品 第 2 页:c");});
test("页大小为零报错", () => { assert.throws(() => makeSettings(0, "商品"));});- 被多处读写、会被修改的字典,换成类;只在一处用、马上丢掉的不必。
- 字段变成属性之后,校验和派生值都有了唯一的家,调用方不再知道数据的内部结构。
- 先封装创建入口,再建类包住原记录,逐个迁移调用方,最后把内部字典换成字段。
- 嵌套记录从外到里封装;返回内层对象时决定是给副本还是做成不可变的值对象。
- 只需要字段不需要规则时,
@dataclass(frozen=True)和readonly接口就够了。