《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的基础上使用。
通用化的模块是更容易变得深度以及隐藏信息。
设计通用化接口的一些启发式问题?
分层强调不同的层不同的抽象。像一些转发的方法、装饰者模式以及一个变量传递都多个方法中,就需要注意他们可能并没有提供新的功能。
使用装饰者模式模式的一些启发式问题?
BufferInputStream
。如果一个复杂度没法避免,尽量放在实现部分,可以简化接口,降低复杂度。放在接口部分会导致所有的用户都需要感知这个复杂度,增加了整体复杂度。比如:抛出一个异常就是把处理异常的交给了用户,任何使用者都必须感知。
为了避免过度下推复杂度,有几个启发式问题:
合并和分开模块的目的是为了最少的依赖,最好的信息隐藏以及深度接口。
分开导致的一些可能问题:
如果两块代码是相关就应该放在一起,一些相关的暗示:
需要分开的一个地方是把通用代码和专用代码分开。如果通用的代码里面还包含很多if/else,通用的逻辑就会因为那些专用的case修改而修改,那就不通用了。
文中讨论了方法代码行数问题,观点是说长度其实不重要关键是要把抽象做好,一个方法做一个事情而要做完整。如果抽象做的清晰,自然方法的行数也不会太多,因为可以把一个抽象再做细分么。
错误处理增加了代码复杂度,是软件复杂度重要来源之一。这是因为:
因此,需要减少不必要的异常。几种可能出现过多的场景是:
几种减少异常的方式:
ResponseEntityExceptionHandler
就是典型应用。由于软件开发本身固有的复杂性,尤其是大型软件。针对一个需求多设计一种方案,有助于提升设计技巧,考虑问题更加全面。
针对接口的设计,最重要的是要考虑易用性、通用性以及能否让实现的性能更好。最后一点并不是指接口的实现性能,是指调用者使用该接口的代码性能。比如,写文件一个字符一个字符写与批量写,显然后者性能会更好。
针对实现的设计,最重要的考虑简单性和性能。其实,简单性往往意味着高性能了。
那些表达代码本身无法表达的注释很重要。细的说几个好处:
本质上它减少了认知负担,帮助理清了依赖关系。
注释应该要比被注释的代码要“高”或者“低”,如果差不多那就是在重复代码能够表达的内容了,意义就不大啦。一个技巧尽量不用代码中使用的单词;)
高是指更加的抽象的说明,理解起来更加简单。帮助理解代码接口和总体意图。不容易写,可以通过下面几个启发式问题来解决:
低是指更加精确,更加细节的说明代码的意思。主要对象是变量,方法参数,返回值等等。
在写注释之前要选择一种注释风格,比如javadoc,doxygen,godoc等等。因为,这样就有一个一以贯之的标准,确保一致性,同时能够确定需要写什么注释。
需要注释的地方主要有下面几个地方,其中接口和实现的注释是最重要的:
接口
接口代表抽象,但是无法表达抽象的所有内容。注释就是用来补充这部分内容的。主要目的是让使用者明白接口怎么使用。接口的注释需要和实现的注释分开,如果接口注释还需要写实现的注释,那么这个接口是比较浅的。
具体来说有:
实现
主要描述清楚代码做什么以及为什么,而不是怎么做。因为,代码就是描述怎么做。而且,有了做什么和为什么信息才能里面怎么做。
数据结构字段
大部分情况下是不需要写的。常见的有:
模块依赖
模块直接的依赖很重要,因为模块直接的距离很远,他们之间的信息同步不容易通过代码直接来实现。(go的runtime里面就有很多)。不写注释就很容易导致信息不同步,导致bug。但是,写这个注释有一个难点是注释放在哪里?放在使用方或者被使用方都不方便让双方都同步到。作者的方案是提供一个专门放这些注释的地方,然后在代码中添加注释,注释内容说明引用这个地方。
写完代码和ut之后再写注释容易写出质量不高的注释,几个原因:
写代码之前先写注释有几个好处:
更好的注释
作为工具改进设计
这个是最最最重要的好处了。
命名还是蛮重要的。好的命名帮助理解代码,容易发现bug,不好的命名使得代码难懂,容易引入bug。一个单一的命名可能对代码的影响不大,但是在一个代码库中,变量个数成千上万,好的命名能够降低代码复杂度和维护性。这其实说明了命名是复杂度是递增的一个例子。
什么是好的名字?反映本质、精确以及一致。
反映本质
取名字其实也是抽象的过程,要抓住表达业务的最重要信息,抛弃不重要的信息。是否能够反映事物的本质,一个启发式的问题:当一个人不看声明、注释的时候,他所理解的意思是否和它所表达的一致?
精确
精确的反面是宽泛和模糊。宽泛和模糊意味着针对一个命名会有很多种不同的理解,导致代码不容易懂。比如,用result
来命令函数的返回值,它是一个宽泛的命名任何返回值都可以这么命名,但是对于读代码其实帮助不大。
另外,还有一种不情况需要避免的是用一个特例来命名抽象。
但是,在一个短小的上下文中使用宽泛的命名倒是可以的,比如用i
用在一个短小的循环计数中。
当不知道怎么命名的时候,往往会意味着代码的设计有一些问题,需要重构了!
一致性
一些特定场景下面的通用名称只在这些场景下面使用,不要例外!同一个名字在不同的地方描述的行为要保持一致。如果,多个相同的事物同时出现,可以用前缀来区分。比如在两个缓存直接拷贝数据,可以通过src和dest这样的前缀加以区分。
多层循环的代码中代码一致性能够帮助理解循环的逻辑。比如:外层都用i
,次层用j
,然后是k
。
修改代码的时候也要使用战略方式来修改。最理想的情况就是修改完以后,仿佛原来的设计已经考虑到了当前的需求一样。
在修改过程中常遇到的问题是修改的方式和原来的设计冲突了,或者现有的设计无法满足当前的修改。如果重构设计,就会导致新的代码和原来的设计不一致,增加了认知负担。重构可能会和项目进度冲突,但是还是值得做的一件事情!
还有一个比较容易出错的地方是维护代码和注释的一致性。修改代码以后,忘记同步注释了。写注释的时候有两个要点可以减少错误的发生。一个是把注释放在最靠近需要说明的代码地方,比如流程注释放在签名部分,具体实现细节放在实现代码前面。还有一个是避免注释重复,减少同步地方。最后,在提交代码的时候记得查看diff记录,确保修改的内容和注释是一致的。
在这里一致性的意思说:相似的事情会用类似的方式处理,不相似的问题以不同的方式处理。
好处有两点:
通常,具有一致性有以下几个方面:
维护一致性不容易,有几个方法:
最后,不要为了一致性而一致性。比如,滥用设计模式,还有把不相似的事情很别扭的用相同的方式来处理都是不好的。
意图明显的代码一般具有以下几个特征:
让意图变得明显的方法:
一些意图明确常见场景:
现在有很多的编程范式和软件工程方法来解决软件复杂度问题。但是,不是说你用某个编程范式或者方法就能降低软件复杂度的。毕竟每个技术对应着具体的问题场景,不可能是全能的。所以,一定要理解每个技术背后的实质。文中提到几种技术:面向对象、敏捷开发、单元测试、测试驱动开发、设计模式以及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
**设计要简单。**这个最重要了!简单的设计一般性能不差!毕竟性能就是因为执行的步骤太多啦!
性能优化的一些常规思路: