软件设计哲学 1-5
原书:
2018 ed : https://milkov.tech/assets/psd.pdf
2021 ed : https://web.stanford.edu/~ouster/cgi-bin/aposd2ndEdExtract.pdf
复杂性
症状
- 变更放大:看似简单的变更需要在许多不同地方进行代码修改。
- 认知负荷:开发人员需要多少知识才能完成一项任务。较高的认知负担意味着开发人员必须花更多的时间来学习所需的信息,并且由于错过了重要的东西而导致错误的风险也更大。
- 未知的未知: 必须修改哪些代码才能完成任务,或者开发人员必须获得哪些信息才能成功地执行任务,这些都是不明显的。
原因
依赖性
依赖关系是软件的基本组成部分,不能完全消除。实际上,我们在软件设计过程中有意引入了依赖性。每次编写新类时,都会围绕该类的 API 创建依赖关系。但是,软件设计的目标之一是减少依赖关系的数量,并使依赖关系保持尽可能简单和明显。
模糊性
当重要的信息不明显时,就会发生模糊。一个简单的例子是一个变量名,它是如此的通用,以至于它没有携带太多有用的信息(例如,时间)。或者,一个变量的文档可能没有指定它的单位,所以找到它的惟一方法是扫描代码,查找使用该变量的位置。晦涩常常与依赖项相关联,在这种情况下,依赖项的存在并不明显。
复杂度会递增
复杂性不是由单个灾难性错误引起的;它堆积成许多小块。单个依赖项或模糊性本身不太可能显着影响软件系统的可维护性。之所以会出现复杂性,是因为随着时间的流逝,成千上万的小依赖性和模糊性逐渐形成。最终,这些小问题太多了,以至于对系统的每次可能更改都会受到其中几个问题的影响。
复杂性的增量性质使其难以控制。可以很容易地说服自己,当前更改所带来的一点点复杂性没什么大不了的。但是,如果每个开发人员对每种更改都采用这种方法,那么复杂性就会迅速累积。一旦积累了复杂性,就很难消除它,因为修复单个依赖项或模糊性本身不会产生很大的变化。为了减缓复杂性的增长,您必须采用第 3 章中讨论的“零容忍”理念。
结论
复杂性来自于依赖性和模糊性的积累。随着复杂性的增加,它会导致变化放大,高认知负荷和未知的未知数。结果,需要更多的代码修改才能实现每个新功能。此外,开发人员花费更多时间获取足够的信息以安全地进行更改,在最坏的情况下,他们甚至找不到所需的所有信息。最重要的是,复杂性使得修改现有代码库变得困难且冒险。
战术编程 ? 战略编程 !
战术编程
战术编程的问题是它是短视的。如果您是战术编程人员,那么您将尝试尽快完成任务。也许您有一个艰难的期限。因此,为未来做计划不是优先事项。您不会花费太多时间来寻找最佳设计。您只想尽快使某件事起作用。您告诉自己,可以增加一些复杂性或引入一两个小错误,如果这样可以使当前任务更快地完成,则可以。
但是很遗憾,在现在项目(产品)经理当道的时代,大部分程序的生命周期短的惊人。 这种情况下,开发也是在一次又一次的画饼下,被欺骗,导致投入项目的心血付之东流。 这也许也是造成大面积战术编程的原因之一吧。
战略性编程需要一种投资心态。您必须花费时间来改进系统的设计,而不是采取最快的方式来完成当前的项目。这些投资会在短期内让您放慢脚步,但从长远来看会加快您的速度,如图所示。

战略编程
成为一名优秀的软件设计师的第一步是要意识到仅工作代码是不够的。引入不必要的复杂性以更快地完成当前任务是不可接受的。最重要的是系统的长期结构。任何系统中的大多数代码都是通过扩展现有代码库编写的,因此,作为开发人员,最重要的工作就是促进这些将来的扩展。因此,尽管您的代码当前必须工作,但您不应将“工作代码”视为主要目标。您的主要目标必须是制作出出色的设计,并且这种设计也会起作用。这是战略计划。
投资多少时间?
随着您对系统的了解,理想的设计趋于零碎出现。因此,最好的方法是连续进行大量小额投资。我建议您将总开发时间的 10%
到 20%
用于投资。该金额足够小,不会对您的日程安排产生重大影响,但又足够大,可以随着时间的推移产生重大收益。因此,您的初始项目将比纯战术方法花费 10-20%
的时间。
模块应该是深的
模块化设计
最好的模块是那些其接口比其实现简单得多的模块。这样的模块具有两个优点。首先,一个简单的接口可以将模块强加于系统其余部分的复杂性降至最低。
其次,如果以不更改其接口的方式修改了一个模块,则该修改不会影响其他模块。 如果模块的接口比其实现简单得多,则可以在不影响其他模块的情况下更改模块的许多方面。
深模块
最好的模块很深:它们允许通过简单的接口访问许多功能。
浅层模块是具有相对复杂的接口的模块,但功能不多:它不会掩盖太多的复杂性。
模块深度是考虑成本与收益的一种方式。模块提供的好处是其功能。
模块的成本(就系统复杂性而言)是其接口。
模块的接口代表了模块强加给系统其余部分的复杂性:接口越小越简单,引入的复杂性就越小。最好的模块是那些收益最大,成本最低的模块。接口不错,但更多或更大的接口不一定更好!
浅模块
浅层模块是一个接口相对于其提供的功能而言复杂的模块。
浅层模块在对抗复杂性方面无济于事,因为它们提供的好处(不必了解它们在内部如何工作)被学习和使用其接口的成本所抵消。小模块往往很浅。
经典主义
不幸的是,深度类的价值在今天并未得到广泛认可。编程中的传统观点是,类应该小而不是深。经常告诉学生,类设计中最重要的事情是将较大的类分成较小的类。对于方法,通常会给出相同的建议:“任何长于 N
行的方法都应分为多种方法”(N
可以低至 10
)。这种方法导致了大量的浅类和方法,这增加了整体系统的复杂性。
“类应该小”的极端做法是我称之为“类炎”的综合症,这是由于错误地认为“类是好的,所以类越多越好”。在遭受类炎的系统中,鼓励开发人员最小化每个新类的功能:如果您想要更多的功能,请引入更多的类。分类炎可能导致个别地简单的分类,但是却增加了整个系统的复杂性。小类不会贡献太多功能,因此必须有很多小类,每个小类都有自己的接口。这些接口的累积会在系统级别产生巨大的复杂性。小类也导致冗长的编程风格,这是由于每个类都需要样板。
这部分是听到的很新鲜的观点,确实之前被经验主义的教条规训为,类,方法,都应该尽可能地小,不应该过大,要让别人看得懂。
但是这里强调的是,应该隐藏一些细节,以减小复杂性,有时候别人并不需要知道具体的实现,只需要通过文档或者注释知道,这个类或者方法的作用即可。
过度的拆分只会徒增他人的使用成本。
上面说的 N 行的方法,个人认为 N 为一个屏幕之内的行数即可,方便阅读代码可以进行一次性全量阅读,具体可为
40
-50
不等。
下图来自《Uinx 编程艺术》:

Java and Unix I/O 示例:Java 和 Unix I/O
之后书中举了一个例子,吐槽
Java
的类库分的很小,一个IO
操作需要创建一堆对象,只会用到最后的那个。个人还是认同的,针对最常用的,应该提供一些默认行为,尽可能向调用端收敛复杂度,也许以后默认需要发生改变,这个也是可通过添加新的方法进行处理的。
结论
通过将模块的接口与其实现分开,我们可以将实现的复杂性从系统的其余部分中隐藏出来。模块的用户只需要了解其接口提供的抽象。
设计类和其他模块时,最重要的问题是使它们更深,以使它们具有适用于常见用例的简单接口,但仍提供重要的功能。这使隐藏的复杂性最大化。
信息隐藏
设计新模块时,应仔细考虑可以在该模块中隐藏哪些信息。如果您可以隐藏更多信息,则还应该能够简化模块的界面,这会使模块更深。
个人认为这里是存在一个度的,应该对机制需要保持开放,对策略可以进行隐蔽。
我要去火星,应该对各种交通工具保持一个开放的选择交给调用方,对坐火箭去的策略则可以保持隐藏,调用方只需要知道调用这个函数,立马升天。
这里也可以理解为,对最上层保持开放,最深,最底层的模块保持隐藏。
时间分解
在设计模块时,应专注于执行每个任务所需的知识,而不是任务发生的顺序。
在时间分解中,执行顺序反映在代码结构中:在不同时间发生的操作在不同的方法或类中。如果在执行的不同点使用相同的知识,则会在多个位置对其进行编码,从而导致信息泄漏。
一些示例
只要有可能,类就应该“做正确的事”,而无需明确要求。默认值就是一个例子。
Golang 的参数默认值,只能
func afunc(args ...interface{})
然后自己再断言使用,某一值不存在即可为调用者装填上默认值。对此,回答如下: https://go.dev/doc/faq#overloading
Regarding operator overloading, it seems more a convenience than an absolute requirement. Again, things are simpler without it.
大概就是,可能会更方便,但是这不是绝对需要的。
微软探头:“好用吗?砍🔪了”,断舍离者狂喜。
如果可以减少使用变量的位置的数量,则将消除类内的依赖关系并降低其复杂性。
仅当在其模块外部不需要隐藏信息时,隐藏信息才有意义。重要的是要识别模块外部需要哪些信息,并确保将其公开。