重构: 改善既有代码设计 - 第二版 第6--10章
重构: 改善既有代码设计 - 第二版 第6–10章
下载地址:https://wwbf.lanzouw.com/iKbPZ2dpsxmj
第 6 章 第一组重构
在重构名录的开头,我首先介绍一组我认为最有用的重构。
我最常用到的重构就是用提炼函数(106)将代码提炼到函数中,或者用提炼变量(119)来提炼变量。既然重构的作用就是应对变化,你应该不会感到惊讶,我也经常使用这两个重构的反向重构——内联函数(115)和内联变量(123)。
提炼的关键就在于命名,随着理解的加深,我经常需要改名。改变函数声明(124)可以用于修改函数的名字,也可以用于添加或删减参数。变量也可以用变量改名(137)来改名,不过需要先做封装变量(132)。在给函数的形式参数改名时,不妨先用引入参数对象(140)把常在一起出没的参数组合成一个对象。
形成函数并给函数命名,这是低层级重构的精髓。有了函数以后,就需要把它们组合成更高层级的模块。我会使用函数组合成类(144),把函数和它们操作的数据一起组合成类。另一条路径是用函数组合成变换(149)将函数组合成变换式(transform),这对于处理只读数据尤为便利。再往前一步,常常可以用拆分阶段(154)将这些模块组成界限分明的处理阶段。
6.1 提炼函数(Extract Function)
曾用名:提炼函数(Extract Method)
反向重构:内联函数(115)
function printOwing(invoice) {printBanner();let outstanding = calculateOutstanding();//print detailsconsole.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}`);}
}
动机
提炼函数是我最常用的重构之一。(在这儿我用了“函数/function”这个词,但换成面向对象语言中的“方法/method”,或者其他任何形式的“过程/procedure”或者“子程序/subroutine”,也同样适用。)我会浏览一段代码,理解其作用,然后将其提炼到一个独立的函数中,并以这段代码的用途为这个函数命名。
对于“何时应该把代码放进独立的函数”这个问题,我曾经听过多种不同的意见。有的观点从代码的长度考虑,认为一个函数应该能在一屏中显示。有的观点从复用的角度考虑,认为只要被用过不止一次的代码,就应该单独放进一个函数;只用过一次的代码则保持内联(inline)的状态。但我认为最合理的观点是“将意图与实现分开”:如果你需要花时间浏览一段代码才能弄清它到底在干什么,那么就应该将其提炼到一个函数中,并根据它所做的事为其命名。以后再读到这段代码时,你一眼就能看到函数的用途,大多数时候根本不需要关心函数如何达成其用途(这是函数体内干的事)。
一旦接受了这个原则,我就逐渐养成一个习惯:写非常小的函数——通常只有几行的长度。在我看来,一个函数一旦超过 6 行,就开始散发臭味。我甚至经常会写一些只有 1 行代码的函数。Kent Beck 曾向我展示最初的 Smalltalk 系统中的一个例子,从那时起我就接受了“函数名的长度不重要”的观念。那时运行 Smalltalk 的计算机只有黑白屏显示器,如果你想高亮突显某些文本或图像,就需要反转视频的显示。为此,Smalltalk 用于控制图像显示的类有一个叫作 highlight 的方法,其中的实现就只是调用 reverse 方法。在这个例子里,highlight 方法的名字比实现还长,但这并不重要,因为在这个方法中,代码的意图与实现之间有着相当大的距离。
有些人担心短函数会造成大量函数调用,因而影响性能。在我尚且年轻时,有时确实会有这个问题;但如今“由于函数调用影响性能”的情况已经非常罕见了。短函数常常能让编译器的优化功能运转更良好,因为短函数可以更容易地被缓存。所以,应该始终遵循性能优化的一般指导方针,不用过早担心性能问题。
小函数得有个好名字才行,所以你必须在命名上花心思。起好名字需要练习,不过一旦你掌握了其中的技巧,就能写出很有自描述性的代码。
我经常会看见这样的情况:在一个大函数中,一段代码的顶上放着一句注释,说明这段代码要做什么。在把这段代码提炼到自己的函数中时,这样的注释往往会提示一个好名字。
做法
- 创造一个新函数,根据这个函数的意图来对它命名(以它“做什么”来命名,而不是以它“怎样做”命名)。
Tip
如果想要提炼的代码非常简单,例如只是一个函数调用,只要新函数的名称能够以更好的方式昭示代码意图,我还是会提炼它;但如果想不出一个更有意义的名称,这就是一个信号,可能我不应该提炼这块代码。不过,我不一定非得马上想出最好的名字,有时在提炼的过程中好的名字才会出现。有时我会提炼一个函数,尝试使用它,然后发现不太合适,再把它内联回去,这完全没问题。只要在这个过程中学到了东西,我的时间就没有白费。
如果编程语言支持嵌套函数,就把新函数嵌套在源函数里,这能减少后面需要处理的超出作用域的变量个数。我可以稍后再使用搬移函数(198)把它从源函数中搬移出去。
- 将待提炼的代码从源函数复制到新建的目标函数中。
- 仔细检查提炼出的代码,看看其中是否引用了作用域限于源函数、在提炼出的新函数中访问不到的变量。若是,以参数的形式将它们传递给新函数。
Tip
如果提炼出的新函数嵌套在源函数内部,就不存在变量作用域的问题了。
这些“作用域限于源函数”的变量通常是局部变量或者源函数的参数。最通用的做法是将它们都作为参数传递给新函数。只要没在提炼部分对这些变量赋值,处理起来就没什么难度。
如果某个变量是在提炼部分之外声明但只在提炼部分被使用,就把变量声明也搬移到提炼部分代码中去。
如果变量按值传递给提炼部分又在提炼部分被赋值,就必须多加小心。如果只有一个这样的变量,我会尝试将提炼出的新函数变成一个查询(query),用其返回值给该变量赋值。
但有时在提炼部分被赋值的局部变量太多,这时最好是先放弃提炼。这种情况下,我会考虑先使用别的重构手法,例如拆分变量(240)或者以查询取代临时变量(178),来简化变量的使用情况,然后再考虑提炼函数。
- 所有变量都处理完之后,编译。
Tip
如果编程语言支持编译期检查的话,在处理完所有变量之后做一次编译是很有用的,编译器经常会帮你找到没有被恰当处理的变量。
- 在源函数中,将被提炼代码段替换为对目标函数的调用。
- 测试。
- 查看其他代码是否有与被提炼的代码段相同或相似之处。如果有,考虑使用以函数调用取代内联代码(222)令其调用提炼出的新函数。
Tip
有些重构工具直接支持这一步。如果工具不支持,可以快速搜索一下,看看别处是否还有重复代码。
范例:无局部变量
在最简单的情况下,提炼函数易如反掌。请看下列函数:
function printOwing(invoice) {let outstanding = 0;console.log("***********************");console.log("**** Customer Owes ****");console.log("***********************");// calculate outstandingfor (const o of invoice.orders) {outstanding += o.amount;}// record due dateconst today = Clock.today;invoice.dueDate = new Date(today.getFullYear(),today.getMonth(),today.getDate() + 30);//print detailsconsole.log(`name: ${invoice.customer}`);console.log(`amount: ${outstanding}`);console.log(`due: ${invoice.dueDate.toLocaleDateString()}`);
}
你可能会好奇 Clock.today 是干什么的。这是一个 Clock Wrapper[mf-cw],也就是封装系统时钟调用的对象。我尽量避免在代码中直接调用 Date.now()这样的函数,因为这会导致测试行为不可预测,以及在诊断故障时难以复制出错时的情况。
我们可以轻松提炼出“打印横幅”的代码。我只需要剪切、粘贴再插入一个函数调用动作就行了:
function printOwing(invoice) {let outstanding = 0;printBanner();// calculate outstandingfor (const o of invoice.orders) {outstanding += o.amount;}// record due dateconst today = Clock.today;invoice.dueDate = new Date(today.getFullYear(),today.getMonth(),today.getDate() + 30);//print detailsconsole.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 outstandingfor (const o of invoice.orders) {outstanding += o.amount;}// record due dateconst 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()}`);
}
看起来提炼函数是一个极其简单的重构。但很多时候,情况会变得比较复杂。
在上面的例子中,我把 printDetails 函数嵌套在 printOwing 函数内部,这样前者就能访问到 printOwing 内部定义的所有变量。如果我使用的编程语言不支持嵌套函数,就没法这样操作了,那么我就要面对“提炼出一个顶层函数”的问题。此时我必须细心处理“只存在于源函数作用域”的变量,包括源函数的参数以及源函数内部定义的临时变量。
范例:有局部变量
局部变量最简单的情况是:被提炼代码段只是读取这些变量的值,并不修改它们。这种情况下我可以简单地将它们当作参数传给目标函数。所以,如果我面对下列函数:
function printOwing(invoice) {let outstanding = 0;printBanner();// calculate outstandingfor (const o of invoice.orders) {outstanding += o.amount;}// record due dateconst today = Clock.today;invoice.dueDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 30);//print detailsconsole.log(`name: ${invoice.customer}`);console.log(`amount: ${outstanding}`);console.log(`due: ${invoice.dueDate.toLocaleDateString()}`);
就可以将“打印详细信息”这一部分提炼为带两个参数的函数:
function printOwing(invoice) {let outstanding = 0;printBanner();// calculate outstandingfor (const o of invoice.orders) {outstanding += o.amount;}// record due dateconst 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 outstandingfor (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);
}
范例:对局部变量再赋值
如果被提炼代码段对局部变量赋值,问题就变得复杂了。这里我们只讨论临时变量的问题。如果你发现源函数的参数被赋值,应该马上使用拆分变量(240)将其变成临时变量。
被赋值的临时变量也分两种情况。较简单的情况是:这个变量只在被提炼代码段中使用。若果真如此,你可以将这个临时变量的声明移到被提炼代码段中,然后一起提炼出去。如果变量的初始化和使用离得有点儿远,可以用移动语句(223)把针对这个变量的操作放到一起。
比较糟糕的情况是:被提炼代码段之外的代码也使用了这个变量。此时我需要返回修改后的值。我会用下面这个已经很眼熟的函数来展示该怎么做:
function printOwing(invoice) {let outstanding = 0;printBanner();// calculate outstandingfor (const o of invoice.orders) {outstanding += o.amount;}recordDueDate(invoice);printDetails(invoice, outstanding);
}
前面的重构我都一步到位地展示了结果,因为它们都很简单。但这次我会一步一步展示“做法”里的每个步骤。
首先,把变量声明移动到使用处之前。
function printOwing(invoice) {printBanner();// calculate outstandinglet outstanding = 0;for (const o of invoice.orders) {outstanding += o.amount;}recordDueDate(invoice);printDetails(invoice, outstanding);
}
然后把想要提炼的代码复制到目标函数中。
function printOwing(invoice) {printBanner();// calculate outstandinglet 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 是提炼代码段中唯一被重新赋值的变量,所以我可以直接返回它。
我的 JavaScript 环境在编译期提供不了任何价值——简直还不如文本编辑器的语法分析有用,所以“做法”里的“编译”一步可以跳过了。下一件事是修改原来的代码,令其调用新函数。新函数返回了修改后的 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;
}
我还顺手把原来的 outstanding 变量声明成 const 的,令其在初始化之后不能再次被赋值。
这时候,你可能会问:“如果需要返回的变量不止一个,又该怎么办呢?”
有几种选择。最好的选择通常是:挑选另一块代码来提炼。我比较喜欢让每个函数都只返回一个值,所以我会安排多个函数,用以返回多个值。如果真的有必要提炼一个函数并返回多个值,可以构造并返回一个记录对象—不过通常更好的办法还是回过头来重新处理局部变量,我常用的重构手法有以查询取代临时变量(178)和拆分变量(240)。
如果我想把提炼出的函数搬移到别的上下文(例如变成顶层函数),会引发一些有趣的问题。我偏好小步前进,所以我本能的做法是先提炼成嵌套函数,然后再将其移入新的上下文。但这种做法的麻烦在于处理局部变量,而这个困难无法提前发现,直到我开始最后的搬移时才突然暴露。从这个角度考虑,即便可以先提炼成嵌套函数,或许也应该至少将目标函数放在源函数的同级,这样我就能立即看出提炼的范围是否合理。
6.2 内联函数(Inline Function)
曾用名:内联函数(Inline Method)
反向重构:提炼函数(106)
function getRating(driver) {return moreThanFiveLateDeliveries(driver) ? 2 : 1;
}function moreThanFiveLateDeliveries(driver) {return driver.numberOfLateDeliveries > 5;
}function getRating(driver) {return (driver.numberOfLateDeliveries > 5) ? 2 : 1;
}
动机
本书经常以简短的函数表现动作意图,这样会使代码更清晰易读。但有时候你会遇到某些函数,其内部代码和函数名称同样清晰易读。也可能你重构了该函数的内部实现,使其内容和其名称变得同样清晰。若果真如此,你就应该去掉这个函数,直接使用其中的代码。间接性可能带来帮助,但非必要的间接性总是让人不舒服。
另一种需要使用内联函数的情况是:我手上有一群组织不甚合理的函数。可以将它们都内联到一个大型函数中,再以我喜欢的方式重新提炼出小函数。
如果代码中有太多间接层,使得系统中的所有函数都似乎只是对另一个函数的简单委托,造成我在这些委托动作之间晕头转向,那么我通常都会使用内联函数。当然,间接层有其价值,但不是所有间接层都有价值。通过内联手法,我可以找出那些有用的间接层,同时将无用的间接层去除。
做法
- 检查函数,确定它不具多态性。
Tip
如果该函数属于一个类,并且有子类继承了这个函数,那么就无法内联。
- 找出这个函数的所有调用点。
- 将这个函数的所有调用点都替换为函数本体。
- 每次替换之后,执行测试。
Tip
不必一次完成整个内联操作。如果某些调用点比较难以内联,可以等到时机成熟后再来处理。
- 删除该函数的定义。
被我这样一写,内联函数似乎很简单。但情况往往并非如此。对于递归调用、多返回点、内联至另一个对象中而该对象并无访问函数等复杂情况,我可以写上好几页。我之所以不写这些特殊情况,原因很简单:如果你遇到了这样的复杂情况,就不应该使用这个重构手法。
范例
在最简单的情况下,这个重构简单得不值一提。一开始的代码是这样:
function rating(aDriver) {return moreThanFiveLateDeliveries(aDriver) ? 2 : 1;
}
function moreThanFiveLateDeliveries(aDriver) {return aDriver.numberOfLateDeliveries & gt;5;
}
我只要把被调用的函数的 return 语句复制出来,粘贴到调用处,取代原本的函数调用,就行了。
function rating(aDriver) {return aDriver.numberOfLateDeliveries & amp;gt;5 ? 2 : 1;
}
不过实际情况可能不会这么简单,需要我多做一点儿工作,帮助代码融入它的新家。例如,开始时的代码与前面稍有不同:
function rating(aDriver) {return moreThanFiveLateDeliveries(aDriver) ? 2 : 1;
}function moreThanFiveLateDeliveries(dvr) {return dvr.numberOfLateDeliveries & gt;5;
}
几乎是一样的代码,但 moreThanFiveLateDeliveries 函数声明的形式参数名与调用处使用的变量名不同,所以我在内联时需要对代码做些微调。
function rating(aDriver) {return aDriver.numberOfLateDeliveries & gt;5 ? 2 : 1;
}
情况还可能更复杂。例如,请看下列代码:
function reportLines(aCustomer) {const lines = [];gatherCustomerData(lines, aCustomer);return lines;
}
function gatherCustomerData(out, aCustomer) {out.push(["name", aCustomer.name]);out.push(["location", aCustomer.location]);
}
我要把 gatherCustomerData 内联到 reportLines 中,这时简单的剪切和粘贴就不够了。这段代码还不算很麻烦,大多数时候我还是一步到位地完成了重构,只是需要做些调整。如果想更谨慎些,也可以每次搬移一行代码:可以首先对第一行代码使用搬移语句到调用者(217)——我还是用简单的“剪切-粘贴-调整”方式进行。
function reportLines(aCustomer) {const lines = [];lines.push(["name", aCustomer.name]);gatherCustomerData(lines, aCustomer);return lines;
}
function gatherCustomerData(out, aCustomer) {out.push(["name", aCustomer.name]);out.push(["location", aCustomer.location]);
}
然后继续处理后面的代码行,直到完成整个重构。
function reportLines(aCustomer) {const lines = [];lines.push(["name", aCustomer.name]);lines.push(["location", aCustomer.location]);return lines;
}
重点在于始终小步前进。大多数时候,由于我平时写的函数都很小,内联函数可以一步完成,顶多需要一点代码调整。但如果遇到了复杂的情况,我会每次内联一行代码。哪怕只是处理一行代码,也可能遇到麻烦,那么我就会使用更精细的重构手法搬移语句到调用者(217),将步子再拆细一点。有时我会自信满满地快速完成重构,然后测试却失败了,这时我会回退到上一个能通过测试的版本,带着一点儿懊恼,以更小的步伐再次重构。
6.3 提炼变量(Extract Variable)
曾用名:引入解释性变量(Introduce Explaining Variable)
反向重构:内联变量(123)
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;
动机
表达式有可能非常复杂而难以阅读。这种情况下,局部变量可以帮助我们将表达式分解为比较容易管理的形式。在面对一块复杂逻辑时,局部变量使我能给其中的一部分命名,这样我就能更好地理解这部分逻辑是要干什么。
这样的变量在调试时也很方便,它们给调试器和打印语句提供了便利的抓手。
如果我考虑使用提炼变量,就意味着我要给代码中的一个表达式命名。一旦决定要这样做,我就得考虑这个名字所处的上下文。如果这个名字只在当前的函数中有意义,那么提炼变量是个不错的选择;但如果这个变量名在更宽的上下文中也有意义,我就会考虑将其暴露出来,通常以函数的形式。如果在更宽的范围可以访问到这个名字,就意味着其他代码也可以用到这个表达式,而不用把它重写一遍,这样能减少重复,并且能更好地表达我的意图。
“将新的名字暴露得更宽”的坏处则是需要额外的工作量。如果工作量很大,我会暂时搁下这个想法,稍后再用以查询取代临时变量(178)来处理它。但如果处理其他很简单,我就会立即动手,这样马上就可以使用这个新名字。有一个好的例子:如果我处理的这段代码属于一个类,对这个新的变量使用提炼函数(106)会很容易。
做法
- 确认要提炼的表达式没有副作用。
- 声明一个不可修改的变量,把你想要提炼的表达式复制一份,以该表达式的结果值给这个变量赋值。
- 用这个新变量取代原来的表达式。
- 测试。
如果该表达式出现了多次,请用这个新变量逐一替换,每次替换之后都要执行测试。
范例
我们从一个简单计算开始:
function price(order) {//price is base price - quantity discount + shippingreturn (order.quantity * order.itemPrice -Math.max(0, order.quantity - 500) * order.itemPrice * 0.05 +Math.min(order.quantity * order.itemPrice * 0.1, 100));
}
这段代码还算简单,不过我可以让它变得更容易理解。首先,我发现,底价(base price)等于数量(quantity)乘以单价(item price)。
function price(order) {//price is base price - quantity discount + shippingreturn (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) {//price is base price - quantity discount + shippingconst basePrice = order.quantity * order.itemPrice;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) {//price is base price - quantity discount + shippingconst basePrice = order.quantity * order.itemPrice;return (basePrice -Math.max(0, order.quantity - 500) * order.itemPrice * 0.05 +Math.min(order.quantity * order.itemPrice * 0.1, 100));
}
稍后的代码还用到了同样的表达式,也可以用新建的变量取代之。
function price(order) {//price is base price - quantity discount + shippingconst basePrice = order.quantity * order.itemPrice;return (basePrice -Math.max(0, order.quantity - 500) * order.itemPrice * 0.05 +Math.min(basePrice * 0.1, 100));
}
下一行是计算批发折扣(quantity discount)的逻辑,我也将它提炼出来:
function price(order) {//price is base price - quantity discount + shippingconst basePrice = order.quantity * order.itemPrice;const quantityDiscount =Math.max(0, order.quantity - 500) * order.itemPrice * 0.05;return basePrice - quantityDiscount + Math.min(basePrice * 0.1, 100);
}
最后,我再把运费(shipping)计算提炼出来。同时我还可以删掉代码中的注释,因为现在代码已经可以完美表达自己的意义了:
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;
}
范例:在一个类中
下面是同样的代码,但这次它位于一个类中:
class Order {constructor(aRecord) {this._data = aRecord;}get quantity() {return this._data.quantity;}get itemPrice() {return this._data.itemPrice;}get price() {return (this.quantity * this.itemPrice -Math.max(0, this.quantity - 500) * this.itemPrice * 0.05 +Math.min(this.quantity * this.itemPrice * 0.1, 100));}
}
我要提炼的还是同样的变量,但我意识到:这些变量名所代表的概念,适用于整个 Order 类,而不仅仅是“计算价格”的上下文。既然如此,我更愿意将它们提炼成方法,而不是变量。
class Order {constructor(aRecord) {this._data = aRecord;}get quantity() {return this._data.quantity;}get itemPrice() {return this._data.itemPrice;}get price() {return this.basePrice - this.quantityDiscount + this.shipping;}get basePrice() {return this.quantity * this.itemPrice;}get quantityDiscount() {return Math.max(0, this.quantity - 500) * this.itemPrice * 0.05;}get shipping() {return Math.min(this.basePrice * 0.1, 100);}
}
这是对象带来的一大好处:它们提供了合适的上下文,方便分享相关的逻辑和数据。在如此简单的情况下,这方面的好处还不太明显;但在一个更大的类当中,如果能找出可以共用的行为,赋予它独立的概念抽象,给它起一个好名字,对于使用对象的人会很有帮助。
6.4 内联变量(Inline Variable)
曾用名:内联临时变量(Inline Temp)
反向重构:提炼变量(119)
let basePrice = anOrder.basePrice;
return (basePrice > 1000);
return anOrder.basePrice > 1000;
动机
在一个函数内部,变量能给表达式提供有意义的名字,因此通常变量是好东西。但有时候,这个名字并不比表达式本身更具表现力。还有些时候,变量可能会妨碍重构附近的代码。若果真如此,就应该通过内联的手法消除变量。
做法
- 检查确认变量赋值语句的右侧表达式没有副作用。
- 如果变量没有被声明为不可修改,先将其变为不可修改,并执行测试。
Tip
这是为了确保该变量只被赋值一次。
- 找到第一处使用该变量的地方,将其替换为直接使用赋值语句的右侧表达式。
- 测试。
- 重复前面两步,逐一替换其他所有使用该变量的地方。
- 删除该变量的声明点和赋值语句。
- 测试。
6.5 改变函数声明(Change Function Declaration)
别名:函数改名(Rename Function)
曾用名:函数改名(Rename Method)
曾用名:添加参数(Add Parameter)
曾用名:移除参数(Remove Parameter)
别名:修改签名(Change Signature)
function circum(radius) {...}
function circumference(radius) {...}
动机
函数是我们将程序拆分成小块的主要方式。函数声明则展现了如何将这些小块组合在一起工作——可以说,它们就是软件系统的关节。和任何构造体一样,系统的好坏很大程度上取决于关节。好的关节使得给系统添加新部件很容易;而糟糕的关节则不断招致麻烦,让我们难以看清软件的行为,当需求变化时难以找到合适的地方进行修改。还好,软件是软的,我可以改变这些关节,只是要小心修改。
对于这些关节而言,最重要的元素当属函数的名字。一个好名字能让我一眼看出函数的用途,而不必查看其实现代码。但起一个好名字并不容易,我很少能第一次就把名字起对。“就算这个名字有点迷惑人,还是放着别管吧——说到底,不过就是一个名字而已。”邪恶的混乱魔王就是这样引诱我的。为了拯救程序的灵魂,绝不能上了他的当。如果我看到一个函数的名字不对,一旦发现了更好的名字,就得尽快给函数改名。这样,下一次再看到这段代码时,我就不用再费力搞懂其中到底在干什么。(有一个改进函数名字的好办法:先写一句注释描述这个函数的用途,再把这句注释变成函数的名字。)
对于函数的参数,道理也是一样。函数的参数列表阐述了函数如何与外部世界共处。函数的参数设置了一个上下文,只有在这个上下文中,我才能使用这个函数。假如有一个函数的用途是把某人的电话号码转换成特定的格式,并且该函数的参数是一个人(person),那么我就没法用这个函数来处理公司(company)的电话号码。如果我把函数接受的参数由“人”改成“电话号码”,这段处理电话号码格式的代码就能被更广泛地使用。
修改参数列表不仅能增加函数的应用范围,还能改变连接一个模块所需的条件,从而去除不必要的耦合。在前面这个例子中,修改参数列表之后,“处理电话号码格式”的逻辑所在的模块就无须了解“人”这个概念。减少模块彼此之间的信息依赖,当我要做出修改时就能减轻我大脑的负担——毕竟我的脑容量已经不如从前那么大了(跟我脑袋的大小没关系)。
如何选择正确的参数,没有简单的规则可循。我可能有一个简单的函数,用于判断支付是否逾期——如果超期 30 天未付款,那么这笔支付就逾期了。这个函数的参数应该是“支付”(payment)对象,还是支付的到期日呢?如果使用支付对象,会使这个函数与支付对象的接口耦合,但好处是可以很容易地访问后者的其他属性,当“逾期”的逻辑发生变化时就不用修改所有调用该函数的代码——换句话说,提高了该函数的封装度。
对这道难题,唯一正确的答案是“没有正确答案”,而且答案还会随着时间变化。所以我发现掌握改变函数声明重构手法至关重要,这样当我想好代码中应该有哪些关节时,才能使代码随着我的理解而演进。
在本书中引用重构手法时,我通常只使用它的主名称。但“改名”(rename)是改变函数声明的重要应用场景,所以,如果只是用于改名,我会将这个重构称作函数改名(Rename Function),这样能更清晰地表达我的用意。从做法的角度,不管是给函数改名还是修改参数列表,做法都是一样的。
做法
对于本书中的大部分重构,我只展示了一套做法。这并非因为只有这一套做法,而是因为大部分情况下,一套标准的做法都管用。不过,改变函数声明是一个例外。它有一套简单的做法,这套做法常常够用;但在很多时候,有必要以更渐进的方式逐步迁移到达最终结果。所以,在进行此重构时,我会查看变更的范围,自问是否能一步到位地修改函数声明及其所有调用者。如果可以,我就采用简单的做法。迁移式的做法让我可以逐步修改调用方代码,如果函数被很多地方调用,或者修改不容易,或者要修改的是一个多态函数,或者对函数声明的修改比较复杂,能渐进式地逐步修改就很重要。
简单的做法
如果想要移除一个参数,需要先确定函数体内没有使用该参数。
修改函数声明,使其成为你期望的状态。
找出所有使用旧的函数声明的地方,将它们改为使用新的函数声明。
测试。
最好能把大的修改拆成小的步骤,所以如果你既想修改函数名,又想添加参数,最好分成两步来做。(并且,不论何时,如果遇到了麻烦,请撤销修改,并改用迁移式做法。)
迁移式做法
- 如果有必要的话,先对函数体内部加以重构,使后面的提炼步骤易于开展。
- 使用提炼函数(106)将函数体提炼成一个新函数。
Tip
如果你打算沿用旧函数的名字,可以先给新函数起一个易于搜索的临时名字。
- 如果提炼出的函数需要新增参数,用前面的简单做法添加即可。
- 测试。
- 对旧函数使用内联函数(115)。
- 如果新函数使用了临时的名字,再次使用改变函数声明(124)将其改回原来的名字。
- 测试。
如果要重构的函数属于一个具有多态性的类,那么对于该函数的每个实现版本,你都需要通过“提炼出一个新函数”的方式添加一层间接,并把旧函数的调用转发给新函数。如果该函数的多态性是在一个类继承体系中体现,那么只需要在超类上转发即可;如果各个实现类之间并没有一个共同的超类,那么就需要在每个实现类上做转发。
如果要重构一个已对外发布的 API,在提炼出新函数之后,你可以暂停重构,将原来的函数声明为“不推荐使用”(deprecated),然后给客户端一点时间转为使用新函数。等你有信心所有客户端都已经从旧函数迁移到新函数,再移除旧函数的声明。
范例:函数改名(简单做法)
下列函数的名字太过简略了:
function circum(radius) {return 2 * Math.PI * radius;
}
我想把它改得更有意义一点儿。首先修改函数的声明:
function circumference(radius) {return 2 * Math.PI * radius;
}
然后找出所有调用 circum 函数的地方,将其改为 circumference。
在不同的编程语言环境中,“找到所有调用旧函数的地方”这件事的难度也各异。静态类型加上趁手的 IDE 能提供最好的体验,通常可以全自动地完成函数改名,出错的概率极低。如果没有静态类型,就需要多花些工夫:即便再好的搜索工具,也可能会找出很多同名但并非同一函数的地方。
增减参数的做法也相同:找出所有调用者,修改函数声明,然后修改调用者。最好是能分步骤修改:如果既想给函数改名,又想添加参数,我会先完成改名,测试,然后添加参数,然后再次测试。
这个重构的简单做法缺点在于,我必须一次性修改所有调用者和函数声明(或者说,所有的函数声明,如果有多态的话)。如果只有不多的几处调用者,或者如果有可靠的自动化重构工具,这样做是没问题的。但如果调用者很多,事情就会变得很棘手。另外,如果函数的名字并不唯一,也可能造成问题。例如,我想给代表“人”的 Person 类的 changeAddress 函数改名,但同时在代表“保险合同”的 InsuranceAgreement 类中也有一个同名的函数,而我并不想修改后者的名字。修改越是复杂,我就越不希望一步到位地完成。如果有这些问题出现,我就会改为使用迁移式做法。同样,如果使用简单做法时出了什么错,我也会把代码回滚到上一个已知正确的状态,并改用迁移式做法再来一遍。
范例:函数改名(迁移式做法)
还是这个名字太过简略的函数:
function circum(radius) {return 2 * Math.PI * radius;
}
按照迁移式做法,我首先要对整个函数体使用提炼函数(106):
function circum(radius) {return circumference(radius);
}
function circumference(radius) {return 2 * Math.PI * radius;
}
此时我要执行测试,然后对旧函数使用内联函数(115):找出所有调用旧函数的地方,将其改为调用新函数。每次修改之后都可以执行测试,这样我就可以小步前进,每次修改一处调用者。所有调用者都修改完之后,我就可以删除旧函数。
大多数重构手法只用于修改我有权修改的代码,但这个重构手法同样适用于已发布 API——使用这些 API 的代码我无权修改。以上面的代码为例,创建出 circumference 函数之后,我就可以暂停重构,并(如果可以的话)将 circum 函数标记为 deprecated。然后我就耐心等待客户端改用 circumference 函数,等他们都改完了,我再删除 circum 函数。即便永远也抵达不了“删除 circum 函数”这个快乐的终点,至少新代码有了一个更好的名字。
范例:添加参数
想象一个管理图书馆的软件,其中有代表“图书”的 Book 类,它可以接受顾客(customer)的预订(reservation):
class Book…
addReservation(customer) {this._reservations.push(customer);
}
现在我需要支持“高优先级预订”,因此我要给 addReservation 额外添加一个参数,用于标记这次预订应该进入普通队列还是优先队列。如果能很容易地找到并修改所有调用方,我可以直接修改;但如果不行,我仍然可以采用迁移式做法,下面是详细的过程。
首先,我用提炼函数(106)把 addReservation 的函数体提炼出来,放进一个新函数。这个新函数最终会叫 addReservation,但新旧两个函数不能同时占用这个名字,所以我会先给新函数起一个容易搜索的临时名字。
class Book…
addReservation(customer) {this.zz_addReservation(customer);
}
zz_addReservation(customer) {this._reservations.push(customer);
}
然后我会在新函数的声明中增加参数,同时修改旧函数中调用新函数的地方(也就是采用简单做法完成这一步)。
class Book…
addReservation(customer) {this.zz_addReservation(customer, false);
}zz_addReservation(customer, isPriority) {this._reservations.push(customer);
}
在修改调用方之前,我喜欢利用 JavaScript 的语言特性先应用引入断言(302),确保调用方一定会用到这个新参数。
class Book…
zz_addReservation(customer, isPriority) {assert(isPriority === true || isPriority === false);this._reservations.push(customer);
}
现在,如果我在修改调用方时出了错,没有提供新参数,这个断言会帮我抓到错误——以我过去的经验来看,比我更容易出错的程序员怕是不多。
现在,我可以对源函数使用内联函数(115),使其调用者转而使用新函数。这样我可以每次只修改一个调用者。
现在我就可以把新函数改回原来的名字了。一般而言,此时用简单做法就够了;但如果有必要,也可以再用一遍迁移式做法。
范例:把参数改为属性
此前的范例都很简单:改个名,增加一个参数。有了迁移式做法以后,这个重构手法可以相当利落地处理更复杂的情况。下面就是一个更复杂的例子。
假设我有一个函数,用于判断顾客(customer)是不是来自新英格兰(New England)地区:
function inNewEngland(aCustomer) {return ["MA", "CT", "ME", "VT", "NH", "RI"].includes(aCustomer.address.state);
}
下面是一个调用该函数的地方:
调用方…
const newEnglanders = someCustomers.filter(c => inNewEngland(c));
inNewEngland 函数只用到了顾客所在的州(state)这项信息,基于这个信息来判断顾客是否来自新英格兰地区。我希望重构这个函数,使其接受州代码(state code)作为参数,这样就能去掉对“顾客”概念的依赖,使这个函数能在更多的上下文中使用。
在使用改变函数声明时,我通常会先运用提炼函数(106),但在这里我会先对函数体做一点重构,使后面的重构步骤更简单。我先用提炼变量(119)提炼出我想要的新参数:
function inNewEngland(aCustomer) {const stateCode = aCustomer.address.state;return ["MA", "CT", "ME", "VT", "NH", "RI"].includes(stateCode);
}
然后再用提炼函数(106)创建新函数:
function inNewEngland(aCustomer) {const stateCode = aCustomer.address.state;return xxNEWinNewEngland(stateCode);
}function xxNEWinNewEngland(stateCode) {return ["MA", "CT", "ME", "VT", "NH", "RI"].includes(stateCode);
}
我会给新函数起一个好记又独特的临时名字,这样回头要改回原来的名字时也会简单一些。(你也看到,对于怎么起这些临时名字,我并没有统一的标准。)
我会在源函数中使用内联变量(123),把刚才提炼出来的参数内联回去:
function inNewEngland(aCustomer) {return xxNEWinNewEngland(aCustomer.address.state);
}
然后我会用内联函数(115)把旧函数内联到调用处,其效果就是把旧函数的调用处改为调用新函数。我可以每次修改一个调用处。
调用方…
const newEnglanders = someCustomers.filter(c => xxNEWinNewEngland(c.address.state));
旧函数被内联到各调用处之后,我就再次使用改变函数声明,把新函数改回旧名字:
调用方…
const newEnglanders = someCustomers.filter(c => inNewEngland(c.address.state));
顶层作用域…
function inNewEngland(stateCode) {return ["MA", "CT", "ME", "VT", "NH", "RI"].includes(stateCode);
}
自动化重构工具减少了迁移式做法的用武之地,同时也使迁移式做法更加高效。自动化重构工具可以安全地处理相当复杂的改名、参数变更等情况,所以迁移式做法的用武之地就变少了,因为自动化重构工具经常能提供足够的支持。如果遇到类似这里的例子,尽管工具无法自动完成整个重构,还是可以更快、更安全地完成关键的提炼和内联步骤,从而简化整个重构过程。
6.6 封装变量(Encapsulate Variable)
曾用名:自封装字段(Self-Encapsulate Field)
曾用名:封装字段(Encapsulate Field)
let defaultOwner = { firstName: "Martin", lastName: "Fowler" };
let defaultOwnerData = { firstName: "Martin", lastName: "Fowler" };
export function defaultOwner() {return defaultOwnerData;
}
export function setDefaultOwner(arg) {defaultOwnerData = arg;
}
动机
重构的作用就是调整程序中的元素。函数相对容易调整一些,因为函数只有一种用法,就是调用。在改名或搬移函数的过程中,总是可以比较容易地保留旧函数作为转发函数(即旧代码调用旧函数,旧函数再调用新函数)。这样的转发函数通常不会存在太久,但的确能够简化重构过程。
数据就要麻烦得多,因为没办法设计这样的转发机制。如果我把数据搬走,就必须同时修改所有引用该数据的代码,否则程序就不能运行。如果数据的可访问范围很小,比如一个小函数内部的临时变量,那还不成问题。但如果可访问范围变大,重构的难度就会随之增大,这也是说全局数据是大麻烦的原因。
所以,如果想要搬移一处被广泛使用的数据,最好的办法往往是先以函数形式封装所有对该数据的访问。这样,我就能把“重新组织数据”的困难任务转化为“重新组织函数”这个相对简单的任务。
封装数据的价值还不止于此。封装能提供一个清晰的观测点,可以由此监控数据的变化和使用情况;我还可以轻松地添加数据被修改时的验证或后续逻辑。我的习惯是:对于所有可变的数据,只要它的作用域超出单个函数,我就会将其封装起来,只允许通过函数访问。数据的作用域越大,封装就越重要。处理遗留代码时,一旦需要修改或增加使用可变数据的代码,我就会借机把这份数据封装起来,从而避免继续加重耦合一份已经广泛使用的数据。
面向对象方法如此强调对象的数据应该保持私有(private),背后也是同样的原理。每当看见一个公开(public)的字段时,我就会考虑使用封装变量(在这种情况下,这个重构手法常被称为封装字段)来缩小其可见范围。一些更激进的观点认为,即便在类内部,也应该通过访问函数来使用字段——这种做法也称为“自封装”。大体而言,我认为自封装有点儿过度了——如果一个类大到需要将字段自封装起来的程度,那么首先应该考虑把这个类拆小。不过,在分拆类之前,自封装字段倒是一个有用的步骤。
封装数据很重要,不过,不可变数据更重要。如果数据不能修改,就根本不需要数据更新前的验证或者其他逻辑钩子。我可以放心地复制数据,而不用搬移原来的数据——这样就不用修改使用旧数据的代码,也不用担心有些代码获得过时失效的数据。不可变性是强大的代码防腐剂。
做法
- 创建封装函数,在其中访问和更新变量值。
- 执行静态检查。
- 逐一修改使用该变量的代码,将其改为调用合适的封装函数。每次替换之后,执行测试。
- 限制变量的可见性。
Tip
有时没办法阻止直接访问变量。若果真如此,可以试试将变量改名,再执行测试,找出仍在直接使用该变量的代码。
- 测试。
- 如果变量的值是一个记录,考虑使用封装记录(162)。
范例
下面这个全局变量中保存了一些有用的数据:
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" });
每次替换之后,执行测试。
处理完所有使用该变量的代码之后,我就可以限制它的可见性。这一步的用意有两个,一来是检查是否遗漏了变量的引用,二来可以保证以后的代码也不会直接访问该变量。在 JavaScript 中,我可以把变量和访问函数搬移到单独一个文件中,并且只导出访问函数,这样就限制了变量的可见性。
defaultOwner.js…
let defaultOwner = { firstName: "Martin", lastName: "Fowler" };
export function getDefaultOwner() {return defaultOwner;
}
export function setDefaultOwner(arg) {defaultOwner = arg;
}
如果条件不允许限制对变量的访问,可以将变量改名,然后再次执行测试,检查是否仍有代码在直接使用该变量。这阻止不了未来的代码直接访问变量,不过可以给变量起个有意义又难看的名字(例如__privateOnly_defaultOwner
),提醒后来的客户端。
我不喜欢给取值函数加上 get 前缀,所以我对这个函数改名。
defaultOwner.js…
let defaultOwnerData = { firstName: "Martin", lastName: "Fowler" };
export function getdefaultOwner() {return defaultOwnerData;
}
export function setDefaultOwner(arg) {defaultOwnerData = arg;
}
JavaScript 有一种惯例:给取值函数和设值函数起同样的名字,根据有没有传入参数来区分。我把这种做法称为“重载取值/设值函数”(Overloaded Getter Setter)[mf-orgs],并且我强烈反对这种做法。所以,虽然我不喜欢 get 前缀,但我会保留 set 前缀。
封装值
前面介绍的基本重构手法对数据结构的引用做了封装,使我能控制对该数据结构的访问和重新赋值,但并不能控制对结构内部数据项的修改:
const owner1 = defaultOwner();
assert.equal("Fowler", owner1.lastName, "when set");
const owner2 = defaultOwner();
owner2.lastName = "Parsons";
assert.equal("Parsons", owner1.lastName, "after change owner2"); // is this ok?
前面的基本重构手法只封装了对最外层数据的引用。很多时候这已经足够了。但也有很多时候,我需要把封装做得更深入,不仅控制对变量引用的修改,还要控制对变量内容的修改。
这有两个办法可以做到。最简单的办法是禁止对数据结构内部的数值做任何修改。我最喜欢的一种做法是修改取值函数,使其返回该数据的一份副本。
defaultOwner.js…
let defaultOwnerData = { firstName: "Martin", lastName: "Fowler" };
export function defaultOwner() {return Object.assign({}, defaultOwnerData);
}
export function setDefaultOwner(arg) {defaultOwnerData = arg;
}
对于列表数据,我尤其常用这一招。如果我在取值函数中返回数据的一份副本,客户端可以随便修改它,但不会影响到共享的这份数据。但在使用副本的做法时,我必须格外小心:有些代码可能希望能修改共享的数据。若果真如此,我就只能依赖测试来发现问题了。另一种做法是阻止对数据的修改,比如通过封装记录(162)就能很好地实现这一效果。
let defaultOwnerData = {firstName: "Martin", lastName: "Fowler"};
export function defaultOwner() {return new Person(defaultOwnerData);}
export function setDefaultOwner(arg) {defaultOwnerData = arg;}class Person {constructor(data) {this._lastName = data.lastName;this._firstName = data.firstName}get lastName() {return this._lastName;}get firstName() {return this._firstName;}// and so on for other properties
现在,如果客户端调用 defaultOwner 函数获得“默认拥有人”数据、再尝试对其属性(即 lastName 和 firstName)重新赋值,赋值不会产生任何效果。对于侦测或阻止修改数据结构内部的数据项,各种编程语言有不同的方式,所以我会根据当下使用的语言来选择具体的办法。
“侦测和阻止修改数据结构内部的数据项”通常只是个临时处置。随后我可以去除这些修改逻辑,或者提供适当的修改函数。这些都处理完之后,我就可以修改取值函数,使其返回一份数据副本。
到目前为止,我都在讨论“在取数据时返回一份副本”,其实设值函数也可以返回一份副本。这取决于数据从哪儿来,以及我是否需要保留对源数据的连接,以便知悉源数据的变化。如果不需要这样一条连接,那么设值函数返回一份副本就有好处:可以防止因为源数据发生变化而造成的意外事故。很多时候可能没必要复制一份数据,不过多一次复制对性能的影响通常也都可以忽略不计。但是,如果不做复制,风险则是未来可能会陷入漫长而困难的调试排错过程。
请记住,前面提到的数据复制、类封装等措施,都只在数据记录结构中深入了一层。如果想走得更深入,就需要更多层级的复制或是封装。
如你所见,数据封装很有价值,但往往并不简单。到底应该封装什么,以及如何封装,取决于数据被使用的方式,以及我想要修改数据的方式。不过,一言以蔽之,数据被使用得越广,就越是值得花精力给它一个体面的封装。
6.7 变量改名(Rename Variable)
let a = height * width;
let area = height * width;
动机
好的命名是整洁编程的核心。变量可以很好地解释一段程序在干什么——如果变量名起得好的话。但我经常会把名字起错——有时是因为想得不够仔细,有时是因为我对问题的理解加深了,还有时是因为程序的用途随着用户的需求改变了。
使用范围越广,名字的好坏就越重要。只在一行的 lambda 表达式中使用的变量,跟踪起来很容易——像这样的变量,我经常只用一个字母命名,因为变量的用途在这个上下文中很清晰。同理,短函数的参数名也常常很简单。不过在 JavaScript 这样的动态类型语言中,我喜欢把类型信息也放进名字里(于是变量名可能叫 aCustomer)。
对于作用域超出一次函数调用的字段,则需要更用心命名。这是我最花心思的地方。
机制
- 如果变量被广泛使用,考虑运用封装变量(132)将其封装起来。
- 找出所有使用该变量的代码,逐一修改。
Tip
如果在另一个代码库中使用了该变量,这就是一个“已发布变量”(published variable),此时不能进行这个重构。
如果变量值从不修改,可以将其复制到一个新名字之下,然后逐一修改使用代码,每次修改后执行测试。
- 测试。
范例
如果要改名的变量只作用于一个函数(临时变量或者参数),对其改名是最简单的。这种情况太简单,根本不需要范例:找到变量的所有引用,修改过来就行。完成修改之后,我会执行测试,确保没有破坏什么东西。
如果变量的作用域不止于单个函数,问题就会出现。代码库的各处可能有很多地方使用它:
let tpHd = "untitled";
有些地方是在读取变量值:
result += `<h1>${tpHd}</h1>`;
另一些地方则更新它的值:
tpHd = obj["articleTitle"];
对于这种情况,我通常的反应是运用封装变量(132):
result += `<h1>${title()}</h1>`;setTitle(obj["articleTitle"]);function title() {return tpHd;
}
function setTitle(arg) {tpHd = arg;
}
现在就可以给变量改名:
let _title = "untitled";function title() {return _title;
}
function setTitle(arg) {_title = arg;
}
我可以继续重构下去,将包装函数内联回去,这样所有的调用者就变回直接使用变量的状态。不过我很少这样做。如果这个变量被广泛使用,以至于我感到需要先做封装才敢改名,那就有必要保持这个状态,将变量封装在函数后面。
Tip
如果我确实想内联,在重构过程中,我就会将取值函数命名为 getTitle,并且其中的变量名也不会以下划线开头。
给常量改名
如果我想改名的是一个常量(或者在客户端看来就像是常量的元素),我可以复制这个常量,这样既不需要封装,又可以逐步完成改名。假如原来的变量声明是这样:
const cpyNm = "Acme Gooseberries";
改名的第一步是复制这个常量:
const companyName = "Acme Gooseberries";
const cpyNm = companyName;
有了这个副本,我就可以逐一修改引用旧常量的代码,使其引用新的常量。全部修改完成后,我会删掉旧的常量。我喜欢先声明新的常量名,然后把新常量复制给旧的名字。这样最后删除旧名字时会稍微容易一点,如果测试失败,再把旧常量放回来也稍微容易一点。
这个做法不仅适用于常量,也同样适用于客户端只能读取的变量(例如 JavaScript 模块中导出的变量)。
6.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) {...}
动机
我常会看见,一组数据项总是结伴同行,出没于一个又一个函数。这样一组数据就是所谓的数据泥团,我喜欢代之以一个数据结构。
将数据组织成结构是一件有价值的事,因为这让数据项之间的关系变得明晰。使用新的数据结构,参数的参数列表也能缩短。并且经过重构之后,所有使用该数据结构的函数都会通过同样的名字来访问其中的元素,从而提升代码的一致性。
但这项重构真正的意义在于,它会催生代码中更深层次的改变。一旦识别出新的数据结构,我就可以重组程序的行为来使用这些结构。我会创建出函数来捕捉围绕这些数据的共用行为——可能只是一组共用的函数,也可能用一个类把数据结构与使用数据的函数组合起来。这个过程会改变代码的概念图景,将这些数据结构提升为新的抽象概念,可以帮助我更好地理解问题域。果真如此,这个重构过程会产生惊人强大的效用——但如果不用引入参数对象开启这个过程,后面的一切都不会发生。
做法
- 如果暂时还没有一个合适的数据结构,就创建一个。
Tip
我倾向于使用类,因为稍后把行为放进来会比较容易。我通常会尽量确保这些新建的数据结构是值对象[mf-vo]。
- 测试。
- 使用改变函数声明(124)给原来的函数新增一个参数,类型是新建的数据结构。
- 测试。
- 调整所有调用者,传入新数据结构的适当实例。每修改一处,执行测试。
- 用新数据结构中的每项元素,逐一取代参数列表中与之对应的参数项,然后删除原来的参数。测试。
范例
下面要展示的代码会查看一组温度读数(reading),检查是否有任何一条读数超出了指定的运作温度范围(range)。温度读数的数据如下:
const station = {name: "ZB1",readings: [{ temp: 47, time: "2016-11-10 09:10" },{ temp: 53, time: "2016-11-10 09:20" },{ temp: 58, time: "2016-11-10 09:30" },{ temp: 53, time: "2016-11-10 09:40" },{ temp: 51, time: "2016-11-10 09:50" },],
};
下面的函数负责找到超出指定范围的温度读数:
function readingsOutsideRange(station, min, max) {return station.readings.filter(r => r.temp < min || r.temp > max);
}
调用该函数的代码可能是下面这样的。
调用方
alerts = readingsOutsideRange(station,operatingPlan.temperatureFloor,operatingPlan.temperatureCeiling
);
请注意,这里的调用代码从另一个对象中抽出两项数据,转手又把这一对数据传递给 readingsOutsideRange。代表“运作计划”的 operatingPlan 对象用了另外的名字来表示温度范围的下限和上限,与 readingsOutsideRange 中所用的名字不同。像这样用两项各不相干的数据来表示一个范围的情况并不少见,最好是将其组合成一个对象。我会首先为要组合的数据声明一个类:
class NumberRange {constructor(min, max) {this._data = { min: min, max: max };}get min() {return this._data.min;}get max() {return this._data.max;}
}
我声明了一个类,而不是基本的 JavaScript 对象,因为这个重构通常只是一系列重构的起点,随后我会把行为搬移到新建的对象中。既然类更适合承载数据与行为的组合,我就直接从声明一个类开始。同时,在这个新类中,我不会提供任何更新数据的函数,因为我有可能将其处理成值对象(Value Object)[mf-vo]。在使用这个重构手法时,大多数情况下我都会创建值对象。
然后我会运用改变函数声明(124),把新的对象作为参数传给 readingsOutsideRange。
function readingsOutsideRange(station, min, max, range) {return station.readings.filter(r => r.temp < min || r.temp > max);
}
在 JavaScript 中,此时我不需要修改调用方代码,但在其他语言中,我必须在调用处为新参数传入 null 值,就像下面这样。
调用方
alerts = readingsOutsideRange(station,operatingPlan.temperatureFloor,operatingPlan.temperatureCeiling,null
);
到目前为止,我还没有修改任何行为,所以测试应该仍然能通过。随后,我会挨个找到函数的调用处,传入合适的温度范围。
调用方
const range = new NumberRange(operatingPlan.temperatureFloor,operatingPlan.temperatureCeiling
);
alerts = readingsOutsideRange(station,operatingPlan.temperatureFloor,operatingPlan.temperatureCeiling,range
);
此时我还是没有修改任何行为,因为新添的参数没有被使用。所有测试应该仍然能通过。
现在我可以开始修改使用参数的代码了。先从“最大值”开始:
function readingsOutsideRange(station, min, max, range) {return station.readings.filter(r => r.temp < min || r.temp > range.max);
}
调用方
const range = new NumberRange(operatingPlan.temperatureFloor,operatingPlan.temperatureCeiling
);
alerts = readingsOutsideRange(station,operatingPlan.temperatureFloor,operatingPlan.temperatureCeiling,range
);
此时要执行测试。如果测试通过,我再接着处理另一个参数。
function readingsOutsideRange(station, min, range) {return station.readings.filter(r => r.temp < range.min || r.temp > range.max);
}
调用方
const range = new NumberRange(operatingPlan.temperatureFloor,operatingPlan.temperatureCeiling
);
alerts = readingsOutsideRange(station, operatingPlan.temperatureFloor, range);
这项重构手法到这儿就完成了。不过,将一堆参数替换成一个真正的对象,这只是长征第一步。创建一个类是为了把行为搬移进去。在这里,我可以给“范围”类添加一个函数,用于测试一个值是否落在范围之内。
function readingsOutsideRange(station, range) {return station.readings.f ilter(r => !range.contains(r.temp));
}
class NumberRange…
contains(arg) {return (arg >= this.min && arg <= this.max);}
这样我就迈出了第一步,开始逐渐打造一个真正有用的“范围”[mf-range]类。一旦识别出“范围”这个概念,那么每当我在代码中发现“最大/最小值”这样一对数字时,我就会考虑是否可以将其改为使用“范围”类。(例如,我马上就会考虑把“运作计划”类中的 temperatureFloor 和 temperatureCeiling 替换为 temperatureRange。)在观察这些成对出现的数字如何被使用时,我会发现一些有用的行为,并将其搬移到“范围”类中,简化其使用方法。比如,我可能会先给这个类加上“基于数值判断相等性”的函数,使其成为一个真正的值对象。
6.9 函数组合成类(Combine Functions into Class)
function base(aReading) {...}
function taxableCharge(aReading) {...}
function calculateBaseCharge(aReading) {...}
class Reading {base() {...}taxableCharge() {...}calculateBaseCharge() {...}
}
动机
类,在大多数现代编程语言中都是基本的构造。它们把数据与函数捆绑到同一个环境中,将一部分数据与函数暴露给其他程序元素以便协作。它们是面向对象语言的首要构造,在其他程序设计方法中也同样有用。
如果发现一组函数形影不离地操作同一块数据(通常是将这块数据作为参数传递给函数),我就认为,是时候组建一个类了。类能明确地给这些函数提供一个共用的环境,在对象内部调用这些函数可以少传许多参数,从而简化函数调用,并且这样一个对象也可以更方便地传递给系统的其他部分。
除了可以把已有的函数组织起来,这个重构还给我们一个机会,去发现其他的计算逻辑,将它们也重构到新的类当中。
将函数组织到一起的另一种方式是函数组合成变换(149)。具体使用哪个重构手法,要看程序整体的上下文。使用类有一大好处:客户端可以修改对象的核心数据,通过计算得出的派生数据则会自动与核心数据保持一致。
类似这样的一组函数不仅可以组合成一个类,而且可以组合成一个嵌套函数。通常我更倾向于类而非嵌套函数,因为后者测试起来会比较困难。如果我想对外暴露多个函数,也必须采用类的形式。
在有些编程语言中,类不是一等公民,而函数则是。面对这样的语言,可以用“函数作为对象”(Function As Object)[mf-fao]的形式来实现这个重构手法。
做法
- 运用封装记录(162)对多个函数共用的数据记录加以封装。
Tip
如果多个函数共用的数据还未组织成记录结构,则先运用引入参数对象(140)将其组织成记录。
- 对于使用该记录结构的每个函数,运用搬移函数(198)将其移入新类。
Tip
如果函数调用时传入的参数已经是新类的成员,则从参数列表中去除之。
- 用以处理该数据记录的逻辑可以用提炼函数(106)提炼出来,并移入新类。
范例
我在英格兰长大,那是一个热爱喝茶的国度。(个人而言,我不喜欢在英格兰喝到的大部分茶,对中国茶和日本茶倒是情有独钟。)所以,我虚构了一种用于向老百姓供给茶水的公共设施。每个月会有软件读取茶水计量器的数据,得到类似这样的读数(reading):
reading = { customer: "ivan", quantity: 10, month: 5, year: 2017 };
浏览处理这些数据记录的代码,我发现有很多地方在做着相似的计算,于是我找到了一处计算“基础费用”(base charge)的逻辑。
客户端 1…
const aReading = acquireReading();
const baseCharge = baseRate(aReading.month, aReading.year) * aReading.quantity;
在英格兰,一切生活必需品都得交税,茶自然也不例外。不过,按照规定,只要不超出某个必要用量,就不用交税。
客户端 2…
const aReading = acquireReading();
const base = baseRate(aReading.month, aReading.year) * aReading.quantity;
const taxableCharge = Math.max(0, base - taxThreshold(aReading.year));
我相信你也发现了:计算基础费用的公式被重复了两遍。如果你跟我有一样的习惯,现在大概已经在着手提炼函数(106)了。有趣的是,好像别人已经动过这个脑筋了。
客户端 3…
const aReading = acquireReading();
const basicChargeAmount = calculateBaseCharge(aReading);function calculateBaseCharge(aReading) {return baseRate(aReading.month, aReading.year) * aReading.quantity;
}
看到这里,我有一种自然的冲动,想把前面两处客户端代码都改为使用这个函数。但这样一个顶层函数的问题在于,它通常位于一个文件中,读者不一定能想到来这里寻找它。我更愿意对代码多做些修改,让该函数与其处理的数据在空间上有更紧密的联系。为此目的,不妨把数据本身变成一个类。
我可以运用封装记录(162)将记录变成类。
class Reading {constructor(data) {this._customer = data.customer;this._quantity = data.quantity;this._month = data.month;this._year = data.year;}get customer() {return this._customer;}get quantity() {return this._quantity;}get month() {return this._month;}get year() {return this._year;}
}
首先,我想把手上已有的函数 calculateBaseCharge 搬到新建的 Reading 类中。一得到原始的读数数据,我就用 Reading 类将它包装起来,然后就可以在函数中使用 Reading 类了。
客户端 3…
const rawReading = acquireReading();
const aReading = new Reading(rawReading);
const basicChargeAmount = calculateBaseCharge(aReading);
然后我用搬移函数(198)把 calculateBaseCharge 搬到新类中。
class Reading…
get calculateBaseCharge() {return baseRate(this.month, this.year) * this.quantity;
}
客户端 3…
const rawReading = acquireReading();
const aReading = new Reading(rawReading);
const basicChargeAmount = aReading.calculateBaseCharge;
搬移的同时,我会顺便运用函数改名(124),按照我喜欢的风格对这个函数改名。
get baseCharge() {return baseRate(this.month, this.year) * this.quantity;
}
客户端 3…
const rawReading = acquireReading();
const aReading = new Reading(rawReading);
const basicChargeAmount = aReading.baseCharge;
用这个名字,Reading 类的客户端将不知道 baseCharge 究竟是一个字段还是推演计算出的值。这是好事,它符合“统一访问原则”(Uniform Access Principle)[mf-ua]。
现在我可以修改客户端 1 的代码,令其调用新的方法,不要重复计算基础费用。
客户端 1…
const rawReading = acquireReading();
const aReading = new Reading(rawReading);
const baseCharge = aReading.baseCharge;
很有可能我会顺手用内联变量(123)把 baseCharge 变量给去掉。不过,我们当下介绍的重构手法更关心“计算应税费用”的逻辑。同样,我先将那里的客户端代码改为使用新建的 baseCharge 属性。
客户端 2…
const rawReading = acquireReading();
const aReading = new Reading(rawReading);
const taxableCharge = Math.max(0,aReading.baseCharge - taxThreshold(aReading.year)
);
运用提炼函数(106)将计算应税费用(taxable charge)的逻辑提炼成函数:
function taxableChargeFn(aReading) {return Math.max(0, aReading.baseCharge - taxThreshold(aReading.year));
}
客户端 3…
const rawReading = acquireReading();
const aReading = new Reading(rawReading);
const taxableCharge = taxableChargeFn(aReading);
然后我运用搬移函数(198)将其移入 Reading 类:
class Reading…
get taxableCharge() {return Math.max(0, this.baseCharge - taxThreshold(this.year));
}
客户端 3…
const rawReading = acquireReading();
const aReading = new Reading(rawReading);
const taxableCharge = aReading.taxableCharge;
由于所有派生数据都是在使用时计算得出的,所以对存储下来的读数进行修改也没问题。一般而论,我更倾向于使用不可变的数据;但很多时候我们必须得使用可变数据(比如 JavaScript 整个语言生态在设计时就没有考虑数据的不可变性)。如果数据确有可能被更新,那么用类将其封装起来会很有帮助。
6.10 函数组合成变换(Combine Functions into Transform)
function base(aReading) {...}
function taxableCharge(aReading) {...}
function enrichReading(argReading) {const aReading = _.cloneDeep(argReading);aReading.baseCharge = base(aReading);aReading.taxableCharge = taxableCharge(aReading);return aReading;
}
动机
在软件中,经常需要把数据“喂”给一个程序,让它再计算出各种派生信息。这些派生数值可能会在几个不同地方用到,因此这些计算逻辑也常会在用到派生数据的地方重复。我更愿意把所有计算派生数据的逻辑收拢到一处,这样始终可以在固定的地方找到和更新这些逻辑,避免到处重复。
一个方式是采用数据变换(transform)函数:这种函数接受源数据作为输入,计算出所有的派生数据,将派生数据以字段形式填入输出数据。有了变换函数,我就始终只需要到变换函数中去检查计算派生数据的逻辑。
函数组合成变换的替代方案是函数组合成类(144),后者的做法是先用源数据创建一个类,再把相关的计算逻辑搬移到类中。这两个重构手法都很有用,我常会根据代码库中已有的编程风格来选择使用其中哪一个。不过,两者有一个重要的区别:如果代码中会对源数据做更新,那么使用类要好得多;如果使用变换,派生数据会被存储在新生成的记录中,一旦源数据被修改,我就会遭遇数据不一致。
我喜欢把函数组合起来的原因之一,是为了避免计算派生数据的逻辑到处重复。从道理上来说,只用提炼函数(106)也能避免重复,但孤立存在的函数常常很难找到,只有把函数和它们操作的数据放在一起,用起来才方便。引入变换(或者类)都是为了让相关的逻辑找起来方便。
做法
- 创建一个变换函数,输入参数是需要变换的记录,并直接返回该记录的值。
Tip
这一步通常需要对输入的记录做深复制(deep copy)。此时应该写个测试,确保变换不会修改原来的记录。
- 挑选一块逻辑,将其主体移入变换函数中,把结果作为字段添加到输出记录中。修改客户端代码,令其使用这个新字段。
Tip
如果计算逻辑比较复杂,先用提炼函数(106)提炼之。
- 测试。
- 针对其他相关的计算逻辑,重复上述步骤。
范例
在我长大的国度,茶是生活中的重要部分,以至于我想象了这样一种特别的公共设施,专门给老百姓供应茶水。每个月,从这个设备上可以得到读数(reading),从而知道每位顾客取用了多少茶。
reading = { customer: "ivan", quantity: 10, month: 5, year: 2017 };
几个不同地方的代码分别根据茶的用量进行计算。一处是计算应该向顾客收取的基本费用。
客户端 1…
const aReading = acquireReading();
const baseCharge = baseRate(aReading.month, aReading.year) * aReading.quantity;
另一处是计算应该交税的费用—比基本费用要少,因为政府明智地认为,每个市民都有权免税享受一定量的茶水。
客户端 2…
const aReading = acquireReading();
const base = baseRate(aReading.month, aReading.year) * aReading.quantity;
const taxableCharge = Math.max(0, base - taxThreshold(aReading.year));
浏览处理这些数据记录的代码,我发现有很多地方在做着相似的计算。这样的重复代码,一旦需要修改(我打赌这只是早晚的问题),就会造成麻烦。我可以用提炼函数(106)来处理这些重复的计算逻辑,但这样提炼出来的函数会散落在程序中,以后的程序员还是很难找到。说真的,我还真在另一块代码中找到了一个这样的函数。
客户端 3…
const aReading = acquireReading();
const basicChargeAmount = calculateBaseCharge(aReading);function calculateBaseCharge(aReading) {return baseRate(aReading.month, aReading.year) * aReading.quantity;
}
处理这种情况的一个办法是,把所有这些计算派生数据的逻辑搬移到一个变换函数中,该函数接受原始的“读数”作为输入,输出则是增强的“读数”记录,其中包含所有共用的派生数据。
我先要创建一个变换函数,它要做的事很简单,就是复制输入的对象:
function enrichReading(original) {const result = _.cloneDeep(original);return result;
}
我用了 Lodash 库的 cloneDeep 函数来进行深复制。
这个变换函数返回的本质上仍是原来的对象,只是添加了更多的信息在上面。对于这种函数,我喜欢用“enrich”(增强)这个词来给它命名。如果它生成的是跟原来完全不同的对象,我就会用“transform”(变换)来命名它。
然后我挑选一处想要搬移的计算逻辑。首先,我用现在的 enrichReading 函数来增强“读数”记录,尽管该函数暂时还什么都没做。
客户端 3…
const rawReading = acquireReading();
const aReading = enrichReading(rawReading);
const basicChargeAmount = calculateBaseCharge(aReading);
然后我运用搬移函数(198)把 calculateBaseCharge 函数搬移到增强过程中:
function enrichReading(original) {const result = _.cloneDeep(original);result.baseCharge = calculateBaseCharge(result);return result;
}
在变换函数内部,我乐得直接修改结果对象,而不是每次都复制一个新对象。我喜欢不可变的数据,但在大部分编程语言中,保持数据完全不可变很困难。在程序模块的边界处,我做好了心理准备,多花些精力来支持不可变性。但在较小的范围内,我可以接受可变的数据。另外,我把这里用到的变量命名为 aReading,表示它是一个累积变量(accumulating variable)。这样当我把更多的逻辑搬移到变换函数 enrichReading 中时,这个变量名也仍然适用。
修改客户端代码,令其改用增强后的字段:
客户端 3…
const rawReading = acquireReading();
const aReading = enrichReading(rawReading);
const basicChargeAmount = aReading.baseCharge;
当所有调用 calculateBaseCharge 的地方都修改完成后,就可以把这个函数内嵌到 enrichReading 函数中,从而更清楚地表明态度:如果需要“计算基本费用”的逻辑,请使用增强后的记录。
在这里要当心一个陷阱:在编写 enrichReading 函数时,我让它返回了增强后的读数记录,这背后隐含的意思是原始的读数记录不会被修改。所以我最好为此加个测试。
it("check reading unchanged", function () {const baseReading = { customer: "ivan", quantity: 15, month: 5, year: 2017 };const oracle = _.cloneDeep(baseReading);enrichReading(baseReading);assert.deepEqual(baseReading, oracle);
});
现在我可以修改客户端 1 的代码,让它也使用这个新添的字段。
客户端 1…
const rawReading = acquireReading();
const aReading = enrichReading(rawReading);
const baseCharge = aReading.baseCharge;
此时可以考虑用内联变量(123)去掉 baseCharge 变量。
现在我转头去看“计算应税费用”的逻辑。第一步是把变换函数用起来:
const rawReading = acquireReading();
const aReading = enrichReading(rawReading);
const base = baseRate(aReading.month, aReading.year) * aReading.quantity;
const taxableCharge = Math.max(0, base - taxThreshold(aReading.year));
基本费用的计算逻辑马上就可以改用变换得到的新字段代替。如果计算逻辑比较复杂,我可以先运用提炼函数(106)。不过这里的情况足够简单,一步到位修改过来就行。
const rawReading = acquireReading();
const aReading = enrichReading(rawReading);
const base = aReading.baseCharge;
const taxableCharge = Math.max(0, base - taxThreshold(aReading.year));
执行测试之后,我就用内联变量(123)去掉 base 变量:
const rawReading = acquireReading();
const aReading = enrichReading(rawReading);
const taxableCharge = Math.max(0,aReading.baseCharge - taxThreshold(aReading.year)
);
然后把计算逻辑搬移到变换函数中:
function enrichReading(original) {const result = _.cloneDeep(original);result.baseCharge = calculateBaseCharge(result);result.taxableCharge = Math.max(0,result.baseCharge - taxThreshold(result.year));return result;
}
修改使用方代码,让它使用新添的字段。
const rawReading = acquireReading();
const aReading = enrichReading(rawReading);
const taxableCharge = aReading.taxableCharge;
测试。现在我可以再次用内联变量(123)把 taxableCharge 变量也去掉。
增强后的读数记录有一个大问题:如果某个客户端修改了一项数据的值,会发生什么?比如说,如果某处代码修改了 quantity 字段的值,就会导致数据不一致。在 JavaScript 中,避免这种情况最好的办法是不要使用本重构手法,改用函数组合成类(144)。如果编程语言支持不可变的数据结构,那么就没有这个问题了,那样的语言中会更常用到变换。但即便编程语言不支持数据结构不可变,如果数据是在只读的上下文中被使用(例如在网页上显示派生数据),还是可以使用变换。
6.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];
}
动机
每当看见一段代码在同时处理两件不同的事,我就想把它拆分成各自独立的模块,因为这样到了需要修改的时候,我就可以单独处理每个主题,而不必同时在脑子里考虑两个不同的主题。如果运气够好的话,我可能只需要修改其中一个模块,完全不用回忆起另一个模块的诸般细节。
最简洁的拆分方法之一,就是把一大段行为分成顺序执行的两个阶段。可能你有一段处理逻辑,其输入数据的格式不符合计算逻辑的要求,所以你得先对输入数据做一番调整,使其便于处理。也可能是你把数据处理逻辑分成顺序执行的多个步骤,每个步骤负责的任务全然不同。
编译器是最典型的例子。编译器的任务很直观:接受文本(用某种编程语言编写的代码)作为输入,将其转换成某种可执行的格式(例如针对某种特定硬件的目标码)。随着经验加深,我们发现把这项大任务拆分成一系列阶段会很有帮助:首先对文本做词法分析,然后把 token 解析成语法树,然后再对语法树做几步转换(如优化),最后生成目标码。每一步都有边界明确的范围,我可以聚焦思考其中一步,而不用理解其他步骤的细节。
在大型软件中,类似这样的阶段拆分很常见,例如编译器的每个阶段又包含若干函数和类。即便只有不大的一块代码,只要我发现了有益的将其拆分成多个阶段的机会,同样可以运用拆分阶段重构手法。如果一块代码中出现了上下几段,各自使用不同的一组数据和函数,这就是最明显的线索。将这些代码片段拆分成各自独立的模块,能更明确地标示出它们之间的差异。
做法
- 将第二阶段的代码提炼成独立的函数。
- 测试。
- 引入一个中转数据结构,将其作为参数添加到提炼出的新函数的参数列表中。
- 测试。
- 逐一检查提炼出的“第二阶段函数”的每个参数。如果某个参数被第一阶段用到,就将其移入中转数据结构。每次搬移之后都要执行测试。
Tip
有时第二阶段根本不应该使用某个参数。果真如此,就把使用该参数得到的结果全都提炼成中转数据结构的字段,然后用搬移语句到调用者(217)把使用该参数的代码行搬移到“第二阶段函数”之外。
- 对第一阶段的代码运用提炼函数(106),让提炼出的函数返回中转数据结构。
Tip
也可以把第一阶段提炼成一个变换(transform)对象。
范例
我手上有一段“计算订单价格”的代码,至于订单中的商品是什么,我们从代码中看不出来,也不太关心。
function priceOrder(product, quantity, shippingMethod) {const basePrice = product.basePrice * quantity;const discount = Math.max(quantity - product.discountThreshold, 0)* product.basePrice * product.discountRate;const shippingPerCase = (basePrice > shippingMethod.discountThreshold)? shippingMethod.discountedFee : shippingMethod.feePerCase;const shippingCost = quantity * shippingPerCase;const price = basePrice - discount + shippingCost;return price;
}
虽然只是个常见的、过于简单的范例,从中还是能看出有两个不同阶段存在的。前两行代码根据商品(product)信息计算订单中与商品相关的价格,随后的两行则根据配送(shipping)信息计算配送成本。后续的修改可能还会使价格和配送的计算逻辑变复杂,但只要这两块逻辑相对独立,将这段代码拆分成两个阶段就是有价值的。
我首先用提炼函数(106)把计算配送成本的逻辑提炼出来。
function priceOrder(product, quantity, shippingMethod) {const basePrice = product.basePrice * quantity;const discount = Math.max(quantity - product.discountThreshold, 0)* product.basePrice * product.discountRate;const price = applyShipping(basePrice, shippingMethod, quantity, discount);return price;
}
function applyShipping(basePrice, shippingMethod, quantity, discount) {const shippingPerCase = (basePrice > shippingMethod.discountThreshold)? shippingMethod.discountedFee : shippingMethod.feePerCase;const shippingCost = quantity * shippingPerCase;const price = basePrice - discount + shippingCost;return price;
}
第二阶段需要的数据都以参数形式传入。在真实环境下,参数的数量可能会很多,但我对此并不担心,因为很快就会将这些参数消除掉。
随后我会引入一个中转数据结构,使其在两阶段之间沟通信息。
function priceOrder(product, quantity, shippingMethod) {const basePrice = product.basePrice * quantity;const discount = Math.max(quantity - product.discountThreshold, 0)* product.basePrice * product.discountRate;const priceData = {};const price = applyShipping(priceData, basePrice, shippingMethod, quantity, discount);return price;
}function applyShipping(priceData, basePrice, shippingMethod, quantity, discount) {const shippingPerCase = (basePrice > shippingMethod.discountThreshold)? shippingMethod.discountedFee : shippingMethod.feePerCase;const shippingCost = quantity * shippingPerCase;const price = basePrice - discount + shippingCost;return price;
}
现在我会审视 applyShipping 的各个参数。第一个参数 basePrice 是在第一阶段代码中创建的,因此我将其移入中转数据结构,并将其从参数列表中去掉。
function priceOrder(product, quantity, shippingMethod) {const basePrice = product.basePrice * quantity;const discount = Math.max(quantity - product.discountThreshold, 0)* product.basePrice * product.discountRate;const priceData = {basePrice: basePrice};const price = applyShipping(priceData, basePrice, shippingMethod, quantity, discount);return price;
}
function applyShipping(priceData, basePrice, shippingMethod, quantity, discount) {const shippingPerCase = (priceData.basePrice > shippingMethod.discountThreshold)? shippingMethod.discountedFee : shippingMethod.feePerCase;const shippingCost = quantity * shippingPerCase;const price = priceData.basePrice - discount + shippingCost;return price;
}
下一个参数是 shippingMethod。第一阶段中没有使用这项数据,所以它可以保留原样。
再下一个参数是 quantity。这个参数在第一阶段中用到,但不是在那里创建的,所以其实可以将其留在参数列表中。但我更倾向于把尽可能多的参数搬移到中转数据结构中。
function priceOrder(product, quantity, shippingMethod) {const basePrice = product.basePrice * quantity;const discount = Math.max(quantity - product.discountThreshold, 0)* product.basePrice * product.discountRate;const priceData = {basePrice: basePrice, quantity: quantity};const price = applyShipping(priceData, shippingMethod, quantity, discount);return price;
}
function applyShipping(priceData, shippingMethod, quantity, discount) {const shippingPerCase = (priceData.basePrice > shippingMethod.discountThreshold)? shippingMethod.discountedFee : shippingMethod.feePerCase;const shippingCost = priceData.quantity * shippingPerCase;const price = priceData.basePrice - discount + shippingCost;return price;
}
对 discount 参数我也如法炮制。
function priceOrder(product, quantity, shippingMethod) {const basePrice = product.basePrice * quantity;const discount = Math.max(quantity - product.discountThreshold, 0)* product.basePrice * product.discountRate;const priceData = {basePrice: basePrice, quantity: quantity, discount:discount};const price = applyShipping(priceData, shippingMethod, discount);return price;
}
function applyShipping(priceData, shippingMethod, discount) {const shippingPerCase = (priceData.basePrice > shippingMethod.discountThreshold)? shippingMethod.discountedFee : shippingMethod.feePerCase;const shippingCost = priceData.quantity * shippingPerCase;const price = priceData.basePrice - priceData.discount + shippingCost;return price;
}
处理完参数列表后,中转数据结构得到了完整的填充,现在我可以把第一阶段代码提炼成独立的函数,令其返回这份数据。
function priceOrder(product, quantity, shippingMethod) {const priceData = calculatePricingData(product, quantity);const price = applyShipping(priceData, shippingMethod);return price;
}
function calculatePricingData(product, quantity) {const basePrice = product.basePrice * quantity;const discount = Math.max(quantity - product.discountThreshold, 0)* product.basePrice * product.discountRate;return {basePrice: basePrice, quantity: quantity, discount:discount};
}
function applyShipping(priceData, shippingMethod) {const shippingPerCase = (priceData.basePrice > shippingMethod.discountThreshold)? shippingMethod.discountedFee : shippingMethod.feePerCase;const shippingCost = priceData.quantity * shippingPerCase;const price = priceData.basePrice - priceData.discount + shippingCost;return price;
}
两个函数中,最后一个 const 变量都是多余的,我忍不住洁癖,将它们内联消除掉。
function priceOrder(product, quantity, shippingMethod) {const priceData = calculatePricingData(product, quantity);return applyShipping(priceData, shippingMethod);
}function calculatePricingData(product, quantity) {const basePrice = product.basePrice * quantity;const discount = Math.max(quantity - product.discountThreshold, 0)* product.basePrice * product.discountRate;return {basePrice: basePrice, quantity: quantity, discount:discount};
}
function applyShipping(priceData, shippingMethod) {const shippingPerCase = (priceData.basePrice > shippingMethod.discountThreshold)? shippingMethod.discountedFee : shippingMethod.feePerCase;const shippingCost = priceData.quantity * shippingPerCase;return priceData.basePrice - priceData.discount + shippingCost;
}
第 7 章 封装
分解模块时最重要的标准,也许就是识别出那些模块应该对外界隐藏的小秘密了[Parnas]。数据结构无疑是最常见的一种秘密,我可以用封装记录(162)或封装集合(170)手法来隐藏它们的细节。即便是基本类型的数据,也能通过以对象取代基本类型(174)进行封装——这样做后续所带来的巨大收益通常令人惊喜。另一项经常在重构时挡道的是临时变量,我需要确保它们的计算次序正确,还得保证其他需要它们的地方能获得其值。这里以查询取代临时变量(178)手法可以帮上大忙,特别是在分解一个过长的函数时。
类是为隐藏信息而生的。在第 6 章中,我已经介绍了使用函数组合成类(144)手法来形成类的办法。此外,一般的提炼/内联操作对类也适用,见提炼类(182)和内联类(186)。
除了类的内部细节,使用隐藏委托关系(189)隐藏类之间的关联关系通常也很有帮助。但过多隐藏也会导致冗余的中间接口,此时我就需要它的反向重构——移除中间人(192)。
类与模块已然是施行封装的最大实体了,但小一点的函数对于封装实现细节也有所裨益。有时候,我可能需要将一个算法完全替换掉,这时我可以用提炼函数(106)将算法包装到函数中,然后使用替换算法(195)。
7.1 封装记录(Encapsulate Record)
曾用名:以数据类取代记录(Replace Record with Data Class)
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;}
}
动机
记录型结构是多数编程语言提供的一种常见特性。它们能直观地组织起存在关联的数据,让我可以将数据作为有意义的单元传递,而不仅是一堆数据的拼凑。但简单的记录型结构也有缺陷,最恼人的一点是,它强迫我清晰地区分“记录中存储的数据”和“通过计算得到的数据”。假使我要描述一个整数闭区间,我可以用{start: 1, end: 5}描述,或者用{start: 1, length: 5}(甚至还能用{end: 5, length: 5},如果我想露两手华丽的编程技巧的话)。但不论如何存储,这 3 个值都是我想知道的,即区间的起点(start)和终点(end),以及区间的长度(length)。
这就是对于可变数据,我总是更偏爱使用类对象而非记录的原因。对象可以隐藏结构的细节,仅为这 3 个值提供对应的方法。该对象的用户不必追究存储的细节和计算的过程。同时,这种封装还有助于字段的改名:我可以重新命名字段,但同时提供新老字段名的访问方法,这样我就可以渐进地修改调用方,直到替换全部完成。
注意,我所说的偏爱对象,是对可变数据而言。如果数据不可变,我大可直接将这 3 个值保存在记录里,需要做数据变换时增加一个填充步骤即可。重命名记录也一样简单,你可以复制一个字段并逐步替换引用点。
记录型结构可以有两种类型:一种需要声明合法的字段名字,另一种可以随便用任何字段名字。后者常由语言库本身实现,并通过类的形式提供出来,这些类称为散列(hash)、映射(map)、散列映射(hashmap)、字典(dictionary)或关联数组(associative array)等。很多编程语言都提供了方便的语法来创建这类记录,这使得它们在各种编程场景下都能大展身手。但使用这类结构也有缺陷,那就是一条记录上持有什么字段往往不够直观。比如说,如果我想知道记录里维护的字段究竟是起点/终点还是起点/长度,就只有查看它的创建点和使用点,除此以外别无他法。若这种记录只在程序的一个小范围里使用,那问题还不大,但若其使用范围变宽,“数据结构不直观”这个问题就会造成更多困扰。我可以重构它,使其变得更直观——但如果真需要这样做,那还不如使用类来得直接。
程序中间常常需要互相传递嵌套的列表(list)或散列映射结构,这些数据结构后续经常需要被序列化成 JSON 或 XML。这样的嵌套结构同样值得封装,这样,如果后续其结构需要变更或者需要修改记录内的值,封装能够帮我更好地应对变化。
做法
对持有记录的变量使用封装变量(132),将其封装到一个函数中。
记得为这个函数取一个容易搜索的名字。
创建一个类,将记录包装起来,并将记录变量的值替换为该类的一个实例。然后在类上定义一个访问函数,用于返回原始的记录。修改封装变量的函数,令其使用这个访问函数。
测试。
新建一个函数,让它返回该类的对象,而非那条原始的记录。
对于该记录的每处使用点,将原先返回记录的函数调用替换为那个返回实例对象的函数调用。使用对象上的访问函数来获取数据的字段,如果该字段的访问函数还不存在,那就创建一个。每次更改之后运行测试。
如果该记录比较复杂,例如是个嵌套解构,那么先重点关注客户端对数据的更新操作,对于读取操作可以考虑返回一个数据副本或只读的数据代理。
移除类对原始记录的访问函数,那个容易搜索的返回原始数据的函数也要一并删除。
测试。
如果记录中的字段本身也是复杂结构,考虑对其再次应用封装记录(162)或封装集合(170)手法。
范例
首先,我从一个常量开始,该常量在程序中被大量使用。
const organization = { name: "Acme Gooseberries", country: "GB" };
这是一个普通的 JavaScript 对象,程序中很多地方都把它当作记录型结构在使用。以下是对其进行读取和更新的地方:
result += `<h1>${organization.name}</h1>`;
organization.name = newName;
重构的第一步很简单,先施展一下封装变量(132)。
function getRawDataOfOrganization() {return organization;
}
读取的例子…
result += `<h1>${getRawDataOfOrganization().name}</h1>`;
更新的例子…
getRawDataOfOrganization().name = newName;
这里施展的不全是标准的封装变量(132)手法,我刻意为设值函数取了一个又丑又长、容易搜索的名字,因为我有意不让它在这次重构中活得太久。
封装记录意味着,仅仅替换变量还不够,我还想控制它的使用方式。我可以用类来替换记录,从而达到这一目的。
class Organization…
class Organization {constructor(data) {this._data = data;}
}
顶层作用域
const organization = new Organization({name: "Acme Gooseberries",country: "GB",
});function getRawDataOfOrganization() {return organization._data;
}
function getOrganization() {return organization;
}
创建完对象后,我就能开始寻找该记录的使用点了。所有更新记录的地方,用一个设值函数来替换它。
class Organization…
set name(aString) {this._data.name = aString;}
客户端…
getOrganization().name = newName;
同样地,我将所有读取记录的地方,用一个取值函数来替代。
class Organization…
get name() {return this._data.name;}
客户端…
result += `<h1>${getOrganization().name}</h1>`;
完成引用点的替换后,就可以兑现我之前的死亡威胁,为那个名称丑陋的函数送终了。
function getRawDataOfOrganization() {return organization._data;
}
function getOrganization() {return organization;
}
我还倾向于把_data 里的字段展开到对象中。
class Organization {constructor(data) {this._name = data.name;this._country = data.country;}get name() {return this._name;}set name(aString) {this._name = aString;}get country() {return this._country;}set country(aCountryCode) {this._country = aCountryCode;}
}
这样做有一个好处,能够使外界无须再引用原始的数据记录。直接持有原始的记录会破坏封装的完整性。但有时也可能不适合将对象展开到独立的字段里,此时我就会先将_data 复制一份,再进行赋值。
范例:封装嵌套记录
上面的例子将记录的浅复制展开到了对象里,但当我处理深层嵌套的数据(比如来自 JSON 文件的数据)时,又该怎么办呢?此时该重构手法的核心步骤依然适用,记录的更新点需要同样小心处理,但对记录的读取点则有多种处理方案。
作为例子,这里有一个嵌套层级更深的数据:它是一组顾客信息的集合,保存在散列映射中,并通过顾客 ID 进行索引。
"1920": {name: "martin",id: "1920",usages: {"2016": {"1": 50,"2": 55,// remaining months of the year},"2015": {"1": 70,"2": 63,// remaining months of the year}}
},
"38673": {name: "neal",id: "38673",// more customers in a similar form
}
对嵌套数据的更新和读取可以进到更深的层级。
更新的例子…
customerData[customerID].usages[year][month] = amount;
读取的例子…
function compareUsage(customerID, laterYear, month) {const later = customerData[customerID].usages[laterYear][month];const earlier = customerData[customerID].usages[laterYear - 1][month];return { laterAmount: later, change: later - earlier };
}
对这样的数据施行封装,第一步仍是封装变量(132)。
function getRawDataOfCustomers() {return customerData;
}
function setRawDataOfCustomers(arg) {customerData = arg;
}
更新的例子…
getRawDataOfCustomers()[customerID].usages[year][month] = amount;
读取的例子…
function compareUsage(customerID, laterYear, month) {const later = getRawDataOfCustomers()[customerID].usages[laterYear][month];const earlier = getRawDataOfCustomers()[customerID].usages[laterYear - 1][month];return { laterAmount: later, change: later - earlier };
}
接下来我要创建一个类来容纳整个数据结构。
class CustomerData {constructor(data) {this._data = data;}
}
顶层作用域…
function getCustomerData() {return customerData;
}
function getRawDataOfCustomers() {return customerData._data;
}
function setRawDataOfCustomers(arg) {customerData = new CustomerData(arg);
}
最重要的是妥善处理好那些更新操作。因此,当我查看 getRawDataOfCustomers 的所有调用者时,总是特别关注那些对数据做修改的地方。再提醒你一下,下面是那步更新操作。
更新的例子…
getRawDataOfCustomers()[customerID].usages[year][month] = amount;
“做法”部分说,接下来要通过一个访问函数来返回原始的顾客数据,如果访问函数还不存在就创建一个。现在顾客类还没有设值函数,而且这个更新操作对结构进行了深入查找,因此是时候创建一个设值函数了。我会先用提炼函数(106),将层层深入数据结构的查找操作提炼到函数里。
更新的例子…
setUsage(customerID, year, month, amount);
顶层作用域…
function setUsage(customerID, year, month, amount) {getRawDataOfCustomers()[customerID].usages[year][month] = amount;
}
然后我再用搬移函数(198)将新函数搬移到新的顾客数据类中。
更新的例子…
getCustomerData().setUsage(customerID, year, month, amount);
class CustomerData…
setUsage(customerID, year, month, amount) {this._data[customerID].usages[year][month] = amount;
}
封装大型的数据结构时,我会更多关注更新操作。凸显更新操作,并将它们集中到一处地方,是此次封装过程最重要的一部分。
一通替换过后,我可能认为修改已经告一段落,但如何确认替换是否真正完成了呢?检查的办法有很多,比如可以修改 getRawDataOfCustomers 函数,让其返回一份数据的深复制的副本。如果测试覆盖足够全面,那么当我真的遗漏了一些更新点时,测试就会报错。
顶层作用域…
function getCustomerData() {return customerData;
}
function getRawDataOfCustomers() {return customerData.rawData;
}
function setRawDataOfCustomers(arg) {customerData = new CustomerData(arg);
}
class CustomerData…
get rawData() {return _.cloneDeep(this._data);
}
我使用了 lodash 库来辅助生成深复制的副本。
另一个方式是,返回一份只读的数据代理。如果客户端代码尝试修改对象的结构,那么该数据代理就会抛出异常。这在有些编程语言中能轻易实现,但用 JavaScript 实现可就麻烦了,我把它留给读者作为练习好了。或者,我可以复制一份数据,递归冻结副本的每个字段,以此阻止对它的任何修改企图。
妥善处理好数据的更新当然价值不凡,但读取操作又怎么处理呢?这有几种选择。
第一种选择是与设值函数采用同等待遇,把所有对数据的读取提炼成函数,并将它们搬移到 CustomerData 类中。
class CustomerData…
usage(customerID, year, month) {return this._data[customerID].usages[year][month];
}
顶层作用域…
function compareUsage(customerID, laterYear, month) {const later = getCustomerData().usage(customerID, laterYear, month);const earlier = getCustomerData().usage(customerID, laterYear - 1, month);return { laterAmount: later, change: later - earlier };
}
这种处理方式的美妙之处在于,它为 customerData 提供了一份清晰的 API 列表,清楚描绘了该类的全部用途。我只需阅读类的代码,就能知道数据的所有用法。但这样会使代码量剧增,特别是当对象有许多用途时。现代编程语言大多提供直观的语法,以支持从深层的列表和散列[mf-lh]结构中获得数据,因此直接把这样的数据结构给到客户端,也不失为一种选择。
如果客户端想拿到一份数据结构,我大可以直接将实际的数据交出去。但这样做的问题在于,我将无从阻止用户直接对数据进行修改,进而使我们封装所有更新操作的良苦用心失去意义。最简单的应对办法是返回原始数据的一份副本,这可以用到我前面写的 rawData 方法。
class CustomerData…
get rawData() {return _.cloneDeep(this._data);
}
顶层作用域…
function compareUsage(customerID, laterYear, month) {const later = getCustomerData().rawData[customerID].usages[laterYear][month];const earlier = getCustomerData().rawData[customerID].usages[laterYear - 1][month];return { laterAmount: later, change: later - earlier };
}
简单归简单,这种方案也有缺点。最明显的问题是复制巨大的数据结构时代价颇高,这可能引发性能问题。不过也正如我对性能问题的一贯态度,这样的性能损耗也许是可以接受的——只有测量到可见的影响,我才会真的关心它。这种方案还可能带来困惑,比如客户端可能期望对该数据的修改会同时反映到原数据上。如果采用了只读代理或冻结副本数据的方案,就可以在此时提供一个有意义的错误信息。
另一种方案需要更多工作,但能提供更可靠的控制粒度:对每个字段循环应用封装记录。我会把顾客(customer)记录变成一个类,对其用途(usage)字段应用封装集合(170),并为它创建一个类。然后我就能通过访问函数来控制其更新点,比如说对用途(usage)对象应用将引用对象改为值对象(252)。但处理一个大型的数据结构时,这种方案异常繁复,如果对该数据结构的更新点没那么多,其实大可不必这么做。有时,合理混用取值函数和新对象可能更明智,即使用取值函数来封装数据的深层查找操作,但更新数据时则用对象来包装其结构,而非直接操作未经封装的数据。我在“Refactoring Code to Load a Document”[mf-ref-doc]这篇文章中讨论了更多的细节,有兴趣的读者可移步阅读。
7.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) { ... }
动机
我喜欢封装程序中的所有可变数据。这使我很容易看清楚数据被修改的地点和修改方式,这样当我需要更改数据结构时就非常方便。我们通常鼓励封装——使用面向对象技术的开发者对封装尤为重视——但封装集合时人们常常犯一个错误:只对集合变量的访问进行了封装,但依然让取值函数返回集合本身。这使得集合的成员变量可以直接被修改,而封装它的类则全然不知,无法介入。
为避免此种情况,我会在类上提供一些修改集合的方法——通常是“添加”和“移除”方法。这样就可使对集合的修改必须经过类,当程序演化变大时,我依然能轻易找出修改点。
只要团队拥有良好的习惯,就不会在模块以外修改集合,仅仅提供这些修改方法似乎也就足够。然而,依赖于别人的好习惯是不明智的,一个细小的疏忽就可能带来难以调试的 bug。更好的做法是,不要让集合的取值函数返回原始集合,这就避免了客户端的意外修改。
一种避免直接修改集合的方法是,永远不直接返回集合的值。这种方法提倡,不要直接使用集合的字段,而是通过定义类上的方法来代替,比如将 aCustomer.orders.size 替换为 aCustomer.numberOfOrders。我不同意这种做法。现代编程语言都提供了丰富的集合类和标准接口,能够组合成很多有价值的用法,比如集合管道(Collection Pipeline)[mf-cp]等。使用特殊的类方法来处理这些场景,会增加许多额外代码,使集合操作容易组合的特性大打折扣。
还有一种方法是,以某种形式限制集合的访问权,只允许对集合进行读操作。比如,在 Java 中可以很容易地返回集合的一个只读代理,这种代理允许用户读取集合,但会阻止所有更改操作——Java 的代理会抛出一个异常。有一些库在构造集合时也用了类似的方法,将构造出的集合建立在迭代器或枚举对象的基础上,因为迭代器也不能修改它迭代的集合。
也许最常见的做法是,为集合提供一个取值函数,但令其返回一个集合的副本。这样即使有人修改了副本,被封装的集合也不会受到影响。这可能带来一些困惑,特别是对那些已经习惯于通过修改返回值来修改原集合的开发者——但更多的情况下,开发者已经习惯于取值函数返回副本的做法。如果集合很大,这个做法可能带来性能问题,好在多数列表都没有那么大,此时前述的性能优化基本守则依然适用(见 2.8 节)。
使用数据代理和数据复制的另一个区别是,对源数据的修改会反映到代理上,但不会反映到副本上。大多数时候这个区别影响不大,因为通过此种方式访问的列表通常生命周期都不长。
采用哪种方法并无定式,最重要的是在同个代码库中做法要保持一致。我建议只用一种方案,这样每个人都能很快习惯它,并在每次调用集合的访问函数时期望相同的行为。
做法
如果集合的引用尚未被封装起来,先用封装变量(132)封装它。
在类上添加用于“添加集合元素”和“移除集合元素”的函数。
如果存在对该集合的设值函数,尽可能先用移除设值函数(331)移除它。如果不能移除该设值函数,至少让它返回集合的一份副本。
执行静态检查。
查找集合的引用点。如果有调用者直接修改集合,令该处调用使用新的添加/移除元素的函数。每次修改后执行测试。
修改集合的取值函数,使其返回一份只读的数据,可以使用只读代理或数据副本。
测试。
范例
假设有个人(Person)要去上课。我们用一个简单的 Course 来表示“课程”。
class Person…
constructor (name) {this._name = name;this._courses = [];
}
get name() {return this._name;}
get courses() {return this._courses;}
set courses(aList) {this._courses = aList;}
class Course…
constructor(name, isAdvanced) {this._name = name;this._isAdvanced = isAdvanced;
}
get name() {return this._name;}
get isAdvanced() {return this._isAdvanced;}
客户端会使用课程集合来获取课程的相关信息。
numAdvancedCourses = aPerson.courses.filter(c => c.isAdvanced).length;
有些开发者可能觉得这个类已经得到了恰当的封装,毕竟,所有的字段都被访问函数保护到了。但我要指出,对课程列表的封装还不完整。诚然,对列表整体的任何更新操作,都能通过设值函数得到控制。
客户端代码…
const basicCourseNames = readBasicCourseNames(filename);
aPerson.courses = basicCourseNames.map(name => new Course(name, false));
但客户端也可能发现,直接更新课程列表显然更容易。
客户端代码…
for (const name of readBasicCourseNames(filename)) {aPerson.courses.push(new Course(name, false));
}
这就破坏了封装性,因为以此种方式更新列表 Person 类根本无从得知。这里仅仅封装了字段引用,而未真正封装字段的内容。
现在我来对类实施真正恰当的封装,首先要为类添加两个方法,为客户端提供“添加课程”和“移除课程”的接口。
class Person…
addCourse(aCourse) {this._courses.push(aCourse);
}
removeCourse(aCourse, fnIfAbsent = () => {throw new RangeError();}) {const index = this._courses.indexOf(aCourse);if (index === -1) fnIfAbsent();else this._courses.splice(index, 1);
}
对于移除操作,我得考虑一下,如果客户端要求移除一个不存在的集合元素怎么办。我可以耸耸肩装作没看见,也可以抛出错误。这里我默认让它抛出错误,但留给客户端一个自己处理的机会。
然后我就可以让直接修改集合值的地方改用新的方法了。
客户端代码…
for (const name of readBasicCourseNames(filename)) {aPerson.addCourse(new Course(name, false));
}
有了单独的添加和移除方法,通常 setCourse 设值函数就没必要存在了。若果真如此,我就会使用移除设值函数(331)移除它。如果出于其他原因,必须提供一个设值方法作为 API,我至少要确保用一份副本给字段赋值,不去修改通过参数传入的集合。
class Person…
set courses(aList) {this._courses = aList.slice();}
这套设施让客户端能够使用正确的修改方法,同时我还希望能确保所有修改都通过这些方法进行。为达此目的,我会让取值函数返回一份副本。
class Person…
get courses() {return this._courses.slice();}
总的来讲,我觉得对集合保持适度的审慎是有益的,我宁愿多复制一份数据,也不愿去调试因意外修改集合招致的错误。修改操作并不总是显而易见的,比如,在 JavaScript 中原生的数组排序函数 sort()就会修改原数组,而在其他语言中默认都是为更改集合的操作返回一份副本。任何负责管理集合的类都应该总是返回数据副本,但我还养成了一个习惯,只要我做的事看起来可能改变集合,我也会返回一个副本。
7.3 以对象取代基本类型(Replace Primitive with Object)
曾用名:以对象取代数据值(Replace Data Value with Object)
曾用名:以类取代类型码(Replace Type Code with Class)
orders.filter(o => "high" === o.priority || "rush" === o.priority);orders.filter(o => o.priority.higherThan(new Priority("normal")))
动机
开发初期,你往往决定以简单的数据项表示简单的情况,比如使用数字或字符串等。但随着开发的进行,你可能会发现,这些简单数据项不再那么简单了。比如说,一开始你可能会用一个字符串来表示“电话号码”的概念,但是随后它又需要“格式化”“抽取区号”之类的特殊行为。这类逻辑很快便会占领代码库,制造出许多重复代码,增加使用时的成本。
一旦我发现对某个数据的操作不仅仅局限于打印时,我就会为它创建一个新类。一开始这个类也许只是简单包装一下简单类型的数据,不过只要类有了,日后添加的业务逻辑就有地可去了。这些小小的封装值开始可能价值甚微,但只要悉心照料,它们很快便能成长为有用的工具。创建新类无须太大的工作量,但我发现它们往往对代码库有深远的影响。实际上,许多经验丰富的开发者认为,这是他们的工具箱里最实用的重构手法之一——尽管其价值常为新手程序员所低估。
做法
如果变量尚未被封装起来,先使用封装变量(132)封装它。
为这个数据值创建一个简单的类。类的构造函数应该保存这个数据值,并为它提供一个取值函数。
执行静态检查。
修改第一步得到的设值函数,令其创建一个新类的对象并将其存入字段,如果有必要的话,同时修改字段的类型声明。
修改取值函数,令其调用新类的取值函数,并返回结果。
测试。
考虑对第一步得到的访问函数使用函数改名(124),以便更好反映其用途。
考虑应用将引用对象改为值对象(252)或将值对象改为引用对象(256),明确指出新对象的角色是值对象还是引用对象。
范例
我将从一个简单的订单(Order)类开始。该类从一个简单的记录结构里读取所需的数据,这其中有一个订单优先级(priority)字段,它是以字符串的形式被读入的。
class Order…
constructor(data) {this.priority = data.priority;// more initialization
}
客户端代码有些地方是这么用它的:
客户端…
highPriorityCount = orders.filter(o => "high" === o.priority|| "rush" === o.priority).length;
无论何时,当我与一个数据值打交道时,第一件事一定是对它使用封装变量(132)。
class Order…
get priority() {return this._priority;}
set priority(aString) {this._priority = aString;}
现在构造函数中第一行初始化代码就会使用我刚刚创建的设值函数了。
这使它成了一个自封装的字段,因此我暂可放任原来的引用点不理,先对字段进行处理。
接下来我为优先级字段创建一个简单的值类(value class)。该类应该有一个构造函数接收值字段,并提供一个返回字符串的转换函数。
class Priority {constructor(value) {this._value = value;}toString() {return this._value;}
}
这里的转换函数我更倾向于使用 toString 而不用取值函数(value)。对类的客户端而言,一个返回字符串描述的 API 应该更能传达“发生了数据转换”的信息,而使用取值函数取用一个字段就缺乏这方面的感觉。
然后我要修改访问函数,使其用上新创建的类。
class Order…
get priority() {return this._priority.toString();}
set priority(aString) {this._priority = new Priority(aString);}
提炼出 Priority 类后,我发觉现在 Order 类上的取值函数命名有点儿误导人了。它确实还是返回了优先级信息,但却是一个字符串描述,而不是一个 Priority 对象。于是我立即对它应用了函数改名(124)。
class Order…
get priorityString() {return this._priority.toString();}
set priority(aString) {this._priority = new Priority(aString);}
客户端…
highPriorityCount = orders.filter(o => "high" === o.priorityString|| "rush" === o.priorityString).length;
这里设值函数的名字倒没有使我不满,因为函数的参数能够清晰地表达其意图。
到此为止,正式的重构手法就结束了。不过当我进一步查看优先级字段的客户端时,我在想让它们直接使用 Priority 对象是否会更好。于是,我着手在订单类上添加一个取值函数,让它直接返回新建的 Priority 对象。
class Order…
get priority() {return this._priority;}
get priorityString() {return this._priority.toString();}
set priority(aString) {this._priority = new Priority(aString);}
客户端…
highPriorityCount = orders.filter(o => "high" === o.priority.toString()|| "rush" === o.priority.toString()).length;
随着 Priority 对象在别处也有了用处,我开始支持让 Order 类的客户端拿着 Priority 实例来调用设值函数,这可以通过调整 Priority 类的构造函数实现。
class Priority…
constructor(value) {if (value instanceof Priority) return value;this._value = value;
}
这样做的意义在于,现在新的 Priority 类可以容纳更多业务行为——无论是新的业务代码,还是从别处搬移过来的。这里有些例子,它会校验优先级的传入值,支持一些比较逻辑。
class Priority…
constructor(value) {if (value instanceof Priority) return value;if (Priority.legalValues().includes(value))this._value = value;elsethrow new Error(`<${value}> is invalid for Priority`);
}
toString() {return this._value;}
get _index() {return Priority.legalValues().findIndex(s => s === this._value);}
static legalValues() {return ['low', 'normal', 'high', 'rush'];}equals(other) {return this._index === other._index;}
higherThan(other) {return this._index > other._index;}
lowerThan(other) {return this._index < other._index;}
修改的过程中,我发觉它实际上已经担负起值对象(value object)的角色,因此我又为它添加了一个 equals 方法,并确保它的值不可修改。
加上这些行为后,我可以让客户端代码读起来含义更清晰。
客户端…
highPriorityCount = orders.filter(o => o.priority.higherThan(new Priority("normal"))).length;
7.4 以查询取代临时变量(Replace Temp with Query)
const basePrice = this._quantity * this._itemPrice;
if (basePrice > 1000)return basePrice * 0.95;
elsereturn basePrice * 0.98;get basePrice() {this._quantity * this._itemPrice;}...if (this.basePrice > 1000)return this.basePrice * 0.95;
elsereturn this.basePrice * 0.98;
动机
临时变量的一个作用是保存某段代码的返回值,以便在函数的后面部分使用它。临时变量允许我引用之前的值,既能解释它的含义,还能避免对代码进行重复计算。但尽管使用变量很方便,很多时候还是值得更进一步,将它们抽取成函数。
如果我正在分解一个冗长的函数,那么将变量抽取到函数里能使函数的分解过程更简单,因为我就不再需要将变量作为参数传递给提炼出来的小函数。将变量的计算逻辑放到函数中,也有助于在提炼得到的函数与原函数之间设立清晰的边界,这能帮我发现并避免难缠的依赖及副作用。
改用函数还让我避免了在多个函数中重复编写计算逻辑。每当我在不同的地方看见同一段变量的计算逻辑,我就会想方设法将它们挪到同一个函数里。
这项重构手法在类中施展效果最好,因为类为待提炼函数提供了一个共同的上下文。如果不是在类中,我很可能会在顶层函数中拥有过多参数,这将冲淡提炼函数所能带来的诸多好处。使用嵌套的小函数可以避免这个问题,但又限制了我在相关函数间分享逻辑的能力。
以查询取代临时变量(178)手法只适用于处理某些类型的临时变量:那些只被计算一次且之后不再被修改的变量。最简单的情况是,这个临时变量只被赋值一次,但在更复杂的代码片段里,变量也可能被多次赋值——此时应该将这些计算代码一并提炼到查询函数中。并且,待提炼的逻辑多次计算同样的变量时,应该能得到相同的结果。因此,对于那些做快照用途的临时变量(从变量名往往可见端倪,比如 oldAddress 这样的名字),就不能使用本手法。
做法
检查变量在使用前是否已经完全计算完毕,检查计算它的那段代码是否每次都能得到一样的值。
如果变量目前不是只读的,但是可以改造成只读变量,那就先改造它。
测试。
将为变量赋值的代码段提炼成函数。
如果变量和函数不能使用同样的名字,那么先为函数取个临时的名字。
确保待提炼函数没有副作用。若有,先应用将查询函数和修改函数分离(306)手法隔离副作用。
测试。
应用内联变量(123)手法移除临时变量。
范例
这里有一个简单的订单类。
class Order…
class Order {constructor(quantity, item) {this._quantity = quantity;this._item = item;}get price() {var basePrice = this._quantity * this._item.price;var discountFactor = 0.98;if (basePrice > 1000) discountFactor -= 0.03;return basePrice * discountFactor;}
}
我希望把 basePrice 和 discountFactor 两个临时变量变成函数。
先从 basePrice 开始,我先把它声明成 const 并运行测试。这可以很好地防止我遗漏了对变量的其他赋值点——对于这么个小函数是不太可能的,但当我处理更大的函数时就不一定了。
class Order…
class Order {constructor(quantity, item) {this._quantity = quantity;this._item = item;}get price() {const basePrice = this._quantity * this._item.price;var discountFactor = 0.98;if (basePrice > 1000) discountFactor -= 0.03;return basePrice * discountFactor;}
}
然后我把赋值操作的右边提炼成一个取值函数。
class Order…
get price() {const basePrice = this.basePrice;var discountFactor = 0.98;if (basePrice > 1000) discountFactor -= 0.03;return basePrice * discountFactor;
}get basePrice() {return this._quantity * this._item.price;
}
测试,然后应用内联变量(123)。
class Order…
get price() {const basePrice = this.basePrice;var discountFactor = 0.98;if (this.basePrice > 1000) discountFactor -= 0.03;return this.basePrice * discountFactor;
}
接下来我对 discountFactor 重复同样的步骤,先是应用提炼函数(106)。
class Order…
get price() {const discountFactor = this.discountFactor;return this.basePrice * discountFactor;
}get discountFactor() {var discountFactor = 0.98;if (this.basePrice > 1000) discountFactor -= 0.03;return discountFactor;
}
这里我需要将对 discountFactor 的两处赋值一起搬移到新提炼的函数中,之后就可以将原变量一起声明为 const。
然后,内联变量:
get price() {return this.basePrice * this.discountFactor;
}
7.5 提炼类(Extract Class)
反向重构:内联类(186)
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;}
}
动机
你也许听过类似这样的建议:一个类应该是一个清晰的抽象,只处理一些明确的责任,等等。但是在实际工作中,类会不断成长扩展。你会在这儿加入一些功能,在那儿加入一些数据。给某个类添加一项新责任时,你会觉得不值得为这项责任分离出一个独立的类。于是,随着责任不断增加,这个类会变得过分复杂。很快,你的类就会变成一团乱麻。
设想你有一个维护大量函数和数据的类。这样的类往往因为太大而不易理解。此时你需要考虑哪些部分可以分离出去,并将它们分离到一个独立的类中。如果某些数据和某些函数总是一起出现,某些数据经常同时变化甚至彼此相依,这就表示你应该将它们分离出去。一个有用的测试就是问你自己,如果你搬移了某些字段和函数,会发生什么事?其他字段和函数是否因此变得无意义?
另一个往往在开发后期出现的信号是类的子类化方式。如果你发现子类化只影响类的部分特性,或如果你发现某些特性需要以一种方式来子类化,某些特性则需要以另一种方式子类化,这就意味着你需要分解原来的类。
做法
决定如何分解类所负的责任。
创建一个新的类,用以表现从旧类中分离出来的责任。
如果旧类剩下的责任与旧类的名称不符,为旧类改名。
构造旧类时创建一个新类的实例,建立“从旧类访问新类”的连接关系。
对于你想搬移的每一个字段,运用搬移字段(207)搬移之。每次更改后运行测试。
使用搬移函数(198)将必要函数搬移到新类。先搬移较低层函数(也就是“被其他函数调用”多于“调用其他函数”者)。每次更改后运行测试。
检查两个类的接口,去掉不再需要的函数,必要时为函数重新取一个适合新环境的名字。
决定是否公开新的类。如果确实需要,考虑对新类应用将引用对象改为值对象(252)使其成为一个值对象。
范例
我们从一个简单的 Person 类开始。
class Person…
get name() {return this._name;}
set name(arg) {this._name = arg;}
get telephoneNumber() {return `(${this.officeAreaCode}) ${this.officeNumber}`;}
get officeAreaCode() {return this._officeAreaCode;}
set officeAreaCode(arg) {this._officeAreaCode = arg;}
get officeNumber() {return this._officeNumber;}
set officeNumber(arg) {this._officeNumber = arg;}
这里,我可以将与电话号码相关的行为分离到一个独立的类中。首先,我要定义一个空的 TelephoneNumber 类来表示“电话号码”这个概念:
class TelephoneNumber {}
易如反掌!接着,我要在构造 Person 类时创建 TelephoneNumber 类的一个实例。
class Person…
constructor() {this._telephoneNumber = new TelephoneNumber();
}
class TelephoneNumber…
get officeAreaCode() {return this._officeAreaCode;}
set officeAreaCode(arg) {this._officeAreaCode = arg;}
现在,我运用搬移字段(207)搬移一个字段。
class Person…
get officeAreaCode() {return this._telephoneNumber.officeAreaCode;}
set officeAreaCode(arg) {this._telephoneNumber.officeAreaCode = arg;}
再次运行测试,然后我对下一个字段进行同样处理。
class TelephoneNumber…
get officeNumber() {return this._officeNumber;}
set officeNumber(arg) {this._officeNumber = arg;}
class Person…
get officeNumber() {return this._telephoneNumber.officeNumber;}
set officeNumber(arg) {this._telephoneNumber.officeNumber = arg;}
再次测试,然后再搬移对电话号码的取值函数。
class TelephoneNumber…
get telephoneNumber() {return `(${this.officeAreaCode}) ${this.officeNumber}`;}
class Person…
get telephoneNumber() {return this._telephoneNumber.telephoneNumber;}
现在我需要做些清理工作。“电话号码”显然不该拥有“办公室”(office)的概念,因此我得重命名一下变量。
class TelephoneNumber…
get areaCode() {return this._areaCode;}
set areaCode(arg) {this._areaCode = arg;}get number() {return this._number;}
set number(arg) {this._number = arg;}
class Person…
get officeAreaCode() {return this._telephoneNumber.areaCode;}
set officeAreaCode(arg) {this._telephoneNumber.areaCode = arg;}
get officeNumber() {return this._telephoneNumber.number;}
set officeNumber(arg) {this._telephoneNumber.number = arg;}
TelephoneNumber 类上有一个对自己(telephone number)的取值函数也没什么道理,因此我又对它应用函数改名(124)。
class TelephoneNumber…
toString() {return `(${this.areaCode}) ${this.number}`;}
class Person…
get telephoneNumber() {return this._telephoneNumber.toString();}
“电话号码”对象一般还具有复用价值,因此我考虑将新提炼的类暴露给更多的客户端。需要访问 TelephoneNumber 对象时,只须把 Person 类中那些 office 开头的访问函数搬移过来并略作修改即可。但这样 TelephoneNumber 就更像一个值对象(Value Object)[mf-vo]了,因此我会先对它使用将引用对象改为值对象(252)(那个重构手法所用的范例,正是基于本章电话号码例子的延续)。
7.6 内联类(Inline Class)
反向重构:提炼类(182)
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;}
}
动机
内联类正好与提炼类(182)相反。如果一个类不再承担足够责任,不再有单独存在的理由(这通常是因为此前的重构动作移走了这个类的责任),我就会挑选这一“萎缩类”的最频繁用户(也是一个类),以本手法将“萎缩类”塞进另一个类中。
应用这个手法的另一个场景是,我手头有两个类,想重新安排它们肩负的职责,并让它们产生关联。这时我发现先用本手法将它们内联成一个类再用提炼类(182)去分离其职责会更加简单。这是重新组织代码时常用的做法:有时把相关元素一口气搬移到位更简单,但有时先用内联手法合并各自的上下文,再使用提炼手法再次分离它们会更合适。
做法
对于待内联类(源类)中的所有 public 函数,在目标类上创建一个对应的函数,新创建的所有函数应该直接委托至源类。
修改源类 public 方法的所有引用点,令它们调用目标类对应的委托方法。每次更改后运行测试。
将源类中的函数与数据全部搬移到目标类,每次修改之后进行测试,直到源类变成空壳为止。
删除源类,为它举行一个简单的“丧礼”
范例
下面这个类存储了一次物流运输(shipment)的若干跟踪信息(tracking information)。
class TrackingInformation {get shippingCompany() {return this._shippingCompany;}set shippingCompany(arg) {this._shippingCompany = arg;}get trackingNumber() {return this._trackingNumber;}set trackingNumber(arg) {this._trackingNumber = arg;}get display() {return `${this.shippingCompany}: ${this.trackingNumber}`;}
}
它作为 Shipment(物流)类的一部分被使用。
class Shipment…
get trackingInfo() {return this._trackingInformation.display;
}
get trackingInformation() {return this._trackingInformation;}
set trackingInformation(aTrackingInformation) {this._trackingInformation = aTrackingInformation;
}
TrackingInformation 类过去可能有很多光荣职责,但现在我觉得它已不再能肩负起它的责任,因此我希望将它内联到 Shipment 类里。
首先,我要寻找 TrackingInformation 类的方法有哪些调用点。
调用方…
aShipment.trackingInformation.shippingCompany = request.vendor;
我将开始将源类的类似函数全都搬移到 Shipment 里去,但我的做法与做搬移函数(198)时略微有些不同。这里,我先在 Shipment 类里创建一个委托方法,并调整客户端代码,使其调用这个委托方法。
class Shipment…
set shippingCompany(arg) {this._trackingInformation.shippingCompany = arg;}
调用方…
aShipment.trackingInformation.shippingCompany = request.vendor;
对于 TrackingInformation 类中所有为客户端调用的方法,我将施以相同的手法。这之后,我就可以将源类中的所有东西都搬移到 Shipment 类中去。
我先对 display 方法应用内联函数(115)手法。
class Shipment…
get trackingInfo() {return `${this.shippingCompany}: ${this.trackingNumber}`;
}
再继续搬移“收货公司”(shipping company)字段。
get shippingCompany() {return this._trackingInformation._shippingCompany;}
set shippingCompany(arg) {this._trackingInformation._shippingCompany = arg;}
我并未遵循搬移字段(207)的全部步骤,因为此处我只是改由目标类 Shipment 来引用 shippingCompany,那些从源类搬移引用至目标类的步骤在此并不需要。
我会继续相同的手法,直到所有搬迁工作完成为止。那时,我就可以删除 TrackingInformation 类了。
class Shipment…
get trackingInfo() {return `${this.shippingCompany}: ${this.trackingNumber}`;
}
get shippingCompany() {return this._shippingCompany;}
set shippingCompany(arg) {this._shippingCompany = arg;}
get trackingNumber() {return this._trackingNumber;}
set trackingNumber(arg) {this._trackingNumber = arg;}
7.7 隐藏委托关系(Hide Delegate)
反向重构:移除中间人(192)
manager = aPerson.department.manager;manager = aPerson.manager;class Person {get manager() {return this.department.manager;}
}
动机
一个好的模块化的设计,“封装”即使不是其最关键特征,也是最关键特征之一。“封装”意味着每个模块都应该尽可能少了解系统的其他部分。如此一来,一旦发生变化,需要了解这一变化的模块就会比较少——这会使变化比较容易进行。
当我们初学面向对象技术时就被教导,封装意味着应该隐藏自己的字段。随着经验日渐丰富,你会发现,有更多可以(而且值得)封装的东西。
如果某些客户端先通过服务对象的字段得到另一个对象(受托类),然后调用后者的函数,那么客户就必须知晓这一层委托关系。万一受托类修改了接口,变化会波及通过服务对象使用它的所有客户端。我可以在服务对象上放置一个简单的委托函数,将委托关系隐藏起来,从而去除这种依赖。这么一来,即使将来委托关系发生变化,变化也只会影响服务对象,而不会直接波及所有客户端。
做法
对于每个委托关系中的函数,在服务对象端建立一个简单的委托函数。
调整客户端,令它只调用服务对象提供的函数。每次调整后运行测试。
如果将来不再有任何客户端需要取用 Delegate(受托类),便可移除服务对象中的相关访问函数。
测试。
范例
本例从两个类开始,代表“人”的 Person 和代表“部门”的 Department。
class Person…
constructor(name) {this._name = name;
}
get name() {return this._name;}
get department() {return this._department;}
set department(arg) {this._department = arg;}
class Department…
get chargeCode() {return this._chargeCode;}
set chargeCode(arg) {this._chargeCode = arg;}
get manager() {return this._manager;}
set manager(arg) {this._manager = arg;}
有些客户端希望知道某人的经理是谁,为此,它必须先取得 Department 对象。
客户端代码…
manager = aPerson.department.manager;
这样的编码就对客户端揭露了 Department 的工作原理,于是客户知道:Department 负责追踪“经理”这条信息。如果对客户隐藏 Department,可以减少耦合。为了这一目的,我在 Person 中建立一个简单的委托函数。
class Person…
get manager() {return this._department.manager;}
现在,我得修改 Person 的所有客户端,让它们改用新函数:
客户端代码…
manager = aPerson.department.manager;
只要完成了对 Department 所有函数的修改,并相应修改了 Person 的所有客户端,我就可以移除 Person 中的 department 访问函数了。
7.8 移除中间人(Remove Middle Man)
反向重构:隐藏委托关系(189)
manager = aPerson.manager;class Person {get manager() {return this.department.manager;}
}manager = aPerson.department.manager;
动机
在隐藏委托关系(189)的“动机”一节中,我谈到了“封装受托对象”的好处。但是这层封装也是有代价的。每当客户端要使用受托类的新特性时,你就必须在服务端添加一个简单委托函数。随着受托类的特性(功能)越来越多,更多的转发函数就会使人烦躁。服务类完全变成了一个中间人(81),此时就应该让客户直接调用受托类。(这个味道通常在人们狂热地遵循迪米特法则时悄然出现。我总觉得,如果这条法则当初叫作“偶尔有用的迪米特建议”,如今能少很多烦恼。)
很难说什么程度的隐藏才是合适的。还好,有了隐藏委托关系(189)和删除中间人,我大可不必操心这个问题,因为我可以在系统运行过程中不断进行调整。随着代码的变化,“合适的隐藏程度”这个尺度也相应改变。6 个月前恰如其分的封装,现今可能就显得笨拙。重构的意义就在于:你永远不必说对不起——只要把出问题的地方修补好就行了。
做法
为受托对象创建一个取值函数。
对于每个委托函数,让其客户端转为连续的访问函数调用。每次替换后运行测试。
替换完委托方法的所有调用点后,你就可以删掉这个委托方法了。
这能通过可自动化的重构手法来完成,你可以先对受托字段使用封装变量(132),再应用内联函数(115)内联所有使用它的函数。
范例
我又要从一个 Person 类开始了,这个类通过维护一个部门对象来决定某人的经理是谁。(如果你一口气读完本书的好几章,可能会发现每个“人与部门”的例子都出奇地相似。)
客户端代码…
manager = aPerson.manager;
class Person…
get manager() {return this._department.manager;}
class Department…
get manager() {return this._manager;}
像这样,使用和封装 Department 都很简单。但如果大量函数都这么做,我就不得不在 Person 之中安置大量委托行为。这就该是移除中间人的时候了。首先在 Person 中建立一个函数,用于获取受托对象。
class Person…
get department() {return this._department;}
然后逐一处理每个客户端,使它们直接通过受托对象完成工作。
客户端代码…
manager = aPerson.department.manager;
完成对客户端引用点的替换后,我就可以从 Person 中移除 manager 方法了。我可以重复此法,移除 Person 中其他类似的简单委托函数。
我可以混用两种用法。有些委托关系非常常用,因此我想保住它们,这样可使客户端代码调用更友好。何时应该隐藏委托关系,何时应该移除中间人,对我而言没有绝对的标准——代码环境自然会给出该使用哪种手法的线索,具备思考能力的程序员应能分辨出何种手法更佳。
如果手边在用自动化的重构工具,那么本手法的步骤有一个实用的变招:我可以先对 department 应用封装变量(132)。这样可让 manager 的取值函数调用 department 的取值函数。
class Person…
get manager() {return this.department.manager;}
在 JavaScript 中,调用取值函数的语法跟取用普通字段看起来很像,但通过移除 department 字段的下划线,我想表达出这里是调用了取值函数而非直接取用字段的区别。
然后我对 manager 方法应用内联函数(115),一口气替换它的所有调用点。
7.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)) || '';
}
动机
我从没试过给猫剥皮,听说有好几种方法,我敢肯定,其中某些方法会比另一些简单。算法也是如此。如果我发现做一件事可以有更清晰的方式,我就会用比较清晰的方式取代复杂的方式。“重构”可以把一些复杂的东西分解为较简单的小块,但有时你就必须壮士断腕,删掉整个算法,代之以较简单的算法。随着对问题有了更多理解,我往往会发现,在原先的做法之外,有更简单的解决方案,此时我就需要改变原先的算法。如果我开始使用程序库,而其中提供的某些功能/特性与我自己的代码重复,那么我也需要改变原先的算法。
有时我会想修改原先的算法,让它去做一件与原先略有差异的事。这时候可以先把原先的算法替换为一个较易修改的算法,这样后续的修改会轻松许多。
使用这项重构手法之前,我得确定自己已经尽可能分解了原先的函数。替换一个巨大且复杂的算法是非常困难的,只有先将它分解为较简单的小型函数,我才能很有把握地进行算法替换工作。
做法
- 整理一下待替换的算法,保证它已经被抽取到一个独立的函数中。
- 先只为这个函数准备测试,以便固定它的行为。
- 准备好另一个(替换用)算法。
- 执行静态检查。
- 运行测试,比对新旧算法的运行结果。如果测试通过,那就大功告成;否则,在后续测试和调试过程中,以旧算法为比较参照标准。
第 8 章 搬移特性
到目前为止,我介绍的重构手法都是关于如何新建、移除或重命名程序的元素。此外,还有另一种类型的重构也很重要,那就是在不同的上下文之间搬移元素。我会通过搬移函数(198)手法在类与其他模块之间搬移函数,对于字段可用搬移字段(207)手法做类似的搬移。
有时我还需要单独对语句进行搬移,调整它们的顺序。搬移语句到函数(213)和搬移语句到调用者(217)可用于将语句搬入函数或从函数中搬出;如果需要在函数内部调整语句的顺序,那么移动语句(223)就能派上用场。有时一些语句做的事已有现成的函数代替,那时我就能以函数调用取代内联代码(222)消除重复。
对付循环,我有两个常用的手法:拆分循环(227)可以确保每个循环只做一件事,以管道取代循环(231)则可以直接消灭整个循环。
最后这项手法,我相信一定会是任何一个合格程序员的至爱,那就是移除死代码(237)。没什么能比手刃一段长长的无用代码更令一个程序员感到满足的了。
8.1 搬移函数(Move Function)
曾用名:搬移函数(Move Method)
class Account {
get overdraftCharge() {...}class AccountType {get overdraftCharge() {...}
动机
模块化是优秀软件设计的核心所在,好的模块化能够让我在修改程序时只需理解程序的一小部分。为了设计出高度模块化的程序,我得保证互相关联的软件要素都能集中到一块,并确保块与块之间的联系易于查找、直观易懂。同时,我对模块设计的理解并不是一成不变的,随着我对代码的理解加深,我会知道那些软件要素如何组织最为恰当。要将这种理解反映到代码上,就得不断地搬移这些元素。
任何函数都需要具备上下文环境才能存活。这个上下文可以是全局的,但它更多时候是由某种形式的模块所提供的。对一个面向对象的程序而言,类作为最主要的模块化手段,其本身就能充当函数的上下文;通过嵌套的方式,外层函数也能为内层函数提供一个上下文。不同的语言提供的模块化机制各不相同,但这些模块的共同点是,它们都能为函数提供一个赖以存活的上下文环境。
搬移函数最直接的一个动因是,它频繁引用其他上下文中的元素,而对自身上下文中的元素却关心甚少。此时,让它去与那些更亲密的元素相会,通常能取得更好的封装效果,因为系统别处就可以减少对当前模块的依赖。
同样,如果我在整理代码时,发现需要频繁调用一个别处的函数,我也会考虑搬移这个函数。有时你在函数内部定义了一个帮助函数,而该帮助函数可能在别的地方也有用处,此时就可以将它搬移到某些更通用的地方。同理,定义在一个类上的函数,可能挪到另一个类中去更方便我们调用。
是否需要搬移函数常常不易抉择。为了做出决定,我需要仔细检查函数当前上下文与目标上下文之间的区别,需要查看函数的调用者都有谁,它自身又调用了哪些函数,被调用函数需要什么数据,等等。在搬移过程中,我通常会发现需要为一整组函数创建一个新的上下文,此时就可以用函数组合成类(144)或提炼类(182)创建一个。尽管为函数选择一个最好的去处不太容易,但决定越难做,通常说明“搬移这个函数与否”的重要性也越低。我发现好的做法是先把函数安置到某一个上下文里去,这样我就能发现它们是否契合,如果不太合适我可以再把函数搬移到别的地方。
做法
检查函数在当前上下文里引用的所有程序元素(包括变量和函数),考虑是否需要将它们一并搬移
如果发现有些被调用的函数也需要搬移,我通常会先搬移它们。这样可以保证移动一组函数时,总是从依赖最少的那个函数入手。
如果该函数拥有一些子函数,并且它是这些子函数的唯一调用者,那么你可以先将子函数内联进来,一并搬移到新家后再重新提炼出子函数。
检查待搬移函数是否具备多态性。
在面向对象的语言里,还需要考虑该函数是否覆写了超类的函数,或者为子类所覆写。
将函数复制一份到目标上下文中。调整函数,使它能适应新家。
如果函数里用到了源上下文(source context)中的元素,我就得将这些元素一并传递过去,要么通过函数参数,要么是将当前上下文的引用传递到新的上下文那边去。
搬移函数通常意味着,我还得给它起个新名字,使它更符合新的上下文。
执行静态检查。
设法从源上下文中正确引用目标函数。
修改源函数,使之成为一个纯委托函数。
测试。
考虑对源函数使用内联函数(115)
也可以不做内联,让源函数一直做委托调用。但如果调用方直接调用目标函数也不费太多周折,那么最好还是把中间人移除掉。
范例:搬移内嵌函数至顶层
让我用一个函数来举例。这个函数会计算一条 GPS 轨迹记录(track record)的总距离(total distance)。
function trackSummary(points) {const totalTime = calculateTime();const totalDistance = calculateDistance();const pace = totalTime / 60 / totalDistance ;return {time: totalTime,distance: totalDistance,pace: pace};function calculateDistance() {let result = 0;for (let i = 1; i < points.length; i++) {result += distance(points[i-1], points[i]);}return result;}function distance(p1,p2) { ... }function radians(degrees) { ... }function calculateTime() { ... }}
我希望把 calculateDistance 函数搬移到顶层,这样我就能单独计算轨迹的距离,而不必算出汇总报告(summary)的其他部分。
我先将函数复制一份到顶层作用域中:
function trackSummary(points) {const totalTime = calculateTime();const totalDistance = calculateDistance();const pace = totalTime / 60 / totalDistance ;return {time: totalTime,distance: totalDistance,pace: pace};function calculateDistance() {let result = 0;for (let i = 1; i < points.length; i++) {result += distance(points[i-1], points[i]);}return result;}...function distance(p1,p2) { ... }function radians(degrees) { ... }function calculateTime() { ... }}function top_calculateDistance() {let result = 0;for (let i = 1; i < points.length; i++) {result += distance(points[i-1], points[i]);}return result;}
复制函数时,我习惯为函数一并改个名,这样可让“它们有不同的作用域”这个信息显得一目了然。现在我还不想花费心思考虑它正确的名字该是什么,因此我暂且先用一个临时的名字。
此时代码依然能正常工作,但我的静态分析器要开始抱怨了,它说新函数里多了两个未定义的符号,分别是 distance 和 points。对于 points,自然是将其作为函数参数传进来。
function top_calculateDistance(points) {let result =0;for (let i = 1; i < points.length; i++) {result += distance(points[i-1], points[i]);}return result;
}
至于 distance,虽然我也可以将它作为参数传进来,但也许将其计算函数 calculate Distance 一并搬移过来会更合适。该函数的代码如下。
function trackSummary…
function distance(p1, p2) {const EARTH_RADIUS = 3959; // in milesconst dLat = radians(p2.lat) - radians(p1.lat);const dLon = radians(p2.lon) - radians(p1.lon);const a =Math.pow(Math.sin(dLat / 2), 2) +Math.cos(radians(p2.lat)) *Math.cos(radians(p1.lat)) *Math.pow(Math.sin(dLon / 2), 2);const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));return EARTH_RADIUS * c;
}
function radians(degrees) {return (degrees * Math.PI) / 180;
}
我留意到 distance 函数中只调用了 radians 函数,后者已经没有再引用当前上下文里的任何元素。因此与其将 radians 作为参数,我更倾向于将它也一并搬移。不过我不需要一步到位,我们可以先将这两个函数从当前上下文中搬移进 calculateDistance 函数里:
function trackSummary(points) {const totalTime = calculateTime();const totalDistance = calculateDistance();const pace = totalTime / 60 / totalDistance ;return {time: totalTime,distance: totalDistance,pace: pace};function calculateDistance() {let result = 0;for (let i = 1; i < points.length; i++) {result += distance(points[i-1], points[i]);}return result;function distance(p1,p2) { ... }function radians(degrees) { ... }}
这样做的好处是,我可以充分发挥静态检查和测试的作用,让它们帮我检查有无遗漏的东西。在这个实例中一切顺利,因此,我可以放心地将这两个函数直接复制到 top_calculateDistance 中:
function top_calculateDistance(points) {let result = 0;for (let i = 1; i < points.length; i++) {result += distance(points[i-1], points[i]);}return result;function distance(p1,p2) { ... }function radians(degrees) { ... }}
这次复制操作同样不会改变程序现有行为,但给了静态分析器更多介入的机会,增加了暴露错误的概率。假如我在上一步没有发现 distance 函数内部还调用了 radians 函数,那么这一步就会被分析器检查出来。
现在万事俱备,是时候端出主菜了——我要在原 calculateDistance 函数体内调用 top_calculateDistance 函数:
function trackSummary(points) {const totalTime = calculateTime();const totalDistance = calculateDistance();const pace = totalTime / 60 / totalDistance ;return {time: totalTime,distance: totalDistance,pace: pace};function calculateDistance() {return top_calculateDistance(points);}
接下来最重要的事是要运行一遍测试,看看功能是否仍然完整,函数在其新家待得是否舒适。
测试通过后,便算完成了主要任务,就好比搬家,现在大箱小箱已经全搬到新家,接下来就是将它们拆箱复位了。第一件事是决定还要不要保留原来那个只起委托作用的函数。在这个例子中,原函数的调用点不多,作为嵌套函数它们的作用范围通常也很小,因此我觉得这里大可直接移除原函数。
function trackSummary(points) {const totalTime = calculateTime();const totalDistance = top_calculateDistance(points);const pace = totalTime / 60 / totalDistance;return {time: totalTime,distance: totalDistance,pace: pace,};
}
同时,也该是时候为这个函数认真想个名字了。因为顶层函数拥有最高的可见性,因此取个好名非常重要。totalDistance 听起来不错,但还不能马上用这个名字,因为 trackSummary 函数中有一个同名的变量——我不觉得这个变量有保留的价值,因此我们先用内联变量(123)处理它,之后再使用改变函数声明(124):
function trackSummary(points) {const totalTime = calculateTime();const pace = totalTime / 60 / totalDistance(points) ;return {time: totalTime,distance: totalDistance(points),pace: pace};
}
function totalDistance(points) {let result = 0;for (let i = 1; i < points.length; i++) {result += distance(points[i-1], points[i]);}return result;
}
如果出于某些原因,实在需要保留该变量,那么我建议将该变量改个其他的名字,比如 totalDistanceCache 或 distance 等。
由于 distance 函数和 radians 函数并未使用 totalDistance 中的任何变量或函数,因此我倾向于把它们也提升到顶层,也就是 4 个方法都放置在顶层作用域上。
function trackSummary(points) { ... }
function totalDistance(points) { ... }
function distance(p1,p2) { ... }
function radians(degrees) { ... }
有些人则更倾向于将 distance 和 radians 函数保留在 totalDistance 内,以便限制它们的可见性。在某些语言里,这个顾虑也许有其道理,但新的 ES 2015 规范为 JavaScript 提供了一个美妙的模块化机制,利用它来控制函数的可见性是再好不过了。通常来说,我对嵌套函数还是心存警惕的,因为很容易在里面编写一些私有数据,并且在函数之间共享,这可能会增加代码的阅读和重构难度。
范例:在类之间搬移函数
在类之间搬移函数也是一种常见场景,下面我将用一个表示“账户”的 Account 类来讲解。
class Account…
get bankCharge() {let result = 4.5;if (this._daysOverdrawn > 0) result += this.overdraftCharge;return result;
}get overdraftCharge() {if (this.type.isPremium) {const baseCharge = 10;if (this.daysOverdrawn <= 7)return baseCharge;elsereturn baseCharge + (this.daysOverdrawn - 7) * 0.85;}elsereturn this.daysOverdrawn * 1.75;
}
上面的代码会根据账户类型(account type)的不同,决定不同的“透支金额计费”算法。因此,很自然会想到将 overdraftCharge 函数搬移到 AccountType 类去。
第一步要做的是:观察被 overdraftCharge 使用的每一项特性,考虑是否值得将它们与 overdraftCharge 函数一起移动。此例中我需要让 daysOverdrawn 字段留在 Account 类中,因为它会随不同种类的账户而变化。
然后,我将 overdraftCharge 函数主体复制到 AccountType 类中,并做相应调整。
class AccountType…
overdraftCharge(daysOverdrawn) {if (this.isPremium) {const baseCharge = 10;if (daysOverdrawn <= 7)return baseCharge;elsereturn baseCharge + (daysOverdrawn - 7) * 0.85;}elsereturn daysOverdrawn * 1.75;
}
为了使函数适应这个新家,我必须决定如何处理两个作用范围发生改变的变量。isPremium 如今只需要简单地从 this 上获取,但 daysOverdrawn 怎么办呢?我是直接传值,还是把整个 account 对象传过来?为了方便,我选择先简单传一个值,不过如果后续还需要账户(account)对象上除了 daysOverdrawn 以外的更多数据,例如需要根据账户类型(account type)来决定如何从账户(account)对象上取用数据时,那么我很可能会改变主意,转而选择传入整个 account 对象。
完成函数复制后,我会将原来的方法代之以一个委托调用。
class Account…
get bankCharge() {let result = 4.5;if (this._daysOverdrawn > 0) result += this.overdraftCharge;return result;
}get overdraftCharge() {return this.type.overdraftCharge(this.daysOverdrawn);
}
然后下一件需要决定的事情是,是保留 overdraftCharge 这个委托函数,还是直接内联它?内联的话,代码会变成下面这样。
class Account…
get bankCharge() {let result = 4.5;if (this._daysOverdrawn > 0)result += this.type.overdraftCharge(this.daysOverdrawn);return result;
}
在早先的步骤中,我将 daysOverdrawn 作为参数直接传递给 overdraftCharge 函数,但如若账户(account)对象上有很多数据需要传递,那我就比较倾向于直接将整个对象作为参数传递过去:
class Account…
get bankCharge() {let result = 4.5;if (this._daysOverdrawn > 0) result += this.overdraftCharge;return result;
}get overdraftCharge() {return this.type.overdraftCharge(this);
}
class AccountType…
overdraftCharge(account) {if (this.isPremium) {const baseCharge = 10;if (account.daysOverdrawn <= 7)return baseCharge;elsereturn baseCharge + (account.daysOverdrawn - 7) * 0.85;}elsereturn account.daysOverdrawn * 1.75;
}
8.2 搬移字段(Move Field)
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;}
动机
编程活动中你需要编写许多代码,为系统实现特定的行为,但往往数据结构才是一个健壮程序的根基。一个适应于问题域的良好数据结构,可以让行为代码变得简单明了,而一个糟糕的数据结构则将招致许多无用代码,这些代码更多是在差劲的数据结构中间纠缠不清,而非为系统实现有用的行为。代码凌乱,势必难以理解;不仅如此,坏的数据结构本身也会掩藏程序的真实意图。
因此,好的数据结构至关重要——不过这也与编程活动的许多方面一样,它们都很难一次做对。我通常都会做些预先的设计,设法得到最恰当的数据结构,此时如果你具备一些领域驱动设计(domain-driven design)方面的经验和知识,往往有助于你更好地设计数据结构。但即便经验再丰富,技能再熟练,我仍然发现我在进行初版设计时往往还是会犯错。在不断编程的过程中,我对问题域的理解会加深,对“什么是理想的数据结构”会有更多想法。这个星期看来合理而正确的设计决策,到了下个星期可能就不再正确了。
如果我发现数据结构已经不适应于需求,就应该马上修缮它。如果容许瑕疵存在并进一步累积,它们就会经常使我困惑,并且使代码愈来愈复杂。
我开始寻思搬移数据,可能是因为我发现每当调用某个函数时,除了传入一个记录参数,还总是需要同时传入另一条记录的某个字段一起作为参数。总是一同出现、一同作为函数参数传递的数据,最好是规整到同一条记录中,以体现它们之间的联系。修改的难度也是引起我注意的一个原因,如果修改一条记录时,总是需要同时改动另一条记录,那么说明很可能有字段放错了位置。此外,如果我更新一个字段时,需要同时在多个结构中做出修改,那也是一个征兆,表明该字段需要被搬移到一个集中的地点,这样每次只需修改一处地方。
搬移字段的操作通常是在其他更大的改动背景下发生的。实施字段搬移后,我可能会发现字段的诸多使用者应该通过目标对象来访问它,而不应该再通过源对象来访问。诸如此类的清理,我会在此后的重构中一并完成。同样,我也可能因为字段当前的一些用法而无法直接搬移它。我得先对其使用方式做一些重构,然后才能继续搬移工作。
到目前为止,我用以指称数据结构的术语都是“记录”(record),但以上论述对类和对象同样适用。类只是一种多了实例函数的记录,它与其他任何数据结构一样,都需要保持健康。不过类的实例函数确实简化了搬移数据的操作,因为它已经将数据的存取封装到访问函数中。当我搬移数据时,只需要相应修改访问函数的引用,该字段的所有客户端依然可以正常工作。因此,如果你的数据已经用类进行了封装,那么这个重构手法会更容易进行,我下面的展开也做了“通过类封装的数据更容易搬移”这个假设。如果你要搬移的数据是裸记录,没有任何封装,虽然类似的搬移仍然能够进行,但情况就会复杂一些。
做法
确保源字段已经得到了良好封装。
测试。
在目标对象上创建一个字段(及对应的访问函数)。
执行静态检查。
确保源对象里能够正常引用目标对象。
也许你已经有现成的字段或方法得到目标对象。如果没有,看看是否能简单地创建一个方法完成此事。如果还是不行,你可能就得在源对象里创建一个字段,用于存储目标对象了。这次修改可能留存很久,但你也可以只做临时修改,等到系统其他部分的重构完成就回来移除它。
调整源对象的访问函数,令其使用目标对象的字段。
如果源类的所有实例对象都共享对目标对象的访问权,那么可以考虑先更新源类的设值函数,让它修改源字段时,对目标对象上的字段做同样的修改。然后,再通过引入断言(302),当检测到源字段与目标字段不一致时抛出错误。一旦你确定改动没有引入任何可观察的行为变化,就可以放心地让访问函数直接使用目标对象的字段了。
测试。
移除源对象上的字段。
测试。
范例
我将用下面这个例子来介绍这项手法,其中 Customer 类代表了一位“顾客”,CustomerContract 代表与顾客关联的一个“合同”。
class Customer…
constructor(name, discountRate) {this._name = name;this._discountRate = discountRate;this._contract = new CustomerContract(dateToday());
}
get discountRate() {return this._discountRate;}
becomePreferred() {this._discountRate += 0.03;// other nice things
}
applyDiscount(amount) {return amount.subtract(amount.multiply(this._discountRate));
}
class CustomerContract…
constructor(startDate) {this._startDate = startDate;
}
我想要将折扣率(discountRate)字段从 Customer 类中搬移到 CustomerContract 里中。
第一件事情是先用封装变量(132)将对_discountRate 字段的访问封装起来。
class Customer…
constructor(name, discountRate) {this._name = name;this._setDiscountRate(discountRate);this._contract = new CustomerContract(dateToday());
}
get discountRate() {return this._discountRate;}
_setDiscountRate(aNumber) {this._discountRate = aNumber;}
becomePreferred() {this._setDiscountRate(this.discountRate + 0.03);// other nice things
}
applyDiscount(amount) {return amount.subtract(amount.multiply(this.discountRate));
}
我通过定制的 applyDiscount 方法来更新字段,而不是使用通常的设值函数,这是因为我不想为字段暴露一个 public 的设值函数。
接着我在 CustomerContract 中添加一个对应的字段和访问函数。
class CustomerContract…
constructor(startDate, discountRate) {this._startDate = startDate;this._discountRate = discountRate;
}
get discountRate() {return this._discountRate;}
set discountRate(arg) {this._discountRate = arg;}
接下来,我可以修改 customer 对象的访问函数,让它引用 CustomerContract 这个新添的字段。不过当我这么干时,我收到了一个错误:“Cannot set property ‘discountRate’ of undefined”。这是因为我们先调用了 _setDiscountRate
函数,而此时 CustomerContract 对象尚未创建出来。为了修复这个错误,我得先撤销刚刚的代码,回到上一个可工作的状态,然后再应用移动语句(223)手法,将_setDiscountRate
函数调用语句挪动到创建对象的语句之后。
class Customer…
constructor(name, discountRate) {this._name = name;this._setDiscountRate(discountRate);this._contract = new CustomerContract(dateToday());
}
搬移完语句后运行一下测试。测试通过后,再次修改 Customer 的访问函数,让它使用_contract 对象上的 discountRate 字段。
class Customer…
get discountRate() {return this._contract.discountRate;}
_setDiscountRate(aNumber) {this._contract.discountRate = aNumber;}
在 JavaScript 中,使用类的字段无须事先声明,因此替换完访问函数,实际上已经没有其他字段再需要我删除。
搬移裸记录
搬移字段这项重构手法对于类的实例对象通常较易进行,因为将数据访问包装到方法中,是类所天然支持的一种封装手段。如果我要搬移的字段是裸记录,并且被许多函数直接访问,那么这项重构仍然很有意义,只不过情况会复杂不少。
我可以先为记录创建相应的访问函数,并修改所有读取和更新记录的地方,使它们引用新创建的访问函数。如果待搬移的字段是不可变(immutable)的,那么我可以在设值函数中同时更新源字段和目标字段,然后再逐步迁移读取记录的调用点。不过,我依然会尽可能先用封装记录(162)手法将记录封装成类,如此一来后续修改会更加简单。
范例:搬移字段到共享对象
现在,让我们看另外一个场景。还是那个代表“账户”的 Account 类,类上有一个代表“利率”的字段_interestRate。
class Account…
constructor(number, type, interestRate) {this._number = number;this._type = type;this._interestRate = interestRate;
}
get interestRate() {return this._interestRate;}
class AccountType…
constructor(nameString) {this._name = nameString;
}
我不希望让每个账户自己维护一个利率字段,利率应该取决于账户本身的类型,因此我想将它搬移到 AccountType 中去。
利率字段已经通过访问函数得到了良好的封装,因此我只需要在 AccountType 上创建对应的字段及访问函数即可。
class AccountType…
constructor(nameString, interestRate) {this._name = nameString;this._interestRate = interestRate;
}
get interestRate() {return this._interestRate;}
接着我应该着手替换 Account 类的访问函数,但我发现直接替换可能有个潜藏的问题。在重构之前,每个账户都自己维护一份利率数据,而现在我要让所有相同类型的账户共享同一个利率值。如果当前类型相同的账户确实拥有相同的利率,那么这次重构就能成立,因为这不会引起可观测的行为变化。但只要存在一个特例,即同一类型的账户可能有不同的利率值,那么这样的修改就不叫重构了,因为它会改变系统的可观测行为。倘若账户的数据保存在数据库中,那我就应该检查一下数据库,确保同一类型的账户都拥有与其账户类型匹配的利率值。同时,我还可以在 Account 类引入断言(302),确保出现异常的利率数据时能够及时发现。
class Account…
constructor(number, type, interestRate) {this._number = number;this._type = type;assert(interestRate === this._type.interestRate);this._interestRate = interestRate;
}
get interestRate() {return this._interestRate;}
我会保留这条断言,让系统先运行一段时间,看看是否会在这捕获到错误。或者,除了添加断言,我还可以将错误记录到日志里。一段时间后,一旦我对代码变得更加自信,确定它确实没有引起行为变化后,我就可以让 Account 直接访问 AccountType 上的 interestRate 字段,并将原来的字段完全删除了。
class Account…
constructor(number, type) {this._number = number;this._type = type;
}
get interestRate() {return this._type.interestRate;}
8.3 搬移语句到函数(Move Statements into Function)
反向重构:搬移语句到调用者(217)
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>`,];
}
动机
要维护代码库的健康发展,需要遵守几条黄金守则,其中最重要的一条当属“消除重复”。如果我发现调用某个函数时,总有一些相同的代码也需要每次执行,那么我会考虑将此段代码合并到函数里头。这样,日后对这段代码的修改只需改一处地方,还能对所有调用者同时生效。如果将来代码对不同的调用者需有不同的行为,那时再通过搬移语句到调用者(217)将它(或其一部分)搬移出来也十分简单。
如果某些语句与一个函数放在一起更像一个整体,并且更有助于理解,那我就会毫不犹豫地将语句搬移到函数里去。如果它们与函数不像一个整体,但仍应与函数一起执行,那我可以用提炼函数(106)将语句和函数一并提炼出去。这基本就是我下面要描述的做法了,只是下面还多了内联和改名的步骤。这些清理工作通常有其必要性,可以在完成核心步骤后再择机完成。
做法
如果重复的代码段离调用目标函数的地方还有些距离,则先用移动语句(223)将这些语句挪动到紧邻目标函数的位置。
如果目标函数仅被唯一一个源函数调用,那么只需将源函数中的重复代码段剪切并粘贴到目标函数中即可,然后运行测试。本做法的后续步骤至此可以忽略。
如果函数不止一个调用点,那么先选择其中一个调用点应用提炼函数(106),将待搬移的语句与目标函数一起提炼成一个新函数。给新函数取个临时的名字,只要易于搜索即可。
调整函数的其他调用点,令它们调用新提炼的函数。每次调整之后运行测试。
完成所有引用点的替换后,应用内联函数(115)将目标函数内联到新函数里,并移除原目标函数。
对新函数应用函数改名(124),将其改名为原目标函数的名字。
如果你能想到更好的名字,那就用更好的那个。
范例
我将用一个例子来讲解这项手法。以下代码会生成一些关于相片(photo)的 HTML:
function renderPerson(outStream, person) {const result = [];result.push(`<p>${person.name}</p>`);result.push(renderPhoto(person.photo));result.push(`<p>title: ${person.photo.title}</p>`);result.push(emitPhotoData(person.photo));return result.join("\n");
}
function photoDiv(p) {return ["<div>",`<p>title: ${p.title}</p>`,emitPhotoData(p),"</div>",].join("\n");
}function emitPhotoData(aPhoto) {const result = [];result.push(`<p>location: ${aPhoto.location}</p>`);result.push(`<p>date: ${aPhoto.date.toDateString()}</p>`);return result.join("\n");
}
这个例子中的 emitPhotoData 函数有两个调用点,每个调用点的前面都有一行类似的重复代码,用于打印与标题(title)相关的信息。我希望能消除重复,把打印标题的那行代码搬移到 emitPhotoData 函数里去。如果 emitPhotoData 只有一个调用点,那我大可直接把代码复制并粘贴过去就完事,但若调用点不止一个,那我就更倾向于用更安全的手法小步前进。
我先选择其中一个调用点,对其应用提炼函数(106)。除了我想搬移的语句,我还把 emitPhotoData 函数也一起提炼到新函数中。
function photoDiv(p) {return ["<div>", zznew(p), "</div>"].join("\n");
}function zznew(p) {return [`<p>title: ${p.title}</p>`, emitPhotoData(p)].join("\n");
}
完成提炼后,我会逐一查看 emitPhotoData 的其他调用点,找到该函数与其前面的重复语句,一并换成对新函数的调用。
function renderPerson(outStream, person) {const result = [];result.push(`<p>${person.name}</p>`);result.push(renderPhoto(person.photo));result.push(zznew(person.photo));return result.join("\n");
}
替换完 emitPhotoData 函数的所有调用点后,我紧接着应用内联函数(115)将 emitPhotoData 函数内联到新函数中。
function zznew(p) {return [`<p>title: ${p.title}</p>`,`<p>location: ${p.location}</p>`,`<p>date: ${p.date.toDateString()}</p>`,].join("\n");
}
最后,再对新提炼的函数应用函数改名(124),就大功告成了。
function renderPerson(outStream, person) {const result = [];result.push(`<p>${person.name}</p>`);result.push(renderPhoto(person.photo));result.push(emitPhotoData(person.photo));return result.join("\n");
}function photoDiv(aPhoto) {return ["<div>", emitPhotoData(aPhoto), "</div>"].join("\n");
}function emitPhotoData(aPhoto) {return [`<p>title: ${aPhoto.title}</p>`,`<p>location: ${aPhoto.location}</p>`,`<p>date: ${aPhoto.date.toDateString()}</p>`,].join("\n");
}
同时我会记得调整函数参数的命名,使之与我的编程风格保持一致。
8.4 搬移语句到调用者(Move Statements to Callers)
反向重构:搬移语句到函数(213)
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`);
}
动机
作为程序员,我们的职责就是设计出结构一致、抽象合宜的程序,而程序抽象能力的源泉正是来自函数。与其他抽象机制的设计一样,我们并非总能平衡好抽象的边界。随着系统能力发生演进(通常只要是有用的系统,功能都会演进),原先设定的抽象边界总会悄无声息地发生偏移。对于函数来说,这样的边界偏移意味着曾经视为一个整体、一个单元的行为,如今可能已经分化出两个甚至是多个不同的关注点。
函数边界发生偏移的一个征兆是,以往在多个地方共用的行为,如今需要在某些调用点面前表现出不同的行为。于是,我们得把表现不同的行为从函数里挪出,并搬移到其调用处。这种情况下,我会使用移动语句(223)手法,先将表现不同的行为调整到函数的开头或结尾,再使用本手法将语句搬移到其调用点。只要差异代码被搬移到调用点,我就可以根据需要对其进行修改。
这个重构手法比较适合处理边界仅有些许偏移的场景,但有时调用点和调用者之间的边界已经相去甚远,此时便只能重新进行设计了。若果真如此,最好的办法是先用内联函数(115)合并双方的内容,调整语句的顺序,再提炼出新的函数来,以形成更合适的边界。
做法
最简单的情况下,原函数非常简单,其调用者也只有寥寥一两个,此时只需把要搬移的代码从函数里剪切出来并粘贴回调用端去即可,必要的时候做些调整。运行测试。如果测试通过,那就大功告成,本手法可以到此为止。
若调用点不止一两个,则需要先用提炼函数(106)将你不想搬移的代码提炼成一个新函数,函数名可以临时起一个,只要后续容易搜索即可。
如果原函数是一个超类方法,并且有子类进行了覆写,那么还需要对所有子类的覆写方法进行同样的提炼操作,保证继承体系上每个类都有一份与超类相同的提炼函数。接着将子类的提炼函数删除,让它们引用超类提炼出来的函数。
对原函数应用内联函数(115)。
对提炼出来的函数应用改变函数声明(124),令其与原函数使用同一个名字。
如果你能想到更好的名字,那就用更好的那个。
范例
下面这个例子比较简单:emitPhotoData 是一个函数,在两处地方被调用。
function renderPerson(outStream, person) {outStream.write(`<p>${person.name}</p>\n`);renderPhoto(outStream, person.photo);emitPhotoData(outStream, person.photo);
}function listRecentPhotos(outStream, photos) {photos.filter(p => p.date > recentDateCutoff()).forEach(p => {outStream.write("<div>\n");emitPhotoData(outStream, p);outStream.write("</div>\n");});
}function emitPhotoData(outStream, photo) {outStream.write(`<p>title: ${photo.title}</p>\n`);outStream.write(`<p>date: ${photo.date.toDateString()}</p>\n`);outStream.write(`<p>location: ${photo.location}</p>\n`);
}
我需要修改软件,支持 listRecentPhotos 函数以不同方式渲染相片的 location 信息,而 renderPerson 的行为则保持不变。为了使这次修改更容易进行,我要应用本手法,将 emitPhotoData 函数最后的那行代码搬移到其调用端。
一般来说,像这样简单的场景,我都会直接将 emitPhotoData 的最后一行剪切并粘贴到两个调用它的函数后面。但为了演示这项重构手法如何在更复杂的场景下运作,这里我还是遵从更详细也更安全的步骤。
重构的第一步是先用提炼函数(106),将那些最终希望留在 emitPhotoData 函数里的语句先提炼出去。
function renderPerson(outStream, person) {outStream.write(`<p>${person.name}</p>\n`);renderPhoto(outStream, person.photo);emitPhotoData(outStream, person.photo);
}function listRecentPhotos(outStream, photos) {photos.filter(p => p.date > recentDateCutoff()).forEach(p => {outStream.write("<div>\n");emitPhotoData(outStream, p);outStream.write("</div>\n");});
}function emitPhotoData(outStream, photo) {zztmp(outStream, photo);outStream.write(`<p>location: ${photo.location}</p>\n`);
}function zztmp(outStream, photo) {outStream.write(`<p>title: ${photo.title}</p>\n`);outStream.write(`<p>date: ${photo.date.toDateString()}</p>\n`);
}
新提炼出来的函数一般只会短暂存在,因此我在命名上不需要太认真,不过,取个容易搜索的名字会很有帮助。提炼完成后运行一下测试,确保提炼出来的新函数能正常工作。
接下来,我要对 emitPhotoData 的调用点逐一应用内联函数(115)。先从 renderPerson 函数开始。
function renderPerson(outStream, person) {outStream.write(`<p>${person.name}</p>\n`);renderPhoto(outStream, person.photo);zztmp(outStream, person.photo);outStream.write(`<p>location: ${person.photo.location}</p>\n`);
}
function listRecentPhotos(outStream, photos) {photos.filter(p => p.date > recentDateCutoff()).forEach(p => {outStream.write("<div>\n");emitPhotoData(outStream, p);outStream.write("</div>\n");});
}function emitPhotoData(outStream, photo) {zztmp(outStream, photo);outStream.write(`<p>location: ${photo.location}</p>\n`);
}function zztmp(outStream, photo) {outStream.write(`<p>title: ${photo.title}</p>\n`);outStream.write(`<p>date: ${photo.date.toDateString()}</p>\n`);
}
然后再次运行测试,确保这次函数内联能正常工作。测试通过后,再前往下一个调用点。
function renderPerson(outStream, person) {outStream.write(`<p>${person.name}</p>\n`);renderPhoto(outStream, person.photo);zztmp(outStream, person.photo);outStream.write(`<p>location: ${person.photo.location}</p>\n`);
}function listRecentPhotos(outStream, photos) {photos.filter(p => p.date > recentDateCutoff()).forEach(p => {outStream.write("<div>\n");zztmp(outStream, p);outStream.write(`<p>location: ${p.location}</p>\n`);outStream.write("</div>\n");});
}function emitPhotoData(outStream, photo) {zztmp(outStream, photo);outStream.write(`<p>location: ${photo.location}</p>\n`);
}function zztmp(outStream, photo) {outStream.write(`<p>title: ${photo.title}</p>\n`);outStream.write(`<p>date: ${photo.date.toDateString()}</p>\n`);
}
至此,我就可以移除外面的 emitPhotoData 函数,完成内联函数(115)手法。
function renderPerson(outStream, person) {outStream.write(`<p>${person.name}</p>\n`);renderPhoto(outStream, person.photo);zztmp(outStream, person.photo);outStream.write(`<p>location: ${person.photo.location}</p>\n`);
}function listRecentPhotos(outStream, photos) {photos.filter(p => p.date > recentDateCutoff()).forEach(p => {outStream.write("<div>\n");zztmp(outStream, p);outStream.write(`<p>location: ${p.location}</p>\n`);outStream.write("</div>\n");});
}function emitPhotoData(outStream, photo) {zztmp(outStream, photo);outStream.write(`<p>location: ${photo.location}</p>\n`);
}function zztmp(outStream, photo) {outStream.write(`<p>title: ${photo.title}</p>\n`);outStream.write(`<p>date: ${photo.date.toDateString()}</p>\n`);
}
最后,我将 zztmp 改名为原函数的名字 emitPhotoData,完成本次重构。
function renderPerson(outStream, person) {outStream.write(`<p>${person.name}</p>\n`);renderPhoto(outStream, person.photo);emitPhotoData(outStream, person.photo);outStream.write(`<p>location: ${person.photo.location}</p>\n`);
}function listRecentPhotos(outStream, photos) {photos.filter(p => p.date > recentDateCutoff()).forEach(p => {outStream.write("<div>\n");emitPhotoData(outStream, p);outStream.write(`<p>location: ${p.location}</p>\n`);outStream.write("</div>\n");});
}function emitPhotoData(outStream, photo) {outStream.write(`<p>title: ${photo.title}</p>\n`);outStream.write(`<p>date: ${photo.date.toDateString()}</p>\n`);
}
8.5 以函数调用取代内联代码(Replace Inline Code with Function Call)
let appliesToMass = false;
for (const s of states) {if (s === "MA") appliesToMass = true;
}appliesToMass = states.includes("MA");
动机
善用函数可以帮助我将相关的行为打包起来,这对于提升代码的表达力大有裨益—— 一个命名良好的函数,本身就能极好地解释代码的用途,使读者不必了解其细节。函数同样有助于消除重复,因为同一段代码我不需要编写两次,每次调用一下函数即可。此外,当我需要修改函数的内部实现时,也不需要四处寻找有没有漏改的相似代码。(当然,我可能需要检查函数的所有调用点,判断它们是否都应该使用新的实现,但通常很少需要这么仔细,即便需要,也总好过四处寻找相似代码。)
如果我见到一些内联代码,它们做的事情仅仅是已有函数的重复,我通常会以一个函数调用取代内联代码。但有一种情况需要特殊对待,那就是当内联代码与函数之间只是外表相似但其实并无本质联系时。这种情况下,当我改变了函数实现时,并不期望对应内联代码的行为发生改变。判断内联代码与函数之间是否真正重复,从函数名往往可以看出端倪:如果一个函数命名得当,也确实与内联代码做了一样的事,那么这个名字用在内联代码的语境里也应该十分协调;如果函数名显得不协调,可能是因为命名本身就比较糟糕(此时可以运用函数改名(124)来解决),也可能是因为函数与内联代码彼此的用途确实有所不同。若是后者的情况,我就不应该用函数调用取代该内联代码。
我发现,配合一些库函数使用,会使本手法效果更佳,因为我甚至连函数体都不需要自己编写了,库已经提供了相应的函数。
做法
将内联代码替代为对一个既有函数的调用。
测试。
8.6 移动语句(Slide Statements)
曾用名:合并重复的代码片段(Consolidate Duplicate Conditional Fragments)
const pricingPlan = retrievePricingPlan();
const order = retreiveOrder();
let charge;
const chargePerUnit = pricingPlan.unit;const pricingPlan = retrievePricingPlan();
const chargePerUnit = pricingPlan.unit;
const order = retreiveOrder();
let charge;
动机
让存在关联的东西一起出现,可以使代码更容易理解。如果有几行代码取用了同一个数据结构,那么最好是让它们在一起出现,而不是夹杂在取用其他数据结构的代码中间。最简单的情况下,我只需使用移动语句就可以让它们聚集起来。此外还有一种常见的“关联”,就是关于变量的声明和使用。有人喜欢在函数顶部一口气声明函数用到的所有变量,我个人则喜欢在第一次需要使用变量的地方再声明它。
通常来说,把相关代码搜集到一处,往往是另一项重构(通常是在提炼函数(106))开始之前的准备工作。相比于仅仅把几行相关的代码移动到一起,将它们提炼到独立的函数往往能起到更好的抽象效果。但如果起先存在关联的代码就没有彼此在一起,那么我也很难应用提炼函数(106)的手法。
做法
确定待移动的代码片段应该被搬往何处。仔细检查待移动片段与目的地之间的语句,看看搬移后是否会影响这些代码正常工作。如果会,则放弃这项重构。
往前移动代码片段时,如果片段中声明了变量,则不允许移动到任何变量的声明语句之前。往后移动代码片段时,如果有语句引用了待移动片段中的变量,则不允许移动到该语句之后。往后移动代码片段时,如果有语句修改了待移动片段中引用的变量,则不允许移动到该语句之后。往后移动代码片段时,如果片段中修改了某些元素,则不允许移动到任何引用了这些元素的语句之后。
剪切源代码片段,粘贴到上一步选定的位置上。
测试。
如果测试失败,那么尝试减小移动的步子:要么是减少上下移动的行数,要么是一次搬移更少的代码。
范例
移动代码片段时,通常需要想清楚两件事:本次调整的目标是什么,以及该目标能否达到。第一件事通常取决于代码所在的上下文。最简单的情况是,我希望元素的声明点和使用点互相靠近,因此移动语句的目标便是将元素的声明语句移动到靠近它们的使用处。不过大多数时候,我移动代码的动机都是因为想做另一项重构,比如在应用提炼函数(106)之前先将相关的代码集中到一块,以方便做函数提炼。
确定要把代码移动到哪里之后,我就需要思考第二个问题,也就是此次搬移能否做到的问题。为此我需要观察待移动的代码,以及移动中间经过的代码段,我得思考这个问题:如果我把代码移动过去,执行次序的不同会不会使代码之间产生干扰,甚至于改变程序的可观测行为?
请观察以下代码片段:
1 const pricingPlan = retrievePricingPlan();2 const order = retreiveOrder();3 const baseCharge = pricingPlan.base;4 let charge;5 const chargePerUnit = pricingPlan.unit;6 const units = order.units;7 let discount;8 charge = baseCharge + units * chargePerUnit;9 let discountableUnits = Math.max(units - pricingPlan.discountThreshold, 0);
10 discount = discountableUnits * pricingPlan.discountFactor;
11 if (order.isRepeat) discount += 20;
12 charge = charge - discount;
13 chargeOrder(charge);
前七行是变量的声明语句,移动它们通常很简单。假如我想把与处理折扣(discount)相关的代码搬移到一起,那么我可以直接将第 7 行(let discount)移动到第 10 行上面(discount = …那一行)。因为变量声明没有副作用,也不会引用其他变量,所以我可以很安全地将声明语句往后移动,一直移动到引用 discount 变量的语句之上。此种类型的语句移动也十分常见——当我要提炼函数(106)时,通常得先将相关变量的声明语句搬移过来。
我会再寻找类似的没有副作用的变量声明语句。类似地,我可以毫无障碍地把声明了 order 变量的第 2 行(const order = …)移动到使用它的第 6 行(const units = …)上面。
上面搬移变量声明语句之所以顺利,除了因为语句本身没有副作用,还得益于我移动语句时跨过的代码片段同样没有副作用。事实上,对于没有副作用的代码,我几乎可以随心所欲地编排它们的顺序,这也是优秀的程序员都会尽量编写无副作用代码的原因之一。
当然,这里还有一个小细节,那就是我从何得知第 2 行代码没有副作用呢?我只有深入检查 retrieveOrder()函数的内部实现,才能真正确保它确实没有副作用(除了检查函数本身,还得检查它内部调用的函数都没有副作用,以及它调用的函数内部调用的函数都没有副作用……一直检查到调用链的底端)。实践中,我编写代码总是尽量遵循命令与查询分离(Command-Query Separation)[mf-cqs]原则,在这个前提下,我可以确定任何有返回值的函数都不存在副作用。但只有在我了解代码库的前提下才如此自信;如果我对代码库还不熟悉,我就得更加小心。但在我自己的编码过程中,我确实总是尽量遵循命令与查询分离的模式,因为它让我一眼就能看清代码有无副作用,而这件事情真是价值不菲。
如果待移动的代码片段本身有副作用,或者它需要跨越的代码存在副作用,移动它们时就必须加倍小心。我得仔细寻找两个代码片段中间的代码有没有副作用,是不是对执行次序敏感。因此,假设我想将第 11 行(if(order.isRepeat)…)挪动到段落底部,我会发现行不通,因为中间第 12 行语句引用了 discount 变量,而我在第 11 行中可能改动这个变量;类似地,假设我想将第 13 行(chargeOrder(charge))往上搬移,那也是行不通的,因为第 13 行引用的 charge 变量在第 12 行会被修改。不过,如果我想将第 8 行代码(charge = baseCharge + …)移动到第 9 行到第 11 行中间的任意地方则是可行的,因为这几行都未修改任何变量的状态。
移动代码时,最容易遵守的一条规则是,如果待移动代码片段中引用的变量在另一个代码片段中被修改了,那我就不能安全地将前者移动到后者之后;同样,如果前者会修改后者中引用的变量,也一样不能安全地进行上述移动。但这条规则仅仅作为参考,它也不是绝对的,比如下面这个例子,虽然两个语句都修改了彼此之间的变量,但我仍能安全地调整它们的先后顺序。
a = a + 10;
a = a + 5;
但无论如何,要判断一次语句移动是否安全,都意味着我得真正理解代码的工作原理,以及运算符之间的组合方式等。
正因此项重构如此需要关注状态更新,所以我会尽量移除那些会更新元素状态的代码。比如此例中的 charge 变量,在移动其相关的代码之前,我会先看看是否能对它应用拆分变量(240)手法。
以上的分析都比较简单,没什么难度,因为代码里修改的都是局部变量,局部变量是比较好处理的。但处理更复杂的数据结构时,情况就不同了,判断代码段之间是否存在相互干扰会困难得多。这时测试扮演了重要角色:每次移动代码之后运行测试,看看有没有任何测试失败。如果我的测试覆盖足够全面,我就会对这次重构比较有信心;但如果测试覆盖不够,我就得小心一些了——通常,我会先改善代码的测试,然后再进行重构。
如果移动过后测试失败了,那么意味着我得减小移动的步子,比如一次先移动 5 行,而不是 10 行;或者先移动到那些看着比较可能出错的代码上面,但不越过它,看看效果。同时,测试失败也可能是一个征兆,提醒我这次移动可能还不是时候,可能还需要在别处先做一些其他的工作。
范例:包含条件逻辑的移动
对于拥有条件逻辑的代码,移动手法同样适用。当从条件分支中移走代码时,通常是要消除重复逻辑;将代码移入条件分支时,通常是反过来,有意添加一些重复逻辑。
在下面这个例子中,两个条件分支里都有一个相同的语句:
let result;
if (availableResources.length === 0) {result = createResource();allocatedResources.push(result);
} else {result = availableResources.pop();allocatedResources.push(result);
}
return result;
我可以将这两句重复代码从条件分支中移走,只在 if-else 块的末尾保留一句。
let result;
if (availableResources.length === 0) {result = createResource();
} else {result = availableResources.pop();
}
allocatedResources.push(result);
return result;
这个手法同样可以反过来用,也就是把一个语句分别搬移到不同的条件分支里,这样会在每个条件分支里留下同一段重复的代码。
延伸阅读
除了我介绍的这个方法,我还见过一个十分相似的重构手法,名字叫作“交换语句位置”(Swap Statement)[wake-swap]。该手法同样适用于移动相邻的代码片段,只不过它适用的是只有一条语句的片段。你可以把它想成移动语句手法的一个特例,也就是待移动的代码片段以及它所跨过的代码片段,都只有一条语句。我对这项重构手法很感兴趣,毕竟我也一直在强调小步修改——有时甚至小步到于初学重构的人看来都很不可思议的地步。
但最后,我还是选择在本重构手法中介绍如何移动范围更大的代码片段,因为我自己平时就是这么做的。我只有在处理大范围的语句移动遇到困难时才会变得小步、一次只移动一条语句,但即便是这样的困难我也很少遇见。无论如何,当代码过于复杂凌乱时,小步的移动通常会更加顺利。
8.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;
动机
你常常能见到一些身兼多职的循环,它们一次做了两三件事情,不为别的,就因为这样可以只循环一次。但如果你在一次循环中做了两件不同的事,那么每当需要修改循环时,你都得同时理解这两件事情。如果能够将循环拆分,让一个循环只做一件事情,那就能确保每次修改时你只需要理解要修改的那块代码的行为就可以了。
拆分循环还能让每个循环更容易使用。如果一个循环只计算一个值,那么它直接返回该值即可;但如果循环做了太多件事,那就只得返回结构型数据或者通过局部变量传值了。因此,一般拆分循环后,我还会紧接着对拆分得到的循环应用提炼函数(106)。
这项重构手法可能让许多程序员感到不安,因为它会迫使你执行两次循环。对此,我一贯的建议也与 2.8 节里所明确指出的一致:先进行重构,然后再进行性能优化。我得先让代码结构变得清晰,才能做进一步优化;如果重构之后该循环确实成了性能的瓶颈,届时再把拆开的循环合到一起也很容易。但实际情况是,即使处理的列表数据更多一些,循环本身也很少成为性能瓶颈,更何况拆分出循环来通常还使一些更强大的优化手段变得可能。
做法
复制一遍循环代码。
识别并移除循环中的重复代码,使每个循环只做一件事。
测试。
完成循环拆分后,考虑对得到的每个循环应用提炼函数(106)。
范例
下面我以一段循环代码开始。该循环会计算需要支付给所有员工的总薪水(total salary),并计算出最年轻(youngest)员工的年龄。
let youngest = people[0] ? people[0].age : Infinity;
let totalSalary = 0;
for (const p of people) {if (p.age < youngest) youngest = p.age;totalSalary += p.salary;
}return `youngestAge: ${youngest}, totalSalary: ${totalSalary}`;
该循环十分简单,但仍然做了两种不同的计算。要拆分这两种计算,我要先复制一遍循环代码。
let youngest = people[0] ? people[0].age : Infinity;
let totalSalary = 0;
for (const p of people) {if (p.age < youngest) youngest = p.age;totalSalary += p.salary;
}
for (const p of people) {if (p.age < youngest) youngest = p.age;totalSalary += p.salary;
}return `youngestAge: ${youngest}, totalSalary: ${totalSalary}`;
复制过后,我需要将循环中重复的计算逻辑删除,否则就会累加出错误的结果。如果循环中的代码没有副作用,那便可以先留着它们不删除,可惜上述例子并不符合这种情况。
let youngest = people[0] ? people[0].age : Infinity;
let totalSalary = 0;
for (const p of people) {if (p.age < youngest) youngest = p.age;totalSalary += p.salary;
}for (const p of people) {if (p.age < youngest) youngest = p.age;totalSalary += p.salary;
}return `youngestAge: ${youngest}, totalSalary: ${totalSalary}`;
至此,拆分循环这个手法本身的内容就结束了。但本手法的意义不仅在于拆分出循环本身,而且在于它为进一步优化提供了良好的起点——下一步我通常会寻求将每个循环提炼到独立的函数中。在做提炼之前,我得先用移动语句(223)微调一下代码顺序,将与循环相关的变量先搬移到一起:
let totalSalary = 0;
for (const p of people) {totalSalary += p.salary;
}let youngest = people[0] ? people[0].age : Infinity;
for (const p of people) {if (p.age < youngest) youngest = p.age;
}return `youngestAge: ${youngest}, totalSalary: ${totalSalary}`;
然后,我就可以顺利地应用提炼函数(106)了。
return `youngestAge: ${youngestAge()}, totalSalary: ${totalSalary()}`;function totalSalary() {let totalSalary = 0;for (const p of people) {totalSalary += p.salary;}return totalSalary;
}function youngestAge() {let youngest = people[0] ? people[0].age : Infinity;for (const p of people) {if (p.age < youngest) youngest = p.age;}return youngest;
}
对于像 totalSalary 这样的累加计算,我绝少能抵挡得住进一步使用以管道取代循环(231)重构它的诱惑;而对于 youngestAge 的计算,显然可以用替换算法(195)替之以更好的算法。
return `youngestAge: ${youngestAge()}, totalSalary: ${totalSalary()}`;function totalSalary() {return people.reduce((total,p) => total + p.salary, 0);
}
function youngestAge() {return Math.min(...people.map(p => p.age));
}
8.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)
;
动机
与大多数程序员一样,我入行的时候也有人告诉我,迭代一组集合时得使用循环。不过时代在发展,如今越来越多的编程语言都提供了更好的语言结构来处理迭代过程,这种结构就叫作集合管道(collection pipeline)。集合管道[mf-cp]是这样一种技术,它允许我使用一组运算来描述集合的迭代过程,其中每种运算接收的入参和返回值都是一个集合。这类运算有很多种,最常见的则非 map 和 filter 莫属:map 运算是指用一个函数作用于输入集合的每一个元素上,将集合变换成另外一个集合的过程;filter 运算是指用一个函数从输入集合中筛选出符合条件的元素子集的过程。运算得到的集合可以供管道的后续流程使用。我发现一些逻辑如果采用集合管道来编写,代码的可读性会更强——我只消从头到尾阅读一遍代码,就能弄清对象在管道中间的变换过程。
做法
创建一个新变量,用以存放参与循环过程的集合。
也可以简单地复制一个现有的变量赋值给新变量。
从循环顶部开始,将循环里的每一块行为依次搬移出来,在上一步创建的集合变量上用一种管道运算替代之。每次修改后运行测试。
搬移完循环里的全部行为后,将循环整个删除。
如果循环内部通过累加变量来保存结果,那么移除循环后,将管道运算的最终结果赋值给该累加变量。
范例
在这个例子中,我们有一个 CSV 文件,里面存有各个办公室(office)的一些数据。
office, country, telephone
Chicago, USA, +1 312 373 1000
Beijing, China, +86 4008 900 505
Bangalore, India, +91 80 4064 9570
Porto Alegre, Brazil, +55 51 3079 3550
Chennai, India, +91 44 660 44766... (more data follows)
下面这个 acquireData 函数的作用是从数据中筛选出印度的所有办公室,并返回办公室所在的城市(city)信息和联系电话(telephone number)。
function acquireData(input) {const lines = input.split("\n");let firstLine = true;const result = [];for (const line of lines) {if (firstLine) {firstLine = false;continue;}if (line.trim() === "") continue;const record = line.split(",");if (record[1].trim() === "India") {result.push({ city: record[0].trim(), phone: record[2].trim() });}}return result;
}
这个循环略显复杂,我希望能用一组管道操作来替换它。
第一步是先创建一个独立的变量,用来存放参与循环过程的集合值。
function acquireData(input) {const lines = input.split("\n");let firstLine = true;const result = [];const loopItems = lines;for (const line of loopItems) {if (firstLine) {firstLine = false;continue;}if (line.trim() === "") continue;const record = line.split(",");if (record[1].trim() === "India") {result.push({ city: record[0].trim(), phone: record[2].trim() });}}return result;
}
循环第一部分的作用是在忽略 CSV 文件的第一行数据。这其实是一个切片(slice)操作,因此我先从循环中移除这部分代码,并在集合变量的声明后面新增一个对应的 slice 运算来替代它。
function acquireData(input) {const lines = input.split("\n");let firstLine = true;const result = [];const loopItems = lines.slice(1);for (const line of loopItems) {if (firstLine) {firstLine = false;continue;}if (line.trim() === "") continue;const record = line.split(",");if (record[1].trim() === "India") {result.push({ city: record[0].trim(), phone: record[2].trim() });}}return result;
}
从循环中删除代码还有一个好处,那就是 firstLine 这个控制变量也可以一并移除了——无论何时,删除控制变量总能使我身心愉悦。
该循环的下一个行为是要移除数据中的所有空行。这同样可用一个过滤(filter)运算替代之。
function acquireData(input) {const lines = input.split("\n");const result = [];const loopItems = lines.slice(1).filter(line => line.trim() !== "");for (const line of loopItems) {if (line.trim() === "") continue;const record = line.split(",");if (record[1].trim() === "India") {result.push({city: record[0].trim(), phone: record[2].trim()});}}return result;
}
编写管道运算时,我喜欢让结尾的分号单占一行,这样方便调整管道的结构。
接下来是将数据的一行转换成数组,这明显可以用一个 map 运算替代。然后我们还发现,原来的 record 命名其实有误导性,它没有体现出“转换得到的结果是数组”这个信息,不过既然现在还在做其他重构,先不动它会比较安全,回头再为它改名也不迟。
function acquireData(input) {const lines = input.split("\n");const result = [];const loopItems = lines.slice(1).filter(line => line.trim() !== "").map(line => line.split(","));for (const line of loopItems) {const record = line;.split(",");if (record[1].trim() === "India") {result.push({city: record[0].trim(), phone: record[2].trim()});}}return result;
}
然后又是一个过滤(filter)操作,只从结果中筛选出印度办公室的记录。
function acquireData(input) {const lines = input.split("\n");const result = [];const loopItems = lines.slice(1).filter(line => line.trim() !== "").map(line => line.split(",")).filter(record => record[1].trim() === "India");for (const line of loopItems) {const record = line;if (record[1].trim() === "India") {result.push({city: record[0].trim(), phone: record[2].trim()});}}return result;
}
最后再把结果映射(map)成需要的记录格式:
function acquireData(input) {const lines = input.split("\n");const result = [];const loopItems = lines.slice(1).filter(line => line.trim() !== "").map(line => line.split(",")).filter(record => record[1].trim() === "India").map(record => ({city: record[0].trim(), phone: record[2].trim()}));for (const line of loopItems) {const record = line;result.push(line);}return result;
}
现在,循环剩余的唯一作用就是对累加变量赋值了。我可以将上面管道产出的结果赋值给该累加变量,然后删除整个循环:
function acquireData(input) {const lines = input.split("\n");const result = lines.slice(1).filter(line => line.trim() !== "").map(line => line.split(",")).filter(record => record[1].trim() === "India").map(record => ({city: record[0].trim(), phone: record[2].trim()}));for (const line of loopItems) {const record = line;result.push(line);}return result;
}
以上就是本手法的全部精髓所在了。不过后续还有些清理工作可做:我内联了 result 变量,为一些函数变量改名,最后还对代码进行布局,让它读起来更像个表格。
function acquireData(input) {const lines = input.split("\n");return lines.slice (1).filter (line => line.trim() !== "").map (line => line.split(",")).filter (fields => fields[1].trim() === "India").map (fields => ({city: fields[0].trim(), phone: fields[2].trim()}));
}
我还想过是否要内联 lines 变量,但我感觉它还算能解释该行代码的意图,因此我还是将它留在了原处。
延伸阅读
如果想了解更多用集合管道替代循环的案例,可以参考我的文章“Refactoring with Loops and Collection Pipelines”[mf-ref-pipe]。
8.9 移除死代码(Remove Dead Code)
if (false) {doSomethingThatUsedToMatter();
}
动机
事实上,我们部署到生产环境甚至是用户设备上的代码,从来未因代码量太大而产生额外费用。就算有几行用不上的代码,似乎也不会因此拖慢系统速度,或者占用过多的内存,大多数现代的编译器还会自动将无用的代码移除。但当你尝试阅读代码、理解软件的运作原理时,无用代码确实会带来很多额外的思维负担。它们周围没有任何警示或标记能告诉程序员,让他们能够放心忽略这段函数,因为已经没有任何地方使用它了。当程序员花费了许多时间,尝试理解它的工作原理时,却发现无论怎么修改这段代码都无法得到期望的输出。
一旦代码不再被使用,我们就该立马删除它。有可能以后又会需要这段代码,但我从不担心这种情况;就算真的发生,我也可以从版本控制系统里再次将它翻找出来。如果我真的觉得日后它极有可能再度启用,那还是要删掉它,只不过可以在代码里留一段注释,提一下这段代码的存在,以及它被移除的那个提交版本号——但老实讲,我已经记不得我上次撰写这样的注释是什么时候了,当然也未曾因为不写而感到过遗憾。
在以前,业界对于死代码的处理态度多是注释掉它。在版本控制系统还未普及、用起来又不太方便的年代,这样做有其道理;但现在版本控制系统已经相当普及。如今哪怕是一个极小的代码库我都会把它放进版本控制,这些无用代码理应可以放心清理了。
做法
如果死代码可以从外部直接引用,比如它是一个独立的函数时,先查找一下还有无调用点。
将死代码移除。
测试。
第 9 章 重新组织数据
数据结构在程序中扮演着重要的角色,所以毫不意外,我有一组重构手法专门用于数据结构的组织。将一个值用于多个不同的用途,这就是催生混乱和 bug 的温床。所以,一旦看见这样的情况,我就会用拆分变量(240)将不同的用途分开。和其他任何程序元素一样,给变量起个好名字不容易但又非常重要,所以我常会用到变量改名(137)。但有些多余的变量最好是彻底消除掉,比如通过以查询取代派生变量(248)。
引用和值的混淆经常会造成问题,所以我会用将引用对象改为值对象(252)和将值对象改为引用对象(256)在两者之间切换。
9.1 拆分变量(Split Variable)
曾用名:移除对参数的赋值(Remove Assignments to Parameters)
曾用名:分解临时变量(Split Temp)
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);
动机
变量有各种不同的用途,其中某些用途会很自然地导致临时变量被多次赋值。“循环变量”和“结果收集变量”就是两个典型例子:循环变量(loop variable)会随循环的每次运行而改变(例如 for(let i=0; i<10; i++)语句中的 i);结果收集变量(collecting variable)负责将“通过整个函数的运算”而构成的某个值收集起来。
除了这两种情况,还有很多变量用于保存一段冗长代码的运算结果,以便稍后使用。这种变量应该只被赋值一次。如果它们被赋值超过一次,就意味它们在函数中承担了一个以上的责任。如果变量承担多个责任,它就应该被替换(分解)为多个变量,每个变量只承担一个责任。同一个变量承担两件不同的事情,会令代码阅读者糊涂。
做法
在待分解变量的声明及其第一次被赋值处,修改其名称。
如果稍后的赋值语句是“i=i+某表达式形式”,意味着这是一个结果收集变量,就不要分解它。结果收集变量常用于累加、字符串拼接、写入流或者向集合添加元素。
如果可能的话,将新的变量声明为不可修改。
以该变量的第二次赋值动作为界,修改此前对该变量的所有引用,让它们引用新变量。
测试。
重复上述过程。每次都在声明处对变量改名,并修改下次赋值之前的引用,直至到达最后一处赋值。
范例
下面范例中我要计算一个苏格兰布丁运动的距离。在起点处,静止的苏格兰布丁会受到一个初始力的作用而开始运动。一段时间后,第二个力作用于布丁,让它再次加速。根据牛顿第二定律,我可以这样计算布丁运动的距离:
function distanceTravelled (scenario, time) {let result;let acc = scenario.primaryForce / scenario.mass;let primaryTime = Math.min(time, scenario.delay);result = 0.5 * acc * primaryTime * primaryTime;let secondaryTime = time - scenario.delay;if (secondaryTime > 0) {let primaryVelocity = acc * scenario.delay;acc = (scenario.primaryForce + scenario.secondaryForce) / scenario.mass;result += primaryVelocity * secondaryTime + 0.5 * acc * secondaryTime * secondaryTime;}return result;
}
真是个丑陋的小东西。注意观察此例中的 acc 变量是如何被赋值两次的。acc 变量有两个责任:第一是保存第一个力造成的初始加速度;第二是保存两个力共同造成的加速度。这就是我想要分解的东西。
在尝试理解变量被如何使用时,如果编辑器能高亮显示一个符号(symbol)在函数内或文件内出现的所有位置,会相当便利。大部分现代编辑器都可以轻松做到这一点。
首先,我在函数开始处修改这个变量的名称,并将新变量声明为 const。接着,我把新变量声明之后、第二次赋值之前对 acc 变量的所有引用,全部改用新变量。最后,我在第二次赋值处重新声明 acc 变量:
function distanceTravelled (scenario, time) {let result;const primaryAcceleration = scenario.primaryForce / scenario.mass;let primaryTime = Math.min(time, scenario.delay);result = 0.5 * primaryAcceleration * primaryTime * primaryTime;let secondaryTime = time - scenario.delay;if (secondaryTime > 0) {let primaryVelocity = primaryAcceleration * scenario.delay;let acc = (scenario.primaryForce + scenario.secondaryForce) / scenario.mass;result += primaryVelocity * secondaryTime + 0.5 * acc * secondaryTime * secondaryTime;}return result;
}
新变量的名称指出,它只承担原先 acc 变量的第一个责任。我将它声明为 const,确保它只被赋值一次。然后,我在原先 acc 变量第二次被赋值处重新声明 acc。现在,重新编译并测试,一切都应该没有问题。
然后,我继续处理 acc 变量的第二次赋值。这次我把原先的变量完全删掉,代之以一个新变量。新变量的名称指出,它只承担原先 acc 变量的第二个责任:
function distanceTravelled (scenario, time) {let result;const primaryAcceleration = scenario.primaryForce / scenario.mass;let primaryTime = Math.min(time, scenario.delay);result = 0.5 * primaryAcceleration * primaryTime * primaryTime;let secondaryTime = time - scenario.delay;if (secondaryTime > 0) {let primaryVelocity = primaryAcceleration * scenario.delay;const secondaryAcceleration = (scenario.primaryForce + scenario.secondaryForce) / scenario.mass;result += primaryVelocity * secondaryTime +0.5 * secondaryAcceleration * secondaryTime * secondaryTime;}return result;
}
现在,这段代码肯定可以让你想起更多其他重构手法。尽情享受吧。(我敢保证,这比吃苏格兰布丁强多了——你知道他们都在里面放了些什么东西吗?1 )
范例:对输入参数赋值
另一种情况是,变量是以输入参数的形式声明又在函数内部被再次赋值,此时也可以考虑拆分变量。例如,下列代码:
function discount (inputValue, quantity) {if (inputValue > 50) inputValue = inputValue - 2;if (quantity > 100) inputValue = inputValue - 1;return inputValue;
}
这里的 inputValue 有两个用途:它既是函数的输入,也负责把结果带回给调用方。(由于 JavaScript 的参数是按值传递的,所以函数内部对 inputValue 的修改不会影响调用方。)
在这种情况下,我就会对 inputValue 变量做拆分。
function discount (originalInputValue, quantity) {let inputValue = originalInputValue;if (inputValue > 50) inputValue = inputValue - 2;if (quantity > 100) inputValue = inputValue - 1;return inputValue;
}
然后用变量改名(137)给两个变量换上更好的名字。
function discount (inputValue, quantity) {let result = inputValue;if (inputValue > 50) result = result - 2;if (quantity > 100) result = result - 1;return result;
}
我修改了第二行代码,把 inputValue 作为判断条件的基准数据。虽说这里用 inputValue 还是 result 效果都一样,但在我看来,这行代码的含义是“根据原始输入值做判断,然后修改结果值”,而不是“根据当前结果值做判断”——尽管两者的效果恰好一样。
1 苏格兰布丁(haggis)是一种苏格兰菜,把羊心等内脏装在羊胃里煮成。由于它被羊胃包成一个球体,因此可以像球一样踢来踢去,这就是本例的由来。“把羊心装在羊胃里煮成……”,呃,有些人难免对这道菜恶心,Martin Fowler 想必是其中之一。——译者注
9.2 字段改名(Rename Field)
class Organization {get name() {...}
}class Organization {get title() {...}
}
动机
命名很重要,对于程序中广泛使用的记录结构,其中字段的命名格外重要。数据结构对于帮助阅读者理解特别重要。多年以前,Fred Brooks 就说过:“只给我看你的工作流程却隐藏表单,我将仍然一头雾水。但是如果你给我展示表单,或许不需要流程图,就能柳暗花明。”现在已经不太有人画流程图了,不过道理还是一样的。数据结构是理解程序行为的关键。
既然数据结构如此重要,就很有必要保持它们的整洁。一如既往地,我在一个软件上做的工作越多,对数据的理解就越深,所以很有必要把我加深的理解融入程序中。
记录结构中的字段可能需要改名,类的字段也一样。在类的使用者看来,取值和设值函数就等于是字段。对这些函数的改名,跟裸记录结构的字段改名一样重要。
做法
如果记录的作用域较小,可以直接修改所有该字段的代码,然后测试。后面的步骤就都不需要了。
如果记录还未封装,请先使用封装记录(162)。
在对象内部对私有字段改名,对应调整内部访问该字段的函数。
测试。
如果构造函数的参数用了旧的字段名,运用改变函数声明(124)将其改名。
运用函数改名(124)给访问函数改名。
范例:给字段改名
我们从一个常量开始。
const organization = { name: "Acme Gooseberries", country: "GB" };
我想把 name 改名为 title。这个对象被很多地方使用,有些代码会更新 name 字段。所以我首先要用封装记录(162)把这个记录封装起来。
class Organization {constructor(data) {this._name = data.name;this._country = data.country;}get name() {return this._name;}set name(aString) {this._name = aString;}get country() {return this._country;}set country(aCountryCode) {this._country = aCountryCode;}
}const organization = new Organization({name: "Acme Gooseberries",country: "GB",
});
现在,记录结构已经被封装成类。在对字段改名时,有 4 个地方需要留意:取值函数、设值函数、构造函数以及内部数据结构。这听起来似乎是增加了重构的工作量,但现在我可以分别小步修改这 4 处,而不必一次修改所有地方,所以其实是降低了重构的难度。小步修改就意味着每一步出错的可能性大大减小,因此会省掉很多工作量——如果我从不犯错,小步前进不会节省工作量;但“从不犯错”这样的梦,我很久以前就已经不做了。
由于已经把输入数据复制到内部数据结构中,现在我需要将这两个数据结构区分开,以便各自单独处理。我可以另外定义一个字段,修改构造函数和访问函数,令其使用新字段。
class Organization…
class Organization {constructor(data) {this._title = data.name;this._country = data.country;}get name() {return this._title;}set name(aString) {this._title = aString;}get country() {return this._country;}set country(aCountryCode) {this._country = aCountryCode;}
}
接下来我就可以在构造函数中使用 title 字段。
class Organization…
class Organization {constructor(data) {this._title = data.title !== undefined ? data.title : data.name;this._country = data.country;}get name() {return this._title;}set name(aString) {this._title = aString;}get country() {return this._country;}set country(aCountryCode) {this._country = aCountryCode;}
}
现在,构造函数的调用者既可以使用 name 也可以使用 title(后者的优先级更高)。我会逐一查看所有调用构造函数的地方,将它们改为使用新名字。
const organization = new Organization({title: "Acme Gooseberries",country: "GB",
});
全部修改完成后,就可以在构造函数中去掉对 name 的支持,只使用 title。
class Organization…
class Organization {constructor(data) {this._title = data.title;this._country = data.country;}get name() {return this._title;}set name(aString) {this._title = aString;}get country() {return this._country;}set country(aCountryCode) {this._country = aCountryCode;}
}
现在构造函数和内部数据结构都已经在使用新名字了,接下来我就可以给访问函数改名。这一步很简单,只要对每个访问函数运用函数改名(124)就行了。
class Organization…
class Organization {constructor(data) {this._title = data.title;this._country = data.country;}get title() {return this._title;}set title(aString) {this._title = aString;}get country() {return this._country;}set country(aCountryCode) {this._country = aCountryCode;}
}
上面展示的重构过程,是本重构手法最重量级的做法,只有对广泛使用的数据结构才用得上。如果该数据结构只在较小的范围(例如单个函数)中用到,我可能可以一步到位地完成所有改名动作,不需要提前做封装。何时需要用上全套重量级做法,这由你自己判断——如果在重构过程中破坏了测试,我通常会视之为一个信号,说明我需要改用更渐进的方式来重构。
有些编程语言允许将数据结构声明为不可变。在这种情况下,我可以把旧字段的值复制到新名字下,逐一修改使用方代码,然后删除旧字段。对于可变的数据结构,重复数据会招致灾难;而不可变的数据结构则没有这些麻烦。这也是大家愿意使用不可变数据的原因。
9.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;}
动机
可变数据是软件中最大的错误源头之一。对数据的修改常常导致代码的各个部分以丑陋的形式互相耦合:在一处修改数据,却在另一处造成难以发现的破坏。很多时候,完全去掉可变数据并不现实,但我还是强烈建议:尽量把可变数据的作用域限制在最小范围。
有些变量其实可以很容易地随时计算出来。如果能去掉这些变量,也算朝着消除可变性的方向迈出了一大步。计算常能更清晰地表达数据的含义,而且也避免了“源数据修改时忘了更新派生变量”的错误。
有一种合理的例外情况:如果计算的源数据是不可变的,并且我们可以强制要求计算的结果也是不可变的,那么就不必重构消除计算得到的派生变量。因此,“根据源数据生成新数据结构”的变换操作可以保持不变,即便我们可以将其替换为计算操作。实际上,这是两种不同的编程风格:一种是对象风格,把一系列计算得出的属性包装在数据结构中;另一种是函数风格,将一个数据结构变换为另一个数据结构。如果源数据会被修改,而你必须负责管理派生数据结构的整个生命周期,那么对象风格显然更好。但如果源数据不可变,或者派生数据用过即弃,那么两种风格都可行。
做法
识别出所有对变量做更新的地方。如有必要,用拆分变量(240)分割各个更新点。
新建一个函数,用于计算该变量的值。
用引入断言(302)断言该变量和计算函数始终给出同样的值。
如有必要,用封装变量(132)将这个断言封装起来。
测试。
修改读取该变量的代码,令其调用新建的函数。
测试。
用移除死代码(237)去掉变量的声明和赋值。
范例
下面这个例子虽小,却完美展示了代码的丑陋。
class ProductionPlan…
get production() {return this._production;}
applyAdjustment(anAdjustment) {this._adjustments.push(anAdjustment);this._production += anAdjustment.amount;
}
丑与不丑,全在观者。我看到的丑陋之处是重复——不是常见的代码重复,而是数据的重复。如果我要对生产计划(production plan)做调整(adjustment),不光要把调整的信息保存下来,还要根据调整信息修改一个累计值——后者完全可以即时计算,而不必每次更新。
但我是个谨慎的人。“可以即时计算”只是我的猜想——我可以用引入断言(302)来验证这个猜想。
class ProductionPlan…
get production() {assert(this._production === this.calculatedProduction);return this._production;
}get calculatedProduction() {return this._adjustments.reduce((sum, a) => sum + a.amount, 0);
}
放上这个断言之后,我会运行测试。如果断言没有失败,我就可以不再返回该字段,改为返回即时计算的结果。
class ProductionPlan…
get production() {assert(this._production === this.calculatedProduction);return this.calculatedProduction;
}
然后用内联函数(115)把计算逻辑内联到 production 函数内。
class ProductionPlan…
get production() {return this._adjustments.reduce((sum, a) => sum + a.amount, 0);
}
再用移除死代码(237)扫清使用旧变量的地方。
class ProductionPlan…
applyAdjustment(anAdjustment) {this._adjustments.push(anAdjustment);this._production += anAdjustment.amount;
}
范例:不止一个数据来源
上面的例子处理得轻松愉快,因为 production 的值很明显只有一个来源。但有时候,累计值会受到多个数据来源的影响。
class ProductionPlan…
constructor (production) {this._production = production;this._adjustments = [];
}
get production() {return this._production;}
applyAdjustment(anAdjustment) {this._adjustments.push(anAdjustment);this._production += anAdjustment.amount;
}
如果照上面的方式运用引入断言(302),只要 production 的初始值不为 0,断言就会失败。
不过我还是可以替换派生数据,只不过必须先运用拆分变量(240)。
constructor (production) {this._initialProduction = production;this._productionAccumulator = 0;this._adjustments = [];
}
get production() {return this._initialProduction + this._productionAccumulator;
}
现在我就可以使用引入断言(302)。
class ProductionPlan…
get production() {assert(this._productionAccumulator === this.calculatedProductionAccumulator);return this._initialProduction + this._productionAccumulator;
}get calculatedProductionAccumulator() {return this._adjustments.reduce((sum, a) => sum + a.amount, 0);
}
接下来的步骤就跟前一个范例一样了。不过我会更愿意保留 calculatedProduction Accumulator 这个属性,而不把它内联消去。
9.4 将引用对象改为值对象(Change Reference to Value)
反向重构:将值对象改为引用对象(256)
class Product {
applyDiscount(arg) {this._price.amount -= arg;}class Product {
applyDiscount(arg) {this._price = new Money(this._price.amount - arg, this._price.currency);
}
动机
在把一个对象(或数据结构)嵌入另一个对象时,位于内部的这个对象可以被视为引用对象,也可以被视为值对象。两者最明显的差异在于如何更新内部对象的属性:如果将内部对象视为引用对象,在更新其属性时,我会保留原对象不动,更新内部对象的属性;如果将其视为值对象,我就会替换整个内部对象,新换上的对象会有我想要的属性值。
如果把一个字段视为值对象,我可以把内部对象的类也变成值对象[mf-vo]。值对象通常更容易理解,主要因为它们是不可变的。一般说来,不可变的数据结构处理起来更容易。我可以放心地把不可变的数据值传给程序的其他部分,而不必担心对象中包装的数据被偷偷修改。我可以在程序各处复制值对象,而不必操心维护内存链接。值对象在分布式系统和并发系统中尤为有用。
值对象和引用对象的区别也告诉我,何时不应该使用本重构手法。如果我想在几个对象之间共享一个对象,以便几个对象都能看见对共享对象的修改,那么这个共享的对象就应该是引用。
做法
检查重构目标是否为不可变对象,或者是否可修改为不可变对象。
用移除设值函数(331)逐一去掉所有设值函数。
提供一个基于值的相等性判断函数,在其中使用值对象的字段。
大多数编程语言都提供了可覆写的相等性判断函数。通常你还必须同时覆写生成散列码的函数。
范例
设想一个代表“人”的 Person 类,其中包含一个代表“电话号码”的 Telephone Number 对象。
class Person…
constructor() {constructor() {this._telephoneNumber = new TelephoneNumber();
}get officeAreaCode() {return this._telephoneNumber.areaCode;}
set officeAreaCode(arg) {this._telephoneNumber.areaCode = arg;}
get officeNumber() {return this._telephoneNumber.number;}
set officeNumber(arg) {this._telephoneNumber.number = arg;}
class TelephoneNumber…
get areaCode() {return this._areaCode;}
set areaCode(arg) {this._areaCode = arg;}get number() {return this._number;}
set number(arg) {this._number = arg;}
代码的当前状态是提炼类(182)留下的结果:从前拥有电话号码信息的 Person 类仍然有一些函数在修改新对象的属性。趁着还只有一个指向新类的引用,现在是时候使用将引用对象改为值对象将其变成值对象。
我需要做的第一件事是把 TelephoneNumber 类变成不可变的。对它的字段运用移除设值函数(331)。移除设值函数(331)的第一步是,用改变函数声明(124)把这两个字段的初始值加到构造函数中,并迫使构造函数调用设值函数。
class TelephoneNumber…
constructor(areaCode, number) {this._areaCode = areaCode;this._number = number;
}
然后我会逐一查看设值函数的调用者,并将其改为重新赋值整个对象。先从“地区代码”(area code)开始。
class Person…
get officeAreaCode() {return this._telephoneNumber.areaCode;}
set officeAreaCode(arg) {this._telephoneNumber = new TelephoneNumber(arg, this.officeNumber);
}
get officeNumber() {return this._telephoneNumber.number;}
set officeNumber(arg) {this._telephoneNumber.number = arg;}
对于其他字段,重复上述步骤。
class Person…
get officeAreaCode() {return this._telephoneNumber.areaCode;}
set officeAreaCode(arg) {this._telephoneNumber = new TelephoneNumber(arg, this.officeNumber);
}
get officeNumber() {return this._telephoneNumber.number;}
set officeNumber(arg) {this._telephoneNumber = new TelephoneNumber(this.officeAreaCode, arg);
}
现在,TelephoneNumber 已经是不可变的类,可以将其变成真正的值对象了。是不是真正的值对象,要看是否基于值判断相等性。在这个领域中,JavaScript 做得不好:语言和核心库都不支持将“基于引用的相等性判断”换成“基于值的相等性判断”。我唯一能做的就是创建自己的 equals 函数。
class TelephoneNumber…
equals(other) {if (!(other instanceof TelephoneNumber)) return false;return this.areaCode === other.areaCode &&this.number === other.number;
}
对其进行测试很重要:
it("telephone equals", function () {assert(new TelephoneNumber("312", "555-0142").equals(new TelephoneNumber("312", "555-0142")));
});
这段测试代码用了不寻常的格式,是为了帮助读者一眼看出上下两次构造函数调用完全一样。
我在这个测试中创建了两个各自独立的对象,并验证它们相等。
在大多数面向对象语言中,内置的相等性判断方法可以被覆写为基于值的相等性判断。在 Ruby 中,我可以覆写==运算符;在 Java 中,我可以覆写 Object.equals()方法。在覆写相等性判断的同时,我通常还需要覆写生成散列码的方法(例如 Java 中的 Object.hashCode()方法),以确保用到散列码的集合在使用值对象时一切正常。
如果有多个客户端使用了 TelephoneNumber 对象,重构的过程还是一样,只是在运用移除设值函数(331)时要修改多处客户端代码。另外,有必要添加几个测试,检查电话号码不相等以及与非电话号码和 null 值比较相等性等情况。
9.5 将值对象改为引用对象(Change Value to Reference)
反向重构:将引用对象改为值对象(252)
let customer = new Customer(customerData);let customer = customerRepository.get(customerData.id);
动机
一个数据结构中可能包含多个记录,而这些记录都关联到同一个逻辑数据结构。例如,我可能会读取一系列订单数据,其中有多条订单属于同一个顾客。遇到这样的共享关系时,既可以把顾客信息作为值对象来看待,也可以将其视为引用对象。如果将其视为值对象,那么每份订单数据中都会复制顾客的数据;而如果将其视为引用对象,对于一个顾客,就只有一份数据结构,会有多个订单与之关联。
如果顾客数据永远不修改,那么两种处理方式都合理。把同一份数据复制多次可能会造成一点困扰,但这种情况也很常见,不会造成太大问题。过多的数据复制有可能会造成内存占用的问题,但就跟所有性能问题一样,这种情况并不常见。
如果共享的数据需要更新,将其复制多份的做法就会遇到巨大的困难。此时我必须找到所有的副本,更新所有对象。只要漏掉一个副本没有更新,就会遭遇麻烦的数据不一致。这种情况下,可以考虑将多份数据副本变成单一的引用,这样对顾客数据的修改就会立即反映在该顾客的所有订单中。
把值对象改为引用对象会带来一个结果:对于一个客观实体,只有一个代表它的对象。这通常意味着我会需要某种形式的仓库,在仓库中可以找到所有这些实体对象。只为每个实体创建一次对象,以后始终从仓库中获取该对象。
做法
为相关对象创建一个仓库(如果还没有这样一个仓库的话)。
确保构造函数有办法找到关联对象的正确实例。
修改宿主对象的构造函数,令其从仓库中获取关联对象。每次修改后执行测试。
范例
我将从一个代表“订单”的 Order 类开始,其实例对象可从一个 JSON 文件创建。用来创建订单的数据中有一个顾客(customer)ID,我们用它来进一步创建 Customer 对象。
class Order…
constructor(data) {this._number = data.number;this._customer = new Customer(data.customer);// load other data
}
get customer() {return this._customer;}
class Customer…
constructor(id) {this._id = id;
}
get id() {return this._id;}
以这种方式创建的 Customer 对象是值对象。如果有 5 个订单都属于 ID 为 123 的顾客,就会有 5 个各自独立的 Customer 对象。对其中一个所做的修改,不会反映在其他几个对象身上。如果我想增强 Customer 对象,例如从客户服务获取到了更多关于顾客的信息,我必须用同样的数据更新所有 5 个对象。重复的对象总是会让我紧张——用多个对象代表同一个实体(例如一名顾客),这会招致混乱。如果 Customer 对象是可变的,问题就更加严重,因为各个对象之间的数据可能不一致。
如果我想每次都使用同一个 Customer 对象,那么就需要有一个地方存储这个对象。每个应用程序中,存储实体的地方会各有不同,在最简单的情况下,我会使用一个仓库对象[mf-repos]。
let _repositoryData;export function initialize() {_repositoryData = {};_repositoryData.customers = new Map();
}export function registerCustomer(id) {if (!_repositoryData.customers.has(id))_repositoryData.customers.set(id, new Customer(id));return findCustomer(id);
}export function findCustomer(id) {return _repositoryData.customers.get(id);
}
仓库对象允许根据 ID 注册顾客,并且对于一个 ID 只会创建一个 Customer 对象。有了仓库对象,我就可以修改 Order 对象的构造函数来使用它。
在使用本重构手法时,可能仓库对象已经存在了,那么就可以直接使用它。
下一步是要弄清楚,Order 的构造函数如何获得正确的 Customer 对象。在这个例子里,这一步很简单,因为输入数据流中已经包含了顾客的 ID。
class Order…
constructor(data) {this._number = data.number;this._customer = registerCustomer(data.customer);// load other data
}
get customer() {return this._customer;}
现在,如果我在一条订单中修改了顾客信息,就会同步反映在该顾客拥有的所有订单中。
在这个例子里,我在第一个引用该顾客信息的 Order 对象中新建了 Customer 对象。另一个常见的做法是:首先获取一份包含所有 Customer 对象的列表,将其填入仓库对象,然后在读取 Order 对象时关联到对应的 Customer 对象。如果这样做,那么 Order 对象包含的顾客 ID 必须指向一个仓库中已有的 Customer 对象,否则就表示程序中有错误。
上面的代码还有一个问题:构造函数与一个全局的仓库对象耦合。全局对象必须小心对待:它们就像强力的药物,少用一点儿大有益处,用过量就是毒药。如果想解决这个问题,可以将仓库对象作为参数传递给构造函数。
第 10 章 简化条件逻辑
程序的大部分威力来自条件逻辑,但很不幸,程序的复杂度也大多来自条件逻辑。我经常借助重构把条件逻辑变得更容易理解。我常用分解条件表达式(260)处理复杂的条件表达式,用合并条件表达式(263)厘清逻辑组合。我会用以卫语句取代嵌套条件表达式(266)清晰表达“在主要处理逻辑之前先做检查”的意图。如果我发现一处 switch 逻辑处理了几种情况,可以考虑拿出以多态取代条件表达式(272)重构手法。
很多条件逻辑是用于处理特殊情况的,例如处理 null 值。如果对某种特殊情况的处理逻辑大多相同,那么可以用引入特例(289)(常被称作引入空对象)消除重复代码。另外,虽然我很喜欢去除条件逻辑,但如果我想明确地表述(以及检查)程序的状态,引入断言(302)是一个不错的补充。
10.1 分解条件表达式(Decompose Conditional)
if (!aDate.isBefore(plan.summerStart) && !aDate.isAfter(plan.summerEnd))charge = quantity * plan.summerRate;
elsecharge = quantity * plan.regularRate + plan.regularServiceCharge;if (summer())charge = summerCharge();
elsecharge = regularCharge();
动机
程序之中,复杂的条件逻辑是最常导致复杂度上升的地点之一。我必须编写代码来检查不同的条件分支,根据不同的条件做不同的事,然后,我很快就会得到一个相当长的函数。大型函数本身就会使代码的可读性下降,而条件逻辑则会使代码更难阅读。在带有复杂条件逻辑的函数中,代码(包括检查条件分支的代码和真正实现功能的代码)会告诉我发生的事,但常常让我弄不清楚为什么会发生这样的事,这就说明代码的可读性的确大大降低了。
和任何大块头代码一样,我可以将它分解为多个独立的函数,根据每个小块代码的用途,为分解而得的新函数命名,并将原函数中对应的代码改为调用新函数,从而更清楚地表达自己的意图。对于条件逻辑,将每个分支条件分解成新函数还可以带来更多好处:可以突出条件逻辑,更清楚地表明每个分支的作用,并且突出每个分支的原因。
本重构手法其实只是提炼函数(106)的一个应用场景。但我要特别强调这个场景,因为我发现它经常会带来很大的价值。
做法
对条件判断和每个条件分支分别运用提炼函数(106)手法。
范例
假设我要计算购买某样商品的总价(总价=数量 × 单价),而这个商品在冬季和夏季的单价是不同的:
if (!aDate.isBefore(plan.summerStart) && !aDate.isAfter(plan.summerEnd))charge = quantity * plan.summerRate;
elsecharge = quantity * plan.regularRate + plan.regularServiceCharge;
我把条件判断提炼到一个独立的函数中:
if (summer())charge = quantity * plan.summerRate;
elsecharge = quantity * plan.regularRate + plan.regularServiceCharge;function summer() {return !aDate.isBefore(plan.summerStart) && !aDate.isAfter(plan.summerEnd);
}
然后提炼条件判断为真的分支:
if (summer())charge = summerCharge();
elsecharge = quantity * plan.regularRate + plan.regularServiceCharge;function summer() {return !aDate.isBefore(plan.summerStart) && !aDate.isAfter(plan.summerEnd);
}
function summerCharge() {return quantity * plan.summerRate;
}
最后提炼条件判断为假的分支:
if (summer())charge = summerCharge();
elsecharge = regularCharge();function summer() {return !aDate.isBefore(plan.summerStart) && !aDate.isAfter(plan.summerEnd);
}
function summerCharge() {return quantity * plan.summerRate;
}
function regularCharge() {return quantity * plan.regularRate + plan.regularServiceCharge;
}
提炼完成后,我喜欢用三元运算符重新安排条件语句。
charge = summer() ? summerCharge() : regularCharge();function summer() {return !aDate.isBefore(plan.summerStart) && !aDate.isAfter(plan.summerEnd);
}
function summerCharge() {return quantity * plan.summerRate;
}
function regularCharge() {return quantity * plan.regularRate + plan.regularServiceCharge;
}
10.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));
}
动机
有时我会发现这样一串条件检查:检查条件各不相同,最终行为却一致。如果发现这种情况,就应该使用“逻辑或”和“逻辑与”将它们合并为一个条件表达式。
之所以要合并条件代码,有两个重要原因。首先,合并后的条件代码会表述“实际上只有一次条件检查,只不过有多个并列条件需要检查而已”,从而使这一次检查的用意更清晰。当然,合并前和合并后的代码有着相同的效果,但原先代码传达出的信息却是“这里有一些各自独立的条件测试,它们只是恰好同时发生”。其次,这项重构往往可以为使用提炼函数(106)做好准备。将检查条件提炼成一个独立的函数对于厘清代码意义非常有用,因为它把描述“做什么”的语句换成了“为什么这样做”。
条件语句的合并理由也同时指出了不要合并的理由:如果我认为这些检查的确彼此独立,的确不应该被视为同一次检查,我就不会使用本项重构。
做法
确定这些条件表达式都没有副作用。
如果某个条件表达式有副作用,可以先用将查询函数和修改函数分离(306)处理。
使用适当的逻辑运算符,将两个相关条件表达式合并为一个。
顺序执行的条件表达式用逻辑或来合并,嵌套的 if 语句用逻辑与来合并。
测试。
重复前面的合并过程,直到所有相关的条件表达式都合并到一起。
可以考虑对合并后的条件表达式实施提炼函数(106)。
范例
在走读代码的过程中,我看到了下面的代码片段:
function disabilityAmount(anEmployee) {if (anEmployee.seniority < 2) return 0;if (anEmployee.monthsDisabled > 12) return 0;if (anEmployee.isPartTime) return 0;// compute the disability amount
这里有一连串的条件检查,都指向同样的结果。既然结果是相同的,就应该把这些条件检查合并成一条表达式。对于这样顺序执行的条件检查,可以用逻辑或运算符来合并。
function disabilityAmount(anEmployee) {if ((anEmployee.seniority < 2)|| (anEmployee.monthsDisabled > 12)) return 0;if (anEmployee.isPartTime) return 0;// compute the disability amount
测试,然后把下一个条件检查也合并进来:
function disabilityAmount(anEmployee) {if ((anEmployee.seniority < 2)|| (anEmployee.monthsDisabled > 12)|| (anEmployee.isPartTime)) return 0;// compute the disability amount
合并完成后,再对这句条件表达式使用提炼函数(106)。
function disabilityAmount(anEmployee) {if (isNotEligableForDisability()) return 0;// compute the disability amountfunction isNotEligableForDisability() {return ((anEmployee.seniority < 2)|| (anEmployee.monthsDisabled > 12)|| (anEmployee.isPartTime));
}
范例:使用逻辑与
上面的例子展示了用逻辑或合并条件表达式的做法。不过,我有可能遇到需要逻辑与的情况。例如,嵌套 if 语句的情况:
if (anEmployee.onVacation)if (anEmployee.seniority > 10)return 1;
return 0.5;
可以用逻辑与运算符将其合并。
if ((anEmployee.onVacation)&& (anEmployee.seniority > 10)) return 1;
return 0.5;
如果原来的条件逻辑混杂了这两种情况,我也会根据需要组合使用逻辑与和逻辑或运算符。在这种时候,代码很可能变得混乱,所以我会频繁使用提炼函数(106),把代码变得可读。
10.3 以卫语句取代嵌套条件表达式(Replace Nested Conditional with 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();
}
动机
根据我的经验,条件表达式通常有两种风格。第一种风格是:两个条件分支都属于正常行为。第二种风格则是:只有一个条件分支是正常行为,另一个分支则是异常的情况。
这两类条件表达式有不同的用途,这一点应该通过代码表现出来。如果两条分支都是正常行为,就应该使用形如 if…else…的条件表达式;如果某个条件极其罕见,就应该单独检查该条件,并在该条件为真时立刻从函数中返回。这样的单独检查常常被称为“卫语句”(guard clauses)。
以卫语句取代嵌套条件表达式的精髓就是:给某一条分支以特别的重视。如果使用 if-then-else 结构,你对 if 分支和 else 分支的重视是同等的。这样的代码结构传递给阅读者的消息就是:各个分支有同样的重要性。卫语句就不同了,它告诉阅读者:“这种情况不是本函数的核心逻辑所关心的,如果它真发生了,请做一些必要的整理工作,然后退出。”
“每个函数只能有一个入口和一个出口”的观念,根深蒂固于某些程序员的脑海里。我发现,当我处理他们编写的代码时,经常需要使用以卫语句取代嵌套条件表达式。现今的编程语言都会强制保证每个函数只有一个入口,至于“单一出口”规则,其实不是那么有用。在我看来,保持代码清晰才是最关键的:如果单一出口能使这个函数更清楚易读,那么就使用单一出口;否则就不必这么做。
做法
选中最外层需要被替换的条件逻辑,将其替换为卫语句。
测试。
有需要的话,重复上述步骤。
如果所有卫语句都引发同样的结果,可以使用合并条件表达式(263)合并之。
范例
下面的代码用于计算要支付给员工(employee)的工资。只有还在公司上班的员工才需要支付工资,所以这个函数需要检查两种“员工已经不在公司上班”的情况。
function payAmount(employee) {let result;if(employee.isSeparated) {result = {amount: 0, reasonCode:"SEP"};}else {if (employee.isRetired) {result = {amount: 0, reasonCode: "RET"};}else {// logic to compute amountlorem.ipsum(dolor.sitAmet);1consectetur(adipiscing).elit();sed.do.eiusmod = tempor.incididunt.ut(labore) && dolore(magna.aliqua);ut.enim.ad(minim.veniam);result = someFinalComputation();}}return result;
}
嵌套的条件逻辑让我们看不清代码真实的含义。只有当前两个条件表达式都不为真的时候,这段代码才真正开始它的主要工作。所以,卫语句能让代码更清晰地阐述自己的意图。
一如既往地,我喜欢小步前进,所以我先处理最顶上的条件逻辑。
function payAmount(employee) {let result;if (employee.isSeparated) return {amount: 0, reasonCode: "SEP"};if (employee.isRetired) {result = {amount: 0, reasonCode: "RET"};}else {// logic to compute amountlorem.ipsum(dolor.sitAmet);consectetur(adipiscing).elit();sed.do.eiusmod = tempor.incididunt.ut(labore) && dolore(magna.aliqua);ut.enim.ad(minim.veniam);result = someFinalComputation();}return result;
}
做完这步修改,我执行测试,然后继续下一步。
function payAmount(employee) {let result;if (employee.isSeparated) return {amount: 0, reasonCode: "SEP"};if (employee.isRetired) return {amount: 0, reasonCode: "RET"};// logic to compute amountlorem.ipsum(dolor.sitAmet);consectetur(adipiscing).elit();sed.do.eiusmod = tempor.incididunt.ut(labore) && dolore(magna.aliqua);ut.enim.ad(minim.veniam);result = someFinalComputation();return result;
}
此时,result 变量已经没有用处了,所以我把它删掉:
function payAmount(employee) {let result;if (employee.isSeparated) return {amount: 0, reasonCode: "SEP"};if (employee.isRetired) return {amount: 0, reasonCode: "RET"};// logic to compute amountlorem.ipsum(dolor.sitAmet);consectetur(adipiscing).elit();sed.do.eiusmod = tempor.incididunt.ut(labore) && dolore(magna.aliqua);ut.enim.ad(minim.veniam);return someFinalComputation();
}
能减少一个可变变量总是好的。
范例:将条件反转
审阅本书第 1 版的初稿时,Joshua Kerievsky 指出:我们常常可以将条件表达式反转,从而实现以卫语句取代嵌套条件表达式。为了拯救我可怜的想象力,他还好心帮我想了一个例子:
function adjustedCapital(anInstrument) {let result = 0;if (anInstrument.capital > 0) {if (anInstrument.interestRate > 0 && anInstrument.duration > 0) {result = (anInstrument.income / anInstrument.duration) * anInstrument.adjustmentFactor;}}return result;
}
同样地,我逐一进行替换。不过这次在插入卫语句时,我需要将相应的条件反转过来:
function adjustedCapital(anInstrument) {let result = 0;if (anInstrument.capital <= 0) return result;if (anInstrument.interestRate > 0 && anInstrument.duration > 0) {result = (anInstrument.income / anInstrument.duration) * anInstrument.adjustmentFactor;}return result;
}
下一个条件稍微复杂一点,所以我分两步进行反转。首先加入一个逻辑非操作:
function adjustedCapital(anInstrument) {let result = 0;if (anInstrument.capital <= 0) return result;if (!(anInstrument.interestRate > 0 && anInstrument.duration > 0)) return result;result = (anInstrument.income / anInstrument.duration) * anInstrument.adjustmentFactor;return result;
}
但是在这样的条件表达式中留下一个逻辑非,会把我的脑袋拧成一团乱麻,所以我把它简化成下面这样:
function adjustedCapital(anInstrument) {let result = 0;if (anInstrument.capital <= 0) return result;if (anInstrument.interestRate <= 0 || anInstrument.duration <= 0) return result;result = (anInstrument.income / anInstrument.duration) * anInstrument.adjustmentFactor;return result;
}
这两行逻辑语句引发的结果一样,所以我可以用合并条件表达式(263)将其合并。
function adjustedCapital(anInstrument) {let result = 0;if ( anInstrument.capital <= 0|| anInstrument.interestRate <= 0|| anInstrument.duration <= 0) return result;result = (anInstrument.income / anInstrument.duration) * anInstrument.adjustmentFactor;return result;
}
此时 result 变量做了两件事:一开始我把它设为 0,代表卫语句被触发时的返回值;然后又用最终计算的结果给它赋值。我可以彻底移除这个变量,避免用一个变量承担两重责任,而且又减少了一个可变变量。
function adjustedCapital(anInstrument) {if ( anInstrument.capital <= 0|| anInstrument.interestRate <= 0|| anInstrument.duration <= 0) return 0;return (anInstrument.income / anInstrument.duration) * anInstrument.adjustmentFactor;
}
1 “lorem.ipsum……”是一篇常见于排版设计领域的文章,其内容为不具可读性的字符组合,目的是使阅读者只专注于观察段落的字型和版型。——译者注
10.4 以多态取代条件表达式(Replace Conditional 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";
}
动机
复杂的条件逻辑是编程中最难理解的东西之一,因此我一直在寻求给条件逻辑添加结构。很多时候,我发现可以将条件逻辑拆分到不同的场景(或者叫高阶用例),从而拆解复杂的条件逻辑。这种拆分有时用条件逻辑本身的结构就足以表达,但使用类和多态能把逻辑的拆分表述得更清晰。
一个常见的场景是:我可以构造一组类型,每个类型处理各自的一种条件逻辑。例如,我会注意到,图书、音乐、食品的处理方式不同,这是因为它们分属不同类型的商品。最明显的征兆就是有好几个函数都有基于类型代码的 switch 语句。若果真如此,我就可以针对 switch 语句中的每种分支逻辑创建一个类,用多态来承载各个类型特有的行为,从而去除重复的分支逻辑。
另一种情况是:有一个基础逻辑,在其上又有一些变体。基础逻辑可能是最常用的,也可能是最简单的。我可以把基础逻辑放进超类,这样我可以首先理解这部分逻辑,暂时不管各种变体,然后我可以把每种变体逻辑单独放进一个子类,其中的代码着重强调与基础逻辑的差异。
多态是面向对象编程的关键特性之一。跟其他一切有用的特性一样,它也很容易被滥用。我曾经遇到有人争论说所有条件逻辑都应该用多态取代。我不赞同这种观点。我的大部分条件逻辑只用到了基本的条件语句——if/else 和 switch/case,并不需要劳师动众地引入多态。但如果发现如前所述的复杂条件逻辑,多态是改善这种情况的有力工具。
做法
如果现有的类尚不具备多态行为,就用工厂函数创建之,令工厂函数返回恰当的对象实例。
在调用方代码中使用工厂函数获得对象实例。
将带有条件逻辑的函数移到超类中。
如果条件逻辑还未提炼至独立的函数,首先对其使用提炼函数(106)。
任选一个子类,在其中建立一个函数,使之覆写超类中容纳条件表达式的那个函数。将与该子类相关的条件表达式分支复制到新函数中,并对它进行适当调整。
重复上述过程,处理其他条件分支。
在超类函数中保留默认情况的逻辑。或者,如果超类应该是抽象的,就把该函数声明为 abstract,或在其中直接抛出异常,表明计算责任都在子类中。
范例
我的朋友有一群鸟儿,他想知道这些鸟飞得有多快,以及它们的羽毛是什么样的。所以我们写了一小段程序来判断这些信息。
function plumages(birds) {return new Map(birds.map(b => [b.name, plumage(b)]));
}
function speeds(birds) {return new Map(birds.map(b => [b.name, airSpeedVelocity(b)]));
}function plumage(bird) {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";}
}function airSpeedVelocity(bird) {switch (bird.type) {case 'EuropeanSwallow':return 35;case 'AfricanSwallow':return 40 - 2 * bird.numberOfCoconuts;case 'NorwegianBlueParrot':return (bird.isNailed) ? 0 : 10 + bird.voltage / 10;default:return null;}
}
有两个不同的操作,其行为都随着“鸟的类型”发生变化,因此可以创建出对应的类,用多态来处理各类型特有的行为。
我先对 airSpeedVelocity 和 plumage 两个函数使用函数组合成类(144)。
function plumage(bird) {return new Bird(bird).plumage;
}function airSpeedVelocity(bird) {return new Bird(bird).airSpeedVelocity;
}
class Bird {constructor(birdObject) {Object.assign(this, birdObject);}get plumage() {switch (this.type) {case 'EuropeanSwallow':return "average";case 'AfricanSwallow':return (this.numberOfCoconuts > 2) ? "tired" : "average";case 'NorwegianBlueParrot':return (this.voltage > 100) ? "scorched" : "beautiful";default:return "unknown";}}get airSpeedVelocity() {switch (this.type) {case 'EuropeanSwallow':return 35;case 'AfricanSwallow':return 40 - 2 * this.numberOfCoconuts;case 'NorwegianBlueParrot':return (this.isNailed) ? 0 : 10 + this.voltage / 10;default:return null;}}
}
然后针对每种鸟创建一个子类,用一个工厂函数来实例化合适的子类对象。
function plumage(bird) {return createBird(bird).plumage;
}function airSpeedVelocity(bird) {return createBird(bird).airSpeedVelocity;
}function createBird(bird) {switch (bird.type) {case "EuropeanSwallow":return new EuropeanSwallow(bird);case "AfricanSwallow":return new AfricanSwallow(bird);case "NorweigianBlueParrot":return new NorwegianBlueParrot(bird);default:return new Bird(bird);}
}
class EuropeanSwallow extends Bird {}class AfricanSwallow extends Bird {}class NorwegianBlueParrot extends Bird {}
现在我已经有了需要的类结构,可以处理两个条件逻辑了。先从 plumage 函数开始,我从 switch 语句中选一个分支,在适当的子类中覆写这个逻辑。
class EuropeanSwallow…
get plumage() {return "average";
}
class Bird…
get plumage() {switch (this.type) {case 'EuropeanSwallow':throw "oops";case 'AfricanSwallow':return (this.numberOfCoconuts > 2) ? "tired" : "average";case 'NorwegianBlueParrot':return (this.voltage > 100) ? "scorched" : "beautiful";default:return "unknown";}
}
在超类中,我把对应的逻辑分支改为抛出异常,因为我总是偏执地担心出错。
此时我就可以编译并测试。如果一切顺利的话,我可以接着处理下一个分支。
class AfricanSwallow…
get plumage() {return (this.numberOfCoconuts > 2) ? "tired" : "average";
}
然后是挪威蓝鹦鹉(Norwegian Blue)的分支。
class NorwegianBlueParrot…
get plumage() {return (this.voltage >100) ? "scorched" : "beautiful";
}
超类函数保留下来处理默认情况。
class Bird…
get plumage() {return "unknown";
}
airSpeedVelocity 也如法炮制。完成以后,代码大致如下(我还对顶层的 airSpeedVelocity 和 plumage 函数做了内联处理):
function plumages(birds) {return new Map(birds.map(b => createBird(b)).map(bird => [bird.name, bird.plumage]));
}
function speeds(birds) {return new Map(birds.map(b => createBird(b)).map(bird => [bird.name, bird.airSpeedVelocity]));
}function createBird(bird) {switch (bird.type) {case 'EuropeanSwallow':return new EuropeanSwallow(bird);case 'AfricanSwallow':return new AfricanSwallow(bird);case 'NorwegianBlueParrot':return new NorwegianBlueParrot(bird);default:return new Bird(bird);}
}class Bird {constructor(birdObject) {Object.assign(this, birdObject);}get plumage() {return "unknown";}get airSpeedVelocity() {return null;}
}
class EuropeanSwallow extends Bird {get plumage() {return "average";}get airSpeedVelocity() {return 35;}
}
class AfricanSwallow extends Bird {get plumage() {return (this.numberOfCoconuts > 2) ? "tired" : "average";}get airSpeedVelocity() {return 40 - 2 * this.numberOfCoconuts;}
}
class NorwegianBlueParrot extends Bird {get plumage() {return (this.voltage > 100) ? "scorched" : "beautiful";}get airSpeedVelocity() {return (this.isNailed) ? 0 : 10 + this.voltage / 10;}
}
看着最终的代码,可以看出 Bird 超类并不是必需的。在 JavaScript 中,多态不一定需要类型层级,只要对象实现了适当的函数就行。但在这个例子中,我愿意保留这个不必要的超类,因为它能帮助阐释各个子类与问题域之间的关系。
范例:用多态处理变体逻辑
在前面的例子中,“鸟”的类型体系是一个清晰的泛化体系:超类是抽象的“鸟”,子类是各种具体的鸟。这是教科书(包括我写的书)中经常讨论的继承和多态,但并不是实践中使用继承的唯一方式。实际上,这种方式很可能不是最常用或最好的方式。另一种使用继承的情况是:我想表达某个对象与另一个对象大体类似,但又有一些不同之处。
下面有一个这样的例子:有一家评级机构,要对远洋航船的航行进行投资评级。这家评级机构会给出“A”或者“B”两种评级,取决于多种风险和盈利潜力的因素。在评估风险时,既要考虑航程本身的特征,也要考虑船长过往航行的历史。
function rating(voyage, history) {const vpf = voyageProfitFactor(voyage, history);const vr = voyageRisk(voyage);const chr = captainHistoryRisk(voyage, history);if (vpf * 3 > (vr + chr * 2)) return "A";else return "B";
}
function voyageRisk(voyage) {let result = 1;if (voyage.length > 4) result += 2;if (voyage.length > 8) result += voyage.length - 8;if (["china", "east-indies"].includes(voyage.zone)) result += 4;return Math.max(result, 0);
}
function captainHistoryRisk(voyage, history) {let result = 1;if (history.length < 5) result += 4;result += history.filter(v => v.profit < 0).length;if (voyage.zone === "china" && hasChina(history)) result -= 2;return Math.max(result, 0);
}
function hasChina(history) {return history.some(v => "china" === v.zone);
}
function voyageProfitFactor(voyage, history) {let result = 2;if (voyage.zone === "china") result += 1;if (voyage.zone === "east-indies") result += 1;if (voyage.zone === "china" && hasChina(history)) {result += 3;if (history.length > 10) result += 1;if (voyage.length > 12) result += 1;if (voyage.length > 18) result -= 1;}else {if (history.length > 8) result += 1;if (voyage.length > 14) result -= 1;}return result;
}
voyageRisk 和 captainHistoryRisk 两个函数负责打出风险分数,voyageProfitFactor 负责打出盈利潜力分数,rating 函数将 3 个分数组合到一起,给出一次航行的综合评级。
调用方的代码大概是这样:
const voyage = { zone: "west-indies", length: 10 };
const history = [{ zone: "east-indies", profit: 5 },{ zone: "west-indies", profit: 15 },{ zone: "china", profit: -2 },{ zone: "west-africa", profit: 7 },
];const myRating = rating(voyage, history);
代码中有两处同样的条件逻辑,都在询问“是否有到中国的航程”以及“船长是否曾去过中国”。
function rating(voyage, history) {const vpf = voyageProfitFactor(voyage, history);const vr = voyageRisk(voyage);const chr = captainHistoryRisk(voyage, history);if (vpf * 3 > (vr + chr * 2)) return "A";else return "B";
}
function voyageRisk(voyage) {let result = 1;if (voyage.length > 4) result += 2;if (voyage.length > 8) result += voyage.length - 8;if (["china", "east-indies"].includes(voyage.zone)) result += 4;return Math.max(result, 0);
}
function captainHistoryRisk(voyage, history) {let result = 1;if (history.length < 5) result += 4;result += history.filter(v => v.profit < 0).length;if (voyage.zone === "china" && hasChina(history)) result -= 2;return Math.max(result, 0);
}
function hasChina(history) {return history.some(v => "china" === v.zone);
}
function voyageProfitFactor(voyage, history) {let result = 2;if (voyage.zone === "china") result += 1;if (voyage.zone === "east-indies") result += 1;if (voyage.zone === "china" && hasChina(history)) {result += 3;if (history.length > 10) result += 1;if (voyage.length > 12) result += 1;if (voyage.length > 18) result -= 1;}else {if (history.length > 8) result += 1;if (voyage.length > 14) result -= 1;}return result;
}
我会用继承和多态将处理“中国因素”的逻辑从基础逻辑中分离出来。如果还要引入更多的特殊逻辑,这个重构就很有用——这些重复的“中国因素”会混淆视听,让基础逻辑难以理解。
起初代码里只有一堆函数,如果要引入多态的话,我需要先建立一个类结构,因此我首先使用函数组合成类(144)。这一步重构的结果如下所示:
function rating(voyage, history) {return new Rating(voyage, history).value;
}class Rating {constructor(voyage, history) {this.voyage = voyage;this.history = history;}get value() {const vpf = this.voyageProfitFactor;const vr = this.voyageRisk;const chr = this.captainHistoryRisk;if (vpf * 3 > (vr + chr * 2)) return "A";else return "B";}get voyageRisk() {let result = 1;if (this.voyage.length > 4) result += 2;if (this.voyage.length > 8) result += this.voyage.length - 8;if (["china", "east-indies"].includes(this.voyage.zone)) result += 4;return Math.max(result, 0);}get captainHistoryRisk() {let result = 1;if (this.history.length < 5) result += 4;result += this.history.filter(v => v.profit < 0).length;if (this.voyage.zone === "china" && this.hasChinaHistory) result -= 2;return Math.max(result, 0);}get voyageProfitFactor() {let result = 2;if (this.voyage.zone === "china") result += 1;if (this.voyage.zone === "east-indies") result += 1;if (this.voyage.zone === "china" && this.hasChinaHistory) {result += 3;if (this.history.length > 10) result += 1;if (this.voyage.length > 12) result += 1;if (this.voyage.length > 18) result -= 1;}else {if (this.history.length > 8) result += 1;if (this.voyage.length > 14) result -= 1;}return result;}get hasChinaHistory() {return this.history.some(v => "china" === v.zone);}
}
于是我就有了一个类,用来安放基础逻辑。现在我需要另建一个空的子类,用来安放与超类不同的行为。
class ExperiencedChinaRating extends Rating {}
然后,建立一个工厂函数,用于在需要时返回变体类。
function createRating(voyage, history) {if (voyage.zone === "china" && history.some(v => "china" === v.zone))return new ExperiencedChinaRating(voyage, history);else return new Rating(voyage, history);
}
我需要修改所有调用方代码,让它们使用该工厂函数,而不要直接调用构造函数。还好现在调用构造函数的只有 rating 函数一处。
function rating(voyage, history) {return createRating(voyage, history).value;
}
有两处行为需要移入子类中。我先处理 captainHistoryRisk 中的逻辑。
class Rating…
get captainHistoryRisk() {let result = 1;if (this.history.length < 5) result += 4;result += this.history.filter(v => v.profit < 0).length;if (this.voyage.zone === "china" && this.hasChinaHistory) result -= 2;return Math.max(result, 0);
}
在子类中覆写这个函数。
class ExperiencedChinaRating
get captainHistoryRisk() {const result = super.captainHistoryRisk - 2;return Math.max(result, 0);
}
class Rating…
get captainHistoryRisk() {let result = 1;if (this.history.length < 5) result += 4;result += this.history.filter(v => v.profit < 0).length;if (this.voyage.zone === "china" && this.hasChinaHistory) result -= 2;return Math.max(result, 0);
}
分离 voyageProfitFactor 函数中的变体行为要更麻烦一些。我不能直接从超类中删掉变体行为,因为在超类中还有另一条执行路径。我又不想把整个超类中的函数复制到子类中。
class Rating…
get voyageProfitFactor() {let result = 2;if (this.voyage.zone === "china") result += 1;if (this.voyage.zone === "east-indies") result += 1;if (this.voyage.zone === "china" && this.hasChinaHistory) {result += 3;if (this.history.length > 10) result += 1;if (this.voyage.length > 12) result += 1;if (this.voyage.length > 18) result -= 1;}else {if (this.history.length > 8) result += 1;if (this.voyage.length > 14) result -= 1;}return result;
}
所以我先用提炼函数(106)将整个条件逻辑块提炼出来。
class Rating…
get voyageProfitFactor() {let result = 2;if (this.voyage.zone === "china") result += 1;if (this.voyage.zone === "east-indies") result += 1;result += this.voyageAndHistoryLengthFactor;return result;
}
get voyageAndHistoryLengthFactor() {let result = 0;if (this.voyage.zone === "china" && this.hasChinaHistory) {result += 3;if (this.history.length > 10) result += 1;if (this.voyage.length > 12) result += 1;if (this.voyage.length > 18) result -= 1;}else {if (this.history.length > 8) result += 1;if (this.voyage.length > 14) result -= 1;}return result;
}
函数名中出现“And”字样是一个很不好的味道,不过我会暂时容忍它,先聚焦子类化操作。
class Rating…
get voyageAndHistoryLengthFactor() {let result = 0;if (this.history.length > 8) result += 1;if (this.voyage.length > 14) result -= 1;return result;
}
class ExperiencedChinaRating…
get voyageAndHistoryLengthFactor() {let result = 0;result += 3;if (this.history.length > 10) result += 1;if (this.voyage.length > 12) result += 1;if (this.voyage.length > 18) result -= 1;return result;
}
严格说来,重构到这儿就结束了——我已经把变体行为分离到了子类中,超类的逻辑理解和维护起来更简单了,只有在进入子类代码时我才需要操心变体逻辑。子类的代码表述了它与超类的差异。
但我觉得至少应该谈谈如何处理这个丑陋的新函数。引入一个函数以便子类覆写,这在处理这种“基础和变体”的继承关系时是常见操作。但这样一个难看的函数只会妨碍——而不是帮助——别人理解其中的逻辑。
函数名中的“And”字样说明其中包含了两件事,所以我觉得应该将它们分开。我会用提炼函数(106)把“历史航行数”(history length)的相关逻辑提炼出来。这一步提炼在超类和子类中都要发生,我首先从超类开始。
class Rating…
get voyageAndHistoryLengthFactor() {let result = 0;result += this.historyLengthFactor;if (this.voyage.length > 14) result -= 1;return result;
}
get historyLengthFactor() {return (this.history.length > 8) ? 1 : 0;
}
然后在子类中也如法炮制。
class ExperiencedChinaRating…
get voyageAndHistoryLengthFactor() {let result = 0;result += 3;result += this.historyLengthFactor;if (this.voyage.length > 12) result += 1;if (this.voyage.length > 18) result -= 1;return result;
}
get historyLengthFactor() {return (this.history.length > 10) ? 1 : 0;
}
然后在超类中使用搬移语句到调用者(217)。
class Rating…
get voyageProfitFactor() {let result = 2;if (this.voyage.zone === "china") result += 1;if (this.voyage.zone === "east-indies") result += 1;result += this.historyLengthFactor;result += this.voyageAndHistoryLengthFactor;return result;
}get voyageAndHistoryLengthFactor() {let result = 0;result += this.historyLengthFactor;if (this.voyage.length > 14) result -= 1;return result;
}
class ExperiencedChinaRating…
get voyageAndHistoryLengthFactor() {let result = 0;result += 3;result += this.historyLengthFactor;if (this.voyage.length > 12) result += 1;if (this.voyage.length > 18) result -= 1;return result;
}
再用函数改名(124)改掉这个难听的名字。
class Rating…
get voyageProfitFactor() {let result = 2;if (this.voyage.zone === "china") result += 1;if (this.voyage.zone === "east-indies") result += 1;result += this.historyLengthFactor;result += this.voyageLengthFactor;return result;
}get voyageLengthFactor() {return (this.voyage.length > 14) ? - 1: 0;
}
改为三元表达式,以简化 voyageLengthFactor 函数。
class ExperiencedChinaRating…
get voyageLengthFactor() {let result = 0;result += 3;if (this.voyage.length > 12) result += 1;if (this.voyage.length > 18) result -= 1;return result;
}
最后一件事:在“航程数”(voyage length)因素上加上 3 分,我认为这个逻辑不合理,应该把这 3 分加在最终的结果上。
class ExperiencedChinaRating…
get voyageProfitFactor() {return super.voyageProfitFactor + 3;
}get voyageLengthFactor() {let result = 0;result += 3;if (this.voyage.length > 12) result += 1;if (this.voyage.length > 18) result -= 1;return result;
}
重构结束,我得到了如下代码。首先,我有一个基本的 Rating 类,其中不考虑与“中国经验”相关的复杂性:
class Rating {constructor(voyage, history) {this.voyage = voyage;this.history = history;}get value() {const vpf = this.voyageProfitFactor;const vr = this.voyageRisk;const chr = this.captainHistoryRisk;if (vpf * 3 > (vr + chr * 2)) return "A";else return "B";}get voyageRisk() {let result = 1;if (this.voyage.length > 4) result += 2;if (this.voyage.length > 8) result += this.voyage.length - 8;if (["china", "east-indies"].includes(this.voyage.zone)) result += 4;return Math.max(result, 0);}get captainHistoryRisk() {let result = 1;if (this.history.length < 5) result += 4;result += this.history.filter(v => v.profit < 0).length;return Math.max(result, 0);}get voyageProfitFactor() {let result = 2;if (this.voyage.zone === "china") result += 1;if (this.voyage.zone === "east-indies") result += 1;result += this.historyLengthFactor;result += this.voyageLengthFactor;return result;}get voyageLengthFactor() {return (this.voyage.length > 14) ? - 1: 0;}get historyLengthFactor() {return (this.history.length > 8) ? 1 : 0;}
}
与“中国经验”相关的代码则清晰表述出在基本逻辑之上的一系列变体逻辑:
class ExperiencedChinaRating extends Rating {get captainHistoryRisk() {const result = super.captainHistoryRisk - 2;return Math.max(result, 0);}get voyageLengthFactor() {let result = 0;if (this.voyage.length > 12) result += 1;if (this.voyage.length > 18) result -= 1;return result;}get historyLengthFactor() {return (this.history.length > 10) ? 1 : 0;}get voyageProfitFactor() {return super.voyageProfitFactor + 3;}
}
10.5 引入特例(Introduce Special Case)
曾用名:引入 Null 对象(Introduce Null Object)
if (aCustomer === "unknown") customerName = "occupant";class UnknownCustomer {get name() {return "occupant";}
动机
一种常见的重复代码是这种情况:一个数据结构的使用者都在检查某个特殊的值,并且当这个特殊值出现时所做的处理也都相同。如果我发现代码库中有多处以同样方式应对同一个特殊值,我就会想要把这个处理逻辑收拢到一处。
处理这种情况的一个好办法是使用“特例”(Special Case)模式:创建一个特例元素,用以表达对这种特例的共用行为的处理。这样我就可以用一个函数调用取代大部分特例检查逻辑。
特例有几种表现形式。如果我只需要从这个对象读取数据,可以提供一个字面量对象(literal object),其中所有的值都是预先填充好的。如果除简单的数值之外还需要更多的行为,就需要创建一个特殊对象,其中包含所有共用行为所对应的函数。特例对象可以由一个封装类来返回,也可以通过变换插入一个数据结构。
一个通常需要特例处理的值就是 null,这也是这个模式常被叫作“Null 对象”(Null Object)模式的原因——我喜欢说:Null 对象是特例的一种特例。
做法
我们从一个作为容器的数据结构(或者类)开始,其中包含一个属性,该属性就是我们要重构的目标。容器的客户端每次使用这个属性时,都需要将其与某个特例值做比对。我们希望把这个特例值替换为代表这种特例情况的类或数据结构。
给重构目标添加检查特例的属性,令其返回 false。
创建一个特例对象,其中只有检查特例的属性,返回 true。
对“与特例值做比对”的代码运用提炼函数(106),确保所有客户端都使用这个新函数,而不再直接做特例值的比对。
将新的特例对象引入代码中,可以从函数调用中返回,也可以在变换函数中生成。
修改特例比对函数的主体,在其中直接使用检查特例的属性。
测试。
使用函数组合成类(144)或函数组合成变换(149),把通用的特例处理逻辑都搬移到新建的特例对象中。
特例类对于简单的请求通常会返回固定的值,因此可以将其实现为字面记录(literal record)。
对特例比对函数使用内联函数(115),将其内联到仍然需要的地方。
范例
一家提供公共事业服务的公司将自己的服务安装在各个场所(site)。
class Site…
get customer() {return this._customer;}
代表“顾客”的 Customer 类有多个属性,我只考虑其中 3 个。
class Customer…
get name() {...}
get billingPlan() {...}
set billingPlan(arg) {...}
get paymentHistory() {...}
大多数情况下,一个场所会对应一个顾客,但有些场所没有与之对应的顾客,可能是因为之前的住户搬走了,而新搬来的住户我还不知道是谁。这种情况下,数据记录中的 customer 字段会被填充为字符串"unknown"。因为这种情况时有发生,所以 Site 对象的客户端必须有办法处理“顾客未知”的情况。下面是一些示例代码片段。
客户端 1…
const aCustomer = site.customer;
// ... lots of intervening code ...
let customerName;
if (aCustomer === "unknown") customerName = "occupant";
else customerName = aCustomer.name;
客户端 2…
const plan =aCustomer === "unknown" ? registry.billingPlans.basic : aCustomer.billingPlan;
客户端 3…
if (aCustomer !== "unknown") aCustomer.billingPlan = newPlan;
客户端 4…
const weeksDelinquent =aCustomer === "unknown"? 0: aCustomer.paymentHistory.weeksDelinquentInLastYear;
浏览整个代码库,我看到有很多使用 Site 对象的客户端在处理“顾客未知”的情况,大多数都用了同样的应对方式:用"occupant"(居民)作为顾客名,使用基本的计价套餐,并认为这家顾客没有欠费。到处都在检查这种特例,再加上对特例的处理方式高度一致,这些现象告诉我:是时候使用特例对象(Special Case Object)模式了。
我首先给 Customer 添加一个函数,用于指示“这个顾客是否未知”。
class Customer…
get isUnknown() {return false;}
然后我给“未知的顾客”专门创建一个类。
class UnknownCustomer {get isUnknown() {return true;}
}
注意,我没有把 UnknownCustomer 类声明为 Customer 的子类。在其他编程语言(尤其是静态类型的编程语言)中,我会需要继承关系。但 JavaScript 是一种动态类型语言,按照它的子类化规则,这里不声明继承关系反而更好。
下面就是麻烦之处了。我必须在所有期望得到"unknown"值的地方返回这个新的特例对象,并修改所有检查"unknown"值的地方,令其使用新的 isUnknown 函数。一般而言,我总是希望细心安排修改过程,使我可以每次做一点小修改,然后马上测试。但如果我修改了 Customer 类,使其返回 UnknownCustomer 对象(而非"unknown"字符串),那么就必须同时修改所有客户端,让它们不要检查"unknown"字符串,而是调用 isUnknown 函数——这两个修改必须一次完成。我感觉这一大步修改就像一大块难吃的食物一样难以下咽。
还好,遇到这种困境时,有一个常用的技巧可以帮忙。如果有一段代码需要在很多地方做修改(例如我们这里的“与特例做比对”的代码),我会先对其使用提炼函数(106)。
function isUnknown(arg) {if (!(arg instanceof Customer || arg === "unknown"))throw new Error(`investigate bad value: <${arg}>`);return arg === "unknown";
}
我会放一个陷阱,捕捉意料之外的值。如果在重构过程中我犯了错误,引入了奇怪的行为,这个陷阱会帮我发现。
现在,凡是检查未知顾客的地方,都可以改用这个函数了。我可以逐一修改这些地方,每次修改之后都可以执行测试。
客户端 1…
let customerName;
if (isUnknown(aCustomer)) customerName = "occupant";
else customerName = aCustomer.name;
没用多久,就全部修改完了。
客户端 2…
const plan = isUnknown(aCustomer)? registry.billingPlans.basic: aCustomer.billingPlan;
客户端 3…
if (!isUnknown(aCustomer)) aCustomer.billingPlan = newPlan;
客户端 4…
const weeksDelinquent = isUnknown(aCustomer)? 0: aCustomer.paymentHistory.weeksDelinquentInLastYear;
将所有调用处都改为使用 isUnknown 函数之后,就可以修改 Site 类,令其在顾客未知时返回 UnknownCustomer 对象。
class Site…
get customer() {return (this._customer === "unknown") ? new UnknownCustomer() : this._customer;
}
然后修改 isUnknown 函数的判断逻辑。做完这步修改之后我可以做一次全文搜索,应该没有任何地方使用"unknown"字符串了。
客户端 1…
function isUnknown(arg) {if (!(arg instanceof Customer || arg instanceof UnknownCustomer))throw new Error(`investigate bad value: <${arg}>`);return arg.isUnknown;
}
测试,以确保一切运转如常。
现在,有趣的部分开始了。我可以逐一查看客户端检查特例的代码,看它们处理特例的逻辑,并考虑是否能用函数组合成类(144)将其替换为一个共同的、符合预期的值。此刻,有多处客户端代码用字符串"occupant"来作为未知顾客的名字,就像下面这样。
客户端 1…
let customerName;
if (isUnknown(aCustomer)) customerName = "occupant";
else customerName = aCustomer.name;
我可以在 UnknownCustomer 类中添加一个合适的函数。
class UnknownCustomer…
get name() {return "occupant";}
然后我就可以去掉所有条件代码。
客户端 1…
const customerName = aCustomer.name;
测试通过之后,我可能会用内联变量(123)把 customerName 变量也消除掉。
接下来处理代表“计价套餐”的 billingPlan 属性。
客户端 2…
const plan = isUnknown(aCustomer)? registry.billingPlans.basic: aCustomer.billingPlan;
客户端 3…
if (!isUnknown(aCustomer)) aCustomer.billingPlan = newPlan;
对于读取该属性的行为,我的处理方法跟前面处理 name 属性一样——找到通用的应对方式,并在 UnknownCustomer 中使用之。至于对该属性的写操作,当前的代码没有对未知顾客调用过设值函数,所以在特例对象中,我会保留设值函数,但其中什么都不做。
class UnknownCustomer…
get billingPlan() {return registry.billingPlans.basic;}
set billingPlan(arg) { /* ignore */ }
读取的例子…
const plan = aCustomer.billingPlan;
更新的例子…
aCustomer.billingPlan = newPlan;
特例对象是值对象,因此应该始终是不可变的,即便它们替代的原对象本身是可变的。
最后一个例子则更麻烦一些,因为特例对象需要返回另一个对象,后者又有其自己的属性。
客户端…
const weeksDelinquent = isUnknown(aCustomer)? 0: aCustomer.paymentHistory.weeksDelinquentInLastYear;
一般的原则是:如果特例对象需要返回关联对象,被返回的通常也是特例对象。所以,我需要创建一个代表“空支付记录”的特例类 NullPaymentHistory。
class UnknownCustomer…
get paymentHistory() {return new NullPaymentHistory();}
class NullPaymentHistory…
get weeksDelinquentInLastYear() {return 0;}
客户端…
const weeksDelinquent = aCustomer.paymentHistory.weeksDelinquentInLastYear;
我继续查看客户端代码,寻找是否有能用多态行为取代的地方。但也会有例外情况——客户端不想使用特例对象提供的逻辑,而是想做一些别的处理。我可能有 23 处客户端代码用"occupant"作为未知顾客的名字,但还有一处用了别的值。
客户端…
const name = !isUnknown(aCustomer) ? aCustomer.name : "unknown occupant";
这种情况下,我只能在客户端保留特例检查的逻辑。我会对其做些修改,让它使用 aCustomer 对象身上的 isUnknown 函数,也就是对全局的 isUnknown 函数使用内联函数(115)。
客户端…
const name = aCustomer.isUnknown ? "unknown occupant" : aCustomer.name;
处理完所有客户端代码后,全局的 isUnknown 函数应该没人再调用了,可以用移除死代码(237)将其移除。
范例:使用对象字面量
我们在上面处理的其实是一些很简单的值,却要创建一个这样的类,未免有点儿大动干戈。但在上面这个例子中,我必须创建这样一个类,因为 Customer 类是允许使用者更新其内容的。但如果面对一个只读的数据结构,我就可以改用字面量对象(literal object)。
还是前面这个例子——几乎完全一样,除了一件事:这次没有客户端对 Customer 对象做更新操作:
class Site…
get customer() {return this._customer;}
class Customer…
get name() {...}
get billingPlan() {...}
set billingPlan(arg) {...}
get paymentHistory() {...}
客户端 1…
const aCustomer = site.customer;
// ... lots of intervening code ...
let customerName;
if (aCustomer === "unknown") customerName = "occupant";
else customerName = aCustomer.name;
客户端 2…
const plan =aCustomer === "unknown" ? registry.billingPlans.basic : aCustomer.billingPlan;
客户端 3…
const weeksDelinquent =aCustomer === "unknown"? 0: aCustomer.paymentHistory.weeksDelinquentInLastYear;
和前面的例子一样,我首先在 Customer 中添加 isUnknown 属性,并创建一个包含同名字段的特例对象。这次的区别在于,特例对象是一个字面量。
class Customer…
get isUnknown() {return false;}
顶层作用域…
function createUnknownCustomer() {return {isUnknown: true,};
}
然后我对检查特例的条件逻辑运用提炼函数(106)。
function isUnknown(arg) {return arg === "unknown";
}
客户端 1…
let customerName;
if (isUnknown(aCustomer)) customerName = "occupant";
else customerName = aCustomer.name;
客户端 2…
const plan = isUnknown(aCustomer)? registry.billingPlans.basic: aCustomer.billingPlan;
客户端 3…
const weeksDelinquent = isUnknown(aCustomer)? 0: aCustomer.paymentHistory.weeksDelinquentInLastYear;
修改 Site 类和做条件判断的 isUnknown 函数,开始使用特例对象。
class Site…
get customer() {return (this._customer === "unknown") ? createUnknownCustomer() : this._customer;
}
顶层作用域…
function isUnknown(arg) {return arg.isUnknown;
}
然后把“以标准方式应对特例”的地方都替换成使用特例字面量的值。首先从“名字”开始:
function createUnknownCustomer() {return {isUnknown: true,name: "occupant",};
}
客户端 1…
const customerName = aCustomer.name;
接着是“计价套餐”:
function createUnknownCustomer() {return {isUnknown: true,name: "occupant",billingPlan: registry.billingPlans.basic,};
}
客户端 2…
const plan = aCustomer.billingPlan;
同样,我可以在字面量对象中创建一个嵌套的空支付记录对象:
function createUnknownCustomer() {return {isUnknown: true,name: "occupant",billingPlan: registry.billingPlans.basic,paymentHistory: {weeksDelinquentInLastYear: 0,},};
}
客户端 3…
const weeksDelinquent = aCustomer.paymentHistory.weeksDelinquentInLastYear;
如果使用了这样的字面量,应该使用诸如 Object.freeze 的方法将其冻结,使其不可变。通常,我还是喜欢用类多一点。
范例:使用变换
前面两个例子都涉及了一个类,其实本重构手法也同样适用于记录,只要增加一个变换步骤即可。
假设我们的输入是一个简单的记录结构,大概像这样:
{name: "Acme Boston",location: "Malden MA",// more site detailscustomer: {name: "Acme Industries",billingPlan: "plan-451",paymentHistory: {weeksDelinquentInLastYear: 7//more},// more}
}
有时顾客的名字未知,此时标记的方式与前面一样:将 customer 字段标记为字符串"unknown"。
{
name: "Warehouse Unit 15",
location: "Malden MA",
// more site details
customer: "unknown",
}
客户端代码也类似,会检查“未知顾客”的情况:
客户端 1…
const site = acquireSiteData();
const aCustomer = site.customer;
// ... lots of intervening code ...
let customerName;
if (aCustomer === "unknown") customerName = "occupant";
else customerName = aCustomer.name;
客户端 2…
const plan =aCustomer === "unknown" ? registry.billingPlans.basic : aCustomer.billingPlan;
客户端 3…
const weeksDelinquent =aCustomer === "unknown"? 0: aCustomer.paymentHistory.weeksDelinquentInLastYear;
我首先要让 Site 数据结构经过一次变换,目前变换中只做了深复制,没有对数据做任何处理。
客户端 1…
const rawSite = acquireSiteData();
const site = enrichSite(rawSite);
const aCustomer = site.customer;
// ... lots of intervening code ...
let customerName;
if (aCustomer === "unknown") customerName = "occupant";
else customerName = aCustomer.name;function enrichSite(inputSite) {return _.cloneDeep(inputSite);
}
然后对“检查未知顾客”的代码运用提炼函数(106)。
function isUnknown(aCustomer) {return aCustomer === "unknown";
}
客户端 1…
const rawSite = acquireSiteData();
const site = enrichSite(rawSite);
const aCustomer = site.customer;
// ... lots of intervening code ...
let customerName;
if (isUnknown(aCustomer)) customerName = "occupant";
else customerName = aCustomer.name;
客户端 2…
const plan = isUnknown(aCustomer)? registry.billingPlans.basic: aCustomer.billingPlan;
客户端 3…
const weeksDelinquent = isUnknown(aCustomer)? 0: aCustomer.paymentHistory.weeksDelinquentInLastYear;
然后开始对 Site 数据做增强,首先是给 customer 字段加上 isUnknown 属性。
function enrichSite(aSite) {const result = _.cloneDeep(aSite);const unknownCustomer = {isUnknown: true,};if (isUnknown(result.customer)) result.customer = unknownCustomer;else result.customer.isUnknown = false;return result;
}
随后修改检查特例的条件逻辑,开始使用新的属性。原来的检查逻辑也保留不动,所以现在的检查逻辑应该既能应对原来的 Site 数据,也能应对增强后的 Site 数据。
function isUnknown(aCustomer) {if (aCustomer === "unknown") return true;else return aCustomer.isUnknown;
}
测试,确保一切正常,然后针对特例使用函数组合成变换(149)。首先把“未知顾客的名字”的处理逻辑搬进增强函数。
function enrichSite(aSite) {const result = _.cloneDeep(aSite);const unknownCustomer = {isUnknown: true,name: "occupant",};if (isUnknown(result.customer)) result.customer = unknownCustomer;else result.customer.isUnknown = false;return result;
}
客户端 1…
const rawSite = acquireSiteData();
const site = enrichSite(rawSite);
const aCustomer = site.customer;
// ... lots of intervening code ...
const customerName = aCustomer.name;
测试,然后是“未知顾客的计价套餐”的处理逻辑。
function enrichSite(aSite) {const result = _.cloneDeep(aSite);const unknownCustomer = {isUnknown: true,name: "occupant",billingPlan: registry.billingPlans.basic,};if (isUnknown(result.customer)) result.customer = unknownCustomer;else result.customer.isUnknown = false;return result;
}
客户端 2…
const plan = aCustomer.billingPlan;
再次测试,然后处理最后一处客户端代码。
function enrichSite(aSite) {const result = _.cloneDeep(aSite);const unknownCustomer = {isUnknown: true,name: "occupant",billingPlan: registry.billingPlans.basic,paymentHistory: {weeksDelinquentInLastYear: 0,},};if (isUnknown(result.customer)) result.customer = unknownCustomer;else result.customer.isUnknown = false;return result;
}
客户端 3…
const weeksDelinquent = aCustomer.paymentHistory.weeksDelinquentInLastYear;
10.6 引入断言(Introduce Assertion)
if (this.discountRate)base = base - (this.discountRate * base);assert(this.discountRate>= 0);
if (this.discountRate)base = base - (this.discountRate * base);
动机
常常会有这样一段代码:只有当某个条件为真时,该段代码才能正常运行。例如,平方根计算只对正值才能进行,又例如,某个对象可能假设一组字段中至少有一个不等于 null。
这样的假设通常并没有在代码中明确表现出来,你必须阅读整个算法才能看出。有时程序员会以注释写出这样的假设,而我要介绍的是一种更好的技术——使用断言明确标明这些假设。
断言是一个条件表达式,应该总是为真。如果它失败,表示程序员犯了错误。断言的失败不应该被系统任何地方捕捉。整个程序的行为在有没有断言出现的时候都应该完全一样。实际上,有些编程语言中的断言可以在编译期用一个开关完全禁用掉。
我常看见有人鼓励用断言来发现程序中的错误。这固然是一件好事,但却不是使用断言的唯一理由。断言是一种很有价值的交流形式——它们告诉阅读者,程序在执行到这一点时,对当前状态做了何种假设。另外断言对调试也很有帮助。而且,因为它们在交流上很有价值,即使解决了当下正在追踪的错误,我还是倾向于把断言留着。自测试的代码降低了断言在调试方面的价值,因为逐步逼近的单元测试通常能更好地帮助调试,但我仍然看重断言在交流方面的价值。
做法
如果你发现代码假设某个条件始终为真,就加入一个断言明确说明这种情况。
因为断言应该不会对系统运行造成任何影响,所以“加入断言”永远都应该是行为保持的。
范例
下面是一个简单的例子:折扣。顾客(customer)会获得一个折扣率(discount rate),可以用于所有其购买的商品。
class Customer…
applyDiscount(aNumber) {return (this.discountRate)? aNumber - (this.discountRate * aNumber): aNumber;
}
这里有一个假设:折扣率永远是正数。我可以用断言明确标示出这个假设。但在一个三元表达式中没办法很简单地插入断言,所以我首先要把这个表达式转换成 if-else 的形式。
class Customer…
applyDiscount(aNumber) {if (!this.discountRate) return aNumber;else return aNumber - (this.discountRate * aNumber);
}
现在我就可以轻松地加入断言了。
class Customer…
applyDiscount(aNumber) {if (!this.discountRate) return aNumber;else {assert(this.discountRate >= 0);return aNumber - (this.discountRate * aNumber);}
}
对这个例子而言,我更愿意把断言放在设值函数上。如果在 applyDiscount 函数处发生断言失败,我还得先费力搞清楚非法的折扣率值起初是从哪儿放进去的。
class Customer…
set discountRate(aNumber) {assert(null === aNumber || aNumber >= 0);this._discountRate = aNumber;
}
真正引起错误的源头有可能很难发现——也许是输入数据中误写了一个减号,也许是某处代码做数据转换时犯了错误。像这样的断言对于发现错误源头特别有帮助。
注意,不要滥用断言。我不会使用断言来检查所有“我认为应该为真”的条件,只用来检查“必须为真”的条件。滥用断言可能会造成代码重复,尤其是在处理上面这样的条件逻辑时。所以我发现,很有必要去掉条件逻辑中的重复,通常可以借助提炼函数(106)手法。
我只用断言预防程序员的错误。如果要从某个外部数据源读取数据,那么所有对输入值的检查都应该是程序的一等公民,而不能用断言实现——除非我对这个外部数据源有绝对的信心。断言是帮助我们跟踪 bug 的最后一招,所以,或许听来讽刺,只有当我认为断言绝对不会失败的时候,我才会使用断言。