读书记录《软件设计的哲学(第2版)》
- 阅读
书名:A Philosophy of Software Design (2nd Edition)
评价:8.5/10;一开始是看到封面感觉很棒,于是就找来读了下;不是很长,三四个小时就能读完;虽然内容比较基本,但是能系统化的重新复习下也挺好的
版本:anna’s archive,llm翻译为中文
- 软件设计的原则
- 复杂性的管理
- 复杂性源自依赖和晦涩的累积;随着复杂性增加,它导致变更放大、高认知负荷以及未知的未知因素
- 变更放大:一个看似简单的改动需要在多处修改代码
- 认知负荷:为了进行更改,开发者必须积累大量信息。
- 未知的未知:尚不清楚需要修改哪些代码,或者为了进行这些修改必须考虑哪些信息。
- 因此,实现每个新功能需要更多的代码修改。此外,开发人员花费更多时间获取足够的信息以安全地进行更改,在最糟糕的情况下,他们甚至无法找到所需的所有信息。底线是,复杂性使得修改现有代码库变得困难且充满风险。
- 向下转移复杂性最有意义的情况是:(a)被转移的复杂性与类的现有功能紧密相关,(b)转移复杂性将导致应用程序其他部分的简化,以及(c)转移复杂性简化了类的接口。记住,目标是最小化整个系统的复杂性。
- 复杂性源自依赖和晦涩的累积;随着复杂性增加,它导致变更放大、高认知负荷以及未知的未知因素
- 模块化与接口设计
- 在设计类和其他模块时,最重要的议题是使它们具有深度,以便为常见用例提供简单的接口,同时仍能提供重要的功能。
- 在将系统分解为模块时,尽量避免受到运行时操作顺序的影响;这会导致时间分解,从而引发信息泄露和浅层模块。
- 软件设计中最关键的要素之一就是确定谁需要知道什么,以及何时需要知道。当细节至关重要时,最好将它们明确且尽可能显而易见地展现出来
- 多个方法可以拥有相同的签名,只要它们各自提供有用且独特的功能。
- 代码简化与重构
- 在编写详细代码时,简化代码最有效的方法之一是消除特殊情况
- 特殊情况可能导致代码充斥着if语句,这使得代码难以理解且容易产生错误。因此,应尽可能消除特殊情况。最佳的做法是通过设计正常情况,使其自动处理边缘条件,而无需额外代码。
- 如果你为了减少方法数量而不得不引入大量额外参数,那么你可能并没有真正简化问题
- 复杂性的管理
- 从基础开始
- 变量与方法的命名规范
- 因此,你不应满足于仅仅是“合理接近”的命名。花一些额外的时间来挑选精确、无歧义且直观的优秀名称。这份额外的关注将很快得到回报,随着时间的推移,你将学会迅速选择好的名称。
- 名称“cursorVisible”传达了更多信息;例如,它让读者能够猜测真值的含义(通常情况下,布尔变量的名称应始终为谓词形式)。名称中不再包含“blink”一词,因此如果读者想知道为什么光标并非始终可见,他们需要查阅文档;这部分信息相对不那么重要。
- 如果你发现很难为一个特定变量想出一个既精确、直观又不太长的名字,这是一个警示信号。这表明该变量可能没有明确的定义或目的。当这种情况发生时,考虑采用其他分解方法。例如,也许你试图用一个单一变量来表示多个事物;如果是这样,将表示分解为多个变量可能会使每个变量的定义更简单。选择好名字的过程可以发现设计中的弱点,从而改进你的设计。
- 名称中的每个单词都应提供有用信息;那些无助于阐明变量含义的词汇只会增加冗余(例如,它们可能导致更多行换行)。一个常见的错误是在名称中添加诸如“field”或“object”之类的通用名词,比如“fileObject”。在这种情况下,“Object”这个词很可能并未提供有用信息(是否存在不是对象的文件?),因此应从名称中省略。
- 杰兰德的一个观点我深表赞同:“一个名称的声明与其使用之间的距离越远,该名称就应该越长。”之前关于使用名为i和j的循环变量的讨论,正是这一规则的例证。
- 代码结构的清晰性与可读性
- 仅凭方法的长度本身很少是拆分方法的充分理由。通常情况下,开发者倾向于过度拆分方法。拆分方法会引入额外的接口,增加了复杂性。同时,它将原方法的各个部分分离,如果这些部分实际上是相关的,这会使代码更难以阅读。除非拆分方法能使整个系统变得更简单,否则不应进行拆分
- 长方法并不总是坏事。例如,假设一个方法包含五个20行代码的块,这些块按顺序执行。如果这些块相对独立,那么方法可以逐块阅读和理解;将每个块移到单独的方法中并没有太大好处。如果这些块之间有复杂的交互,那么将它们放在一起就更为重要,以便读者可以一次性看到所有代码;如果每个块都在单独的方法中,读者将不得不在这些分散的方法之间来回翻阅,以理解它们是如何协同工作的。包含数百行代码的方法如果具有简单的签名并且易于阅读,那么它们也是很好的。这些方法是深层的(功能丰富,接口简单),这是好事
- 深度比长度更重要:首先确保函数有足够的深度,然后再尝试使其足够短以便轻松阅读。不要为了长度牺牲深度。决定拆分或合并模块应基于复杂度。选择能实现最佳信息隐藏、最少依赖关系及最深接口的结构。
- 注释的重要性与编写技巧
- 优质的注释能显著提升软件的整体质量;编写优质注释并不难;而且(这可能难以置信)编写注释实际上可以很有趣。
- 注释通过提供不同层次的详细信息来增强代码。有些注释提供比代码更低的、更详细的层次信息;这些注释通过阐明代码的确切含义来增加精确性。其他注释提供比代码更高的、更抽象的层次信息;这些注释提供直觉,比如代码背后的推理,或者一种更简单、更抽象的思考代码的方式。与代码处于同一层次的注释很可能会重复代码的内容。
- 具体的注释方式
- 在注释类实例变量、方法参数和返回值时,精确性尤为重要。变量声明中的名称和类型通常不够精确。注释可以填补缺失的细节,例如:
- 这个变量的单位是什么?
- 边界条件是包含性的还是排他性的?
- 如果允许空值,这暗示着什么?
- 如果一个变量指向一个最终必须被释放或关闭的资源,那么谁负责释放或关闭它?
- 是否存在某些特性(不变量),对于变量而言总是成立,例如“这个列表始终至少包含一个条目”?
- 在记录变量时,应考虑名词而非动词。换言之,重点在于变量所代表的内容,而非其如何被操作。
- 在记录一个方法时,描述该方法最可能被调用的条件(特别是在方法仅在特殊情况下被调用时)会非常有帮助。
- 记录抽象的第一步是将接口注释与实现注释分开。接口注释提供了某人为了使用类或方法所需了解的信息;它们定义了抽象。实现注释描述了类或方法内部如何工作以实现抽象。将这两种注释分开很重要,这样接口的用户就不会接触到实现细节。
- 方法接口注释既包含高层次的抽象信息,也包含低层次的精确细节
- 注释通常以一两句话开始,描述调用者感知到的方法行为;这是更高层次的抽象。评论必须详细描述每个参数及其返回值(如有)。
- 这些评论必须非常精确,并且必须描述参数值的任何限制以及参数之间的依赖关系。
- 如果方法有任何副作用,这些必须在接口注释中记录。副作用是指方法对系统未来行为产生影响的任何后果,但不是结果的一部分。例如,如果方法向内部数据结构添加一个值,该值可以通过未来的方法调用检索,这就是副作用;写入文件系统也是副作用。
- 方法的接口注释必须描述该方法可能抛出的任何异常。
- 如果在一个方法被调用之前必须满足某些先决条件,这些条件必须被描述出来(可能需要先调用其他方法;对于二分查找方法,被查找的列表必须是已排序的)。尽量减少先决条件是一个好主意,但任何保留的先决条件都必须有文档说明。
- 在注释类实例变量、方法参数和返回值时,精确性尤为重要。变量声明中的名称和类型通常不够精确。注释可以填补缺失的细节,例如:
- 幸运的是,有一个明显的地方是开发者在添加新状态值时必须去的,那就是状态枚举的声明处。我们利用这一点,在那个枚举中添加了注释,指出了所有也必须修改的其他地方
- 处理跨模块注释:我最近在尝试一种方法,即跨模块问题记录在一个名为designNotes的中央文件中。该文件被清晰地划分为多个标有明确标签的部分,每个部分对应一个主要主题。
- 在遵循注释应描述代码中不明显内容的规则时,“明显”是从初次阅读代码的人(而非你本人)的角度出发的。撰写注释时,尝试站在读者的立场,思考他们需要了解的关键信息是什么。如果你的代码正在接受审查,而审查者指出某些内容不明显,不要与他们争论;如果读者认为某处不明显,那么它就是不明显。与其争论,不如尝试理解他们感到困惑的地方,并思考是否能通过更清晰的注释或更优化的代码来阐明。
- 一般来说,注释与它所描述的代码之间的距离越远,它就应该越抽象(这样可以降低因代码变动而导致注释失效的可能性)。
- 在撰写提交信息时,问问自己:未来开发者是否需要这些信息?如果是,那么请在代码中记录下来。例如,一个描述了促使代码变更的微妙问题的提交信息。如果这未在代码中记录,那么后续开发者可能会在不知情的情况下撤销该变更,从而重新引入一个错误。如果你想在提交信息中也包含这份信息的副本,那当然可以,但最重要的是将其记录在代码中。这体现了将文档置于开发者最可能看到的地方的原则;而提交日志通常并非这样的场所。
- 保持注释最新性的第二种技巧是避免重复。如果文档被复制,开发者找到并更新所有相关副本的难度就会增加。相反,尝试对每个设计决策只记录一次。如果代码中多个地方受到某个特定决策的影响,不要在这些点重复文档。而是找到最显眼的单一位置放置文档。例如,假设某个变量的行为复杂,影响到该变量使用的多个不同地方。你可以在变量声明旁边的注释中记录这种行为。这是一个自然的位置,开发者在理解使用该变量的代码遇到困难时很可能会查看。
- 对于更局部化的约定,例如不变量,找到代码中合适的位置来记录它们。如果你不将这些约定写下来,其他人很可能不会遵循它们。
- 何时测试
- 测试,尤其是单元测试,在软件设计中扮演着重要角色,因为它们促进了重构。没有测试套件,对系统进行重大结构改动是危险的。没有简单的方法来发现错误,因此错误很可能会在新代码部署后才被发现,那时发现和修复错误的成本要高得多。因此,在没有良好测试套件的系统中,开发者会避免重构;他们试图为每个新功能或错误修复最小化代码更改的数量,这意味着复杂性积累,设计错误得不到纠正。有了良好的测试集,开发者在重构时可以更有信心,因为测试套件会发现大多数引入的错误。这鼓励开发者对系统进行结构上的改进,从而得到更好的设计。
- 测试驱动开发的问题在于,它将注意力集中在使特定功能正常工作上,而不是寻找最佳设计。这纯粹是战术编程,带有其所有的不利之处。测试驱动开发过于渐进:在任何时候,都很容易为了通过下一个测试而匆匆添加下一个功能。没有明显的时间进行设计,因此很容易陷入混乱
- 在修复 bug 时,先编写测试是一个合理的做法。在修复 bug 之前,先写一个因为该 bug 而失败的单元测试。然后修复 bug,并确保单元测试现在通过。这是确保你真正修复了 bug 的最佳方法。如果你在编写测试之前就修复了bug,那么新的单元测试可能实际上并未触发该 bug,这种情况下它将无法告诉你是否真正解决了问题。
- 设计模式的应用
- 不要试图将问题强行套入某个设计模式,而应采用更简洁的自定义方法。使用设计模式并不意味着自动提升软件系统的质量;只有当设计模式恰到好处时,才能发挥其优势。
- 每当你遇到一个新的软件开发范式的提议时,从复杂性的角度对其进行质疑:这个提议是否真的有助于减少大型软件系统的复杂性?许多提议表面上听起来不错,但如果你深入探究,你会发现其中一些实际上使复杂性变得更糟,而非更好。
- 变量与方法的命名规范
- 具体做法
- 设计两次
- 我注意到,“设计两次”原则有时对非常聪明的人难以接受。在他们成长的过程中,聪明人发现他们对任何问题的第一个快速想法就足以获得好成绩;没有必要考虑第二个或第三个可能性。这往往导致不良的工作习惯。然而,随着这些人年龄的增长,他们被提拔到面临越来越困难问题的环境中。最终,每个人都会达到一个阶段,即你的第一个想法不再足够好;如果你想取得真正出色的成果,无论你多么聪明,你都必须考虑第二个可能性,甚至可能是第三个。大型软件系统的设计就属于这一类:没有人能够一次就做得完美。
- 注释先行的开发
- 最佳的注释编写时机是在过程的开始,即编写代码的同时。先编写注释使得文档成为设计过程的一部分。这不仅能产生更好的文档,还能带来更优秀的设计,并且使编写文档的过程更加愉快。
- 先写注释意味着在开始编码前,抽象概念会更加稳定。这很可能会在编码过程中节省时间。相反,如果先写代码,抽象概念可能会随着编码的进行而演变,这需要比先写注释的方法更多的代码修订。综合考虑这些因素,整体上先写注释可能会更快。
- 对于一个新类,我首先撰写类接口注释。
- 接下来,我会为最重要的公共方法编写接口注释和签名,但我会让方法体保持空白。
- 我稍微反复斟酌这些评论,直到基本结构感觉差不多合适。
- 在此,我为类中最重要的实例变量撰写声明和注释。
- 最后,我填充了方法的主体,并在必要时添加了实现注释。
- 在编写方法体时,我通常会发现需要额外的属性和实例变量。对于每个新写的方法,我会在方法体之前先写接口注释;对于实例变量,我会在写变量声明的同时填写注释。
- 当代码完成时,注释也已完成。从未有过未编写的注释积压。
- 性能优化与重构
- 一旦你对什么是昂贵、什么是便宜有了大致的了解,你就可以利用这些信息尽可能选择便宜的操作。在很多情况下,更高效的方法可能和较慢的方法一样简单。
- 再举一个例子,考虑在C或C++这样的语言中分配一个结构体数组。有两种方法可以实现这一点。一种方法是将数组用于保存指向结构体的指针,在这种情况下,你必须首先为数组分配空间,然后为每个单独的结构体分配空间。将结构体直接存储在数组中要高效得多,这样你只需为所有内容分配一个大的内存块。
- 一般来说,代码越简单,运行速度往往越快。如果你已经定义并处理了特殊情况和异常,那么就不需要额外的代码来检查这些情况,系统运行速度自然更快。深层类比浅层类更高效,因为每次方法调用它们能完成更多工作。浅层类会导致更多的层级跨越,而每次层级跨越都会增加开销。
- 在进行任何更改之前,应测量系统的现有行为。这有两个目的。首先,这些测量将确定性能调优影响最大的地方。仅仅测量顶层系统性能是不够的。这可能告诉你系统太慢,但不会告诉你原因。你需要更深入地测量,以详细识别影响整体性能的因素;目标是找出系统当前花费大量时间的少数特定位置,并且你有改进的想法。测量的第二个目的是提供一个基准,这样你可以在更改后重新测量性能,以确保性能确实得到了提升。如果更改没有使性能产生可测量的差异,那么就撤销这些更改(除非它们使系统更简单)。除非能显著加快系统速度,否则保留复杂性是没有意义的。
- 改进其性能的最佳方法是进行“根本性”的改变,比如引入缓存,或者采用不同的算法方法(例如平衡树与列表)。
- 首先,问问自己,在常见情况下,为了完成所需任务,必须执行的最少代码量是多少。忽略任何现有的代码结构。想象一下,你正在编写一个新方法,只实现关键路径,即在大多数常见情况下必须执行的最少代码量。当前的代码可能充斥着特殊情况;在这个练习中忽略它们。当前的代码可能在关键路径上经过多个方法调用;想象一下,你可以将所有相关代码放在一个方法中。当前的代码也可能使用多种变量和数据结构;只考虑关键路径所需的数据,并假设任何数据结构对关键路径最为方便。例如,将多个变量合并为一个值可能是有意义的。假设你可以完全重新设计系统,以最小化关键路径必须执行的代码量。我们称这种代码为“理想状态”。
- 在为性能进行重构时,应尽量减少必须检查的特殊情况数量。理想情况下,开始处应只有一个if语句,通过一次测试就能检测所有特殊情况。在正常情况下,只需进行这一次测试,之后关键路径即可无须额外特殊情况测试地执行。如果初始测试未通过(意味着出现了特殊情况),代码可以跳转到关键路径之外的独立位置处理该情况。
- 清晰的设计与高性能是可以兼容的。Buffer类的重写不仅使其性能提升了两倍,同时简化了设计并减少了20%的代码量。复杂的代码往往运行缓慢,因为它执行了多余或重复的工作。相反,如果你编写清晰、简洁的代码,你的系统很可能已经足够快速,以至于你无需过多担心性能问题。在少数确实需要优化性能的情况下,关键仍然是简洁性:找出对性能至关重要的关键路径,并尽可能简化它们。
- 遵守约定和惯例
- 一旦发现任何看似约定的做法,就应遵循。在进行设计决策时,问问自己这个决策是否可能在项目的其他地方也有类似的选择;如果有,找到一个现成的例子,并在你的新代码中采用相同的方法。
- 不要改变现有的惯例。抵制那种想要“改进”现有惯例的冲动。拥有一个“更好的想法”并不是引入不一致性的充分理由。你的新想法可能确实更好,但一致性相对于不一致性的价值几乎总是大于一种方法相对于另一种方法的价值。在引入不一致行为之前,问自己两个问题。首先,你是否拥有重要的新信息来证明你的方法,而这些信息在旧惯例建立时是不可用的?其次,新方法是否好到值得花时间去更新所有旧的使用?如果你的组织同意这两个问题的答案都是“是”,那么就大胆进行升级;完成后,旧惯例的痕迹应该荡然无存。然而,你仍然面临风险,即其他开发者可能不知道新惯例,因此他们未来可能会重新引入旧方法。总的来说,重新考虑已建立的惯例很少是开发者时间的良好利用。
- “显而易见”存在于读者心中:注意到他人代码的不明显之处比发现自己的代码问题要容易得多。因此,判断代码是否显而易见的最佳方法是通过代码审查。如果有人阅读你的代码后认为它不明显,那么它就是不明显的,无论对你来说它看起来多么清晰。通过努力理解是什么使得代码不明显,你将学会如何在将来编写更好的代码。
- 代码如果符合读者预期的惯例,则最为直观;如果不符合,那么记录这种行为就很重要,以免读者感到困惑。
- 为了使代码显而易见,你必须确保读者始终拥有理解代码所需的信息。你可以通过三种方式来实现这一点。最佳方法是减少所需的信息量,运用抽象和消除特殊情况等设计技巧。其次,你可以利用读者在其他情境中已获得的信息(例如,通过遵循惯例和符合预期),这样读者就不必为你的代码学习新信息。第三,你可以通过使用良好的命名和策略性注释等技巧,在代码中向他们展示重要信息。
- 正确对待事件驱动编程
- 事件驱动编程使得跟踪控制流程变得困难。事件处理函数从未被直接调用;它们是通过事件模块间接调用的,通常使用函数指针或接口。即使你在事件模块中找到了调用点,仍然无法确定具体会调用哪个函数:这取决于运行时注册了哪些处理程序。因此,很难对事件驱动代码进行推理,或者确信其工作正常。
- 为了弥补这种晦涩,请在每个处理函数接口注释中指明其何时被调用
- 避免使用通用容器
- 不幸的是,通用容器导致代码不直观,因为被分组的元素具有模糊其含义的通用名称。在上述示例中,调用者必须使用result.getKey()和result.getValue()来引用两个返回值,这无法提供关于值实际含义的任何线索。
- 因此,最好不要使用通用容器。如果你需要一个容器,可以定义一个专门针对特定用途的新类或结构。这样,你就可以为元素使用有意义的名称,并在声明中提供额外的文档,这是通用容器无法做到的。
- 透传变量与上下文
- 透传变量增加了复杂性,因为它们迫使所有中间方法都意识到它们的存在,即便这些方法并不需要使用这些变量。此外,如果一个新的变量出现(例如,系统最初构建时未支持证书,但后来决定添加该支持),你可能需要修改大量接口和方法,以确保该变量能够通过所有相关路径传递。
- 我最常用的解决方案是引入一个上下文对象,如图7.2(d)所示。上下文存储了应用程序的所有全局状态(任何原本需要传递的变量或全局变量)。上下文远非理想的解决方案。
- 存储在上下文中的变量大多具有全局变量的缺点;例如,可能不明显为什么存在某个特定变量,或者它在何处被使用。如果没有纪律,上下文可能会变成一个巨大的数据杂烩,在整个系统中产生不明显的依赖关系。上下文还可能引发线程安全问题;避免问题的最佳方式是使上下文中的变量不可变。遗憾的是,我尚未找到比上下文更好的解决方案。
- 异常处理和配置参数
- 这些方法在短期内会让你的生活更轻松,但它们增加了复杂性,导致许多人必须处理一个问题,而不是仅仅一个人。例如,如果一个类抛出异常,该类的每个调用者都必须处理它。如果一个类导出配置参数,每个安装环境中的每个系统管理员都必须学习如何设置它们。
- 因此,应尽可能避免使用配置参数。在导出配置参数之前,自问:“用户(或更高级别的模块)能否确定比我们在此处确定的更优值?”当确实需要创建配置参数时,尝试提供合理的默认值,以便用户仅在特殊情况下才需提供值。理想情况下,每个模块应完整解决问题;配置参数导致解决方案不完整,从而增加了系统复杂性。
- 抛出异常容易,处理异常却难。因此,异常的复杂性主要来源于异常处理代码。减少异常处理带来的复杂性损害的最佳方法,是减少需要处理异常的地方。
- 异常屏蔽并非在所有情况下都有效,但在其适用的场合,它是一个强有力的工具。它能够产生更深层次的类,因为它减少了类的接口(用户需要了解的异常更少),并以屏蔽异常的代码形式增加了功能。异常屏蔽是向下转移复杂性的一个例子
- 最佳方法是重新定义语义以消除错误条件。对于无法消除的异常,应寻找机会在较低层次上屏蔽它们,从而限制其影响,或者将多个特殊情况处理程序聚合为一个更通用的处理程序。
- 设计两次
- 软件设计的哲学与美学
- 时刻重构
- 如果你想为一个系统保持一个干净的设计,在修改现有代码时必须采取战略性的方法。理想情况下,当你完成每一项改动后,系统应具备如果从一开始就考虑到这些改动而设计的结构。为了实现这一目标,你必须抵制快速修复的诱惑。相反,要思考当前的系统设计是否仍然是最佳的,考虑到所需的改动。如果不是,就重构系统,以便最终获得尽可能最佳的设计。通过这种方法,系统设计随着每一次修改而不断改进。
- 设计的重要性与价值
- 良好软件设计的一个重要元素是区分重要与不重要。应以重要的事物为核心构建软件系统。对于不太重要的事物,应尽量减少它们对系统其余部分的影响。重要的事物应加以强调并使其更加明显;不重要的事物则应尽可能隐藏。
- 一旦你确定了重要的事物,你应该在设计中强调它们。强调的一种方式是通过突出:重要的事物应该出现在更可能被看到的地方,比如界面文档、名称或频繁使用的方法的参数。另一种强调的方式是通过重复:关键的想法反复出现。第三种强调的方式是通过中心性。最重要的事物应该位于系统的核心,它们决定了周围事物的结构。一个例子是操作系统中设备驱动的接口;这是一个核心想法,因为成百上千的驱动程序将依赖于它。
- 专注于最重要的事物的理念不仅适用于软件设计,在技术写作领域也同样重要:使文档易于阅读的最佳方法是在开头识别几个关键概念,并围绕它们构建文档的其余部分。
- 软件开发中的“好品味”
- “好品味”这一短语描述了区分重要与不重要事物的能力。拥有好品味是成为优秀软件设计师的重要组成部分。
- 成为优秀设计师的回报是,你能够将更多时间投入到充满乐趣的设计阶段。而糟糕的设计师则大部分时间都在复杂且脆弱的代码中追踪错误。如果你提升自己的设计技能,你不仅能更快地产出更高质量的软件,而且软件开发过程本身也会变得更加愉快。
- 时刻重构