重构的秘诀:消除重复,清晰意图
11年前有幸阅读了《重构——改善既有代码的设计》第一版,当时是一口气读完的,书中的内容直接惊艳到我了。
今年读了该书的第二版,再次震撼到我了,并且这次的示例代码用的JavaScript,让我更有亲切感。
全书共有12章,前面5章是在讲解重构的原则、测试、代码的坏味道等内容,后面7章是各种经验和实践,全书的精髓所在。
在这些年的编程生涯中,或多或少地使用着一些重构手法,得益于这些手法,让我在编程时能更加的游刃有余。
通篇读下来后,个人总结了重构的秘诀,8个字:消除重复,清晰意图。
书中会从各种角度,全方位的来阐述作者的重构心得,我会将平时常用的几个手法摘录到本文中,可快速应用于实际项目中。
一、第一组重构
1)提炼函数
有的观点从复用的角度考虑,认为只要被用过不止一次的代码,就应该单独放进一个函数;只用过一次的代码则保持内联(inline)的状态。
但作者认为最合理的观点 是“将意图与实现分开”:如果你需要花时间浏览一段代码才能弄清它到底在干什么,那么就应该将其提炼到一个函数中,并根据它所做的事为其命名。
function printOwing(invoice) { printBanner(); let outstanding = calculateOutstanding(); //print details console.log(`name: ${invoice.customer}`); console.log(`amount: ${outstanding}`); } // 重构后 function printOwing(invoice) { printBanner(); let outstanding = calculateOutstanding(); printDetails(outstanding); function printDetails(outstanding) { console.log(`name: ${invoice.customer}`); console.log(`amount: ${outstanding}`); } }
2)提炼变量
表达式有可能非常复杂而难以阅读。这种情况下,局部变量可以帮助我们将 表达式分解为比较容易管理的形式。
在面对一块复杂逻辑时,局部变量使我能给其中的一部分命名,这样我就能更好地理解这部分逻辑是要干什么。
return order.quantity * order.itemPrice Math.max(0, order.quantity - 500) * order.itemPrice * 0.05 + Math.min(order.quantity * order.itemPrice * 0.1, 100); //重构后 const basePrice = order.quantity * order.itemPrice; const quantityDiscount = Math.max(0, order.quantity - 500) * order.itemPrice * 0.05; const shipping = Math.min(basePrice * 0.1, 100); return basePrice - quantityDiscount + shipping;
3)变量改名
好的命名是整洁编程的核心。变量可以很好地解释一段程序在干什么——如 果变量名起得好的话。
只在一行的lambda表达式中使用的变量,跟踪起来很容易——像这样的变量,作者经常只用一个字母命名,因为变量的用途在这个上下文中很清晰。
对于作用域超出一次函数调用的字段,则需要更用心命名。
let a = height * width; //重构后 let area = height * width;
4)引入参数对象
一组数据项总是结伴同行,出没于一个又一个函数。这样一组数据就是所谓的数据泥团,作者喜欢代之以一个数据结构。
将数据组织成结构是一件有价值的事,因为这让数据项之间的关系变得明晰。
使用新的数据结构,参数的参数列表也能缩短。并且经过重构之后,所有使用该数据结构的函数都会通过同样的名字来访问其中的元素,从而提升代码的一致性。
function amountInvoiced(startDate, endDate) {...} function amountReceived(startDate, endDate) {...} function amountOverdue(startDate, endDate) {...} //重构后 function amountInvoiced(aDateRange) {...} function amountReceived(aDateRange) {...} function amountOverdue(aDateRange) {...}
5)拆分阶段
每当看见一段代码在同时处理两件不同的事,作者就想把它拆分成各自独立的模块。
因为这样到了需要修改的时候,就可以单独处理每个主题,而不必同时在脑子里考虑两个不同的主题。
最简洁的拆分方法之一,就是把一大段行为分成顺序执行的两个阶段。举个简单的例子:
编译器的任务可拆分成一系列阶段:首先对文本做词法分析,然后把token解析成语法树,再对语法树做几步转换(如优化),最后生成目标码。
每一步都有边界明确的范围,让人可以聚焦思考其中一步,而不用理解其他步骤的细节。
const orderData = orderString.split(/\s+/); const productPrice = priceList[orderData[0].split("-")[1]]; const orderPrice = parseInt(orderData[1]) * productPrice; //重构后 const orderRecord = parseOrder(order); const orderPrice = price(orderRecord, priceList); function parseOrder(aString) { const values = aString.split(/\s+/); return { productID: values[0].split("-")[1], quantity: parseInt(values[1]) }; } function price(order, priceList) { return order.quantity * priceList[order.productID]; }
6)替换算法
如果发现做一件事可以有更清晰的方式,那么就会用比较清晰的方式取代复杂的方式。
“重构”可以把一些复杂的东西分解为较简单的小块,但有时你就必须壮士断腕,删掉整个算法,代之以较简单的算法。
可以先把原先的算法替换为一个较易修改的算法,这样后续的修改会轻松许多。
使用这项重构手法之前,得确定自己已经尽可能分解了原先的函数。
替换一个巨大且复杂的算法是非常困难的,只有先将它分解为较简单的小型函数,才能很有把握地进行算法替换工作。
function foundPerson(people) { for (let i = 0; i < people.length; i++) { if (people[i] === "Don") { return "Don"; } if (people[i] === "John") { return "John"; } if (people[i] === "Kent") { return "Kent"; } } return ""; } //重构后 function foundPerson(people) { const candidates = ["Don", "John", "Kent"]; return people.find((p) => candidates.includes(p)) || ""; }
7)移除标记参数
“标记参数”是这样的一种参数:调用者用它来指示被调函数应该执行哪一部分逻辑。
标记参数会隐藏函数调用中存在的差异性。使用这样的函数,还得弄清标记参数有哪些可用的值。
布尔型的标记尤其糟糕,因为它们不能清晰地传达其含义。在调用一个函数时,很难弄清true到底是什么意思。
如果明确用一个函数来完成一项单独的任务,其含义会清晰得多。并非所有类似这样的参数都是标记参数。
如果调用者传入的是程序中流动的数据,这样的参数不算标记参数;只有调用者直接传入字面量值,这才是标记参数。
另外,在函数实现内部,如果参数值只是作为数据传给其他函数,这就不是标记参数;只有参数值影响了函数内部的控制流,这才是标记参数。
去掉标记参数后,代码分析工具能更容易地体现出“高级”和“普通”两种预订逻辑在 使用时的区别。
function setDimension(name, value) { if (name === "height") { this._height = value; return; } if (name === "width") { this._width = value; return; } } //重构后 function setHeight(value) { this._height = value; } function setWidth(value) { this._width = value; }
二、搬移特性
1)搬移字段
每当调用某个函数时,除了传入一个记录参数,还总是需要同时传入另一条记录的某个字段一起作为参数。
总是一同出现、一同作为函数参数传递的数据,最好是规整到同一条记录中,以体现它们之间的联系。
如果修改一条记录时, 总是需要同时改动另一条记录,那么说明很可能有字段放错了位置。
此外,如果更新一个字段时,需要同时在多个结构中做出修改,那也是一个征兆,表明该字段需要被搬移到一个集中的地点,这样每次只需修改一处地方。
class Customer { get plan() { return this._plan; } get discountRate() { return this._discountRate; } } //重构后 class Customer { get plan() { return this._plan; } get discountRate() { return this.plan.discountRate; } }
2)搬移语句到函数
要维护代码库的健康发展,需要遵守几条黄金守则,其中最重要的一条当属“消除重复”。
如果发现调用某个函数时,总有一些相同的代码也需要每次执行,那么可以考虑将此段代码合并到函数里头。
这样,日后对这段代码的修改只需改一处地方,还能对所有调用者同时生效。
如果某些语句与一个函数放在一起更像一个整体,并且更有助于理解,那就会毫不犹豫地将语句搬移到函数里去。
如果它们与函数不像一个整体,但仍应与函数一起执行,那可以用提炼函数将语句和函数一并提炼出去。
result.push(`<p>title: ${person.photo.title}</p>`); result.concat(photoData(person.photo)); function photoData(aPhoto) { return [ `<p>location: ${aPhoto.location}</p>`, `<p>date: ${aPhoto.date.toDateString()}</p>` ]; } //重构后 result.concat(photoData(person.photo)); function photoData(aPhoto) { return [ `<p>title: ${aPhoto.title}</p>`, `<p>location: ${aPhoto.location}</p>`, `<p>date: ${aPhoto.date.toDateString()}</p>` ]; }
3)搬移语句到调用者
作为程序员,我们的职责就是设计出结构一致、抽象合宜的程序,而程序抽象能力的源泉正是来自函数。
与其他抽象机制的设计一样,我们并非总能平衡好抽象的边界。
随着系统能力发生演进(通常只要是有用的系统,功能都会演 进),原先设定的抽象边界总会悄无声息地发生偏移。
对于函数来说,这样的边界偏移意味着曾经视为一个整体、一个单元的行为,如今可能已经分化出两个甚至是多个不同的关注点。
函数边界发生偏移的一个征兆是,以往在多个地方共用的行为,如今需要在某些调用点面前表现出不同的行为。于是,得把表现不同的行为从函数里挪出,并搬移到其调用处。
emitPhotoData(outStream, person.photo); function emitPhotoData(outStream, photo) { outStream.write(`<p>title: ${photo.title}</p>\n`); outStream.write(`<p>location: ${photo.location}</p>\n`); } //重构后 emitPhotoData(outStream, person.photo); outStream.write(`<p>location: ${person.photo.location}</p>\n`); function emitPhotoData(outStream, photo) { outStream.write(`<p>title: ${photo.title}</p>\n`); }
4)移动语句
让存在关联的东西一起出现,可以使代码更容易理解。
如果有几行代码取用了同一个数据结构,那么最好是让它们在一起出现,而不是夹杂在取用其他数据结构的代码中间。
最简单的情况下,只需使用移动语句就可以让它们聚集起来。
此外还有一种常见的“关联”,就是关于变量的声明和使用。有人喜欢在函数顶部一口气声明函数用到的所有变量,作者则喜欢在第一次需要使用变量的地 方再声明它。
通常来说,把相关代码搜集到一处,往往是另一项重构(通常是在提炼函数)开始之前的准备工作。
const pricingPlan = retrievePricingPlan(); const order = retreiveOrder(); let charge; const chargePerUnit = pricingPlan.unit; //重构后 const pricingPlan = retrievePricingPlan(); const chargePerUnit = pricingPlan.unit; const order = retreiveOrder(); let charge;
5)拆分循环
如果你在一次循环中做了两件不同的事,那么每当需要修改循环时,你都得同时理解这两件事情。
如果能够将循环拆分,让一个循环只做一件事情,那就能确保每次修改时你只需要理解要修改的那块代码的行为就可以了。
拆分循环还能让每个循环更容易使用。如果一个循环只计算一个值,那么它直接返回该值即可;但如果循环做了太多件事,那就只得返回结构型数据或者通过局部变量传值了。
如果重构之后该循环成了性能的瓶颈,届时再把拆开的循环合到一起也很容易。
let averageAge = 0; let totalSalary = 0; for (const p of people) { averageAge += p.age; totalSalary += p.salary; } averageAge = averageAge / people.length; //重构后 let totalSalary = 0; for (const p of people) { totalSalary += p.salary; } let averageAge = 0; for (const p of people) { averageAge += p.age; } averageAge = averageAge / people.length;
6)以管道取代循环
时代在发展,如今越来越多的编程语言都提供了更好的语言结构来处理迭代过程,这种结构就叫作集合管道。
集合管道是这样一种技术,它允许使用一组运算来描述集合的迭代过程,其中每种运算 接收的入参和返回值都是一个集合。
这类运算有很多种,最常见的则非map和 filter莫属。运算得到的集合可以供管道的后续流程使用。
作者发现一些逻辑如果采用集合管道来编写,代码的可读性会更强——只需从头到尾阅读一遍代码,就能弄清对象在管道中间的变换过程。
const names = []; for (const i of input) { if (i.job === "programmer") names.push(i.name); } //重构后 const names = input.filter((i) => i.job === "programmer").map((i) => i.name);
7)移除死代码
事实上,我们部署到生产环境甚至是用户设备上的代码,从来未因代码量太大而产生额外费用。
就算有几行用不上的代码,似乎也不会因此拖慢系统速度,或者占用过多的内存,大多数现代的编译器还会自动将无用的代码移除。
但当你尝试阅读代码、理解软件的运作原理时,无用代码确实会带来很多额外的思维负担。
它们周围没有任何警示或标记能告诉程序员,让他们能够放心忽略这段函数,因为已经没有任何地方使用它了。
当程序员花费了许多时间,尝试理解它的工作原理时,却发现无论怎么修改这段代码都无法得到期望的输出。
一旦代码不再被使用,我们就该立马删除它。有可能以后又会需要这段代码,可以从版本控制系统里再次将它翻找出来。
三、简化条件逻辑
1)分解条件表达式
程序之中,复杂的条件逻辑是最常导致复杂度上升的地点之一。
必须编写代码来检查不同的条件分支,根据不同的条件做不同的事,然后很快就会得到一个相当长的函数。
大型函数本身就会使代码的可读性下降,而条件逻辑则会使代码更难阅读。
在带有复杂条件逻辑的函数中,代码(包括检查条件分支的代码和真正实现功能的代码)会告诉我发生的事,但常常让我弄不清楚为什么会发生这样的事,这就说明代码的可读性的确大大降低了。
可以将它分解为多个独立的函数,根据每个小块代码的用途,为分解而得的新函数命名,并将原函数中对应的代码改为调用新函 数,从而更清楚地表达自己的意图。
对于条件逻辑,将每个分支条件分解成新函数还可以带来更多好处:可以突出条件逻辑,更清楚地表明每个分支的作用,并且突出每个分支的原因。
if (!aDate.isBefore(plan.summerStart) && !aDate.isAfter(plan.summerEnd)) charge = quantity * plan.summerRate; else charge = quantity * plan.regularRate + plan.regularServiceCharge; //重构后 if (summer()) charge = summerCharge(); else charge = regularCharge();
2)合并条件表达式
当检查条件各不相同,但最终行为却一致。如果发现这种情况,就应该使用“逻辑或”和“逻辑与”将它们合并为一个条件表达式。
之所以要合并条件代码,有两个重要原因。首先,合并后的条件代码会表述“实际上只有一次条件检查,只不过有多个并列条件需要检查而已”,从而使这一次检查的用意更清晰。
当然,合并前和合并后的代码有着相同的效果,但原先代码传达出的信息却是“这里有一些各自独立的条件测试,它们只是恰好同时发生”。
其次,这项重构往往可以为使用提炼函数做好准备。将检查条件提炼成一个独立的函数对于厘清代码意义非常有用,因为它把描述“做什么”的语句换成了“为什么这样做”。
if (anEmployee.seniority < 2) return 0; if (anEmployee.monthsDisabled > 12) return 0; if (anEmployee.isPartTime) return 0; //重构后 if (isNotEligibleForDisability()) return 0; function isNotEligibleForDisability() { return ( anEmployee.seniority < 2 || anEmployee.monthsDisabled > 12 || anEmployee.isPartTime ); }
3)以卫语句取代嵌套条件表达式
条件表达式通常有两种风格。第一种风格是:两个条件分支都属于正常行为。第二种风格则是:只有一个条件分支是正常行为,另一个分支则是异常的情况。
如果两条分支都是正常行为,就应该使用形如if…else…的条件表达式;如果某个条件极其罕见,就应该单独检查该条件,并在该条件为真时立刻从函数中返回。
这样的单独检查常常被称为“卫语句”。以卫语句取代嵌套条件表达式的精髓就是:给某一条分支以特别的重视。如果使用if-then-else结构,你对if分支和else分支的重视是同等的。
这样的代码结构传递给阅读者的消息就是:各个分支有同样的重要性。
卫语句就不同了,它告诉阅读者:“这种情况不是本函数的核心逻辑所关心的,如果它真发生了,请做一些必要的整理工作,然后退出。”
function getPayAmount() { let result; if (isDead) result = deadAmount(); else { if (isSeparated) result = separatedAmount(); else { if (isRetired) result = retiredAmount(); else result = normalPayAmount(); } } return result; } //重构后 function getPayAmount() { if (isDead) return deadAmount(); if (isSeparated) return separatedAmount(); if (isRetired) return retiredAmount(); return normalPayAmount(); }