跳转到内容

以查询取代派生变量

以查询取代派生变量(Replace Derived Variable with Query)去掉一个保存着计算结果的字段,在需要时现算。 它解决的痛是:派生值和它的源数据要靠人记得同步,有一处修改了源数据却忘了更新派生值,两者就对不上了。

生产计划类 ProductionPlan 里有一组调整记录,每条记录加减一些产量。类上还有一个 production 字段存着当前总产量,每次加调整时顺手累加一下。

"""生产计划:重构前。"""
class ProductionPlan:
def __init__(self, base: int) -> None:
self.base = base
self.adjustments: list[int] = []
self.production = base
def apply_adjustment(self, amount: int) -> None:
self.adjustments.append(amount)
self.production += amount
if __name__ == "__main__":
plan = ProductionPlan(100)
plan.apply_adjustment(20)
plan.apply_adjustment(-5)
print(plan.production)
# 输出:
# 115
// 生产计划:重构前。
class ProductionPlan {
adjustments: number[] = [];
production: number;
constructor(readonly base: number) {
this.production = base;
}
applyAdjustment(amount: number): void {
this.adjustments.push(amount);
this.production += amount;
}
}
const plan = new ProductionPlan(100);
plan.applyAdjustment(20);
plan.applyAdjustment(-5);
console.log(plan.production);
// 输出:
// 115

如果现在要加一个需求:允许撤销最后一条调整。写的人很自然地写下:

plan.adjustments.pop()

调整记录只剩 +20,总产量本该是 120,production 却还是 115。撤销的代码只改了列表,不知道还得同步 production。它必须写成两行,一行改列表、一行减产量,而且每个将来修改调整记录的功能(修改某条调整、清空调整、从文件导入调整)都得记得这两行。同一个事实存了两份,靠人记得同步,迟早会不一致。

这是可变数据最隐蔽的样子:数据之间有依赖关系,但这个关系只活在写代码的人脑子里。

"""生产计划:重构后。"""
class ProductionPlan:
def __init__(self, base: int) -> None:
self.base = base
self.adjustments: list[int] = []
@property
def production(self) -> int:
return self.base + sum(self.adjustments)
def apply_adjustment(self, amount: int) -> None:
self.adjustments.append(amount)
if __name__ == "__main__":
plan = ProductionPlan(100)
plan.apply_adjustment(20)
plan.apply_adjustment(-5)
print(plan.production)
# 输出:
# 115
// 生产计划:重构后。
class ProductionPlan {
adjustments: number[] = [];
constructor(readonly base: number) {}
get production(): number {
return this.base + this.adjustments.reduce((sum, a) => sum + a, 0);
}
applyAdjustment(amount: number): void {
this.adjustments.push(amount);
}
}
const plan = new ProductionPlan(100);
plan.applyAdjustment(20);
plan.applyAdjustment(-5);
console.log(plan.production);
// 输出:
// 115

重构前 apply_adjustment 和外部撤销各自改数据,production 要靠人同步;重构后 production 每次从 adjustments 现算,不会不一致

两版的输出完全一样。区别在撤销那种场景:在重构后的版本上执行 plan.adjustments.pop(),production 立刻变成 120,因为它根本没有存,每次都从调整记录算出来。

production 现在每次从 adjustments 现算,无论谁用什么方式改了调整记录,总产量都是对的。apply_adjustment 只剩一行。

  1. 找出所有更新派生字段的地方。
  2. 写一个计算派生值的函数。
  3. 用引入断言(第 40 章)在派生字段被读取的地方检查它和计算结果是否一致:assert self.production == self._calculated_production()。跑测试,确认在测试覆盖的场景里二者一致。
  4. 把读取派生字段的地方改成调用计算函数。跑测试。
  5. 用移除死代码(第 28 章)删掉派生字段和所有更新它的代码。跑测试。

第 3 步是可选的,但很有用:断言会在测试里抓出“本来就不一致”的情况,就像本章的撤销问题。

以查询取代派生变量:删除一个可以由其他数据计算出来的字段,在需要时调用计算函数。适用的信号是:一个字段的值总能从别的字段算出来,而且有不止一处代码负责更新它。总额、计数、平均值、状态标志(“是否已完成”可以从各子任务的状态算出)都是常见的派生值。

派生字段还有一个变种:一个状态标志,比如订单上的 is_paid,同时又有一组付款记录。只要能从付款记录判断是否付清,is_paid 就是派生的,同样会遇到不同步的问题。把它换成一个现算的属性,规则(全额付清?部分付款算不算?)也就有了唯一的定义处。

前端的状态管理里很常见:购物车 store 里除了商品列表,还存着一个 total 字段,每次加商品、删商品、改数量都要更新它。Vue 的 computed、Redux 的 selector、MobX 的 @computed 都是同一个思路:总价不存,现算。

// 购物车:total 是计算属性,不是字段。
interface Item {
sku: string;
price: number;
qty: number;
}
class CartStore {
private items: Item[] = [];
add(item: Item): void {
this.items.push(item);
}
setQty(sku: string, qty: number): void {
const item = this.items.find((i) => i.sku === sku);
if (item) item.qty = qty;
}
remove(sku: string): void {
this.items = this.items.filter((i) => i.sku !== sku);
}
get total(): number {
return this.items.reduce((sum, i) => sum + i.price * i.qty, 0);
}
}
const cart = new CartStore();
cart.add({ sku: "bean", price: 68, qty: 1 });
cart.add({ sku: "kettle", price: 199, qty: 1 });
cart.setQty("bean", 3);
cart.remove("kettle");
console.log(cart.total);
// 输出:
// 204

三个修改操作都不用碰 total。以后加“清空购物车”或“应用优惠券”,也不会因为忘了更新总价而出错。

数据库里也会出现派生字段:订单表上存着 total,订单明细表里也能算出总额。这种冗余在数据库里有时是为了查询性能故意做的,但它带来的同步问题和代码里一模一样。如果保留它,就要让“修改明细”和“更新总额”在同一个事务里、经过同一个入口完成;更稳妥的做法是在代码里始终从明细现算,只把数据库里的冗余字段当作查询用的缓存,并定期核对。

计算很贵(遍历百万条记录、查数据库),又被频繁读取时,存一份是合理的缓存。这时要把“所有修改源数据的入口”收拢到一处(封装集合,第 14 章),在那一处同步更新缓存,而不是让每个调用方自己记得。

  • 能从别的数据算出来的值,不存,现算。
  • 存两份的同一个事实迟早不一致;现算的值永远和源数据一致。
  • 迁移时可以先加断言检查派生字段和计算结果是否一致,它常常会抓出现有的 bug。
  • 计算很贵时才缓存,并把所有修改入口收拢到一处统一更新。