John Ousterhout是斯坦福的计算机教授,同时也是Tcl语言设计者,Raft协议的发明者之一。他最近出了一本新书《A Philosophy of Software Design》,亚马逊上全是5星好评,豆瓣上评分9.2,在Youtube上也有一个他在Google做的关于这本新书的介绍。我看了前面几章,根据书评,已及上面的演讲做了以下摘要。
什么是现在计算机科学中最为核心的Concept,Donald Knuth认为是layers of abstraction,John认为是Problem Decomposition,如何把复杂的问题分解。不过他并没有看到已有的课程注重培养学生相关的能力,因而诞生了开一门教授相关技能的课程,也就是Stanford CS190 Software Design Studio的来源(这门课有非常多coding和coding review的内容,不适合自学)。书的内容也大多是来源课程。
什么是软件的复杂性,作者给了一个定义
Complexity is anything related to the structure of a software system that makes it hard to understand and modify the system.
以下三种情况都是软件复杂性的体现。
- Change Amplification:修改扩散化
当我们在想要添加一个新的feature,或者修改某一处的功能表现,我们需要修改多处地方。
例如,如果我们使用字面常量去
- Cognitive load:认知负荷
当出现认知负荷过重时,工程师在系统中修改或是添加一个新的功能需要花费更多的时间去了解已有的信息,并且非常可能由于遗漏了部分信息导致bug。
- Unknown unknowns:无法知晓的未知事件
这指的是,对于功能的更改,工程师无法清楚得确认应该修改哪里,或者无法知晓他应该有哪些信息,才能确认事情是做对得。举个例子, 如果工程师在 下图的(c)上修改banner的背景,从设计上,只需要修改bannerBg是不够的,应该部分的页面还有emph元素,因而,他修改之后可能需要进行检查,才能发现,部分页面与需求的预期是不符的。
作者认为,复杂来源于依赖以及晦涩。
Complexity is caused by two things: dependencies and obscurity.
依赖是软件设计引入,每当我们新写一个class时,我们就在系统中引入一个依赖。他是软件的基础,几乎不可消除。软件设计的目的是尽量减少依赖,简化并清晰化这些依赖。
晦涩是第二个来源,例如不恰当的变量命名,例如函数名命名为time,它不是明确的,实际可用的信息量是非常少的。例如我们在文档中,并没有标注具体的参数的单位是什么,依赖于我们去遍历查找模块使用的地方进行确认。
依赖导致了修改扩散化以及认知负荷加重,而模糊不清则导致了Unknown unknowns以及认知负荷过重。
复杂性不是来源于某一个依赖,或者是模糊不清,而是来源于成千上百个类似的小的依赖或是模糊不清的代码,它最终导致系统复杂化,难以修改。
如何降低软件的复杂性,作者提供了一些实际可操作实践的方法,以下是部分摘要
使用战略性编程而不是战术性编程。战术性编程追求快速完成功能,它往往会带来技术栈,导致系统的复杂度上升。战略性编程则是明确 working code isn't enough,他关注于功能的实现,以及未来,期望一个好的系统设计。
但是好的设计并不是免费,他需要投入时间成本,并且在短时间内,不能看到显著效果。
现在的软件系统是有生命周期的,并且投入设计的时间也并非越长越好。
一个有效的方案是,每一位工程师都持续得,花费少量的时间在系统设计上。
模块可能以各种形式存在,例如一个类,一个子系统,或者是一个服务。他包括了接口与具体实现部分。
下图中长方形的宽表示模块的接口复杂度,而高度表示具体的接口实现。模块的调用者应当不需要知晓具体的实现,他关注的是模块提供的接口。接口的复杂度决定了调用者所需要知晓的消息的多少,以及对于模块的依赖的多少。有深度的模块,对于系统的调用者更为友好,它的引入,相对于Shallow module,能够减少系统的复杂度。
作者举了Unix文件操作的例子:
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(int fd);它只提供了5个对于文件系统操作的接口,然后它背后的实现极为复杂。它需要解决以下问题
文件如何在磁盘上存储
文件树如何组织
权限管理系统如何运作的
如何兼容不同的文件存储介质
如何调度对于文件系统的并发写入
如何在内存系统中缓存磁盘文件系统
...
...
另一个案例则是具有垃圾回收的语言,例如Golang或是Java。他们通过垃圾回收机制,完全隐藏了回收对象占用的堆内存的接口,类似的机制也是显著得降低了系统的复杂性。
好的命名能够发挥文档的作用,命名包含的信息能够提高代码的可读性。错误,或者是晦涩,模糊不清的名字则会提高阅读者/使用者的心智负担,甚而导致bug。
作者举了一个案例,在80年代末,90年代初,作者与他的研究生开发了一个分布式操作系统Sprite。由于一个未知的bug,这个系统会经常性得宕机。后来作者花了六个月时间,才最终定位到这个bug。在文件系统中,他们使用block这个变量作为文件的逻辑块号,或者是物理块号。在某一段代码中,一个逻辑块号的block变量被使用为物理块号,这导致磁盘中的一个不相关的块的数据被覆盖写入了。作者和他的学生当时阅读代码时,看到这个block变量被赋予一个逻辑块号,认为他一定会当作一个逻辑块号使用。
Take a bit of extra time to choose great names, which are precise, unambiguous, and intuitive.
花点时间在命名上的投入是值得的,他使得你/他人的工作变得更为简单,并将有助于减少bug。类似的训练多了,你会发现命名也变得更多简单。
在开发一个模块时,如果遇到异常,我们通常会抛出异常,并且认为模块的职责已经完成。
异常能够跳出当前的堆栈,当我们调用一个依赖模块时,可能抛出一个模块依赖的模块的异常。处理异常的代码,通常会比正常的逻辑的流的代码复杂。并且,处理异常的代码,可能会带来更多的异常。由于这些特性,异常是复杂性的一个来源。
作者认为更好的方案是:重新定义方法的语义,尽可能消除异常。
作者举了一些错误的案例:
-
windows系统的对于打开文件的处理:
当用户删除一个正在打开的文件时,会抛出一个无法删除的异常。用户为了删除文件,需要查找打开文件的进程,并关闭进程。有些时候,只能通过重启系统,然后再删除文件。Unix系统的做法是,用户可以删除已打开的文件,文件系统会将文件标记为已删除,然后返回成功。标记为删除后,其他的进程不能看到这个文件,所以也无法打开。而原先打开文件的进程可以读取,写入对应的文件。
-
Tcl的unset方法:
Tcl定义了一个unset方法,用于移除一个变量。作者定义unset方法时,初衷是如果开发者unset一个不存在的变量,那应该是一个bug,因而,他定义unset方法删除一个不存在的变量时,则会抛出异常。
unset方法最常用于清理前一个操作创建的一系列临时状态。如果逻辑中可能提前提出,部分的状态甚而不穿创建出来。因而在使用unset方法时会抛出异常,开发者需要使用catch语句捕获类似的异常。
回头来看,作者认为这是他在创建Tcl语言过程中所犯的最大的错误之一。
The best way to reduce the complexity damage caused by exception handling is to reduce the number of places where exceptions have to be handled.
最好的降低异常对于软件复杂度的影响是,尽量减少需要处理的异常。
以上只是一些摘要,其他部分有兴趣的同学可以去看下书,或者看下他在google的演讲。
references


