以查询取代派生变量
以查询取代派生变量(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两版的输出完全一样。区别在撤销那种场景:在重构后的版本上执行 plan.adjustments.pop(),production 立刻变成 120,因为它根本没有存,每次都从调整记录算出来。
production 现在每次从 adjustments 现算,无论谁用什么方式改了调整记录,总产量都是对的。apply_adjustment 只剩一行。
怎么一步步做
Section titled “怎么一步步做”- 找出所有更新派生字段的地方。
- 写一个计算派生值的函数。
- 用引入断言(第 40 章)在派生字段被读取的地方检查它和计算结果是否一致:
assert self.production == self._calculated_production()。跑测试,确认在测试覆盖的场景里二者一致。 - 把读取派生字段的地方改成调用计算函数。跑测试。
- 用移除死代码(第 28 章)删掉派生字段和所有更新它的代码。跑测试。
第 3 步是可选的,但很有用:断言会在测试里抓出“本来就不一致”的情况,就像本章的撤销问题。
定义与适用场景
Section titled “定义与适用场景”以查询取代派生变量:删除一个可以由其他数据计算出来的字段,在需要时调用计算函数。适用的信号是:一个字段的值总能从别的字段算出来,而且有不止一处代码负责更新它。总额、计数、平均值、状态标志(“是否已完成”可以从各子任务的状态算出)都是常见的派生值。
派生字段还有一个变种:一个状态标志,比如订单上的 is_paid,同时又有一组付款记录。只要能从付款记录判断是否付清,is_paid 就是派生的,同样会遇到不同步的问题。把它换成一个现算的属性,规则(全额付清?部分付款算不算?)也就有了唯一的定义处。
真实系统里的例子
Section titled “真实系统里的例子”购物车的总价
Section titled “购物车的总价”前端的状态管理里很常见:购物车 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,订单明细表里也能算出总额。这种冗余在数据库里有时是为了查询性能故意做的,但它带来的同步问题和代码里一模一样。如果保留它,就要让“修改明细”和“更新总额”在同一个事务里、经过同一个入口完成;更稳妥的做法是在代码里始终从明细现算,只把数据库里的冗余字段当作查询用的缓存,并定期核对。
什么时候别用
Section titled “什么时候别用”计算很贵(遍历百万条记录、查数据库),又被频繁读取时,存一份是合理的缓存。这时要把“所有修改源数据的入口”收拢到一处(封装集合,第 14 章),在那一处同步更新缓存,而不是让每个调用方自己记得。
- 能从别的数据算出来的值,不存,现算。
- 存两份的同一个事实迟早不一致;现算的值永远和源数据一致。
- 迁移时可以先加断言检查派生字段和计算结果是否一致,它常常会抓出现有的 bug。
- 计算很贵时才缓存,并把所有修改入口收拢到一处统一更新。