跳到主要内容

API 的关节:谁承担获取数据的责任

1. 这一章讲什么

前几章的手法都在函数体内部或类与类之间施展,这一章面向函数的边界——调用者看到什么、传什么、拿到什么。原书的定位一句话:「模块和函数是软件的骨肉,而API则是将骨肉连接起来的关节」;关节好,加新部件容易;关节糟,「当需求变化时难以找到合适的地方进行修改」(第 05 章「关节」之说的 API 篇)1。本章手法的总决策线只有一个:某项责任,放在调用方还是被调方?

2. 顶层全景

职责切分:查询(只取值,无副作用) ⟂ 修改(改状态) → 查询修改分离
假差异: 一个参数让函数「变身」 → 移除标记参数
数据进出:拆开的几个值 → 传整个对象 → 保持对象完整
责任摇摆:函数自己取(查询) ↔ 调用方传入(参数) → 以查询/参数互取代
创建口: 构造后不可变;创建方式要灵活 → 移除设值函数 / 工厂函数
函数太大:拆解为对象(命令),但 95% 时候用函数就够 → 以命令/函数互取代

图说:六组手法都围绕「函数签名」这个合同的条款展开。

3. 查询与修改分离:CQS

只提供值、没有任何看得到副作用的函数是宝贝:「我可以任意调用这个函数,也可以把调用动作搬到调用函数的其他地方」——顺序无所谓、测试容易,「需要操心的事情少多了」2。由此引出一条著名规则:命令与查询分离(CQS):「任何有返回值的函数,都不应该有看得到的副作用」。作者对它的态度值得原样记录:「我并不绝对遵守它,不过我总是尽量遵守,而它也回报我很好的效果」——是强默认,不是教条3

边界案例也交代了:把查询结果缓存(把算过的结果存起来复用)在字段里,虽然改了对象状态,但「这一修改是察觉不到的,因为不论如何查询,总是获得相同结果」——不算违反4。原书的反例函数名叫 getTotalOutstandingAndSendBill:算欠款总额还顺手寄账单,拆成 totalOutstanding()sendBill() 两个,各回各家。

4. 假差异:移除标记参数

标记参数(flag argument)是调用者传进去、用来指示被调函数走哪部分逻辑的参数:bookConcert(aCustomer, isPremium)。作者不喜欢它的理由直指 API 的可读性:「标记参数却隐藏了函数调用中存在的差异性」;布尔型最糟,「在调用一个函数时,我很难弄清true到底是什么意思」——拆成 premiumBookConcert(aCustomer) 和普通版,每个函数只干一件事,读调用代码就不用猜了5

两个判定,防矫枉过正:调用者传的是程序中流动的数据(变量),不算;参数值只作为数据往下传、不影响函数内部的控制流(即程序在运行中选择走哪条路的机制),也不算——「只有调用者直接传入字面量值」「只有参数值影响了函数内部的控制流」才是标记参数6。原书例子:deliveryDate(anOrder, isRush) 的加急版比普通版少等几天(MA、NY 等州各有时限),true/false 的调用散落多处,拆成 rushDeliveryDate 与常规版。多个标记参数同时出现是个信号:「说明这个函数可能做得太多」7

5. 数据的进出:保持对象完整,参数与查询互推

保持对象完整:看到调用方从一个记录里抽出几个值、再一起传给函数,改成把整个记录传过去,让函数自己取——「传递整个记录」能应对变化(以后要多取几个字段,签名不用动),还能缩短参数列表8。它还有诊断含义:抽几个值出来单独做逻辑,本身就是坏味道「依恋情结」,常暗示逻辑该搬进对象;甚至调用者传自己的若干字段时,可以直接把 this 传过去。

剩下的问题是参数数量的进出,这是一对互为反向的手法,共用一条判据——「同样容易」:

  • 以查询取代参数:传入的值函数自己拿也「同样容易」时,把参数去掉。「同样容易」四个字划出界限:「去除参数也就意味着“获得正确的参数值”的责任被转移」——作者默认偏向简化调用方,但函数承担不起时就不做9。最安全的场景:「如果可以从一个参数推导出另一个参数,那么几乎没有任何理由要同时传递这两个参数」10
  • 以参数取代查询:函数体里引用了全局变量等「令人不快的引用关系」时,改成参数传入,把获取的责任交还调用者11。收益是引用透明性(给定相同参数,永远得到相同结果——这样的函数易理解、易测试),常见形态是「负责逻辑处理的模块中只有纯函数,其外再包裹处理I/O和其他可变元素的逻辑代码」12

这对反向手法的存在本身,就是作者最诚实的一句话:「归根到底,这是关于程序中责任分配的问题,而这方面的决策既不容易,也不会一劳永逸」——所以才需要两个方向都会走13

6. 收紧创建口:设值函数与工厂函数

移除设值函数的逻辑起点是:为字段提供设值函数,「就暗示这个字段可以被改变」;不想让它被改,最直接的表达就是不给——字段只能在构造函数里赋值14。两个典型场景:构造函数是设值函数的唯一使用者(自封装过度);以及「创建脚本」——先构造、再连串 set 的对象创建方式,创建完就不该再改。有一条放弃条件要记住:需要更新一个多处共享引用的对象时(无法用「造新对象」替代改旧对象),这个手法不适用15

以工厂函数取代构造函数针对构造函数的「丑陋的局限性」,作者列了三条:「只能返回当前所调用类的实例」(给不了子类或代理)、「名字是固定的」、「需要通过特殊的操作符来调用」(new)16。工厂函数是普通函数,三条限制全无——第 01 章多态计算器必须经过 createPerformanceCalculator 的原因就在这条。原书员工例子里,new Employee(name, 'E') 变成 createEngineer(name),调用点的意图从「造个员工、类型码是 E」变成「造个工程师」。

7. 函数太大:命令对象,以及为什么少用

把一个复杂函数封装成只服务它的对象,叫命令对象(command object):参数变成字段、执行变成方法,于是可以支持撤销、分步构造、用继承定制行为,「即便编程语言本身并不支持嵌套函数,我也可以借助命令对象的方法和字段把复杂的函数拆解开」17。但作者立刻把账算清:「命令对象的灵活性也是以复杂性作为代价的。所以,如果要在作为一等公民的函数和命令对象之间做个选择,95%的时候我都会选函数」——只有特别需要那几种能力时才换18。他还特意清理了一个词义坑:「命令」一词「承载了太多含义」:命令模式里的对象、CQS 里改状态的函数,是两回事,书中用「修改函数」称呼后者19

顺带一个语言注脚:JavaScript 把函数当一等公民是「它最正确的设计决策之一」——本章一大半手法在其他语言里是为弥补「函数不是公民」而生的,JS 里可以少用20

8. 作者的判断与证据

判断证据
CQS 是强默认不是铁律作者自述「并不绝对遵守」并给出缓存反例3
责任分配没有一劳永逸的答案一对互逆手法并列给出的结构性理由13
函数优于命令对象(95%)明确的比例表态+适用条件列举18
构造函数三大局限是语言层的,不是风格层的逐一列举 Java/多数 OO 语言的事实约束16

判断(我们的,不是书里的): 本章和第 05 章的「改变函数声明」合起来,给出了全书少有的「可逆性」设计观:签名与责任的决策都假定自己会错,手法存在的意义就是让改错便宜。这和第 12 章的 YAGNI(不为猜想的未来加灵活性)是同一枚硬币——既然猜不对,就让「猜错后调整」变便宜。如果错,会错在: 对外发布的 API 没有「调用方可控」的前提时,调整成本由下游承担,可逆性设计会失效(见第 12 章 published interface 的约束)。

9. 边界与局限

  • 查询改参数会让函数丢失缓存机会;参数取代查询后,每个调用者都要重复获取值的代码。
  • 移除设值函数在共享可变对象上明确不适用(放弃条件)15
  • 工厂函数的多态创建在类型码值来自外部数据时有校验问题:非法类型码的报错位置从构造函数移到了工厂,原书未展开错误处理的设计。
  • 「95% 选函数」的剩余 5% 场景(撤销、生命周期、拆解超长函数)需要工程判断,书里只给了方向。

10. 可带走的

  1. 有返回值的函数尽量别有副作用;做不到无副作用,至少别混在一个函数里。
  2. 布尔参数是「读调用代码的人的谜题」——拆成两个函数。
  3. 传字面量 true/false 才叫标记参数;传数据变量不算。
  4. 能从已传参数推导出来的参数,删掉。
  5. 抽三四个字段传出去之前,考虑传整个对象,或把逻辑搬进对象。
  6. 想要引用透明性,把全局依赖变成参数;想简化调用方,反向操作——两个方向都要会。
  7. 对象创建后不该再变的字段,删掉设值函数。
  8. 需要「造哪种子类」的灵活性时,用工厂函数挡在构造函数前面。
  9. 拆超长函数优先提炼函数;命令对象留给撤销、分步这类真需求。

11. 原文地图

主题原书章原文位置
骨肉与关节第11章 开篇text/19-ch11-11-api.txt:3(搜「骨肉」)
CQS 规则与作者态度第11章 §11.1text/19-ch11-11-api.txt:49(搜「任何有返回值的函数」) · text/19-ch11-11-api.txt:51(搜「并不绝对遵守它」)
缓存不算副作用第11章 §11.1text/19-ch11-11-api.txt:58(搜「察觉不到的」)
标记参数的害处第11章 §11.3text/19-ch11-11-api.txt:342(搜「隐藏了函数调用中存在的差异性」) · text/19-ch11-11-api.txt:344(搜「true到底是什么意思」)
标记参数判定第11章 §11.3text/19-ch11-11-api.txt:350(搜「字面量值」) · text/19-ch11-11-api.txt:351(搜「控制流」)
传整个记录第11章 §11.4text/19-ch11-11-api.txt:505(搜「把整个记录传给这个函数」)
「同样容易」判据第11章 §11.5text/19-ch11-11-api.txt:732(搜「同样容易」) · text/19-ch11-11-api.txt:725(搜「总结该函数的可变性」)
参数可推导就别双传第11章 §11.5text/19-ch11-11-api.txt:744(搜「同时传递这两个参数」)
令人不快的引用第11章 §11.6text/19-ch11-11-api.txt:836(搜「令人不快的引用关系」)
纯函数模块第11章 §11.6text/19-ch11-11-api.txt:850(搜「纯函」)
责任分配第11章 §11.6text/19-ch11-11-api.txt:856(搜「责任分配的问题」)
设值函数的暗示第11章 §11.7text/19-ch11-11-api.txt:987(搜「暗示这个字段可以被改变」) · text/19-ch11-11-api.txt:996(搜「创建脚本」)
放弃条件第11章 §11.7text/19-ch11-11-api.txt:1013(搜「请放弃本重构」)
构造函数三局限第11章 §11.8text/19-ch11-11-api.txt:1075(搜「丑陋的局限性」) · text/19-ch11-11-api.txt:1076(搜「只能返回当前所调用类的实例」)
95% 选函数第11章 §11.9text/19-ch11-11-api.txt:1196(搜「95%的时候我都会选函数」)
命令的多义第11章 §11.9text/19-ch11-11-api.txt:1199(搜「承载了太多含义」)
JS 一等公民第11章 §11.9text/19-ch11-11-api.txt:1221(搜「一等公民对待」)

Footnotes

  1. 出处:「重构API」第 3 段(text/19-ch11-11-api.txt:3,搜「骨肉」)。

  2. 出处:「重构API」第 46 段(text/19-ch11-11-api.txt:46,搜「需要操心的事情少多了」)。

  3. 出处:「重构API」第 49 段(text/19-ch11-11-api.txt:49,搜「任何有返回值的函数」)与第 51 段(text/19-ch11-11-api.txt:51,搜「并不绝对遵守它」)。 2

  4. 出处:「重构API」第 58 段(text/19-ch11-11-api.txt:58,搜「察觉不到的」)。

  5. 出处:「重构API」第 342 段(text/19-ch11-11-api.txt:342,搜「隐藏了函数调用中存在的差异性」)与第 344 段(text/19-ch11-11-api.txt:344,搜「true到底是什么意思」)。

  6. 出处:「重构API」第 350 段(text/19-ch11-11-api.txt:350,搜「字面量值」)与第 351 段(text/19-ch11-11-api.txt:351,搜「控制流」)。

  7. 出处:「重构API」第 358 段(text/19-ch11-11-api.txt:358,搜「做得太多」)。

  8. 出处:「重构API」第 505 段(text/19-ch11-11-api.txt:505,搜「把整个记录传给这个函数」)。

  9. 出处:「重构API」第 732 段(text/19-ch11-11-api.txt:732,搜「同样容易」);参数列表的定位在第 725 段(text/19-ch11-11-api.txt:725,搜「总结该函数的可变性」)。

  10. 出处:「重构API」第 744 段(text/19-ch11-11-api.txt:744,搜「同时传递这两个参数」)。

  11. 出处:「重构API」第 836 段(text/19-ch11-11-api.txt:836,搜「令人不快的引用关系」)。

  12. 出处:「重构API」第 850 段(text/19-ch11-11-api.txt:850,搜「纯函」);引用透明性定义在第 747 段(text/19-ch11-11-api.txt:747,搜「传入相同的参数值」)。

  13. 出处:「重构API」第 856 段(text/19-ch11-11-api.txt:856,搜「责任分配的问题」)。 2

  14. 出处:「重构API」第 987 段(text/19-ch11-11-api.txt:987,搜「暗示这个字段可以被改变」)。

  15. 出处:「重构API」第 1013 段(text/19-ch11-11-api.txt:1013,搜「请放弃本重构」)。 2

  16. 出处:「重构API」第 1075 段(text/19-ch11-11-api.txt:1075,搜「丑陋的局限性」)与第 1077-1079 段(text/19-ch11-11-api.txt:1076,搜「只能返回当前所调用类的实例」)。 2

  17. 出处:「重构API」第 1192 段(text/19-ch11-11-api.txt:1192,搜「复杂的函数拆解开」)。

  18. 出处:「重构API」第 1195-1197 段(text/19-ch11-11-api.txt:1196,搜「95%的时候我都会选函数」)。 2

  19. 出处:「重构API」第 1199 段(text/19-ch11-11-api.txt:1199,搜「承载了太多含义」)。

  20. 出处:「重构API」第 1221 段(text/19-ch11-11-api.txt:1221,搜「一等公民对待」)。