《A Philosophy of Software Design》#

作者 John Ousterhout

为什么要关注软件的复杂度#

从开发的视角来看,软件设计遇到的最基础的问题就是解构功能:如何将一个复杂的问题分解成能够被独立解决的小问题。这是一个熵减的过程。而从软件整体的视角来看,恰恰相反,随着加入的功能越来越多,维护的人员规模越来越庞大,是一个复杂性逐渐升高的过程。从另外一个角度说,软件开发时最大的一个限制就是如何去理解我们已经创造的这个系统。

代码写出来是给人读的。机器执行只认机器码,代码写的再好也没用。但是软件系统除了运行以外,还有一个作用是能让除了写代码的你以外的人看懂。代码复杂度越低,从另外一个角度说,系统的鲁棒性也越强。因为有越多的人能读懂你的代码,review你的代码,代码中潜在的bug也越容易被发现。所以,作为开发者,我们的工作不仅是编写我们自己维护更轻松的代码,更要是编写所有人维护更轻松的代码。

如何降低软件整体的复杂度,作者在文中提到了两种方法:

  • 让代码更加简洁明了。这个也有一个著名的KISS原则:Keep It Simple and Stupid。比如,忽略一些特殊的cases。
  • 将复杂性封装在黑盒,通过接口提供易用的功能。这种方式也被称作模块化设计。在这种情况下,局部系统的复杂性并不会显著增加整体系统的复杂性。

复杂度的表现#

复杂度可以有三种表现形式:

  • Change amplification:如果一个简单的变更需要我们在多个不同的地方修改相同的代码时,这时复杂度的第一个表现;
  • Cognitive load:第二个复杂度的表现是开发者要完成一项任务,他需要了解多少前置知识。这个需要花的时间越多,潜藏忽略的信息就越多,完成的任务里存在bug的可能性越大;有时候,某种方法可能需要更多简单的代码实现,但它可能是更好的方法,因为它能降低其他开发者的心智负担。
  • Unknown unknowns:第三个表现就是完成功能需要修改某处代码,但是这部分代码十分隐蔽,看起来与要完成的功能都毫无关联。这种情况是最糟的。因为前两种情况都可以通过花费更多的精力来解决,但是Unknown unknowns在于你根本没有意识到这一点。可能直到bug出现了才发现这个问题。

一个好的系统设计是明了清晰。两种情况会显著增加系统的复杂性:

  • 依赖过多,表现在代码不能被独立地理解和修改,必须先要搞明白或者修改另外一块代码;
  • 代码晦涩难懂,出现这种情况绝大部分是因为文档的不完善。不过,优秀的设计需要的文档会更少,如果代码需要大量的文档,通常也说明设计存在一定的问题。

如何避免复杂度增高#

在开发过程中,要始终秉承一个思路:==我们的首要目标不仅仅是开发迭代一个功能,而是产出一个优秀的设计,这个设计正要能满足我们的功能需求==。这是一种更高层次地,从战略地眼光去看待软件开发。如果我们仅仅关注眼下的功能,一味地追求效率,留给后来者的很可能是一个烂摊子,俗称“屎山”。这种方式是非常短视的。

要做到这一点,就需要我们在开发中不断迭代系统的设计,使系统更符合当下的需求。作者建议花费总开发时间的10%-20%去改善系统设计,一方面不会过多地影响整体进度,另一方面也对后续的开发有着非常积极的意义。

当然,对于雇主来说,最好的降低开发成本的方式是雇佣优秀的工程师,相比平庸的工程师,他们的价钱更高点,但是能极大地提高生产效率。

设计深模块#

所谓的深模块指的是模块里面尽量包括更多的复杂性,然后通过简单的接口暴露给用户。 这样做的目的是减少模块之间的依赖。

image-20220319190303080

模块由两部分组成:接口和实现。接口包括了开发者想要使用这个模块所需知道的所有信息,即模块能干什么。接口不仅仅是传参,返回值,还包括了接口的注释,错误信息等等。

实现包括了模块为了实现这个承诺所有的代码,即怎么做的。

接口尽量简单,这样能减少模块给系统其他部分带来的复杂度。实现需要包括尽可能多的功能,可能有些人的想法是将每个功能都独立成一个模块,这样的缺点是整个系统的模块数增多,导致模块间的依赖也变多了,反而增加了复杂度。合理的方式应该是把相近的功能尽可能的合并到一个模块中。

如果考虑到有些用户对模块有定制化的需求,最好的办法也是通过默认值等方式,让最通用的case尽量简单,同时提供一些自定义修改的参数。

减少Errors的定义#

在模块中,过多地返回定义地errors其实是一种懒惰地行为。因为很多时候,模块里不知道怎么处理错误,调用者也不会知道怎么处理错误。反而让调用者需要考虑到各种异常case,增大了代码的复杂度。如果模块有太多的异常需要处理,会增加接口的复杂性,也就是上面说的浅模块。

减少Errors的几种方法:

  1. 最好的方式是定义一个没有errors需要处理的API,这是最能简化软件的方式;
  2. 第二种方法是能够让errors在模块内部消化,不对外暴露;
  3. 第三种方法是聚合多个异常,抛出一个统一的异常,这样也能减少调用者的代码量;
  4. 第四种方法就是打印错误信息,直接crash程序。这些异常通常不值得处理,并且也很难处理,发生的概率也很低。

设计第二种方案#

设计第二种方案是从设计的角度降低复杂度的方式。好的设计能从根本上降低软件的复杂度。软件设计很难,所以大多数时候我们的第一个想法并不一定是最好的设计。考虑多个可能性,对比他们之间的优劣,选择最好的一个,或者将他们取长补短组合起来,形成新的方案。

注释#

写注释的过程,也是一个改善系统设计的过程。好的注释能够给软件质量带来很大的提升。

要不要写注释主要取决于两点:

  1. 函数是否足够简单,用户能一眼看出实现的功能;
  2. 函数名称和函数声明是否能够表达出函数的功能。

如果不能达到这两点之一,那么注释就是必要的。不能因为不想写注释而把函数拆成多个简单的接口,增加系统的复杂度。

注释能够携带一些代码内看不到的信息。这些信息可能是设计者写代码的想法,有什么考虑因素。这些是在代码中无法体现的。注释本身也是抽象的一部分,对于复杂函数的注释,也更容易让用户了解接口的功能,以及检查接口的实现是否和设计者所期望的一致。

写注释最好的时间是在写代码之前。注释是对代码的总结,而不是简单的重复。

Summary of Design Principles#

Here are the most important software design principles discussed in this book:

  1. Complexity is incremental: you have to sweat the small stuff (see p. 11).
  2. Working code isn’t enough (see p. 14).
  3. Make continual small investments to improve system design (see p. 15).
  4. Modules should be deep (see p. 22)
  5. Interfaces should be designed to make the most common usage as simple as possible (see p. 27).
  6. It’s more important for a module to have a simple interface than a simple implementation (see pp. 55, 71).
  7. General-purpose modules are deeper (see p. 39).
  8. Separate general-purpose and special-purpose code (see p. 62).
  9. Different layers should have different abstractions (see p. 45).
  10. Pull complexity downward (see p. 55).
  11. Define errors (and special cases) out of existence (see p. 79).
  12. Design it twice (see p. 91).
  13. Comments should describe things that are not obvious from the code (see p. 101).
  14. Software should be designed for ease of reading, not ease of writing (see p. 149).
  15. The increments of software development should be abstractions, not features (see p. 154).

Summary of Red Flags#

  • Shallow Module: the interface for a class or method isn’t much simpler than its implementation (see pp. 25, 110).
  • Information Leakage: a design decision is reflected in multiple modules (see p. 31).
  • Temporal Decomposition: the code structure is based on the order in which operations are executed, not on information hiding (see p. 32).
  • Overexposure: An API forces callers to be aware of rarely used features in order to use commonly used features (see p. 36).
  • Pass-Through Method: a method does almost nothing except pass its arguments to another method with a similar signature (see p. 46).
  • Repetition: a nontrivial piece of code is repeated over and over (see p. 62).
  • Special-General Mixture: special-purpose code is not cleanly separated from general purpose code (see p. 65).
  • Conjoined Methods: two methods have so many dependencies that its hard to understand the implementation of one without understanding the implementation of the other (see p. 72).
  • Comment Repeats Code: all of the information in a comment is immediately obvious from the code next to the comment (see p. 104).
  • Implementation Documentation Contaminates Interface: an interface comment describes implementation details not needed by users of the thing being documented (see p. 114).
  • Vague Name: the name of a variable or method is so imprecise that it doesn’t convey much useful information (see p. 123).
  • Hard to Pick Name: it is difficult to come up with a precise and intuitive name for an entity (see p. 125).
  • Hard to Describe: in order to be complete, the documentation for a variable or method must be long. (see p. 131).
  • Nonobvious Code: the behavior or meaning of a piece of code cannot be understood easily. (see p. 148).