重新组织 数据:一个变量只干一件事
1. 这一章讲什么
前几章的手法大多围绕函数,这一章回到数据本身。原书开篇的判断是:把一个值用于多个不同的用途,「这就是催生混乱和bug的温床」1。本章四组手法各管一件事:变量被赋予多个责任(拆分变量)、字段名字骗人(字段改名)、存了其实能算的东西(派生变量改查询)、同一份数据该复制还是共享(值对象与引用对象互转)。
2. 顶层全景
一个变量被赋值多次 ──→ 它在干几件事? ──→ 拆分变量(循环变量/收集器除外)
存着的值随时能算 ──→ 派生变量 → 查询函数(先用断言验证"能算")
数据嵌在别的对象里 ──┬─ 要多方看见彼此的修改 ──→ 引用对象(一份,大家指过去)
└─ 各自独立、只读最省心 ──→ 值对象(整个换新)
图说:三行判据层层递进——先管好一个变量,再管一个字段,最后管一份共享数据。
3. 拆分变量:赋值两次就是两个责任
变量有多种正当的多值用途:循环变量(每轮自增的 i)和结果收集变量(一路累加的 total)。除这些之外,「如果它们被赋值超过一次,就意味它们在函数中承担了一个以上的责任」;而「如果变量承担多个责任,它就应该被替换(分解)为多个变量,每个变量只承担一个责任」2。
主走查(苏格兰布丁,数据为原书示例): 函数算一个被两次加速的物体的运动距离。变量 acc 先后存了两个东西:第一个力造成的初始加速度 primaryForce / mass,和两个力合力后的加速度 (primaryForce + secondaryForce) / mass——作者的评价:「真是个丑陋的小东西」3。拆法是逐段改名:第一段赋值处把 acc 改名为 primaryAcceleration 并声明为 const(不能改,逼你确认它只赋值一次),引用一并替换;第二次赋值处重新声明 acc,再把这一段也改名 secondaryAcceleration。拆完的副产品是可读性自然浮现:公式的两段(第一段力作用时间、第二段合力作用时间)各用各的加速度,物理含义一目了然。走查里还有一个细节判据:循环累加(「i = i + 表达式」形态)是收集变量,不要拆。
对参数赋值是同味变体:discount(inputValue, quantity) 里 inputValue 既是输入又当输出——「它既是函数的输入,也负责把结果带回给调用方」(JavaScript 按值传参,改参数不影响调用方,所以这个「带回」是错觉)4。拆成 inputValue(原始输入,判断用它)和 result(结果,修改它):判断的基准永远是原始输入,语义更诚实。
字段改名是数据侧最常用的手法,动机引了 Fred Brooks 的名言:只给我看业务的处理步骤、却把数据的样子(他叫「表单」)藏着,仍然一头雾水;把数据的样子给我看,不需要流程图也能柳暗花明——结论:数据结构是理解程序行为的关键,值得为它费改名功夫5。操作顺序有讲究:先封装记录,再改内部字段、再改访问函数——广为使用的裸记录不能直接改名。
4. 能算出来的,别存着:派生变量改查询
「可变数据是软件中最大的错误源头之一」——本节的出发点6。如果某个字段随时能从别的字段算出来,把它换成计算函数,「也算朝着消除可变性的方向迈出了一大步」,而且避免了「源数据修改时忘了更新派生变量」这类 bug7。原书的反面例子是生产计划:每次添加调整量,既往数组里 push 一条记录,又手工把 _production 累加一遍——「我看到的丑陋之处是重复——不是常见的代码重复,而是数据的重复」:同一事实存了两处,必须人肉保持同步8。
作者的谨慎值得学:「可以即时计算」只是猜想,动手前先用断言验证——加一句「断言缓存值 == 现算值」跑一段时间,确认两者永远相等,再删掉字段9。例外也说清了:源数据不可变、算出的结果也不变时,存着无妨。这里藏着两种风格的分野:对象风格(派生属性包在对象里,随源数据更新)与函数风格(把一份数据变换成另一份数据)——「如果源数据会被修改……对象风格显然更好。但如果源数据不可变,或者派生数据用过即弃,那么两种风格都可行」10。(这正是第 05 章「组合成类 vs 组合成变换」判据的数据侧版本。)
5. 一份数据的两副面孔:引用对象与值对象
同一个内部对象,可以当引用对象也可以当值对象,差别只在更新方式:当引用,「保留原对象不动,更新内部对象的属性」;当值,「替换整个内部对象,新换上的对象会有我想要的属性值」11。原书例子:商品打折,引用式是 this._price.amount -= arg(原地改),值式是 this._price = new Money(新数额, 币种)(整个换新)。
值对象通常更可取,因为它不可变:「不可变的数据结构处理起来更容易」——可以放心传给程序任何角落而不怕被偷改,可以随便复制而不用管内存关系,在分布式与并发(多段代码同时执行)场景尤其有用12。但反向判据必须先查:「如果我想在几个对象之间共享一个对象,以便几个对象都能看见对共享对象的修改,那么这个共享的对象就应该是引用」13。
6. 从值到引用:仓库登 场
什么时候必须把值改成引用?「如果共享的数据需要更新,将其复制多份的做法就会遇到巨大的困难」——必须找到所有副本逐一更新,「只要漏掉一个副本没有更新,就会遭遇麻烦的数据不一致」14。改法带来一个定义性的后果:「对于一个客观实体,只有一个代表它的对象」;而要做到这一点,需要「某种形式的仓库」——所有实体对象登记在册、每个只创建一次,此后一律从仓库取15。
原书走查很具体:订单数据里存顾客 ID,构造函数直接 new Customer(data.customer)——ID 为 123 的顾客若有 5 张订单,内存里就有 5 个互不知情的 Customer 对象;「重复的对象总是会让我紧张」,可变时更危险,因为各副本数据可能已经不一致16。改造:建一个模块级仓库,registerCustomer(id) 用 Map 注册——有则复用、无则创建;订单构造函数改为 registerCustomer(data.customer)。从此改一处顾客信息,5 张订单同时生效。作者对仓库本身也留了警惕:模块级全局仓库「就像强力的药物,少用一点儿大有益处,用过量就是毒药」17。
7. 作者的判断与证据
| 判断 | 证据 |
|---|---|
| 可变数据是最大错误源之一 | 全章立论;函数式编程整座建在相反前提上(作者引述)6 |
| 派生值应即算,除非不可变 | 给出对象/函数两种风格的适用边界10 |
| 值对象默认优于引用对象,除非要共享可变状态 | 给出分布式/并发的适用理由12 |
| 值改引用=引入全局状态,慎用 | 「强力的药物」警示,与第 03 章全局数据味道相互印证17 |
判断(我们的,不是书里的): 本章与第 04 章有一处暗合值得点破:「先用断言验证猜想再动手」和「测试不该通过时要真的失败」是同一种科学习惯——先让假设可检验,再改代码。重构手法里凡是「动之前」,几乎都有这么一道验证工序(改变函数声明的断言抓漏、替换算法的测试固定)。如果错,会错在: 如果某项目测试基建为零,这些「动之前」的工序全都执行不了,手法只剩一半可安全使用。
8. 边界与局限
- 拆分变量在「收集变量」上的禁令要记牢:把累加变量按两责拆掉,结果就错了。
- 派生变量的断言验证需要代码里允许临时加断言;强类型语言里还有编译器帮忙,纯动态语言更依赖测试。
- 值改引用引入全局仓库,并发与测试隔离都会变难——作者只给了一句「强力药物」的警示,具体代价要在工程里自己掂量。
- 「一个实体一个对象」在分布式系统里只在单库边界内成立;跨服务的「仓库」超出本书范围(补充,不在书里,来自通用知识)。
9. 可带走的
- 变量被赋值第二次之前,先问:它是不是在干第二件事?
- 循环累加变量不要拆。
- 参数不该当输出用:拆出 result,判断用原始输入。
- 发现「代码重复」之前,先检查是不是「数据重复」——同步两处数据比同步两处代码更阴险。
- 删可算字段前,先用断言验证「缓存值==现算值」。
- 选引用还是值,只看一个问题:多方需要看见彼此的修改吗?
- 需要共享可变实体时,建仓库:一个 ID 只有一个对象。
- 全局仓库是强力的药物,剂量要小。
10. 原文地图
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| 温床 | 第9章 开篇 | text/17-ch09.txt:4(搜「催生混乱和bug的温床」) |
| 两次赋值=两个责任 | 第9章 §9.1 | text/17-ch09.txt:34(搜「承担了一个以上的责任」) |
| 苏格兰布丁 | 第9章 §9.1 | text/17-ch09.txt:73(搜「丑陋的小东西」) · text/17-ch09.txt:86(搜「primaryAcceleration」) |
| 参数的两用 | 第9章 §9.1 | text/17-ch09.txt:137(搜「两个用途」) |
| Brooks 引语 | 第9章 §9.2 | text/17-ch09.txt:179(搜「Fred Brooks」) |
| 可变数据是错误源头 | 第9章 §9.3 | text/17-ch09.txt:316(搜「最大的错误源头」) |
| 忘了更新派生变量 | 第9章 §9.3 | text/17-ch09.txt:321(搜「忘了更新派生变量」) |
| 数据的重复 | 第9章 §9.3 | text/17-ch09.txt:355(搜「数据的重复」) |
| 断言验证猜想 | 第9章 §9.3 | text/17-ch09.txt:359(搜「可以即时计算」) |
| 对象风格 vs 函数风格 | 第9章 §9.3 | text/17-ch09.txt:327(搜「对象风格」) |
| 引用/值更新之别 | 第9章 §9.4 | text/17-ch09.txt:466(搜「换整个内部对象」) |
| 值对象的好处 | 第9章 §9.4 | text/17-ch09.txt:469(搜「不可变的数据结构处理起来更容易」) |
| 该用引用的判据 | 第9章 §9.4 | text/17-ch09.txt:474(搜「看见对共享对象的修改」) |
| 漏掉一个副本 | 第9章 §9.5 | text/17-ch09.txt:601(搜「漏掉一个副本」) |
| 仓库 | 第9章 §9.5 | text/17-ch09.txt:605(搜「某种形式的仓库」) |
| 5 个订单 / 让我紧张 | 第9章 §9.5 | text/17-ch09.txt:634(搜「ID为123」) · text/17-ch09.txt:637(搜「让我紧张」) |
| 强力的药物 | 第9章 §9.5 | text/17-ch09.txt:685(搜「强力的药物」) |