广度:那些你忘了改的地方
广度是获取一次真实修改所需共同考虑的全部构件的成本,也是确认修改闭包已经完整的成本。
本文为中文版,根据英文原版改写,而非逐句直译。
一处小改动,波及的范围可能远超预期
上一篇讨论了深度:理解一个焦点构件的结构成本。深度描述的是代码的静态结构,而广度源于另一种体验。
假设一个团队要修改一条折扣规则。
改动可能从一个显而易见的地方开始:某个定价函数、某个结账组件,或者某个策略对象。那里的代码也许很清晰,局部边界也许很可靠,函数的深度也许很低。
但这次修改仍然可能是错的。
结账界面显示了新折扣,计费任务却仍在套用旧规则;订单服务按一个版本做校验,分析流水线却按另一个版本给打折订单分类;文档里写的还是旧条件。
没有哪个构件难以理解。问题在于,这次修改要求同时考虑好几个构件,而其中一个被漏掉了。
这就是广度(breadth)。
广度是获取一次真实修改为保证正确而必须共同考虑的全部构件的成本。
它衡量的是找出这组完整构件有多难,以及确信没有遗漏有多难。
深度问的是理解一个构件有多难,广度问的是哪些构件必须一起纳入上下文。
修改闭包
大多数工程师早在为它命名之前,就已经感受过这种构件集合。写一个功能时,你发现自己要同时改好几个地方:schema 里的一列、一个校验器、一个界面字段、一个后台任务。你还会注意到,代码里没有任何地方记录这几处是相关的,它们之间的关系只存在于你此刻做出的决定里。这时警报就响了。下一个改这段代码的人,也许就是几周后的你自己,脑子里不会有这个决定,于是其中某一处就会被漏掉。
在 CMP 中,这种感觉有一个名字。
修改闭包(modification closure)是一组必须共同考虑的构件,因为一次真实的修改要求它们一起变更、一起检查或保持一致。
这里的“构件”应作宽泛理解。闭包可以包括源文件、测试、schema、迁移脚本、界面组件、API 定义、后台任务、配置、事件消费者、测试夹具、外部协议定义或文档。
闭包在形成的那一刻最容易看清。写功能或修 bug 时,你不得不把整个闭包一次性装进脑子里:你同时触及 schema、校验器、界面字段和后台任务,并留意每一处要如何配合当前的改动。在代码的整个生命周期里,再没有哪个时刻能让完整的集合如此自然地出现在同一个人的工作记忆中。
所以,写代码的时候是发现闭包内部问题的最佳时机:有些改动看似局部,实则暗含着别处的改动;有些地方,未来的修改者很可能只改了这一处,却永远发现不了其余部分。有经验的工程师甚至把这种检查练成了对代码坏味道的直觉。编辑代码时,他们有一部分注意力在自动追问:“如果我是下一个只改其中一处的人,我能找到其他几处吗?”答案是否定的时候,我们就能感觉到哪里不对。
正因如此,修改闭包可以直接操作,而早先的设计原则往往停留在抽象层面。单一职责原则(SRP)告诉你,一个模块应该只有一个变化的理由;“一起变化的东西放在一起”告诉你,要把共同变化的代码放到一处。这两条原则的问题在于,它们的定义都取决于一个假想的未来。“变化的理由”和“一起变化的东西”描述的是尚未发生的修改。要应用它们,你得先设想未来可能出现的变化,再判断现有代码能否经受得住。这种设想是开放式的:没有任何信号告诉你考虑的场景已经足够,代码里也没有东西可以验证你的猜测。
修改闭包同样定义为“共同考虑”,但切入角度不同。你此刻为完成一个功能或修复一个 bug 而写的代码,按定义就是闭包,你从自己正在改动的地方就能直接看到它。剩下的设计问题很具体:你要留下什么线索,好让下一个修改者在不了解你这次决定的情况下,依然能找到每一个成员?
闭包最重要的属性是完整性。广度不仅是识别闭包成员的成本,更是确认集合已经完整的成本。只有找齐了每一个成员,闭包才有用。一次修改如果覆盖了闭包的大部分却漏掉一个成员,那它并不是“基本正确”,而是错误的,只是错误出现在一个尚未暴露的地方。
广度的失败模式:遗漏
闭包不完整时,失败的方式很特别。修改者从未到访某个本属于闭包的地方。被改动的代码能编译、能运行,局部测试也能通过。评审看起来没问题,补丁看起来很完整。但在代码库的某处,一个本该随改动一起调整的构件仍停留在旧行为上,直到下游某个东西依赖它时,这个缺口才会暴露。
这就是为什么遗漏(omission)比深度的失败更危险。深度的失败是耗竭:读者找不到可信的停止点,只能沿着结构一路追下去,跳转太多、抽象太多、隐藏行为太多。这种工作缓慢、累人、上下文负担重,但成本在发生时就是可见的,旁观者也能看出读者在挣扎。广度的失败恰好相反。修改者在工作中毫无察觉,因为代码里没有任何东西提示那个缺失的成员;他们之所以认为闭包已经完整,只是因为走过的路径都没有显示出别的东西。深度在阅读时让人痛苦,广度在遗忘之后才让人付出代价。
缺失的成员一旦暴露,往往不在最初改动的位置。它可能表现为一份过时的报表、一个对不上的界面、一次失败的导出、一处计费不一致、一个过时的迁移假设,或者一个仍然看到旧行为的下游消费者。
因此,广度不只是工作量成本,也是信心成本。它由两部分组成:闭包包含多少构件,以及修改者能多可靠地确认闭包已经完整。通常第二部分更重。修改者需要的不只是相关上下文,而是充分的上下文(sufficient context)。对广度而言,充分意味着能回答两个问题,而不是一个:
我需要改什么?
以及:
我怎么知道,必须一起改动或一起检查的东西我都已经找到了?
广度是确认闭包完整的成本
在 CMP 中,广度指的是任务层面的上下文成本。深度衡量理解单个构件有多难,广度衡量一次修改必须把哪些构件一起纳入上下文,以及修改者能多有把握地判断这个集合已经完整。
这两种成本并不对称。深度是被阅读代码的属性:无论对一个构件做什么修改,它的深度大致相同。广度是所做修改的属性:同一个构件在不同的修改中可能处于截然不同的闭包里。因此,“一个代码库的广度是多少”并不是一个有意义的问题,我们只能问某次具体修改在这个代码库上的广度。
这让两者承担了互补的职责:
深度是理解一个焦点构件的结构成本。
广度是获取那些必须共同考虑的构件的任务层面成本。
广度挑选出一次修改需要考虑的构件,深度为每个构件定价。真实的修改两者都要支付。
软件设计之所以重要,是因为软件必须变化。对深度而言,充分上下文意味着修改者能理解自己触及的每一个构件。对广度而言,它意味着修改者找到了每一个必须随改动一起变化的构件,并且可以相信没有遗漏。归根结底,广度就是确认闭包完整的成本。
后续文章会讨论设计如何降低或管理广度,手段包括局部性、索引、架构、测试和语言机制。下一篇会把深度和广度放在一起,看看真实的设计手法如何在两者之间变换上下文。