重构 (refactoring),即在不改变代码外在行为的前提下,修改代码以改进程序的内部结构。什么时候该重构?什么时候不该重构?重构的要遵循哪些原则?有哪些具体的技巧?本文作为《重构:改善既有代码的设计》读书笔记,会一一解答上述问题,并总结了 18 条实战重构技巧,帮助大家写出更好的代码。
何时该重构
在回答这个问题前,先看看何时不该重构:
- 如果一段代码跑的很好,即使它写的很丑,逻辑再乱,当没有需求要修改它时,那就别去重构它;
- 如果重写比重构还容易,那该考虑的不是重构,而是重写了。
那什么时候该重构呢?可以参考下面三条原则:
- DRY(Dont Repeat Yourself):事不过三,三则重构;
- 预备性重构:重构的最佳时机就是在添加新功能之前。有时候开发新功能的时候,如果对现有代码做微调,开发起来会更容易,这就是预备性重构;
- 理解性重构:当你看一段代码逻辑、结构或命名很费劲的时候,此时进行的重构,就是帮助理解性的重构。
而当你决定要进行重构的时候,切记重构的前提:先检查自己是否有一套可靠的测试集。你必须要保证每一处改动都处于编译通过和测试通过的状态,否则重构对你、对测试人员都会带来巨大的成本。重构在于平时的小步迭代和严格要求,小步修改积累起来就能大大改善系统设计,切忌等到代码实在难以维护时才来一个大重构。
再次强调,要正确地进行重构,前提是得有一套稳固的测试集合。何为稳固高质量的测试集?很多人只关注测试覆盖率,实际上覆盖率只是指标之一,关键是你的测试集是否能有效覆盖边界,检测出 BUG。每当你收到一个 BUG 时,想想你的单测是否能覆盖它?关于测试的话题这里不多展开,之所以提这么多是因为这对重构而言确实十分重要。
代码的坏味道
这个标题很委婉,什么样的代码会散发坏的味道呢?
Shit Code.
那什么样的代码需要重构呢?这里列举了 18 种情况供参考:
- 魔法数字,神秘命名。另外,当你想不出一个好名字时,背后可能藏着更深的设计问题;
- 重复代码。这个不必多说,通常来说提炼复用逻辑,避免重复代码是好事情。但是切记,不要为了过度追求解决重复代码而损坏了其可读性和可维护性。
- 过长函数,过长参数,过大的类等。有些团队会限制文件/函数体代码行数,能一定程度解决这个问题,但究其根因还是要从抽象和封装上来解决问题。
- 全局数据。少量的全局数据或许无妨,但是全局数据过多就是灾难,处理的难度就会指数增长。
- 可变数据。控制好数据的变化,否则你都不知道你的数据什么时候,在哪里,被谁给改了。
- 发散式变化和霰弹式修改。如果某个模块经常因为各种外因在不同的方向上发生修改(发散式变化),或者改一个功能经常要改另外几个模块(霰弹式修改),那就得重构了。
- 依恋情节。一个模块跟另一个模块的交流比跟自己模块内交流还频繁,这就是依恋情节,得治了。
- 数据泥团。如果经常在不同地方都看到相同的几项数据(他们就像泥团一样经常混在一起),就得考虑将他们几个提炼成类或者独立对象了。
- 偏爱基本类型。说的就是为了偷懒的程序员只用基本数据类型,而不愿创建适合业务场景的数据结构。
- 重复的 switch。switch 不可怕,可怕的是当你想增加分支时,要找到好几个重复的 swtich 逐一更新。
- 冗余的元素。如无必要,勿增实体。
- 为了通用而设计的通用性,如果你抽象出来的东西并没有发挥多大作用,那就是过度设计了。
- 临时字段。我们经常看见类/函数体内有为了某种特定情况而存在的字段,这会让人难以理解,你可能需要将其提炼到新的地方。
- 过长的消息链。如一个功能需要 A 请求 B,B 请求 C,C 又请求 D...这就是消息链。
- 过度运用委托。封装是对象的基本特征,封装往往伴随着委托,要防止过度委托。
- 纯数据类。它们拥有一些字段,以及用于访问读写这些字段的函数,除此之外一无长物。如果产生这种类往往意味着行为被放在了错误的地方。
- 不合理的继承。如果子类复用了超类的行为,但又不愿意支持超类的接口,这种继承关系就有问题。
- 注释。最后提一下注释,当你需要写注释时,请先尝试重构,试着让所有注释都变得多余。
重构实战技巧
针对代码中的坏味道(实际上书中列举了更多),这里介绍一些实际的重构技巧。每一个技巧都有对应的代码案例供参考,这里总结了 18 条(为什么又是 18?可能刚好对应降龙十八掌吧哈哈):
1. 提炼函数
先做一道选择题:什么时候应该提炼函数,把代码放进独立的函数里?
- A. 从代码长度考虑,如果一个函数不能在一屏中显示,或超出 N 行,那么就得将内部逻辑提炼成新的函数;
- B. 从复用角度考虑,如果一段逻辑被使用超过 2 次,那么就得将这段逻辑提炼成新的函数;
- C. 从意图和实现考虑,如果某段代码有着共同意图,那么就得将其提炼,并根据其意图进行合理的命名。
先想一想。
书中给的答案是 C。
但是我觉得实际研发中要三条综合起来看,对于三个选项:
- A. 通常来说,函数体代码太长肯定是有问题的,可以适当提炼;
- B. 只按复用度考虑提炼,在后期维护时如果有新增场景,可能得回来修改这个复用函数,如果这时复用函数里又新增了特化逻辑,那么这个提炼可能让维护成本更高,因为每修改一次,你都得回归所有用到的地方;
- C. 按意图和实现考虑原则上是最合理的。但假设这个实现没有任何复用度可言,代码长度也在接受范围内,可能也没必要提炼成函数,通过换行来形成代码块,或者加下注释,阅读起来反而会更顺畅。
所以说规矩是死的,业务是活的,得根据实际场景来选择合适的重构技巧。
再来一道练习题:请使用刚刚现学的提炼函数技巧,对下面代码进行重构。
function printOwing(invoice) {
let outstanding = 0;
printBanner();
// calculate outstanding
for (const o of invoice.orders) {
outstanding += o.amount;
}
// record due date
const today = Clock.today;
invoice.dueDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 30);
//print details
console.log(`name: ${invoice.customer}`);
console.log(`amount: ${outstanding}`);
console.log(`due: ${invoice.dueDate.toLocaleDateString()}`);
}
参考答案(对有着共同意图的代码块进行提炼):
function printOwing(invoice) {
printBanner();
const outstanding = calculateOutstanding(invoice);
recordDueDate(invoice);
printDetails(invoice, outstanding);
}
function calculateOutstanding(invoice) {
let outstanding = 0;
for (const o of invoice.orders) {
outstanding += o.amount;
}
return outstanding;
}
function recordDueDate(invoice) {
const today = Clock.today;
invoice.dueDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 30);
}
function printDetails(invoice, outstanding) {
console.log(`name: ${invoice.customer}`);
console.log(`amount: ${outstanding}`);
console.log(`due: ${invoice.dueDate.toLocaleDateString()}`);
}
2. 内联函数
内联函数是提炼函数的反向重构技巧。提炼是将原本函数内逻辑拆分出去,而内联恰恰相反,将外部的逻辑内联到同一个函数体内。
为什么会出现反向重构的技巧呢?因为业务是在不断变化之中,以前合理的提炼以后未必合理。再加上总会有人刚学会“提炼函数”技巧,就想展示一下身手,结果造成了过度提炼。
那么选择题又来了,什么时候应该把代码内联到一个函数里?
- A. 内部代码和函数名称同样清晰易读时可以内联,因为单独提炼带来的间接性并未起到明显作用;
- B. 手上有一群组织不合理的函数可以内联,将他们先都内联到一个大函数中,再重新提炼重组;
- C. 代码有太多间接层,各个函数都只是对另外一个函数的简单委托,在这些委托之间来回跳转让你感到头晕,这时就应该内联。
这题不装了,ABC 全都要。
再来一道练习题:请使用内联函数技巧,对下面代码进行重构:
function rating(aDriver) {
return moreThanFiveLateDeliveries(aDriver) ? 2 : 1;
}
function moreThanFiveLateDeliveries(dvr) {
return dvr.numberOfLateDeliveries > 5;
}
参考答案:
function rating(aDriver) {
return aDriver.numberOfLateDeliveries > 5 ? 2 : 1;
}
3 提炼变量
代码中的表达式可能复杂而难以阅读,提炼出局部变量可以帮助我们理解,同时在调试和打印的时候也更方便。
练习题:请使用提炼变量数技巧,对下面代码进行重构:
function price(order) {
//price is base price - quantity discount + shipping
return order.quantity * order.itemPrice -
Math.max(0, order.quantity - 500) * order.itemPrice * 0.05 +
Math.min(order.quantity * order.itemPrice * 0.1, 100);
}
参考答案:
function price(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;
}
4 内联变量
内联变量是提炼变量的反向重构技巧。我上面说的代码中的表达式可能复杂难懂,但并不是所有的表达式都这样。有时候你单独把表达式提炼成变量后,并没有比表达式本身更具表现力,并且这个新变量可能会妨碍附近的代码。
练习题:请使用内联变量数技巧,对下面代码进行重构:
function compareOrderPrice(orderA, orderB) {
let priceA = orderA.price;
let priceB = orderB.price;
return priceA > priceB;
}
参考答案:
function compareOrderPrice(orderA, orderB) {
return orderA.price > orderB.price;
}
好吧我承认每写一个重构技巧,就会写一个与之对立的反向重构技巧。作者要这么安排,可能就是要让大家活学活用,实战时忘掉规则。后面提到的技巧也都有对应的反向技巧,将不再独立出来分析了。
5 函数改名
不是所有的函数命名/函数参数都那么合理,但当你想改的时候,又怕对其调用方产生影响。这里有两种做法,第一种是找到系统中所有的调用方,一次性将函数名全部替换。但是这个往往比较困难,因为你很难真的找到所有的调用方,因此我们可以采取第二种渐进式策略:
- 将要修改的函数提炼成一个新的函数;
- 对旧函数使用内联函数技巧;
- 标记旧函数为 deprecated。
练习题:请使用函数改名技巧,对下面代码进行重构:
function circum(radius) {
return 2 * Math.PI * radius;
}
参考答案:
// @deprecated
function circum(radius) {
return circumference(radius);
}
function circumference(radius) {
return 2 * Math.PI * radius;
}
6 变量改名
好的命名是整洁编程的核心,使用范围越广,名字的好坏就越重要。最简单的改名策略是全局替换。也可以参考“改变函数声明”的技巧,用一个新的变量来过渡,比如要对下面的 cpyNm 进行改名重构:
// 修改前,缩写很奇怪
export const cpyNm = 'ByteDance';
// 修改后,用新的 companyName 来替代,逐步下线掉老的 cpyNm
export const companyName = 'ByteDance';
export const cpyNm = companyName;
当然还有一种方式,就是使用下面要提到的“封装变量”:对外统一暴露方法,假设我要修改某个变量值,我只需修改封装变量内部的数据,而不用批量替换外部用到的方法。
7 封装变量
封装变量实际上是对数据做封装,数据被使用的越广,就越值得花精力去做好封装。控制变量的可变性能让程序更容易维护。比如下面定义了一个默认用户的变量,可以无限制的读写:
let defaultOwner = {firstName: "Martin", lastName: "Fowler"};
spaceship.owner = defaultOwner;
defaultOwner = {firstName: "Rebecca", lastName: "Parsons"};
稍作封装(仅做示例):
// defaultOwner.js
let defaultOwner = {firstName: "Martin", lastName: "Fowler"};
export function getDefaultOwner() { return {...defaultOwner}; }
export function setDefaultOwner(arg) { defaultOwner = arg; }
defaultOwner 本身不可见,只对外暴露读写方法。并且读取方法返回的是一份副本,这样就控制了可变性。
8 引入参数对象
如果一组数据项总是结伴同行,出没于一个又个函数,这样一组数据就是前面提到的数据泥团,可以用数据结构来替代。比如下面的 startDate 和 endDate 总是结伴而行,那么就需要引入一个新的 DateRange 数据结构了:
// 修改前
function amountInvoiced(startDate: Date, endDate: Date) {...}
// 修改后
interface DateRange {
start: Date;
end: Date;
}
function amountInvoiced(dateRange: DateRange) {...}
9 函数组合成类
如果发现一组函数形影不离地操作同一块数据(通常是将这块数据作为参数传递给函数),那么是时候组建一个类了。类能明确地给这些函数提供一个共用的环境,在对象内部调用这些函数可以少传许多参数,从而简化函数调用,并且这样一个对象也可以更方便地传递给系统的其他部分。
// 修改前
function base(aReading) {...}
function taxableCharge(aReading) {...}
function calculateBaseCharge(aReading) {...}
// 修改后,通过组件类来统一调用
class Reading {
aReading: ReadData;
base() {...}
taxableCharge() {...}
calculateBaseCharge() {...}
}
10 函数组合成变换
前面说了函数组合成类,其实还有一种形式就是组合成变换函数。并不是所有的数据处理都需要组合成类,采用变换函数同样有效:
// 修改前
function base(aReading) {...}
function taxableCharge(aReading) {...}
function calculateBaseCharge(aReading) {...}
// 修改后,通过数据变换函数来统一处理
function enrichReading(argReading) {
const aReading = _.cloneDeep(argReading);
aReading.baseCharge = base(aReading);
aReading.taxableCharge = taxableCharge(aReading);
aReading.flexCharge = calculateBaseCharge(aReading);
return aReading;
}
那什么时候该组合成类?什么时候该组合成变换呢?如果代码中会对源数据做更新,那么使用类要好得多。如果使用变换,派生数据会被存储在新生成的记录中,一旦源数据被修改,就会遭遇数据不一致。当然,如果你使用不可变数据编程方式,用变换会更常见,你可以理解为这两种技巧一个是面向对象编程,一个是函数式编程。
11 隐藏委托关系
一个好的模块化的设计,“封装”是其最关键特征之一。“封装”意味着每个模块都应该尽可能少了解系统的其他部分。如此一来,一旦发生变化,需要了解这一变化的模块就会比较少——这会使变化比较容易进行。
举个例子,有 Person 和 Department 两个类:
class Persion {
...
get name() {return this._name;}
get department() {return this._department;}
}
class Department {
...
get manager() {return this._manager;}
}
当我们想知道某个人的 manager 是谁时,他可能通过 deparment 委托调用:
const manager = aPersion.department.manager;
这样就暴露了系统内部的耦合关系,一旦对 deparment 做修改就很可能影响外部获取 manager 的逻辑,这时我们可以通过封装新的方法来隐藏委托调用关系,以便于后期更易维护:
class Persion {
...
get name() {return this._name;}
get department() {return this._department;}
get manager() {return this._department.manager;} // 新增获取 manager 方法
}
// 客户端直接调用
const manager = aPersion.manager;
当然,隐藏委托关系,就意味着添加一个中间人,Persion 的 get manager 方法就是一个中间人。一旦这种中间人变多,整个 Persion 类就可以变成了一个中间人类,存在各种转发关系。因此该技巧还有一个反向重构技巧:移除中间人。何时该隐藏委托关系,何时该移除中间人,没有固定的答案,我们大可在系统运行中不断调整。
12 搬移特性
在不同的上下文之间搬移元素也是十分有用的重构技巧。如搬移函数、搬移字段和搬移语句等。
以搬移函数为例,任何函数都需要具备上下文环境才能存活。这个上下文可以是全局的,但它更多时候是由某种形式的模块所提供的。对一个面向对象的程序而言,类作为最主要的模块化手段,其本身就能充当函数的上下文;通过嵌套的方式,外层函数也能为内层函数提供一个上下文。不同的语言提供的模块化机制各不相同,但这些模块的共同点是,它们都能为函数提供一个赖以存活的上下文环境。搬移函数最直接的一个动因是,它频繁引用其他上下文中的元素,而对自身上下文中的元素却关心甚少。此时,让它去与那些更亲密的元素相会,通常能取得更好的封装效果,因为系统别处就可以减少对当前模块的依赖。
再以搬移语句为例,如果我们发现调用某个函数时,总有一些相同的代码也需要每次执行,那么可以考虑将此段代码合并到函数里面。如下,调用 photoData 时往往会先处理 persion.photo.title 信息,那么可以直接将该语句移动到函数体里:
// 修改前
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>`,
];
}
当然,搬移进去算一种技巧,那么搬移出来自然也是一种技巧。函数的抽象边界往往会随着系统演进而发生偏移。以往在多个地方共用的行为,如今需要在某些调用点面前表现出不同的行为。这时就得把表现不同的行为从函数里挪出,并搬移到其调用处。所以,搬移特性的关键点在于对上下文和行为边界的把控。
最后再提一个要搬移的特性,就是那些没有被用到的特性,即死代码。删除一大段无用的代码是开心的。你可能会担心以后可能还会用到,所以会先注释起来。但完全没必要,第一你未来未必会用到,第二即使会用到也可以通过版本控制再找回来。
13 重构循环
除了上面说到的函数和语句的处理,还有一个非常重要的技巧,那就是对循环的处理。看下面这段代码:
let averageAge = 0, 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;
另外一个技巧,就是用管道替换循环,很多编程语言都提供了集合管道的能力,如下:
// 修改前
const names = [];
for (const i of input) {
if (i.sex === "M")
names.push(i.name);
}
}
// 修改后,集合管道能让逻辑更清晰
const names = input.filter(i => i.sex === "M").map(i => i.name);
14 替换算法
前面说的技巧都是代码的组织和结构调整,但有时候只调整组织结构是不够的,直接更新算法和实现思路可能更有效。举个例子:
// 优化前
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)) || '';
}
15 重新组织数据
数据结构应该是清晰的,内聚的。一旦某些数据组织方式让你觉得混乱,你就得考虑优化它了。
先看一个最简单的变量拆分技巧:
let temp = 2 * (height + width);
console.log(temp);
temp = height * width;
console.log(temp);
程序员为了偷懒,用 temp 来打印了不同含义的内容,在实际多人项目中,合理的拆分变量更易理解:
const perimeter = 2 * (height + width);
console.log(perimeter);
const area = height * width;
console.log(area);
TBD
说好的 18 式技巧,最后 3 式留到下篇来写吧,因为这 3 式对应了原书的三个章节,敬请期待:
- 简化条件逻辑
- 对 API 的重构
- 处理继承关系