目录
《A Philosophy of Software Design》是最近读的一本关于软件设计的好书。书中定义了软件的复杂度,给出了软件复杂性的深层原因。同时,又给出了降低软件复杂度的一些建议。
软件复杂度
定义
广义上来说,软件复杂度是任何和系统结构相关的并且让系统难以理解和修改的事情。从收益和成本角度上来看,如果实现一个简单的功能需要花费大量的精力财力,那么这个系统就是复杂的。
复杂度由那些经常变动的功能来决定。因为,尽管你某个业务模块很复杂但是写完以后就不动了,那这个模块对整个系统的复杂度来说也是影响不大。反而,那些经常修改的模块,如果他们复杂度对系统整体的复杂度影响更大,因为每次修改都会影响系统复杂度。所以,在衡量一个系统复杂度是需要考虑一个修改程度这个因数。
代码的阅读者是最容易发现复杂度的。
症状
修改放大
修改一个需求,需要改动很多地方。模块依赖,缺少抽象时,就容易发生。
认知负担
修改一个功能模块,需要了解很多信息才能正确修改。比如调用c的malloc
函数,你得记得调用free
。当引用全局变量、代码结构不一致、模块依赖时,就容易出现这个问题。
不确定是否修改正确
由于代码结构复杂,除了通读这个代码才能做到正确修改功能。这种情况下面,很容易忘记某些逻辑,导致修改内容和这个逻辑产生冲突,导致bug。比如:全局变量的不一致性,某个地方认为是这样,另外一个地方却认为是另外一个意思,你不读两个地方的代码是无法发现的。
修改放大,如果告诉你具体需要修改哪些地方,那么还是能够正确实现功能的。同样,认知负担,如果能够明确依赖哪些信息,也是能够理解代码的。不确定是否修改正确是最糟糕的,除了通读代码全局了解以外,你是没法确定怎么修改。
根源
复杂度的两个根源是:依赖和难懂的代码。毕竟对于信息来讲,使用它就已经形成了依赖,同时提供的信息本身也存在是否容易理解这个属性。
依赖是根本性存在的,因为我们需要做任务分解,势必存在任务之间的依赖。能够做的就是减少依赖,让依赖简单。
难懂的代码,常见的有依赖不明确,注释不充分,代码结构不一致。
依赖容易产生修改放大和认知负担的问题,而难懂的代码容易产生认知负担和不确定是否修改正确的问题。
本质
复杂度的本质之一是递增的。软件只会越来越复杂,我们需要做的就是降低增加的速度。
方法
战略编程和战术编程
战术编程以快速完成功能或者修复问题为目标,很难有好的设计,容易引入依赖和难懂的代码,增加系统复杂度。
战略编程强调除了完成功能和修复问题是不够,更重要的是有一个好的设计,引入当前工作不必要的复杂度。每次修改代码都需要考虑尽量使依赖最小化、依赖简单化以及代码易懂。同时,因为设计没法做到完美而存在缺陷以及需求的迭代导致当前设计过时,在修改功能的同时改进设计。
显然,相比战术编程,战略编程需要花更多的时间,书中提到花开发时间的10%-20%是合理的。
模块化设计
模块化设计是管理软件复杂度的重要手段之一。通过把系统分成相对独立的字模块,每个模块独立处理其中一部分复杂度。这里的模块是广义的概念,可以指一个服务、一个包、一个类甚至一个函数。
为了管理依赖,将模块由接口和实现构成。接口描述模块能“做什么”,实现部分描述“怎么做”。
接口包含了模块使用者需要了解的全部信息。而模块开发者需要了解接口和实现的细节。接口应该越简单越好,这样使用者需要了解的信息就越少,依赖也就越少,从而复杂度就越低。
接口还细分为正式部分和非正式部分。正式部分是指通过代码或者协议表达出来的信息。比如函数签名,api接口中HTTP的Method等等。非正式部分是正式部分无法表达那些信息,比如接口一些副作用,某个函数调用会释放某些资源等等。通常这部分的信息要比正式部分复杂的多,只能通过注释来说明。
接口的设计要让常规的场景变得简单。像JAVA中的从一个文件名创建一个有缓存的数据流需要特别的构造BufferedInputStream
使得缓存接口的使用变得复杂,容易遗漏这个环节。
如何实现接口呢?答案是抽象。抽象是指识别出实体的最重要部分,忽略其他不重要的信息来简化实体,抽象是很常用的工具,生活中也无处不在,像微波炉,汽车都是。
模块化设计本质上就是在做抽象这件事情。它需要识别出每个字模块最重要的部分,而且尽可能的少,这样才能使得接口更加简单。如果包含了不重要的内容,增加认知负担,如果忽略的重要内容,就容易让代码难懂。
深度模块
深度模块中的“深”是模块的接口简单,实现的功能强大。因为简单的接口意味着较少的依赖和容易懂,也就意味较低的复杂度。但是,提供的功能却是强大的。因此,从复杂的角度来说就是以较少的复杂度实现了较强的功能,具有高性价比。总之,越深越好。下面几小节介绍让模块变深的方法。
Unix中的文件I/O是一个很好的深度模块的例子。它包含以下5个签名简单的接口:
int open(const char* path, int flags, mode_t permissions);
ssize_t read(int fd, void* buffer, size_t count);
ssize_t write(int fd, const void* buffer, size_t count);
off_t lseek(int fd, off_t offset, int referencePosition);
int close(fd);
open
通过层级结构的文件名打开文件,返回文件描述符来表示一个打开的文件。还有其他参数表示打开文件的读写属性,没有文件是否创建等等。read
和write
表示数据在文件和应用程序的缓存直接的传递,大部分情况下的读写数据是顺序,因此read
和write
默认就是顺序读写。lseek
用来解决随机读写的情况。然而,实现这些接口的代码有好几万行,他们解决很多复杂问题:
- 为了有效的访问数据在磁盘中怎么表示文件?
- 目录怎么存?怎么处理层级的路径名称以便找到对应的文件?
- 怎么实现权限保护,避免把别人的文件删除?
- 如何实现文件访问?比如:怎么划分中断和后台逻辑代码负责的功能?他们之间怎么确保通信安全?
- 并发访问时如何调度?
- 怎么做到把最近访问的文件的数据放到内存,减少磁盘的访问次数,以此来提升性能?
- 如果把不同的二级存储设备比如磁盘和闪存同一到一个文件系统中来?
所有这些问题以及其他很多问题都是Unix的文件系统实现的,对于开发者来说他们并不可见。
另外一个深度模块的例子是编程语言中的垃圾回收功能。它消除的内存释放接口,简化了语言的接口。但是,这个功能的实现却是很复杂的。
浅的模块也就是接口相比实现来讲比较包含的信息比较多。两者差不多。比如说list实现相对来说就比较浅,实现比较简单基本和接口没有太多差别,除了内部保存的数据结构以外。它是没法避免的,只是从复杂度角度来说,它的性价比是比较低的。
信息隐藏
本质上讲就是抽象不到位,违背了每个模块独立处理其中一部分复杂度的问题。信息泄漏的问题就是会增加依赖和认知负担。
接口和实现角度来说,不能把实现的信息暴露到接口。比如具体的数据结构和算法细节。
抽象的时候不要重复概念。比如:读一个文件,修改文件内容,最后写文件。如果把三个操作封装成三个模块,就会把编码算法这个信息分布在读模块和写模块当中。
还有一种信息泄漏是过度暴露信息,一个接口提供比较丰富的内容。但是,常用的只是其中一部分,这样其他内容就会对使用这个常用内容来讲增加了认知负担。
注意,信息隐藏是隐藏哪些应该隐藏信息,不能把需要暴露的信息也隐藏。比如:一些影响性能的参数应该让开发者来调节。
软件设计人员比较重要的技能是确定谁在什么时候需要什么信息!需要暴露的信息就需要让它变得明显!
通用化
平时开发经常会遇到写一个接口时候,考虑将来会遇到什么问题,然后按照预想方式来设计相对通用的接口,这样后面就可以不用改接口了,毕竟改接口是比较麻烦的事情。还一种说法是写接口的时候不要预测将来,因为大部分情况下预测都是不靠谱的,因此应该按照当前的需求来设计,这样符合增量迭代的思路。
但是应该适度通用,做一定的抽象,不仅仅满足当前的需求;同时不过度抽象,当实现当前需求时感到难用。比如:文本编辑器中插入、删除字符串。比较合适的设计的是
void insert(Position s, String s)
void delete(Position s, Position e)
比设计成
void insert(Cursor c, String s)
void delete(Cursor s, Cursor e)
要好,因为前者需要的信息是本身固有的,可以用在其他不是UI的场景,更加通用。而后者只能在UI的基础上使用。
通用化的模块是更容易变得深度以及隐藏信息。
设计通用化接口的一些启发式问题?
- 满足当前需求需要最简单的接口是什么?如果接口数量较少那很有可能是通用化的。
- 这个方法有多少个不同的地方使用?专用的方法通常只会在一个地方使用。
- 接口使用起来方便吗?这个可以防止通用化做的太过,如果使用不方便有可能是过度通用化了。
分层
分层强调不同的层不同的抽象。像一些转发的方法、装饰者模式以及一个变量传递都多个方法中,就需要注意他们可能并没有提供新的功能。
使用装饰者模式模式的一些启发式问题?
- 能否把新功能放在当前类中?如果功能相对通用,或者当前类的逻辑类似,在或者当前类会使用这个功能,那么就应该放在当前类中。继续吐槽JDK中
BufferInputStream
。 - 如果新功能专门为了当前用户场景的,那么放在使用这个接口的那一层比较合适?
- 能否和现有的一个装饰者合并,从而让那个装饰者更加深度?
- 能否重新实现一个类呢?
下推复杂逻辑
如果一个复杂度没法避免,尽量放在实现部分,可以简化接口,降低复杂度。放在接口部分会导致所有的用户都需要感知这个复杂度,增加了整体复杂度。比如:抛出一个异常就是把处理异常的交给了用户,任何使用者都必须感知。
为了避免过度下推复杂度,有几个启发式问题:
- 下推的功能是否和目标类的功能相似的?
- 是否降低了应用中其他地方的复杂度?
- 是否简化接口?
合并还是分开?
合并和分开模块的目的是为了最少的依赖,最好的信息隐藏以及深度接口。
分开导致的一些可能问题:
- 代码重复。
- 需要额外管理这些分开的模块的代码。
- 分开以后不容易寻找相关的模块。
如果两块代码是相关就应该放在一起,一些相关的暗示:
- 共享信息。
- 一起使用。使用了A就一定会使用B,使用了B就一定会使用A。
- 重复概念。
- 如果不看一个模块另外一个模块就无法理解。
需要分开的一个地方是把通用代码和专用代码分开。如果通用的代码里面还包含很多if/else,通用的逻辑就会因为那些专用的case修改而修改,那就不通用了。
文中讨论了方法代码行数问题,观点是说长度其实不重要关键是要把抽象做好,一个方法做一个事情而要做完整。如果抽象做的清晰,自然方法的行数也不会太多,因为可以把一个抽象再做细分么。
错误处理
错误处理增加了代码复杂度,是软件复杂度重要来源之一。这是因为:
- 增加了接口的复杂度,因为附加了错误信息。
- 它打乱正常的执行逻辑(如果是支持Exception的语言还好些)。
- 处理异常的代码要比正常的逻辑复杂,代码量也更多,也就更容易引入错误。
- 不容易测试,有些错误都不要mock,不容易复现。
- 容易忽视一些错误。
因此,需要减少不必要的异常。几种可能出现过多的场景是:
- 过度防御编程。
- 逃避处理复杂的场景,直接把错误扔给了使用者处理。
- 接口中过多的异常信息。
几种减少异常的方式:
- 把异常当作正常逻辑的一部分,简化接口。比如:删除一个正在使用的文件,linux是正常的处理,而windows就抛异常了。起始,这个在分布式里面尤其明显,机器硬件出问题是常态了。
- 掩盖异常。实现部分处理异常,比如TCP超时重传。它是下推复杂逻辑的一种表现。
- 聚集异常。抽象一些异常,把多种异常用统一的逻辑来处理。spring中
ResponseEntityExceptionHandler
就是典型应用。 - 直接crash。最简单粗暴的处理方式。打印错误日志,保留现场,停止应用程序。这个只使用于一个错误无法处理,或者处理的复杂度太高的情况。
设计两次
由于软件开发本身固有的复杂性,尤其是大型软件。针对一个需求多设计一种方案,有助于提升设计技巧,考虑问题更加全面。
针对接口的设计,最重要的是要考虑易用性、通用性以及能否让实现的性能更好。最后一点并不是指接口的实现性能,是指调用者使用该接口的代码性能。比如,写文件一个字符一个字符写与批量写,显然后者性能会更好。
针对实现的设计,最重要的考虑简单性和性能。其实,简单性往往意味着高性能了。
写好注释
那些表达代码本身无法表达的注释很重要。细的说几个好处:
- 让代码更容易阅读。
- 有助于抽象,隐藏复杂度。
- 正确的书写注释的姿势,能够提升系统的设计。
- 使得整体的软件质量有很大的提升。
本质上它减少了认知负担,帮助理清了依赖关系。
回答不写注释的4个借口
- 自解释的好代码。也就是说只要写好代码,代码本身足够容易懂,不需要注释。对那些短小的代码而言在一定程度上是成立的。如果,当作一个通用的规律,那就是神话了。从语言层面来说,注释是自然语言而代码是上下文无关文法,前者的表达能力强太多了。这个我中毒比较深π_π。
- 没时间。写注释时间不超过写代码的时间,而且如果能够帮助改善设计,那么这个代价是值得的。
- 注释容易过时,产生误导。其实更新注释时间比较短。需要花很长时间修改注释意味着代码有大修改,这往往是比较少见的。另外,通过代码review也能减少这样的问题。
- 没有价值的注释。这个主要是因为注释没写对。
怎么写注释:注释应该表达代码无法表达的信息
注释应该要比被注释的代码要“高”或者“低”,如果差不多那就是在重复代码能够表达的内容了,意义就不大啦。一个技巧尽量不用代码中使用的单词;)
高是指更加的抽象的说明,理解起来更加简单。帮助理解代码接口和总体意图。不容易写,可以通过下面几个启发式问题来解决:
- 这段代码要做什么?
- 用最简单的话来概括代码所做的事情该怎么讲?
- 这段代码最重要的是什么?
低是指更加精确,更加细节的说明代码的意思。主要对象是变量,方法参数,返回值等等。
在写注释之前要选择一种注释风格,比如javadoc,doxygen,godoc等等。因为,这样就有一个一以贯之的标准,确保一致性,同时能够确定需要写什么注释。
需要注释的地方主要有下面几个地方,其中接口和实现的注释是最重要的:
接口
接口代表抽象,但是无法表达抽象的所有内容。注释就是用来补充这部分内容的。主要目的是让使用者明白接口怎么使用。接口的注释需要和实现的注释分开,如果接口注释还需要写实现的注释,那么这个接口是比较浅的。
具体来说有:
- 类:需要提供高层的注释。
- 类的方法:包含高层和底层的注释。高层部分,需要通过一两句话描述使用者视角下的行为。底层部分包括每个参数和返回值的限制以及依赖,如果有异常也需要描述。另外,还有调用的前置条件和副作用。
实现
主要描述清楚代码做什么以及为什么,而不是怎么做。因为,代码就是描述怎么做。而且,有了做什么和为什么信息才能里面怎么做。
数据结构字段
大部分情况下是不需要写的。常见的有:
- 比如字段来自第三方,支付的时候生成的交易号来自支付宝或者微信。
- 字段包含很多中不同信息,比如一个字段里面不同的位信息表达不同的内容,比如雪花算法生成的分布式id。
模块依赖
模块直接的依赖很重要,因为模块直接的距离很远,他们之间的信息同步不容易通过代码直接来实现。(go的runtime里面就有很多)。不写注释就很容易导致信息不同步,导致bug。但是,写这个注释有一个难点是注释放在哪里?放在使用方或者被使用方都不方便让双方都同步到。作者的方案是提供一个专门放这些注释的地方,然后在代码中添加注释,注释内容说明引用这个地方。
先写注释
写完代码和ut之后再写注释容易写出质量不高的注释,几个原因:
- 拖延症。拖着拖着就不写了。
- 代码稳定以后再写,这个时候代码量已经不小了,再写注释工作量也就不小了,有压力。
- 赶着实现功能和改bug,就不了了之。
- 时间久了以后,容易忘记一些设计细节。
写代码之前先写注释有几个好处:
更好的注释
- 及时记录设计要点。
- 专注抽象,不被实现细节打搅。
- 在编码和测试的时候还能弥补注释的问题。
作为工具改进设计
这个是最最最重要的好处了。
- 注释是唯一能够完整描述抽象的一个工具。注释完整以后就可以进行设计review和调优了。另外,注释描述变量或者代码片段最重要的事情,在写代码之前考虑清楚是最好的,可以减少代码的返工。
- 注释可以做为设计好坏的标准。注释简单明了,容易懂,设计一般会比较好。相反,注释冗长复杂,很可能接口设计不够抽象。
命名
命名还是蛮重要的。好的命名帮助理解代码,容易发现bug,不好的命名使得代码难懂,容易引入bug。一个单一的命名可能对代码的影响不大,但是在一个代码库中,变量个数成千上万,好的命名能够降低代码复杂度和维护性。这其实说明了命名是复杂度是递增的一个例子。
什么是好的名字?反映本质、精确以及一致。
反映本质
取名字其实也是抽象的过程,要抓住表达业务的最重要信息,抛弃不重要的信息。是否能够反映事物的本质,一个启发式的问题:当一个人不看声明、注释的时候,他所理解的意思是否和它所表达的一致?
精确
精确的反面是宽泛和模糊。宽泛和模糊意味着针对一个命名会有很多种不同的理解,导致代码不容易懂。比如,用result
来命令函数的返回值,它是一个宽泛的命名任何返回值都可以这么命名,但是对于读代码其实帮助不大。
另外,还有一种不情况需要避免的是用一个特例来命名抽象。
但是,在一个短小的上下文中使用宽泛的命名倒是可以的,比如用i
用在一个短小的循环计数中。
当不知道怎么命名的时候,往往会意味着代码的设计有一些问题,需要重构了!
一致性
一些特定场景下面的通用名称只在这些场景下面使用,不要例外!同一个名字在不同的地方描述的行为要保持一致。如果,多个相同的事物同时出现,可以用前缀来区分。比如在两个缓存直接拷贝数据,可以通过src和dest这样的前缀加以区分。
多层循环的代码中代码一致性能够帮助理解循环的逻辑。比如:外层都用i
,次层用j
,然后是k
。
修改老代码
修改代码的时候也要使用战略方式来修改。最理想的情况就是修改完以后,仿佛原来的设计已经考虑到了当前的需求一样。
在修改过程中常遇到的问题是修改的方式和原来的设计冲突了,或者现有的设计无法满足当前的修改。如果重构设计,就会导致新的代码和原来的设计不一致,增加了认知负担。重构可能会和项目进度冲突,但是还是值得做的一件事情!
还有一个比较容易出错的地方是维护代码和注释的一致性。修改代码以后,忘记同步注释了。写注释的时候有两个要点可以减少错误的发生。一个是把注释放在最靠近需要说明的代码地方,比如流程注释放在签名部分,具体实现细节放在实现代码前面。还有一个是避免注释重复,减少同步地方。最后,在提交代码的时候记得查看diff记录,确保修改的内容和注释是一致的。
保持一致性
在这里一致性的意思说:相似的事情会用类似的方式处理,不相似的问题以不同的方式处理。
好处有两点:
- 认知杠杆。一旦某种事情确定了一种处理方式以后,后面相似的事情就可以用之前的处理方式来理解了。
- 减少错误。如果相似的事情处理方式不一样,当开发看到相似的事情的是往往会假设他们的处理方式是相似,从而导致理解错误。
通常,具有一致性有以下几个方面:
- 命名。
- 代码风格。在开发中,团队里面都会有一个代码规范。缩进啊,一行多少个字符啊等等。
- 接口。尽管有多个不同的实现,但是实现之间在语义上有一致性的一面,这样有利于代码理解。
- 设计模式。设计模式是指一些常见问题通用解决方案。遇到类似的问题的时候使用设计模式使得代码可读性更高。
- 不变性。它是指变量或者是结构的一个属性。常量显然比变量更加容易推理。
维护一致性不容易,有几个方法:
- 文档。写好最重要的那些一致性惯例,放到大家容易看到的地方。鼓励大家经常review。
- 工具。通过工具自动化检查一致性。比如:自动化格式代码等等。
- 遵循现有的惯例。代码结构,设计方式。
- 不要修改现有的惯例。权衡一个改进带来的收益和修改的成本。往往后面的成本是更高的,因为涉及的范围很广。
最后,不要为了一致性而一致性。比如,滥用设计模式,还有把不相似的事情很别扭的用相同的方式来处理都是不好的。
让代码的意图更加明显
意图明显的代码一般具有以下几个特征:
- 阅读的人不用太多思考就能读懂代码。
- 阅读的人很容易找到为了理解代码的其他信息。
- 相比难懂的代码,注释更少。
让意图变得明显的方法:
- 注释。
- 良好的命名。
- 一致性。
- 良好的编程风格。
- reivew,别人更容易看出你的代码是否意图明显!
一些意图明确常见场景:
- 异步编程。主要是控制流不清晰,请求和返回不连贯,需要额外的信息去串起来。主要通过注释来解决。
- 泛型容器。比如pair<F,S>这样的容器结构,当用来做一个返回的类型时,它提供的方法很难准确表达具体的业务信息。解决方法就是不用!
- 变量声明的类型和具体的类型不一样。这个一样就好了!这个主要是发生在函数的局部变量里面,这里的需要的抽象最少。
- 违反知觉的代码。比如:一个构造函数里面跑一个线程,等线程执行完以后才返回。这个需要尽量避免!
软件开发趋势和软件复杂度
现在有很多的编程范式和软件工程方法来解决软件复杂度问题。但是,不是说你用某个编程范式或者方法就能降低软件复杂度的。毕竟每个技术对应着具体的问题场景,不可能是全能的。所以,一定要理解每个技术背后的实质。文中提到几种技术:面向对象、敏捷开发、单元测试、测试驱动开发、设计模式以及JAVA中的Getters和Setters模式。
为性能而设计
在性能成为系统的瓶颈之前,我们需要保证系统的复杂度最低。因此,我们有两个问题:
- 如何在系统复杂度低情况下,性能最高?
- 当性能成为瓶颈的时候,怎么提升性能?
针对第一个问题其实比较简单。一个了解你使用的工具,比如:
-
编程语言。go中的defer在1.13之后性能比较好。
-
数据结构。构造一个基于数组的列表时给一个合适的初始容量可以减少内存扩容的开销。
-
算法。选择基于数组的列表还是基于双向列表的列表。
-
了解一些操作的延时,以下是Google大神的总结。
Latency Comparison Numbers (~2012) ---------------------------------- L1 cache reference 0.5 ns Branch mispredict 5 ns L2 cache reference 7 ns 14x L1 cache Mutex lock/unlock 25 ns Main memory reference 100 ns 20x L2 cache, 200x L1 cache Compress 1K bytes with Zippy 3,000 ns 3 us Send 1K bytes over 1 Gbps network 10,000 ns 10 us Read 4K randomly from SSD* 150,000 ns 150 us ~1GB/sec SSD Read 1 MB sequentially from memory 250,000 ns 250 us Round trip within same datacenter 500,000 ns 500 us Read 1 MB sequentially from SSD* 1,000,000 ns 1,000 us 1 ms ~1GB/sec SSD, 4X memory Disk seek 10,000,000 ns 10,000 us 10 ms 20x datacenter roundtrip Read 1 MB sequentially from disk 20,000,000 ns 20,000 us 20 ms 80x memory, 20X SSD Send packet CA->Netherlands->CA 150,000,000 ns 150,000 us 150 ms Notes ----- 1 ns = 10^-9 seconds 1 us = 10^-6 seconds = 1,000 ns 1 ms = 10^-3 seconds = 1,000 us = 1,000,000 ns
-
**设计要简单。**这个最重要了!简单的设计一般性能不差!毕竟性能就是因为执行的步骤太多啦!
性能优化的一些常规思路:
- 首先要有性能基准测试,找到性能低下的代码路径。记住:猜是不靠谱的!
- 找到耗时的代码路径以后。
- 可以考虑:缓存、预计算、并发、分布式以及修改更优的算法等等。
- 简化代码路径。更少的分支判断,模块调用等等。