Skip to content

《软件设计哲学》要点总结

约 7562 字大约 25 分钟

2025-09-13

以下是我阅读完《软件设计哲学》后的每章的要点总结。

《软件设计哲学》要点总结

解决复杂性的方法

  1. 使代码更简单和更明显。
  2. 封装它,使用时不必了解细节。

复杂性的本质

  • 如果一个软件系统难以理解和修改,那就很复杂。如果很容易理解和修改,那就很简单。

  • 在复杂的系统中,要实施甚至很小的改进都需要大量的工作。在一个简单的系统中,可以用更少的精力实现更大的改进。

  • ★系统的总体复杂度(C)由每个部分的复杂度(cp)乘以开发人员在该部分上花费的时间(tp)加权。

    • 在一个永远不会被看到的地方隔离复杂性几乎和完全消除复杂性一样好。
  • 读者比作家更容易理解复杂性。作为开发人员,您的工作不仅是创建可以轻松使用的代码,而且还要创建其他人也可以轻松使用的代码

  • 复杂性的症状

    • 变更放大
    • 认知负荷
      • 较高的认知负担意味着开发人员必须花更多的时间来学习所需的信息
      • 有时,需要更多代码行的方法实际上更简单,因为它减少了认知负担。
    • 未知的未知
      • 必须修改哪些代码才能完成任务,或者开发人员必须获得哪些信息才能成功地执行任务,这些都是不明显的。
      • 一个未知的未知意味着你需要知道一些事情,但是你没有办法找到它是什么,甚至是否有一个问题

    在复杂性的三种表现形式中,未知的未知是最糟糕的。一个未知的未知意味着你需要知道一些事情,但是你没有办法找到它是什么,甚至是否有一个问题。你不会发现它,直到错误出现后,你做了一个改变。更改放大是令人恼火的,但是只要清楚哪些代码需要修改,一旦更改完成,系统就会工作。同样,高的认知负荷会增加改变的成本,但如果明确要阅读哪些信息,改变仍然可能是正确的。对于未知的未知,不清楚该做什么,或者提出的解决方案是否有效。唯一确定的方法是读取系统中的每一行代码,这对于任何大小的系统都是不可能的。甚至这可能还不够,因为更改可能依赖于一个从未记录的细微设计决策。

  • 复杂性的原因

    • 复杂性是由两件事引起的:依赖性和模糊性
    • 软件设计的目标之一是减少依赖关系的数量,并使依赖关系保持尽可能简单和明显
    • 当重要的信息不明显时,就会发生模糊
    • 模糊性也是设计问题。如果系统设计简洁明了,则所需的文档将更少。对大量文档的需求通常是一个警告,即设计不正确。减少模糊性的最佳方法是简化系统设计。
    • 依赖性导致变化放大和高认知负荷。晦涩会产生未知的未知数,还会增加认知负担。如果我们找到最小化依赖关系和模糊性的设计技术,那么我们就可以降低软件的复杂性。
  • 复杂度是递增的,不要忽视小依赖和模糊

复杂性来自于依赖性和模糊性的积累。随着复杂性的增加,它会导致变化放大,高认知负荷和未知的未知数。结果,需要更多的代码修改才能实现每个新功能。此外,开发人员花费更多时间获取足够的信息以安全地进行更改,在最坏的情况下,他们甚至找不到所需的所有信息。最重要的是,复杂性使得修改现有代码库变得困难且冒险。

第3章 工作代码是不够的

  • 好的软件设计中最重要的元素之一是您在执行编程任务时所采用的思维方式
  • 战术编程:主要重点是使某些功能正常工作,例如新功能或错误修复;战术编程几乎不可能产生出良好的系统设计,因其是短视的。
  • 战略编程:能跑起来的的代码是不够的。战略性编程需要一种投资心态。您必须花费时间来改进系统的设计,而不是采取最快的方式来完成当前的项目。这些投资会在短期内让您放慢脚步,但从长远来看会加快您的速度
  • 大量的前期投资(例如尝试设计整个系统)将不会有效,最好的方法是连续进行大量小额投资,将总开发时间的 10%到 20%用于投资。
  • 每位工程师都对良好的设计进行连续的少量投资

第4章 模块应该是深的

  • 管理软件复杂性最重要的技术之一就是设计系统,以便开发人员在任何给定时间只需要面对整体复杂性的一小部分。这种方法称为模块化设计。
  • 模块化设计的目标是最大程度地减少模块之间的依赖性。
  • 为了管理依赖关系,我们将每个模块分为两个部分:接口和实现。接口包括了在不同模块工作的开发者为了使用给定模块必须知道的所有内容。
  • 模块的接口包含两种信息:正式信息和非正式信息。接口的形式部分在代码中明确指定,并且其中一些可以通过编程语言检查其正确性。
  • 每个接口还包括非正式元素。这些没有以编程语言可以理解或执行的方式指定。接口的非正式部分包括其高级行为
  • 设计抽象的重要一点就是识别重要性,并在设计过程中,将重要信息的数量尽量减到最少
  • 模块深浅
    • 最好的模块很深,它们允许通过简单的接口访问许多功能。
    • 浅层模块是具有相对复杂的接口的模块,但功能不多,它不会掩盖太多的复杂性。
    • 模块深度是考虑成本与收益的一种方式。模块提供的收益是其功能。模块的成本(就系统复杂性而言)是其接口。
    • 模块的接口代表了模块强加给系统其余部分的复杂性:接口越小越简单,引入的复杂性就越小。
    • 最好的模块是那些收益最大(功能复杂),成本最低(接口简单)的模块。
    • 接口是好的,但更多或更大的接口不一定更好!
    • e.g. UNIX I/O 只有五个基本系统调用
  • 深度模块最极致的例子
    • Unix 文件系统(隐藏了巨大的实现复杂性)
    • 垃圾收集器,该模块没有接口
  • 浅模块:浅层模块是指其接口相对于它提供的功能来说比较复杂的模块
  • “类应该小”的观点导致了巨大的复杂性。类应该深,而不是小。
  • 设计类和其他模块时,最重要的问题是使它们更深,以使它们具有适用于常见用例的简单接口,但仍提供重要的功能。这样做能最大限度地隐藏复杂性。

第5章 信息隐藏(和泄漏)

第四章认为模块应该很深。本章及随后的其他章节讨论了创建深层模块的技术。

  • 隐藏在模块中的信息通常包含有关如何实现某种机制的详细信息
  • 隐藏的信息包括与该机制有关的数据结构和算法
  • 信息隐藏的最佳形式是将信息完全隐藏在模块中
  • 部分的信息的隐藏也具有价值,即一个特定的特性或信息只被少数的类使用者所需要
  • 当一个设计决策反映在多个模块中时,就会发生信息泄漏
  • 典型的例子:假设两个类都具有特定文件格式的知识(也许一个类读取该格式的文件,而另一个类写入它们)。即使两个类都不在其接口中公开该信息,它们都依赖于文件格式:如果格式更改,则两个类都将需要修改。像这样的后门泄漏比通过接口泄漏更有害,因为它并不明显
  • ★ 信息泄漏是软件设计中最重要的危险信号之一。作为一个软件设计师,你能学到的最好的技能之一就是对信息泄露的高度敏感性。如果您在类之间遇到信息泄漏,请自问“我如何才能重新组织这些类,使这些特定的知识只影响一个类?”如果受影响的类相对较小,并且与泄漏的信息紧密相关,那么将它们合并到一个类中是有意义的。另一种可能的方法是从所有受影响的类中提取信息,并创建一个只封装这些信息的新类。但是,这种方法只有在您能够找到一个从细节中抽象出来的简单接口时才有效;如果新类通过其接口公开了大部分知识,那么它就不会提供太多的价值(您只是用通过接口的泄漏替换了后门泄漏)。
  • 时间分解常常导致信息泄漏。在设计模块时,应专注于执行每个任务所需的知识,而不是任务发生的顺序
  • 如果一个常用特性的 API 迫使用户了解其他很少使用的特性,这将增加不需要这些很少使用的特性的用户的认知负荷。

第6章 通用模块更深入

  • 以某种通用的方式实现新模块
  • 专用文本 API 至少具有三种删除文本的方法:退格,删除和 deleteSelection。通用性更强的 API 只有一种删除文本的方法,可同时满足所有三个目的。仅在每种方法的 API 保持简单的前提下,减少方法的数量才有意义。如果您必须引入许多其他参数以减少方法数量,那么您可能并没有真正简化事情。
  • 这个 API 对我当前的需求来说容易使用吗?这个问题可以帮助你确定什么时候你在让一个 API 变得简单和通用方面走得太远了。(单字符和字符串操作)
  • 通用接口相比于特定目的的接口有许多优势。它们往往更简单,拥有更少但更深入的方法。它们还提供了类之间的更清晰的分隔,而专用接口则倾向于在类之间泄漏信息。使模块具有某种通用性是降低整体系统复杂性的最佳方法之一。

第7章 不同的层,不同的抽象

  • 透传方法 除了将参数传递给另外一个与其有相同 API 的方法外,不执行任何操作。这通常表示各类之间没有明确的职责划分。
  • 解决方案是重构类,以使每个类都有各自不同且连贯的职责
  • 有时装饰者很有意义,但通常有更好的选择。
  • “不同层,不同抽象”规则的另一个应用是,类的接口通常应与其实现不同:内部使用的表示形式应与接口中出现的抽象形式不同。
    • 面向字符的接口封装了文本类内部的行拆分和连接的复杂性,这使文本类更深,并简化了使用该类的高级代码
  • 跨层传递变量的处理:使用上下文对象

添加到系统中的每一个设计基础设施,如接口、参数、函数、类或定义,都会增加复杂性,因为开发人员必须了解这个元素。为了使一个元素提供对抗复杂性的净增益,它必须消除在没有设计元素的情况下出现的一些复杂性。否则,您最好在没有该特定元素的情况下实现该系统。例如,一个类可以通过封装功能来降低复杂性,这样该类的用户就不必知道它了。 “不同的层,不同的抽象”规则只是此思想的一种应用:如果不同的层具有相同的抽象,例如透传方法或装饰器,则很有可能它们没有提供足够的利益来补偿它们代表的其他基础结构。类似地,传递参数要求几种方法中的每一种都知道它们的存在(这增加了复杂性),而又不提供其他功能。

第8章 降低复杂性

  • 模块具有简单的接口比简单的实现更为重要。 应该在模块内部处理复杂性,而不是让模块的使用着处理复杂性。 如果出现不确定如何处理的条件,最简单的方法是抛出异常并让调用者处理它。 如果不确定要实施什么策略,则可以定义一些配置参数来控制该策略,然后由系统管理员自行确定最佳策略。
  • 设置参数增加了复杂性 应尽可能避免使用配置参数。在导出配置参数之前,请问自己:“用户(或更高级别的模块)是否能够确定比我们在此确定的更好的值?” 当您创建配置参数时,请查看是否可以自动计算合理的默认值,因此用户仅需在特殊情况下提供值即可。理想情况下,每个模块都应完全解决问题。配置参数导致解决方案不完整,从而增加了系统复杂性。
    • 利: 低级基础结构代码很难知道要应用的最佳策略,而用户则对其领域更加熟悉
    • 弊: 配置参数还提供了一个轻松的借口,可以避免处理重要问题并将其传递给其他人
  • 如果(a)被降低的复杂度与该类的现有功能密切相关,(b)降低复杂度将导致应用程序中其他地方的许多简化,则降低复杂度最有意义。目标是最大程度地降低整体系统复杂性

第9章 在一起更好还是分开更好?

  • 讨论何时将代码段组合在一起以及何时将它们分开是有意义的。
  • 如果信息共享则汇聚在一起
  • 如果可以简化接口则汇集在一起
  • 消除重复
    • 如果代码段只有一两行,那么用方法调用替换它可能不会有太多好处
    • 如果代码段与其环境以复杂的方式进行交互(例如,通过访问多个局部变量),则替换方法可能需要复杂的签名(例如,许多“按引用传递”参数),这会降低其价值
    • 消除重复的另一种方法是重构代码,使相关代码段仅需要在一个地方执行
  • 系统的下层倾向于更通用,而上层则更专用
  • 不需要专用的日志记录类,应该使用通用的类
  • 设计方法时,最重要的目标是提供简洁的抽象。每种方法都应该做一件事并且完全做的彻底。该方法应该具有简洁的接口,以便用户无需费神就可以正确使用它。该方法应该很深:其接口应该比其实现简单得多。如果一个方法具有所有这些属性,那么它的长短与否无关紧要。
  • 如果您不能不理解另一种方法的实现而导致无法理解一种方法的实现,那就是一个危险信号
  • 拆分或合并模块的决定应基于复杂性。选择一种结构,它可以最好的隐藏信息,产生最少的依赖关系和最深的接口。

第10章 通过定义规避错误

  • 异常处理是软件系统中最糟糕的复杂性来源之一
  • 在许多情况下,可以修改操作的语义,以便正常行为可以处理所有情况,并且没有要报告的特殊条件
  • 解决异常的几个方法:
    • 通过定义规避错误 修改函数的含义:异常情况->不生效但是不抛出异常(unset不存在的变量)

★文件删除的典型例子: windows不允许删除正在使用的文件; unix允许延迟删除文件(删除正在使用的文件不会异常;正在使用文件的程序仍然可以读写,也不会导致程序运行异常;当不再有程序访问该被标记删除的文件时,文件才真正被删除)

substring 方法的 index 越界异常。应该编写一个高层的方法规则这种异常,这样的方法更深。 例如: start < min 取 min; end > max 取 max; start > end 返回空

  • 异常屏蔽:让较低级别的代码来检测和处理异常 比如TCP在遇到丢包的时候会重发数据包,客户端不会察觉到底层的这种异常

  • 异常聚合: 用一个代码段处理许多异常,而非单独处理 不断向上抛出异常,并定义不同的异常分类,在最顶层的异常中根据分类和参数统一处理 对于不常发生的错误,需要错误升级,以修复bug;然而不对经常会发生的错误,已经使用异常屏蔽进行处理 ##不常发生的错误一旦发生错误,意味着会造成比较大的影响,因此需要抛出该异常进行处理?*

  • 让程序崩溃 尝试处理内存不足错误几乎没有道理,应该让程序中止。 如果内存耗尽,则继续应用程序是没有意义的。最好在检测到错误后立即崩溃。 对于大多数程序,如果在读取或写入打开的文件时发生 I/O 错误(例如磁盘硬错误) 或者无法打开网络套接字,则应用程序没有什么办法从在错误中恢复 因此中止程序并输出清晰的错误信息是明智之举

  • 通过设计规避特殊情况。将特殊情况看作通用情况的一部分,比如列表是一种特殊的树结构

  • 异常与软件设计中的许多其他领域一样,您必须确定哪些是重要的,哪些是不重要的。不重要的事物应该被隐藏起来,它们越多越好。但是,当某件事很重要时,必须将其暴露出来。

第11章 设计两次原则 design it twice

  • 即使你确定只有一种合理的方法,无论如何也要考虑第二种设计,不管你认为它有多糟糕。 考虑该设计的弱点并将它们与其他设计的特征进行对比将很有启发性。
  • 对高级软件的易用性是接口最重要的考虑因素
  • ★ 我们无法从一开始就作出最好的设计,不妨将几种可能的设计都列举出来进行对比,而不是因为想不到完美的设计而停滞不前。在枚举不完美设计的过程中,可能就会发现那个完美的方案。
  • ★ 既然是对比多个设计,就不可能把所有的方法细节都实现一遍,而是只列出主要的函数签名即可,这样也可以避免提早陷入对细节的纠结中去。
  • no-one is good enough to get it right with their first try!
  • 设计两次的方式不仅可以改善您的设计,而且可以提高您的设计能力。设计和比较多种方法的过程将教会你使设计变得更好或更差的因素。这将使你更容易排除不好的设计,并钻研真正伟大的设计。

第12章 为什么要写注释?有四个理由

  • 我希望这些章节能使您相信三件事:
    1. 好的注释可以对软件的整体质量产生很大的影响;
    2. 写好注释并不难;
    3. 并且(可能很难相信)写注释实际上很有趣。

第13章 注释应该描述代码中不明显的内容

  • 统一注释的约定格式
  • 不要重复代码显而易见的信息
  • 精确描述底层细节,比如描述变量的使用细节
  • 高层注释描述整体功能,比如方法的注释
  • 注释最重要的作用之一就是定义抽象
    • 如果接口注释也必须描述实现,则该类或方法很浅
  • 一些具体的做法
    • 简短的句子描述让使用者感知方法的整体行为
    • 必须描述参数和返回值
    • 必须描述副作用
    • 必须描述可能产生的任何异常
    • 必须描述调用方法需要满足的前提条件
  • 在撰写注释时,请尝试使自己进入读者的心态,并问自己他或她需要知道哪些关键事项

第14章 选择的名字

  • 像其他形式的抽象一样,最好的名字是那些将注意力集中在对底层实体最重要的东西上,而忽略那些次要的细节。
  • 良好名称具有两个属性:精度和一致性
  • 如何很难为一个变量想出准确直观的名字,可能需要拆分该变量
  • 一致性: fileBlock srcFileBlock dstFileBlock

第15章 先写注释

  • 迟到的注释不是好注释
  • 注释是一种设计工具 如果方法或变量需要较长的注释,则它是一个危险信号,表明您没有很好的抽象
  • 早期注释很有趣 如果您是策略性编程,而您的主要目标是一个出色的设计,而不仅仅是编写有效的代码,那么编写注释应该很有趣,因为这是您确定最佳设计的方式

第16章 修改现有的代码

  • 一个成熟的系统的设计更多地取决于系统演化过程中所做的更改,而不是任何初始概念
  • 前面的章节描述了如何在初始设计和实现过程中降低复杂性。本章讨论如何防止随着系统的发展而蔓延。
  • 保持战略性 在战略编程中,最重要的目标是进行出色的系统设计
  • 维护注释:将注释保留在代码附近 确保注释更新的最佳方法是将注释放置在它们描述的代码附近,以便开发人员在更改代码时可以看到它们。注释离其关联的代码越远,正确更新的可能性就越小 (PEG文档的教训)
  • 注释属于代码,而不是提交日志 将文档放置在开发人员最有可能看到它的地方的原则; 尽量少放在提交日志中。
  • 维护注释:避免重复 如果文档重复,那么开发人员将很难找到并更新所有相关副本。 如果信息已经在程序之外的某个地方记录了,不要在程序内部重复记录;只需参考外部文档
  • 更高级的注释更易于维护

第17章 一致性

  • 如果系统是一致的,则意味着相似的事情以相似的方式完成,而不同的事情则以不同的方式完成,可以降低系统复杂性并使其行为更明显
  • 编码风格一致性
  • 接口一致性
  • 设计模式
  • 自动检查器对于低级别的语法约定特别有用
  • Do not take it too far!

第18章 代码应该是显而易见的

  • 精确而有意义的名称可以阐明代码的行为,并减少对文档的需求
  • 总是以相似的方式完成相似的事情,那么读者可以识别出他们以前所见过的模式,并立即得出(安全)结论,而无需详细分析代码。
  • 分段和注释
  • 使代码不那么明显的事情
    • 事件驱动编程
    • 通用容器: 软件的设计应易于阅读而不是易于编写,通用容器对于编写代码的人来说是很方便的,但是它们会给所有的后续读者带来困惑

第19章 软件发展趋势

  • 接口继承:通过将同一接口用于多种用途,从而提供了对抗复杂性的杠杆作用
  • 实现继承:父类不仅定义了一个或多个方法的签名,而且还定义了默认实现。子类可以选择继承方法的父类实现,也可以通过定义具有相同签名的新方法来覆盖它。它减少了第2章中描述的变化放大问题
    • 实现继承会在父类及其每个子类之间创建依赖关系
    • 广泛使用实现继承的类层次结构往往具有很高的复杂性
    • 应谨慎使用实现继承,使用共享功能的小辅助类的组合
    • 将父类管理的状态与子类管理的状态分开,子类仅以只读方式或通过父类中的其他方法使用父类中的变量。
  • 敏捷开发
  • 单元测试
  • 设计模式
    • Gamma,Helm,Johnson 和 Vlissides 的《设计模式:可复用的面向对象软件的基础》一书中而普及
    • 设计模式的出现是因为它们解决了常见的问题,并且因为它们被普遍认为提供干净的解决方案
    • 设计模式的最大风险是过度使用
  • 最好避免使用 getter 和 setter(或任何公开的实现数据)

第20章 设计性能

  • 提升性能关键是要意识到哪些操作从根本上来说是昂贵的
    • 网络通信
    • 二级存储的I/O
    • 动态内存分配
    • 缓存缺失
  • 修改前的度量
  • 围绕关键路径进行设计

总结

设计原则小结

  1. 复杂性是逐步增加的:您必须努力处理小事情
  2. 能跑起来的的代码是不够的
  3. 持续进行少量投资以改善系统设计
  4. 模块应较深
  5. 接口的设计应尽可能简化最常见的用法
  6. 一个模块具有一个简单的接口比一个简单的实现更重要
  7. 通用模块更深入
  8. 通用和专用代码分开
  9. 不同的层应具有不同的抽象
  10. 降低复杂度
  11. 定义不存在的错误(和特殊情况)
  12. 设计两次
  13. 注释应描述代码中不明显的内容
  14. 软件的设计应易于阅读而不是易于编写
  15. 软件开发的增量应该是抽象而不是功能

危险信号小结

  1. 浅模块:类或方法的接口并不比其实现简单得多
  2. 信息泄漏:设计决策反映在多个模块中
  3. 时间分解:代码结构基于执行操作的顺序,而不是信息隐藏
  4. 过度暴露:API 强制调用者注意很少使用的功能,以便使用常用功能
  5. Pass-Through Method:一种方法几乎不执行任何操作,只是将其参数传递给具有相似签名的另一种方法
  6. 重复:一遍又一遍的重复代码
  7. 特殊通用混合物:特殊用途代码未与通用代码完全分开
  8. 联合方法:两种方法之间的依赖性很大,以至于很难理解一种方法的实现而又不理解另一种方法的实现
  9. 注释重复代码:注释旁边的代码会立即显示注释中的所有信息
  10. 实施文档污染了界面:界面注释描述了所记录事物的用户不需要的实施细节
  11. 含糊不清的名称:变量或方法的名称过于精确,以至于它不能传达很多有用的信息
  12. 难以选择的名称:很难为实体提供准确而直观的名称
  13. 难以描述:为了完整起见,变量或方法的文档必须很长。
  14. 非显而易见的代码:一段代码的行为或含义不容易理解。