设计,软件匠艺的圣杯与终极目标。我们全都在寻求一种完美设计,不费吹灰之力就能添加特性。我们想要一种强固设计,经年累月地维护之后,仍能保持简洁与灵活。设计归根结底 就是软件的一切。
我写过很多关于设计的内容。我写过关于设计原则、设计模式和架构的几本书。而且还有很多其他作者也写过这个主题的书。软件设计方面的文字资料汗牛充栋。
但那些不是本章要讨论的。建议你自行研究有关设计的内容,阅读图书,理解软件设计和架构的原则、模式和理论体系。
而所有这些内容的关键,亦即我们所需的全体特点在设计中的反映,一言以蔽之,曰“简”。如切特·亨德里克森(Chet Hendrickson)所言:“鲍勃大叔写了上千页关于整洁代码的内容。而肯特·贝克只写了四行字。”1这四行字就是我们在这里要集中探讨的。
乍看之下,满足系统所有需求特性,同时提供最大修改灵活性的最简设计就是该系统的最佳设计。然而,这会令我们思考简单性的含义。2简单并不代表容易。简单意味着非联接,而非联接并不简单。
软件系统中哪些部分是联接着的?最为昂贵和突出的联接,莫过于将高层级策略纠缠于低层级细节的那些部分。连接 SQL 与 HTML、将框架服务于系统核心价值、按照业务规则做报表格式化等,这些操作往往造成可怕的复杂度。对于这些情况,联接易于实现,但难以添加新特性,难以修正缺陷,难以改进和清理设计。
简单设计是基于高层级策略不关心低层级细节实现的设计。高层级策略被隔离于低层级细节之外,不会受到对于低层级细节修改的影响。
实现这种分隔的基本手段是抽象。抽象放大本质因素,消除无关因素。高层级策略是本质因素,所以被放大。低层级细节是无关因素,所以被隔离。
这种抽象的实施手段是多态。我们安排高层级策略采用多态接口,用于管理低层级细节。然后,我们再来安排多态接口的低层级细节。这样一来,所有源代码依赖都是从低层级细节指 向高层级策略的,而且高层级策略对低层级细节的实现一无所知。修改低层级细节不会影响 高层级策略(见图 6.1)。
多态如果说最佳系统设计是满足特性的最简设计,那么我们可以说,这种设计必须有最少的抽
象元素,将高层级策略与低层级细节隔离开来。
这与我们在 20 世纪 80 年代和 90 年代所采用的策略正好相反。在那些日子里,我们沉迷于
在代码中植入钩子,以为可供未来之用。我们之所以选了这么一条路,是因为那时软件很难修改— 即便设计本身够简单。软件为何难以修改?因为构建时间太长,测试时间更长。
在 20 世纪 80 年代,就连小系统都需要一小时以上的构建时间和许多小时的测试时间。当 然,测试是手工进行的,所以也很不完善。随着系统变得更大、更复杂,程序员们也更加不敢 做出修改。这导致了过度设计的思潮,推动我们构造复杂程度远超特性所需的系统。
20 世纪 90 年代,我们改弦更张,极限编程和敏捷编程诞生了。机器能力强大,构建时间 被压缩到分秒之内。我们发现,已经能够采用支持快速运行的自动测试了。
这一技术飞跃让肯特·贝克谈到的 YAGNI 原则和简单设计四原则变得切实可行。
YAGNI
1999 年,我和马丁·福勒、肯特·贝克、荣恩·杰弗里斯及另外几位朋友一起教极限编程 课。话题转到过度设计和过早泛化。有人在白板上写下 YAGNI,说:“你不需要它(You aren’t gonna need it)。”贝克打断他说,也许你仍然需要它,但应该问问自己:“如果不需要呢(What if you aren’t)?”
此即 YAGNI 问句的出处。每次当你认为“我需要这个钩子”时,问问自己,如果根本不 用钩子会怎样。如果丢弃钩子的成本可以接受,大概就不该用钩子。如果在设计中使用钩子的 成本随着时间推移变得太高,而用到钩子的概率很低的话,大概你就不该把钩子放进来。
很难想象 20 世纪 90 年代末的钩子狂热。设计者们拼命往软件中塞钩子。那时,钩子被认 为是软件领域的共识和“最佳实践”。
所以,当极限编程的 YAGNI 规则刚出现时,立即被严厉批判,被认为是异端邪说和无理 废话。
讽刺的是,如今 YAGNI 是良好软件设计的最重要规则之一。如果你有一套像样的测试集, 且熟知重构规则,添加新特性和修改设计来支持该特性的代价,几乎必然小于实现和维护未来 才可能用得上的钩子的代价。
无论怎么看,钩子都问题多多。我们很少能用对它,因为我们特别不擅长预测客户真正想 做的事。所以,我们往往基于不怎么会发生的假设情况,塞进了远多于所需的钩子。
更重要的是,千兆赫兹级别时钟频率和以 TB 计的内存对软件过程和架构产生的影响出乎 我们的意料。直至 20 世纪 90 年代末,我们才意识到,我们能借助这些进步来大幅简化设计。
软件行业最吊诡的事是,按照摩尔定律以指数级别发展的处理器速度,推动我们打造越来 越复杂的软件系统,同时也令我们有可能简化这些系统的设计。
事实证明,YAGNI 是我们现在掌握的几乎无限的计算机能力的意外结果。因为构建时间已经缩减到几秒钟,因为只要我们能编写和执行全面的测试集,并确保其几秒钟就执行完毕,我 们就能不把钩子放进去,而是随着需求的变化重构设计。
这是否意味着永远不需要钩子呢?我们是否总是只为今日所需的特性设计系统呢?我们是 否永不向前看,永不做未来计划呢?
不,那不是 YAGNI 的意思。有时,使用钩子会是好主意。在代码中预留未来余地的做法 并未过时,顾及未来永远是明智做法。
只不过,在过去二三十年里,权衡因素急剧变化,导致如今最好放弃大部分钩子。所以我 们会问:
如果你不再需要它,会怎样?
用测试覆盖
我第一次读到贝克的简单设计法则是在《解析极限编程— 拥抱变化》(Extreme Programming Explained)第一版 1中。那时,四大法则如下所示:
1. 系统(代码和测试)必须与你要沟通的一切沟通。2. 系统不能有重复代码。
3. 系统应包括尽量少的类。
4. 系统应包括尽量少的方法。
到了 2011 年,四大法则演化成了:
1. 测试通过。
2. 揭示意图。
3. 没有重复。
4. 小。
2014 年,科瑞·海恩斯(Corey Haines)写了一本阐述这四大法则的书 1。
2015 年,马丁·福勒写了一篇关于这个主题的网文 2。他换了一种说法来谈四大法则:
1. 通过测试。
2. 揭示意图。
3. 没有重复。
4. 最少元素。
在本书中,我这样表达第一法则:
1. 用测试覆盖。注意第一法则在不同年代是如何被强调的。第一法则被一分为二,后两个法则却合二为一。
还要注意,随着时间推移,测试的功用从沟通变为覆盖,重要性越来越高。
覆盖
测试覆盖的概念由来已久。我能找到的最早讨论可以追溯到 1963 年。3那篇文章开头两段 我认为即便不是振聋发聩,也算很有意思。
有效的程序检查对任何复杂计算机程序都必不可少。在程序被认为可以应用于实 际问题之前,总是要对其运行一个或多个测试用例。每个测试用例都会检查程序部分。然而,错误往往在程序投入运行后几个月(甚至几年) 才出现。这表明程序中,在很少出现的输入条件下才会被调用的部分,在检查阶 段没有被正确测试。
想要信心十足地依赖任何特定程序,仅仅知道该程序在大多数情况下都能工作或 者它迄今为止甚至没有出过错,远远不够。真正的问题是,是否可以指望它每次 都能成功满足其功能设计规格。这意味着,在程序通过检查阶段后,即使输入数 据或条件的不寻常组合,也不应存在程序出现意外错误的可能性。程序的每一部 分都必须在检查时使用,以便确认其正确性。
1963 年,距在第一台电子计算机上运行第一个程序 1不过区区十七年。那时我们已经知道, 减少软件错误威胁的唯一有效途径就是测试每一行代码。
过去几十年里一直有各种代码覆盖工具出现。我不记得第一次见到是什么时候。我想大约 在20世纪80年代末到90年代初吧。当时,我使用Sun Microsystem公司的Sparc工作站,而Sun 公司就有一个叫作 tcov 的工具。
我也不太记得第一次听到有人问“你代码覆盖率是多少”是在什么时候了。大概在 21 世纪 早期吧。但在那以后,代码覆盖率的概念就变得非常普遍了。
从那时起,作为持续构建的一部分运行代码覆盖工具,发布每个构建版本的代码覆盖率数 值,几乎成了软件团队的惯例。
代码覆盖率达到多少合适呢?80%?90%?很多团队认为这样的数字足够好了。但在本书 出版前 60 年,米勒和马洛尼给出了很不一样的答案:100%。
除了 100%,其他数字有什么意义呢?如果你满足于 80%的代码覆盖率,那你还有 20%的 代码不知道能不能正常工作。你怎么可能满足于此?你的客户怎么可能满足于此?
所以,当我在简单设计的第一法则中用到“覆盖”一词时,我就是指覆盖 100%的代码行, 以及覆盖 100%的代码分支。
渐近目标
你也许会抱怨,100%是不可能达到的目标。我无可辩驳。覆盖 100%的代码行和 100%的分 支绝非易事。实际上,在一些情形下也许不现实。但那并不意味着覆盖率没有提升空间。
将 100%看作渐近目标吧。也许你永远无法达到,但没理由不在每次签入代码时逼近它。我参与过多个代码行数增长到许多万行,但代码覆盖率一直维持在百分之九十几的项目。
设计?
不过,如此之高的代码覆盖率与简单设计有何关系呢?覆盖率为何是第一法则呢?
可测试的代码就是解耦了的代码。
为了让代码中的每个部分都达到够高的行与分支覆盖率,测试代码就该能访问这些部分。这意味着每个部分必须与其他代码充分解耦,可以分离出来,从单独的测试中调用。所以,这 些测试不仅测试行为,也测试耦合程度。编写分离出来的测试也是一种设计行为,因为被测试 的代码必须被设计为可被测试。
在第四章“测试设计”中,我们将探讨测试代码和生产代码如何往不同方向演化,以防止 测试与生产代码耦合得太紧,从而产生脆弱的测试。但测试脆弱问题与模块脆弱问题并无二致, 解决方法也一样。如果系统的设计能避免测试变得脆弱,那么它也能防止系统的其他元素变得 脆弱。
但还有更多好处
测试并不只是能推动你创造出解耦和强固的设计,它还能让你持续改进这些设计。如我们 多次谈到的那样,可信赖的测试集能极大地减少对修改的恐惧。如果你拥有这样的测试集,而 且如果测试集执行得很快,那么每当找到了更好的做法时就能改进代码设计。当现有设计无法 满足需求变化时,这些测试将能让你无所畏惧地改进设计,更好地满足新需求。
这就是覆盖率作为简单设计第一法则,而且是最重要法则的原因。没有覆盖系统的测试集, 另外三条法则就变得不切实际,因为这些法则都基于高覆盖率。而且另三条法则与重构有关。没有良好、详尽的测试集,重构几乎无法做到。
充分表达
在编程早期的几十年里,我们的代码无法揭示意图。事实上,“代码”(code)这个名字本 身就表明意图被掩盖了。在那些日子里,代码看起来像图 6.2 这样:
请注意那些无处不在的注释。测试绝对必要,因为代码本身根本没有揭示出程序的意图。
然而,我们已不在 20 世纪 70 年代工作了。我们使用的语言具有极大的表现力。遵守适当的纪律,我们可以生产像“写得很好的散文,从不掩盖设计者的意图”1的代码 2。下面这段来自第 4 章录像带租赁店例子的 Java 代码就是这种代码的范例:
public class RentalCalculator {
private List<Rental> rentals = new ArrayList<>();
public void addRental(String title, int days) {
rentals.add(new Rental(title, days));
}
public int getRentalFee() {
int fee = 0;
for (Rental rental : rentals)
fee += rental.getFee();
return fee;
}
public int getRenterPoints() {
int points = 0;
for (Rental rental : rentals)
points += rental.getPoints();
return points;
} }
如果你不是项目成员,可能不理解这段代码中的所有内容。然而,即使只是最粗略地一瞥, 设计者的基本意图也很容易识别。变量、函数和类型的名称极具描述性,算法的结构也很容易看出来。这段代码具有表现力。这段代码很简单。
底层抽象
为了防止你认为,表达性仅仅指为函数和变量起个好名字,我应该指出,还有另一个考虑: 层级的分隔和对底层抽象的阐述。
如果每行代码、每个函数和每个模块都安置在定义明确的分区中,清楚描述了代码的层级, 以及自身在整个抽象中的位置,那么这个软件系统就具有表现力。
你可能已经发现这句话难以理解,所以我再啰唆一下,说得更清楚一些。
想象一个需求复杂的应用程序。我喜欢用的例子是一个工资系统。
按小时计薪的雇员每周五根据他们提交的考勤卡领取工资。在一周内工作 40 小时后, 他们每工作一小时都会获得一个半小时的工资。- 提成类雇员在每个月的第一个和第三个星期五发工资。薪酬由基本工资和他们所提交 的销售收据的佣金组成。
- 固定薪资类雇员的工资在每月的最后一天支付。月薪数额固定。应该不难想象,用一组带有复杂的 switch 语句或 if/else 链的函数可以捕捉到这些需求。
然而,这样的一组函数很可能会掩盖底层抽象。底层抽象是什么?
public List<Paycheck> run(Database db) {
Calendar now = SystemTime.getCurrentDate();
List<Paycheck> paychecks = new ArrayList<>();
for (Employee e : db.getAllEmployees()) {
if (e.isPayDay(now)){
paychecks.add(e.calculatePay());
- }
return paychecks;
}
- 请注意,这里没有提到任何盘踞在需求里的狰狞细节。这个应用的本质是我们需要在发薪日支付所有员工的工资。把高层级的策略和低层级的细节分开,是使设计简单和富有表现力的最基本要素。
再论测试:问题的后半部分
回到贝克的初版第一法则:
1. 系统(代码和测试)必须与你要沟通的一切沟通。
他这么说有自己的理由。而且,在某种意义上,后来的修改并非幸事。
无论生产代码多有表达力,都不能与它所在的上下文沟通。那是测试的事。
你写的每个测试,尤其是那些独立和解耦的测试,都展示了生产代码本该如何使用。测试写得好,就成了它们要测试那部分代码的最佳调用范例。
因此,代码与测试相得益彰,展示了系统中每个元素的功用,以及系统中每个元素该被如 何使用。
这与设计有何关系呢?当然是全然有关。设计要达到的基本目标就是让其他程序员能容易 地理解、改进和升级我们的系统。除了让系统表述它能做什么,以及该怎么用,没有更好的路 子可以实现这一目标。
尽量减少重复
在软件的早期,我们根本就没有源代码编辑器。我们用 2 号铅笔在预先印好的编码表上写代码。最好的编辑工具是一块橡皮。我们没有用于复制和粘贴的有效方法。
正因如此,我们没有重复代码。对我们来说,创建代码片段的单一实例并将其放入子程序中,更为方便。
但后来出现了源代码编辑器。编辑器带来了复制/粘贴操作。突然间,复制代码片段并将其粘贴到新位置,然后修改到能工作为止,变得容易多了。
因此,年复一年,越来越多的系统中出现了大量重复代码。
重复通常问题多多。两段或更多类似的代码往往需要一起修改。很难找到这些雷同部分。正确修改它们甚至更难,因为它们存在于不同的上下文代码中。而且,重复会导致脆弱。
一般来说,最好将重复代码抽象为新函数,并为其提供适当的参数,以适应不同调用场景 的差异。这样,就消灭了重复代码。
有时,这种策略行不通。例如,有时重复发生在遍历复杂数据结构的代码中。系统的许多不同部分可能希望遍历该结构,并将使用相同的循环和遍历代码,然后在该代码的主体中操作 数据结构。
数据结构随着时间推移发生变化,程序员将不得不找到遍历代码中的全部重复内容,并且 正确地做修改。遍历代码重复越多,出现脆弱问题的危险性就越大。
使用lambda表达式、Command对象、Strategy模式,甚至Template Method模式 1,将重复代码封装起来,向遍历代码传递必需的操作,就能消除重复代码。
意外重复
并非所有的重复都应该消除。在有些情况下,两段代码可能非常相似甚至一模一样,但将 会出于截然不同的原因而被修改 2。我将这类情况称为意外重复。意外重复不应该被消除。应该 允许重复持续存在。随着需求的变化,两段代码将各自演化,这种重复将被消解。
显然,管理重复代码并非小菜一碟。鉴别真重复代码与意外重复代码,封装与孤立真重复 代码,需要多多思考、谨慎对待。
判别真重复与意外重复,极大地取决于代码表达自身意图的程度。意外重复的两段代码各 有意图。真重复的代码意图一致。
使用抽象手段、lambda 表达式和设计模式来封装与孤立真重复,牵涉到大量重构工作。重 构需要足够坚固的测试集。
所以,消除重复在简单设计法则中位列第三。先是测试,然后是表达。
尺寸尽量小
简单设计由简单元素组成。简单元素很小。简单设计的最后一条法则指出,在让所有的测 试都通过之后,在让代码尽可能有表现力之后,在把重复的部分降到最低之后,接下来你应该 在不违反其他三条法则的情况下,努力减小每个函数内部的代码规模。
该如何做到?主要是通过抽取更多函数。正如我们在第 5 章“重构”中所讨论的,抽取函 数,直到不能再抽取为止。
这样做给你留下了精巧的小函数,由于它们有漂亮的长名字,因而有助于使函数非常小, 而且富有表现力。
简单设计
好多年前,肯特·贝克和我讨论过设计原则。我一直纠结于他提出的观点。他说,如果你 不打折扣地遵循四法则,也就满足了其他设计原则— 这些设计原则可以精简为覆盖率、表达、 单一化与缩减。
我不知道他说得对不对。我不知道一套高覆盖、表达力强、没有重复代码、尺寸足够缩减 的程序是否满足开放-闭合原则或单一权责原则。但我确定的是,学习优秀的设计和架构原则(例 如 SOLID 原则)极大地有助于创建既切分恰当又简单的设计方案。
本书主题不是那些原则。之前我已写过好几本谈这个话题的书 1,其他人也写过。建议你去读读那些著作,学习那些原则,磨炼你的技艺。
作者简介
罗伯特 C. 马丁(鲍勃大叔),软件开发行业领军人物,曾任C++ Report杂志主编、敏捷联盟首任主席、Object Mentor公司总裁,面向对象设计、模式、UML、敏捷方法学和极限编程领域的资深顾问。
1964年,年仅12岁的就已写下他的第一行代码。他自1970年起从事程序员职业。他与人合办了cleancoders.com网站,为软件开发者提供在线视频培训服务。他还创办了Uncle Bob咨询有限公司,为分布于世界各地的大公司提供软件咨询、培训和技能培养服务。同时,他也供职于芝加哥的软件咨询企业8th Light,任大匠(Master Craftsman)一职。
马丁先生在多本行业杂志上发表过数十篇文章,是各种国际性会议和行业活动讲坛上的常客。他也是cleancoders.com网站上广受赞誉的多个系列视频的创作者,也是Designing Object-Oriented C++ Applications Using the Booch Method 以及 Jolt 获奖图书 Agile Software Development, Principles, Palterns,and Practices,Clean Code 等畅销书作者。
译者简介
韩磊,IT产品与运营专家、IT图书专业译者,译有《代码整洁之道》《梦断代码》《C#编程风格》等多部计算机图书。曾担任CSDN副总经理、《程序员》总编辑、广东二十一世纪传媒股份有限公司新媒体事业部总经理等职,现任AR初创企业亮风台广州公司总经理。
中外匠师如此评价
感谢鲍勃大叔,也感谢本书的译者韩磊,感谢你们给中国的软件工程师带来这么好的一本书!
——章淼 BFE开源项目发起人、《代码的艺术》作者
向每一个工程师、每一个技术管理者郑重推荐《匠艺整洁之道》,希望你能有收获,也和每一个致力于提升研发效率与质量的技术人,一起共勉!
——沈剑 公众号“架构师之路”作者
鲍勃大叔给我们带来了软件开发领域几十年的匠艺追求,这份净心,对于尚处于青春期的技术行业,是每一位从业者必要的修炼。只有不停磨炼匠艺,纠正“35岁转管理”这样的行业浮躁心态,从而走向真正的工匠精神之路。
——肖然 Thoughtworks全球数字化转型专家、中国敏捷教练企业联盟秘书长
这本新书一如既往地精彩,它通俗易懂又发人深省,如果你是一位对于写出好的程序有更高要求的程序员:不仅仅当成一个朝九晚五的工作,而是一门手艺,甚至一门艺术,你会喜欢这本书的。
——黄东旭 PingCAP联合创始人兼CTO
我们这一代工程师是幸福的,因为有鲍勃大叔这样的大师一直引领着我们,如果你现在正在匠师之路上,那就赶紧打开《匠艺整洁之道》吧!
——孙玄 奈学科技创始人兼CEO、58集团前技术委员会主席
如之前的Clean系列图书一样,当我遇到困惑的时候,也会再翻出来寻找一些前人的启发。如果你跟我一样,打算在软件行业奋斗一生,那么这样的书,推荐你也拥有一本。
——翟永超 公众号“程序员DD”主理人、《Spring Cloud微服务实战》作者
它是一本类似于24条军规的书,重申现代世界实际构建者—也就是我们,我们这些工程师应该遵守的职业纪律,它帮助我们面对这份职业的责任,同时帮助我们提高作为工程师或者管理者的上限。
读读此书吧,软件工程已经不仅仅是编码就足够了,而它将会帮到你。
——彭哲夫(CMGS) Garena高级软件工程师
开发者与其追逐技术热点,不如修炼内功、提升技艺水平。而决定技艺水平下限的正是纪律、标准、原则和职业操守这些软实力。鲍勃大叔的新书《匠艺整洁之道》是这样一本好书,帮助开发者提高能力基线和专业精神,产出健壮、高容错和高效率的软件,更好地服务社会,为社会创造更多价值。
——丁宇 阿里云云原生应用平台总经理
我们日常对着需求文档来完成项目,也许并不困难,但真正难的是软件设计、代码细节,以及写出充满工程理念、可靠、健壮的应用。工作10余年的我,现在仍然会对软件工程感兴趣,我坚信它是提升整体工业水平的基础。让我们再次畅快感受这本书吧!
——毛剑 Bilibili基础架构负责人
写代码是件容易的事情,但是写出好代码却是件非常难的事情,它需要编写者具备大量的实践经验,以及得到良好的指导。鲍勃大叔把自己几十年的经验“抽象”为程序员要学会的编程纪律、标准和职业操守,指导程序员成为真正的“匠人”—写出优秀的代码、创建出色的系统,更重要的是,为自己的工作感到骄傲和自豪!
——刘欣 IBM前架构师、公众号“码农翻身”作者
这本书深入浅出剖析测试驱动开发(TDD)、敏捷技术应用实践、协同编程、架构至简设计等技术整洁方法论,让读者能真正掌握架构整洁设计的哲学本质,从而在面向不同业务场景时,都能够给出优雅的架构整洁解决方案,使得企业真正降本增效。本书是架构整洁设计实践类好书,特推荐之。
——孙玄 奈学科技创始人兼CEO、58集团前技术委员会主席
你看过《代码整洁之道》吗?它的作者是鲍勃大叔,这本《匠艺整洁之道》是他的封山之作,我看完之后被深深地吸引。特别力荐给那些追求代码优美、高质量和高效率的程序员朋友们。
——程军 饿了么前技术总监、公众号“军哥手记”主理人