重构:改善既有代码的设计

1. 重构的原则

1.1 何谓重构

使用一系列重构手法,在不改变软件可观察行为的前提下,调整其结构。

因此,重构中的代码很少进入不可工作的状态,即便重构没有完成,我也可以在任何时刻停下来。

1.2 为何重构

  1. 重构可以改进软件的设计。完成同样一件事,设计欠佳的程序往往需要更多代码,这常常是因为代码在不同的地方使用完全相同的语句做同样的事,因此改进设计的一个重要方向就是消除重复代码。代码量减少并不会使系统运行更快,因为这对程序的资源占用几乎没有任何明显影响。然而代码量减少将使未来可能的程序修改动作容易得多。代码越多,做正确的修改就越困难,因为有更多代码需要理解。

  2. 重构使软件更容易理解。

  3. 重构可以帮助找到bug。

  4. 重构可以提高编码速度。需要添加新功能时,内部质量良好的软件让我可以很容易找到在哪里修改、如何修改。良好的模块划分使我只需要理解代码库的一小部分,就可以做出修改。如果代码很清晰,我引入bug的可能性就会变小,即使引入了bug,调试也会容易得多。

1.3 何时重构

  1. 预备性重构:让添加新功能更容易。重构的最佳时机就在添加新功能之前。

  2. 帮助理解的重构:使代码更易懂。先在一些小细节上使用重构来帮助理解,给一两个变量改名,让它们更清楚地表达意图,以方便理解,或是将一个长函数拆成几个小函数。

  3. 捡垃圾式重构。如果我发现的垃圾很容易重构,我会马上重构它;如果重构需要花一些精力,我可能会拿一张便笺纸把它记下来,完成当下的任务再回来重构它。

  4. 有计划的重构和见机行事的重构。如果团队过去忽视了重构,那么常常会需要专门花一些时间来优化代码库,以便更容易添加新功能。在重构上花一个星期的时间,会在未来几个月里发挥价值。分离重构提交并不是毋庸置疑的原则,只有当你真的感到有益时,才值得这样做。

  5. 长期重构

  6. 复审代码时的重构。代码复审对于编写清晰代码也很重要。重构还可以帮助代码复审工作得到更具体的结果。不仅获得建议,而且其中许多建议能够立刻实现。最终你将从实践中得到比以往多得多的成就感。

1.4 重构的挑战

  1. 延缓新功能的开发。重构的唯一目的就是让我们开发更快,用更少的工作量创造更大的价值。

  2. 代码所有权。

  3. 分支。

  4. 测试。绝大多数的情况下,如果想要重构,就必须先有可以自测试的代码。

  5. 遗留代码。每次触碰一块代码时,我会尝试的把它变好一点点。

  6. 数据库。借助数据迁移脚本,将数据库结构的修改与代码相结合,使大规模的、涉及数据库的修改可以比较容易地开展。

  7. 重构,架构和YAGNI。只有当未来重构会很困难时,我才考虑现在就添加灵活性机制。

  8. 重构与软件开发过程。

  9. 重构与性能。虽然重构可能使软件运行更慢,但它也使软件的性能优化更容易。

2. 代码的坏味道

2.1 神秘命名(mysterious name)

整洁代码最重要的一环就是好的名字,所以我们会深思熟虑如何给函数、模块、变量和类命名,使它们能清晰地表明自己的功能和用法。

改名不仅仅是修改名字而已。如果你想不出一个好名字,说明背后很可能潜藏着更深的设计问题。

2.2 重复代码(duplicated code)

如果你在一个以上的地点看到相同的代码结构,那么可以肯定:设法将它们合而为一,程序会变得更好。

一旦有重复代码存在,阅读这些重复的代码时你就必须加倍仔细,留意其间细微的差异。如果要修改重复代码,你必须找出所有的副本来修改。

2.3 过长函数(long function)

函数越长,越难以理解。让小函数易于理解的关键还是在于良好的命名。如果你能给函数起个好名字,阅读代码的人就可以通过名字了解函数的作用,根本不必去看其中写了些什么。

每当感觉需要以注释来说明点什么的时候,我们就把需要说明的东西写进一个独立函数中,并以其用途(而非实现手法)命名。

如果代码前方有一行注释,就是在提醒你:可以将这段代码替换成一个函数,而且可以在注释的基础上给这个函数命名。

2.4 过长参数列表(long parameter list)

如果可以向某个参数发起查询而获得另一个参数的值,那么就可以使用以查询取代参数去掉这第二个参数。如果你发现自己正在从现有的数据结构中抽出很多数据项,就可以考虑使用保持对象完整手法,直接传入原来的数据结构。如果有几项参数总是同时出现,可以用引入参数对象将其合并成一个对象。如果某个参数被用作区分函数行为的标记(flag),可以使用移除标记参数。

使用类可以有效地缩短参数列表。如果多个函数有同样的几个参数,引入一个类就尤为有意义。你可以使用函数组合成类,将这些共同的参数变成这个类的字段。

2.5 全局数据(global data)

全局数据的问题在于,从代码库的任何一个角落都可以修改它,而且没有任何机制可以探测出到底哪段代码做出了修改。

首要的防御手段是封装变量,每当我们看到可能被各处的代码污染的数据,这总是我们应对的第一招。你把全局数据用一个函数包装起来,至少你就能看见修改它的地方,并开始控制对它的访问。随后,最好将这个函数(及其封装的数据)搬移到一个类或模块中,只允许模块内的代码使用它,从而尽量控制其作用域。

2.6 可变数据(mutable data)

和全局数据一样,对数据的修改经常导致出乎意料的结果和难以发现的bug。可以用封装变量来确保所有数据更新操作都通过很少几个函数来进行,使其更容易监控和演进。

2.7 发散式变化(divergent change)

一旦需要修改软件系统,我们希望能够跳到系统的某一点,只在该处做修改。如果不能做到这一点,你就嗅出两种紧密相关的刺鼻味道中的一种了。

如果某个模块经常因为不同的原因在不同的方向上发生变化,发散式变化就出现了。每当要对某个上下文做修改时,我们只需要理解这个上下文,而不必操心另一个。

2.8 霰弹式修改(shotgun surgery)

霰弹式修改类似于发散式变化,但又恰恰相反。如果每遇到某种变化,你都必须在许多不同的类内做出许多小修改,你所面临的坏味道就是霰弹式修改。如果需要修改的代码散布四处,你不但很难找到它们,也很容易错过某个重要的修改。

面对霰弹式修改,一个常用的策略就是使用与内联(inline)相关的重构——如内联函数或是内联类——把本不该分散的逻辑拽回一处。

2.9 依恋情节(feature envy)

一个函数跟另一个模块中的函数或者数据交流格外频繁,远胜于在自己所处模块内部的交流,这就是依恋情结的典型情况。

这个函数既然想跟这些数据待在一起,那就使用搬移函数把它移过去。有时候,函数中只有一部分受这种依恋之苦,这时候应该使用提炼函数把这一部分提炼到独立的函数中,再使用搬移函数带它去它的梦想家园。

最根本的原则是:将总是一起变化的东西放在一块儿

2.10 数据泥团(data clumps)

常常可以在很多地方看到相同的三四项数据:两个类中相同的字段、许多函数签名中相同的参数。这些总是绑在一起出现的数据真应该拥有属于它们自己的对象。首先请找出这些数据以字段形式出现的地方,运用提炼类将它们提炼到一个独立对象中。然后将注意力转移到函数签名上,运用引入参数对象或保持对象完整为它瘦身。这么做的直接好处是可以将很多参数列表缩短,简化函数调用。

2.11 基本类型偏执(primitive obsession)

大多数编程环境都大量使用基本类型,即整数、浮点数和字符串等。但是很多程序员不愿意创建对自己的问题域有用的基本类型,如钱、坐标、范围等。

可以运用以对象取代基本类型将原本单独存在的数据值替换为对象,从而走出传统的洞窟,进入炙手可热的对象世界。

2.12 重复的switch(repeated switches)

在有些面向对象主义者看来,任何switch语句都应该用以多态取代条件表达式消除掉。

如果在不同的地方反复使用同样的switch逻辑(可能是以switch/case语句的形式,也可能是以连续的if/else语句的形式)。重复的switch的问题在于:每当你想增加一个选择分支时,必须找到所有的switch,并逐一更新。多态给了我们对抗这种黑暗力量的武器,使我们得到更优雅的代码库。

2.13 循环语句(loops)

管道操作(如filter和map)可以帮助我们更快地看清被处理的元素以及处理它们的动作。

2.14 冗赘的元素(lazy element)

程序元素(如类和函数)能给代码增加结构,从而支持变化、促进复用或者哪怕只是提供更好的名字也好,但有时我们真的不需要这层额外的结构。

2.15 夸夸其谈通用性(speculative generality)

如果函数或类的唯一用户是测试用例,这就飘出了坏味道“夸夸其谈通用性”。

如果你的某个抽象类其实没有太大作用,请运用折叠继承体系。不必要的委托可运用内联函数和内联类除掉。如果函数的某些参数未被用上,可以用改变函数声明去掉这些参数。如果有并非真正需要、只是为不知远在何处的将来而塞进去的参数,也应该用改变函数声明去掉。

2.16 临时字段(temporary field)

如果类的内部某个字段仅为某种特定情况而设,这样的代码就让人比较难以理解,因为你通常认为对象在所有时候都需要它的所有字段。

2.17 过长的消息链(message chains)

如果你看到用户向一个对象请求另一个对象,然后再向后者请求另一个对象,然后再请求另一个对象……这就是消息链。在实际代码中你看到的可能是一长串取值函数或一长串临时变量。采取这种方式,意味客户端代码将与查找过程中的导航结构紧密耦合。一旦对象间的关系发生任何变化,客户端就不得不做出相应修改。

通常更好的选择是先观察消息链最终得到的对象是用来干什么的,看看能否以提炼函数把使用该对象的代码提炼到一个独立的函数中,再运用搬移函数把这个函数推入消息链。

2.18 中间人(middle man)

对象的基本特征之一就是封装——对外部世界隐藏其内部细节。封装往往伴随着委托。

但是人们可能过度运用委托。你也许会看到某个类的接口有一半的函数都委托给其他类,这样就是过度运用。这时应该使用移除中间人,直接和真正负责的对象打交道。

2.19 内幕交易(insider trading)

如果两个模块总是在咖啡机旁边窃窃私语,就应该用搬移函数和搬移字段减少它们的私下交流。如果两个模块有共同的兴趣,可以尝试再新建一个模块,把这些共用的数据放在一个管理良好的地方;或者用隐藏委托关系,把另一个模块变成两者的中介。

继承常会造成密谋,因为子类对超类的了解总是超过后者的主观愿望。如果你觉得该让这个孩子独立生活了,请运用以委托取代子类或以委托取代超类让它离开继承体系。

2.20 过大的类(large class)

如果想利用单个类做太多事情,其内往往就会出现太多字段。

可以运用提炼类将几个变量一起提炼至新类内。提炼时应该选择类内彼此相关的变量,将它们放在一起。

2.21 异曲同工的类(Alternative Classes with Different Interfaces)

使用类的好处之一就在于可以替换:今天用这个类,未来可以换成用另一个类。但只有当两个类的接口一致时,才能做这种替换。可以用改变函数声明将函数签名变得一致。但这往往还不够,请反复运用搬移函数将某些行为移入类中,直到两者的协议一致为止。如果搬移过程造成了重复代码,或许可运用提炼超类补偿一下。

2.22 纯数据类(data class)

所谓纯数据类是指:它们拥有一些字段,以及用于访问(读写)这些字段的函数,除此之外一无长物。这些类早期可能拥有public字段,若果真如此,你应该在别人注意到它们之前,立刻运用封装记录将它们封装起来。对于那些不该被其他类修改的字段,请运用移除设值函数。

纯数据类常常意味着行为被放在了错误的地方。也就是说,只要把处理数据的行为从客户端搬移到纯数据类里来,就能使情况大为改观。

2.23 被拒绝的遗赠(refused badquest)

子类应该继承超类的函数和数据。但如果它们不想或不需要继承,又该怎么办呢?

按传统说法,这就意味着继承体系设计错误。你需要为这个子类新建一个兄弟类,再运用函数下移和字段下移把所有用不到的函数下推给那个兄弟。

如果子类复用了超类的行为(实现),却又不愿意支持超类的接口,“被拒绝的遗赠”的坏味道就会变得很浓烈。拒绝继承超类的实现,这一点我们不介意;但如果拒绝支持超类的接口,这就难以接受了。既然不愿意支持超类的接口,就不要虚情假意地糊弄继承体系,应该运用以委托取代子类或者以委托取代超类彻底划清界限。

2.24 注释(comments)

注释可以带我们找到本章先前提到的各种坏味道。找到坏味道后,我们首先应该以各种重构手法把坏味道去除。完成之后我们常常会发现:注释已经变得多余了,因为代码已经清楚地说明了一切。

如果你需要注释来解释一块代码做了什么,试试提炼函数;如果函数已经提炼出来,但还是需要注释来解释其行为,试试用改变函数声明为它改名;如果你需要注释说明某些系统的需求规格,试试引入断言。

3. 基础重构

3.1 提炼函数(extract function)

如果你需要花时间浏览一段代码才能弄清它到底在干什么,那么就应该将其提炼到一个函数中,并根据它所做的事为其命名。以后再读到这段代码时,你一眼就能看到函数的用途,大多数时候根本不需要关心函数如何达成其用途(这是函数体内干的事)。

快捷键:option + command + m

原代码:

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}`);
 }
}

3.2 内联函数(inline function)

有时候你会遇到某些函数,其内部代码和函数名称同样清晰易读。也可能你重构了该函数的内部实现,使其内容和其名称变得同样清晰。若果真如此,你就应该去掉这个函数,直接使用其中的代码。

原代码:

function getRating(driver) {
  return moreThanFiveLateDeliveries(driver) ? 2 : 1;
}

function moreThanFiveLateDeliveries(driver) {
 return driver.numberOfLateDeliveries > 5;
}

重构后的代码:

function getRating(driver) {
 return (driver.numberOfLateDeliveries > 5) ? 2 : 1;
}

3.3 提炼变量(extract variable)

表达式有可能非常复杂而难以阅读。这种情况下,局部变量可以帮助我们将表达式分解为比较容易管理的形式。在面对一块复杂逻辑时,局部变量使我能给其中的一部分命名,这样我就能更好地理解这部分逻辑是要干什么。 快捷键:option + command + v

原代码:

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.4 内联变量(inline variable)

在一个函数内部,变量能给表达式提供有意义的名字,因此通常变量是好东西。但有时候,这个名字并不比表达式本身更具表现力。还有些时候,变量可能会妨碍重构附近的代码。若果真如此,就应该通过内联的手法消除变量。

原代码:

let basePrice = anOrder.basePrice;
return (basePrice > 1000);

重构后的代码:

return anOrder.basePrice > 1000;

3.5 改变函数声明(change function declaration)

一个好名字能让我一眼看出函数的用途,而不必查看其实现代码。但起一个好名字并不容易,我很少能第一次就把名字起对。如果我看到一个函数的名字不对,一旦发现了更好的名字,就得尽快给函数改名。

函数的参数列表阐述了函数如何与外部世界共处。修改参数列表不仅能增加函数的应用范围,还能改变连接一个模块所需的条件,从而去除不必要的耦合。

快捷键:command + F6

原代码:

function circum(radius) {...}

重构后的代码:

function circumference(radius) {...}

3.6 封装变量(encapsulate variable)

如果想要搬移一处被广泛使用的数据,最好的办法往往是先以函数形式封装所有对该数据的访问。

原代码:

let defaultOwner = {firstName: "Martin", lastName: "Fowler"};

重构后的代码:

let defaultOwnerData = {firstName: "Martin", lastName: "Fowler"};
export function defaultOwner()       {return defaultOwnerData;}
export function setDefaultOwner(arg) {defaultOwnerData = arg;}

3.7 变量改名(rename variable)

好的命名是整洁编程的核心。变量可以很好地解释一段程序在干什么。

快捷键: shift + F6

原代码:

let a = height * width;

重构代码:

let area = height * width;

3.8 引入参数对象(introduce parameter object)

一组数据项总是结伴同行,出没于一个又一个函数。这样一组数据就是所谓的数据泥团。

将数据组织成结构是一件有价值的事,因为这让数据项之间的关系变得明晰。使用新的数据结构,参数的参数列表也能缩短。并且经过重构之后,所有使用该数据结构的函数都会通过同样的名字来访问其中的元素,从而提升代码的一致性。

原代码:

function amountInvoiced(startDate, endDate) {...} 
function amountReceived(startDate, endDate) {...} 
function amountOverdue(startDate, endDate) {...}

重构后的代码:

function amountInvoiced(aDateRange) {...} 
function amountReceived(aDateRange) {...} 
function amountOverdue(aDateRange) {...}

3.9 函数组合成类(combine function into class)

如果发现一组函数形影不离地操作同一块数据(通常是将这块数据作为参数传递给函数),我就认为,是时候组建一个类了。类能明确地给这些函数提供一个共用的环境,在对象内部调用这些函数可以少传许多参数,从而简化函数调用,并且这样一个对象也可以更方便地传递给系统的其他部分。

原代码:

function base(aReading) {...}
function taxableCharge(aReading) {...} 
function calculateBaseCharge(aReading) {...}

重构后的代码:

class Reading { 
  base() {...}
  taxableCharge() {...} 
  calculateBaseCharge() {...}
}

3.10 函数组合成变换(combine functions into transform)

在软件中,经常需要把数据“喂”给一个程序,让它再计算出各种派生信息。这些派生数值可能会在几个不同地方用到,因此这些计算逻辑也常会在用到派生数据的地方重复。我更愿意把所有计算派生数据的逻辑收拢到一处,这样始终可以在固定的地方找到和更新这些逻辑,避免到处重复。

一个方式是采用数据变换(transform)函数:这种函数接受源数据作为输入,计算出所有的派生数据,将派生数据以字段形式填入输出数据。有了变换函数,我就始终只需要到变换函数中去检查计算派生数据的逻辑。

原代码:

function base(aReading) {...}
function taxableCharge(aReading) {...}

重构后的代码:

function enrichReading(argReading) {
  const aReading = _.cloneDeep(argReading);
  aReading.baseCharge = base(aReading);
  aReading.taxableCharge = taxableCharge(aReading);
  return aReading;
}

3.11 拆分阶段(split phase)

每当看见一段代码在同时处理两件不同的事,我就想把它拆分成各自独立的模块,因为这样到了需要修改的时候,我就可以单独处理每个主题,而不必同时在脑子里考虑两个不同的主题。

原代码:

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];
}

4. 封装

4.1 封装记录(encapsulate record)

记录型结构是多数编程语言提供的一种常见特性。它们能直观地组织起存在关联的数据,让我可以将数据作为有意义的单元传递,而不仅是一堆数据的拼凑。

对象可以隐藏细节,同时封装也有助于改名。

原代码:

organization = {name: "Acme Gooseberries", country: "GB"};

重构后的代码:

class Organization { 
 constructor(data) {
  this._name = data.name; 
  this._country = data.country;
 }
 get name()    {return this._name;} 
 set name(arg) {this._name = arg;}
 get country()    {return this._country;} 
 set country(arg) {this._country = arg;}
}

4.2 封装集合(encapsulate collection)

我喜欢封装程序中的所有可变数据。这使我很容易看清楚数据被修改的地点和修改方式,这样当我需要更改数据结构时就非常方便。

但封装集合时人们常常犯一个错误:只对集合变量的访问进行了封装,但依然让取值函数返回集合本身。这使得集合的成员变量可以直接被修改,而封装它的类则全然不知,无法介入。

为避免此种情况,我会在类上提供一些修改集合的方法——通常是“添加”和“移除”方法。这样就可使对集合的修改必须经过类,当程序演化变大时,我依然能轻易找出修改点。

原代码:

class Person {
  get courses() {return this._courses;}
  set courses(aList) {this._courses = aList;}
}

重构后的代码:

class Person {
  get courses() {return this._courses.slice();} 
  addCourse(aCourse) { ... } 
  removeCourse(aCourse) { ... }
}                        

4.3 以对象取代基本类型(replace primitive with object)

一旦我发现对某个数据的操作不仅仅局限于打印时,我就会为它创建一个新类。一开始这个类也许只是简单包装一下简单类型的数据,不过只要类有了,日后添加的业务逻辑就有地可去了。

原代码:

orders.filter(o => "high" === o.priority || "rush" === o.priority);

重构后的代码:

orders.filter(o => o.priority.higherThan(new Priority("normal")))

4.4 以查询取代临时变量(replace temp with query)

临时变量的一个作用是保存某段代码的返回值,以便在函数的后面部分使用它。

如果我正在分解一个冗长的函数,那么将变量抽取到函数里能使函数的分解过程更简单,因为我就不再需要将变量作为参数传递给提炼出来的小函数。将变量的计算逻辑放到函数中,也有助于在提炼得到的函数与原函数之间设立清晰的边界,这能帮我发现并避免难缠的依赖及副作用。

改用函数还让我避免了在多个函数中重复编写计算逻辑。每当我在不同的地方看见同一段变量的计算逻辑,我就会想方设法将它们挪到同一个函数里。

原代码:

const basePrice = this._quantity * this._itemPrice; 
if (basePrice > 1000)
  return basePrice * 0.95; 
else
  return basePrice * 0.98;

重构后的代码:

get basePrice() {this._quantity * this._itemPrice;}

...

if (this.basePrice > 1000) 
  return this.basePrice * 0.95;
else
  return this.basePrice * 0.98;

4.5 提炼类(extract class)

设想你有一个维护大量函数和数据的类。这样的类往往因为太大而不易理解。此时你需要考虑哪些部分可以分离出去,并将它们分离到一个独立的类中。如果某些数据和某些函数总是一起出现,某些数据经常同时变化甚至彼此相依,这就表示你应该将它们分离出去。

另一个往往在开发后期出现的信号是类的子类化方式。如果你发现子类化只影响类的部分特性,或如果你发现某些特性需要以一种方式来子类化,某些特性则需要以另一种方式子类化,这就意味着你需要分解原来的类。

原代码:

class Person {
 get officeAreaCode() {return this._officeAreaCode;} 
 get officeNumber()   {return this._officeNumber;}
}

重构后的代码:

class Person {
 get officeAreaCode() {return this._telephoneNumber.areaCode;} 
 get officeNumber()   {return this._telephoneNumber.number;}
}
class TelephoneNumber {
 get areaCode() {return this._areaCode;} 
 get number()   {return this._number;}
}

4.6 内联类(inline class)

内联类正好与提炼类相反。如果一个类不再承担足够责任,不再有单独存在的理由(这通常是因为此前的重构动作移走了这个类的责任),那么就应该将这个类内联到其他类中。

原代码:

class Person {
 get officeAreaCode() {return this._telephoneNumber.areaCode;} 
 get officeNumber()  {return this._telephoneNumber.number;}
}
class TelephoneNumber {
 get areaCode() {return this._areaCode;} 
 get number() {return this._number;}
}

重构后的代码:

class Person {
 get officeAreaCode() {return this._officeAreaCode;} 
 get officeNumber()  {return this._officeNumber;}
}

4.7 隐藏委托关系(hide delegate)

如果某些客户端先通过服务对象的字段得到另一个对象(受托类),然后调用后者的函数,那么客户端就必须知晓这一层委托关系。万一受托类修改了接口,变化会波及通过服务对象使用它的所有客户端。我可以在服务对象上放置一个简单的委托函数,将委托关系隐藏起来,从而去除这种依赖。

原代码:

manager = aPerson.department.manager;

重构后的代码:

manager = aPerson.manager; 

class Person {
  get manager() {return this.department.manager;}
}

4.8 移除中间人(remove middle man)

在隐藏委托关系中,谈到了“封装受托对象”的好处。但是这层封装也是有代价的。每当客户端要使用受托类的新特性时,你就必须在服务端添加一个简单委托函数。随着受托类的特性(功能)越来越多,更多的转发函数就会使人烦躁。服务类完全变成了一个中间人,此时就应该让客户直接调用受托类。

注意合适的隐藏程度。

原代码:

manager = aPerson.manager; 

class Person {
 get manager() {return this.department.manager;}

重构后的代码:

manager = aPerson.department.manager;

4.9 替换算法(substitute algorithm)

如果我发现做一件事可以有更清晰的方式,我就会用比较清晰的方式取代复杂的方式。

如果我开始使用程序库,而其中提供的某些功能/特性与我自己的代码重复,那么我也需要改变原先的算法。

原代码:

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)) || '';
}

5. 搬移特性

5.1 搬移函数(move function)

为了设计出高度模块化的程序,我得保证互相关联的软件要素都能集中到一块,并确保块与块之间的联系易于查找、直观易懂。

搬移函数最直接的一个动因是,它频繁引用其他上下文中的元素,而对自身上下文中的元素却关心甚少。

如果我在整理代码时,发现需要频繁调用一个别处的函数,我也会考虑搬移这个函数。

原代码:

class Account {
 get overdraftCharge() {...}
}

重构后的代码:

class AccountType {
    get overdraftCharge() {...}
}    

5.2 搬移字段

每当调用某个函数时,除了传入一个记录参数,还总是需要同时传入另一条记录的某个字段一起作为参数。总是一同出现、一同作为函数参数传递的数据,最好是规整到同一条记录中,以体现它们之间的联系。

原代码:

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;}
}

5.3 搬移语句到函数(move statements into function)

要维护代码库的健康发展,需要遵守几条黄金守则,其中最重要的一条当属“消除重复”。如果我发现调用某个函数时,总有一些相同的代码也需要每次执行,那么我会考虑将此段代码合并到函数里头。

如果某些语句与一个函数放在一起更像一个整体,并且更有助于理解,那我就会毫不犹豫地将语句搬移到函数里去。

原代码:

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>`,
 ];
}

5.4 搬移语句到调用者(move statements to calles)

函数边界发生偏移的一个征兆是,以往在多个地方共用的行为,如今需要在某些调用点面前表现出不同的行为。于是,我们得把表现不同的行为从函数里挪出,并搬移到其调用处。

原代码:

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`);
}

5.5 以函数调用取代内联代码(replace inline code with function call)

原代码:

let appliesToMass = false; 
for(const s of states) {
  if (s === "MA") appliesToMass = true;
}

重构后的代码:

appliesToMass = states.includes("MA");

5.6 移动语句(slide statements)

让存在关联的东西一起出现,可以使代码更容易理解。

关于变量的声明和使用。有人喜欢在函数顶部一口气声明函数用到的所有变量,有的则喜欢在第一次需要使用变量的地方再声明它。

原代码:

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.7 拆分循环(split loop)

如果你在一次循环中做了两件不同的事,那么每当需要修改循环时,你都得同时理解这两件事情。如果能够将循环拆分,让一个循环只做一件事情,那就能确保每次修改时你只需要理解要修改的那块代码的行为就可以了。

拆分循环还能让每个循环更容易使用。如果一个循环只计算一个值,那么它直接返回该值即可;但如果循环做了太多件事,那就只得返回结构型数据或者通过局部变量传值了。

原代码:

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;

5.8 以管道取代循环(replace loop with pipeline)

一些逻辑如果采用集合管道来编写,代码的可读性会更强——我只消从头到尾阅读一遍代码,就能弄清对象在管道中间的变换过程。

原代码:

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)
;

5.9 移除死代码(remove dead code)

但当你尝试阅读代码、理解软件的运作原理时,无用代码确实会带来很多额外的思维负担。一旦代码不再被使用,我们就该立马删除它。有可能以后又会需要这段代码,但我从不担心这种情况;就算真的发生,我也可以从版本控制系统里再次将它翻找出来。

原代码:

if(false) { 
  doSomethingThatUsedToMatter();
}

6. 重新组织数据

6.1 拆分变量(split variable)

如果变量承担多个责任,它就应该被替换(分解)为多个变量,每个变量只承担一个责任。同一个变量承担两件不同的事情,会令代码阅读者糊涂。

原代码:

let temp = 2 * (height + width); 
console.log(temp);
temp = height * width; 
console.log(temp);

重构后的代码:

const perimeter = 2 * (height + width); 
console.log(perimeter);
const area = height * width; 
console.log(area);

6.2 字段改名(rename field)

命名很重要,对于程序中广泛使用的记录结构,其中字段的命名格外重要。

命名的重要性。

原代码:

class Organization { 
  get name() {...}
}

重构后的代码:

class Organization { 
  get title() {...}
}

6.3 以查询取代派生变量(replace derived variable with query)

有些变量是可以通过其他变量计算出来的,这样我们就能去掉这些变量,避免源数据修改时忘了更新派生变量。

原代码:

get discountedTotal() {return this._discountedTotal;} 
set discount(aNumber) {
 const old = this._discount; 
 this._discount = aNumber; 
 this._discountedTotal += old - aNumber;
}

重构后的代码:

get discountedTotal() {return this._baseTotal - this._discount;} 
set discount(aNumber) {this._discount = aNumber;}

6.4 将引用对象改为值对象(change reference to value)

在把一个对象(或数据结构)嵌入另一个对象时,位于内部的这个对象可以被视为引用对象,也可以被视为值对象。

如果将内部对象视为引用对象,在更新其属性时,我会保留原对象不动,更新内部对象的属性;如果将其视为值对象,我就会替换整个内部对象,新换上的对象会有我想要的属性值。

Java中是值传递还是引用传递?Java中只有值传递。

原代码:

class Product {
  applyDiscount(arg) {this._price.amount -= arg;}
}

重构后的代码:

class Product { 
  applyDiscount(arg) {
    this._price = new Money(this._price.amount - arg, this._price.currency);
  }
}

6.5 将值对象改为引用对象(change value to reference)

把值对象改为引用对象会带来一个结果:对于一个客观实体,只有一个代表它的对象。

原代码:

let customer = new Customer(customerData);

重构后的代码:

let customer = customerRepository.get(customerData.id);

7. 简化条件逻辑

7.1 分解条件表达式(decompose conditional)

大型函数本身就会使代码的可读性下降,而条件逻辑则会使代码更难阅读。

和任何大块头代码一样,我可以将它分解为多个独立的函数,根据每个小块代码的用途,为分解而得的新函数命名,并将原函数中对应的代码改为调用新函数,从而更清楚地表达自己的意图。

原代码:

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();

7.2 合并条件表达式(consolidate conditional expression)

检查条件各不相同,最终行为却一致。如果发现这种情况,就应该使用“逻辑或”和“逻辑与”将它们合并为一个条件表达式。

原代码:

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));

7.3 以卫语句取代嵌套条件表达式(replace nested conditional with guard clauses)

如果某个条件极其罕见,就应该单独检查该条件,并在该条件为真时立刻从函数中返回。这样的单独检查常常被称为“卫语句”(guard clauses)。

误区:每个函数只有一个入口和一个出口。

原代码:

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();
}

7.4 以多态取代条件表达式(replace condtional with polymorphism)

复杂的条件逻辑是编程中最难理解的东西之一,使用类和多态能把逻辑拆分表述的更清晰。

另外,如果有一个基础逻辑,在其上又有一些变体。那么就可以把基础逻辑放进超类中。

原代码:

switch (bird.type) {
 case 'EuropeanSwallow': 
  return "average";
 case 'AfricanSwallow':
  return (bird.numberOfCoconuts > 2) ? "tired" : "average"; 
 case 'NorwegianBlueParrot':
  return (bird.voltage > 100) ? "scorched" : "beautiful"; 
 default:
  return "unknown";

重构后的代码:

class EuropeanSwallow { 
 get plumage() {
  return "average";
 }
}  
class AfricanSwallow { 
 get plumage() {
   return (this.numberOfCoconuts > 2) ? "tired" : "average";
 }
}  
class NorwegianBlueParrot { 
 get plumage() {
   return (this.voltage > 100) ? "scorched" : "beautiful";
}

7.5 引入特例对象(introduce special case)

有一种常见的重复代码的情况:一个数据结构的使用者都在检查某个特殊的值,并且当这个特殊值出现时所做的处理也都相同。处理这种情况的一个最好的办法就是使用特例模式:创建一个特殊的对象,用以表达对这种特例的共用行为的处理。

一种常见的特例对象是”Null对象“。

原代码:

if (aCustomer === "unknown") customerName = "occupant";

重构后的代码:

class UnknownCustomer {
  get name() {return "occupant";}
}  

7.6 引入断言(introduce assertion)

常常可能会出现这样一种代码:只有当某个条件为真,该段代码才会正常进行。但是,这样的假设通常并没有出现在代码中明确的表现出来,必须要阅读整个算法才能看出来假设的条件。

解决方案可能是写一段注释,但是还有更好的方案:使用断言明确标明这些假设。

断言的失败不应该被系统的任何地方捕获,整个程序的行为在有没有断言出现的时候都应该完全一样。

原代码:

if (this.discountRate)
  base = base - (this.discountRate * base);

重构后的代码:

assert(this.discountRate>= 0); 
if (this.discountRate)
  base = base - (this.discountRate * base);

8. 重构API

8.1 将查询函数和修改函数分离(separate query from modifier)

任何有返回值的函数,都不应该有看得到的副作用——命令与查询分离(Command-Query Separation)[mf-cqs]。

如果遇到一个“既有返回值又有副作用”的函数,我就会试着将查询动作从修改动作中分离出来。

原代码:

function getTotalOutstandingAndSendBill() {
  const result = customer.invoices.reduce((total, each) => each.amount + total, 0);
  sendBill();
  return result;
}

重构后的代码:

function totalOutstanding() {
  return customer.invoices.reduce((total, each) => each.amount + total, 0);
}
function sendBill() { 
  emailGateway.send(formatBill(customer));
}

8.2 函数参数化(parameterize function)

如果我发现两个函数逻辑非常相似,只有一些字面量值不同,可以将其合并成一个函数,以参数的形式传入不同的值,从而消除重复。

原代码:

function tenPercentRaise(aPerson) { 
  aPerson.salary = aPerson.salary.multiply(1.1);
}
function fivePercentRaise(aPerson) { 
  aPerson.salary = aPerson.salary.multiply(1.05);
}

重构后的代码:

function raise(aPerson, factor) {
  aPerson.salary = aPerson.salary.multiply(1 + factor);
}

8.3 移除标记参数(remove flag argument)

标记参数是这样的一种参数:调用者用它来指示被调函数应该执行哪一部分逻辑

如果调用者传入的是程序中流动的数据,这样的参数不算标记参数;只有调用者直接传入字面量值,这才是标记参数。另外,在函数实现内部,如果参数值只是作为数据传给其他函数,这就不是标记参数;只有参数值影响了函数内部的控制流,这才是标记参数。

原代码:

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;}

8.4 保持对象完整(preserve whole object)

“传递整个记录”的方式能更好地应对变化:如果将来被调的函数需要从记录中导出更多的数据,我就不用为此修改参数列表。并且传递整个记录也能缩短参数列表,让函数调用更容易看懂。

从一个对象中抽取出几个值,单独对这几个值做某些逻辑操作,这是一种代码坏味道(依恋情结),通常标志着这段逻辑应该被搬移到对象中。

原代码:

const low = aRoom.daysTempRange.low; 
const high = aRoom.daysTempRange.high; 
if (aPlan.withinRange(low, high))

重构后的代码:

if (aPlan.withinRange(aRoom.daysTempRange))

8.5 以查询取代参数(replace parameter with query)

参数列表应该尽量避免重复,并且参数列表越短就越容易理解。如果可以从一个参数推导出另一个参数,那么几乎没有任何理由要同时传递这两个参数。

原代码:

availableVacation(anEmployee, anEmployee.grade); 

function availableVacation(anEmployee, grade) {
  // calculate vacation...

重构后的代码:

availableVacation(anEmployee)

function availableVacation(anEmployee) { 
  const grade = anEmployee.grade;
  // calculate vacation...

8.6 以参数取代查询(replace query with parameter)

为了让目标函数不再依赖于某个元素,我把这个元素的值以参数形式传递给该函数。

如果把所有依赖关系都变成参数,会导致参数列表冗长重复;如果作用域之间的共享太多,又会导致函数间依赖过度。

原代码:

targetTemperature(aPlan)

function targetTemperature(aPlan) { 
  currentTemperature = thermostat.currentTemperature;
  // rest of function...

重构后的代码:

targetTemperature(aPlan, thermostat.currentTemperature) 

function targetTemperature(aPlan, currentTemperature) {
  // rest of function...

8.7 移除设值函数(remove setting method)

如果为某个字段提供了设值函数,这就暗示这个字段可以被改变。如果不希望在对象创建之后此字段还有机会被改变,那就不要为它提供设值函数(同时将该字段声明为不可变)。

同理,日常使用到的lombook也会破坏对象的封装性。

原代码:

class Person {
  get name() {...}
  set name(aString) {...}

重构后的代码:

class Person {
  get name() {...}
}

8.8 以工厂函数取代构造函数(replace constructor with factory function)

构造函数有非常多的局限性,比如只能返回当前调用类的实例,构造函数的名字是固定的。但是工厂函数则不受这些条件限制,工厂函数实现的内部可以调用构造函数,也可以调用其他方法。

原代码:

leadEngineer = new Employee(document.leadEngineer, 'E');

重构后的代码:

leadEngineer = createEngineer(document.leadEngineer);

8.9 以命名对象取代函数(replace function with command)

与普通的函数相比,命令对象提供了更大的控制灵活性和更强的表达能力。

如果没有合适的命名规范,就给命令对象中负责实际执行命令的函数起一个通用的名字,例如“execute”或者“call”。

原代码:

function score(candidate, medicalExam, scoringGuide) { 
  let result = 0;
  let healthLevel = 0;
  // long body code
}

重构后的代码:

class Scorer {
  constructor(candidate, medicalExam, scoringGuide) { 
    this._candidate = candidate;
    this._medicalExam = medicalExam; 
    this._scoringGuide = scoringGuide;
  }

  execute() { 
    this._result = 0;
    this._healthLevel = 0;
    // long body code
  }
}

8.10 以函数取代命名对象(replace command with function)

大多数时候,我只是想调用一个函数,让它完成自己的工作就好。如果这个函数不是太复杂,那么命令对象可能显得费而不惠,我就应该考虑将其变回普通的函数。

原代码:

class ChargeCalculator { 
 constructor (customer, usage){
  this._customer = customer; 
  this._usage = usage;
 }
 execute() {
  return this._customer.rate * this._usage;
 }
}

重构后的代码:

function charge(customer, usage) { 
  return customer.rate * usage;
}

9. 处理继承关系

9.1 函数上移(pull up method)

原代码:

class Employee {...}

class Salesman extends Employee { 
 get name() {...}
}

class Engineer extends Employee { 
 get name() {...}
}

重构后的代码:

class Employee { 
 get name() {...}
}

class Salesman extends Employee {...} 
class Engineer extends Employee {...}

9.2 字段上移(pull up field)

原代码:

class Employee {...}

class Salesman extends Employee {
 private String name;
}

class Engineer extends Employee { 
 private String name;
}

重构后的代码:

class Employee { 
 protected String name;
}

class Salesman extends Employee {...} 
class Engineer extends Employee {...}

9.3 构造函数本体上移(pull up constructor body)

原代码:

class Party {...}

class Employee extends Party { 
 constructor(name, id, monthlyCost) {
  super(); 
  this._id = id;
  this._name = name; 
  this._monthlyCost = monthlyCost;
 }
}

重构后的代码:

class Party { 
 constructor(name){
  this._name = name;
 }
}

class Employee extends Party { 
 constructor(name, id, monthlyCost) {
  super(name); 
  this._id = id;
  this._monthlyCost = monthlyCost;
 }
}

9.4 函数下移(push down method)

如果超类中的某个函数只与一个(或少数几个)子类有关,那么最好将其从超类中挪走,放到真正关心它的子类中去。

原代码:

class Employee { 
  get quota {...}
}

class Engineer extends Employee {...} 
class Salesman extends Employee {...}

重构后的代码:

class Employee {...}
class Engineer extends Employee {...} 
class Salesman extends Employee {
  get quota {...}
}

9.5 字段下移(push down field)

如果某个字段只被一个子类(或者一小部分子类)用到,就将其搬移到需要该字段的子类中。

原代码:

class Employee {
 private String quota;
}

class Engineer extends Employee {...} 
class Salesman extends Employee {...}

重构后的代码:

class Employee {...}
class Engineer extends Employee {...}

class Salesman extends Employee { 
 protected String quota;
}

9.6 以子类取代类型码(Replace Type Code with Subclasses)

引入子类可以使用多态处理条件逻辑。

原代码:

function createEmployee(name, type) { 
  return new Employee(name, type);
}

重构后的代码:

function createEmployee(name, type) { 
  switch (type) {
    case "engineer": return new Engineer(name); 
    case "salesman": return new Salesman(name); 
    case "manager": return new Manager (name);
}

9.7 移除子类(remove subclass)

子类很有用,它们为数据结构的多样和行为的多态提供支持,它们是针对差异编程的好工具。但随着软件的演化,子类所支持的变化可能会被搬移到别处,甚至完全去除,这时子类就失去了价值。

原代码:

class Person {
 get genderCode() {return "X";}
}
class Male extends Person {
 get genderCode() {return "M";}
}
class Female extends Person { 
 get genderCode() {return "F";}
}

重构后的代码:

class Person {
  get genderCode() {return this._genderCode;}
}

9.8 提炼超类(extract superClass)

如果我看见两个类在做相似的事,可以利用基本的继承机制把它们的相似之处提炼到超类。

原代码:

class Department {
 get totalAnnualCost() {...} 
 get name() {...}
 get headCount() {...}
}

class Employee {
 get annualCost() {...}
 get name() {...}
 get id() {...}
}

重构后的代码:

class Party {
 get name() {...}
 get annualCost() {...}
}

class Department extends Party { 
 get annualCost() {...}
 get headCount() {...}
}

class Employee extends Party { 
 get annualCost() {...}
 get id() {...}
}

9.9 折叠继承关系(Collapse Hierarchy)

随着继承体系的演化,我有时会发现一个类与其超类已经没多大差别,不值得再作为独立的类存在。此时我就会把超类和子类合并起来。

那么,移除的类是子类还是超类呢?我选择的依据是看哪个类的名字放在未来更有意义。如果两个名字都不够好,我就随便挑一个。

原代码:

class Employee {...}
class Salesman extends Employee {...}

重构后代码:

class Employee {...}

9.10 以委托取代子类(replace subclass with delegate)

但继承也有其短板。最明显的是,继承这张牌只能打一次。导致行为不同的原因可能有多种,但继承只能用于处理一个方向上的变化。

同时,继承给类之间引入了非常紧密的关系。在超类上做任何修改,都很可能破坏子类。

这两个问题用委托都能解决。对于不同的变化原因,我可以委托给不同的类。委托是对象之间常规的关系。与继承关系相比,使用委托关系时接口更清晰、耦合更少。因此,继承关系遇到问题时运用以委托取代子类是常见的情况。

组合优先于继承。

原代码:

class Order {
 get daysToShip() {
  return this._warehouse.daysToShip;
 }
}

class PriorityOrder extends Order { 
 get daysToShip() {
  return this._priorityPlan.daysToShip;
 }
}

重构后的代码:

class Order {
 get daysToShip() {
  return (this._priorityDelegate)
   ? this._priorityDelegate.daysToShip
   : this._warehouse.daysToShip;
 }
}

class PriorityOrderDelegate { 
 get daysToShip() {
  return this._priorityPlan.daysToShip
 }
}

9.11 以委托取代超类(Replace Superclass with Delegate)

举个例子,有一个经典误用继承的例子:让栈继承列表。列表类的所有操作都会出现在栈类的接口上,然而其中大部分操作对一个栈来说并不适用。更好的做法应该是把列表作为栈的字段,把必要的操作委派给列表就行了。

子类的所有实例都应该是超类的实例,通过超类的接口来使用子类的实例应该完全不出问题。

原代码:

class List {...}
class Stack extends List {...}

重构后的代码:

class Stack { 
  constructor() {
    this._storage = new List();
  }
}
class List {...}

最后更新于