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。