书评:软件设计哲学


这是来自henrikwarne的书评,banq有不同意见:
我真的很喜欢John Ousterhout 的A Philosophy of Software Design。它紧凑而简短,只有 170 页,因此可以快速阅读,但它包含了许多好主意。重点是如何构建系统以使其易于理解和使用。作者是斯坦福大学计算机科学教授,但他也花了 14 年时间开发商业软件。
 
复杂COMPLEXITY
这本书以一个关于复杂性的好章节开始。作者将复杂性定义为任何与软件系统结构相关的、难以理解和修改的事物。复杂性有三个症状:变更放大(变更需要在许多不同部分修改代码)、认知负荷(作为开发人员,您需要知道多少才能完成一项任务)和未知的未知数(它不是很明显需要修改程序的哪些部分才能完成任务)。
这些症状的原因是依赖和默默无闻。方法调用创建显式依赖项。但也有隐含的依赖关系。一个例子是当你实现了一个带有发送者和接收者的消息协议时。对发送方的任何更改通常也需要对接收方进行更改。模糊性通常与不明确的依赖关系有关。一个例子是如果你添加一个新的异常,然后你还需要在错误消息表中添加一个新条目,但它们之间的联系并不明显。
软件设计的目标是降低系统的复杂性。这是一项持续的活动,因为软件系统通常会不断修改,而且因为每一个微小的变化都会导致复杂性(“复杂性是增量的”在书中的几个地方重复)。
 
深层模块
本书的一个中心主题是模块应该深入。这意味着某些功能的接口应该比其实现简单得多。这样,理解和使用接口的成本低于实现功能的收益,从而有助于降低系统的整体复杂性。从这个意义上说,模块可以是从方法或函数到类或子系统的任何东西。
深模块的反面是浅模块。实现并不比接口大多少。直接使用接口而不是实现的好处不是很大,所以对降低系统的复杂度没有帮助。因此小模块通常是浅的。有趣的是,这与例如Clean Code中的建议完全相反。那里的口头禅是使用许多小的类和方法,而不是一些较大的类和方法。
深层模块的一个例子是用于 I/O 操作的 Unix/Linux 系统调用。其中有五个(open、read、write、lseek、close),方法很简单,但它们隐藏了大量关于如何在磁盘上存储和访问文件的实现细节。
相比之下,浅层模块的一个例子是 Java I/O 类。为了打开和读取文件,您需要使用FileInputStream、 BufferedInputStream和 ObjectInputStream. 
功能本可以由一个这样的类提供,从而减少所需的样板代码。
此外,通常需要的功能,例如缓冲 I/O,应该是默认行为,只有在不常见的情况下才需要额外的参数或设置。这也将有助于降低系统复杂性。
与深层模块的概念相关的是使它们“有点通用”的建议。作者的意思是,接口应该足够通用以支持多种实现,即使实现只涵盖了今天所需要的。将 API 减少到一些通用方法而不是许多特殊用途将做到这一点并使模块深入。
书中给出的交互式文本编辑器的示例,其中多个函数,如delete、backspace、deleteSelection等,被更通用的函数insert和delete取代。这导致了更清晰的功能划分以及简化的界面。
最后,与深层模块也有些相关的是不同层应该使用不同抽象的想法。例如在 TCP 中,顶级抽象是字节流的抽象。较低级别使用可以丢失或重新排序的数据包的抽象。如果相邻层包含相同或非常相似的抽象,也许应该将它们组合起来以创建更深的模块。这种情况的一个迹象是存在传递方法——这些方法只不过是调用具有相同或非常相似签名的其他方法。
(banq注:抽象成深层模块会导致复杂性,兔子洞,过于抽象会抽象泄露,很多以后的上下文不是你当时能够想象到的,文件操作这些操作系统级别的功能都已经稳定,因为操作系统本身已经很稳定,与应用界限很明显。深层模块会导致过早优化,这对于经常变化的需求是不利的)
 
不要抛出异常
如果您的代码抛出异常,则您将迫使该代码的所有调用者准备好在发生异常时进行处理。您经常抛出异常,因为您不知道在这种情况下该做什么。但是,如果您不知道该做什么,那么调用者很可能也不知道该做什么。
如果您可以定义了您的功能,使其永远不需要抛出异常,那么您就降低了系统的复杂性。
作者举了几个很好的例子。在作者创建的脚本语言Tcl中,unset指令删除了一个变量。如果变量不存在,则抛出错误。然而,事实证明unset最常见的用途是清理由先前操作创建的临时状态。但是很难知道之前的操作进行了多远,因此很难知道是否创建了变量。因此,您必须准备好在很多地方处理未设置的异常。如果不设置抛出异常,它反而会更有用,更简单。
另一个例子是Java 中的substring方法。如果较低的索引低于零,或者较高的索引超出字符串长度,则抛出IndexOutOfBoundsException。这会强制调用者在调用substring之前处理这些情况。相反,您可以将其定义为返回索引大于或等于beginIndex且小于endIndex的字符串(如果有)的字符。这样就不需要抛出异常,大大简化了使用。当为超出范围的列表切片返回空结果时,Python 会做类似的事情。
其他策略包括掩码,在较低级别捕获和处理异常,因此调用者不必这样做,以及异常聚合,减少必须处理的异常数量。定义不存在的案例的策略也可以用于特殊案例。一个例子来自文本编辑器,有时选择文本,有时不选择文本。使用布尔值明确跟踪是否有选择会导致许多特殊处理。相反,如果您允许空选择零个字符,则不需要特殊处理。
(banq注:异常抛出是表示当前逻辑的意味,当前逻辑代表一种职责,如果超出它的职责范围,应该报错,这是边界明显的案例,反之,为了不抛出错误,会纳入更多职责和功能,增加复杂性)
 
优化
优化章节首先列出了各种操作所需的典型时间,例如网络通信、存储(磁盘、闪存)的 I/O、内存分配和缓存未命中。它继续遵循始终测量而不是假设的标准建议。在优化时,最大的收益通常可以通过根本性的变化来获得。例如通过引入缓存,或改变算法或数据结构。
之后,您必须专注于加速最常执行的路径。想象一下这个案例的理想代码是什么样的。目前可能使用了多个方法调用,但也许所有的工作都可以在一个方法调用中完成。如果一开始就可以排除所有的特殊情况,那么之后就不需要检查了。也许所有需要的数据都可以组合在一个单一的数据结构中。这条快速理想的关键路径是您正在努力的目标。下一步是重新排列要优化的代码,以尽可能接近这种理想情况。与前几章的联系是深模块比浅模块更有效,因为每个方法调用都完成了更多的工作。本章以来自RAMCloud 的一个很好的例子结束系统,这种思想被应用于缓冲区分配代码,对于最常见的操作,速度提高了两倍。
(banq注:过早优化是噩梦,关键还是取决于上下文,对于操作系统级别的上下文,非常成熟,没有未考虑到的问题,问题空间已经明朗,就可以大胆干,大胆折腾优化,但是如果摸着石头过河情况下的探索和创新,这时的目标是摸清情况,及时纠正错误,而优化则可能造成错误的原因)
 
代码注释
作者是写大量代码注释评论的大力支持者。他在第 103 页写道:“每个类都应该有一个接口注释,每个类变量都应该有一个注释,每个方法都应该有一个接口注释”。这是过分的。
注释的问题在于它们可能与代码不同步,并且它们会阻塞代码。因此,只有在没有其他方法时才应该使用它们。在大多数情况下,为方法和变量精心选择的名称使得注释变得不必要。如果代码很复杂,需要用注释来解释,或许可以重写一下,使其更清晰。
(banq注:如果你有时间编写大量注释,不如花力气把函数类设计得更简单一目了然一些。)