Software Design Book 小记

Posted on 2022年10月9日周日 技术

半年前从一位朋友那里借了一本他还没读完的<A Philosophy of Software Design>,自己一直没读。十一期间花了3天时间断断续续的看完,这本书对我最有用的部分是其在增订版中对 <Clean Code> 中一些观点的反对,其实软件工程这门学科还很年轻,虽然有一些大多数人赞同的最佳实践,但没有什么是一定正确的 。以及自己之前就从其他书本中看到过本书中的各种观点,有的甚至更进一步,比如:本书只提到了为什么对象继承不好,而没有说什么更好,但<Effective Java> 中就鲜明提出了 perfer compositation over inheritation,以及《程序员修炼之道》中还提到了extension。

以及书本中关于复杂度的种种论述都对软件开发非常有帮助,但其他部分就一般般,有经验的开发者快速浏览下即可。

复杂度的定义

用一个公式概括地说,软件复杂度的定义是软件中各个组件的复杂度 * 其被修改频率之和。也就是说为了降低软件复杂度,我们可以把组件个体的复杂度降低,也可以从整体出发,把复杂度高的逻辑放在被修改频率偏低的模块中。

复杂度增长会带来3个问题:

  1. 代码修改的复杂度上升,一处修改,处处修改。
  2. 认知负担变重,需要了解过多概念才知道如何修改。
  3. unknown unknown,既不知道哪些知识是认识问题所需的,更不知道如何获得这些知识,即使代码改错,或者有一些地方没有改,工程师也无法知道。

代码之间的依赖混乱会带来1和2问题,而关键信息缺失(比如多处代码与文档中信息不一致)会带来3和2问题。

战略编程 vs 战术编程

但是注意这里说的战略依然以敏捷流程为执行周期,而不是waterfall模型,waterfall模型中,项目过大,执行时间和反馈周期过长,对设计更优的软件架构不利。

书本上说,基于战术编程的时间,多投入10%-20%就可以转化为战略编程。但是我不这么认为,战略编程会带来非常多的throwaway work,各种比较调研,总体上我倾向于严密的战略编程的开发成本是极限战术编程的一倍。但我对书中的一个图例很赞同,使用战略编程的项目,取得的进展和花费的时间是近线形的关系,而使用战术编程的项目,取得一个单位进展,需要近指数级的开发时间投入。因为战略编程可以利用良好的架构隐藏复杂度,让每一单位进展都尽量独立于已有的代码;而战术编程往往导致每一份新代码都需要考虑到已有老代码,越来越积重难返。

同时书中说到的10-20%的时间,让我想到了:

书中认为好的代码可以吸引到更好的开发者,而一些startup认为采用战术编程可以跑得快,等业务跑起来了再用钱请更好的开发者来修代码其实是不可取的。我觉得这对一家科技公司来说是肯定的,但是对一个业务驱动的公司来说,可能还是看业务本身是否需要强有力的技术支持,有一些业务的确不需要。

模块应该尽量深

一个模块隐藏的实现越多,暴露接口越少,同时不让使用者觉得使用不便,那可以认为这个模块的复杂度是令人满意的。

文章中说到了一个非常有趣的例子:Go和Java中的GC,实现非常复杂,但是GC使得编程语言不需要暴露释放内存地址的接口。哪怕我们需要添加大量的复杂代码,但经过适当的排布后,这些代码甚至可以降低原有代码的接口数量。

感觉GC的例子和服务端负责计算,客户端负责渲染的情况有共同之处,复杂计算逻辑统一到一处,那么各种客户端就不需要各自独立维护一份计算代码了。

作者也认为当今一些开发者倾向于把函数和class越拆越小,也会导致函数和class变得shallow(not deep),平时开发中要注意。

信息隐藏(和泄漏)

这一章节说的是不同模块负责的内容应该正交(互不相关),我归纳了一下书中例子想要强调的特性:

越通用,越简单

(自己瞎意译的,原文是 General-Purpose Modules are Deeper,因为deep在书中指代deep module,其一个重要属性就是复杂度低,即简单)

我理解因为软件逻辑的复杂度被简化到了一定程度后只能转移,而不能被消除,在接口和实现之间分配复杂度就是在决定各个复杂度被使用者接触到的频率。通常接口越简单通用,实现就会越复杂,从而让模块变得更深(deeper)。

作者也在这里强调了设计通用的接口并不是让大家over design,通用的接口在未来有兼容更多需求的可能性,但未来也可能没有新的相关需求,甚至会出现一个破坏现有接口的需求。我们可以先保留接口的通用性,但是只实现满足当前需求的代码,待未来有需要了再基于现有接口做改进,扩展模块内部的实现,让模块变得更深。

层次不同,抽象不同

不同的层次应该有不同的抽象,如果不同层次有相同的抽象,那么往往说明相应层次的代码不够深。

比如经典的 The Principle of Least Knowledge,一个调用链,A -> B -> C,如果B只是简单地把请求转发到C,那么A不应该调用B,而是应该直接调用C。因为凡是新增代码就是在增加复杂度,当添加一份代码时,我们要考虑新增代码带来的收益和其增加的复杂度,在上述情况中,让B转发请求,只是增加了复杂度,而没有带来任何收益,反而增加了之后维护代码的成本。

书里面还讨论了decorators和pass-through variables的情况。

关于装饰器,Java和Python都通过注解的方式很优雅解决了问题,其他语言就需要注意一下,作者建议考虑一下不去使用修饰器(有点废话了):

pass-through variables指一个调用链中,每个方法都要传递的变量,比如Go中的ctx context.Context,作者建议的方法是类似用thread local的方法把context存在某个实例中,但我觉得其实现在大家并没有特别好的方法来解决这个问题。自己觉得更优雅的方法或许是类似Guice或其他依赖注入框架针对线程做依赖注入的方法,即把从thread local中存取变量的逻辑都托管给框架,框架对方法做封装,填充thread local中已有的变量,剩下的变量暴露出来让调用方传入,不过我也没有在实践中有使用过,也没有调研过相关框架的能力。

为长函数辩护

书中提到了 Robert Martin 所著的 <Clean Code>,其中提倡一个函数的长度应该越短越好。

但作者并不认同所有的函数都应该越短越好,Each method should do one thing and do it completely. 如果一个函数很难被拆分成几个更短函数的组合,或者被拆解出的短函数无法做到互相独立,之间有上下文依赖,那么这种(不负责任的)拆分反而会增加代码的复杂度和维护成本。

注释

要不要写注释

在这里作者又旗帜鲜明地和 <Clean Code> 唱起了反调,<Clean Code> 认为代码注释在最好的情况下也是“必要之恶”,通常注释代表了代码本身的失败,开发者无法写出充满表达性的代码。

但作者认为注释和代码是相辅相成的,两者共同降低了复杂度,没有注释反而会带来复杂度的增加。比如:有了注释就不需要过长的函数名(书中的例子 isLeastRelevantMultipleOfNextLargerPrimeFactor );有了注释就没必要硬生生地把函数拆解成多个互相关联不独立的短函数( <Clear Code> 认为函数就是注释)。

函数的注释可以使代码使用者不需要阅读代码即可以使用函数,这也是一种抽象。同时注释可以记录下代码中无法反映出的,开发者设计代码时考虑的一些额外信息。

注释应该描述代码中不明显的部分

作者不鼓励把那些阅读代码就可以直接得到的信息写在注释中,书中的反例如下:

ptr_copy=get_copy(obj)    #Getpointercopy
ifis_unlocked(ptr_copy):  #Isobjfree?
returnobj                 #returncurrentobj

注释可以被分类为这几类,各自有不同的标准:

对一些 cross-module design ,即一个设计被多个 module 使用,作者建议把相关的注释写在多个 module 共用的一些结构体定义里,或者在源代码中附上一个叫 designNotes 的文件。

先写注释,后写代码

作者认为在写完代码后写注释并不是个好习惯,因为在代码都写完后才写注释会增大写注释的阻力;同时写完代码后再写注释,很可能一些关键信息已经被大脑丢弃了,想找回来很费力。

其实这就好比我看完书立刻就写读书笔记一样,最省事儿哈哈。

所以作者提倡先写注释,后写代码。

作者说一般人不这么做的理由通常是代码需要来回几次修改才能最终固定下来,所以等代码写完再写注释最省力。但作者提出反复修改代码的代价太大,提前写注释其实就是一种固定代码结构的方法,如果代码的结构需要反复修改,不如就在注释中反复修改,这样修改成本更小,开发成本反而更低。