重构
重构的原则
- 重构(名词):对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。
- 重构(动词):使用一系列的重构手法,在不改变软件可观察行为的前提下,调整期结构。
重构与性能优化的相似之处:两者都需要修改代码,并且两者都不会改变程序的整体功能。
差别在于:
- 重构为了让代码 更容易理解,更易于修改。这可能会让代码运行的更快,也有可能使代码运行的更慢。
- 在性能优化时,只关心让程序运行的更快,最终得到的代码有可能更难理解和维护。
两顶帽子
- 添加新功能时,不应该修改既有的代码,只管添加新功能。
- 在重构时,不应该添加新功能,只管调整代码的结构。
在正常开发过程中,应该根据此刻的任务,对编程状态提出不同的要求。
为何重构
重构可以很好的帮助我们控制自己的代码。重构是一个工具,他可以用于一下几个目的:
- 重构改进软件的设计。
经常性的重构有助于代码维持自己该有的形态。
- 重构使软件更容易理解。
- 重构帮助找到 BUG。
- 重构提高编程速度。
何时重构
- 预备性重构:让添加新功能更容易
重构的最佳时=时机就在添加新功能之前。
- 帮助理解的重构:使代码更易懂
- 捡垃圾式重构
上面的三种重构都是--见机行事的重构。不会专门安排时间重构,而是在添加功能或修复 BUG 的同时顺便重构。
代码的坏味道
什么样的代码需要修改?
- 神秘命名
整洁代码最重要的一环就是好的名字,所以我们会深思熟虑如何给函数、模块、变量和类命名,使他们能清晰的表明自己的功能和用法。
然而,命名是编程中最难的两件事之一,因此改名可能是最常用是我重构手法,包括 改变函数声明、变量改名、字段改名 等。
- 重复代码
如果在一个以上的地点看到相同的代码结构,那么可以肯定:设法将它们合而为一,程序就会变得更好。
最单纯的重复代码就是:“同一个类的两个函数含有相同是我表达式”。那么可以使用 提炼函数 提炼出重复的代码,然后调用它。
如果重复代码只是相似而不是完全相同,那么可以尝试用 移动语句 重组代码顺序,把相似的部分放在一起以便提炼。
如果重复的代码段位于同一个超类的不同子类中,可以使用 函数上移 来避免在两个子类之间互相调用。
- 过长函数
活得最长、最好的程序,其中的函数都比较短。因为函数越长,就越难理解。
小函数易于理解的关键还是在于良好的命名。
最终的效果是:应该更积极地分解函数,我们应当遵循这样一条原则:每当感觉需要以注释来说明点什么的时候,那么就应该把需要说明的东西写进一个独立函数中,并以其用途(而非实现手法)命名。 我们可以对一组甚至一行代码做这件事。哪怕替换后的函数调用动作比函数自身还长,只要函数名称能够解释其用途,我们就该毫不犹豫地那么做。
关键不在于函数的长度,而在于函数 ”做什么“ 和 ”如何做“ 之间的语义距离。
百分之九十九的场合里,要把函数变短,只需要使用 提炼函数 。 找到函数中适合集中在一起的部分,将它们提炼出来形成一个新函数。
如果函数内有大量的参数和临时变量,它们会对函数的提炼形成阻碍。如果尝试运用 提炼函数 最终就会把许多参数传递给被提炼出来的新函数,导致可读性几乎没有任何提升。此时可以经常运用 以查询取代临时变量 来消除这些临时变量。 引入参数对象 和 保持对象完整 则可以将过长的参数列表变得更简洁。
如果做完还是存在太多临时变量和参数,那就应该使出杀手锏-- 以命令取代函数。
如何确定该提炼那一段代码呢?一个很好的技巧就是:寻找注释。 它们通常能指出代码用途和实现手法之间的语义距离。如果代码前方有一行注释,就是在提醒你:可以将这段代码替换成一个函数,而且可以在注释的基础上给这个函数命名。 就算只有一行代码,如果它需要以注释来说明,那也值得将它提炼到独立函数中去。
条件表达式和循环常常也是提炼信号。可以使用 分解条件表达式 处理条件表达式。对于庞大的 Switch 语句,其中每个分支都应该通过 提炼函数 变成独立的函数调用,如果有多个 Switch 语句基于同一个条件进行分支选择,就应该使用 以多态取代条件表达式。
至于循环,你应该将循环和循环内的代码提炼到一个独立的函数中。 如果你发现提炼出来的循环很难命名,可能是因为其做了几件不同的事情。如果是这种情况,请勇敢地使用 拆分循环 将其拆分成各自独立的任务。
- 过长参数列表
如果可以向某个参数发起查询而获得另一个参数的值,那么就可以使用 以查询取代参数 去掉函数的某个参数。
如果发现可以从现有的数据结构中抽出很多数据项,就可以考虑使用 保持对象完整 手法,直接传入原来的数据结构。
如果有几项参数总是同时出现,可以用 引入参数对象 将其合并成一个对象。
如果某个参数被用作区分函数行为的标记(flag),可以使用 移除标记参数。
使用类可以有效的缩短参数列表。如果多个函数有同样的几个参数,引入一个类就尤为有意义。可以使用 函数组合成类,将这些共同的参数变成这个类的字段。
- 全局数据
全局数据仍然是最刺鼻的坏味道之一。全局数据的问题在于,从代码库的任何地方都可以修改它,而且没有任何机制可以探测出到底哪段代码做出了修改。
全局数据最显而易见的形式就是全局变量,但类变量和单例也有这样的问题。
首要的防御手段就是 封装变量 ,每当我们看到可能被各处的代码污染的数据,这总是我们应对的第一招。把全局数据用一个函数包装起来,至少就能看见修改它的地方,并开始控制它的访问。最好将这个函数(及其封装的数据)搬移到一个类或模块中,只允许模块内的代码使用它,从而尽量控制其作用域。
- 可变数据
对数据的修改经常导致出乎意料的结果和难以发现的 bug。
可以用 封装变量 来确保所以数据更新操作都通过很少几个函数来进行,使其更容易监控和演进。
如果一个变量在不同的时候被用于存储不同的东西,可以使用 拆分变量 将其拆分为各自不同用途的变量,从而避免危险的更新操作。
使用 移动语句和提炼函数 尽量把逻辑从处理更新操作的代码中搬移出来,将没有副作用的代码与执行数据更新操作的代码分开。
设计 API 时,可以使用 将查询函数和修改函数分离 确保调用者不会调用到有副作用的代码,除非他们真的需要更新数据。
尽早使用 移除设置函数 有时只是把设值函数的使用者找出看看,就能帮助我们发现缩小变量作用域的机会。
如果可变数据的值能在其他地方计算出来,这就是一个特别刺鼻的坏味道。消除这种坏味道的办法很简单,使用 以查询取代派生变量 即可。
如果变量作用域只有几行代码,即使其中的数据可变,也不是什么大问题;但随着变量作用域的扩展,风险也随之增大。可以用 函数组合成类 或者 函数组合成变换 来限制需要对变量进行修改的代码量。
如果一个变量在其内部结构中包含了数据,通常最好不要直接修改其中的数据,而是用 将引用对象改为值对象 令其直接替换整个数据结构。
- 发散式变化
在修改代码时,我们希望能够跳到系统的某一点,只在该处做修改。如果不能做到这一点,那么这个就是一个坏味道。
简单来讲就是,如果某个模块经常因为不同的原因在不同的方向上发生变化,发散式变化就出现了。(例如:在修改时得先做这个再做那个)
如果发生变化的两个方向自然的形成了先后的次序,就可以用 拆分阶段 将两者分开,两者之间通过一个清晰的数据结构进行沟通。
如果两个方向之间有更多的来回调用,就应该先创建适当的模块,然后用 搬移函数 将其分开。如果函数内部混合了两类处理逻辑,应该先用 提炼函数 将其分开,然后在做搬移。如果模块是以类的形式定义的,就可以使用 提炼类 来做拆分。
- 霰弹式修改
霰弹式修改类似于发散式变化,但又恰恰相反。如果每遇到某种变化,你都必须在许多不同的类内作出修改。这就是坏味道。
这种情况下,应该使用 搬移函数 和 搬移字段 把所有需要修改的代码放进同一个模块里。如果有很多函数都在操作相似的数据,可以使用 函数组合成类。如果有些函数的功能是转化或者充实数据结构,可以使用 函数组合成变换。如果一些函数的输出可以组合后提供给一段专门使用这些计算结果的逻辑,这种时候常常用得上 拆分阶段。
面对霰弹式修改,一个常用的策略就是使用与内联相关的重构 -- 如 内联函数 或 内联类 -- 把不该分散的逻辑拽回一处。完成内联之后你可能会闻到过长函数或者过大的类的味道,不过你总可以用于提炼相关的重构手法将其拆解成更合理的小块。
- 依恋情结
所谓模块化,就是力求将代码分出区域,最大化区域内部的交互、最小化跨区域的交互。但有时你会发现,一个函数跟另一个 模块中的函数或者数据交流格外频繁,远胜于在自己所在模块内部的交流,这就是依恋情结。
疗法显而易见:这个函数想跟这些数据待在一起,那就使用 搬移函数 把它移过去,有时候函数中只有一部分受这种依恋之苦,这时候应该使用 提炼函数 把这一部分提炼到独立的函数中,在使用 搬移函数 带它去它的梦想家园。
一个函数往往会用到几个模块的功能,那么应该把它放在哪里呢?原则上是:判断那个模块拥有的此函数使用的数据最多,然后就把这个函数和那些数据摆在一起。如果先 提炼函数 将这个函数分解为数个较小的函数并分别放置于不同地点,上述步骤也就比较容易完成。
- 数据泥团
数据项就像小孩子,喜欢成群结队的待在一起,你常常可以在很多地方看到相同的三四项数据:两个类中相同的字段,许多函数签名中相同的参数。这些总是绑在一起出现的数据真的应该拥有属于他们自己的对象。
首先请找出这些数据以字段形式出现的地方,运用 提炼类 将他们提炼到一个独立的对象中。然后将注意力转移到函数签名上,运用 引入参数对象 或 保持对象完整 为它们瘦身。这么做的直接好处就是可以将函数的参数列表缩短、简化函数的调用。
不必在意数据泥团只用上新对象的一部分字段,只有以新对象取代两个(或更多)字段,就值得这么做。
- 基本类型偏执
大多数编程环境都大量使用基本类型,即、整数、浮点数、字符串等。我们可以发现一个有趣的现象:很多程序员不愿意创建对自己的问题域有用的基本类型,如:钱、坐标、范围等。于是,就可以看到把钱当做普通数字来计算的情况、计算物理量时无视单位(如把英寸和毫米相加)的情况以及大量类似 if(a<upper&&a>lower)
这样的代码。
避免这样的情况,可以使用 对象取代基本类型 将原本单独存在的数据值替换为对象。如果想要替换的数据值是控制条件行为的类型码,则可以运用 以子类取代类型码 加上 以多态取代条件表达式 的组合将它换掉。
如果有一组数据总是同时出现的基本类型数据,这就是数据泥团的征兆,应该运用 提炼类 和 引入参数对象 来处理。
- 重复的 switch
重复的 switch: 在不同的地方反复使用同样的 switch 逻辑(可能是以 switch/case 语句的形式,也可能是以连续的 if/else 语句形式)。重复的 switch 的问题在于:每当你想增加一个选择分支时,必须找到所有的 switch ,并逐一更新。
避免这种坏味道的方法就是使用 多态取代条件表达式 方法。
- 循环语句
从最早的编程语言开始,循环就一直是程序设计的核心要素。但是目前循环有些过时了。函数如今作为一等公民已经得到广泛的支持,因此我们可以使用 以管道取代循环 来让这些循环退休。我们发现,管道操作(如:filter 和 map)可以帮助我们更快地看清被处理的元素一级处理他们的动作。
- 冗赘的元素
程序元素(如类和函数)能给代码添加结构,从而支持变化、促进复用或者哪怕只是提供更好的名字,但有时真的不需要这层额外的结构。可能有这样一个函数,他的名字就跟实现代码看起来一模一样;也可能有这样一个类,根本就是一个简单的函数,类似这样都是坏味道。
避免只需要使用 内联函数 或是 内联类 。如果这个类处于一个继承体系中,可以使用 折叠继承体系。
- 夸夸其谈通用性
当有人说”我想总有一天需要做这个事“,并因而企图以各种各样的钩子和特殊情况来处理一些非必要的事情,这个就是一个坏味道。这么做的结果往往造成系统更难理解和维护。如果所有的装置都会被用上,那么值得这么做;如果用不到,就不值得。
如果抽象的类其实没有什么太大的作用,请运用 折叠继承体系 。不必要的委托可运用 内联函数 和 内联类 除掉。如果函数的某些参数未被使用上,可以用 改变函数声明 去掉这些参数。如果有并非真正需要、只是为不知远在何处的将来而塞进去的参数,也应该用 改变函数声明 去掉。
如果函数或类的唯一用户是测试用例,这就飘出了坏味道 ”夸夸其谈通用性“。如果发现这样的函数或类,可以先删除测试用例,然后使用 移除死代码。
- 临时字段
有时你会看到这样的类:其内部某个字段仅为某种特定的情况而设。这样的代码让人不易理解,因为你通常认为对象在所有时候都需要它的所有字段。在字段未被使用的情况下猜测当初设置它的目的,会让你发疯。
请使用 提炼类 给这个可怜的孤儿创造一个家,然后用 搬移函数 把所有和这些字段相关的代码都放进这个新家。也许你还可以使用 引入特例 在 ”变量不合法“ 的情况下创建一个替代对象,从而避免写出条件代码。
- 过长的消息链
如果看到这样的代码 obj.obj.obj.obj.obj.name
,类似这种代码就是消息链。在实际代码中你可能看到的是一长串的取值函数或者一长串的临时变量。采取这种方式,意味着客户端的代码将于查找过程中的导航结构紧密耦合。一旦对象间的关系发生了任何变化,客户端就不得不做出相应的修改。
这个时候应该使用 隐藏委托关系 ,你可以在消息链的不同位置采用这种重构手法。理论上,你可以重构消息链上的所有对象,但这么就会把所有中间对象都变成”中间人“。通常更好的选择是:先观察消息链最终得到的对象是用来干什么的,看看是否可以 提炼函数 把这个函数推入消息链。如果还有许多客户端代码需要访问链上的其他对象,同样可以添加一个函数来完成此事。
- 中间人
对象的基本特征之一就是封装--对外部世界隐藏其内部细节。封装往往伴随着委托。
但是人们可能过度运用委托。你也许会看到某个类的接口有一半的函数都委托给其他类,这样就是过度运用。 这时应该使用 移除中间人 ,直接和真正负责的对象打交道。如果这样 ”不干实事“ 的函数只有少数几个,可以运用 内联函数把它们放进调用端。
- 内幕交易
软件开发者喜欢在模块之间建起高墙,极其反感在模块之间大量交换数据,因为这会增加模块之间的耦合。在实际情况中,一定的数据交换不可以避免,但我们必须尽量减少这种情况,并把这种交换放到明面上来。
如果两个模块总是相互调用函数,就应该用 搬移函数 和 搬移字段 减少他们之间的交流。如果两个模块有共同的兴趣,可以尝试再新建一个模块,把这些公用的数据放在一个管理良好的地方:或者用 隐藏委托关系 把另一个模块变成两者的中介。
继承常会造成密谋,因为子类对超类的了解总是超过后者的主观愿望。如果你绝的该让这个孩子独立生活了,请运用以 委托取代子类 或 以委托取代超类 让他离开继承体系。
- 过大的类
如果想利用单个类做太多的事情,其内部往往就会出现太多的字段。一旦如此,重复代码也就接踵而至。
可以使用 提炼类 将几个变量一起提炼到新类中。提炼时应该选择类内彼此相关的变量,将它们放在一起。
通常类内的数个变量有着相同的前缀或者后缀,这就意味着有机会把它们提炼到某个组件中,如果这个组件适合做一个子类,你就会发现 提炼超类 或者 以子类取代类型码 会比较简单。
观察一个大类的使用者,经常可以找到如何拆分类的线索。看看使用者是否只用到了这个类所有的功能的一个子集,每个这样的子集都可以拆分成一个类。一旦有机会就试用 提炼类 、提炼超类 和 以子类取代类型码 将其拆分出来。
- 异曲同工的类
使用类的好处之一就是可以替换:今天用这个类,未来可以使用另一个类。但只有当两个类的接口一致时,才能做这种替换。可以用 改变函数声明 将函数签名变得一致。但这往往不够,请反复使用 搬移函数 将某些行为移入超类中,直到两者的协议一致为止。如果有重复的代码,可以使用 提炼超类 补偿一下。
- 纯数据类
所谓纯数据类就是:拥有一些字段,已经用于访问(读写)这些字段的函数,除此之外一无长物。这些类早期可能用于 public 字段,你应该在别人注意到他们之前,立刻运用 封装记录 将它们封装起来。对于那些不该被其他类修改的字段,请运用 移除设置函数。
找到这些取值、设置函数被其他类调用的地点。尝试以 搬迁函数 把那些调用行为搬移到纯数据类中。如果无法搬迁整个函数,就运用 提炼函数 产生一个可被搬移的函数。
纯数据类常常意味着行为被放在了错误的地方。也就是说,只要把处理数据的行为从客户端搬移到纯数据类中,就可以使情况大为好转。
但是也存在例外情况,就是纯数据类记录对象被用作函数调用的返回结果,就比如使用 使用拆分阶段 之后得到的中转数据结构就是这种情况。这种结果数据对象有一个关键的特征:他是不可修改的。不可修改的字段无需封装,使用者可以直接通过字段取得数据,无需通过取值函数。
- 被拒绝的遗赠
子类应该继承超类的函数和数据。但是如果它们不想或者不需要继承,怎么办?
按照传统的说法,这就意味着继承体系设计错误。你需要为这个子类新建一个兄弟类,在运用 函数下移 和 字段下移 把所有用不到的函数下推给那个兄弟类。这样,超类就只支持所有子类共享的东西了。你常常会听到这样的建议:所有超类都应该是抽象的。
如果子类复用了超类的行为(实现),却又不愿意支持超类的接口,”被拒绝的遗赠“ 的坏味道就很强烈。拒绝继承超类的实现,这一点没事;但如果拒绝支持超类的接口,这就很难以接收。既然不愿意支持超类的接口,就不要虚情假意的糊弄继承体系,应该运用 委托取代子类 或者 以委托取代超类 彻底的划清界限。
- 注释
如果你需要注释来解释一块代码做了什么,试试 提炼函数;如果函数已经提炼出来,但还是需要注释解释其行为,试试用 改变函数声明 为他改名;如果你需要注释说明某些系统的需求规格,试试 引入断言。
构筑测试体系
重构手法
在重构名录开始,首先介绍一组作者认为最有用的重构手法。
作者最常用的重构手法是 提炼函数 将代码提炼到函数中,或者使用 提炼变量 来提炼变量。也经常使用这两个手法的反向重构 -- 内联函数 和 内联变量。
提炼的关键就在于命名,随着理解的加深,需要经常改名, 改变函数声明 可以用于修改函数的名字,也可以用于添加和删减参数。变量也可以用于修改函数的名字,也可以用于添加或删减参数。变量可以用 变量改名 来改名,不过需要先做 封装变量 。再给函数的形式参数改名时,不妨先用 引入参数对象 把经常一起出没的参数组合成一个对象。
形成函数并给函数命名,这是低层级重构的精髓。有了函数之后,就需要把他们组合成更高层次的模块。可以使用 函数组合成类 ,把函数和他们操作的数据一起组合成类。另一条路径是用 函数组合成变换 将函数组合成变换式,这对于处理只读数据尤为便利。在往前一步,常常可以使用 拆分阶段 将这些模块组成界限分明的处理阶段。
提炼函数
反向重构:内联函数
Bad Code:
function printOwing(invoice) {
printBanner();
let outstanding = calculateOutstanding();
// print details
console.log(`name: ${invoice.customer}`);
console.log(`amount: ${outstanding}`);
}
Good Code:
function printOwing(invoice) {
printBanner();
let outstanding = calculateOutstanding();
printDetails(outstanding);
function printDetails(outstanding) {
console.log(`name: ${invoice.customer}`);
console.log(`amount: ${outstanding}`);
}
}
- 动机
提炼函数是最长使用的重构手法之一。
对于 “何时应该把代码放进独立的函数” 这个问题,有很多的观点。有的观点是从代码的长度考虑的,认为一个函数应该能在一屏中显示。有的观点从复用的角度考虑,认为只要被用过不止一次的代码,就应该单独放进一个函数;只用过一次的代码则应该保持内联的状态。
但作者认为的最合理的观点是: “将意图与实现分开”:如果你需要花时间浏览一段代码才能弄清楚它到底在干什么,那么就应该将其提炼到一个函数中,并根据它所做的事情为其命名。
一旦接收了这个原则,我就逐渐养成一个习惯:写非常小的函数-- 通常只有几行的长度。在作者看来,一个函数一旦超过了 6 行,就开始散发臭味。
有些人担心短函数会造成大量函数调用,因而影响性能。在如今 “由于函数调用影响性能” 的情况已经很罕见了。短函数常常能让编译器的优化功能运转良好,因为短函数可以更容易地被缓存。所以应该始终遵循性能优化的一般指导方针,不用过早担心性能问题。
-
做法
- 创造一个新函数,根据这个函数的意图来对他命名(以它“做什么”来命名,而不是以它“怎样做”命名)。
如果想提炼的代码非常简单,例如只是一个函数调用,只要新函数的名称能够以更好的方式昭示代码的意图,那就提炼他;但如果想不出一个更有意义的名称,这就是一个信号,可能不应该提炼这段代码。不过,不一定非得马上想出最好的名称,有时在提炼的过程中好的名称才会出现。
如果编程语言支持嵌套函数,就把新函数嵌套在源函数中,这能减少后面需要处理的超出作用域的变量个数。可以稍后再使用 搬移函数 把它从源函数中搬移出去。
- 将待提炼的代码从源函数复制到新建的目标函数中。
- 仔细检查提炼出来的代码,看看其中是否引用了作用域限于源函数、在提炼出的新函数中访问不到的变量。若是,以参数的形式将它们传递给新函数。
如果提炼出的新函数在源函数内部,就不存在变量作用域的问题了。
这些“作用域限于源函数”的变量通常是局部变量或者是源函数的参数。最通用的做法是将它们作为参数传递给新函数。只要没在提炼部分对这些变量赋值,处理起来就没有什么难度。
如果某个变量在提炼部分之外声明但只在提炼部分被使用,就把变量声明也搬移到提炼部分代码中。
如果变量按值传递给提炼部分,又在提炼部分被赋值,就必须多交小心。如果只有一个这样的变量,就要尝试将提炼的新函数变成一个查询,用其返回值给改变量赋值。
但有时在提炼部分被赋值的局部变量太多,这是最好是先放弃提炼。这种情况下,就要考虑别的重构手法,例如 拆分变量 或者 以查询取代临时变量 ,来简化变量的使用情况,然后在考虑提炼函数。
- 在源函数中,将被提炼的代码段替换为对目标函数的调用。
内联函数
反向重构:提炼函数
Bad Code:
function getRating(driver) {
return moreThanFiveLateDeliveries(driver) ? 2 : 1;
}
function moreThanFiveLateDeliveries(driver) {
return driver.numberOfLateDeliveries > 5;
}
Good Code:
function getRating(driver) {
return driver.numberOfLateDeliveries > 5 ? 2 : 1;
}
- 动机
本书经常以简短的函数表现动作意图,这样会使代码更清晰易读。但是有时候你会遇到某些函数,其内部代码和函数名同样清晰易读。也可能你重构了该函数的内部实现,使其内容和其名称变得同样清晰。若果真如此,你就应该去掉这个函数,直接使用其中的代码。
另一种需要使用内联函数的情况是:有一群组织不甚合理的函数。可以将他们都内联到一个大型函数中,再以合适的方式重新提炼出小函数。
如果代码中有太多的间接层,使得系统中的所有函数都似乎只是对另一个函数的简单委托,这样的情况也需要内联函数。通过内联手法可以去除无用的间接层。
-
做法
- 检查函数,确定他不具有多态性
如果该函数属于一个类,并且有子类继承了这个函数,那么就无法内联。
- 找出这个函数的所有调用点
- 将这个函数的所有调用点都替换成函数本体
- 每次替换完成之后,需要测试
不必一次完成整个内联操作。如果某些调用点比较难以内联,可以等到时机成熟后再来处理。
对于递归调用、多返回点、内联至另一个对象中而该对象并无访问函数的复杂情况,就不能使用内联函数作为重构的手法了。
提炼变量
反向重构:内联变量
Bad Code:
return (
order.quantity * order.itemPrice -
Math.max(0, order.quantity - 500) * order.itemPrice * 0.05 +
Math.min(order.quantity * order.itemPrice * 0.1, 100)
);
Good Code:
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;
- 动机
表达式有可能非常复杂而难以阅读。这种情况下,局部变量可以帮助我们将表达式分解为比较容易管理的形式。在面对一块复杂逻辑时,局部变量使我们能给其中的一部分命名,这样就可以更加轻松理解这部分逻辑干了什么。
如果使用提炼变量,就要给代码中的一个表达式命名。一但决定要这么做,就得考虑这个名字所处的上下文。如果这个名字只在当前的函数中有意义,那么提取变量就是一个不错的选择;
如果这个变量名在更宽的上下文中也有意义,就要考虑将其暴露出来,通常以函数的形式。
“将新的名字暴露得更宽” 的坏处则是需要额外的工作量。如果工作量很大,就得先暂时放下,稍后可以使用 已查询取代临时变量 来处理。有一个很好的例子: 如果处理的这段代码属于一个类,对这个新的变量使用 提炼函数 会很容易。
-
做法
- 确认要提炼的表达式没有副作用
- 声明一个不可修改的变量,把你想要提炼的表达式复制一份,以该表达式的结果值给这个变量赋值
- 用这个新变量取代表达式
内联变量
反向重构:提炼变量
Bad Code:
let basePrice = anOrder.basePrice;
return basePrice > 1000;
Good Code:
return anOrder.basePrice > 1000;
- 动机
在一个函数内部,变量能给表达式提供有意义的名字,因此通常变量是好东西。但有时,这个名字并不比表达式本身更具有表现力。还有些时候,变量可能会妨碍重构附近的代码,若果真如此,就应该通过内联的手法消除变量。
-
做法
- 检查确认变量赋值语句的右侧表达式没有副作用
- 如果变量没有被声明为不可修改,先将其变为不可修改。
这是为了确保改变了只被赋值一次。
- 找到第一处使用该变量的地方,将其替换为直接使用赋值语句的右侧表达式。
- 重复前两步,逐一替换其他使用改该变量的地方。
- 删除改变量的声明点和赋值语句
改变函数声明
Bad Code:
function circum(radius) {}
Good Code:
function circumference(radius) {}
- 动机
函数是我们将程序拆分成小块的主要方式。函数声明则展现了如何将这些小块组合在一起工作。
对于函数而言,最重要的元素就是函数的名字。一个好名字能让我们一眼就看出函数的用途,而不必查看其实现代码。
如果看到一个函数的名字不对,一旦发现更好的名字,就得尽快给函数改名。
(有一个改进函数名字的好办法:先写一句注释描述这个函数的用途,再把这句注释变成函数的名字)
对于函数的参数,道理也是一样的。函数的参数列表阐述了函数如何与外部世界共处。函数的参数设置了一个上下文,只有在这个上下文中,我们才能使用这个函数。
修改参数列表不仅能增加函数的应用范围,还能改变链接一个模块所需的条件,从而去除不必要的耦合。
改变函数声明 这个重构手法,包括了给函数改名和修改参数列表。
- 做法
在重构时查看变更范围,自问如果可以一步到位地修改函数声明以及其调用者,就可以采用简单的做法。
如果函数被很多地方调用,或者修改不容易,或者要修改的是一个多态函数,或者对函数声明的修改比较复杂,就要考虑使用渐进式的修改。
简单的做法:
- 如果想要移除一个参数,就要先确定该函数中是否使用了这个参数。
- 修改函数声明,使其成为你期望的状态。
- 找出所有使用旧函数声明的地方,将他们改为使用新函数的调用。
最好能把大的修改拆成小的步骤,所以如果你即想修改函数名,又想添加参数,最好分为两步来做。
渐进式做法:
- 如果有必要的话,先对函数体内部加以重构,使后面的提炼步骤易于开展。
- 使用 提炼函数 将函数提炼成一个新函数。
如果打算沿用旧函数的名字,可以先给函数起一个易于搜索的临时名字。
- 如果提炼出的函数需要新增参数,用前面的简单做法添加即可
- 对旧函数使用 内联函数。
- 如果新函数使用了临时的名字,再次使用 改变函数声明 将其改回原来的名字。
如果要重构的函数属于一个具有多态性的类,那么对于该函数的每个实现版本,都需要通过 "提炼一个新函数" 的方式添加一层间接,并把旧函数的调用转发给新函数。如果该函数的多态性是在一个类继承体系中体现,那么只需要在超类上转发即可。如果各个实现类之间并没有一个共同的超类,那么就需要在每个实现类上做转发。
如果要重构一个已对外发布的 API ,在提炼出新函数之后,就可以暂停重构,将原来的函数声明为 “不推荐使用” 然后给客户端一点时间转为使用新函数,等有信心所有客户端都已经从旧函数迁移到新函数,在移除旧函数的声明。
封装变量
Bad Code:
let defaultOwner = {
firstName: "Martin",
lastName: "Fowler",
};
Good Code:
let defaultOwner = {
firstName: "Martin",
lastName: "Fowler",
};
export function defaultOwner() {
return defaultOwner;
}
export function setDefaultOwner(arg) {
defaultOwner = arg;
}
- 动机
如果想要搬移一处被广泛使用的数据,最好的办法往往是先以函数形式封装所有对象数据的访问。这样,就能把 “重新组织数据” 的困难任务转化为 “重新组织函数” 这个相对简单的任务。
封装数据的价值还不止于此。封装能提供一个清晰的观测点,可以由此监控数据的变化和使用情况;还可以轻松地添加数据被修改时的验证或后续逻辑。对于所有可变的数据,只要它的作用域超出单个函数,我就会将其封装起来,只允许通过函数访问。数据作用域越大,封装就越重要。
面向对象方法如此强调对象的数据应该保持私有,背后也是同样的原理。每当看见一个公开的字段时,就需要考虑使用封装变量来缩小其可见范围。
封装数据很重要,不可变的数据更重要,如果数据不能修改,就根本需要数据更新前的验证或者其他逻辑钩子。可以放心地复制数据,而不用搬移原来的数据。
-
做法
- 创建封装函数,在其中访问和更新变量值。
- 执行静态检查。
- 逐一修改使用该变量的代码,将其改为调用合适的封装函数。每次替换之后,执行测试。
- 限制变量的可见性
有时没办法阻止直接访问变量,可以试试将变量改名,在执行测试,找出仍在直接使用该变量的代码。
- 如果变量的值是一个记录,考虑使用封装记录。
变量改名
Bad Code:
let a = height * width;
Good Code:
let area = height * width;
- 动机
好的命名是整洁编程的核心。好的变量名字可以很好地解释一段程序在干什么。
-
机制
- 如果变量被广泛使用,可以考虑使用 封装变量 将其封装起来
- 找出所以使用该变量的代码,逐一修改
如果在另一个代码库中使用了该变量,这就是一个 “已发布变量”, 此时不能进行这个重构。
如果变量值从不修改,可以将其复制到一个新名字之下,然后逐一修改使用代码。
引入参数对象
Bad Code:
function amountInvoiced(startDate, endDate) {}
function amountReceived(startDate, endDate) {}
function amountOverdue(startDate, endDate) {}
Good Code:
function amountInvoiced(aDateRange) {}
function amountReceived(aDateRange) {}
function amountOverdue(aDateRange) {}
- 动机
如果一组数据总是结伴同行,出没于一个有一个函数,这样的一组数据就是数据泥团,这个时候就要将其改为参数对象。
将数据组织成结构是一件有价值的事,因为这让数据项之间的关系变得明晰。使用新的数据结构,函数的参数列表也能缩短。并且经过重构之后,所有使用该数据结构的函数都会通过样的名字来访问其中的元素,从而提升代码的一致性。
引入参数对象的真正意义在于,它会催生代码中更深层次的改变。一旦识别出新的数据结构,就可以重组程序的行为来使用这些结构。
-
做法
- 如果暂时还没有一个合适的数据结构,就创建一个。
我倾向于使用类,因为稍后把行为放进来会比较容易。通常会尽量确保这些新建的数据结构是值对象。
- 使用 改变函数声明 给原来的函数新增一个参数,类型是新建的数据结构。
- 调整所有调用者,传入新数据结构的适当实例,每修改一处,执行测试。
- 用新数据结构中的每项元素,逐一取代参数列表中与之对应的参数项,然后删除原来的参数。
函数组合成类
Bad Code:
function base(aReading) {}
function taxableCharge(aReading) {}
function calculateBaseCharge(aReading) {}
Good Code:
class Reading {
base() {}
taxableCharge() {}
calculateBaseCharge() {}
}
- 动机
类,在大多数现代编程语言中都是基本的构造。他们把数据与函数捆绑到同一个环境中,将一部分数据与函数暴露给其他程序元素以便协作。它们是面向对象语言的首要构造,在其他程序设计方法中也同样有用。
如果发现一组函数形影不离地操作同一块数据(通常是将这块数据作为参数传递给函数),这个时候就应该组建一个类了。类能明确地给这些函数提供一个共用的环境,在对象内部调用这些函数可以少传很多参数,从而简化函数调用,并且这样一个对象也可以更方便地传递给系统的其他部分。
将函数组织到一起的另一种方式就是 函数组合成变换 。具体使用那个重构手法,要看程序整体的上下文。使用类有一大好处:客户端可以修改对象的核心数据,通过计算得出的派生数据则会自动与核心数据保持一致。
-
做法
- 运用 封装记录 对多个函数共用的数据记录加以封装。
如果多个函数共用的数据还未组织成记录结构,则先运用 引入参数对象 将其组织成记录
- 对于使用该记录结构的每个函数,运用 搬移函数 将其移入新类。
如果函数调用时传入的参数已经是新类的成员,则从参数列表中去除之。
- 用以处理该数据记录的逻辑可以用 提炼函数 提炼出来,并移入新类。
函数组合成变换
Bad Code:
function base(aReading) {}
function taxableCharge(aReading) {}
Good Code:
function enrichReading(argReading) {
const aReading = _.cloneDeep(argReading);
aReading.baseCharge = base(aReading);
argReading.taxableCharge = taxableCharge(aReading);
return aReading;
}
- 动机
在软件中,经常需要把数据 “喂” 给一个程序,让它再计算出各种派生信息。这些派生数值可能会在几个不同的地方用到,因此这些计算逻辑也常会用到派生数据的地方重复。这时就很有必要将这些逻辑进行整合,避免重复。
一个方式是采用数据变换函数:这种函数接受源数据作为输入,计算出所有的派生数据,将派生数据以字段形式填入输出数据。有了变换函数,我就始终只需要到变换函数中去检查计算派生数据的逻辑。
函数组合成变换的替代方案是 函数组合成类 ,后者的做法是先用源数据创建一个类,再把相关的计算逻辑搬移到类中。这两种重构手法都很有用。两者一个重要的区别就是:如果代码中会对源数据做更新,那么使用类要好得多,如果使用变换,派生数据就会被存储在新生成的记录中,一旦源数据被修改,就会造成数据的不一致。
使用函数组合起来的原因之一,是为了避免计算派生数据的逻辑到处重复。从道理上来说,只用 提炼函数 也能避免重复,但孤立存在的函数常常很难找到,只有把函数和他们操作的数据放在一起,用起来才方便。引入变换(或者类)都是为了让相关的逻辑找起来方便。
-
做法
- 创建一个变换函数。输入参数是需要变换的记录,并直接返回改记录的值。
这一步通常需要对输入的记录做深复制,此时应该写个测试,确保变换不会修改原来的记录。
- 挑选一块逻辑,将其主体移入变换函数中,把结果作为字段添加到输出记录中,修改客户端代码,令其使用这个新字段。
如果计算逻辑比较复杂,先用 提炼函数 提炼之。
- 针对其他相关的计算逻辑,重复上述步骤。
拆分阶段
Bad Code:
const orderData = orderString.split(/\s+/);
const productPrice = priceList[orderData[0].split("-")[0]];
const orderPrice = parseInt(orderData[1]) * productPrice;
Good Code:
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];
}
- 动机
每当出现一段代码在同事处理两间不同的事,就需要把它们拆分成各自独立的模块,因为这样到了需要修改的时候,就可以单独处理每个主题,而不必同时在脑子里考虑两个不同的主题。
最简洁的拆分方法之一,就是把一大段行为分成顺序执行的两个阶段。
即便只有不大的一块代码,只要我发现了有益的将其拆分成多个阶段的机会,同样可以运用拆分阶段重构手法。如果一块代码中出现了上下几段,各自使用不同的一组数据和函数,这就是最明显的线索。将这些代码片段拆分成各自独立的模块,能更明确地标示出他们之间的差异。
-
做法
- 将第二阶段的代码提炼成独立的函数
- 引入一个中转数据结构,将其作为参数添加到提炼出的新函数的参数列表中。
- 逐一检查提炼出的 “第二阶段函数” 的每个参数。如果某个参数被第一阶段用到,就将其移入中转数据结构。每次搬移之后都要执行测试。
有时第二阶段根部不应该使用某个参数。果真如此,就把使用该参数得到的结果全都提炼成中转数据结构的字段,然后用 搬移语句到调用者 把使用该参数的代码搬移到 “第二阶段函数” 之外。
- 对第一阶段的代码运用 提炼函数 ,让提炼出的函数返回中转数据结构。
也可以把第一阶段提炼成一个变换对象。
封装
数据结构无疑是最常见的一种秘密,我们可以使用 封装记录 或 封装集合 手法来隐藏他们的细节。即便是基本类型的数据,也能通过 以对象取代基本类型 进行封装,这样做后续所带来的巨大收益通常令人惊喜。
另一项经常在重构时挡道的是临时变量,我需要确保他们的计算次序正确,还得保证其他需要他们的地方能获得其值。这里 以查询取代临时变量 手法可以帮上大忙,特别是在分解一个过长的函数时。
封装记录
Bad Code:
const organization = {
name: "Acme Gooseberries",
country: "GB",
};
Good Code:
class Organization {
constructor(data) {
this._name = data.name;
this._country = data.country;
}
get name() {
return this._name;
}
set name(value) {
this._name = value;
}
get country() {
return this._country;
}
set country(value) {
this._country = value;
}
}
- 动机
P 162
-
做法
- 对持有记录的变量使用 封装变量 ,将其封装到一个函数中。
记得为这个函数取一个容易搜索的名字
- 创建一个类,将记录包装起来,并将记录变量的值替换成该类的一个实例。然后在类上定义一个访问函数,用于返回原始的记录。修改封装变量的函数,令其使用这个访问函数。
- 新建一个函数,让他返回该类的对象,而非那条原始的记录。
- 对于该记录的每处使用点,将原先返回记录的函数调用替换为那个返回实例对象的函数调用。使用对象上的访问函数获取数据的字段,如果该字段的访问函数还不存在,那就创建一个。每次更改之后运行测试。
如果该记录比较复杂,例如是个嵌套解构,那么先重点关注客户端对数据的更新操作,对于读取操作可以考虑返回一个数据副本或只读的数据代理。
- 移除类对原始记录的访问函数,那个容易搜索的返回原始数据的函数也要一并删除。
- 如果记录中的字段本身也是复杂结构,考虑对其再次应用 封装记录 或 封装集合 手法。
封装集合
Bad Code:
class Person {
get courses() {
return this._courses;
}
set courses(aList) {
this._courses = aList;
}
}
Good Code:
class Person {
get courses() {
return this._courses.slice();
}
addCourses(aCourses) {}
removeCourses(aCourses) {}
}
- 动机
我喜欢封装程序中的所有可变数据。这使我很容易看清楚数据被修改的地点和修改方式,这样当我需要更改数据结构时就非常方便。我们通常鼓励封装--使用面向对象技术的开发者对封装尤为重视--但封装集合时人们常常犯一个错误:只对集合变量的访问进行了封装,但依然让取值函数返回集合本身。这使得集合的成员变量可以被直接修改,而封装他的类则全然不知,无法介入。
为避免此种情况,我会在类上提供一些修改集合的方法--通常是 “添加” 和 “移除”方法。 更好地做法是,不要让集合的取值函数返回原始集合,这就避免了客户端的意外修改。
一种避免直接修改集合的方法是,永远不直接返回集合的值。
还有一种方法是,以某种形式限制集合的访问权,只允许对集合进行读操作。
使用数据代理和数据复制的另一个区别是,对源数据的修改会反应到代理上,但不会反应到副本上。
-
做法
- 如果集合的引用尚未被封装起来,先用 封装变量 封装它。
- 在类上添加用于 “添加集合元素” 和 “移除集合元素” 的函数
如果存在对该集合的设置函数,尽可能先用 移除设置函数 移除它。如果不能移除该设置函数,至少让他返回集合的一份副本。
- 查找集合的引用点。如果有调用者直接修改集合,令该处调用使用新的添加、移除元素的函数。每次修改后执行测试。
- 修改集合的取值函数,使其返回一份只读的数据,可以使用只读代理或数据副本。
以对象取代基本类型
Bad Code:
orders.filter((o) => "high" === o.priority || "rush" === o.priority);
Good Code:
orders.filter((o) => o.priority.higherThan(new Priority("normal")));
- 动机
一旦发现对某个数据的操作不仅仅局限于打印时,就要为他创建一个新类。一开始这个类也许只是简单包装一下简单类型的数据,不过只要类有了,日后添加的业务逻辑就有地方可去了。
开发初期,你往往决定以简单的数据项表示简单的情况,比如使用数字或字符串等,但随着开发的进行,你可能会发现,这些简单数据项不在那么简单。比如要加 “格式化的概念”等等。这个时候就要使用 以对象取代基本类型 了。
-
做法
- 如果变量尚未被封装起来,先使用 封装变量 封装它。
- 微折购数据值创建一个简单的类。类的构造函数应该保存这个数据值,并未它提供一个取值的函数。
- 执行静态坚持
- 修改第一步得到的设值函数,令其创建一个新类的对象并将其存入字段,如果有必要的话,同时修改字段的类型声明。
- 修改取值函数,令其调用新类的取值函数,并返回结果。
- 考虑对第一步得到的访问函数使用 函数改名 以便更好反映其用途。
- 考虑应用 将引用对象改为值对象 或 将值对象改为引用对象,明确指出新对象的角色是值对象还是引用对象。
以查询取代临时变量
Bad Code:
const basePrice = this._quantity * this._itemPrice;
if (basePrice > 1000) {
return basePrice * 0.95;
} else {
return basePrice * 0.98;
}
Good Code:
get basePrice(){
return this._quantity * this._itemPrice;
}
...
if (this.basePrice > 1000) {
return this.basePrice * 0.95;
} else {
return this.basePrice * 0.98;
}
- 动机
临时变量的一个作用是保存某段代码的返回值,以便在函数后面部分使用它,临时变量允许我引用之前的值,既能解释他的含义,还能避免对代码进行重复计算。但尽管使用变量很方便,很多时候还是值得更进一步,将他们抽取成函数。
如果我正在分解一个冗长的函数,那么将变量抽取到函数里能使函数的分解过程更简单,因为我就不在需要将变量作为参数传递给提炼出来的小函数了。将变量的计算逻辑放到函数中,也有助于在提炼得到的函数与原函数之间设立清晰的边界,这能帮助我们发现并避免难缠的依赖以副作用。
改用函数还让我避免了在多个函数中重复编写计算逻辑。每当我在不同的地方看见同一段变量的计算逻辑,我就会把它们放到同一个函数中。
这项重构手法子类中施展效果最好,因为类为代提炼函数提供了一个共同的上下文。如果不是在类中,我们很有可能会在顶层函数中拥有过多参数,这将冲淡提炼函数所能带来的诸多好处。使用嵌套的小的函数可以避免这个问题,但有限制了我在相关函数 jian 分享逻辑的能力。
已查询取代临时变量 手法只适用于处理某些类型的临时变量:那些只被计算过一次且之后不再被修改的变量。最简单的情况是,这个临时变量只被赋值一次,但在更复杂的代码片段里,变量也可能被多次赋值--此时应该将这些计算代码一并提炼到查询函数中。对于那些做快照用途的临时变量(从变量名往往可见端倪,例如 oldAddress 这样的名字),就不能使用本手法。
-
做法
- 检查变量在使用 💰 是否已经完全计算完毕,检查计算它的那段代码是否每次都能得到一样的值。
- 如果变量目前不是只读的,但是可以改造成只读变量,那就先改造它。
- 将为变量赋值的代码段提炼成函数
如果变量和函数不能使用相同的名字,那么先为函数取个临时的名字。确保代提炼函数没有副作用,如有,先应用 将查询函数和修改函数分离 手法隔离副作用。
- 应用 内联变量 手法移除临时变量
提炼类
Bad Code:
class Person {
get officeAreaCode() {
return this._officeAreaCode;
}
get officeNumber() {
return this._officeNumber;
}
}
Good Code:
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;
}
}
- 动机
你也许听到过类似这样的建议:一个类应该是一个清晰地抽象,只处理一些明确的责任。但是在实际工作中,类会不断成长扩展。于是随着责任的不断增加,这个类会变得过分复杂。很快你的类就会变成一团乱麻。
设想你有一个维护大量函数和数据的类。这样的类往往因为太大而不易理解。此时就需要考虑哪些部分可以分离出去,并将他们分离到一个独立的类中。如果某些数据和某些函数总是一起出现,某些数据经常同时变化甚至彼此相依,这就表示你应该将它们分离出去。
另一个往往在开发后期出现的信号是类的子类化方式。如果你发现子类化只影响类的部分特性,或如果你发现某些特性需要以一种方式来子类化,某些特性则需要以另一种方式子类化,这就意味着你需要分解原来的类。
-
做法
- 决定如何分解类所负的责任。
- 创建一个新的类,用于表现从旧类中分离出来的责任。如果旧类剩下的责任与旧类的名称不符,为旧类改名。
- 构造旧类时创建一个新类的实例,建立 ”从旧类访问新类“ 的链接关系。
- 对于你想搬移的每一个字段,运用 搬移字段搬移 之。每次更改后运行测试。
- 使用 搬移函数 将必要函数搬移到新类。先搬移较低层函数(也就是”被其他函数调用“ 多于 ”调用其他函数“ 者)。
- 检查两个类的接口,去掉不再需要的函数,必要时为函数重新取一个合适新环境的名字。
- 决定是否公开新的类。如果确实需要,考虑对新类应用将 引用对象改为值对象 使其成为一个值对象。
内联类
Bad Code:
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;
}
}
Good Code:
class Person {
get officeAreaCode() {
return this._officeAreaCode;
}
get officeNumber() {
return this._officeNumber;
}
}
- 动机
内联类 和 提炼类 正好相反。如果一个类不再承担足够责任,不再有单独存在的理由(这通常是因为此前的重构动作移走了这个类的责任),我就会挑选这一 ”萎缩类“ 的最频繁用户(也是一个类),以本手法将其塞进另一个类中。
应用这个手法的另一个场景是,我手头有两个类,想重新安排它们肩负的职责,并让它们产生关联。这时我发现先用本手法将它们内联成一个类再用提炼类去分离其职责会更加简单。这时重新组织代码时常用的做法:有时把相关元素一口气搬移到位更简单,但有时先用内联手法合并各自上下文,在使用提炼手法再次分离他们会更合适。
-
做法
- 对于待内联类(源类)中的所有 public 函数,在目标类上创建一个对于的函数,新创建的所有函数应该直接委托至源类。
- 修改源类 public 方法的所有引用点,令他们调用目标类对应的委托方法。
- 将源类中的函数与数据全部搬移到目标类,每次修改之后进行测试。直到源类变成空壳为止。
- 删除源类。
隐藏委托关系
Bad Code:
const manager = aPerson.department.manager;
Good Code:
const manager = aPerson.manager;
class Person {
get manager() {
return this.department.manager;
}
}
- 动机
一个好的模块化设计,”封装“ 即使不是最关键特征,也是最关键特征之一。 ”封装“ 意味着每个模块都应该尽可能少了解系统的其他部分。如此依赖,一旦发生变化,需要了解这一变化的模块就会比较少--这会使变化比较容易进行。
-
做法
- 对于每个委托关系中的函数,在服务对象端建立一个简单的委托函数。
- 调整客户端,令他只调用服务对象提供的函数,每次调整后运行测试。
- 如果将来不再有任何客户端需要取 Delegate (受拖类),便可移除服务对象中的相关访问函数
移除中间人
Bad Code:
const manager = aPerson.manager;
class Person {
get manager() {
return this.department.manager;
}
}
Good Code:
const manager = aPerson.department.manager;
- 动机
在 隐藏委托关系 的 ”动机“ 一节中,我谈到了 ”封装受托对象“ 的好处。但是这层封装也是有代价的,每当客户端要使用受托类的新特性时,你就必须在服务端添加一个简单的委托函数。随着受托类的特性(功能)越来越多,更多的转发函数就会使人烦躁。服务类完全变成了一个中间人,此时就应该让客户直接调用受托类。
-
做法
- 为受托对象创建一个取值函数
- 对于每个委托函数,让其客户端转为连续的访问函数调用。
替换完委托方法的所有调用点后,你就可以删除掉这个委托方法了。这能通过可自动化的重构手法来完成,你可以先对受托字段使用 封装变量 在应用 内联函数 内联所有使用它的函数。
替换算法
Bad Code:
function foundPerson(people) {
for (let index = 0; index < people.length; index++) {
const name = array[index];
if (name === "Don") {
return "Don";
}
if (name === "John") {
return "John";
}
if (name === "Kent") {
return "Kent";
}
}
return "Not Found";
}
Good Code:
function foundPerson(people) {
const candidates = ["Don", "John", "Kent"];
return people.find((person) => candidates.includes(person)) || "";
}
- 动机
P195
-
做法
- 整理一下待替换的算法,保证他已经被抽取到一个独立的函数中。
- 先只为这个函数准备测试,以便固定它的行为。
- 准备好另一个算法。
- 对比测试。
搬移特性
搬移函数
Bad Code:
class Account {
get overdraftCharge() {}
}
Good Code:
class AccountType {
get overdraftCharge() {}
}
- 动机
为了设计出高度模块化的程序,我们得保证互相关联的软件要素都能集中到一块,并确保块与块之间的联系易于查找、直观易懂。
任何函数都需要具备上下文环境才能存活。这个上下文可以是全局的,但它更多时候是由每种形式的模块所提供的。对一个面向对象的程序而言,类作为最重要的模块化手段,其本身就能充当函数的上下文;通过嵌套的方式,外层函数也能为内层函数提供一个上下文。不同的语言提供的模块化机制各不相同,但这些模块的共同点是,他们都能为函数提供一个赖以存活的上下文环境。
搬移函数最直接的一个动因是,它频繁引用其他上下文中的元素,而对自身上下文中的元素确关系甚少。此时,让他去与那些更亲密的元素相会,通常能取得更好的封装效果,因为系统别处就可以减少对当前模块的依赖。
如果我在整理代码时,发现需要频繁调用一个别处的函数,我也会考虑搬移这个函数,有时你在函数内部定义一个帮助函数,而该帮助函数可能在别的地方也有用处,此时就可以将它搬移到某些更通用的地方。同理,定义在一个类上的函数,可能挪到另一个类中去更方便使我们调用。
在搬移过程中,我通常会发现需要为一整组函数创建一个新的上下文,此时就可以用 函数组合成类 和 提炼类 创建一个。
-
做法
- 检查函数在当前上下文里引用的所有程序元素(包括变量和函数),考虑是否需要将它们一并转移
如果发现有些被调用的函数也需要搬移,我通常会先搬移它们。这样可以保证移动一组函数时,总是从依赖最少的那个函数入手。
如果该函数拥有一些子函数,并且它是这些子函数的唯一调用者,那么你可以先将子函数内联进来,一并搬移到新家后再重新提炼出子函数。
- 检查待搬移函数是否具备多态性。
在面向对象的语言里,还需要考虑该函数是否覆写了超类的函数,或者为子类覆写。
- 将函数复制一份到目标上下文中,调整函数,使它能适应新家
如果函数里用到了源上下文中的元素,我就得将这些元素一并传递过去,要么通过函数传参,要么是将当前上下文的引用传递到新的上下文那边去。
搬移函数通常意味着,我还得给它起个新名字,使它更符合新的上下文。
- 设法从源上下文中正确引用目标函数。
- 修改源函数,使之成为一个纯委托函数。
- 考虑对源函数使用 内联函数
也可以不做内联,让源函数一直做委托调用。但如果调用方直接调用目标函数也不费太多周折,那么最好还是把中间人去掉。
搬移字段
Bad Code:
class Customer {
get plan() {
return this._plan;
}
get discountRate() {
return this._discountRate;
}
}
Good Code:
class Customer {
get plan() {
return this._plan;
}
get discountRate() {
return this.plan._discountRate;
}
}
- 动机
在搬移数据时,我们可能会发现每当调用某个函数时,除了传入一个记录参数,还总是需要同时传入另一条记录的某个字段一起作为参数。总是一同出现、一同作为函数参数传的的数据,最好是规整到同一条记录中,以体现他们之间的联系。修改的难度也是引起我注意的一个原因,如果修改一条记录时,总是需要同时改动另一条记录,那么说明很可能有字段放错了位置。此外,如果我更新一个字段时,需要同时在多个结构中做出修改,那也是一个征兆,表明该字段需要被搬移到一个集中的地点,这样每次只需要修改一处地方。
实施字段搬移后,我可能会发现字段的诸多使用者应该通过目标对象来访问它而不应该在通过源对象来访问。
-
做法
- 确保源字段已经得到了良好的封装
- 在目标对象上创建一个字段(以及对应的访问函数)
- 确保源对象里能够正常引用目标对象。
也许你已经有现成的字段或者方法得到目标对象。如果没有,看看是否能简单地创建一个方法完成此事。如果还不行,你可能就得在源对象里创建一个字段,用于存储目标对象了。这次修改可能留存很久,但你也可以只做临时修改,等系统其他部分的重构完成就回来移除它。
- 调整源对象的访问函数,令其使用目标对象的字段。
如果源类的所有实例对象都共享对象的访问权,那么可以考虑先更新源类的设值函数,让它修改源字段时,对目标对象上的字段做出同样的修改。然后,再通过 引入断言 ,当检测到源字段与目标字段不一致时抛出错误。一旦你确定没有引入任何可观察的行为变化,就可以放心地让访问函数直接使用目标对象的字段了。
- 移除源对象上的字段
搬移语句到函数
Bad Code:
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>`,
];
}
Good Code:
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>`,
];
}
- 动机
“消除重复” 是维护代码库健康发展的黄金守则之一。如果发现调用某个函数时,总有一些相同的代码也需要每次执行,那么就要考虑将此段代码合并到函数里面,这样,日后对这段代码的修改只需改一处地方,还能对所有调用者同时生效。如果将来代码对不同的调用者需要有不同的行为,也还可以通过 搬移语句到调用者 将它(或其中一部分)搬移出来也十分简单。
如果某些语句与一个函数放在一起更像一个整体,并且更有助与理解,那我就会毫不犹豫地将语句搬移到函数里去。如果他们与函数不像一个整体,但仍应与函数一起执行,那也可以使用 提炼函数 将语句和函数一并提炼出去。
-
做法
- 如果重复的代码段离调用目标函数的地方还有些距离,则先用 移动语句 将这些语句挪动到紧邻目标函数的位置。
- 如果目标函数仅被唯一一个源函数调用,那么只需要将源函数中的重复代码段剪切并粘贴到目标函数中即可,然后运行测试。本做法的后续步骤至此可以忽略。
- 如何函数不止一个调用点,那么先选择其中一个调用点应用 提炼函数 将待搬移的语句与目标函数一起提炼成一个新函数。给新函数取个临时的名字,只要易于搜索即可。
- 调整函数的其他调用点,令他们调用新提炼的函数。每次调整之后运行测试。
- 完成所有引用点的替换后,应用 内联函数 将目标函数内联到新函数里,并移除原目标函数。
- 对新函数应用 函数改名 ,将其改名为原目标函数的名字。
搬移语句到调用者
Bad Code:
emitPhotoData(outStream, person.photo);
function emitPhotoData(outStream, aPhoto) {
outStream.write(`<p>title:${aPhoto.title}</p>`);
outStream.write(`<p>location:${aPhoto.location}</p>`);
}
Good Code:
emitPhotoData(outStream, person.photo);
outStream.write(`<p>location:${person.photo.location}</p>`);
function emitPhotoData(outStream, aPhoto) {
outStream.write(`<p>title:${aPhoto.title}</p>`);
}
- 动机
函数边界发生偏移的一个征兆是,以往在多个地方共用的行为,如今需要在某些调用点前面表现出不同的行为。于是,我们得把表现不同的行为从函数里挪出,并搬移到其调用处。这种情况下,就要使用 移动语句 手法,先将表现不同的行为调整到函数的开头或结尾,在使用本手法将语句搬移到其调用点。只要差异代码被 ban 移到调用点,就可以根据需要对其进行修改了。
这个重构手法比较合适处理边界仅有些许偏移的场景,但有时调用点和调用者之间的边界已经相去甚远,此时应该重新进行设计,最好的办法是先用 内联函数 合并双方的内容,调整语句的顺序,在提炼出新的函数来,以形成更合适的边界。
-
做法
- 最简单的情况下,原函数非常简单,其调用者也只有寥寥一两个,此时只需把要搬移的代码从函数里剪切出来并粘贴回调用端去即可,必要的时候做些调整。如果测试通过,本手法可以到此为止。
- 若调用点不止一两个,则需要先用 提炼函数 将你不想搬移的代码提炼成一个新函数,函数名可以临时起一个,只要后续容易搜索即可。
如果原函数是一个超类方法,并且有子类进行了覆写,那么还需要对所有子类的覆写方法进行同样的提炼操作,保证继承体系上每个类都有一份与超类相同的提炼函数。接着将子类的提炼函数删除,让他们引用超类提炼出来的函数。
- 对原函数应用 内联函数
- 对提炼出来的函数应用 改变函数声明 令其与原函数使用同一个名字。
以函数调用取代内联代码
Bad Code:
let appliesToMass = false;
for (const s of states) {
if (s === "MA") {
appliesToMass = true;
}
}
Good Code:
states.includes("MA");
- 动机
善用函数可以帮助我将相关行为打包起来,对于提升代码的表达力大有裨益,一个命名良好的函数,本身就能极好的解释代码用途,使读者不必了解其细节。函数同样有助于消除重复,需要修改函数时,也不需要四处寻找有没有漏改的相似代码。
如果见到一些内联代码,它们做的事情仅仅是已有函数的重复,我通常会以一个函数调用取代内联代码。但有一种情况需要特殊对待,那就是当内联代码与函数之间只有外表相似但其实并无本质联系时。这种情况下,当改变函数实现是,并不期望对应内联代码的行为发生改变。
- 做法
将内联代码替代为对一个既有函数的调用
移动语句
Bad Code:
const pricingPlan = retrievePricingPlan();
const order = retrieveOrder();
let charge;
const chargePerUnit = pricingPlan.unit;
Good Code:
const pricingPlan = retrievePricingPlan();
const chargePerUnit = pricingPlan.unit;
const order = retrieveOrder();
let charge;
- 动机
让存在关联的东西一起出现,可以使代码更容易理解。如果有几行代码取用了同一个数据结构,那么最好是让他们在一起出现,而不是夹杂在取用其他数据结构的代码中间。最简单的情况下,我只需要使用 移动语句 就可以让它们聚集起来,此外还有一种常见的 "关联" 就是关于变量的声明和使用。
通常来说,把相关代码搜集到一处往往是另一项重构(通常是在提炼函数)在开始之前的准备工作。相比于仅仅把几行的代码移动到一起,将它们提炼到独立的函数往往能起到更好的抽象效果。但如果起先存在关联的代码就没有彼此在一起。那么就很难应用 提炼函数 的手法。
-
做法
-
确定待移动的代码片段应该被搬往何处。仔细检查待移动片段与目的地之间的语句,看看搬移后是否会影响这些代码正常工作。如果会,则放弃这项重构。
-
剪切源代码片段,粘贴到上一步选定的位置上。
-
拆分循环
Bad Code:
let averageAge = 0;
let totalSalary = 0;
for (const p of people) {
averageAge += p.age;
totalSalary += p.salary;
}
averageAge = averageAge / people.length;
Good Code:
let averageAge = 0;
for (const p of people) {
averageAge += p.age;
}
let totalSalary = 0;
for (const p of people) {
totalSalary += p.salary;
}
averageAge = averageAge / people.length;
- 动机
如果在一次循环中做了多件事情,那么每当需要修改循环时,你都得同时理解这多件事情,如果能够将循环拆分,让一个循环只做一件事情,那就能确保每次修改时你只需要理解要修改的那块代码的行为就可以了。
拆分循环还能让每个循环更容易使用。一般 拆分循环 后,我还会紧接着对拆分得到的循环应用 提炼函数
-
做法
- 复制一遍循环代码
- 识别并移除循环中的重复代码,使每个循环只做一件事情。
完成循环拆分后,考虑对得到的每个循环应用 提炼函数
以管道取代循环
Bad Code:
const names = [];
for (const i of input) {
if (i.job === "programmer") {
names.push(i.name);
}
}
Good Code:
const names = input.filter((i) => i.job === "Programmer").map((i) => i.name);
- 动机
一些逻辑如果采用集合管道来编写,代码的可读性会更强--只消从头到尾阅读一遍代码,就能弄清对象在管道中间的变换过程。
-
做法
- 创建一个新变量,用以存放参与循环过程的集合。也可以简单地复制一个现有的变量赋值给新变量。
- 从循环顶部开始,将循环里的每一块行为一次搬移出来,在上一步创建的集合变量上用一种管道运算替代之。
- 搬移完循环里的全部行为之后,将循环整个删除。
如果循环内部通过累加变量来保存结果,那么移除循环后,将管道运算的最终结果赋值给该累加量。
移除死代码
if (false) {
doSomethingThatUsedToMatter();
}
- 动机
一旦代码不在被使用,我们就该立刻删除它。如果有可能以后需要使用这段代码,也可以直接去版本控制系统中再次将它翻找出来。
-
做法
- 如果死代码可以从外部直接引用,比如它是一个独立的函数时,先查找一下还有无调用点。
- 将死代码移除。
重新组织数据
拆分变量
Bad Code:
let temp = 2 * (height + width);
console.log(temp);
temp = height * width;
console.log(temp);
Good Code:
const perimeter = 2 * (height + width);
console.log(perimeter);
const area = height * width;
console.log(area);
- 动机
变量有各种不同的用途,其中某些用途会很自然的导致临时变量被多次赋值。“循环变量” 和 “结果收集变量” 就是两个典型的例子。“结果收集变量” 负责将 “通过整个函数的运算” 而构成的某个值收集起来。
除了这两种情况,还有很多变量用于保存一段冗长代码的运算结果,一遍稍后使用,这种变量应该只被赋值一次。如果他们被赋值超过一次,就意味它们在函数中承担了一个以上的责任。如果变量承担多个责任,它就应该被替换(分解)为多个变量,每个变量只承担一个责任。同一个变量承担两件不同的事情,会令代码阅读者糊涂。
-
做法
- 在待分解变量的声明以其第一次被赋值处,修改其名称。
如果稍后的赋值语句是 “i=i+” 某种表达式形式,意味着这是一个结果收集变量,就不要分解它。结果收集变量常用于累加、字符串拼接,写入流或者向集合添加元素。
- 如果可能的话,将新的变量声明为不可修改。
- 以该变量的第二次赋值动作为界,修改此前对该变量的所有引用,让它们引用行变量。
- 重复上述过程。每次都在声明处对变量改名,并修改下次赋值之前的引用,直至到达最后一处赋值。
字段改名
Bad Code:
class Organization {
get name() {}
}
Good Code:
class Organization {
get title() {}
}
- 动机
命名很重要,对于程序中广泛使用的记录结构,其中字段的命名格外重要。数据结构对于帮助阅读理解特别重要。
记录结构中的字段可能需要改名,类的字段也一样,在类的使用者看来,取值和设值函数就等于是字段。对这些函数的改名,跟裸记录结构的字段改名一样重要。
-
做法
- 如果记录的作用域较小,可以直接修改所有该字段的代码,然后测试,后面的步骤就不需要了。
- 如果记录还未封装,请先使用 封装记录。
- 在对象内部对私有字段改名,对应调整内部访问改字段的函数。
- 如果构造函数的参数用了旧的字段名,运用 改变函数声明 将其改名。
- 运用 函数改名 给访问函数改名。
以查询取代派生变量
Bad Code:
get discountedTotal() {
return this._discountedTotal;
}
set discount(aNumber) {
const old = this._discount;
this._discount = aNumber;
this._discountedTotal += old - aNumber;
}
Good Code:
get discountedTotal() {
return this._baseTotal - this._discount;
}
set discount(aNumber) {
this._discount = aNumber;
}
- 动机
可变数据是软件最大的错误源头之一。对数据的修改常常导致代码的各个部分以丑陋的形式互相耦合:在一处修改数据,却在另一处造成难以发现的破坏。因此尽量把可变数据的作用域限制在最小范围。
有些变量其实可以很容易的随时计算出来。如果能去掉这些变量,也算朝着消除可变性的方向迈出一大步。计算常能更清晰地表达数据的含义,而且也避免了 “源数据修改时忘了更新派生变量” 的错误。
-
做法
- 识别出所有对变量做更新的地方。如有必要,用 拆分变量 分割各个更新点。
- 新建一个函数,用于计算该变量的值。
- 用 引入断言 断言该变量和计算函数始终给出同样的值。如有必要,用 封装变量 将这个断言封装起来。
- 修改读取该变量的代码,令其调用新建的函数
- 用 移除死代码 去掉变量的声明和赋值。
将引用对象改为值对象
Bad Code:
class Product {
applyDiscount(arg) {
this._price.amount -= arg;
}
}
Good Code:
class Product {
applyDiscount(arg) {
this._price = new Money(this._price.amount - arg, this._price.currency);
}
}
- 动机
再把一个对象(或数据结构)嵌入另一个对象时,位于内部的这个对象可以被视为引用对象,也可以被视为值对象。两者最明显的差异在于如何更新内部对象的属性:如果将内部对象视为引用对象,在更新其属性时,应该要保留原对象不动,更新内部对象的属性;如果将其视为值对象,应该要替换整个内部对象。
如果把一个字段视为值对象,我可以把内部对象的类也变成值对象。值对象通常更容易理解,主要因为它们是不可变的。
如果想在几个对象之间共享一个对象,以便几个对象都能看见对共享对象的修改,那么这个共享的对象就应该是引用。
-
做法
- 检查重构目标是否为不可变对象,或者是否可修改为不可变对象。
- 用 移除设值函数 逐一去掉所有设值函数。
- 提供一个基于值的相等性判断函数,在其中使用值对象的字段。
大多数编程语言都提供了可覆写的相等性判断函数。通常你还必须同时覆写生成散列码的函数。
将值对象改为引用对象
Bad Code:
let customer = new Customer(customerData);
Good Code:
let customer = customerRepository.get(customerData.id);
- 动机
P256
-
做法
- 为相关对象创建一个仓库(如果还没有这样一个仓库的话)
- 确保构造函数有办法找到关联对象的正确实例。
- 修改宿主对象的构造函数,令其从仓库中获取关联对象。每次修改后执行测试
简化条件逻辑
分解条件表达式
Bad Code:
if (!aDate.isBefore(plan.summerStart) && !aDate.isAfter(plan.summerEnd)) {
charge = quantity * plan.summerRate;
} else {
charge = quantity * plan.regularRate + plan.regularServiceCharge;
}
Good Code:
if (!summer()) {
charge = summerCharge();
} else {
charge = regularCharge();
}
- 动机
程序之中,复杂的条件逻辑是最长导致复杂度上升的地点之一。大型函数本身就会使代码的可读性下降,而条件逻辑则会使代码更难阅读。
和任何大块头代码一样,我可以将它分解为多个独立的函数,更加每个小块代码的用途,为分解而得的新函数命名,并将原函数中对应的代码改为调用新函数,从而更清楚地表达自己的意图。对于条件逻辑,更清楚地表明每个分支的作用,并且突出每个分支的原因。
本重构手法其实只是 提炼函数 的一个应用场景,但一定要重视这个场景。它会带来很大的价值
-
做法
- 对条件判断和每个条件分支分别运用 提炼函数 手法。
合并条件表达式
Bad Code:
if (anEmployee.seniority < 2) return 0;
if (anEmployee.monthsDisabled > 12) return 0;
if (anEmployee.isPartTime) return 0;
Good Code:
if (isNotEligibleForDisability()) return 0;
function isNotEligibleForDisability() {
return (
anEmployee.seniority < 2 ||
anEmployee.monthsDisabled > 12 ||
anEmployee.isPartTime
);
}
- 动机
有时会发现这样一串条件检查:检查条件各不相同,最终行为确一致。如果发现这种情况,就应该使用 “逻辑或” 和 “逻辑与” 将它们合并为一个条件表达式。
合并之后的条件代码会表述检查的用意更加清晰。
这项重构往往可以使用 提炼函数 。将检查条件提炼成一个独立的函数对于厘清代码意义非常有用。
条件语句的合并也同时指出了不要合并的理由:如果我认为这些检查的确彼此独立,的确不应该被视为同一次检查,就不要使用本项重构。
-
做法
- 确定这些条件表达式都没有副作用。如果某个条件表达式存在副作用,可以先用 将查询函数和修改函数分离 处理。
- 使用适当的逻辑运算符,将两个相关条件表达式合并为一个,顺序执行的条件表达式用逻辑或来合并,嵌套的 if 语句用逻辑与来合并。
- 重复前面的合并过程,直到所有相关的条件表达式都合并到一起。
- 可以考虑对合并后的条件表达式实施 提炼函数
以卫语句取代嵌套条件表达式
Bad Code:
function getPayAmount() {
let result;
if (isDead) {
result = deadAmount();
} else {
if (isSeparated) {
result = separatedAmount();
} else {
if (isRetired) {
result = retiredAmount();
} else {
result = normalPayAmount();
}
}
}
return result;
}
Good Code:
function getPayAmount() {
if (isDead) return deadAmount();
if (isSeparated) return separatedAmount();
if (isRetired) return retiredAmount();
return normalPayAmount();
}
- 动机
条件表达式通常有两种风格:第一种是:两个条件分支都属于正常行为。第二种是:只有一个条件分支是正常行为,另一个分支是异常的情况。
如果某个条件及其罕见,就应该单独检查该条件,并在该条件为真时立刻从函数中返回。这样的单独检查常常被称为 "卫语句"。
以卫语句取代嵌套条件表达式 的精髓就是:给某一条分支以特别的重视。如果使用 if - then - else 结构,你对 if 分支和 else 分支 的重视是同等的。这样的代码结构传递给阅读者的消息就是:各个分支有同样的重要性。 卫语句就不同了,他告诉阅读者:“这种情况不是本函数的核心逻辑所关心的,如果它真的发生了,请做一些必要的整理工作,然后退出”
在我看来,保持代码清晰才是最关键的:如果单一出口能使这个函数更清楚易读,那么就使用单一出口;否则就不必这么做。
-
做法
- 选中最外层需要被替换的条件逻辑,将其替代为卫语句。
- 如果所有卫语句都引发同样的结果,可以使用 合并条件表达式 合并之。
以多态取代条件表达式
Bad Code:
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";
}
Good Code:
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";
}
}
- 动机
复杂的条件逻辑是编程中最难理解的东西之一,很多时候,可以将条件逻辑拆分到不同的场景(或者叫高阶用例)从而拆解复杂的条件逻辑。这种拆分有时用条件逻辑本身的结构就足以表达,但使用类和多态能把逻辑的拆分表述的更轻楚。
大部分的条件逻辑只用到了基本的条件语句--if/else 和 switch/case。并不需要劳师动众的引入多态,但如果发现如前所述的复杂条件逻辑,多态是改善这种情况的有利工具。
-
做法
- 如果现有的类尚不具备多态行为,就用工厂函数创建之,令工厂函数返回恰当的对象实例。
- 在调用方代码中使用工厂函数获得对象实例。
- 将带有条件逻辑的函数移到超类中。如果条件逻辑还未提炼至独立的函数,首先对其使用 提炼函数
- 任选一个子类,在其中建立一个函数,使之覆写超类中容纳条件表达式的那个函数。将与该子类相关的条件表达式分支复制到新函数中,并对它进行适当调整。
- 在超类函数中保留默认情况的逻辑。或者,如果超类应该是抽象的,就把该函数声明为 abstract ,或在其中直接抛出异常,表明计算责任都在子类中。
引入断言
Bad Code:
if (this.discountRate) {
base = base - this.discountRate * base;
}
Good Code:
asserts(this.discountRate >= 0);
if (this.discountRate) {
base = base - this.discountRate * base;
}
- 动机
常常会有这样一段代码:只有当某个条件为真时,该段代码才能正常运行。
这样的假设通常并没有在代码中明确的表现出来,必须阅读整个算法才能看出。使用断言明确标明这些假设是一种更好的技术。
断言是一个条件表达式,应该总是为真。如果它失败了,表示程序员犯了错误。断言的失败不应该被系统的任何地方捕获。整个程序的行为在有没有断言出现的时候都应该是一样的。
断言是一种很有价值的交流形式--他告诉阅读者,程序在执行到这一点时,对当前状态做了何种假设。另外断言对调试也很有帮助。而且因为他们再交流上很有价值,即使解决了当下正在追踪的错误,还是倾向把断言留着。
-
做法
- 如果你发现代码假设某个条件始终为真,就加入一个断言明确的说明这种情况。
因为断言应该不会对系统运行造成任何影响,所以”加入断言“永远都应该是行为保持的。
引入特例
Bad Code:
if (aCustomer === "unknown") {
customerName = "occupant";
}
Good Code:
class UnknownCustomer {
get name() {
return "occupant";
}
}
- 动机
一种常见的重复代码是这种情况:一个数据结构的使用者都在检查某个特例的值,并且当这个特殊值出现时所做的处理也都相同。如果发现代码库中有多处以同样方式应对同一个特殊值,就要把这个处理逻辑收拢到一起。
处理这种情况的一个好办法就是使用 ”特例“ 模式:创建一个特例元素,用以表达对这种特例的共用行为的处理。
-
做法
- 给重构目标添加检查特例的属性,令其返回 false。
- 创建一个特例对象,其中只有检查特例的属性,返回 true
- 创建一个特例对象,其中只有检查特例的属性,返回 true
- 对 ”于特例值做对比“ 的代码运用 提炼函数 ,确保所有客户端都使用这个新函数,而不再直接做特例值的比对
- 将新的特例对象引入代码中,可以从函数调用中返回,也可以在变换函数中生成
- 修改特例比对函数的主体,在其中直接使用检查特例的属性。
- 使用 函数组合成类 或者 函数组合成变换 ,把通用的特例处理逻辑搬移到新建的特例对象中。特例类对于简单的请求通常会返回固定的值,因此可以将其实现为字面记录
- 对特例比对函数使用 内联函数 ,将其内联到仍需要的地方
重构 API
将查询函数与修改函数分离
Bad Code:
function getTotalOutstandingAndSendBill() {
const result = customer.invoices.reduce(
(total, each) => each.amount + total,
0
);
sendBill();
return result;
}
Good Code:
function totalOutstanding() {
return customer.invoices.reduce((total, each) => each.amount + total, 0);
}
function sendBill() {
emailGateway.send(formatBill(customer));
}
- 动机
如果某个函数只是提供一个值,没有任何看得到的副作用,那么这是一个很有价值的东西。
明确表现出 ”有副作用“ 与 ”无副作用“ 两种函数之间的差异,是个很好的想法。有一条好规则:任何有返回值的函数,都不应该有看的到的副作用--命令与查询分离。
如果遇到一个 ”既有返回值有用副作用“ 的函数,可以先试着将查询动作从修改动作中分离出来。
有一种常见的优化方法是:将查询所得结果缓存于某个字段中,这样一来后续的重复查询就可以大大加快速度。
-
做法
-
复制整个函数将其作为一个查询来命名。如果想不出好名字,可以看看函数返回的是什么,查询的结果会被填入一个变量,这个变量的名字应该能对函数如何命名有所启发。
-
从新建的查询函数中去掉所有造成副作用的语句。
-
寻找所以调用用函数的地方。如果调用处用到了该函数的返回值,就将其改为调用新建的查询函数,并在下面马上在调用一次原函数。
-
从原函数中去掉返回值。
-
完成重构之后,查询函数与返回值之间常会有重复代码,可以做必要的清理。
函数参数化
Bad Code:
function tenPercentRaise(aPerson) {
aPerson.salary = aPerson.salary.multiply(1.1);
}
function fivePercentRaise(aPerson) {
aPerson.salary = aPerson.salary.multiply(1.05);
}
Good Code:
function raise(aPerson, factor) {
aPerson.salary = aPerson.salary.multiply(1 + factor);
}
- 动机
如果发现两个函数逻辑非常相似,只有一些字面量值不同,可以将其合并成一个函数,以参数的形式传入不同的值,从而消除重复。这个重构可以使函数更有用,因为重构后的函数还可以用于处理其他的值。
-
做法
-
从一组相似的函数中选择一个。
-
运用 改变函数声明,把需要作为参数传入的字面量添加到参数列表中。
-
修改该函数所有的调用处,使其在调用时传入该字面量。
-
修改函数体,令其使用新传入的参数。每使用一个新参数都要测试。
-
对于其他与之相似的函数,逐一将其调用处改为调用已经参数化的函数.
-
如果第一个函数经过参数化以后不能直接替代另一个与之相似的函数,就先对参数化之后的函数做必要的调整,在做替换。
移除标记参数
Bad Code:
function setDimension(name, value) {
if (name === "height") {
this._height = value;
return;
}
if (name === "width") {
this._width = value;
return;
}
}
Good Code:
function setHeight(value) {
this._height = value;
}
function setWidth(value) {
this._width = value;
}
- 动机
“标记参数” 是这样的一种参数:调用者通他来指示被调用函数应该执行那一部分逻辑。例如:
function bookConcert(aCustomer, isPremium) {
if (isPremium) {
// TODO
} else {
// TODO
}
}
如果调用者传入的是程序中流动的数据,这样的参数不算标记参数。只有调用者直接传入字面量值,这才是标记参数。另外,在函数实现内部,如果参数值只是作为数据传给其他函数,这就不是标记参数; 只有参数值影响了函数内部的控制流,这才是标记参数。
如果一个函数有多个标记参数,说明这个函数可能做的太多,应该考虑是否能用更简单的函数来组合出完整的逻辑。
-
做法
- 针对参数的每一种可能值,新建一个明确函数。如果主函数有清晰的条件分发逻辑,可以用 分解条件表达式 创建明确函数;否则,可以在原函数之上创建包装函数。
- 对于 "用字面量值作为参数" 的函数调用者,将其改为调用新建的明确函数。
保持对象完整
Bad Code:
const low = aRoom.daysTempRange.low;
const high = aRoom.daysTempRange.high;
if (aPlan.withinRange(low, high)) {
}
Good Code:
if (aPlan.withinRange(aRoom.daysTempRange)) {
}
- 动机
如果发现代码从一个记录结构中导出几个值,然后又把这个几个值一起传递给一个函数,我会更愿意把整个记录传给这个函数,在函数体内部导出需要的值。
“传递整个记录” 的方式能更好地应对变化:如果将来被调用的函数需要从记录中导出更多的数据,我就不用为此修改参数列表。并且传递整个记录也能缩短参数列表,让函数调用更容易看懂。如果有很多函数都在使用记录中的同一组数据,处理这部分数据的逻辑常会重复,此时可以把这些处理逻辑搬移到完整对象中去。
从一个对象中抽取几个值,单独对这几个值做某些逻辑操作,这也是一种坏味道(依恋情结),通常标志着这段逻辑应该被搬移到对象中。 保持对象完整 经常发生在 引入参数对象 之后,我会搜寻使用原来的数据泥团代码,代之使用新的对象
如果几处代码都在使用对象的一部分功能,可能意味着应该用 提炼类 把这一部分功能单独提炼出来。
还有一种常被忽略的情况:调用者将自己的若干数据作为参数,传递给被调用函数,这种情况下,我可以将调用者的自我引用(this)作为参数,直接传递给目标函数。
-
做法
- 新建一个空函数,给它以期望中的参数列表(及传入完整对象作为参数)。给这个函数起一个容易搜索的名字,这样到重构结束时方便替换。
- 在新函数体内调用旧函数,并把新的参数(完整对象)映射到旧的参数列表(来源于完整对象的各项数据)
- 逐一修改旧函数的调用者,令其使用新函数。修改之后,调用处用于 "从完整对象中导出参数值" 的代码可能就没用了,可以用 移除死代码 去掉。
- 所有调用处都修改过来之后,使用 内联函数 把旧函数内联到新函数中。
- 给新函数改名,从重构开始时的容易搜索的临时名字,改为使用旧函数的名字,同时修改所有调用处。
以查询取代参数
Bad Code:
availableVacation(anEmployee, anEmployee.grade);
function availableVacation(anEmployee, grade) {
// TODO
}
Good Code:
availableVacation(anEmployee);
function availableVacation(anEmployee) {
const grade = anEmployee.grade;
}
- 动机
函数的参数列表应该总结该函数的可变性,标示出函数可能体现出行为差异的主要方式。和任何代码中的语句一样,参数列表应该尽量避免重复,并且参数列表越短越容易理解。
如果调用函数时传入了一个值,而这个值由函数自己来获得特同样容易,这就是重复。
去除参数也就意味着 “获得正确的参数值” 的责任被转移:有参数传入时,调用者需要负责获得正确的参数值;参数去除后,责任就被转移给了函数本身。
不使用 以查询取代参数 最常见的原因是,移除参数可能给函数体增加不必要的依赖关系 -- 迫使函数访问某个程序元素,而我原本不想让函数了解这个元素的存在。
如果想要去除的参数值只需要向另一个参数查询就能得到,这是使用 以查询取代参数 最安全的场景。
如果在处理的函数具有引用透明性,不论任何时候,只有传入相同的参数值,改函数的行为永远一致,这样的函数既容易理解又容易测试,我就不会重构它。
-
做法
- 如果有必要,使用 提炼函数 将参数的计算过程提炼到一个独立的函数中
- 将函数体内引用该参数的地方改为调用新建的函数。
- 全部替换完成之后,使用 改变函数声明 将该参数去掉。
以参数取代查询
Bad Code:
function targetTemperature(aPlan) {
currentTemperature = thermostat.currentTemperature;
}
targetTemperature(aPlan);
Good Code:
function targetTemperature(aPlan, currentTemperature) {
// TODO
}
targetTemperature(aPlan, thermostat.currentTemperature);
- 动机
在浏览函数实现时,有时会发现一些令人不快的引用关系,例如:引用一个全局变量,或者引用另一个我想要移除的元素。为了解决这些令人不快的引用,就需要将其替换为函数的参数,从而将处理引用关系的责任转交给函数的调用者。
需要使用本重构的情况大多源于我想要改变代码的依赖关系--为了让目标函数不再依赖于某个元素,我把这个元素的值以参数形式传递给该函数。不过需要注意权衡:如果把所有依赖关系都变成参数,会导致参数列表冗长重复;如果作用域之间的共享太多,又会导致函数间依赖过度。
如果一个函数用同样的参数调用总是给出同样的结果,我们就说这个函数具有 “引用透明性” ,这样的函数理解起来更容易。如果一个函数使用了另一个元素,而后者不具引用透明性,那么包含该元素的函数也就失去了引用透明性。只要把 “不具引用透明性的元素” 编程参数传入,函数就能重获引用透明性。虽然这样就把责任转移给了函数的调用者,但是具有引用透明性的模块能带来很多益处。
以参数取待查询 并非只有好处。把查询变成参数后,就迫使调用者必须弄清楚如何提供正确的参数值,这会增加函数调用者的复杂度。
归根结底,这是关于程序中责任分配的问题,而这方面的决策既不容易,也不会一劳永逸。
-
做法
- 对执行查询操作的代码使用 提炼变量,将其从函数体中分离出来。
- 现在的函数体代码已经不再执行查询操作,(而是使用前一步提炼出的变量),对这部分代码使用 提炼函数。给提炼出的新函数起一个容易搜索的名字。
- 使用 内联变量 ,消除刚才提炼出来的变量。
- 对原来的函数使用 内联函数
- 对新函数改名,改回原来函数的名字。
移除设置函数
Bad Code:
class Person {
get name() {}
set name(sString) {}
}
Good Code:
class Person {
get name() {}
}
- 动机
如果为某个字段提供了设值函数,这就暗示这个字段可以被改变。如果不希望在对象创建之后此字段还有机会被改变,那就不要为它提供设值函数。(同时将该字段声明为不可变)。这样一来,该字段就只能在构造函数中赋值,“不行让他被修改”的意图会更加清晰,并且可以排除其值被修改的可能性。
-
做法
- 如果构造函数尚无法得到想要设入字段的值,就使用 改变函数声明 将这个值以参数的形式传入构造函数。在构造函数中调用设值函数,对字段设值。如果想要移除多个设值函数,可以一次性把它们的值都传入构造函数。
- 移除所有在构造函数之外对设值函数的调用,改为使用新的构造函数。如果不能把 “调用设值函数” 替换为 “创建一个新对象” (例如你需要更新一个多处共享引用的对象),请放弃本重构。
- 使用 内联函数 消去设值函数。如果可能的话,把字段声明为不可变。
以工厂函数取代构造函数
Bad Code:
leadEngineer = new Employee(document.leadEngineer, "F");
Good Code:
leadEngineer = createEngineer(document.leadEngineer);
- 动机
P334
-
做法
- 新建一个工厂函数,让它调用现有的构造函数。
- 将调用构造函数的代码改为调用工厂函数。
- 尽量缩小构造函数的课可见范围。
以命令取代函数
Bad Code:
function score(candidate, medicalExam, scoringGuide) {
let result = 0;
let healthLevel = 0;
// long body code
}
Good Code:
class Score {
constructor(candidate, medicalExam, scoringGuide) {
this._candidate = candidate;
this._medicalExam = medicalExam;
this._scoringGuide = scoringGuide;
}
execute() {
let result = 0;
let healthLevel = 0;
// long body code
}
}
- 动机
函数,不管是独立函数,还是以方法形式附着在对象上的函数,是程序设计的基本构造块。不过将函数封装成自己的对象,有时也是一种有用的办法。这样的对象我们称为“命令对象”。或简称 “命令”。这种对象大多只服务于单一函数,获得对该函数的请求,执行该函数。就是这种对象存在的意义。
与普通的函数相比,命令对象提供了更大的控制灵活性和更强的表达能力。除了函数调用本身,命令对象还可以支持附加操作,例如撤销之类的。
在这里 “命令” 是值一个对象,其中封装了一个函数调用请求。这是遵循了设计模式一书中的命令模式。
-
做法
- 为想要包装的函数创建一个空类,根据该函数的名字为其命名。
- 使用 搬移函数 把函数移到空的类中。保持原来的函数作为转发函数,至少保留到重构结束之前才删除。遵循编程语言的命名规范来给命令对象起名。如果没有合适的命名规范,就给命令对象中负责实际执行命令的函数起一个通用的名字。
- 可以考虑给每个参数创建一个字段,并在构造函数中添加对应的参数。
以函数取代命令
Bad Code:
class ChargeCalculator {
constructor(customer, usage) {
this._customer = customer;
this._usage = usage;
}
execute() {
return this._customer.rate * usage;
}
}
Good Code:
function charge(customer, usage) {
return customer.rate * usage;
}
- 动机
命令对象为处理复杂计算提供了强大的机制。借助命令对象,可以轻松地将原本复杂的函数拆解为多个方法,彼此之间通过字段共享状态;拆解后的方法可以分别调用;开始调用之前的数据状态也可以逐步构建。
大多数时候,只是想调用一个函数,让他完成自己的工作就好。如果这个函数不是太复杂,那么命令对象可能显得费而不惠,我就应该考虑将其变回普通的函数。
-
做法
- 运用 提炼函数, 把 “创建并执行命令对象” 的代码单独提炼到一个函数中。这一步会新建一个函数,最终这个函数会取代现在的命令对象。
- 对命令对象在执行阶段用到的函数,逐一使用 内联函数 。如果被调用的函数有返回值,请先对调用处使用 提炼变量,然后在使用 内联函数。
- 使用 改变函数声明,把构造函数的参数转移到执行函数。
- 对于所有的字段,在执行函数中找到引用他们的地方,并改为使用参数。每次修改后都要测试
- 把 “调用构造函数” 和 “调用执行函数” 两步都内联到调用方(也就是最终要替换命令对象的那个函数)
- 移除死代码把命令类消去。
处理继承关系
函数上移
Bad Code:
class Employee {}
class Salesman extends Employee {
get name() {}
}
class Engineer extends Employee {
get name() {}
}
Good Code:
class Employee {
get name() {}
}
class Salesman extends Employee {}
class Engineer extends Employee {}
- 动机
避免重复代码是很重要的。只要系统内出现重复,你就会面临 “修改其中一个却未能修改另一个”的风险。
如果某个函数在各个子类中的函数体都相同(他们很有可能是通过赋值粘贴得到的),这就是最显而易见的 函数上移 适用场合。
函数上移 常常紧随其他重构而被使用。也许你可能找出若干个身处不同子类内的函数,而他们又可以通过某种形式的参数调整成为相同的函数。这时,最简单的办法就是先分别对这些函数应用 函数参数化 ,然后应用 函数上移。
函数上移 中最麻烦的一点就是,被提升的函数可能会引用只出现于子类而不出现于超类的特性。此时可以使用 字段上移 和 函数上移 先将这些特性(类或者函数)提升到超类中。
如果两个函数工作流程大体相似,但实现细节略有差异,那么可以考虑借助 塑造模板函数 构造出相同的函数。然后在提升他们。
-
做法
- 检查待提升函数,确定他们是完全一致的,如果他们做了相同的事情,但函数体并不完全一致,那就先对他们进行重构,知道函数体完全一致。
- 检查函数体内引用的所有函数调用和字段都能从超类中调用到。
- 如果待提升的函数的签名不同,使用 改变函数声明 将那些签名都修改为你想要在超类中使用的签名。
- 在超类中新建一个函数,将某个待提升函数的代码复制到其中。
- 移除一个待提升的子类函数。
- 逐一删除待提升的子类函数,知道只剩下超类中的函数为止。
字段上移
Bad Code:
class Employee {}
class Salesman extends Employee {
name;
}
class Engineer extends Employee {
name;
}
Good Code:
class Employee {
name;
}
class Salesman extends Employee {}
class Engineer extends Employee {}
- 动机
判断若干字段是否重复,唯一的办法就是观察函数如何使用他们。如果他们被使用的方式很相似,就可以将它们提升到超类中去。
本项重构可以从两方面减少重复:首先它去除了重复的数据声明;其次它使用我可以将使用该字段饿行为从子类移到超类,从而去除重复的行为。
-
做法
- 针对待提升之字段,检查他们的所有使用点,确认他们以同样的当时被使用。
- 如果这些字段的名称不同,先使用 变量改名 为他们取个相同的名字。
- 在超类中新建一个字段。新字段需要对所有子类可见。
- 移除子类中的字段。
构造函数本体上移
Bad Code:
class Part {}
class Employee extends Part {
constructor(name, id, monthlyCost) {
super();
this._name = name;
this._id = id;
this._monthlyCost = monthlyCost;
}
}
Good Code:
class Part {
constructor(name) {
this._name = name;
}
}
class Employee extends Part {
constructor(name, id, monthlyCost) {
super(name);
this._id = id;
this._monthlyCost = monthlyCost;
}
}
- 动机
如果我看见各个子类中的函数有共同行为,我的第一个念头就是使用 提炼函数 将他们提炼到一个独立的函数中,然后使用 函数上移 将这个函数提升到超类中。
如果重构过程过于复杂,就要考虑转而使用 以工厂函数取代构造函数
-
做法
- 如果超类还不存在构造函数,首先为其定义一个。确保让子类调用超类的构造函数。
- 使用 移动语句 将子类中构造函数中的公共语句移到超类的构造函数调用语句之后。
- 逐一移除子类间的公共代码,将其提升至超类构造函数中。对于公共代码中引用到的变量,将其作为参数传递给超类的构造函数。
- 如果存在无法简单提升至超类的公共代码,先应用 提炼函数 ,在利用 函数上移 提升之。
函数下移
Bad Code:
class Employee {
get quota() {}
}
class Engineer extends Employee {}
class Salesman extends Employee {}
Good Code:
class Employee {}
class Engineer extends Employee {}
class Salesman extends Employee {
get quota() {}
}
- 动机
如果超类中的某个函数只与一个(或少数个)子类有关,那么最好将其从超类中挪走,放到真正关心他的子类中去。这项重构手法只有在超类明确知道哪些子类需要这个函数时适用。如果超类不知晓这个信息,可以使用 以多态取代条件表达式 只留共用的行为在超类。
-
做法
- 将超类中的函数本体复制到每一个需要此函数的子类中。
- 删除超类中的函数。
- 将函数从所有不需要它的那些子类中删除。
字段下移
Bad Code:
class Employee {
quota;
}
class Engineer extends Employee {}
class Salesman extends Employee {}
Good Code:
class Employee {}
class Engineer extends Employee {}
class Salesman extends Employee {
quota;
}
- 动机
如果某个字段只被一个子类(或者一小部分子类)用到,就将其搬移到需要该字段的子类中。
-
做法
- 在所有需要该字段的子类中声明该字段,
- 将该字段从超类中移除。
- 将该字段从所有不需要它的子类中删除。
以子类取代类型码
反向重构: 移除子类
Bad Code:
function createEmployee(name, type) {
return new Employee(name, type);
}
Good Code:
function createEmployee(name, type) {
switch (type) {
case "employee":
return new Employee(name);
case "saleman":
return new Salesman(name);
case "manager":
return new Manager(name);
}
}
- 动机
表现分类关系的第一种工具是类型码字段--根据具体的编程语言,可能实现为枚举,符号,字符串。或者数字。类型码的取值经常来自给系统提供数据的外部服务。
大多时候有这些类型码就足够了。但也有时候,还可以再往前一步,引入子类。继承有两个诱人之处。首先,可以用多态来处理条件逻辑。如果有一个函数都在根据类型码的取值不同采取不同的行为,多态就显得特别有用。引入子类之后,可以使用 以多态取代条件表达式 来处理这些函数。
另外,有些字段或者函数之对特定的类型码取值才有意义。
在使用 以子类取代类型码 时,需要考虑一个问题:应该直接处理携带类型码的这个类,还是应该处理类型码本身?
-
做法
- 自封装类型码字段。
- 任选一个类型码取值,为其创建一个子类。覆写类型码类的取值函数,令其返回该类型码的字面量值。
- 创建一个选择器逻辑,把类型码参数映射到新的子类。如果选择直接继承的方案,就用 以工厂函数取代构造函数 包装构造函数,把选择器逻辑放在工厂函数里;如果选择间接继承的方案,选择器逻辑可以保留在构造函数中。
- 针对每个类型码取值,重复上述“创建子类、添加选择器逻辑”的过程。
- 去除类型码字段。
- 使用 函数下移 和 以多态取代条件表达式 处理原本访问了类型码的函数u,全部处理完成之后,就可以移除类型码的访问函数了。
移除子类
反向重构: 以子类取代类型码
Bad Code:
class Person {
get genderCode() {
return "X";
}
}
class Male extends Person {
get genderCode() {
return "M";
}
}
class Female extends Person {
get genderCode() {
return "F";
}
}
Good Code:
class Person {
get genderCode() {
return this._genderCode;
}
}
- 动机
子类很有用,他们为数据结构的多样性和行为的多态提供支持,他们是针对差异编程的好工具。但是随着软件的演化,子类所支持的变化可能会被搬移到别处,甚至完全去除,这时子类就失去了价值。有时添加子类是为了应对未来的新功能,结构构想中的功能压根就没被构造出来,或者用了另一种方式构造,使该子类不再被需要了。
子类存在着就有成本,阅读者要花心思去理解它的用意,所以如果子类的用处太少,就不值得存在了。此时,最好的选择就是移除子类,将其替换为超类中的一个字段。
-
做法
- 使用 以工厂函数取代构造函数 ,把子类的构造函数包装到超类的工厂函数中。--如果构造函数的客户端调用一个数组字段来决定实例那个子类,可以把这个判断逻辑放到超类的工厂函数中。
- 如果有任何代码检查子类的类型,先用 提炼函数 把类型检查逻辑包装起来,然后用 搬移函数 将其搬移到超类。
- 新建一个字段,用于代表子类的类型。
- 将原本针对子类的类型做判断的函数改为使用新建的类型字段。
- 删除子类。
本重构手法常用于一次移除多个子类,此时需要先把这些子类都封装起来(添加工厂函数、搬移类型检查。)然后在逐个将它们折叠到超类中
提炼超类
Bad Code:
class Department {
get totalAnnualCost() {}
get name() {}
get headCount() {}
}
class Employee {
get annualCost() {}
get name() {}
get id() {}
}
Good Code:
class Party {
get annualCost() {}
get name() {}
}
class Department extends Party {
get annualCost() {}
get headCount() {}
}
class Employee {
get annualCost() {}
get id() {}
}
- 动机
如果有两个类在做相似的事,可以利用基本的继承机制把它们的相似之处提炼到超类。可以用 字段上移 ,把相同的数据搬移到超类,用 函数上移 搬移相同的行为。
另一种选择就是 提炼类。这两种方案之间的选择,其实就是继承和委托之间的选择,总之目的都是把重复的行为收拢一处。 提炼超类 通常是比较简单的做法,所以首选这个方案。即便选错,也可以使用 以委托取代超类。
-
做法
- 为原本的类新建一个空白超类。--如果需要的话,用 改变函数声明 调整构造函数的签名。
- 使用 构造函数本体上移,函数上移 和 字段上移 手法,逐一将子类的共同元素上移到超类。
- 检查留在子类的函数,看它们是否还有共同的成分。如果有,可以先用 提炼函数 将其提炼出来。再用 函数上移 搬移到超类
- 检查所有使用原本的类的客户端代码,考虑将其调整为使用超类的接口。
折叠继承体系
Bad Code:
class Employee {}
class Salesman extends Employee { }
Good Code:
class Employee {}
- 动机
在重构类继承体系时,我经常把函数和字段上下移动。随着继承体系的演化,我有时会发现一个类与其超类已经没有多大的差别,不值得在作为独立的类存在。此时就可以把超类和子类结合起来。
-
做法
- 选择想移除的类:是超类还是子类?--- 我选择的依据是看那个类的名字放在未来更有意义。如果两个名字都不够好,就随便挑一个。
- 使用 字段上移、字段下移、函数上移、函数下移,把所有元素都移到同一个类中。
- 调整即将被移除的那个类的所有引用点,令他们改而引用合并后留下的类。
- 移除我们的目标;此时他应该已经成为了一个空类。
以委托取代子类
Bad Code:
class Order {
get daysToShip() {
return this._warehouse.daysToShip;
}
}
class PriorityOrder extends Order {
get daysToShip() {
return this._priorityPlan.daysToShip;
}
}
Good Code:
class Order {
get daysToShip() {
return this._priorityDelegate
? this._priorityDelegate.daysToShip
: this._warehouse.daysToShip;
}
}
class PriorityOrderDelegate {
get daysToShip() {
return this._priorityPlan.daysToShip;
}
}
- 动机
如果一个对象的行为有明显的类别之分,继承是很自然的表达方式。可以把共用的数据和行为放在超类中,每个子类根据需要覆写部分特性。
但是继承也有短板。最明显的是,继承这张牌只能打一次,导致行为不同的原因有很多种,但继承只能用于处理一个方向上的变化。
更大的问题在于,继承给类之间引入了非常紧密的关系。在超类上做任何修改,都很有可能破坏子类,所以我必须非常小心并且充分理解子类如何从超类派生。
这个两个问题用委托都能解决。对于不同的变化原因,可以委托不同的类。委托是对象之间常规的关系。与继承关系相比,使用委托关系时接口更清晰、耦合更少,因此,继承关系遇到问题时运用 以委托取代子类 是常见的情况。
有一条流行的原则: “对象组合优于类继承”。(组合和委托是一回事)。
-
做法
- 如果构造函数有多个调用者,首先用 以工厂函数取代构造函数 把构造函数包装起来。
- 创建一个空的委托类,这个类的构造函数应该接受所有子类特有的数据项,并且经常以参数的形式接受一个指向超类的引用。
- 在超类中添加一个字段,用于安放委托对象。
- 修改子类的创建逻辑,使其初始化上述委托字段,放入一个委托对象的实例。---这一步可以在工厂函数中完成,也可以在构造函数中完成(如果构造函数有足够的信息以创建正确的委托对象的话)
- 选择一个子类中的函数,将其移入委托类。
- 使用 搬移函数 手法搬移上述函数,不要删除源类中的委托代码。 --- 如果这个方法用到的其他元素也应该被移入委托对象,就把它们一并搬移。如果他用到的元素应该留在超类中,就在委托对象中添加一个字段,令其指向超类的实例。
- 如果被搬移的源函数还在子类之外被调用了,就把留在源类中的委托代码从子类移到超类,并在委托代码之前加上卫语句,检查委托对象存在,如果子类之外已经没有其他调用者,就用 移除死代码 去掉已经没人使用的委托代码。 --- 如果有多个委托类,并且其中的代码出现了重复, 就使用 提炼超类 手法消除重复。此时如果默认行为已经被移入了委托类的超类,源超类的委托函数就不在需要卫语句。
- 重复上述过程,知道子类中所有函数都搬移到委托类。
- 找到所有调用子类的构造函数的地方,逐一将其改为使用超类的构造函数。
- 运用 移除死代码 去掉子类。
以委托取代超类
Bad Code:
class List {}
class Stack extends List {}
Good Code:
class Stack {
constructor() {
this._storage = new List();
}
}
class List {}
- 动机
在面向对象程序中,通过继承来复用现有功能,是一种强大有便捷的手段。只要继承一个已有的类,覆写一些功能,在添加一些功能,就能达成目的,但继承也有可能造成困扰和混乱。
如果超类的一些函数对子类并不适用,就说明不应该通过继承来获得超类的功能。
除了 "子类用得上超类的所有函数" 之外,合理的继承关系还有一个重要的特征:子类的所有实例都应是超类的实例,通过超类的接口来使用子类的实例应该完全不出问题。
如果子类与超类之间的耦合过强,超类的变换很容易破坏子类的功能,我还是会使用 以委托取代超类。
-
做法
- 在子类中新建一个字段,使其引用超类的一个对象,并将这个委托引用初始化为超类的新实例。
- 针对超类的每个函数,在子类中创建一个转发函数,将调用请求转发给委托引用。每转发一块完整逻辑,都要测试。--- 大多数时候,每转发一个函数就可以测试,但一对设值、取值必须同时转移,然后才能测试。
- 当所有超类函数都被转发函数覆写后,就可以去掉继承关系。