最常用到的重构就是用提炼函数(106)将代码提炼到函数中,或者用提炼变量(119)来提炼变量。既然重构的作用就是应对变化,你应该不会感到惊讶,我也经常使用这两个重构的反向重构——内联函数(115)和内联变量(123)。
提炼的关键就在于命名,随着理解的加深,我经常需要改名。改变函数声明(124)可以用于修改函数的名字,也可以用于添加或删减参数。变量也可以用变量改名(137)来改名,不过需要先做封装变量(132)。在给函数的形式参数改名时,不妨先用引入参数对象(140)把常在一起出没的参数组合成一个对象。
形成函数并给函数命名,这是低层级重构的精髓。有了函数以后,就需要把它们组合成更高层级的模块。我会使用函数组合成类(144),把函数和它们操作的数据一起组合成类。另一条路径是用函数组合成变换(149)将函数组合成变换式(transform),这对于处理只读数据尤为利。再往前一步,常常可以用拆分阶段(154)将这些模块组成界限分明的处理阶段。
6.1 提炼函数
何时应该把代码放进独立的函数?
“将意图与实现分开”:如果你需要花时间浏览一段代码才能弄清它到底在干什么,那么就应该将其提炼到一个函数中,并根据它所做的事为其命名。以后再读到这段代码时,你一眼就能看到函数的用途,大多数时候根本不需要关心函数如何达成其用途(这是函数体内干的事)。
创造一个新函数,根据这个函数的意图来对它命名(以它“做什么”来命名,而不是以它“怎样做”命名)。
一旦接受了这个原则,我就逐渐养成一个习惯:写非常小的函数——通常只有几行的长度。在我看来,一个函数一旦超过6行,就开始散发臭味。
有些人担心短函数会造成大量函数调用,因而影响性能。在我尚且年轻时,有时确实会有这个问题;但如今“由于函数调用影响性能”的情况已经非常罕见了。短函数常常能让编译器的优化功能运转更良好,因为短函数可以更容易地被缓存。所以,应该始终遵循性能优化的一般指导方针,不用过早担心性能问题。
6.1.1 范例:无局部变量
function printOwing(invoice) {
let outstanding = 0;
console.log("***********************");
console.log("**** Customer Owes ****");
console.log("***********************");
// 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) {
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 printBanner() {
console.log("***********************");
console.log("**** Customer Owes ****");
console.log("***********************");
}
同样,我还可以把“打印详细信息”部分也提炼出来:
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);
printDetails();
function printDetails()
{
console.log(`name: ${invoice.customer}`);
console.log(`amount: ${outstanding}`);
console.log(`due: ${invoice.dueDate.toLocaleDateString()}`);
}
6.1.2 范例:有局部变量
局部变量最简单的情况是:被提炼代码段只是读取这些变量的值,并不修改它们。这种情况下我可以简单地将它们当作参数传给目标函数。所以,如果我面对下列函数:
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) {
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);
printDetails(invoice, outstanding);
}
function printDetails(invoice, outstanding) {
console.log(`name: ${invoice.customer}`);
console.log(`amount: ${outstanding}`);
console.log(`due: ${invoice.dueDate.toLocaleDateString()}`);
}
如果局部变量是一个数据结构(例如数组、记录或者对象),而被提炼代码段又修改了这个结构中的数据,也可以如法炮制。所以,“设置到期日”的逻辑也可以用同样的方式提炼出来:
function printOwing(invoice) {
let outstanding = 0;
printBanner();
// calculate outstanding
for (const o of invoice.orders) {
outstanding += o.amount;
}
recordDueDate(invoice);
printDetails(invoice, outstanding);
}
function recordDueDate(invoice) {
const today = Clock.today;
invoice.dueDate = new Date(today.getFullYear(), today.getMonth
(), today.getDate() + 30);
}
6.1.3 范例:对局部变量再赋值
function printOwing(invoice) {
let outstanding = 0;
printBanner();
// calculate outstanding
for (const o of invoice.orders) {
outstanding += o.amount;
}
recordDueDate(invoice);
printDetails(invoice, outstanding);
}
首先,把变量声明移动到使用处之前。
使用 移动语句 的手法
function printOwing(invoice) {
printBanner();
// calculate outstanding
let outstanding = 0;
for (const o of invoice.orders) {
outstanding += o.amount;
}
recordDueDate(invoice);
printDetails(invoice, outstanding);
}
然后把想要提炼的代码复制到目标函数中。
function printOwing(invoice) {
printBanner();
// calculate outstanding
let outstanding = 0;
for (const o of invoice.orders) {
outstanding += o.amount;
}
recordDueDate(invoice);
printDetails(invoice, outstanding);
}
function calculateOutstanding(invoice) {
let outstanding = 0;
for (const o of invoice.orders) {
outstanding += o.amount;
}
return outstanding;
}
由于outstanding变量的声明已经被搬移到提炼出的新函数中,就不需要再将其作为参数传入了。outstanding是提炼代码段中唯一被重新赋值的变量,所以我可以直接返回它。
下一件事是修改原来的代码,令其调用新函数。新函数返回了修改后的outstanding变量值,我需要将其存入原来的变量中。
function printOwing(invoice) {
printBanner();
let 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 printOwing(invoice) {
printBanner();
const outstanding = calculateOutstanding(invoice);
recordDueDate(invoice);
printDetails(invoice, outstanding);
}
function calculateOutstanding(invoice) {
let result = 0;
for (const o of invoice.orders) {
result += o.amount;
}
return result;
}
6.2 内联函数
function getRating(driver)
{
return moreThanFiveLateDeliveries(driver) ? 2 : 1;
}
function moreThanFiveLateDeliveries(driver)
{
return driver.numberOfLateDeliveries > 5;
}
function getRating(driver)
{
return (driver.numberOfLateDeliveries > 5) ? 2 : 1;
}
函数内部代码和函数名称同样清晰易读。
本书经常以简短的函数表现动作意图,这样会使代码更清晰易读。但有时候你会遇到某些函数,其内部代码和函数名称同样清晰易读。也可能你重构了该函数的内部实现,使其内容和其名称变得同样清晰。若果真如此,你就应该去掉这个函数,直接使用其中的代码。间接性可能带来帮助,但非必要的间接性总是让人不舒服。
另一种需要使用内联函数的情况是:我手上有一群组织不甚合理的函数。可以将它们都内联到一个大型函数中,再以我喜欢的方式重新提炼出小函数。
如果代码中有太多间接层,使得系统中的所有函数都似乎只是对另一个函数的简单委托,造成我在这些委托动作之间晕头转向,那么我通常都会使用内联函数。当然,间接层有其价值,但不是所有间接层都有价值。通过内联手法,我可以找出那些有用的间接层,同时将无用的间接层去除。
6.3 提炼变量(Extract Variable)
反向重构:内联变量(123)
动机
表达式有可能非常复杂而难以阅读。这种情况下,局部变量可以帮助我们将表达式分解为比较容易管理的形式。在面对一块复杂逻辑时,局部变量使我能给其中的一部分命名,这样我就能更好地理解这部分逻辑是要干什么。这样的变量在调试时也很方便,它们给调试器和打印语句提供了便利的抓手。
做法
a. 确认要提炼的表达式没有副作用。
b. 声明一个不可修改的变量,把你想要提炼的表达式复制一份,以该表达式的结果值给这个变量赋值。
c. 用这个新变量取代原来的表达式。
d. 测试。
如果该表达式出现了多次,请用这个新变量逐一替换,每次替换之后都要执行测试。
范例:在一个类中
下面是同样的代码,但这次它位于一个类中:
我要提炼的还是同样的变量,但我意识到:这些变量名所代表的概念,适用于整个Order类,而不仅仅是“计算价格”的上下文。既然如此,我更愿意将它们提炼成方法,而不是变量。
这是对象带来的一大好处:它们提供了合适的上下文,方便分享相关的逻辑和数据。在如此简单的情况下,这方面的好处还不太明显;但在一个更大的类当中,如果能找出可以共用的行为,赋予它独立的概念抽象,给它起一个好名字,对于使用对象的人会很有帮助。
6.4 内联变量(Inline Variable)
反向重构:提炼变量(119)
动机
在一个函数内部,变量能给表达式提供有意义的名字,因此通常变量是好东西。但有时候,这个名字并不比表达式本身更具表现力。还有些时候,变量可能会妨碍重构附近的代码。若果真如此,就应该通过内联的手法消除变量。
做法
a. 检查确认变量赋值语句的右侧表达式没有副作用。
b. 如果变量没有被声明为不可修改,先将其变为不可修改,并执行测试。
这是为了确保该变量只被赋值一次。
c. 找到第一处使用该变量的地方,将其替换为直接使用赋值语句的右侧表达式。
d. 测试。
e. 重复前面两步,逐一替换其他所有使用该变量的地方。
f. 删除该变量的声明点和赋值语句。
g. 测试。
6.5 改变函数声明
auto circum(double radius){ … }
Auto circumference(double radius){ … }
一个好名字能让我一眼看出函数的用途,而不必查看其实现代码。但起一个好名字并不容易,有一个改进函数名字的好办法:先写一句注释描述这个函数的用途,再把这句注释变成函数的名字。
对于函数的参数,道理也是一样。函数的参数列表阐述了函数如何与外部世界共处。函数的参数设置了一个上下文,只有在这个上下文中,我才能使用这个函数。假如有一个函数的用途是把某人的电话号码转换成特定的格式,并且该函数的参数是一个人(person),那么我就没法用这个函数来处理公司(company)的电话号码。如果我把函数接受的参数由“人”改成“电话号码”,这段处理电话号码格式的代码就能被更广泛地使用。
修改参数列表不仅能增加函数的应用范围,还能改变连接一个模块所需的条件,从而去除不必要的耦合。
6.6 封装变量[封装到函数,通过函数操作]
重构的作用就是调整程序中的元素。函数相对容易调整一些,因为函数只有一种用法,就是调用。在改名或搬移函数的过程中,总是可以比较容易地保留旧函数作为转发函数(即旧代码调用旧函数,旧函数再调用新函数)。这样的转发函数通常不会存在太久,但的确能够简化重构过程。
数据就要麻烦得多,因为没办法设计这样的转发机制。如果我把数据搬走,就必须同时修改所有引用该数据的代码,否则程序就不能运行。如果数据的可访问范围很小,比如一个小函数内部的临时变量,那还不成问题。但如果可访问范围变大,重构的难度就会随之增大,这也是说全局数据是大麻烦的原因。
所以,如果想要搬移一处被广泛使用的数据,最好的办法往往是先以函数形式封装所有对该数据的访问。这样,我就能把“重新组织数据”的困难任务转化为“重新组织函数”这个相对简单的任务。
封装数据的价值还不止于此。封装能提供一个清晰的观测点,可以由此监控数据的变化和使用情况;我还可以轻松地添加数据被修改时的验证或后续逻辑。我的习惯是:对于所有可变的数据,只要它的作用域超出单个函数,我就会将其封装起来,只允许通过函数访问。数据的作用域越大,封装就越重要。
范例
下面这个全局变量中保存了一些有用的数据:
let defaultOwner = {firstName: "Martin", lastName: "Fowler"};
使用它的代码平淡无奇:
spaceship.owner = defaultOwner;
更新这段数据的代码是这样:
defaultOwner = {firstName: "Rebecca", lastName: "Parsons"};
首先我要定义读取和写入这段数据的函数,给它做个基础的封装。
function getDefaultOwner() {return defaultOwner;}
function setDefaultOwner(arg) {defaultOwner = arg;}
然后就开始处理使用defaultOwner的代码。每看见一处引用该数据的代码,就将其改为调用取值函数。
spaceship.owner = getDefaultOwner();
每看见一处给变量赋值的代码,就将其改为调用设
值函数。
setDefaultOwner({firstName: "Rebecca", lastName: "Parsons"});
6.7 变量改名
好的命名是整洁编程的核心。变量可以很好地解释一段程序在干什么——如果变量名起得好的。使用范围越广,名字的好坏就越重要。
6.8 引入参数对象
我常会看见,一组数据项总是结伴同行,出没于一个又一个函数。这样一组数据就是所谓的数据泥团。
将数据组织成结构是一件有价值的事,因为这让数据项之间的关系变得明晰。使用新的数据结构,参数的参数列表也能缩短。并且经过重构之后,所有使用该数据结构的函数都会通过同样的名字来访问其中的元素,从而提升代码的一致性。
可能只是一组共用的函数,也可能用一个类把数据结构与使用数据的函数组合起来。
6.9 函数组合成类
类,在大多数现代编程语言中都是基本的构造。它们把数据与函数捆绑到同一个环境中,将一部分数据与函数暴露给其他程序元素以便协作。它们是面向对象语言的首要构造,在其他程序设计方法中也同样有用。
如果发现一组函数形影不离地操作同一块数据(通常是将这块数据作为参数传递给函数),我就认为,是时候组建一个类了。类能明确地给这些函数提供一个共用的环境,在对象内部调用这些函数可以少传许多参数,从而简化函数调用,并且这样一个对象也可以更方便地传递给系统的其他部分。
6.10 函数组合成变换
函数组合成变换的替代方案是函数组合成类(144),后者的做法是先用源数据创建一个类,再把相关的计算逻辑搬移到类中。这两个重构手法都很有用,我常会根据代码库中已有的编程风格来选择使用其中哪一个。不过,两者有一个重要的区别:如果代码中会对源数据做更新,那么使用类要好得多;如果使用变换,派生数据会被存储在新生成的记录中,一旦源数据被修改,我就会遭遇数据不一致。
6.11 拆分阶段
每当看见一段代码在同时处理两件不同的事,我就想把它拆分成各自独立的模块,因为这样到了需要修改的时候,我就可以单独处理每个主题,而不必同时在脑子里考虑两个不同的主题。如果运气够好的话,我可能只需要修改其中一个模块,完全不用回忆起另一个模块的诸般细节。