重构,改善代码的既有设计——第一组重构
第一组重构
在重构名录的开头,先介绍一组我认为最有用的重构。
提炼函数(Extract Function)
动机
提炼函数是我最常用的重构之一(在这里使用的是函数这个词,但换成面向对象中的“方法/method“,或者其他任何形式的”过程/procedure“或者”子程序/subroutine“,也同样适用)。我会浏览一段代码,理解其作用,然后将其提炼到一个独立的函数中。
对于何时应该把代码放进独立的函数这个问题,我认为最合理的观点就是如果你需要话一段时间浏览一段代码才能弄清楚它到底在干什么,那么就应该将其提炼到一个函数中,并根据它所做的事为其命名。一旦接受了这个原则,我就逐渐养成了一个习惯:写非常小的函数——通常只有几行,在我看来,一个函数一旦超过六行,就开始散发臭味。我甚至经常写一些一行代码的函数。另外代码的意图和实现之间有着相当大的距离。
另外不用担心短函数会造成大量的函数调用从而造成性能问题,短函数常常会让编译器的优化功能运转更良好,应为短函数可以更容易地被缓存。另外小函数得有个好名字才行,所以你必须在命名上花心思。
做法
- 创建一个新函数,根据这个函数的意图来对它命名(以它“做什么”来命名,而不是以它“怎样做”来命名)。
- 将待提炼的代码从源函数复制到新建的目标函数中
- 仔细检查提炼出的代码,看看其中是否引用了作用域限于源函数、在提炼出的新函数中访问不到的变量。若是,以参数的形式将它们传递给新函数。
- 所有变量都处理完之后,编译。
- 在源函数中,将被提炼代码段替换为对目标函数的调用。
- 测试。
- 查看其他代码是否有与被提炼的代码段相同或相似之处。如果有,考虑使用以函数调用取代内联代码令其调用出新的函数。
范例:无局部变量
function printOwing(invoice) {
let outstanding = 0;
for (const o of invoice.orders) {
outstanding += o.amount;
}
// 这是封装系统时钟调用的对象
const today = Clock.today;
(invoice.dueDate = new Date(
today.getFullYear(),
today.getMonth(),
today.getDate() + 30
)),
console.log(`name: ${invoice.customer}`);
console.log(`amount: ${outstanding}`);
console.log(`name: ${invoice.dueDate.toLocaleDateString()}`);
}
我可以轻松地把打印详细信息部分提炼出来
function printOwing(invoice) {
let outstanding = 0;
for (const o of invoice.orders) {
outstanding += o.amount;
}
// 这是封装系统时钟调用的对象
const today = Clock.today;
(invoice.dueDate = new Date(
today.getFullYear(),
today.getMonth(),
today.getDate() + 30
)),
printDetails();
function printDetails() {
console.log(`name: ${invoice.customer}`);
console.log(`amount: ${outstanding}`);
console.log(`name: ${invoice.dueDate.toLocaleDateString()}`);
}
}
范例:无局部变量
局部变量最简单的情况是:被提炼代码段只是读取这些变量的值,并不修改它们,这种情况下我可以简单的地将它们当作参数传给目标参数。
function printOwing(invoice) {
let outstanding = 0;
for (const o of invoice.orders) {
outstanding += o.amount;
}
// 这是封装系统时钟调用的对象
const today = Clock.today;
(invoice.dueDate = new Date(
today.getFullYear(),
today.getMonth(),
today.getDate() + 30
)),
recordDueDate(invoice);
printDetails(invoice, outstanding);
}
function printDetails(invoice, outstanding) {
console.log(`name: ${invoice.customer}`);
console.log(`amount: ${outstanding}`);
console.log(`name: ${invoice.dueDate.toLocaleDateString()}`);
}
function recordDueDate(invoice) {
const today = Clock.today;
invoice.dueDate = new Date(
today.getFullYear(),
today.getMonth(),
today.getDate() + 30
);
}
范例:对局部变量再赋值
如果你发现源函数地参数被赋值,应该马上使用拆分变量将其变成临时变量。
function printOwing(invoice) {
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;
}
function printDetails(invoice, outstanding) {
console.log(`name: ${invoice.customer}`);
console.log(`amount: ${outstanding}`);
console.log(`name: ${invoice.dueDate.toLocaleDateString()}`);
}
function recordDueDate(invoice) {
const today = Clock.today;
invoice.dueDate = new Date(
today.getFullYear(),
today.getMonth(),
today.getDate() + 30
);
}
如果返回的变量不止一个,有几种选择,最好的选择通常是:挑选另一块变量来提炼,我比较喜欢让每个函数都只返回一个值,所以我会安排多个函数用以返回多个值。如果真的有必要提炼一个函数返回多个值,可以构造并返回一个记录对象,不过通常最好的办法还是回过头来重新处理局部变量。
内联函数
动机
有时候你会遇到某些函数,其内部代码和函数名称同样清晰易读,也可能你重构了该函数的内部实现,使其内容和其名称变得同样清晰。若果真如此,你就应该去掉这个函数,直接使用其中的代码。间接性可能带来帮助,但非必要的间接性总让人不舒服。
另一种需要使用内联函数的情况是:我手上有一群组织不甚合理的函数,可以将他们都内联到一个大型函数中,再以我喜欢的方式重新提炼出小函数。
如果代码中有太多间接层,使得系统中的所有函数似乎都只是对另一个函数的间接委托,造成我在这些委托动作之间晕头转向,那么我通常会使用内联函数。
做法
- 检查函数,确定它不具有多态性。如果该函数属于一个类,并且子类继承了这个函数,那么就无法内联。
- 找出这个函数的所有调用点。
- 将这个函数的所有调用点都替换为函数本体
- 每次替换之后,执行测试。不必一次完成整个内联操作,如果某些调用点比较难以内联,可以等到时机成熟后再处理
- 删除该函数的定义。
function rating(aCustomer) {
const lines = [];
gatherCustomerData(lines, aCustomer);
return lines;
}
function gatherCustomerData(out, aCustomer) {
out.push(["name", aCustomer.name]);
out.push(["location", aCustomer.location]);
}
重构为
function rating(aCustomer) {
const lines = [];
lines.push(["name", aCustomer.name]);
lines.push(["location", aCustomer.location]);
return lines;
}
提炼变量
动机
表达式可能非常复杂而且难以阅读,这种情况下,局部变量可以帮助我们将表达式分解为比较容易管理的形式。再面对一块复杂逻辑时,局部变量使我能给其中一部分命名,这样我能更好的理解这部分逻辑是要干什么。
如果我考虑使用提炼变量,就意味着我要给代码中的一个表达式命名。如果这个名字只在当前函数中有意义,那么提炼变量个不错的选择,如果这个变量名在更宽的上下文中也有意义,我会考虑将其暴露出来。
做法
- 确认要提炼的表达式没有副作用。
- 生命一个不可修改的变量,把你想要提炼的表达式复制一份,以该表达式的结果值给这个变量赋值。
- 用这个新变量取代原来的表达式
- 测试
如果该表达式出现了多次,请用这个新变量逐一替换,每次替换之后都要执行测试。
范例
function price(order) {
return (
order.quantity * order.itemPrice -
Math.max(0, order.quantity - 500) * order.itemPrice * 0.05 +
Math.min(order.quantity * order.itemPrice * 0.1, 100)
);
}
重构为
function price(order) {
const basePrice = order.quantity * order.itemPrice;
const quantityDiscount =
Math.max(0, order.quantity - 500) * order.itemPrice * 0.05;
const shipping = Math.min(order.quantity * order.itemPrice * 0.1, 100);
return basePrice - quantityDiscount + shipping;
}
范例:在一个类中
下面是同样的代码,但这次它位于一个类中
class Order {
constructor(aRecord) {
this._data = aRecord;
}
get price() {
return (
order.quantity * order.itemPrice -
Math.max(0, order.quantity - 500) * order.itemPrice * 0.05 +
Math.min(order.quantity * order.itemPrice * 0.1, 100)
);
}
}
这些变量名所代表的概念,适用于整个 Order 类,而不仅仅是“计算价格”的上下文,因此,将它们提炼成方法显然更有益处。
class Order {
constructor(aRecord) {
this._data = aRecord;
}
get price() {
return this.basePrice - this.quantityDiscount + this.shipping;
}
get basePrice() {
return order.quantity * order.itemPrice;
}
get quantityDiscount() {
return Math.max(0, order.quantity - 500) * order.itemPrice * 0.05;
}
get shipping() {
return Math.min(order.quantity * order.itemPrice * 0.1, 100);
}
}
内联变量
动机
在一个函数内部,变量能给表达式提供有意义的名字,因此通常变量是好东西。
做法
- 检查确认变量赋值语句的右侧表达式没有副作用。
- 如果变量没有被声明为不可修改,先将其变为不可修改,并执行测试。
- 找到第一处该使用该变量的地方,将其替换为直接使用赋值语句的右侧表达式。
- 测试。
- 重复前面两步,逐一替换其他所有使用该变量的地方。
- 删除该变量的测试点和赋值语句。
- 测试
改变函数声明
动机
函数是我们将程序拆分成小块的主要方式。函数声明则展现了如何将这些小块组合在一起工作——可以说,它们就是软件系统的关节。
对于这些关节而言,最重要的元素当属函数的名字,如果看到一个函数的名字不对,一旦发现了更好的名字,就得尽快给函数改名。
对于函数的参数,道理也是一样。函数的参数列表阐述了函数如何与外部世界共处。函数的参数设置了一个上下文,只有在这个上下文中,我才能使用这个函数。比如加入该函数参数是是 person,我就不能用它处理 company。
做法
改变函数声明有一套简单的做法,这套简单的做法常常够用,但在很多时候,有必要以更渐进的方式逐步迁移达到最终结果。迁移式的方法让我们可以逐步修改调用方方代码,如果函数被很多地方调用,或者修改不容易,或者要修改的是一个多态函数,或者对函数声明的修改比较复杂,能渐进式地逐步修改就很重要。
简单做法
- 如果想移除一个参数,首先确定函数体内没有使用该参数。
- 修改函数声明,使其成为你期望的状态。
- 找出所有使用旧函数声明的地方,将他们改为新的函数声明。
- 测试
迁移式做法
- 如果有必要,先对函数内部加以重构
- 使用提炼函数将函数体提炼成一个新函数
- 如果提炼出的函数需要新增参数,用前面的简单做法添加即可
- 测试
- 对旧函数使用内联函数
- 如果新函数使用了临时的名字,再次使用改变函数声明将其改回原来的名字
- 测试
范例:函数改名(简单做法)
下列函数的名字太过简略了:
function circum(radius) {
return 2 * Math.PI * radius;
}
修改函数声明
function circumference(radius) {
return 2 * Math.PI * radius
}
将所有调用 circum 的地方改为 circumference。
在不同的编程语言环境中,‘找到所有调用旧函数的地方’这件事的难度也各异。静态类型加上趁手的 IDE 能提供最好的体验,通常可以全自动地完成函数改名。如果没有静态类型,就需要多花些功夫。
范例:函数改名(迁移式做法)
function circum(radius) {
return 2 * Math.PI * radius;
}
首先对整个函数体使用提炼函数
function circum(radius) {
return circumference(radius);
}
function circumference(radius) {
return 2 * Math.PI * radius;
}
每次修改一处调用者,所有调用者都修改完之后,我就可以删除旧函数。
范例:添加参数
现在有一个管理函数的软件
class Book...
addReservation(customer) {
this._reservationhs.push(customer)
}
现在我需要支持“高优先级预定”,因此我要给 addReservation 额外添加一个参数,用于标记这次预定应该进入普通预定还是优先队列。搜先我用提炼函数把 addReservation 的函数体提炼出来,放进一个新函数。
class Book...
addReservation(customer) {
this._reservationhs.push(customer, false)
}
zz_addReservation(customer) {
this._reservationhs.push(customer)
}
之后在新函数的声明中增加参数,同时修改旧函数中调用新函数的地方
class Book...
addReservation(customer) {
this._reservationhs.push(customer, false)
}
zz_addReservation(customer) {
this._reservationhs.push(customer, isPriority)
}
在修改调用方之前,可以利用 js 的语言特性先引入断言
class Book...
zz_addReservation(customer, isPriority) {
assert(isPriority === true || isPriority === false)
this._reservationhs.push(customer, isPriority)
}
现在,入股哦我在修改调用方时出了错,没有提供新参数,这个断言会帮我抓到错误。
范例:把参数改为属性
假如我有一个函数,用于判断顾客(custom)是否来自新英格兰(New England)地区:
function inNewEngland(aCustomer) {
return ['MA', 'CT',
'ME'
].includes(aCustomer.address.state)
}
下面是函数调用方
const newEnglanders = someCustomers.filter((c) => inNewEngland(c));
inNewEngland 函数只用到了顾客所在的州这项信息,我希望重构这个函数,使其接受州代码(state code) 作为参数,这样就能去掉对’顾客‘概念的依赖,使这个函数能在更多的上下文中使用。
function inNewEngland(aCustomer) {
const stateCode = aCustomer.address.state
return ['MA', 'CT',
'ME'
].includes(stateCode)
}
然后再用提炼函数创建新函数
function inNewEngland(aCustomer) {
const stateCode = aCustomer.address.state
return xxNEWinNewEngland(stateCode)
}
function xxNEWinNewEngland(stateCode) {
return ['MA', 'CT',
'ME'
].includes(stateCode)
}
这时给新函数起一个好记又独特的临时名字,这样回头要改回原来的名字时也简单一些。
之后在原函数中使用内联变量,把刚才提炼出来的参数内联回去:
function inNewEngland(aCustomer) {
return xxNEWinNewEngland(aCustomer.address.state);
}
之后用内联函数把旧函数内联到调用处,可以每次修改一个调用处。
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'
].includes(stateCode)
}
封装变量
动机 重构的作用就是调整程序中的元素。函数相对容易调整一些,应为函数只有一种用法,就是调用。在改名或搬移函数的过程中,总是可以比较容易地保留就函数作为转发函数(即旧代码调用旧函数,旧函数再调用新函数)。这样的转发函数通常不会存在太久,但的确能够简化重构过程。
而数据就麻烦的多,因为美发设计这样的转发机制。如果把数据搬走,就必须修改所有引用该数据的代码,否则该程序就不能运行。如果数据的可访问范围很大,重构的难度就会随之增大,这也是说全局数据是大麻烦的原因。
如果想要搬移一处被广泛使用的数据,最好的办法往往是先以函数形式封装对该数据的访问。封装还能提供一个清晰的观测点,可以由此监控数据的变化和使用情况。我的习惯是,对于所有可变的数据,只要它的作用域超过单个函数,我就会将其封装起来,只允许通过函数访问。数据的作用域越大,封装就越重要。
面向对象方法如此强调对象的数据应该保持私有(private),背后也是同样的原因。每当看见一个公开(public)的字段时,我就会考虑使用封装变量。
做法
- 创建封装函数,在其中访问和更新变量值。
- 执行静态检查。
- 逐一修改使用该变量的代码,将其改为调用合适的封装函数。每次替换之后,执行测试。
- 限制变量的可见性。
- 测试。
- 如果变量的值是一个记录,考虑使用封装记录。
范例
下面这个全局变量中保存了一些有用的数据
let defaultOwner = {
firstName: "Martin",
lastName: "Fowler",
};
// 使用它的代码
spaceship.owner = defaultOwner;
对这段数据进行处理
let defaultOwner = {
firstName: "Martin",
lastName: "Fowler"
}
function getDefaultOwner() {
return defaultOwner
}
function setDefaultOwner(arg) {
defaultOwner = arg
}
// 使用它的代码
spaceship.owner = getDefaultOwner()
setDefaultOwner({
firstName: "Rebecca",
lastName: "Parsons"
})
在替换完所有使用该变量的代码之后,就可以限制它的可见性,这一步的用意有两个,一来检查是否遗漏了变量的引用,二来可以保住以后的代码也不会直接访问该变量。在 JS 中,可以把变量和访问函数搬移到单独一个文件中,并且导出访问函数,这样就限制了变量的可见性。
如果条件不允许限制对变量的访问,可一个将该变量改名,起个有意义又难看的名字,比如 __privateOnly_defaultOwner,提醒后来的客户端。
封装值
前面介绍的基本重构手法对数据结构的引用做了封装,是我能控制对该数据结构的访问和重新赋值,但并不能对结构内部数据项的修改。
有两个办法可以做到禁止对数据结构内部的数值做修改:
- 修改取值函数,时期返回该数据的一份副本
let defaultOwnerData = {
firstName: "Martin",
lastName: "Fowler",
};
function getDefaultOwner() {
return Object.assign({}, defaultOwnerData);
}
function setDefaultOwner(arg) {
defaultOwner = arg;
}
对于列表数据,我尤其常用这一招。但在使用副本的做法时,我必须格外小心:有些代码可能希望修改共享的数据。若果真如此,我就只能依赖测试来发现问题了。
- 阻止对数据的修改,比如通过封装记录就能很好的体现这一效果。
let defaultOwnerData = {
firstName: "Martin",
lastName: "Fowler",
};
function defaultOwner() {
return new Person(defaultOwnerData);
}
function setDefaultOwner(arg) {
defaultOwner = arg;
}
class Person {
constructor(data) {
this._lastName = data.lastName;
this._firstName = data.firstName;
}
get lastName() {
return this._lastName;
}
get firstName() {
return this._firstName;
}
}
到目前为止,我都在讨论“在取数据时返回一份副本”,其实设置函数也可以返回一份副本。这取决于数据从哪儿来,以及我是否需要保留对源数据的连接,以便知悉源数据的变化。如果不需要这样一条连接,那么设值函数就有好处:可以防止因为源函数发生变化而造成的意外事故。如果不做复制,风险则是可能未来可能会陷入漫长而困难的调试排错过程。
另外,前面的数据复制,类封装只是在数据记录结构中深入了一层,如果想要走的更深入,则需要更多层级的复制或是封装。
一言以蔽之,数据被使用得越广,就越是值得花精力给它一个体面的封装。
变量改名
动机
好的命名是整洁编程的核心。变量能够很好地解释一段程序在干什么。使用范围越广,名字的好坏就越重要。在 JS 这样的动态语言中,我喜欢把类型信息也放进名字里。
机制
- 如果变量被广泛使用,考虑使用封装变量将其封装起来。
- 找出所有使用该变量的代码,逐一修改。
- 测试。
范例
如果要改名的变量只作用于一个函数(临时变量或参数),对其改名是最简单的,这种情况太简单,根本不需要范例:找到变量的所有引用,修改过来就行。完成修改之后就执行测试。
如果变量的作用域不止于单个函数,问题就会出现。
// 代码库的各处可能有很多地方使用它
let tpHd = "untitled";
// 有些地方是在读取变量值
result += `<h1>${tpHd}</h1>`;
// 还有些地方是在更新它的值
tpHd = obj["articleTitle"];
// 对于这种情况,我通常的反应就是运用封装变量
result += `<h1>${tpHd}</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;
}
我可以继续重构下去,将包装函数内联回去,这样所有的调用者就变回直接使用变量的状态。不过我很少这样做,如果这个变量被广泛使用,以至于我感到先做封装才敢改名,将变量封装在函数后面。
给常量改名
我想改名的是一个常量(或者在客户端看来就像是常量的元素),我可以复制这个常量,这样既不需要封装,又可以逐步完成改名。
const cpyNm = "Acme Gooseberries";
// 第一步是复制这个常量
const companyName = "Acme Gooseberries";
const cpyNm = companyName;
有了这个副本,就可以逐一修改引用旧常量的代码,使其引用新的常量,全部修改完成后,我会删掉旧的常量。这个做法不仅适用于常量,也适用于客户端只能读取的变量。
引入参数对象
动机
我常会看见,一组数据项总是结伴而行,出没于一个有一个函数。这样一组下数据就是所谓的数据泥团,我喜欢代之以一个数据结构。
将数据组织成结果是一件有价值的事,因为这让数据项之间的关系变得明晰。但这项重构的真正的意义在于,它会催生代码中更深层次的改变。一旦识别出新的数据结构,我就可以重做程序的行为来使用这些结构。我会 创建出函数来不做围绕这些数据的共用行为——可能只是一组公用的函数,也可能用一个类把数据结构与使用数据的函数组合起来。
做法
- 如果还没有合适的数据结构,就创建一个。我倾向于使用类,因为稍后把行为放进来会比较容易。我通常会尽量确保这些新建的数据结构是值对象。
- 测试。
- 使用改变函数声明给原来的函数新增一个参数,类型是新建的数据结构。
- 测试。
- 调整所有调用者,传入新数据结构的适当实例。每修改一处,执行测试。
- 用新数据结构中的每项元素,逐一取代参数列表中与之对应的参数项,然后删除原来的参数,测试。
范例
下面代码查看一组温度读数,数据如下
const station = {
name: "ZB1",
raadings: [
{
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, mas) {
return station.reading.filter((r) => r.temp < min || r.temp > max);
}
// 调用方的代码可能是下面这样
alerts = readingsOutsideRange(
station,
operationPlan.temperatureFloor,
operationPlan.temperatureCeiling
);
这里的调用代码从另一个对象中抽出两项数据,转手又把这一堆数据传递给 readingsOutsideRange。像这样用 min 和 max 两项毫不相干的数据来表示一个范围的情况并不少见,最好是将其组合为一个对象。
class NumberRange {
constructor(min, max) {
this._data = {
min: min,
max: max,
};
}
get min() {
return this._data.min;
}
get max() {
return this._data.max;
}
}
我声明了一个类,而不是基本的 JavaScript 对象,因为这个重构通常只是一系列重构的起点,在这个新类中,我不会提供任何更新数据的函数,因为我有可能将其处理成值对象。在使用这个重构手法时,大多数情况下我都会创建值对象。
function readingsOutsideRange(station, min, max, range) {
return station.reading.filter(
(r) => r.temp < range.min || r.temp > range.max
);
}
const range = new NumberRange(
operationPlan.temperatureFloor,
operationPlan.temperatureCeiling
);
alerts = readingsOutsideRange(station, range);
这项重构手法到这儿就完成了。不过,创建一个类只是为了把行为搬移进去。这里我可以给‘范围’类添加一个函数,用于测试一个值是否落在范围之内。
function readingsOutsideRange(station, min, max, range) {
return station.reading
.filter(r => r.temp < range.min || r.temp > range.max)
}
class NumberRange...
contains(arg) {
return (arg >= this.min && arg <= this.max)
}
函数组合成类
动机
类,在大多数现代编程语言中都是基本的构造。它们是面向对象语言的首要构造,在其他程序设计方法中也同样有用。
如果发现一组函数形影不离地操作同一块数据,那么就是时候组建一个类了。除了可以把已有的函数组织起来,这个重构还给我们一个机会,去发现其他的计算逻辑,把它们也重构到新的类当中。
将函数组织到一起的另一种方式是函数组合变换。在有些编程语言中,类不是一等公民,而函数则是。面对这样的语言,可以用函数所谓对象的心事来实现这个重构手法。
做法
- 运用封装记录对多个函数公用的数据记录加以封装,比如引入参数对象。
- 对于使用该记录结构的每个函数,运用搬移函数将其移入新类。
- 用以处理该数据记录的逻辑何以用提炼函数提炼出来,并移入新类。
范例
我虚构了一种用于向老百姓供给茶水计量器的数据,得到类似这样的读数
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));
// 客户端3,基础费用计算了两次,有人已经提炼了
const aReading = acquireReading();
const basicChargeAmount = calculateBaseCharge(aReading);
function calculateBaseCharge(aReading) {
return baseRate(aReading.month, aReading.year) * aReading.quantity;
}
看到这里,我很自然地想把这个提炼的函数替换掉 客户端 1 和 客户端 2 的代码。但这样一个顶层函数的问题在于,它通常位于一个文件中,读者不一定能先到来这里寻找它,我更愿意对代码做更多修改,让该函数与其处理的数据在空间上有更紧密的联系,为此目的,不妨把数据本身变为一个类。
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 类中
class Reading...
get baseCharge() {
return baseRate(this.month, this.year) * this.quantity
}
get taxableCharge() {
return Math.max(0, this.baseCharge - taxThreshold(this.year))
}
// 客户端3
const rawReading = acquireReading()
const aReading = new Reading(rawReading)
const basicChargeAmount = aReading.taxableCharge
// 客户端1
const rawReading = acquireReading()
const aReading = new Reading(rawReading)
const basicChargeAmount = aReading.baseCharge
函数组合成变换
动机
在软件中,经常需要把数据喂给一个程序,让它计算出各种派生信息。我更愿意把所有计算派生数据的逻辑收拢到一处,这样始终可以在固定的地方找到和更新这些逻辑,避免重复。
一个方式是采用数据变换函数:这种函数接收源数据作为输入,计算出所有的派生数据,将派生数据以字段形式填入输出数据。函数组合成变换的替代方案是函数组合成类,先用源数据创建一个类,再把相关的计算逻辑搬移到类中。
不过这两者有一个重要的区别:如果代码中会对源数据做更新,那么使用类要好得多;如果使用变换,派生数据会被存储在新生成的记录中,一旦源数据被修改,我就会遭遇数据不一致。
把函数组合起来的原因有二:一是防止计算派生数据的逻辑到处重复。二是孤立存在的函数常常很难找到。
做法
- 创建有一个变换函数,输入参数是需要变换的记录,并直接返回该记录的值。这一步通常需要对数据的记录做深复制,确保不会修改原来的记录。
- 挑选一块逻辑,将其主题移入变换函数中,把结果作为字段添加到输出记录中。修改客户端代码,令其使用这个新字段。
- 测试。
- 针对其他相关的计算逻辑,重复上述步骤。
范例
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));
// 客户端3,基础费用计算了两次,有人已经提炼了
const aReading = acquireReading();
const basicChargeAmount = calculateBaseCharge(aReading);
function calculateBaseCharge(aReading) {
return baseRate(aReading.month, aReading.year) * aReading.quantity;
}
把这些计算派生数据的逻辑搬移到一个变换函数中,该函数接收原始的“读数”作为输入,输出则是增强的“读数”记录,其中包含所有共用的派生数据。
我首先要创建一个变换函数,它要做的事很简单,就是复制输入的对象:
import _ from "lodash";
function enrichReading(original) {
const result = _.cloneDeep(original);
result.baseCharge = calculateBaseCharge(result);
result.taxableCharge = Math.max(
0,
result.baseCharge - taxThreshold(result.year)
);
return result;
}
// 客户端3
const rawReading = acquireReading();
const aReading = enrichReading(rawReading);
const taxableCharge = aReading.taxableCharge;
// 客户端1
const rawReading = acquireReading();
const aReading = enrichReading(rawReading);
const basicChargeAmount = aReading.baseCharge;
增强后的读数记录有一个大问题:如果某个客户端修改了一项数据的值,会导致数据不一致,在 js 中,避免这种情况的最好办法是不要使用本重构手法,改用函数组合成类。如果编程语言支持不可变的数据结构,那么就没有这个问题了。如果数据是在制度的上下文中使用(比如在网页上显示派生数据),还是可以使用变换。
拆分阶段
动机
每当看见一段代码在同时处理两件不同的事,我就想把它拆分成各自独立的模块,因为这样到了需要修改的时,我就可以单独处理每个主题,而不必同时在脑子里考虑两个不同的主题。
最简单的拆分方法之一,就是把一大段行为分成顺序执行的两个阶段。可能你有一段处理逻辑,其数据数据的格式不符合计算逻辑的要求,所以你得先对输入数据作一番调整,使其便于处理
如果一块代码中出现了上下几段,各自使用不同的一组数据和函数,将这些代码片段拆分成各自独立的模块,更能明确地标识出它们之间的差异。
作法
- 将第二阶段的代码提炼成独立的函数。
- 测试。
- 引入一个数据中转结构,将其作为参数添加到提炼出的新函数的参数列表中。
- 测试。
- 逐一检查提炼出的“第二阶段函数“的每个参数。如果某个参数被第一阶段用到,就将其移入中转数据结构。每次搬移之后都要进行测试。
- 对第一阶段的代码运用提炼函数,让提炼出的函数返回中转数据结构。
范例
我有一段”计算订单价格“的代码
function priceOrder(product, quantity, shioppingMethod) {
// 根据商品信息计算订单中与商品相关的价格
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;
}
可以看出上面有两块逻辑相对独立
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;
}