重构指北——《重构,改善既有代码设计》精读
本文总结自笔者的开发经验以及 Martin Fowler 的《重构,改善既有代码设计》读书体会,希望能帮助更多的开发者了解重构,重构并不是想象中的重活,它也可以很简单。Commit a feature,review and refactor。
1. 什么是重构
这里先给重构下一个定义:改善既有代码的设计。
具体来说就是在不改变代码功能行为的情况下,对其内部结构的一种调整。需要注意的是,重构不是代码优化,重构注重的是提高代码的可理解性与可扩展性,对性能的影响可好可坏。而性能优化则让程序运行的更快,当然,最终的代码可能更难理解和维护。
2. 为什么要重构
2.1. 改善程序的内部设计
如果没有重构,在软件不停的版本迭代中,代码的设计只会越来越腐败,导致软件开发寸步难行。
这里的原因主要有两点:
人们只为了短期目的而修改代码时,往往没有完全理解整体的架构设计(在大项目中常有这种情况,比如在不同的地方,使用完全相同的语句做着同样的事情),代码就会失去自己的结构,代码结构的流失具有累积效应,越难看出代码所代表的设计意图,就越难保护其设计。 我们几乎不可能预先做出完美的设计,以面对后续未知的功能开发,只有在实践中才能找到真理。
所以想要体面又快速的开发功能,重构必不可少。
2.2. 使得代码更容易理解
在开发中,我们需要先理解代码在做什么,才能着手修改,很多时候自己写的代码都会忘记其实现,更不论别人的代码。可能在这段代码中有一段糟糕的条件判断逻辑,又或者其变量命名实在糟糕又确实注释,需要花上好一段时间才能明白其真正用意。
合理的重构能让代码“自解释”,以方便理解,无论对于协同开发,还是维护先前自己实现的功能,对代码的开发有着立竿见影的效果。
2.3. 提高开发的速度 && 方便定位错误
提高开发的速度可能有点“反直觉”,因为重构在很多时候看来是额外的工作量,并没有新的功能和特性产出,但是减少代码的书写量(复用模块),方便定位错误(代码结构优良),这些能让我们在开发的时候节省大量的时间,在后续的开发中“轻装上阵”。
3. 重构的原则
3.1. 保持当下的编程状态
Kent Beck 提出了“两顶帽子”的比喻,在开发软件时,把自己的时间分配给两种截然不同的行为:添加新功能和重构,添加新功能的时候,不应该修改既有的代码,只管添加新功能,并让程序正确运行;在重构时就不能添加新功能,只管调整代码结构,只有在绝对必要时才能修改相关代码。
在开发过程中,我们可能经常变换“帽子”,在新增功能的时候会意识到,如果把程序结构改一下,功能的添加会容易很多,或者实现会更加优雅,于是一会换一顶“帽子”,一边重构,一边新增新功能。这很容易让自己产生混乱,对自己的代码难以理解。
任何时候我们都要清楚自己戴的是哪一顶“帽子”,并专注自己的编程状态,这让我们的目标清晰且过程可控,能对自己编码的进度有掌握。
3.2. 可控制的重构
重构的过程并非一蹴而就,如果因为重构影响了自己对时间的掌控,对函数功能的掌控,那么你就应该及时停下,思考你的行为是否值得。我们必须保证程序的可用性与时间的可控性,并且要保证我们的步伐要小,确保每一步都有 git 管理和代码测试,否则你会陷入程序不可用的中间态,更可怕的是你忘记了之前代码的样子!
在本文后续章节何时开始重构中会有更多这方面的介绍,这里先跳过不谈。
4. 识别代码的臭味道
重构世界的规则我们已经了解,下面有一份重构指南北,是时候去回顾代码里的片段,识别它们身上的臭味并将其消灭!
当然,如果觉得其中的内容过长,可跳过不看,也可匆匆略过,日后回顾也是不错的选择。
4.1. 神秘命名
我承认,在侦探小说透过神秘的文字去猜测故事情节是一种很棒的体验,但在代码中,这往往让程序员困扰!需要花费大量时间去探究一个变量的作用和一个函数的功能,甚者需要在该代码片段中加入大量注释。
这里并不是批评注释这种行为,而是一个优秀的代码片段和编码命名,往往能让代码自解释,减少一些不必要的注释,阅读代码如同阅读文字一样流畅。
由此可见,变量命名实在是任何重构时都要第一步更正的地方,但也很遗憾的是,命名是编程中最难的几件事之一。
需要在简洁性和命名长度中平衡。 需要统一变量命名的风格,特别是一个整个团队!因为变量命名往往不在代码风格检测之内! 需要变量的名字既能做到彼此关联,又对其信息的识别互不干扰,想象一下,在一个代码片段中在存在着 cgi
和cgiList
等变量,你可以直接从中读出之间的关联,若是cgi
和list
呢,它们之间的联系就丢失了,又或者同时出现了people
和human
两个变量,这是不是让你产生了疑惑?需要良好的英语水平。
变量命名并没有确切细致的教程,也很难强制统一,一般符合以下三点即可。
有意义的 相关联的 不复用的
实践是检验质量的唯一标准,如果你的变量能够让其他同学见名知意,就说明你是正确的!
4.2. 重复代码
提炼重复代码无疑是重构中最经典的手法,很多时候我们会在不同的地方写下相似的代码,又或者拷贝一份副本至当前上下文中,它们之间的差异寥寥无几。
这时会出现一个很棘手的问题,当需要去修改其中的功能时,你必须找出所有的副本一一修改,这让人在阅读和修改代码时都很容易出现纰漏。所以我们要拒绝重复造轮子,尽量实现高可复用性的代码。
我们可以将其抽离成一个公共函数,并以其功能作为命名。
4.3. 过长函数
函数越长,就越难以理解,与之带来的还有高耦合性,不利于拆解重组。
目前普遍认为代码的行数不要超出一个屏幕的范围,因为这样会造成上下滚动,会增大出错的概率。根据腾讯代码规范,一个函数的代码行数不要超出 80 行。
直接看下面这两份代码,它们实现的是同样的功能,不用理解它们的含义(也没有任何含义),仅仅简单对比视觉效果,感觉如何?
// 重构前
function changeList(list) {
console.log('some operation of list')
for (let i=0; i<list.length; i++) {
// do sth
}
console.log('conditional judgment')
let result
if (list.length < 4) {
result = list.pop()
} else {
result = list.shift()
}
const today = new Date(Date.now())
const dueDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 30);
result.dueDate = dueDate
return result
}
// 重构后
function changeList(list) {
console.log('some operation of list')
operationOfList(list)
console.log('conditional judgment')
const result = judgment(list)
result.dueDate = getRecordTime()
return result
}
function operationOfList(list) {
for (let i=0; i<list.length; i++) {
// do sth
}
return list
}
function judgment(list) {
let result
if (list.length < 4) {
result = list.pop()
} else {
result = list.shift()
}
return result
}
function getRecordTime() {
const today = new Date(Date.now())
const dueDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 30);
return dueDate
}
事实证明,拆分函数有利于更好更快地理解代码,以及降低耦合度,更方便地重新“组装”新函数。当然你也可能此时会觉得很麻烦,属于多此一举,但是重构的目标就是要保证代码的可读性。如果有一天你想要修改或增加该函数的功能,看到重构后的代码你会感谢自己的。
4.4. 数据泥团 && 过长参数
数据泥团(魔法数字),顾名思义就是一帮数据无规则的结合在一起,这让人对其难以把控。
如果说有多个参数互相搭配,又或者说某些数据总是成群结队出现,那我们就该把这团泥塑造成一个具体的形象,将其封装成一个数据对象。
function addUser(name, gender, age) {
// some other codes
...
// officeAreaCode 与 officeNumber 成对出现,如果缺少 officeNumber,那么 officeAreaCode 也没有意义,这里应该组合起来
clumps.officeAreaCode = '+86'
clumps.officeNumber = 13688888888;
return person
}
// 重构后
class TelephoneNumber(officeAreaCode, officeNumber) {
constructor() {
this.officeAreaCode = officeAreaCode
this.officeNumber = officeNumber
}
}
// 参数融合
function addUser(person) {
// some other codes
...
// 封装数据
person.telephone = new TelephoneNumber('+86', '13688888888')
}
4.5. 全局数据
很多时候我们都不可避免地使用全局数据,哪怕只有一个变量,全局数据对我们的管理提出了更高的要求。因为哪怕一个小小的更改,都可能引起很多地方出现问题,更可怕的是在无意间触发了这种更改。
全局数据也阻碍了程序的可预测性,由于每个函数都能访问这些变量,因此越来越难弄清那个函数实际读写这些变量,要理解一个程序的工作方式,几乎必须考虑修改全局状态的每个函数,使得调试变得困难。
如果不依靠全局变量,则可以根据不同函数之间传递的状态,这样以来,就能更好的了解每个功能的作用,因为你无需考虑全局变量。
let globalData = 1
// bad
function foo() {
globalData = 2
}
// bad
function fuu() {
globalData = {
a: 1
}
}
现在,我们要对全局数据进行一些封装,控制对其的访问。
// 使用常量 good
const constantData = 1
// 封装变量操作 good
let globalData = 1
function getGlobalData() {
return globalData
}
function setGlobalData(newGlobalData){
if (!isValid(newGlobalData)) {
throw Error('Illegal input!!!')
return
}
globalData = newGlobalData
}
// 暴露方法
export {
getGlobalData,
setGlobalData
}
现在,全局变量不会轻易的被“误触”,也能很快定义其修改的位置和防止错误的修改。
4.6. 发散式变化
当某个函数会因为不同原因在不同方向上发生变化时,发散式变化就诞生了。这听起来有点迷糊,那么就用代码来解释吧。
function getPrice(order) {
// 获取基础价格
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
}
const orderPrice = getPrice(order);
这个函数用于计算商品的价格,它的计算包含了基础价格 + 数量折扣 + 运费,如果基础价格的计算规则改变,我们需要修改这个函数;如果折扣规则发生改变,我们需要修改这个函数;如果运费计算规则改变了,我们还是要修改这个函数。
这种修改容易造成混乱,我们当然也希望程序一旦需要修改,我们就够跳到系统的某一点,所以是时候抽离它们了。
// 计算基础价格
function calBasePrice(order) {
return order.quantity * order.itemPrice
}
// 计算折扣
function calDiscount(order) {
return Math.max(0, order.quantity - 500) * order.itemPrice * 0.05
}
// 计算运费
function calShipping(basePrice) {
return Math.min(basePrice * 0.1, 100)
}
// 计算商品价格
function getPrice(order) {
return calBasePrice(order) - calDiscount(order) + calShipping(calBasePrice(order))
}
const orderPrice = getPrice(order)
虽然该函数行数不多,当其重构的过程与先前的过长函数一致,但是将各个功能抽离处理,有利于更清晰的定位问题与修改。所以过长函数拥有多重臭味道!需要及时消灭。
4.7. 霰弹式修改
霰弹式修改与发散式变化听起来差异不大,实则它们是阴阳两面。霰弹式修改与重复代码有点像,当我们需要做出一点小修改时,却要去四处一个个的修正,你不仅很难找到它们,也很容易错过某个重要的修改,直至错误发生!
// File Reading.js
const reading = {customer: "ivan", quantity: 10, month: 5, year: 2017}
function acquireReading() { return reading }
function baseRate(month, year) {
/* */
}
// File 1
const aReading = acquireReading()
const baseCharge = baseRate(aReading.month, aReading.year) * aReading.quantity
// File 2
const aReading = acquireReading()
const base = (baseRate(aReading.month, aReading.year) * aReading.quantity)
const taxableCharge = Math.max(0, base - taxThreshold(aReading.year))
function taxThreshold(year) { /* */ }
// File 3
const aReading = acquireReading()
const basicChargeAmount = calculateBaseCharge(aReading)
function calculateBaseCharge(aReading) {
return baseRate(aReading.month, aReading.year) * aReading.quantity
}
在上面的代码中,如果 reading
的逻辑发生了改变,我们需要跨越好几个文件去调整它,这很容易造成遗漏的发生。
由于每个地方都对 reading
进行了操作,那么我们可以将其封装起来,统一在一个文件中进行管理。
// File Reading.js
class Reading {
constructor(data) {
this.customer = data.customer
this.quantity = data.quantity
this.month = data.month
this.year = data.year
}
get baseRate() {
/* ... */
}
get baseCharge() {
return baseRate(this.month, this.year) * this.quantity
}
get taxableCharge() {
return Math.max(0, base - taxThreshold())
}
get taxThreshold() {
/* ... */
}
}
const reading = new Reading({ customer: 'Evan You', quantity: 10, month: 8, year: 2021 })
所有的相关逻辑在一起,不仅能提供一个共用的环境,也可以简化调用逻辑,更加清晰。
4.8. for 循环语句
很惊讶,循环一直是程序中的核心要素,在这里重构的世界里居然变成了臭味道。这里并不是要将循环取缔,但仅仅使用普通的 for 循环在当下有些过时,现在我们有很好的替代品。在 JS 的世界里拥有着管道操作(filter,map 等)它们可以帮助我们更好的处理元素以及帮助我们看清处理的动作。
下面我们将会从人群中挑选出所有的程序员并记录他们的名字,哪种做法更赏心悦目呢?
// for
const programmerNames = []
for (const item of people) {
if (item.job === 'programmer') {
programmerNames.push(item.name)
}
}
// pipeline
const programmerNames = people
.filter(item => item.job === 'programmer')
.map(item => item.name)
当然,这个时候你可能会提出它们之间性能的差别,不要忘了重构的意义是为了代码更清晰,性能在这里并不是优先要考虑的事情。
不过这里很也很遗憾的告诉你一个点,仅有少数的管道操作符支持逆序操作(reduce,reduceRight),更多时候必须在之前使用 reverse 来反转数组。所以是否要取缔 for 循环,取决于你自己,也取决于实际场景。
4.9. 复杂的条件逻辑 && 合并条件表达式
复杂的条件逻辑是导致复杂度上升的地点之一,代码会告诉我们会发生什么事,可我们常常弄不清为什么会发生这样的事,这就证明代码的可读性大大降低了。是时候将它们封装成一个带有说明的函数了,见文知意,一目了然。
// bad
if (!date.isBefore(plan.summberStart) && !date.isAfter(plan.summberEnd)) {
charge = quantity * plan.summerRate
} else {
charge = quantity * plan.regularRate + plan.regularServiceCharge
}
// good
if (isSummer()) {
charge = quantity * plan.summerRate
} else {
charge = quantity * plan.regularRate + plan.regularServiceCharge
}
// perfect
isSummer() ? summerCharge() : regularCharge()
如果一串条件检查,检查条件各不相同,最终行为却一致,那么我们就应该使用逻辑或和逻辑与将他们合并成为一个条件表达式。然后再做上面代码的逻辑,封装!
if (man.age < 18) return 0
if (man.hasHeartDisease) return 0
if (!isFull) return 0
// step 1
if (man.age < 18 && man.hasHeartDisease && !isFull) return 0
// step 2
if (isIlegalEntry(man) && !isFull) return 0
4.10. 查询函数与修改函数耦合
如果某个函数只是提供一个值,没有任何副作用,这是一个很有价值的东西,我可以任意调用这个函数没有后顾之忧,也可以随意的搬迁该函数。总而言之,需要操心的事情少多了。
明确的分离“有副作用”和“无副作用”两种函数是一个很好的想法,查询函数和修改函数搭配在平常的开发中也经常出现,是时候将它们分离了!
// 给 2 鹅岁以下的五星员工发邮件鼓励
function getTotalAdnSendEmail() {
const emailList = programmerList
.filter(item => item.occupationalAge <= 2 && item.stars === 5)
.map(item => item.email)
return sendEmail(emailList)
}
// 分离查询函数,这里可以通过传递参数进一步控制查询的语句
function search() {
return programmerList
.filter(item => item.occupationalAge <= 2 && item.stars === 5)
.map(item => item.email)
}
function send() {
return sendEmail(search())
}
这样可以更好的控制查询行为以及复用函数,我们需要在一个函数内操心的事情又少了一些。
4.11. 以卫语句(Guard Clauses)取代嵌套条件表达式
直接上代码:
function getPayAmount() {
let result
if (isDead) {
// do sth and assign to result
} else {
if (isSeparated) {
// do sth and assign to result
} else {
if (isRetired) {
// do sth and assign to result
} else {
// do sth and assign to result
}
}
}
return result
}
在阅读该函数时,是否庆幸在 if else 之间的并非代码而是一段注释,如果是一段代码,则让人目眩眼花。那下面的代码呢?
function getPayAmount() {
if (isDead) return deatAmount()
if (isSeparated) return serparateAmount()
if (isRetired) return retiredAmount()
return normalPayAmount()
}
卫语句的精髓就是给予某条分支特别的重视,它告诉阅读者,这种情况并不是本函数的所关心的核心逻辑,如果它真的发生了,会做一些必要的工作然后提前退出。
我相信每个程序员都会听过“每个函数只能有一个入口和一个出口”这个观念,但“单一出口”原则在这里似乎不起作用,在重构的世界中,保证代码清晰才是最关键的。如果“单一出口”能让代码更易读,那么就使用它吧,否则就不必这么做。
5. 何时开始重构
5.1. 添加新功能之前
重构的最佳时机是在添加新功能之前。
在动手添加新功能之前,看看现有的代码库,此时经常会发现,如果对代码结构做一点微调,自己的工作会轻松很多。比如有个函数提供了需要的大部分功能,但有几个字面量的值与自己的需求不同。如果不做重构,需要复制整个函数再进行微调,这导致重复代码的产生,这是代码臭味道的开始。所以需要戴上重构的“帽子”,做完这件事后,再轻松的开发你的功能。
但这也是在理想情况下的设想,事实上任务的安排总有时间限制,多出一段的重构的耗时可能会让你对时间的安排失控,导致延期,所以对于工作中的场景,并不适用。
5.2. 完成新功能后或 code review 后
结合任务的排期和实际的工作,重构的最佳时机是在完成一个功能后和 code review 后。
在完成功能并测试通过后,此时对任务的进度是可控的,重构不会影响到代码既有实现的功能,在使用 git 等版本控制系统管理的情况下,回退至功能可用时的代码片段是非常轻易的,但你无法立即完成你从未实现好的功能。
在每完成一个功能后重构,也类似于垃圾回收中的时间分片的思想,不必等到代码中塞满“垃圾”时才开始清理,导致“全停顿”的发生。将重构分解为一小步一小步。
让一个团队,特别是共同实现同一项目的团队来校验自己的代码,往往能够发现自己难以注意的问题。比如自己写的一个功能其实另一个同学已经实现过了,完全可以抽离出来复用;比如有经验的同学提出更加优雅的实现方案。
并且自己编写的代码往往带有自己的风格和“坏习惯”,代码风格并不是一种错误,但在一个团队中,不同代码风格的混杂会带来阅读与合作的困难,而对于“坏习惯”而言,比如极其复杂的条件判断语句等,自己难以意识到该做法的不妥,需要群众的意见加以改正。
实际上在每完成一个新功能后重构还有一些笔者认为很重要的优势,就是你会对自己的代码有更清晰的了解,你会去做今后不会再做的事情。
对代码更清晰,能让我们更好的定位问题和提高自己的代码水平,这很好理解。
那这个今后不会再做的事情是什么呢?没错,就是重构。当你完成新功能后,如果不立刻进行 review,那么在上线后很可能就从此被封存在某个地方,直到它出现了 bug。久而久之,整个项目变得难以维护,代码开始发臭。
而在完成新功能后重构,工作量一般也不会很大,是“顺手完成的小工作”,属于一鼓作气阶段,如果打算以后再看,那么往往就没有这个以后了。
5.3. 难以添加新功能的时候
其实并不希望这个状况发生,这代表代码结构已经处于混乱中,添加新功能需要翻越好几个障碍。此时重构是个必选项,也必然是个大工程,这会造成项目的“全停顿”。更糟糕的是此时重构可能不如直接重写,这是我们需要避免的情况。
6. 什么时候不该重构
6.1. 重写比重构容易
这个无需多言。
6.2. 不需要理解该代码片段时
如果一个功能或者 API 一直以来“兢兢业业”,从未出现过 bug,即便其底下隐藏着十分丑陋的代码,那么我们也可以忍受它继续保持丑陋。不要忘了重构的初衷,其中之一就是为了让人更好的理解代码,当我们不需要理解其时,就让它安安静静地躺在哪儿吧,不要让不可控制的行为发生是重构的原则之一。
6.3. 未与合作者商量时
如果一个功能被多个模块引用,而这些模块并非你负责时,你必须提前通知负责人,声明将要对这部分功能进行修改,哪怕重构不会带来任何使用上的变化,因为这也意味着重构行为将会带来“不可控”。
7. 重构与性能
关于重构对性能的影响,是被提及最多的问题。毕竟重构代码很多时候都带来了运行代码行数的增加(并不一定是代码总行数增加,因为重构有提炼函数的部分,优秀的重构总会带来代码总行数的下降)。又或者说将一些性能好的代码变为可读性更高的代码,牺牲掉性能优势。
首先需要回顾一下,代码重构和性能优化是两个不同的概念,重构仅仅只考虑代码的可理解性和可拓展性,对于代码的执行效率是不在乎的,在重构时切记不要同时戴着“两顶帽子”。
而重构对于性能的影响,也很可能没有你想象中的那么高,在面对大部分的业务情况时,重构前和重构后代码的性能差别几乎难以体现。
大部分情况下,我们不需要极致的“压榨”计算机,来减少使用的微乎其微的计算机时钟周期时间,更重要的是,减少自己在开发中使用的时间。
如果对于重构后的的性能不满意,可以在完成重构后有的放矢的对部分高耗时功能进行代码优化。一件很有趣的事情是:大多数程序运行的大半时间都在一小部分代码身上,只要优化这部分代码,就能带来显著的性能提高。如果你一视同仁的优化所有代码,就会发现这是在白费劲,因为被优化的代码不会被经常执行。
所以我认为重构时大可不必为性能过多担忧,可以放手去重构,如有必要再针对个别代码片段优化。短期来看,重构的确可能使软件变慢,但重构也使性能调优更容易,最终还是会得到很好的效果。
8. 完结撒花
笔者并非“重构大师”,本文也只展现了一些十分常见的重构手法以及对重构浅略的思考,还有很多经典的手法与案例,本文未于展示,读者如果对重构感兴趣,想深入了解的话,可以阅读 Martin Fowler 的经典书籍《重构,改善既有代码的设计 第二版》,其中的示例语言选用了 JavaScript,这简直是前端工程师的福音。
对于 VSCode 用户而言,有很多优秀的插件帮助你重构,比如 JavaScript Booster 或 Stepsize,这些插件能提示你如何重构且为代码添加书签和报告。
都读到这了,接下来知道该怎么做了吧。Commit a feature,review and refactor。
9. 引用
[0] 《重构,改善既有代码的设计 第二版》Martin Fowler
往期精彩