大家好,我是阿滨。
前言
相信很多同学都听过《重构》这本书的大名,但是并没有真正把它捧在手里读过,或者是打开电脑阅读它的电子版。甚至绝大数同学会思考重构代码到底是否有必要,他们认为与其花功夫在优化代码上,不如抓紧时间完成一个需求好向老板交差。当然也有许多同学意识到自己的代码,大多时候是认为别人的代码丑陋不堪,终于下定决心开始尝试重构它,但是又不知如何入手。今天,我就跟着《重构》这本书,来和同学们一起探讨一下为何重构,如何发现代码中的坏味道,以及我们该如何去重构它们。
第一个重构
作者在第一章的时候就给出了一个示例,向我们好好地演示了一波如何去重构一段代码,但是我并不打算马上在开头给出来,这对于一些还不太熟悉重构的同学会很措手不及,我在这里先给出它的题目和需要重构的代码,同学们可以先尝试思考为何它需要重构,到底又如何去重构它。
设想有一个戏剧演出团,演员们经常要去各种场合表演戏剧。通常客户(customer)会指定几出剧目,而剧团则根据观众(audience)人数及剧目类型来向客户收费。该团目前出演两种戏剧:悲剧(tragedy)和喜剧(comedy)。给客户发出账单时,剧团还会根据到场观众的数量给出"观众量积分"(volumecredit)优惠,下次客户再请剧团表演时可以使用积分获得折扣------你可以把它看作一种提升客户忠诚度的方式。
该剧团将剧目的数据存储在一个简单的JSON文件中。
json
{
"hamlet":{"name":"Hamlet","type":"tragedy"},
"as-like":{"name":"AsYouLikeIt","type":"comedy"},
"othello":{"name":"Othello","type":"tragedy"}
}
他们开出的账单也存储在一个JSON文件里。
css
[ { { "customer":"BigCo", "performances":[ { "playID":"hamlet", "audience":55 }, { "playID":"as-like", "audience":35 }, { "playID":"othello", "audience":40 } ]
}
]
下面这个简单的函数用于打印账单详情。
javascript
function statement(invoice, plays) {
let totalAmount = 0;
let volumeCredits = 0;
let result = `Statement for ${invoice.customer}\n`;
const format = new Intl.NumberFormat("en-US",
{
style: "currency", currency: "USD",
minimumFractionDigits: 2
}).format;
for (let perf of invoice.performances) {
const play = plays[perf.playID];
let thisAmount = 0;
switch (play.type) {
case "tragedy":
thisAmount = 40000;
if (perf.audience > 30) {
thisAmount += 1000 * (perf.audience - 30);
}
break;
case "comedy":
thisAmount = 30000;
if (perf.audience > 20) {
thisAmount += 10000 + 500 * (perf.audience - 20);
}
thisAmount += 300 * perf.audience;
break;
default:
throw new Error(`unknown type: ${play.type}`);
}
// add volume credits
volumeCredits += Math.max(perf.audience - 30, 0);
// add extra credit for every ten comedy attendees
if ("comedy" === play.type) volumeCredits += Math.floor(perf.audience / 5);
// print line for this order
result += `${play.name}: ${format(thisAmount / 100)} (${perf.audience} seats)\n`;
totalAmount += thisAmount;
}
result += `Amount owed is ${format(totalAmount / 100)}\n`;
result += `You earned ${volumeCredits} credits\n`;
return result;
}
用上面的数据文件(invoices.json和plays.json)作为测试输入,运行这段代码,会得到如下输出:
swift
Statement for BigCo
Hamlet: $650.00 (55 seats)
As You Like It: $580.00 (35 seats)
Othello: $500.00 (40 seats)
Amount owed is $1,730.00
You earned 47 credits
在这个例子中,如果我们的用户希望对系统做几个修改,比如他们希望以HTML格式输出详单,又或者演员们尝试在表演类型上做出更多的突破,程序该如何做出修改。同学们可以带着这个疑惑自己动手尝试一下,如果觉得无从下手也没关系,相信你读完这篇文章,也会有能力在这段代码上大显身手。
重构的原则
何谓重构
作者给出了两个定义:
重构(名词):对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。
重构(动词):使用一系列重构手法,在不改变软件可观察行为的前提下,调整其结构。
重构的关键在于运用大量微小且保持软件行为的步骤,一步步达成大规模的修改。 每个单独的重构要么很小,要么由若干小步骤组合而成。因此,在重构的过程中,我的代码很少进入不可工作的状态,即便重构没有完成,我也可以在任何时刻停下来。
两顶帽子
KentBeck提出了"两顶帽子"的比喻。使用重构技术开发软件时,我把自己的时间分配给两种截然不同的行为:添加新功能和重构。添加新功能时,我不应该修改既有代码,只管添加新功能。
为何重构
- 重构改进软件的设计。 如果没有重构,程序的内部设计(或者叫架构)会逐渐腐败变质。
- 重构使软件更容易理解。 在重构上花一点点时间,就可以让代码更好地表达自己的意图。
- 重构帮助找到bug。 重构一段代码,意味着我可以深入理解代码的所作所为,这时想不把bug揪出来都难。
- 重构提高编程速度。 在需要添加新功能或者修复bug时,内部质量良好的软件让我可以很容易找到在哪里修改、如何修改。
何时重构
- 预备性重构。 让添加新功能更容易。
- 帮助理解的重构。 使代码更易懂。
- 捡垃圾式重构。 像捡垃圾一样马上重构它。
- 有计划的重构和见机行事的重构。 如果过去忽视了重构,那就专门花一些时间来优化代码库。
- 长期重构。 长期地去解决一个复杂的问题。
- 复审代码时重构。
- 怎么对经理说。 如果这位经理懂技术,能理解"设计耐久性假说",那么向他说明重构的意义应该不会很困难。否则,不要告诉经理!
- 何时不应该重构。 如果我看见一块凌乱的代码,但并不需要修改它,那么我就不需要重构它。另一种情况是,如果重写比重构还容易,就别重构了。
重构的挑战
- 延缓新功能开发。 我可能会每天花费很多时间来进行重构,不知情的人会认为这是在浪费时间。但是作为一个程序员,我清楚的知道重构是为了让我添加功能更快,修复bug更快。
- 代码所有权。 对于那些不属于我的代码,大部分情况下我没有权限写入他们的代码库,这会妨碍我重构这些代码。
- 分支
-
- 每个人都在一条分支上工作,进行相当大量的开发之后,才合并回主线分支。在隔离的分支上工作得越久,将完成的工作集成(integrate)回主线就会越困难。
- 为了减轻集成的痛苦,大多数人的办法是频繁地从主线合并(merge)或者变基(rebase)到分支。但如果有几个人同时在各自的特性分支上工作,这个办法并不能真正解决问题,因为我不仅要把主线的修改拉(pull)到我的分支上,而且要把我这里修改的结果推(push)回到主线上,两边都会发生修改。
- 持续集成(CI)。在使用CI时,每个团队成员每天至少向主线集成一次。
- 测试。 为了快速发现错误,我的代码应该有一套完备的测试套件,并且运行速度要快,否则没有人愿意频繁运行它。
- 遗留代码。遗留代码往往很复杂,测试又不足,而且最关键的是,是别人写的(瑟瑟发抖)。
重构、架构和YAGNI
有了重构技术,即便是已经在生产环境中运行了多年的软件,我们也有能力大幅度修改其架构。重构对架构最大的影响在于,通过重构,我们能得到一个设计良好的代码库,使其能够优雅地应对不断变化的需求。有了重构技术,我就可以采取不同的策略。与其猜测未来需要哪些灵活性、需要什么机制来提供灵活性,我更愿意只根据当前的需求来构造软件,同时把软件的设计质量做得很高。随着对用户需求的理解加深,我会对架构进行重构,使其能够应对新的需要。这种设计方法有很多名字:简单设计 、增量式设计 或者 YAGNI [mf-yagni]------"你不会需要它"(you arenʼt going to need it)的缩写。采用YAGNI并不表示完全不用预先考虑架构。总有一些时候,如果缺少预先的思考,重构会难以开展。
重构与软件开发过程
重构的第一块基石是自测试代码 。自测试的代码也是持续集成的关键环节,所以这三大实践------自测试代码 、持续集成 、重构 ------彼此之间有着很强的协同效应。有这三大实践在手,我们就能运用前一节介绍的 YAGNI 设计方法。重构和 YAGNI 交相呼应、彼此增效,重构(及其前置实践)是 YAGNI 的基础,YAGNI 又让重构更易于开展 。有这三大核心实践打下的基础,才谈得上运用敏捷思想的其他部分。 持续交付确保软件始终处于可发布的状态,很多互联网团队能做到一天多次发布,靠的正是持续交付的威力。即便我们不需要如此频繁的发布,持续集成也能帮我们降低风险,并使我们做到根据业务需要随时安排发布,而不受技术的局限。
重构与性能
虽然重构可能使软件运行更慢,但它也使软件的性能优化更容易。 除了对性能有严格要求的实时系统,其他任何情况下"编写快速软件"的秘密就是:先写出可调优的软件,然后调优它以求获得足够的速度。
重构起源何处
你肯定找不出重构起源何处,因为优秀程序员肯定至少会花一些时间来清理自己的代码。
代码的坏味道
神秘命名
一些糟糕的程序员会给一个变量起一个糟糕的名字,比如一个变量的含义是总和,他却将它命名为a,这实在是不堪入目。更糟糕的是,很多人经常不愿意给程序元素改名,觉得不值得费这个劲,但好的名字能节省未来用在猜谜上的大把时间。改名不仅仅是修改名字而已。如果你想不出一个好名字,说明背后很可能潜藏着更深的设计问题。为一个恼人的名字所付出的纠结,常常能推动我们对代码进行精简。
重复代码
一旦有重复代码存在,阅读这些重复的代码时你就必须加倍仔细,留意其间细微的差异。如果要修改重复代码,你必须找出所有的副本来修改。
过长函数
过长的函数往往承担了很多不属于它的职责,不仅会让你的代码混乱不堪,也会让阅读它的人头晕眼花。
过长参数列表
过长的参数列表会导致调用方和函数紧紧地耦合在一起,他的本身也让人疑惑。
全局数据
全局数据的问题在于,从代码库的任何一个角落都可以修改它,而且没有任何机制可以探测出到底哪段代码做出了修改。全局数据最显而易见的形式就是全局变量,但类变量和单例也有这样的问题。
可变数据
对数据的修改经常导致出乎意料的结果和难以发现的 bug。我在一处更新数据,却没有意识到软件中的另一处期望着完全不同的数据,于是一个功能失效了。
发散式变化
如果某个模块经常因为不同的原因在不同的方向上发生变化,发散式变化就出现了。每当要对某个上下文做修改时,我们希望只需要理解这个上下文,而不必操心另一个。
霰弹式修改
霰弹式修改类似于发散式变化,但又恰恰相反。如果每遇到某种变化,你都必须在许多不同的类内做出许多小修改,如果需要修改的代码散布四处,你不但很难找到它们,也很容易错过某个重要的修改。
依恋情结
有时你会发现,一个函数跟另一个模块中的函数或者数据交流格外频繁,远胜于在自己所处模块内部的交流,这就是依恋情结的典型情况。
数据泥团
你常常可以在很多地方看到相同的三四项数据:两个类中相同的字段、许多函数签名中相同的参数。这些总是绑在一起出现的数据真应该拥有属于它们自己的对象。
基本类型偏执
很多程序员不愿意创建对自己的问题域有用的基本类型,如钱、坐标、范围等。于是,我们看到了把钱当作普通数字来计算的情况。
重复的switch
重复的switch的问题在于:每当你想增加一个选择分支时,必须找到所有的switch,并逐一更新。
循环语句
可以使用以管道取代循环来让这些老古董退休。我们发现,管道操作(如 filter和map)可以帮助我们更快地看清被处理的元素以及处理它们的动作。
冗赘的元素
可能有这样一个函数,它的名字就跟实现代码看起来一模一样;也可能有这样一个类,根本就是一个简单的函数。这可能是因为,起初在编写这个函数时,程序员也许期望它将来有一天会变大、变复杂,但那一天从未到来;也可能是因为,这个类原本是有用的,但随着重构的进行越变越小,最后只剩了一个函数。不论上述哪一种原因,请让这样的程序元素庄严赴义吧。
夸夸其谈通用性
当你企图以各式各样的钩子和特殊情况来处理一些非必要的事情时,大可不必,这么做的结果往往造成系统更难理解和维护。
临时字段
有时你会看到这样的类:其内部某个字段仅为某种特定情况而设。这样的代码让人不易理解,因为你通常认为对象在所有时候都需要它的所有字段。在字段未被使用的情况下猜测当初设置它的目的,会让你发疯。
过长的消息链
这意味客户端代码将与查找过程中的导航结构紧密耦合。一旦对象间的关系发生任何变化,客户端就不得不做出相应修改。
中间人
你也许会看到某个类的接口有一半的函数都委托给其他类,这样就是过度运用委托。
内幕交易
软件开发者喜欢在模块之间建起高墙,极其反感在模块之间大量交换数据,因为这会增加模块间的耦合。在实际情况里,一定的数据交换不可避免,但我们必须尽量减少这种情况,并把这种交换都放到明面上来。
过大的类
如果想利用单个类做太多事情,其内往往就会出现太多字段。一旦如此,重复代码也就接踵而至了。
异曲同工的类
使用类的好处之一就在于可以替换:今天用这个类,未来可以换成用另一个类。但只有当两个类的接口一致时,才能做这种替换。
纯数据类
所谓纯数据类是指:它们拥有一些字段,以及用于访问(读写)这些字段的函数,除此之外一无长物。
被拒绝的遗赠
子类应该继承超类的函数和数据。但如果它们不想或不需要继承,又该怎么办呢?
注释
常常会有这样的情况:你看到一段代码有着长长的注释,然后发现,这些注释之所以存在乃是因为代码很糟糕。
构筑测试体系
大多数程序员编写代码的时间仅占所有时间中很少的一部分。有些时间用来决定下一步干什么,有些时间花在设计上,但是,花费在调试上的时间是最多的。频繁地运行自测试可以让我们拥有强大的bug侦测能力。KentBeck将这种先写测试的习惯提炼成一门技艺,叫测试驱动开发(Test-Driven Development,TDD)[mf- tdd]。测试驱动开发的编程方式依赖于下面这个短循环:先 编写一个(失败的)测试,编写代码使测试通过,然后进行重构以保证代码整洁。这个"测试、编码、重构"的循环应该在每个小时内都完成很多次。这种良好的节奏感可使编程工作以更加高效、有条不紊的方式开展。我就不在这里再做更深入的介绍,但我自己确实经常使用,也非常建议你试一试。
尾声
看到这里,相信同学们对于重构是什么,什么需要重构有了一个清晰的认识,在下篇文章中我会结合《重构》来探讨一下重构的手法。希望大家有所收获。