软件复杂度的症状与成因
软件复杂度,可以从一次修改中看出来。需求已经说清楚,开发者却还要花很久查找相关代码,确认几个模块之间的约定,最后仍然拿不准有没有漏改。系统越难理解和修改,复杂度就越高。
John Ousterhout 在《A Philosophy of Software Design》第 2.2 至 2.4 节中,把这种困难归纳为三个症状,分别是变更放大、认知负荷和未知的未知。造成这些症状的主要原因是依赖与晦涩。他的公开讲义也讨论了这些问题,以及它们如何在日常开发中逐渐累积。
用修改难度衡量复杂度
代码行数很容易统计,修改代码所需的知识却没有那么直观。一个函数可能只有几行,调用者仍要先弄清参数单位和调用顺序,才能安全地使用它。这些额外的理解工作,都应当算进复杂度。
有时把变量名写完整,或者多加一个表示单位的类型,代码会变长,读者需要猜测的地方却少了。如果新增结构能减少这些工作,就有保留的理由。相应地,把简单计算拆进几层函数,迫使读者反复跳转,也会增加理解成本。
衡量范围还要包括调用者。模块内部少写几行,却让每个调用点都重复处理同一套准备和清理步骤,整个系统需要维护的代码和约定可能更多。
复杂度的三个症状
变更放大
Change amplification 指一个概念上很小的变更,需要修改许多处代码。书中的网页配色例子很直观,每个页面都把背景色写死在自己的代码里。改主题色时,就要逐个修改页面。页面越多,需要检查的地方越多。
把颜色集中到一个变量以后,所有页面引用它,换颜色就可以集中修改。需要一起变化的内容有了共同的位置,重复修改的工作随之减少。
这种症状的麻烦主要在工作量。只要相关位置能够找全,就可以列出清单,逐项修改和验证。它仍然可能出错,但修改者至少知道检查应该覆盖哪里。
认知负荷
Cognitive load 指开发者为了正确完成任务,必须掌握多少额外信息。书中举了内存管理的例子,一个函数分配内存并返回指针,释放却由调用者负责。调用者除了使用返回值,还必须记住释放责任,漏掉就会造成内存泄漏。
金额单位也是同类问题。
charge(100) 里的 100,表示元还是分?如果必须查实现或翻文档才能知道,接口就把这条约定留给了调用者。对于币种固定、以分计价的内部接口,用 AmountInCents 表达单位,可以让这个条件更容易看见。认知负荷高的代码,修改点可能很少,准备工作却很多。开发者得同时记住多个前提,检查眼前的操作是否满足它们。设计改进的收益,就可以体现在这些前提是否更少、更容易理解。
未知的未知
Unknown unknowns 指修改者不知道还有哪些地方需要处理,甚至没有意识到某个问题存在。这会让检查本身发生遗漏。
继续看网页配色。背景色已经集中到一个变量,但有些页面仍然把与它配套的文字颜色写死在本地。背景一改,文字的可读性也可能受影响。搜索背景变量的引用,未必能发现这些文字颜色与背景之间的关系。
新增订单状态也可以说明这个问题。系统原来只有
pending 和 completed,一段 SQL 用 status != 'completed' 统计待处理订单。加入 cancelled 后,取消的订单也被算进来。SQL 可以照常执行,已有测试如果只覆盖两个旧状态,也可能全部通过。在出错后果相近的前提下,未知的未知尤其值得重视。已知的修改点再多,也能逐一检查;没有被发现的关联,可能连检查清单都进不去。设计因此还要帮助维护者发现相关位置,减少只靠熟悉系统的人凭记忆补充的情况。
复杂度的两个成因
依赖
一处代码要靠另一处的约定才能正确工作,就存在依赖。调用者依赖方法签名,通信双方依赖同一套协议。修改这些约定时,相关代码需要一起检查,有些地方还得跟着调整。
依赖很难全部消除。把页面颜色集中成变量,也仍然保留了页面对变量的依赖。改进在于共同规则更集中,关联更容易识别,维护者可以少做一些重复查找和修改。
依赖还会增加认知负荷。假设给 HTTP 客户端加一行自动重试配置,修改者得先知道服务端如何处理重复请求。如果服务端已经执行成功,只是响应丢失了,再发一次会发生什么?回答这个问题需要理解另一端的行为。
这类复杂度来自修改所需的上下文。依赖涉及的规则越多、分布越散,修改者就越难单独判断一段代码。设计时需要减少不必要的关联,并让必须保留的依赖容易找到。
晦涩
Obscurity 指重要信息不明显,阅读代码时难以弄清它如何工作、为什么这样设计。含义模糊的参数名、没有写下来的单位约定,以及只能口头传授的调用顺序,都属于这类问题。
依赖与晦涩经常一起出现。页面文字色需要配合背景色变化,这是依赖;代码没有表达这层关系,维护者也找不到相关说明,就又有了晦涩。前者要求两处配合修改,后者让人容易漏掉其中一处。
写文档可以补充必要信息,接口设计也值得一起检查。如果常见用法总要调用者记住一串准备和清理步骤,就应该看看模块能承担其中哪些工作。例如批量写入要求每个调用者自己管理会话和事务,统一封装完整流程,可以减少调用者重复实现生命周期的负担。
文档长短只能作为检查设计的线索。复杂业务的含义、选择某个方案的理由,都可能需要认真解释。有些调用者也确实要控制事务范围,模块无法替它决定。Ousterhout 在接口设计讲义中强调简化常见用法,在注释讲义中则讨论了如何记录代码难以表达的信息。这两项工作都能减少使用者需要猜测的地方。
复杂度如何逐渐累积
复杂度经常由许多小决定累积而来。一次复制可能只多出几行代码,一个临时约定也可能只影响当前函数。后来的人沿用这些写法,更多代码开始依赖同样的决定,调整它们就越来越费力。
假设几个服务要读取同一份缓存,各自复制了
user:{id} 的 key 拼接方式。加入租户信息时,需要同时修改所有读写方。缓存格式再升级,还得安排版本兼容和发布顺序。最初只是一个字符串写法,后来已经需要几个服务共同遵守。拖延处理的代价也会重复发生。每次相关修改都要重新查找使用者,核对各处约定;等使用者更多了,统一规则本身又更难。日常开发中,已经弄清原因、与当前变更有关、改完能够检查的小问题,值得及时处理。
这也有成本。一个近期不再修改的稳定模块,重构收益可能有限;需要多个服务协调的调整,则要单独安排。对复杂度的“零容忍”,可以落实为认真对待每次新增的复杂度。赶交付时暂时保留问题,也应当知道留下了什么,以及什么时候值得重新评估。
用症状检查设计
这套区分可以直接用于修改和评审。先列出本次变更涉及的位置,观察同一条规则是否需要到处修改,再看这些关联能否从代码和接口说明中找到。
随后检查理解成本。修改者要读哪些额外代码,记住哪些约定?如果新设计让常见调用少承担一项清理责任,或者让单位可以直接看懂,就有了具体的改善。
最难回答的是检查是否完整。测试全部通过,可以支持已有场景仍然成立,却无法证明检查清单没有遗漏。新增一个状态时,还需要重新审视默认分支和统计条件,确认它们是否仍然符合业务含义。把这些关系写进明确的规则和检查中,下一次修改才有依据可循。