软件设计的哲学-读书笔记
复杂性的成因
- 依赖:
- 依赖关系过多、复杂
- 表现
- 放大变化
- 认知负担
- 模糊:
- 重要信息不明显
- 不一致
- 表现
- 未知
3 Working Code Isn’t Enough
保证代码运行是不够的。
战术编程 VS 战略编程
投资思维
- 为每个新类寻找简单的设计;
- 撰写好的文档;
- 修复之前设计的不合理处;
- 现在的10-20%的总开发时间 > 以后的20%+的减慢开发速度;
4 Modules Should Be Deep
模块应该足够深。
4.1 Modular design
模块可以采取许多形式,例如类、子系统或服务。
模块设计的目标是尽量减少模块之间的依赖关系。
好的模块接口比实现更简单。
4.2 What’s in an interface?
每个接口包括非正式元素,不是编程语言强制要求的,
使用上的约束,例如方法调用的隐含顺序。
4.3 Abstractions
抽象是对一个实体的简化视图。
抽象比必要更复杂
- 包括了不真正重要的细节,增加了使用抽象的开发人员的认知负荷;
- 省略了真正重要的细节时,导致了晦涩难懂,开发人员需要去看实现细节;
设计抽象的关键是了解什么是重要的,并寻找将重要信息量减至最少的设计。
4.4 Deep modules
最好的模块
- 具有简单的接口;
- 提供强大的功能;
模块的深度
- 简单接口,成本低,宽度
- 功能强大,收益高,深度
- 好的模块——》深的模块
深的模块示例
- Unix/Linux系统调用I/O;
- 高级语言中的垃圾回收器,例如Go或者Java;
4.5 Shallow modules
浅的模块示例
- 链表;
浅层类有时是不可避免的,但它们在管理复杂性方面没有多大帮助,有时甚至会增加复杂度。
- 浅层模块提供的好处被学习和使用其接口的成本所抵消;
- 小模块往往是浅层的。
4.6 Classitis
传统的鼓励导致大量的浅层类和方法,这增加了整个系统的复杂性。
- 小类;
- 小方法;
4.7 Examples: Java and Unix I/O
FileInputStream, BufferedInputStream, ObjectInputStream,过于复杂。
接口使用的常见情形应该被设计得尽可能简单。
如果一个接口有许多功能,但大多数开发人员只需要了解其中一些功能,则该接口的有效复杂性只是常用功能的复杂性。
4.8 Conclusion
关于接口的非正式部分,作者目前的观点:用英语描述的文档 > 形式化规范语言编写。
5 Information Hiding (and Leakage)
信息隐藏(和泄漏)
第5章 ~ 第9章在讨论创建深模块的技术。
5.1 Information hiding
每个模块应该封装一些知识,这些知识代表设计决策。
- 简化了模块的接口,隐藏了实现细节,减少了认知负担;
- 更易于模块的演化;
5.2 Information leakage
信息泄漏
-
原因:一个设计决策反映在多个模块中;
-
影响:在模块之间创建依赖关系;
-
后门泄漏更糟,当接口的实现变化时,接口的定义也要变化;
-
解决方案
- 从所有受影响的类中提取信息,并创建一个封装这些信息的新类;
- 简化接口,隐藏暴露的不必要知识;
5.3 Temporal decomposition
时间分解
- 原因:系统结构对应于操作将发生的时间顺序;
- 影响:系统结构的信息泄露;
- 解决方案
- 专注于执行每个任务所需要的知识,而不是任务发生的顺序;
- 将操作封装为一个单一的类,在不同的过程中使用;
5.4 Example: HTTP server
5.5 Example: too many classes
HTTP 请求中请求
7 Different Layer, Different Abstraction
7.1 Pass-through methods
传递方法使类更浅:它们增加接口。
- 实际使用的例外:converter和builder
- 职责存在重叠,如图(a)所示,其重构解决方案
- 将较低级别类暴露给较高级别类的调用者,从较高级别类中删除所有对功能的责任,如图(b)所示;
- 重新分配类之间的功能,如图(c)所示;
- 如果类无法解耦,将它们合并,如图 (d)所示;
7.2 When is interface duplication OK?
相同的接口,不同实现,减少了认知负荷。
7.5 Pass-through variables
引入上下文,传递对象。
避免问题的最好方法是使上下文中的变量为不可变的。
8 Pull Complexity Downwards
对于一个模块来说,拥有一个简单接口比拥有一个简单实现更重要。
向下拉取复杂性,最小化整个系统的复杂性。
- 被拉取的复杂性与类现有的功能密切相关;
- 向下拉取复杂性将在应用程序的其他地方导致许多简化;
- 向下拉取复杂性简化了类的接口;
在开发模块时,寻找机会让自己承受一点额外的痛苦,以减少用户的痛苦。
9 Better Together Or Better Apart?
合并的场景
- 共享信息;
- 简化接口;
- 消除重复;
分开特殊用途代码与通用代码
- 特殊用途代码向上移动到较高层;
- 通用代码向下移动到较低层;
决定是否拆分或合并模块应该基于复杂性。选择导致最佳信息隐藏、最少依赖关系和最深接口的结构。
10 Define Errors Out Of Existence
定义错误不存在:在许多情况下,操作语义可以修改,以便正常行为处理所有情况,并且没有异常情况需要报告。
减少异常处理器数量
- 定义错误不存在,减少错误的最佳方法是使软件更简单;
- 屏蔽异常,不需要软件的较高级别感知;
- 异常聚合,使用单个代码处理许多异常,用单一通用机制来替换几个专为特定情况定制的机制;
- 程序崩溃,打印诊断信息,终止应用程序;
- 检测到内存耗尽时,立即崩溃;
- 复制存储系统的核心价值是恢复丢失的数据,在 I/O 错误上终止不合适;
设计特殊情况不存在:通过设计正常情况,以一种自动处理特殊情况而不需要任何额外代码的方式。
- 异常是特殊情况代码的最重要来源之一;
- “定义错误不存在”属于“设计特殊情况不存在”;
处理异常的哲理;
- 暴露重要的内容,隐藏不重要的内容。
11 Design it Twice
考虑每个主要设计决策的多个选择,处于一个更好的位置设计方案。
- 不需要确定每个替代方案的每个功能。在这个阶段,勾勒出几个最重要的方法;
- 挑选截然不同的方法。即使确信只有一种合理的方法,认为第二种设计很糟糕;
大型软件系统的设计:
- 本身问题过于复杂;
- 没有人足够好;
- 因此想获得真正伟大的结果,必须要考虑多个可能性;
设计多次
- 专注于伟大的设计需要坚持练习;
- 系统设计上存在最佳实践;
12 Why Write Comments? The Four Excuses
例如选择变量名称以及如何使用文档来改进系统的设计。
- 好的注释可以对软件的整体质量产生重大影响;
- 编写好的注释并不难;
- 编写注释实际上可以很有趣;
不写注释的理由
- 好的代码是自我说明的;
- 仍然有大量的设计信息无法在代码中表达,例如
- 对每个方法做什么或其结果的高层次描述;
- 特定设计决策的理据;
- 在什么情况下调用特定方法是有意义的;
- 对于大型系统,用户阅读代码来学习行为是不实际的;
- 注释是抽象的基础,不如代码精准,但可以提供更多的表达能力;
- 仍然有大量的设计信息无法在代码中表达,例如
- 我没有时间写注释;
- 如果你允许注释被降级,你最终将没有任何注释;
- 必须在前期花一些额外的时间来创建一个干净整洁的软件结构,这将使你能够长期有效地工作;
- 写注释不需要花太多时间,不会超过开发时间的10%;
- 许多最重要的评论是那些与抽象相关的评论,例如类的顶级文档和方法。
- 注释过时了,变得误导性;
- 只有在代码发生重大变化时,才需要对文档进行重大更改,而代码的更改将需要比文档更改更多的时间;
- 保持文档、注释与代码的联系;
- 代码审查时,检测和修复过时的注释;
- 我见过的注释都是毫无价值的,为什么要费心呢?
- 大多数文档和注释提供的有效信息较少;
- 一旦你知道如何写,写好文档并维护并不难;
13 Comments Should Describe Things that Aren’t Obvious from the Code
注释的指导原则:注释描述代码无法明显体现的内容。
注释补充抽象信息的描述。
好的注释以比代码更抽象/更详细的细节级别解释事情。
- 注释的惯例,例如Java的Javadoc;
- 每个类都应该有一个接口注释,描述该类提供的总体抽象;
- 每个方法都应该有一个接口注释,描述其总体行为、其参数和返回值、它产生的任何副作用或异常,以及调用该方法之前调用者必须满足的任何其他要求;
不要重复代码
- 通过注释可以写代码,对代码没有理解作用;
- 在注释中使用与被注释实体名称相同的单词;
添加注释,跟学习的程度相关
- 低级别的注释增加精确度;
- 这个变量的单位是什么?
- 边界条件是包含性的还是排他性的?
- 如果允许空值,它意味着什么?
- 如果一个变量引用一个最终必须被释放或关闭的资源,谁负责释放或关闭它?
- 对于这个变量,是否有某些属性始终为真(不变量),例如“这个列表始终至少包含一个条目”?
- 关注变量代表什么,而不是它是如何被操纵的。
- 高级别的注释增强直觉;
- 这段代码在尝试做什么?
- 你能说的最简单的事情是什么,可以解释代码中的所有内容?
- 这段代码最重要的事情是什么?
- 触发代码调用的情形,尤其是对于特殊情形。
接口文档:开发人员使用类必须知道的信息。
接口实现注释:什么和为什么,而不是如何。
14 Choosing Names
就像大多数错误一样,一旦你弄清楚了,问题变得十分简单。
名称是复杂实体的简单抽象。
好的名字
- 精确性
- a GUI text editor,
x
—>charIndex
andy
—>lineIndex
boolean blinkStatus = true
—>boolean cursorVisible = true
VOTED_FOR_SENTINEL_VALUE = "null"
—>NOT_YET_VOTED = "null"
- Red Flag: Vague Name,模糊的名称
- 为特定变量想出一个精确、直观、且不太长的名称;
- 变量的使用范围限定且变量的含义在代码中易见,可以不需要一个长名称;
- 广泛的功能需要更通用的参数名称;
- 试图使用一个变量来表示几件事——》分开到多个变量中;
- Red Flag: Hard to Pick Name,难以挑选名称
- 可能底层对象没有一个干净的设计;
- a GUI text editor,
- 一致性
- 始终使用公共名称用于给定的目的;
- 除了给定的目的,永远不要使用公共名称用于任何事情;
- 确保目的足够狭窄,以至于所有具有该名称的变量具有相同的行为;
- srcFileBlock and dstFileBlock;
- 在最外层的循环中始终使用 i,在嵌套的循环中始终使用 j。
可读性必须由读者,而不是由作者来决定。
一个名字的声明和它的使用之间的距离越大,这个名字应该越长。
15 Write The Comments First
使用注释作为设计过程的一部分。
编写注释的最佳时间是在过程开始时,因为您正在编写代码。
- 先写注释;
16 Modifying Existing Code
维护注释
- 将注释放在描述的代码附近;
- 一个注释离它所描述的代码越远,它就应该越抽象;
- 注释属于代码,而不是提交日志;
- 避免重复;
- 不要在另一个模块中重新记录一个模块的设计决策;
- 不要在方法调用之前放置注释,以解释在调用的方法中发生了什么;
- 如果信息已经记录在程序之外的地方,就不要在程序中重复文档;
- 只需参考外部文档;
- 不要在另一个模块中重新记录一个模块的设计决策;
- 检查差异;
- 花几分钟时间扫描整体更改;
- 确保每个更改都正确反映在文档中;
- 更高层次的注释更容易维护;
17 Consistency
一致性的例子
- 命名;
- 编码风格;
- 接口;
- 设计模式;
- 不变量;
确保一致性
- 文档;
- 更本地化的约定,在代码特定位置指定;
- 强制执行;
- 脚本;
- 代码审查;
- 遵循惯例;
- 不要改变现有的惯例。抵制“改善”现有惯例的冲动;
- 有一个“更好的想法”并不是引入不一致性的充分理由;
- 是否有重要的新信息证明你的方法是合理的,而旧惯例建立时没有这些信息?
- 新方法是否好得多,以至于值得花时间更新所有旧用法?
- 继续升级;
- 当你完成时,应该没有旧惯例的任何痕迹;
- 仍然存在其他开发人员不知道新惯例的风险,因此他们可能在将来重新引入旧方法。
- 总体而言,重新考虑既定的惯例很少能很好地利用开发人员的时间。
一致性只有在开发人员有信心“如果它看起来像一个x,那么它确实是一个x”时才能提供好处。
18 Code Should be Obvious
确定代码是否显而易见的最佳方式是通过代码审查。
使代码更明显
- 精确有意义的名称;
- 一致性;
- 明智地使用空格;
- 注释;
使代码不明显
- 事件驱动编程;
软件应该易于读,而不是易于写。
如果代码符合读者将期望的惯例,那么代码是最明显的;
如果它不符合,那么记录行为是很重要的,这样读者就不会感到困惑。
另一种思考明显性的方式是信息。
如果代码不明显,那通常意味着关于代码的重要信息读者没有。
为了使代码显而易见,你必须确保读者始终拥有他们需要的信息来理解它。
- 减少所需的信息量,使用设计技术,例如抽象和消除特殊情况;
- 利用读者已经在其他上下文中获得的信息,例如遵循惯例和符合预期;
- 在代码中呈现重要信息,例如好的名称和策略性注释。
19 Software Trends
过去几十年在软件开发中变得流行的趋势和模式,讨论其能否对抗软件的复杂性。
- 面向对象编程和继承
- 私有方法和变量可以用于确保信息隐藏;
- 接口继承,通过为多个目的重用相同的接口;
- 允许在解决一个问题时获得的知识,被用来解决其他问题;
- 为了使一个接口具有许多实现,必须捕获所有底层实现的基本功能,同时避开实现之间的细节;
- 实现继承,子类可以选择继承父类的方法实现,或者通过定义具有相同签名的新方法来覆盖它;
- 减少了随着系统演变而需要修改的代码量;
- 创建了父类和每个子类之间的依赖关系,使得修改层次结构中的某个类变得困难;
- 谨慎使用实现继承——》基于组合;
- 分来父类管理与子类管理的状态;
- 敏捷开发
- 开发是增量和迭代的;
- 可能会导致战术编程;
- 单元测试
- 测试应与开发紧密集成,程序员应为其自己的代码编写测试;
- 开发人员在没有良好测试套件的系统中避免重构。他们试图将每个新功能或错误修复的代码更改数量最小化,这意味着复杂性不断累积,设计错误得不到纠正;
- 测试驱动开发
- 在编写代码之前编写单元测试;
- 测试驱动开发关注让特定功能工作,而不是找到最佳设计;
- 在修复错误时,先编写测试代码(复现);
- 设计模式
- 设计模式代表了一种设计替代方案,解决了常见问题,提供了干净的解决方案;
- 过度应用的风险,设计模式是好的,并不意味着更多的设计模式是更好的;
- Getters and setters
每当遇到一个新的软件开发范式时,从复杂性的角度来挑战它:这个提议真的有助于最小化大型软件系统的复杂性吗?
DDD?
20 Designing for Performance
使用基本知识来选择设计替代方案,这些替代方案是“自然高效”的,但也是干净和简单的。
- 在进行任何更改之前,测量系统现有的行为。
- 测量将确定性能调优将产生最大影响的地方;
- 提供基准,可以重新测量性能;
- 设计围绕关键路径
- 根据经验,几乎总是有可能找到一个干净而简单的设计,非常接近理想;
- 特殊情况对性能并不那么重要,为特殊情况编写代码以简单性为重,而不是性能;
如果写干净、简单的代码,系统很可能足够快,以至于不必太担心性能。
在少数情况下,确实需要优化性能,关键仍然是简单:找到对性能最重要的关键路径,并使它们尽可能简单。
21 Conclusion
处理复杂性
- 导致复杂性的根本原因,例如依赖性和模糊性;
- 识别不必要复杂性的危险信号,例如信息泄漏,不需要的错误条件,或者名字太通用;
- 提出了一些创建更简单的软件系统的通用想法,例如努力实现深层次和通用的类,定义不存在的错误,以及将接口文档与实现文档分开;
- 讨论了生产简单设计所需的投资心态;
如何用最简单的结构来解决一个特定的问题?
- 一个既简单又强大的解决方案;
- 一个干净、简单、明显的设计;
提高设计技能,正向循环
- 更快地生产出高质量的软件;
- 软件开发过程更加有趣;
Summary of Design Principles
设计原则的总结,抽象的,偏理念。
- Complexity is incremental: you have to sweat the small stuff. (2.4 Complexity is incremental)
- Working code isn’t enough. (3 Working Code Isn’t Enough)
- Make continual small investments to improve system design. (3.3 How much to invest?)
- Modules should be deep. (4 Modules Should Be Deep)
- Interfaces should be designed to make the most common usage as simple aspossible. (4.7 Examples: Java and Unix I/O)
- It’s more important for a module to have a simple interface than a simple implementation. (8 Pull Complexity Downwards)
- General-purpose modules are deeper. (6 General-Purpose Modules are Deeper)
- Separate general-purpose and special-purpose code. (9.4 Separate general-purpose and special-purpose code)
- Different layers should have different abstractions. (7 Different Layer, Different Abstraction)
- Pull complexity downward. (8 Pull Complexity Downwards)
- Define errors (and special cases) out of existence. (10.9 Design special cases out of existence)
- Design it twice. (11 Design it Twice)
- Comments should describe things that are not obvious from the code. (13 Comments Should Describe Things that Aren’t Obvious from the Code)
- Software should be designed for ease of reading, not ease of writing. (Red Flag: Nonobvious Code)
- The increments of software development should be abstractions, not features. (19.2 Agile development)
Summary of Red Flags
危险信号表示系统设计出现了问题。
- Shallow Module: the interface for a class or method isn’t much simpler than its implementation. (4.5 Shallow modules)
- Information Leakage: a design decision is reflected in multiple modules. (5.2 Information leakage)
- Temporal Decomposition: the code structure is based on the order in which operations are executed, not on information hiding. (5.3 Temporal decomposition)
- Overexposure: An API forces callers to be aware of rarely used features in order to use commonly used features. (5.7 Example: defaults in HTTP responses)
- Pass-Through Method: a method does almost nothing except pass its arguments to another method with a similar signature. (7.1 Pass-through methods)
- Repetition: a nontrivial piece of code is repeated over and over. (9.4 Separate general-purpose and special-purpose code)
- Special-General Mixture: special-purpose code is not cleanly separated from general purpose code. (9.5 Example: insertion cursor and selection)
- Conjoined Methods: two methods have so many dependencies that its hard to understand the implementation of one without understanding the implementation of the other. (9.8 Splitting and joining methods)
- Comment Repeats Code: all of the information in a comment is immediately obvious from the code next to the comment. (13.2 Don’t repeat the code)
- Implementation Documentation ContaminantsInterface: an interface comment describes implementation details not needed by users of the thing being documented. (13.5 Interface documentation)
- Vague Name: the name of a variable or method is so imprecise that it doesn’t convey much useful information. (14.3 Names should be precise)
- Hard to Pick Name: it is difficult to come up with a precise and intuitive name for an entity. (14.3 Names should be precise)
- Hard to Describe: in order to be complete, the documentation for a variable or method must be long. (15.3 Comments are a design tool)
- Nonobvious Code: the behavior or meaning of a piece of code cannot be understood easily. (18.2 Things that make code less obvious)