《软件设计的哲学》解读
引言
本书的英文名称为《A Philosophy of Software Design》,由斯坦福大学教授、Tcl 语言发明者 John Ousterhout所著,在bookstack上有gdut-yy等人翻译的中文版。
很少有人能够在哲学层面来思考软件设计并成书,本书就是一个特殊的存在,而本文将带您一起领略这篇著作的思想精髓。
A Philosophy of Software Design
复杂性的表现
在作者看来,软件开发的复杂性主要从以下三个方面表现出来:
- 变更放大:看似简单的需求,却需要在许多不同地方进行修改
- 认知负荷:开发者必须花更多时间来学习所需知识
- 未知的未知:开发者不知道需要了解哪些信息、修改哪些地方才能完成任务
“对未知的未知”显然是三种表现里面最糟糕的一种,这些我们应该很容易理解,甚至可能深有体会。
软件开发复杂性的三种表现
复杂性的定义
我们已经知道了复杂性的表现,那么如何更为精确地表达复杂性呢?作者给出了一个数学公式,通过它,可以帮助我们更好地抓住复杂性的本质:
用数学方法表示复杂性
在公式中,系统的总体复杂度(C)由每个部分的复杂度(cp)乘以开发人员在该部分上花费的时间(tp)加权。
我们可以由此公式看出,软件系统开发的复杂度突出地表现为开发人员完成工作所需的时间。
复杂性的成因
在了解了“是什么”之后,我们进入“为什么”环节,来看一下有哪些原因会导致复杂性。
作者研究得出,导致复杂性的原因主要是以下两个方面:
- 依赖性:无法孤立地理解和修改给定的一段代码
- 模糊性:重要和关键的信息不够明显
复杂性的原因
过度的依赖性导致变化放大和高认知负荷,而信息模糊会产生对未知的未知,还会增加认知负担。
依赖关系是软件的基本组成部分,不能把它完全消除。软件设计的目标之一是减少依赖关系的数量,并使依赖关系保持尽可能简单和明显。模糊通常来自文档的不足,但也可以归为设计问题。如果系统设计简洁明了,则所需的文档将更少。
这样,软件开发复杂性的问题就转化为:如果我们找到最小化依赖关系和模糊性的设计技术,那么我们就可以降低软件的复杂性。
复杂性的规律
作者还告诉我们,复杂性是不断累积而来的,越到后期的修复成本越大。复杂性不是由单个灾难性错误引起的,而是随着时间的流逝,通过众多小的依赖性和模糊性逐渐发展而来的。
由于复杂性增量累积的特点,一旦累积了过多的复杂性,其修复成本也将是巨大的,开发人员妥协的可能性将更大,进而累积更多的复杂性。
复杂性的应对思维
按照套路,我们现在要研究“怎么办”了。
根据作者研究,要想驯服开发的复杂性,需要建立如下思维:
- 投资心态:当前进行少部分投入,可以避免将来大量投入
- 零容忍:对复杂性零容忍,避免累积过多,导致无法解决
所谓投资心态,就是开发时要有成本收益概念,强调要避免短视,要认识到现在的小投入,可避免将来更大的投入。
这里,作者提出了“战略编程”和“战术编程”两个概念,战术编程通常只顾完成当前任务,具有短视性,而战略编程则会考虑长远,会从投资心态看问题。一开始,战术性的编程方法将比战略性方法更快地取得进展。但是,在战术方法下,复杂性积累得更快,从而降低了生产率。随着时间的流逝,战略编程会显现出优势。
战略编程(Strategic)与战术编程(Tactical)定性对比
千里之堤毁于蚁穴,大错误都是由小错误不断累积起来导致的。所以,另一个重要的思维就是要零容忍,不能忽视和放过任何问题,而这通常是很难做到的。
复杂性的应对方法
如果把依赖关系也作为一种信息的话,就可以认为信息过载才是复杂性的根源,而战胜复杂性的方法就是想尽办法精简信息。简化信息,避免信息过载,就是要防止不必要的信息泄露,并避免重复信息。
随着技术的发展,会出现一些好的工具,来帮助我们管理和简化信息,但是这治标不治本,简化信息还是要从软件设计入手。
代码开发涉及的核心信息包括“在哪里”、“做什么”和“如何做”,其中“在哪里”信息对应于软件系统的设计和规约等,“做什么”对应于接口,“如何做”则对应于接口的实现。我们需要做的就是把“如何做”隐藏起来,并精简“在哪里”和“做什么”的信息。
因此,作者提出了“深模块”的概念,即接口简单,而实现“复杂”的代码块,这里的“模块”泛指我们通常所说的模块、类、方法等。从投资角度来说,模块带来的收益是其功能,模块的成本是其接口(就系统复杂性而言),模块的接口代表了模块强加给系统其余部分的复杂性:接口越小越简单,引入的复杂性就越小。因此,最好的模块是那些提供强大功能但具有简单接口的模块。
深模块(Deep Module)与浅模块(Shallow Module)
汇总一下,应对开发复杂性的方法,就是要通过设计手段,充分利用深模块技术,在避免信息泄露的同时,减少模块间依赖关系,从而降低文档需求,从各方面精简信息。
复杂性应对实操
为了帮助读者更好地应对软件开发的复杂性,作者还在实际操作层面进行了探讨,给出了很多具体指导意见和建议,有些观点甚至与流行做法有悖,我们一起来看一下。
关于私有字段:
通过声明变量和方法为私有来隐藏类中的变量和方法并不等同于信息隐藏,其往往通过属性或公共方法对外泄露了信息。
关于装饰器设计模式:
装饰器模式的动机是将类的专用扩展与更通用的核心分开。但是,装饰器类往往很浅,它们引入了大量的样板,以实现少量的新功能。
关于消除变量跨层传递的方法:
变量跨层传递带来复杂性。消除传递变量并不容易,包括共享对象、全局变量和最常用的上下文对象三种方法。
关于配置参数:
配置参数将复杂度给了调用方,反倒提升了使用的复杂度。
降低复杂度的决策:
如果被降低的复杂度与该模块的现有功能密切相关,或者降低复杂度将导致其他地方的许多简化,则降低复杂度最有意义。
关于日志记录:
消除日志记录浅方法的办法是将日志记录语句放置在检测到错误的位置。
关于方法的长度:
一个方法重要的是其深浅,而不是长度。如果方法的接口简单而实现复杂,则其长度并不重要。
关于异常处理:
异常处理是软件系统中最糟糕的复杂性来源之一,抛出异常很容易,处理它们则很困难。
关于注释:
代码内文档在设计中起着重要的作用,对于帮助开发人员理解系统和有效工作至关重要。注释还可以隐藏复杂性,没有注释,用户必须阅读代码才能使用方法,则方法的所有复杂性都将暴露出来。
低级注释可提高精度,高级注释可增强直觉。编写注释的最佳时间是在过程开始时,这不仅可以产生更好的文档,还可以产生更好的设计,并使编写文档的过程更加愉快。
确保注释更新的最佳方法是将注释放置在它们描述的代码附近,注释离其关联的代码越远,正确更新的可能性就越小。
如果注释比代码更高级,更抽象,则注释更易于维护。
关于命名:
良好的名字是一种文档形式,它们使代码更易于理解,减少了对其他文档的需求,并使检测错误更加容易。最好的命名是那些将注意力集中在对底层实体最重要的东西上,而忽略那些次要的细节。
良好名称具有两个属性:精度和一致性。
关于一致性:
一致性是降低系统复杂性并使其行为更明显的强大工具。一致性会产生认知影响力:一旦您了解了某个地方的工作方式,就可以使用该知识立即了解其他使用相同方法的地方。如果系统的实施方式不一致,则开发人员必须分别了解每种情况,这将花费更多时间。
关于继承:
继承会在父类及其每个子类之间创建依赖关系。父类和子类通常都访问父类中的类实例变量,这会导致继承层次结构中的类之间的信息泄漏,并且使得在不查看其他类的情况下很难修改层次结构中的一个类。在使用继承之前,请考虑基于组合的方法是否可以提供相同的好处。如果没有继承的可行选择,请尝试将父类管理的状态与子类管理的状态分开。
关于敏捷开发:
敏捷开发的风险之一是它可能导致战术编程。敏捷开发倾向于使开发人员专注于功能,而不是抽象,它鼓励开发人员推迟设计决策,以便尽快生产可运行的软件。渐进式开发通常是一个好主意,但是渐进式开发应该是抽象的,而不是功能。可以推迟对特定抽象的所有想法,直到功能需要它为止。一旦需要抽象,就要花一些时间进行简洁的设计。
关于测试:
测试,尤其是单元测试,在软件设计中起着重要作用,因为它们有助于重构。没有测试套件,对系统进行重大结构更改很危险。
测试驱动开发是一种软件开发方法,程序员可以在编写代码之前先编写单元测试。测试驱动的开发过于增量,在任何时间点,很容易破解下一个功能以进行下一个测试通过,没有明显的时间进行设计,因此很容易陷入混乱。
关于设计模式:
设计模式解决了常见的问题。如果设计模式在特定情况下运作良好,那么您可能很难想出另一种更好的方法。设计模式的最大风险是过度使用,使用现有的设计模式并不能完全解决所有问题,当自定义方法更加简洁时,请勿尝试将问题强加到设计模式中。与软件设计中的许多想法一样,设计模式良好的概念并不一定意味着更多的设计模式会更好。
关于性能:
干净的设计和高性能是兼容的,复杂的代码通常会很慢,因为它会执行多余或多余的工作。在少数需要优化性能的情况下,找到对性能最重要的关键路径并使它们尽可能简单。
复杂性应对总结
在书的最后,作者对全书观点进行了总结。
类似Martin Fowler写的《重构》一书,John Ousterhout教授也提出了一些设计上的“坏味道”,他称之为“危险信号”:
- 浅层模块:接口相对于其实现过于复杂,“收益”低于“成本”
- 信息泄漏:特定知识分散在多个不同地方
- 时间分解:不同模块间在执行顺序上有要求,泄露了时序信息
- 过度暴露:接口调用者为调用常用功能,而必须关注很少使用功能的调用
- 直通方法:方法几乎不做任何实事,只是传递参数
- 重复代码:重复的代码意味着信息的泄露
- 通专混合:特定用途代码与通用代码没有完全分开
- 关联方法:两方法之间的依赖性很大,使用其中一个之前必须了解另一个
- 冗赘注释:代码完全可以表达注释的意思
- 额外细节:接口注释向用户泄露了其不需关注的细节
- 模糊名称:变量或方法的名称太过模糊,无法传递有用信息
- 难以取名:很难为实体提供准确而直观的名称
- 难以描述:需要冗赘的文档才能描述清楚接口
- 晦涩代码:一段代码的行为或含义不容易理解
作者还总结了一些应对复杂性的设计原则:
- 复杂性是逐步增加的,勿因恶小而为之,勿因善小而不为
- 只是可以工作的代码还不够,需要进行规划,做一些降低复杂性的设计
- 持续进行少量投资以改善系统设计,一旦开始推迟设计改进,就很容易变成永久性推迟
- 模块应较深,深模块是隐藏信息的绝佳途径
- 接口应设计为对最常见的用法简单,接口的有效性等效于其最常见功能的复杂性
- 简单的接口重于简单的实现,接口更影响复杂性
- 通用模块更深,通用模块相比专用模块泄露的信息更少,往往更简单
- 通用和专用代码分开,下层代码更通用,上层代码更专用
- 不同的层应具有不同的抽象,不同的层有相似的抽象,意味着信息泄露
- 复杂度下沉,不要将问题抛给调用方
- 消除异常和特殊情况,在实现中自动处理异常和特例,以简化调用方
- 设计两次,每次通过提供备选方案来改善设计,并提升设计技能
- 注释应描述代码中不明显的内容,以使信息明朗
- 软件的设计应易于阅读而不是易于编写,应该让使用者简单
- 软件开发的增量应该是抽象而不是功能,敏捷开发中应及时进行抽象设计,避免功能堆砌
结语
到这里,我们已经一起完成了“是什么”、“为什么”、“怎么办”等问题的回答,对于“好不好”这个问题的答案,就留待我们实践中解答吧。
感谢作者和译者。