跳转到内容

封装记录

封装记录(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));
// 输出:
// 多练学社(中国)
// 抬头:多练学院

重构前三个模块各自从字典里取字段、各自翻译国家名;重构后它们只读 Organization 的属性,翻译和校验都在类里

逐条看。国旗那个需求现在只改 country_name 一处,两个模块自动跟着变,第三个模块要显示国家直接用 org.country_name。空名字被挡在 name 的设值方法里,无论从哪里赋值都绕不过去,因为已经没有字典可以直接改了。函数签名从 dict 变成了 Organization,点进去就能看到它有哪些属性、哪些规则。

country 只有读属性没有写属性:国家代码在构造之后不该再变,这条规则以前只能靠大家自觉,现在写进了类里。

还有一个不那么显眼的收获在测试上。重构前想测“国家名怎么显示”,得调用 report_line 或 welcome_mail,再从整句话里找国家名;重构后可以直接测 Organization("某社", "FR").country_name == "FR",一行一个规则。规则有了自己的家,也就有了单独测试它的地方。

  1. 对保存记录的变量做封装变量(第 8 章),让所有访问都经过函数。本章的例子里,记录是作为参数传来传去的,创建记录已经只有 make_org 一个入口,相当于这一步已经做好了;如果你的代码里到处都在直接写 {"name": ..., "country": ...},先补一个这样的入口。跑测试。
  2. 建一个类包住记录。最初它只是把字典存在一个私有字段里,提供一个返回原字典的方法,让还没改的调用方继续能用。跑测试。
  3. 为每个字段加读属性,有需要写的再加写属性。
  4. 逐个把调用方从 org["name"] 改成 org.name,每改一个跑一次测试。IDE 能帮你找到所有读写某个键的地方,但字典的键是字符串,找出来的结果要人工确认(IDE 可自动完成查找,确认要人工做)。
  5. 所有调用方都改完后,把类内部的字典换成真正的字段,删掉返回原字典的方法。跑测试。
  6. 看看有没有逻辑该搬进类里。country_name 就是在这一步从两个模块里收进来的。

嵌套的记录(顾客字典里套着地址字典)按同样的步骤从外到里做:先封装外层,让外层的读属性返回内层字典;再封装内层,让外层返回内层对象。返回内层对象时要想清楚调用方能不能改它:不该改就返回副本,或者把内层做成不可变的值对象(第 33 章)。

示意图(不对应本章代码):嵌套记录封装之后,Customer 持有一个 Address,调用方经 Customer 的属性拿到地址,改地址要通过 Customer 的方法

封装记录:用一个类替换被多处直接访问的记录(字典、对象字面量、命名元组),把字段变成属性,访问逻辑放进类里。Fowler 给的判断标准是“可变的记录”:只读、生命周期很短的记录,比如一个函数内部拼出来马上用掉的字典,用不着封装;会被修改、会被传到很多地方的记录,就该封装。

它最常出现在三个地方。一是从配置文件、数据库、接口读进来的数据,刚读进来时是字典,在代码里传了一圈之后,每个读它的地方都知道它的结构。二是“派生值”开始在多处重复计算的时候,本章的国家显示名就是这样。三是需要校验的时候:一旦某个字段有了“不能为空”“必须为正”这类规则,就需要一个能拦住非法写入的地方。

Python 的 @dataclass 和 TS 的 class 都让封装记录的成本很低。如果一个记录只需要字段、不需要规则和行为,@dataclass(frozen=True) 或者 TS 的 readonly 接口就够了,这时它其实是一个值对象,见第 33 章。

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 对象,最常见的做法是直接在组件里读 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 在运行时就是普通字典,不能有方法。只需要类型提示时用它,需要规则时用类。

只在一个函数里存活的临时字典不需要封装,比如拼好马上交给 json.dumps 的那种。封装的回报来自“被多处读写”和“有规则要守”,两样都没有,就是给读者多加一层。

也别为每个字段机械地写一对读写方法。Python 里如果一个字段既不需要校验也不需要派生,直接用普通属性,等有了规则再换成 @property,调用方的写法不用变,这是 Python 属性的好处。TS 同理,readonly 公共字段比一对什么都不做的 getter 和 setter 更清楚。

封装之后的类要不要做成不可变的?看记录在业务里是否会“改”。机构会改名,所以 Organization 保留了一个带校验的写属性;而地址、金额这类值,换一个就是换一个,做成不可变的更省心。拿不准时先做成不可变的,等真有修改的需求再加写属性,反过来就难多了:一个已经被到处修改的对象,想收回修改权,得先找到所有修改它的地方。

手法 它关心什么 封装记录关心什么
封装变量(第 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、d
import 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、d
import 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 接口就够了。