软件设计哲学
软件设计哲学
复杂性
复杂性的病症
- 变化放大:在多个地方修改;(烦人)
- 认知负载:程序员完成任务需要知道多少;(增加变更的成本)
- 不知道你不知道:要完成任务不明确要修改哪快代码,或开发人员必须具备哪些信息才能成功完成任务。(最麻烦)
好的系统设计最重要的目标之一是:
obvious
。
复杂性的成因
- depencies:当一段代码不能独立的理解和修改就形成了;
- obscurity:当重要的信息不明确就形成了。比如
time
这种变量名。
复杂性是渐增的。
战术与战略编程
- 战术:着眼于使功能尽快运行;
- 战略:花时间产生简洁架构以及修复问题。
战术编程
- 主要是跑起来:新需求 以及 bug 修复;
- 花不了太多的功夫做好设计,只想尽快干完;
- 增加复杂性 just ok。
这样就造成了复杂性,注意是渐增的。
战术编程注重快速完成任务。因此会寻找 quick patch
来处理遇到的问题。进而创造了复杂性,进而需要更多的 patch
。而这最终只会走上战术编程这一唯一道路。
战术龙卷风是一位多产的程序员,他抽出代码的速度比其他人快得多,但完全以战术方式工作。而其他程序员得给战术龙卷风擦屁股,更加凸显战术龙卷风的吊。
战略编程
- 第一步:Working Code Isn’t Enough;
- 为了完成当前任务增加不必要的复杂性是不可接受的;
- 首要任务:产生
great design
; - 需要投资思维:相对于最快的完成项目,不如花时间优化设计。
- 多尝试几种设计模式,直到找到最简洁的;
- 多写文档;
- 之前的设计模式可能有问题,花时间去修复而不是不管或
patch
。
10% - 20% 的时间用来投资。
模块应该 Deep
模块化设计的目标是模块间依赖最小化。
每个模块分为两部分:
- 接口:包含了在另一个模块的开发者使用这个模块的需要直到的所有内容。(What)
- 实现:接口许诺的代码实现。(How it dose it)
任何有接口和实现的代码单元都是一个模块。
OO
语言中一个class
就是一个模块;类中的方法,非OO
语言中的方法都可以认为是模块;高等级的子系统以及服务也是模块,它们的接口可能是内核调用
或者HTTP 请求
。
最好的模块化是那些接口比实现简单的多的。1. 简单的接口最大程度地减少模块施加在系统其余部分上的复杂性;2. 如果一个模块修改不需要修改接口,就不会影响其他模块。
接口
包含两种类型的信息:
- formal:
public
方法签名,public field
类型和名称; - informal: 只存在于注释中,还不一定保证完整和精准。
抽象
是一个实体的一个简单试图,消除了不必要的细节。
- 不能暴露不重要的细节;
- 不能隐藏较重要的细节。
深模块
模块深度可以是一种 cost
vs. benefit
的思想。
- benefit: 提供的功能性;
- cost: 对于复杂性来说就是接口。
接口应该使常见情况越简单越好。
信息的隐藏(和泄漏)
信息隐藏
达到深模块的最重要的技术是信息隐藏。基本思想是每个模块应该封装一些知识(代表设计决策)。这些知识隐藏在实现中而不是表现在接口中。e.g.
- B树如何保存信息,如何高效访问;
- 如何实现
TCP
网络协议; - 如何在多线程处理器调度线程。
信息隐藏从两方面减少了复杂性:
- 简化了模块的接口。减少了模块使用者的认知负担;
- 改进系统更加简单。更改实现不影响接口的调用。
信息隐藏不等同于
private
。因为private
字段也能通过public
方法暴露出去。
信息泄漏
当一个设计决策(design decision
)反映在多个模块中时,就会发生信息泄漏。这样会创建模块间的依赖:design decision
的修改都会引起相关模块的修改。
如果信息表现在模块的接口上,这个信息就泄漏了。
当然信息泄漏也放生在那些没在接口上的信息。例如:两个类对一种文件格式的文件进行读写。虽然这个文件格式没有出现在接口上,但是修改这个文件格式就需要修改这两个类。
上面后门式泄漏更有害但也更不明显。
信息泄漏是软件设计中最重要的 red flag
。
作为软件设计师,您可以学习的最佳技能之一是对信息泄漏的高度敏感。
e.g. 如果遇到在类间的信息泄漏,就应该问自己:如何重新组织这些类使得这个信息只影响一个类。
可以考虑两种方式:
- 类合并;
- 抽象出一个新的类。
时间分解
造成信息泄漏的最常见的原因是时间分解。
在时间分解中,系统的结构对应于操作将发生的时间顺序。
e.g. 一个程序对于一种文件格式的读、写、修改在时间解构下可能分为 3 个类。所有的这些类都需要知道文件格式的知识,造成了信息泄漏。
通用模块都 Deeper
设计一个新的模块时需要考虑是使用 general-purpose
还是 special-purpose
方式来实现它。
somewhat general-purpose
class
的功能应该表现当前的需求,而接口不是。接口应该能能满足多种需求而足够通用。接口对于当前的需求也应该容易使用。
e.g. editor
中删除文本
// backspace 键
void backspace(Cursor cursor);
// delete 键
void delete(Cursor cursor);
// 选择后删除
void deleteSelection(Selection selection);
这些特殊化只为接口用户提供了较少的利益,还增加了认知负担。这样到头来,接口会有大量的浅层方法。有许多方法只会被调用一次。
而应该使用较通用的 API
。
void insert(Position position, String newText);
void delete(Position start, Position end);
Position changePosition(Position position, int numChars);
- 这里使用了
Position
代替Cursor
,更加通用;
backspace
可以实现为: text.delete(text.changePosition(cursor, -1), cursor);
假如提供了 backspace
,用户得到这个文件阅读文档。
Position findNext(Position start, String string);
通用性可以更好地隐藏信息
软件设计最重要的元素之一就是确定谁需要知道什么以及何时知道。当细节很重要时,最好使它们明确且尽可能明显,例如修订的 Backspace
操作实现。将这些信息隐藏在界面后面只会产生晦涩感。
三省吾身
- 能满足当前需求的最简单的接口是什么;
- 这个方法在多少情况下回被使用;
- 当前需求下这个
API
用的简单么。
不同层,不同抽象
软件系统由多个层组成。较高的层使用较低层提供的设施。良好设计的系统,每层提供不同的抽象。
如果系统包含了相邻层由这相似的抽象。表明类分解存在问题。
当相邻层由相似的抽象,该问题通常以 pass-through methods
的方式展现。
pass-through methods
:那些方法签名和调用的方法签名类似,除了调用其他方法基本不做啥。
- 这类方法创建了类与类的依赖,被调用的方法签名变更,调用的方法签名也要变更;
- 没有增加功能性,只有最底层的方法有功能性。
这类方法表明:class
之间责任划分混乱。一个类提供接口,但是用另一个类却实现接口。这通常是一个坏主意。
功能的接口通常应该和功能的实现在同一个
class
。
当遇到 pass-through methods
问自己: “这些类分别负责哪些功能和抽象?”你会发现类的责任有重叠。
解决方案:重构类,以使每个类都有各自不同且连贯的职责。
- b: 将低层暴露给上层;
- c: 重新分配两个类的功能;
- d: 合并两个类。
什么时候接口重复是 OK 的?
dispatcher
;- 接口有多个实现;
装饰器: BufferedInputStream
- 动机:将类的特殊目的拓展与
more generic core
分开。 - 创建装饰器类需要考虑的:
- 能直接在类上直接添加功能而不创建装饰器类?
- 能否将特殊的需求与普通需求合并?
- 新功能在现有的装饰器上实现?
- 新功能是否真的需要包装现有功能?
接口 vs. 实现
类的接口通常应与其实现不同:内部使用的表示形式应与接口中出现的抽象不同。如果两者具有相似的抽象,则该类可能不是很深。
传递变量
变量通过一长串方法传递。如果要添加一个变量,就必须顺着调用链都加上。
- 现有的变量里面包括;
- 存到全局变量;
- context object。
降低复杂性
一个模块有一个简单的接口比一个简单的实现更重要。
在一起还是分开?
功能、系统、函数、类、方法、服务。
目标是减少复杂性,增加模块行。
- 一些复杂性原子组件的数量:组件越多越难找到想要的;
- 细分可能导致多的代码来管理组件;
- 细分可能导致组件更加分散;
- 细分可能导致重复;
下面时两片代码相关联的表现:
- 共享信息;
- 共同使用:用了一个代码也会用另一个代码;
- 概念上重叠:有一个简单的高级类,包括这两段代码;
- 不看另一个代码不能了解这个代码。
如果共享信息就在一起
如果能简化接口就在一起
在一起来消除重复
分离通用与特殊用途代码
如果模块包含可用于多种不同目的的机制,则它应仅提供一种通用机制。 它不应包含专门针对特定用途的机制的代码,也不应包含其他通用机制。
与通用机制关联的专用代码通常应放在不同的模块中(通常是与特定用途关联的模块)。
通常,系统低层更具通用性,而高层更特殊用途。分离,就是把特殊用途的代码上移。
分开合并方法
您不应该分解一种方法,除非它使整个系统更加简单。
设计方法时,最重要的目标是提供简洁的抽象。每种方法都应该做一件事并且完全做到这一点。
- 方法应该有个间接和简单的接口;
- 方法应该 Deep:接口比实现简单。
仅当导致更清晰的抽象时,分开方法才有意义。
将子任务分解为单独的方法
子方法包含子任务,父方法包含原来方法剩下部分。仅当:
- 读子方法的代码不需要知道父方法;
- 读父方法的代码不需要知道子方法的实现。
就是阅读代码不需要反复横跳。
分成两个方法,每个方法对原方法的调用者可见
仅针对原方法有复杂的接口(因为要做许多不紧密关联的事情)。
Define Errors Out Of Existence
减少需要处理异常的地方的数量。
为什么异常增加复杂性
异常:任何会改变程序中正常控制流程的不常见条件。
产生异常:
- 调用者提供了错的实参或者配置信息;
- 被调用方法不能完成任务:
I/O
操作等; - 分布式系统的网络问题;
- bugs。
异常打乱正常代码逻辑。遇到异常时:
- 继续前进完成工作;
- 放弃,因为异常可能发生在系统状态不一致。
异常处理代码为更多异常创造了机会。
很难保证处理异常的代码真正工作。因为测试环境很难重现异常,异常处理代码就很难执行。
太多异常
防卫过度代码。
类抛出的异常也是其接口的一部分;拥有许多异常的类比较少异常更浅。
Define errors out of existence
定义 API
没有异常需要处理。
Mask Exception
检测到异常情况并在系统中以较低级别对其进行处理,因此更高级别的软件无需了解该情况。
在适合的地方使用时,其深化了 class
,因为它减少了类的接口增加了功能性。
异常在低层方法处理。
异常聚合
在同一个地方处理多个方法抛出的多个异常。
异常在高层方法处理。
考虑异常聚合的一种方法是,它用可以处理多种情况的单个通用机制替换了几种针对特定情况而量身定制的特殊用途的机制。
Design it Twice
当进行重大设计决策的时候考虑多个选项可能会更好。
比如设计一个 GUI 文本编辑器
操作文本文件:
- 面向行接口;
- 面向单个字符接口;
- 面向字符串接口。
尝试选择截然不同的方法,将学得更多。尽管确信只有一种合适的方法,还是考虑第二种设计。
在对备选方案进行粗略设计之后,列出每个方案的优缺点。
接口最重要的考量是上层软件对其的易用性。
- 接口是否更简单?
- 更具通用性?
- 更高效?
为什么要写注释?
注释应该秒数那些在代码中不明显的东西
注释最重要的原因是抽象:包含了太多在代码中不明显的信息。注释能提供一个简单的、高层的视图。
好的注释在代码不同层级的细节上解释事情。
选择约定
各个语言各种格式。
不要重复代码
许多注释没有啥用。最常见的原因是:注释在重复代码。
增加一条注释应该自问:没看过代码的人只通过看代码就能写出注释?
还有原因是:将被注释的实体的名字复制到注释中。
写好注释:
- 注释中的名字与被描述的实体的名字不同;
- 注释通过提供不同详细程度的信息来增强代码;
- 一些注释提供比代码层更低级、更加详细的,增加了精度;
- 变量声明:比如类实例变量,方法参数,返回值:
- 变量的单位;
- 边界是否包含;
- null 是否允许,null 的意义?
- 变量指向的资源必须被释放。谁负责?
- 有什么不变的属性。
变量时,着重这个变量的表示,而不是如何操纵它。
- 变量声明:比如类实例变量,方法参数,返回值:
- 一些注释提供比代码层更高级、更抽象的,提供了意图:代码背后的原因,或者更抽象的方式思考代码;
- 用于方法内和接口:
- 不应包含细节,应该在高层描述代码,读者能够解释代码中几乎所有的事;
- 三省吾身:
- 这段代码是干嘛的?
- 最简单的事来解释这段代码所有的事?
- 这段代码最重要的事?
- 同层级的注释没啥用;
- 一些注释提供比代码层更低级、更加详细的,增加了精度;
- 使用名词而不是动词
接口文档
注释最重要的角色是定义抽象。
抽象是实体的简化,保留了必要信息,省略了可以安全忽略的细节。
代码不适合描述抽象,太低层次包含了不应被抽象看到的实现细节。
- 将接口注释与实现注释分开;
- 接口注释:使用类和方法的必要信息,抽象的定义;
- 实现注释:为了实现抽象,类和方法内部如何工作。
方法的接口注释包含了 higher-level
和 lower-level
:
- 开始一两句描述调用者认为的方法行为(
higher
); - 描述参数和方法,必须非常精准,同时也要描述约束和依赖;
- 描述边际效应:影响未来系统的操作而不是结果的一部分;
- 描述抛出的异常;
- 描述方法调用必须满足的前置条件。
实现注释: what, why, not how
主要的目标是帮助读者明白代码在做什么。
选择名字
创建图像
当选择一个名字,目标是在读者闹钟创建一个名字后面本质的图像。
名字是一种抽象:它们提供了一种简单的方式来思考背后复杂的实体。
最好的名字是那些专注于实体重要的而忽略次要的细节。
名字应该精准
精准,一致性。
一致性减少了认知负担。
- 给定用途时总是使用常用名字;
- 非给定用途不要使用常用名字;
- 用途足够窄,所有的名字有相同的行为。
变量的声明与用的地方越远,名字应该越长。
先写注释
最先写注释
- 新类,先写类接口注释;
- 接着,写接口注释以及重要方法签名,但是不写方法体;
- 知道基本架构感觉 ok;
- 写类重要实例变量的声明以及注释;
- 填写方法体,添加实现;
- 写方法体时,需要增加方法和实例,同时添加注释。
注释是设计工具
这样提供了系统设计。提供了完全捕获抽象的唯一方法,好的抽象是好的系统设计的基础。
写好注释,必须识别变量或者代码的本质:这个最重要的方面是撒?
注释如同煤矿中的金丝雀。
- 如果注释很长,可能是没有好的抽象;
- 如果接口方法注释很简单也很短,表明这个方法有简单的接口;
- 好的注释必须提供调用方法的所有信息并且易懂。
提早写注释,能让注释编写更加有趣。
修改现有代码
保持战略编程
如果你没有使设计更好,你可能在让它变得更坏。
保持注释,注释尽量靠近代码
注释属于代码,而不是提交记录
当写注释时,问问自己开发者是否将来会使用这些信息。如果需要这些信息,就应当在代码中,而不是需要他们在提交记录中找。
避免重复
如果有重复,找一个地方进行注释。其他地方则直接引用第一个地方。
检查 diff
提交代码前先检查 diff。
高级别的注释更易维护
一致性
如果系统有一致性,意味着
- 相似的东西通过相似的方式实现;
- 减少错误。
一致性的例子:
- 名称;
- 代码风格;
- 接口;
- 设计模式;
- 不变性。
保证一致性
- 文档:创建一个列出最重要的总体约定的文档;
- 强制:工具强制自动化检查;
- 入乡随俗:看看已有的代码是咋写的;
- 不要更改现有规定:抵制“改善”现有公约的冲动。
一致性不只是相似的用相似的方式实现,也是不相似的用不同的方式实现。
代码应该是显而易见的
使代码更加明显
- 选择一个好名字;
- 一致性;
- 明智使用空白
方法注释
/**
* @param numThreads
* The number of threads that this manager should spin up in
* order to manage ongoing connections. The MessageManager spins
* up at least one thread for every open connection, so this
* should be at least equal to the number of connections you
* expect to be open at once. This should be a multiple of that
* number if you expect to send a lot of messages in a short
* amount of time.
* @param handler
* Used as a callback in order to handle incoming messages on
* this MessageManager's open connections. See
* {@code MessageHandler} and {@code handleMessage} for details.
*/
主要代码块间隔
// Round up the length to a multiple of 8 bytes, to ensure alignment.
uint32_t numBytes32 = (downCast<uint32_t>(numBytes) + 7) & ~0x7;
assert(numBytes32 != 0);
// If there is enough memory at firstAvailable, use that. Work down
// from the top, because this memory is guaranteed to be aligned
// (memory at the bottom may have been used for variable-size chunks).
if (availableLength >= numBytes32) {
availableLength -= numBytes32;
return firstAvailable + availableLength;
}
注释
那些使代码不明显
事务驱动
很难跟踪控制流。事件处理函数从来不是直接调用,间接的被事务模块调用,一般是函数指针或接口。
泛型容器
违反读者期望的代码
软件趋势
OO 和继承
- 接口继承,父类定义一个或多个方法的签名,但不实现方法;
- 实现继承,还提供默认方法。
程序员需要完全知晓整个类继承才能修改代码,这造就了高复杂性。所以要谨慎使用。
组合 > 继承
敏捷开发
敏捷开发的最重要元素之一是开发应该是渐进的和迭代的概念。
增量开发应该是抽象的,不是功能的。
单元测试
单元测试和系统测试。
测试驱动开发
不好,其聚焦于实现某些功能,而不是找到最好的设计。
设计模式
解决特定类型问题的通用方法。