跳到主要内容

重新组织数据:一个变量只干一件事

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. 可带走的

  1. 变量被赋值第二次之前,先问:它是不是在干第二件事?
  2. 循环累加变量不要拆。
  3. 参数不该当输出用:拆出 result,判断用原始输入。
  4. 发现「代码重复」之前,先检查是不是「数据重复」——同步两处数据比同步两处代码更阴险。
  5. 删可算字段前,先用断言验证「缓存值==现算值」。
  6. 选引用还是值,只看一个问题:多方需要看见彼此的修改吗?
  7. 需要共享可变实体时,建仓库:一个 ID 只有一个对象。
  8. 全局仓库是强力的药物,剂量要小。

10. 原文地图

主题原书章原文位置
温床第9章 开篇text/17-ch09.txt:4(搜「催生混乱和bug的温床」)
两次赋值=两个责任第9章 §9.1text/17-ch09.txt:34(搜「承担了一个以上的责任」)
苏格兰布丁第9章 §9.1text/17-ch09.txt:73(搜「丑陋的小东西」) · text/17-ch09.txt:86(搜「primaryAcceleration」)
参数的两用第9章 §9.1text/17-ch09.txt:137(搜「两个用途」)
Brooks 引语第9章 §9.2text/17-ch09.txt:179(搜「Fred Brooks」)
可变数据是错误源头第9章 §9.3text/17-ch09.txt:316(搜「最大的错误源头」)
忘了更新派生变量第9章 §9.3text/17-ch09.txt:321(搜「忘了更新派生变量」)
数据的重复第9章 §9.3text/17-ch09.txt:355(搜「数据的重复」)
断言验证猜想第9章 §9.3text/17-ch09.txt:359(搜「可以即时计算」)
对象风格 vs 函数风格第9章 §9.3text/17-ch09.txt:327(搜「对象风格」)
引用/值更新之别第9章 §9.4text/17-ch09.txt:466(搜「换整个内部对象」)
值对象的好处第9章 §9.4text/17-ch09.txt:469(搜「不可变的数据结构处理起来更容易」)
该用引用的判据第9章 §9.4text/17-ch09.txt:474(搜「看见对共享对象的修改」)
漏掉一个副本第9章 §9.5text/17-ch09.txt:601(搜「漏掉一个副本」)
仓库第9章 §9.5text/17-ch09.txt:605(搜「某种形式的仓库」)
5 个订单 / 让我紧张第9章 §9.5text/17-ch09.txt:634(搜「ID为123」) · text/17-ch09.txt:637(搜「让我紧张」)
强力的药物第9章 §9.5text/17-ch09.txt:685(搜「强力的药物」)

Footnotes

  1. 出处:「重新组织数据」第 4 段(text/17-ch09.txt:4,搜「催生混乱和bug的温床」)。

  2. 出处:「重新组织数据」第 34 段(text/17-ch09.txt:34,搜「承担了一个以上的责任」)与第 35 段(text/17-ch09.txt:35,搜「每个变量只承担一个责任」)。

  3. 出处:「重新组织数据」第 73 段(text/17-ch09.txt:73,搜「丑陋的小东西」);改名后的代码在第 84-121 段(text/17-ch09.txt:86,搜「primaryAcceleration」)。

  4. 出处:「重新组织数据」第 137 段(text/17-ch09.txt:137,搜「两个用途」)。

  5. 出处:「重新组织数据」第 179 段(text/17-ch09.txt:179,搜「Fred Brooks」)与第 180 段(text/17-ch09.txt:180,搜「柳暗花明」)。

  6. 出处:「重新组织数据」第 316 段(text/17-ch09.txt:316,搜「最大的错误源头」)。 2

  7. 出处:「重新组织数据」第 321 段(text/17-ch09.txt:321,搜「忘了更新派生变量」)。

  8. 出处:「重新组织数据」第 355 段(text/17-ch09.txt:355,搜「数据的重复」)。

  9. 出处:「重新组织数据」第 359 段(text/17-ch09.txt:359,搜「可以即时计算」)。

  10. 出处:「重新组织数据」第 327 段(text/17-ch09.txt:327,搜「对象风格」)。 2

  11. 出处:「重新组织数据」第 466 段(text/17-ch09.txt:466,搜「换整个内部对象」)。

  12. 出处:「重新组织数据」第 469 段(text/17-ch09.txt:469,搜「不可变的数据结构处理起来更容易」)。 2

  13. 出处:「重新组织数据」第 474 段(text/17-ch09.txt:474,搜「看见对共享对象的修改」)。

  14. 出处:「重新组织数据」第 601 段(text/17-ch09.txt:601,搜「漏掉一个副本」)。

  15. 出处:「重新组织数据」第 604 段(text/17-ch09.txt:604,搜「对于一个客观实体」)与第 605 段(text/17-ch09.txt:605,搜「某种形式的仓库」)。

  16. 出处:「重新组织数据」第 634 段(text/17-ch09.txt:634,搜「ID为123」)与第 637 段(text/17-ch09.txt:637,搜「让我紧张」)。

  17. 出处:「重新组织数据」第 685 段(text/17-ch09.txt:685,搜「强力的药物」)。 2