目录
在线阅读:https://cactus-proj.github.io/A-Philosophy-of-Software-Design-zh/
视频演讲:
软件设计的核心在于降低复杂性
两种解决方法:
- 使代码更简单和更明显来消除复杂性
- 封装,模块化设计,以便程序员在一个模块上工作而不必了解其他模块的细节
识别复杂性:
如果一个软件系统难以理解和修改,那就很复杂。反之,则很简单。
系统的总体复杂度(C)由每个部分的复杂度(cp)乘以开发人员在该部分上花费的时间(tp)加权。在一个永远不会被看到的地方隔离复杂性几乎和完全消除复杂性一样好。
关于复杂性
复杂性的症状:
变更放大:看似简单的变刚刚需要在许多地方修改代码
认知负荷:需要多少知识才能完成一项任务
未知的未知:必须修改哪些代码才能完成任务,不明显
复杂性的原因:依赖性和模糊性
“战术编程”:图快,功能能 work就行,不会花时间寻找最佳设计
战略规划:成为一名优秀的软件设计师的第一步是要意识到能跑起来的代码是不够的,引入不必要的复杂性以更快地完成当前任务是不可接受的,最终的是系统的长期结构。主要目标是必须是制作出色的设计,并且这种设计也会起作用。
关于模块设计
将模块分为两个部分:接口和实现
接口包括了在不同模块工作的开发者为了使用给定模块必须知道的所有内容。通常,接口描述模块做什么,而不描述模块如何做。该实现由执行接口所承诺的代码组成。在特定模块中工作的开发人员必须了解该模块的接口和实现,以及由给定模块调用的任何其他模块的接口。除了正在使用的模块以外,开发人员无需了解其他模块的实现。
深浅模块。最好的模块很深:它们允许通过简单的接口访问许多功能。浅层模块是具有相对复杂的接口的模块,但功能不多:它不会掩盖太多的复杂性。
好的例子:
int open(const char* path, int flags, mode_t permissions);
ssize_t read(int fd, void* buffer, size_t count);
ssize_t write(int fd, const void* buffer, size_t count);
off_t lseek(int fd, off_t offset, int referencePosition);
int close(int fd);
unix 文件 i/o 接口
通过将模块的接口与其实现分开,我们可以将实现的复杂性从系统的其余部分中隐藏出来。模块的用户只需要了解其接口提供的抽象。设计类和其他模块时,最重要的问题是使它们更深,以使它们具有适用于常见用例的简单接口,但仍提供重要的功能。这样做能最大限度地隐藏复杂性。
关于信息隐藏和信息泄露
信息隐藏的最佳形式是将信息完全隐藏在模块中,从而使该信息对模块的用户无关且不可见。但是,部分信息隐藏也具有价值
在设计模块时,应专注于执行每个任务所需的知识,而不是任务发生的顺序。
如果一个常用特性的 API 迫使用户了解其他很少使用的特性,这将增加不需要这些很少使用的特性的用户的认知负荷
关于通用模块
设计新模块时尽量采用通用方式
关于层与抽象
如果一个系统中相邻的分层,存在了相似的抽象概念,这就表明分类拆解可能存在问题
“不同的层,不同的抽象”规则只是此思想的一种应用:如果不同的层具有相同的抽象,例如透传方法或装饰器,则很有可能它们没有提供足够的利益来补偿它们代表的其他基础结构。类似地,传递参数要求几种方法中的每一种都知道它们的存在(这增加了复杂性),而又不提供其他功能。
将简单留给用户,复杂留给自己:
在开发模块时,为了减少用户的痛苦,要找机会给自己多吃一点苦。
关于在一起还是分开
设计方法时,最重要的目标是提供简洁的抽象。每种方法都应该做一件事并且完全做的彻底。该方法应该具有简洁的接口,以便用户无需费神就可以正确使用它。该方法应该很深:其接口应该比其实现简单得多。如果一个方法具有所有这些属性,那么它的长短与否无关紧要。
关于异常
最近的一项研究发现,分布式数据密集型系统中超过 90%的灾难性故障是由错误的错误处理引起的 1。当异常处理代码失败时,很难调试该问题,因为它很少发生。
不行就让应用程序崩溃,比如内存不够、io 错误
设计两次
设计软件非常困难,因此您对如何构造模块或系统的初步思考不太可能会产生最佳的设计。如果为每个主要设计决策考虑多个选项,最终将获得更好的结果:设计两次。
尝试选择彼此根本不同的方法;这样您将学到更多。即使你确定只有一种合理的方法,无论如何也要考虑第二种设计,不管你认为它有多糟糕。考虑该设计的弱点并将它们与其他设计的特征进行对比将很有启发性。
多种设计中选择考虑因素:
1.一种选择是否具有比另一种有更简单的接口?
2.一个接口比另一个接口更通用?
3.一个接口的实现是否比另一个接口的实现更有效率?
关于注释
注释的指导原则:注释应描述代码中不明显的内容
注释编写:
编写注释后,请问自己以下问题:从未看过代码的人能否仅通过查看注释旁边的代码来写出这样的注释?如果答案是肯定的,则注释不会使代码更易于理解。
关于取名
在考虑特定名称时,请问自己:“如果有人孤立地看到该名称,而没有看到其声明,文档或使用该名称的任何代码,他们将能够猜到该名称指的是什么?还有其他名称可以使画面更清晰吗?”
- 对于新类,我首先编写类接口注释。
- 接下来,我为最重要的公共方法编写接口注释和签名,但将方法主体保留为空。
- 我对这些注释进行了迭代,直到基本结构感觉正确为止。
- 此时我为类中最重要的类实例变量编写了声明和注释。
- 最后,我填写方法的主体,并根据需要添加实现注释。
- 在编写方法主体时,我通常会发现需要其他方法和实例变量。对于每个新方法,我在方法主体之前编写接口注释。对于每个变量,我在编写其声明的同时填写了注释。
代码应该是显而易见的
软件的设计应易于阅读而不是易于编写
软件趋势
面向对象 :
接口继承与实现继承
应谨慎使用实现继承。在使用实现继承之前,请考虑基于组合的方法是否可以提供相同的好处。例如,可以使用小型辅助类来实现共享功能。与其从父类中继承功能,原始类可以各自建立在辅助类的功能之上。
敏捷开发:
渐进式开发通常是一个好主意,但是软件开发的增量应该是抽象而不是功能。可以推迟对特定抽象的所有想法,直到功能需要它为止。一旦需要抽象,就要花一些时间进行简洁的设计。
关于设计性能
围绕关键路径进行设计
结论
识别不必要的复杂性的危险信号,例如信息泄漏,不必要的错误情况或名称过于笼统。我提出了一些通用的思想,可以用来创建更简单的软件系统,例如,努力创建更深和更通用的类,定义不存在的错误以及将接口文档与实现文档分离
A clean, simple, and obvious design is a beautiful thing
设计原则小结
- 复杂性是逐步增加的:您必须努力处理小事情(请参阅 2.4)。
- 能跑起来的的代码是不够的(请参阅 3.2)。
- 持续进行少量投资以改善系统设计(请参阅 3.3)。
- 模块应较深(请参见 4.4)
- 接口的设计应尽可能简化最常见的用法(请参阅 4.7)。
- 一个模块具有一个简单的接口比一个简单的实现更重要(请参阅 第八章, 9.8)。
- 通用模块更深入(请参阅 第六章)。
- 通用和专用代码分开(请参见 9.4)。
- 不同的层应具有不同的抽象(请参见 第七章)。
- 降低复杂度(请参阅 第八章)。
- 定义不存在的错误(和特殊情况)(请参阅 10.3)。
- 设计两次(请参阅 第十一章)。
- 注释应描述代码中不明显的内容(请参见 第十三章)。
- 软件的设计应易于阅读而不是易于编写(请参见 18.2))。
- 软件开发的增量应该是抽象而不是功能(请参见 19.2)。
危险信号小结
- 浅模块:类或方法的接口并不比其实现简单得多(请参见第 25、110 页)。
- 信息泄漏:设计决策反映在多个模块中(请参阅第 31 页)。
- 时间分解:代码结构基于执行操作的顺序,而不是信息隐藏(请参见第 32 页)。
- 过度暴露:API 强制调用者注意很少使用的功能,以便使用常用功能(请参阅第 36 页)。
- Pass-Through Method:一种方法几乎不执行任何操作,只是将其参数传递给具有相似签名的另一种方法(请参见第 46 页)。
- 重复:一遍又一遍的重复代码(请参见第 62 页)。
- 特殊通用混合物:特殊用途代码未与通用代码完全分开(请参阅第 65 页)。
- 联合方法:两种方法之间的依赖性很大,以至于很难理解一种方法的实现而又不理解另一种方法的实现(请参阅第 72 页)。
- 注释重复代码:注释旁边的代码会立即显示注释中的所有信息(请参阅第 104 页)。
- 实施文档污染了界面:界面注释描述了所记录事物的用户不需要的实施细节(请参阅第 114 页)。
- 含糊不清的名称:变量或方法的名称过于精确,以至于它不能传达很多有用的信息(请参阅第 123 页)。
- 难以选择的名称:很难为实体提供准确而直观的名称(请参见第 125 页)。
- 难以描述:为了完整起见,变量或方法的文档必须很长。(请参阅第 131 页)。
- 非显而易见的代码:一段代码的行为或含义不容易理解。(请参阅第 148 页)。
打赏作者