
软件的复杂性
什么是复杂性?
举个直观的例子:1+1+1+1 显然比 1+1 要复杂一点,1+2+3+4 显然又比 1+1+1+1 要复杂一点。
这里可以总结两点:
- 更大的能力带来更大的复杂性;
- 整体的复杂性为各局部复杂性的加权和。
直观地说,对于软件系统,如果难于理解和修改,那么它就是复杂的。
有三个症状可以诊断系统的复杂性:
- 变更放大,一个简单的变更需要修改很多不同的地方;
- 认知负担,完成一项任务需要理解很多东西;
- 不知道自己不知道,需要修改哪块代码或掌握哪些信息并不明确。
我想大家在项目开发过程中都遇到过这种情况,明明看起来是一个很简单的修改,但实际改起来却发现是一个大工程——最近的一个项目就有这种感受。细究起来不外乎两种原因:1、这个修改本身的技术内涵比较复杂;2、系统原来的设计有问题,导致后续修改牵一发而动全身。这个直观定义还可以放大到整个研发体系,如果整个体系难于理解和执行变更,那么它就是复杂的。复杂系统最大的问题是事倍功半。
导致复杂性的原因有两个:
- 依赖(可以理解为耦合),一个代码块 A 不能被单独地理解和修改,必须要结合 B,那么 A、B 之间就有依赖。依赖导致变更放大问题和认知负担问题。
- 晦涩,关键信息不明显,如命名不准确、依赖不明显。晦涩导致认知负担问题和不知道自己不知道问题。
随着系统存在的时间越来越长,依赖和晦涩会随着不断叠加的需求而不断累积,经过一个滚雪球的过程,后续任何对系统的修改都会充满了风险和困难。
如何应对复杂性
此处将 method、class、module、service、subsystem 等都视为模块。
模块要深
在现实的模块化编程中,开发一个模块是为了提供给别的模块用,所以应包含两部分:对外的声明(interface)和对内的实现(implementation)。interface 是对本模块能力的抽象,是其他模块在使用该模块时需要知道的信息,应该设计得比 implementation 简单得多。interface / implementation 越小,模块的深度就越深,否则就越浅。

在设计模块时,应该尽量设计深度模块,不重要的内容都抽象掉,尽量减少相互之间的依赖,想想如果 interface 和 implementation 差距不大,那就失去了将其包装为一个模块的意义。
和目前强调尽量设计小的类的流行设计方式相比,深度模块有助于减少类的数量,不过很难说谁绝对正确,毕竟分离不同变化频率的部分也是很有益处的,那么到了具体场景就看设计者具体问题具体分析的 trade-off 功力了。
信息隐藏
实现深度模块采用的方法就是信息隐藏。信息隐藏将模块的能力简化为简单的 interface,减轻了认知负担。同时被隐藏掉的实现细节即使发生变化,因为 interface 不变(注意 SLA 承诺),其他模块也不受影响,使得系统更容易进化。例如我们调用的 restful 接口,我们并不关心提供接口的服务使用了什么操作系统、什么数据库、什么编程语言。
泛化模块
设计模块时有一个权衡:是设计专门针对当前需求的特化模块,还是设计为支持未来扩展需求的泛化模块?
聪明如你马上会想到某位大师曾经曰过:过早优化是万恶之源。但我们还是要在边缘做些试探,因为提前考虑可以在未来节省时间,恰如 CPU 偷偷干的分支预测。
因为模块分为 interface 和 implementation 两部分,所以这里的建议是:泛化 interface + 特化 implementation。
通过特化的 implementation 来满足当前的需求,通过泛化的 interface 来支持未来的扩展,这样的模块也是一个深度模块。比如我作为中间商,对外提供支付接口,不会具体说这个接口是针对哪家银行的(特化),而是提供和具体银行无关的支付域操作(泛化),这样后续本地的支付细节随时可以变。
分层抽象
分层抽象是计算机领域的经典思想,每一层都关注不同的问题,对应不同的抽象,上层只能调用相邻下层的接口,下层不能调用上层,最典型的案例就是 OSI 分层模型。
当分层后发现相邻层存在只是传递了参数给另一层的调用情况时,说明两层的模块之间职责划分有问题,增加了复杂性但没有增加能力,需要调整。对于 Dispatcher、Decorator 的设计来说,虽然 interface 重复,但增加了能力,所以认为没有问题。
当存在一些参数需要跨层传递时,有 4 种方式可以选择:1)逐层传递参数;2)共享对象;3)全局变量;4)共享 Context 对象(Spring 里各种 Aware 的目的)。建议选择第 4 种。

下移复杂性
对于一些配置参数,如果模块内部自己决策能比使用该模块的用户决策得更好,那就内部消化掉,不要暴露给用户去决策。
异常设计
名侦探柯南:真相只有一个。—— exception 却有很多。
异常会增加复杂性,最好是减少必须要处理异常的位置。次之有几种减少(因异常而引入的)复杂性的手段:
- 不定义异常,如果没有必要,就尽量不要定义为异常;
- 隐藏异常,如果在发生异常情况的较低层次能处理好,就不要对高层抛出异常;
- 合并异常,使用同一段代码处理多个异常;
- 直接 crash,对于一些根本无法处理也不常出现的异常情况,在出现时不如直接让应用 crash 掉。
未完待续……
0 条评论