程序有两面价值:“今天可以为你做什么”和“明天可以为你做什么”。大多数时候,我们只关注自己今天想要程序做什么。不论是修复错误或是添加特性,我们都是为了让程序能力更强,让它在今天更有价值。
但是系统当下的行为,只是整个故事的一部分,如果没有认清这一点,你无法长期从事编程工作。如果你为求完成今天的任务而不择手段,导致不可能在明天完成明天的任务,那么最终还是会失败。但是,你知道自己今天需要什么,却不一定知道自己明天需要什么。也许你可以猜到明天的需求,也许吧,但肯定还是有些事情出乎你的意料。
如何理解重构
- 如果你发现自己需要为程序添加一个特性,而代码结构使你无法很方便地达成目的,那就先重构那个程序,使特性的添加比较容易进行,然后再添加特性。
- 重构之前,先确保有一套可靠的测试机制。测试机制可以告诉我们有没有在重构的过程中引入Bug。
- 代码块越小,代码的功能就越容易管理。
- 小步前进、频繁测试。
- 设计模式是你希望达成的目标,重构是你的必经之路。
- 消除重复代码,确定所有的事物和行为在代码中只表述一次,这是优秀设计的根本。
糟糕的代码
重复代码 Duplicated Code
- 同一个类里面两个函数具有相同的表达式 -> 提炼重复的代码。
- 互为兄弟的子类里面含有相同的表达式 -> 提炼重复代码,推入超类。
- 如果代码只是相似,并非完全相同 -> 分离差异部分和相思部分。
- 如果两个毫不相干的类出现重复代码 ->考虑提取到一个独立类中,在其他类引用该类。
过长函数 Long Method
- 每当需要注释来说明一些代码时候,我们应该把这些代码写进一个独立函数,并以其用途命名。
- 在函数内有大量参数和临时变量时,提取为新的函数会导致大量的方法参数。可以使用查询方法替换临时参数,或者使用查询对象优化参数列表。
- 条件表达式和循环也是提炼的信号,如:采用分解条件表达式、提炼for循环体到独立函数。
过大的类 Large Class
- 类出现太多实例变量时,往往会导致重复的代码。可以考虑将彼此关联的变量提取到新的类或子类。
- 避免太长的函数,尽量将长函数化解为多个短函数。也可以从客户端角度提炼使用接口,帮助分解类。
过长的参数列 Long Parameter List
- 太长的参数列不易理解和修改。可以考虑使用对象的方法或属性替换参数传递,或者将一个对象的多个参数替换为该对象。
- 对象之间的依赖和参数列表之间的平衡需要把握好,避免为了消除依赖而造成过长的参数列表。如果参数列变化频繁或者过长,则需要重新考虑以来结构。
发散式变化 Divergent Change
- 如果某个类经常因为不同原因在不同的方向发生变化,那么就应该考虑将这个类按照不同方向拆分为多个类。
霰弹式修改 Shotgun Surgery
- 如果遇到某种变化,你需要在多个类上做出修改,此时应该考虑将需要修改的具有相关行为的代码放到同一个类。
依恋情结 Feature Envy
- 函数对某个类的数据依赖高于自己所处的类,可以考虑提取高外部数据依赖的代码为一个独立方法,再置入对应的类中。
- 一个函数往往会用到多个类的功能,放置原则:哪个类拥有最多被此函数使用的数据,则将该方法移入该类中。
数据泥团 Data Clumps
- 当相同的几项数据在多个函数签名中出现时,应该考虑将其封装成一个对象,以缩短方法的参数列表。
- 如何评判几项数据应该封装成一个整体?删除其中一个数据,看看剩下的其他数据是否失去意义。如果其他数据因为这个删除的数据而无法协作,就应该将他们封装成一个整体。
基本类型偏执 Primitive Obsession
- 面向对象的编程技术里面,不要因为数据个数少而拒绝使用小对象。尽量将一组(2个及以上)的字段封装成一个对象。
switch惊悚现身 Switch Statements
- 面向对象程序最明显的特征就是:少用switch/case语句
- 大多数时候,switch语句可以用多态来替换。每个分支的代码可以提取到独立的函数中,然后将该函数放到对应的多态性的类里面。
- 如果只是在单一函数有些选择的情况,使用多态就有点杀鸡用牛刀了。这时候可以使用Replace Parameter with Explicit Method模式进行优化。
平行继承体系 Parallel Inheritance Hierarchies
- 当你为某个类增加一个子类,必须也为另一个类增加一个子类。
- 一个继承体系类名称前缀和另一个继承体系类名称前缀完全相同。
- 消除的一般策略:让一个继承体系的实例引用另一个继承体系的实例。
- 通过移动函数和属性值,可以将引用端的继承体系消除。
冗赘类 Lazy Class
- 折叠一些没用的子类(Collapse Hierarchy),或者将类内敛(Inline Class)。
夸夸其谈未来性 Speculative Generality
- 用不上的函数或参数应该移除。
- 如果某些函数名称带有多余的抽象性,应该重命名。
- 函数和类的唯一用户是测试用例,应该考虑和测试用例一并删除。
令人迷惑的临时字段 Temporary Field
- 将临时字段和与其相关的代码放入一个独立的类中。
过度耦合的消息链 Message Chains
- 用户向一个对象请求另一个对象,然后再向后者请求另一个对象…
- 这种情况的出现会导致客户端代码与查找过程的导航结构紧密耦合,如果任何环节出现变动,客户端不得不做出修改。
- 观察消息链的最终对象用途,尝试将该代码提取到独立函数中,然后将该方法推入到消息链的某个类。
中间人 Middle Man
- 面向对象的细节封装特性往往伴随着委托。但是如果一个类的接口有较多的函数委托给其他类,则应该考虑消灭中间人,直接和实责对象打交道。
- 如果这类中间人函数只有少数几个,可以使用Inline Method(内敛函数)放入调用端。
- 如果这些Middle Man还有其他行为,则可以运用Replace Delegation with Inheritance将其变成实责对象的子类。
狎昵关系 Inappropriate Intimacy
- 如果两个类关系过于紧密纠缠不清,则可以使用Move Method和Move Field划清界限。
- 使用Change Bidirectional Association to Unidirectional将双向关联改为单向关联。
- 使用Extract Class将两者共同点提取到一个独立的类。
- 继承往往会造成过度亲密,如果该子类可以独立于父类,考虑使用Replace Inheritance with Delegation让子类离开继承体系。
异曲同工的类 Alternative Classes with Different Interfaces
- 如果两个函数功能相同但是函数签名不同,应该根据用途将他们重新命名。
- 在这基础上,通过移动方法到合适的类中,保证两者协议一致。可以考虑使用超类减少重复代码和工作。
不完美的库类 Incomplete Library Class
- 如果想要修改库类的一两个函数,可以使用Introduce Foreign Method
- 如果想要添加一大堆额外行为,可以使用Introduce Local Extension
存粹的数据类 Data Class
- 如果一个类只拥有一些字段以及读写这些字段的函数,那么这个类就是一个数据容器。
- 对于这些类,应该使用Encapsulate Field封装public字段、使用Encapsulate Collection封装容器类字段。
- 对于不开放修改的字段,应该使用Remove Setting Method
被拒绝的遗赠 Refused Bequest
- 如果子类复用了超类的实现,却不愿意实现超类的接口。这种情况下,应该避免修改继承体系,尝试使用Replace Inheritance with Delegation让子类开继承体系。
过多的注释 Comments
- 如果你需要注释来解释一块代码做什么事情,考虑Extract Method到独立函数。
- 如果函数提取出来,但还是需要注释解释其行为,尝试Rename Method。
- 如果你需要解释系统的需求规格,尝试Introduce Assertion。
- 当你想要使用注释说明什么的时候,尝试重构、让所有的注释变得多余。
构筑测试体系
自测试代码的价值
- 自动化的测试避免了人工观察控制台输出
- 持续的自测可以帮助开发者快速定位到错误,节省大量的时间和精力
- 测试代码的编写应该在开始编程之前,当你需要添加新特性的时候,应该先写对应的测试代码。
JUnit测试框架
测试的风格
- 观察类该做的所有事情,针对任何一项功能的任何一种失败情况,进行测试。
- 测试的目的是找出现在或者将来可能出现的错误,因此不要仅仅测试一些字段的访问函数。
- 测试的要诀是,测试你最担心出错的部分。
- 考虑可能出错的边界条件,把测试火力集中在那里。
- 当事情应该出错时,别忘记检查是否抛出了预期的异常。
- 花合理时间抓出大多数bug好过穷尽一生抓出所有bug。
重构列表
重构的记录格式
- 名称:重构词汇表
- 概要:介绍此重构手法适用情景、它所做的事情。
- 动机:为你介绍为什么需要重构、什么情况下不该用此重构
- 做法:简明扼要介绍重构步骤
- 范例:以一个十分简单的例子说明此重构手法。
寻找引用点
- 不要盲目使用查找-替换
- 利用IDE工具快速找到引用点。
- 编译器在删除旧部分的时候可以帮你找到引用点,但是基于反射机制得到的引用点无法找到。
重新组织函数
提炼函数 Extract Method
- 动机
- 函数的粒度越小,其被复用的可能性越大
- 函数粒度越小,对其的覆写也就更容易
- 处理对于长函数、需要注释的函数,帮助理解
- 提炼的目的不在于将函数控制在多少行,而是把握函数名和函数体的语义距离。
- 如果一个提炼可以强化函数的清晰度,那么就值得一做。
- 做法
- 创造一个新的函数,根据这个函数的意图来命名它(以它做什么命名,而不是以它怎么做命名)
- 将提炼的代码从原函数复制到新建的目标函数
- 仔细检查提炼的代码,查看其中是否引用了“作用域限于源函数”的变量(包括局部变量和源函数参数)
- 检查是否有“仅用于被提炼代码段”的临时变量,如果有,在目标函数中将它们声明为临时变量。
- 检查被提炼的代码段,看看有无局部变量的值被它改变。如果一个临时变量的值被修改了,查看是否可以将被提炼代码段处理为一个查询,并将结果复制给相关变量。如果被修改的变量不止一个,你就不能仅仅将这段代码原封不动地提炼出来。你可能需要先使用Split Temporary Variable,然后再尝试提炼。也可以使用Replace Temp with Query将临时变量消除。
- 将被提炼代码段中须要读取的局部变量,当作参数传递给目标函数。
- 处理完所有局部变量以后,进行编译。
- 在源函数中,将被提炼的代码替换为对目标函数的调用。
- 编译、测试。
内联函数 Inline Method
- 动机
- 如果内部代码和函数名称一样清晰,那么就应该直接使用内部代码,消除非必要的间接性。
- 你面对的是一群组织不合理的函数,将其内联到一个大型函数里面,再从中提炼出组织合理的小函数。
- 如果代码里面出现了太多间接层,所有函数似乎都是对另一个函数的简单委托,那么也应该使用内联函数消除无用的间接层。
- 做法
- 检查函数,确定它不具有多态性(没有子类继承、覆写该函数)
- 找出这个函数的所有调用点
- 将这个函数的所有调用点都替换为函数本体
- 编译、测试
- 删除该函数的定义
内联临时变量 Inline Temp
- 动机
- 内联临时变量一般配合Replace Temp with Query使用。
- 做法
- 检查给临时变量赋值的语句,确保等号右边的表达式没有副作用
- 如果这个临时变量并未被声明为final,那么就将它声明为final,然后编译。(这样可以检查该变量是否只被赋值一次)
- 找到该临时变量的所有引用点,将它们替换为“为临时变量赋值”的表达式
- 每次修改后编译并测试
- 修改完所有的引用点之后,删除该临时变量的声明和赋值语句
- 编译、测试
以查询取代临时变量 Replace Temp with Query
- 动机
- 临时变量是暂时的、只在所属函数内部使用。
- 如果将临时变量替换为一个函数查询,那么同一个类中所有函数都可以获得这份信息。
- 以查询替代临时变量往往是提取方法之前的必要步骤。因为局部变量会使得代码难以提炼,替换为查询式会好很多。
- 一些复杂的情况下,可能需要:Split Temporary Variable或者Separate Query from Modifier
- 如果临时变量是用来收集结果的,就需要将某些程序逻辑复制到查询函数。
- 做法
- 找出只被赋值一次的临时变量(如果一个临时变量被赋值多次,考虑拆分为多个变量)
- 将该临时变量声明为final
- 编译(确保该变量只被赋值一次)
- 将“对该临时变量赋值”语句的等号右侧部分提炼到一个独立函数中。
- 首先将函数声明为private。
- 确保提炼出来的函数无副作用,也就是说该函数不修改任何对象的内容。如果有副作用,则使用Separate Query from Modifier。
- 编译、测试
- 在该临时变量上实施Inline Temp。
引入解释性变量 Introduce Explaining Variable
- 动机
- 表达式复杂难以阅读,临时变量可以将表达式分解为比较容易管理的形式
- 在条件逻辑中引入解释性变量非常有价值
- 在较长的算法中,引入解释性变量可以解释每一步运算的意义
- 注意:引入解释性变量往往是其他重构步骤的前序步骤(Replace Temp with Query、Extract Method、Replace Method with Method Object),这个要结合具体情况来看。
- 做法
- 声明一个final临时变量,将待分解的复杂表达式中的一部分动作的运算结果赋值给它
- 将表达式中国的运算结果这部分,替换为临时变量(如果被替换部分重复出现,可以每次替换一个,逐一替换)
- 编译、测试
- 重复上述过程,处理表达式的其他部分
分解临时变量 Split Temporary Variable
- 动机
- 循环变量和结果收集变量在使用中会被多次赋值
- 如果一个临时变量在代码段内的多个不同的语义下使用,那么应该考虑按照责任分解成不同的临时变量,方便读者理解。
- 做法
- 在待分解临时变量的声明及其第一次被赋值处,修改其名称。
- 将新的临时变量声明为final
- 以该临时变量的第二次赋值动作为界,修改此前对该临时变量的所有引用点,让他们引用新的临时变量
- 在第二次赋值处,重新声明原先那个临时变量
- 编译、测试
- 逐次重复上述过程,在新的赋值点对临时变量改名,并修改下次赋值之前的引用点。
移除对参数的赋值 Remove Assignment to Parameters
- 动机
- 在Java中,不要对参数赋值。Java是按照按值传递方式,对参数赋值不会影响调用端,但是增加了代码理解的难度。
- 做法
- 建立一个临时变量,把待处理的参数值赋与给它
- 以“对参数的赋值”为界,将其后所有的对此参数的引用点,全部替换为“对此临时变量的引用”
- 修改赋值语句,使其改为对新建之临时变量的赋值
- 编译、测试
以函数对象取代函数 Replace Method with Method Object
- 动机
- 局部变量的存在会增加函数分解的难度。如果一个函数之中局部变量泛滥成灾,那么想分解这个函数时非常困难的。Replace Temp with Query可以帮助减轻负担,但有时候你会发现根本无法拆解一个函数。这种情况下,应该考虑使用函数对象(Method Object)
- 函数对象会将所有局部变量变成函数对象的字段,然后你就可以对新对象使用Extract Method创造出新函数,从而将原有大型函数变短。
- 做法
- 新建一个类,根据待处理函数的用途命名。
- 在新类中新建一个final字段,用以保存原先大型函数所在的对象。我们将这个字段成为“源对象”。同时,针对原函数的每个临时变量和每个参数,在新类中建立一个对应的字段保存之。
- 在新类中建立一个构造函数,接收源对象以及原函数的所有参数作为参数。
- 在新类中建立一个compute()函数
- 将原函数的代码复制到compute()函数中,如果需要调用源对象的任何函数,请通过源对象字段调用。
- 编译。
- 将旧函数的函数本体替换为这样一条语句:创建上述新对象,然后调用compute()函数。
- 完成上述步骤以后,你可以自由的分解compute函数。
替换算法 Substitute Algorithm
- 动机
- 使用简单算法替换原有的复杂算法。
- 如果有程序库能替代你的算法,应该使用程序库。
- 在做之前确保你已经将其分解成了简单的小型函数(或者它已经是简单的小型函数)。
- 做法
- 准备好另外一个替换用的算法,使其通过编译
- 针对现有测试,执行上述算法
- 如果测试结果与现在不同,以旧算法为准不断修改调试。
- 对于每个测试用例,使用新旧两种算法进行测试对比,这样可以更快定位到不一致的地方。
在对象之间搬移特性
在对象设计的过程中,“决定将责任放在哪儿”是非常重要的事情之一。
在这种类型的重构中,通常只需要使用Move Method和Move Field简单移动地下的行为就可以解决。
如果一个类因为承担过多责任而臃肿不堪,可以使用Extract Class将一部分责任分离出去。
如果一个类变得不太负责任,可以使用Inline Class将其融入到另外一个类。
如果一个类使用了另外一个类,运用Hide Delegate将这种关系隐藏起来。有时候隐藏委托类会导致拥有者的接口经常发生变化,可以使用Remove Middle Man处理。
搬移函数 Move Method
- 动机
- “搬移函数”是重构的理论支柱。如果一个类有太多行为、或者一个类与另一个类有太多合作和形成高耦合,就应该搬移函数。
- 如果一个类的函数,使用其他对象的次数比自己所属的对象的次数还多,应该考虑搬移函数。
- 做法
- 检查源函数所使用的一切特性(包括字段和函数),考虑它们是否也被搬移。
- 如果某个特性只被你算法搬移的函数用到,就应该将它一起搬移。如果有其他函数也有到这个特性,可以考虑将所有用到该特性的函数一并搬移。
- 检查源类的子类和超类,看看是否有该函数的其他声明
- 如果有其他声明,你或许无法搬移,除非目标类也同样表现多态性
- 在目标类中声明这个函数
- 将源函数的代码复制到目标函数中。调整后者,使其能在新家中正常运行。
- 如果目标函数使用了源类中的特性,你得决定如何从目标函数引用源对象。如果目标类中没有相应的引用机制,就把源对象的引用当作函数参数传递进来。
- 如果源函数包含异常处理,你得判断逻辑上应该由哪个类来处理这一异常。
- 编译目标类
- 决定如何从源函数正确引用目标对象
- 可能会有一个现成的字段或者函数帮你得到目标对象。如果没有,尝试建立一个函数或者字段保存目标对象。
- 修改源函数,使之成为一个纯委托函数
- 编译测试
- 决定是否删除源函数,或者将它当作一个委托保留
- 如果你经常要在源对象中引用目标函数,那么将源函数作为委托函数保留会比较简单
- 如果要删除源函数,那么将对源类中源函数的所有调用,替换为对目标函数的调用。
- 编译、测试
- 检查源函数所使用的一切特性(包括字段和函数),考虑它们是否也被搬移。
搬移字段 Move Field
- 动机
- 一个字段如果外部类有更多的函数使用它,可以考虑搬移这个字段。所谓的使用可以是通过set/get函数间接进行。
- Extract Class时,先搬移字段,后搬移函数。
- 做法
- 如果字段的访问是public,使用Encapsulate Field将其封装。
- 编译、测试
- 在目标类中建立与源字段相同的字段,并设立对应的Set/Get函数
- 编译目标类
- 决定如何在源对象中引用目标对象
- 先看有无现成的字段或者函数能得到目标对象
- 如果没有,考虑建立函数获取对象
- 如果还不行,考虑设置字段存放目标对象
- 删除源字段
- 将所有对源字段的引用替换为对某个目标函数的调用
- 编译、测试
提炼类 Extract Class
- 动机
- 类包含大量函数和数据,不易理解。
- 子类化只影响类的部分特性或者一些特性需要一个子类化、另一些特性又需要另外一个子类化。
- 做法
- 决定如何分解类所负的责任
- 建立一个新类,用以表现从旧类中分离出来的责任
- 建立“从旧类访问新类的连接关系”
- 有可能需要一个双向连接
- 对于你想搬迁的每一个字段,使用Move Field
- 每次搬移后,编译、测试
- 使用Move Method将必要的函数搬移到新类。先搬移较低层次的函数(被别人调用多过调用别人),再搬移较高层函数。
- 每次搬移后,编译、测试
- 检查,精简每个类的接口
- 决定是否公开新类。如果确定公开,就要决定让它成为引用对象还是不可变值对象。
将类内联化 Inline Class
- 动机
- 如果一个类不再承担足够的责任、不再有单独存在的理由,将这个类塞进另外一个类中。
- 做法
- 在目标类上声明源类的public协议,并将其中所有的函数委托至源类
- 修改所有源类引用点,改而引用目标类。
- 编译、测试
- 运用Move Method和Move Field将源类特性全部迁移到目标类
隐藏委托关系 Hide Delegate
- 动机
- 封装是面向对象的关键特征之一。
- 如果客户先通过服务对象的字段得到另外一个对象,然后调用后者的函数,那么客户就必须知道这样一层委托关系。当委托关系发生变化时,客户也得相应变化。通过服务对象的委托函数可以隐藏这样的委托关系,从而去除依赖。
- 做法
- 对于每一个委托关系中的函数,在服务对象端建立一个简单的委托函数。
- 调整客户端,令它值调用服务对象提供的函数
- 每次调整后,编译并测试
- 如果将来不再有任何客户需要取用受托类,可以移除服务对象中的相关函数。
- 编译、测试
移除中间人 Remove Middle Man
- 动机
- 当受拖类的特性越来越多时,服务类为其创建的委托函数也越来越多。这时候服务类就变成了一个中间人,此时,应该让客户直接调用受托类。
- 做法
- 建议一个函数,用以获得受托类对象
- 对于每个委托函数,在服务类中删除该函数,并让需要调用该函数的客户转为调用受托对象
- 处理每个受托类函数后,编译、测试
引入外加函数 Introduce Foreign Method
- 动机
- 某个服务类的函数无法满足你的需求,但是你又无法直接修改该服务类的源码。可以考虑引入外部函数,将重复的代码放入这个函数中。
- 做法
- 在客户类中建立一个函数,用于提供你想要的功能
- 这个函数不应该调用任何客户端的特性。如果它需要值,通过传参数解决
- 以服务类实例作为该函数的第一个参数
- 将该函数注释为:“外加函数,应该在服务类中实现”
- 在客户类中建立一个函数,用于提供你想要的功能
引入本地拓展 Introduce Local Extension
- 动机
- 在源类代码无法修改的情况下,如果外加函数超过两个,应该考虑将这些外加函数组织到一个恰当的地方去。
- 要达到上述目的,有两种方式:子类化(subclassing)、包装(wrapping),这两种方式的改造都成为本地拓展。
- 做法
- 建立一个拓展类,将它作为原始类的子类或者包装类。
- 在拓展类中加入转型构造函数。
- 所谓转型构造函数,是指接受原对象作为参数的构造函数。如果采用子类化方案,那么转型构造函数应该调用适当的超类构造函数。如果采用包装类方案,那么转型构造函数应该将它得到的传入参数以实例变量的形式保存起来,用作接受委托的原对象。
- 在拓展类中加入新特性
- 根据需要,将原对象替换为拓展对象
- 将针对原始类定义的所有外加函数搬迁到拓展类
重新组织数据
自封装字段 Self Encapsulate Field
- 动机
- 间接访问变量的好处:子类可以通过覆写一个函数而改变获取数据的途径。
- 它支持更灵活的数据管理方式,比如延迟初始化。
- 做法
- 为待封装字段建立取值、设值函数
- 找出该字段的所有引用点,将它们替换为调用取值/设值函数
- 将该字段声明为private
- 复查,确保找出所有引用点
- 编译、测试
以对象取代数据值 Replace Data Value with Object
- 动机
- 简单的数据值字段变得复杂,需要相关的函数时,考虑将数据值变成对象。
- 做法
- 为待替换的数值新建一个类,在类中声明一个final字段,其类型和源类中的待替换数值类型一样。然后,在新类中加入这个字段的取值函数,再加上一个接受此字段为参数的构造函数。
- 编译
- 将源类中的待替换字段的类型改为前面新建的类。
- 修改源类中该字段的取值函数,令它调用新类的取值函数。
- 如果源类构造函数中用到了这个待替换字段,我们就修改构造函数,令它改用新类构造函数对字段进行赋值动作。
- 修改源类中待替换字段的设值函数,令它为新类创建一个实例。
- 编译、测试
- 现在,你有可能需要对新类使用Change Value to Reference。
将值对象改为引用对象 Change Value to Reference
- 动机
- 值对象对使用者来说,它不关心系统有没有该对象的副本,但是如果你希望给这个对象加入一些可修改的数据,并且其他引用这个对象的地方会被影响到,就应该考虑使用引用对象。这里的值对象,可以理解为业务逻辑上相同的对象,但是内存地址并不相同。
- 做法
- 使用Replace Constructor with Factory Method
- 编译、测试
- 决定由什么对象负责提供访问新对象的途径
- 可能是一个静态字典或者一个注册表对象
- 你也可以使用多个对象作为新对象的访问点
- 决定这些引用对象应该提前创建好,或是应该动态创建
- 如果是预先创建好,而你必须从内存中将它们取出来,那么就得确保它们在被需要的时候能够被及时加载
- 修改工厂函数,令它返回引用对象
- 如果对象是提前创建好的,你就需要考虑:万一有人索求其实并不存在的对象,要如何处理错误
- 你可能希望对工厂函数使用Rename Method,使其传达这样的信息:它返回的是一个既存对象。
- 编译测试
将引用对象改为值对象 Change Reference to Value
- 动机
- 值对象有个重要的特性:它应该是不可变的。任何时刻,只要你调用同一对象的同一查询函数,都应该得到相同的结果。
- 做法
- 检查重构目标是否为不可变对象,或者是否可修改为不可变对象
- 建立equals() 和 hashCode()
- 编译、测试
- 考虑是否删除工厂函数,并将构造函数声明为public
以对象取代数组 Replace Array with Object
- 动机
- 用数组来存储含有不同意义的数值会让人困惑
- 做法
- 新建一个类表示数组所拥有的信息,并在其中以一个public字段保存原先的数组。
- 修改数组的所有用户,让它们改用新类的实例
- 编译、测试
- 逐一为数组元素添加取值、设值函数,根据元素用途为这些访问函数命名。修改客户端代码,让它们通过访问函数取用数组内元素。每次修改后,编译并测试。
- 当所有对数组的直接访问都转而调用访问函数后,将新类中保存该数组的字段声明为private
- 编译
- 对于数组内的每一个元素,在新类中创建一个类型相当的字段。修改该元素的访问函数,令它改用上述新建的字段。
- 每修改一个元素,编译并测试
- 数组每个元素都有相应的字段后,删除该数组。
将单向关联改为双向关联
- 动机
- 两个类一开始是单向的引用关系,但是随着时间推移,被引用者需要得到其引用者以便进行某些处理。此时可以考虑建立“反向指针”
- 做法
- 在被引用类中增加一个字段,用于保存反向指针。
- 决定由哪个类-引用端还是被引用端–控制关联关系。
- 在被控端建立一个辅助函数,其命名应该清楚指出它的有限用途。
- 如果既有的修改函数在控制端,让它负责更新反向指针。
- 如果既有的修改函数在被控端,就在控制端建立一个控制函数,让既有的修改函数调用这个新建的控制函数。
将双向关联改为单向关联 Change Bidirectional Association to Unidirectional
- 动机
- 大量的双向连接容易造成“僵尸对象”,某个对象本来已经该死亡了,却仍然保留在系统中,因为它的引用还没有完全清除。
- 双向连接迫使两个类之间有了依赖,对其中任何一个类的修改,都可能引发另一个类的变化。如果两个类位于不同的包,这种依赖就是包与包之间的依赖。
- 非必要的时候不要使用双向关联。
- 做法
- 找出保存你想去除的指针的字段,检查它的每一个用户,判断是否可以去除该指针。
- 如果客户使用了取值函数,先运用Self Encapsulate Field将待删除字段自我封装起来,然后使用Substitute Algorithm对付取值函数,令它不再使用该字段。然后编译、测试。
- 如果 客户端未使用取值函数,那就直接修改待删除的字段的所有被引用点:改以其他途径获得该字段所保存的对象。每次修改后,编译并测试。
- 如果已经没有任何函数使用待删除的字段,移除所有对该字段的更新逻辑,然后删除该字段。
- 编译、测试。
以字面常量取代魔法数字
- 动机
- 当你需要修改魔法数时,你需要找到程序中所有的魔法数。这个过程比较痛苦。
- 引入常量并不会带来额外的性能开销,却可以大大提高代码的可读性。
- 做法
- 声明一个常量,令其值为原本的魔法数值。
- 找出这个魔法数的所有引用点。
- 检查是否可以使用这个新声明的常量来替换该魔法数。如果可以,便以此常量替换之。
- 编译。
- 所有魔法数都被替换后,编译并测试。此时整个程序应该运转如常,就像没有做任何修改一样。
封装字段 Encapsulate Field
- 动机
- 面向对象的首要原则之一就是封装,或者成为“数据隐藏”。按照此原则,你觉不应该将数据声明为public,否则其他对象就有可能访问甚至修改这项数据,而拥有该数据的对象却毫无察觉。
- 封装字段的好处在于将数据和行为进行分离,当代码需要发生变化时,修改的地方就几处,而不是散落在各个地方。
- 做法
- 为public字段提供取值/设值函数。
- 找到这个类以外的使用该字段的所有地点。如果客户只是读取字段,就把引用替换为对取值函数的调用;如果客户修改了该字段值,就将此引用点替换为对设值函数的调用。
- 每次修改之后,编译并测试。
- 将字段的所有用户修改完毕之后,把字段声明为private。
- 编译、测试。
封装集合 Encapsulate Collection
- 动机
- 封装集合可以避免用户直接操作对象内保存的集合,并隐藏对象内与用户无关的数据结构。
- 取值函数也不应该返回集合本身,这会让用户可以修改集合但是对集合内容一无所知。
- 不应该为集合提供设值函数,应该提供移除/添加元素的函数。
- 做法
- 加入集合添加/移除元素的函数。
- 将保存集合的字段初始化为一个空集合。
- 编译。
- 找出集合设值函数的所有调用者。你可以修改那个设值函数,让它使用上述新建立的“添加/移除元素”函数;也可以直接修改调用端,改让它们调用上述新建立的函数。
- 编译、测试。
- 找出所有通过取值函数获得集合并修改其内容的函数,逐一修改这些函数,让它们改用添加/移除函数。每次修改后,编译并测试。
- 修改现有取值函数的名字,然后添加一个新的取值函数,使其返回一个枚举。找出旧的取值函数的所有被引用点,将它们改为新的取值函数。
- 如果这一步跨度太大,可以先使用Rename Method修改原取值函数的名称,再建立一个新的取值函数用以返回枚举,最后再修改所有调用者,使其使用心得取值函数。
- 编译、测试。
以类取代类型码 Replace Type Code with Class
- 动机
- 类型码终究还是数值,无法强制使用符号名。类型码容易导致Bug和程序的难以理解。
- 做法
- 为类型码建立一个类
- 该类需要一个记录类型码的字段、一个取值函数。
- 还需要一组静态变量保存允许被创建的实例,并以静态函数根据原本的类型码返回合适的实例。
- 修改源类实现,让它改用上述新建的类
- 编译、测试
- 对于源类中每一个使用类型码的函数,相应建立一个函数,让新函数使用新建的类。
- 逐一修改源类用户,让它们使用新接口
- 每修改一个用户,编译并测试
- 删除使用类型码的旧接口,并删除保存旧类型码的静态变量
- 编译、测试。
- 为类型码建立一个类
以子类取代类型码 Replace Type Code with Subclasses
- 动机
- 如果类型码会影响到宿主类的行为,考虑使用多态技术来处理变化行为。
- 当出现switch、if-else结构时,它们检查类型码值,并根据类型码值执行不同的动作。在这种情况下,你应该以Replace Conditional with Polymorphism进行重构。但是为了进行这样的重构,首先应该将类型码替换为可拥有多态行为的继承体系。该体系应该以类型码的宿主类为基类,并针对每一个类型码各建立一个子类。
- 为了实现这样的继承体系,最简单的方法就是Replace Type Code with Subclasses:以类型码宿主类作为基类,针对每种类型码建立子类。
- 但是,如果类型码在对象创建后会发生改变、或者类型码宿主类已经有了子类,那么应该使用Replace Type Code with State/Strategy。
- 使用以子类取代类型码的另外一个原因是宿主类中出现了“只与具备特定类型码之对象相关”的特性。完成本项重构以后,可以使用Push Down Method和Push Down Field将这些特性推入合适的子类,以彰显它们只与特定的子类相关的事实。
- 以子类取代类型码的好处在于,它把“对不同行为的了解”从类用户那儿转移到类自身。如果需要再加入新的变化,只需要添加一个子类就可以了。如果没有多态机制,就必须找到所有的条件表达式,并逐一修改它们。
- 做法
- 使用Self Encapsulate Field将类型码自我封装起来。
- 如果类型码被传递给构造函数,就需要将构造函数换成工厂函数。
- 为类型码的每一个数值建立一个相应的子类。在每个子类中覆写类型码的取值函数,使其返回相应的类型码值。
- 这个值被硬编码于return语句中,这看起来不太好,但也只是权宜之计,当所有case子句都被替换后,问题就会解决。
- 每建立一个新的子类,编译并测试。
- 从超类中删除保存类型码的字段,将类型码的访问函数声明为抽象函数。
- 编译、测试。
- 使用Self Encapsulate Field将类型码自我封装起来。
以State/Strategy取代类型码
- 动机
- 类型码会影响到宿主类的行为,且类型码在对象生命周期内可能会发生变化/无法通过继承实现子类,可以使用本项重构。
- State和Strategy模式非常相似。如果你打算完成本项重构以后再以Replace Conditional with Polymorphism简化一个算法,那么使用Strategy模式比较合适;如果你打算搬迁于状态相关的数据,而且你把新建对象视为一种变迁状态,就应该选择使用State模式。
- 做法
- 使用Self Encapsulate Field将类型码自我封装起来。
- 新建一个类,根据类型码的用途为它命名。这就是一个状态对象。
- 为这个类添加子类,每个子类对应一种类型码。
- 比起注意添加,一次性加入所有必要的子类会更简单。
- 在超类中建立一个抽象的查询函数,用以返回类型码。在每个子类中覆写该函数,返回确切的类型码。
- 编译。
- 在源类中建立一个字段,用以保存信件的状态对象。
- 调整源类中负责查询类型码的函数,将查询动作转发给状态对象。
- 调整源类中为类型码设值的函数,将一个恰当的状态对象子类赋值给“保存状态对象”的那个字段。
- 编译、测试。
以字段取代子类 Replace Subclass with Fields
- 动机
- 建立子类的目的,是为了增加新特性或变化其行为,有一种变化行为称为“常量函数”。它们会返回一个硬编码的值。你可以让不同子类中同一个访问函数返回不同的值,你可以在超类中将访问函数声明为抽象函数,并在不同的子类中让它返回不同的值。
- 尽管常量函数有其用途,但若子类中只有常量函数,实在没有足够的存在价值。你可以在超类中设计一个与常量函数返回值相应字段,从而完全去除这样的子类。
- 做法
- 对所有子类使用Replace Constructor with Factory Method。
- 如果有任何代码直接引用子类,令它改而引用超类。
- 针对每个常量函数,在超类中声明一个final字段。
- 为超类声明一个protected构造函数,用以初始化这些新增字段。
- 新建或修改子类构造函数,使它调用超类的新增构造函数。
- 编译,测试。
- 在超类中实现所有常量函数,令它们返回相应的字段,然后将该函数从子类中删掉。
- 每删除一个常量函数,编译并测试。
- 子类中所有的常量函数都被删除后,使用Inline Method将子类的构造函数内联到超类的工厂函数中。
- 编译,测试。
- 将子类删掉。
- 编译,测试。
- 重复内联构造函数、删除子类过程,直到所有子类都被删除。
简化条件表达式
条件逻辑有可能十分复杂,因此本章提供一些重构手法,专门用来简化它们。其中一项核心重构就是Decompose Conditional,可将一个复杂的条件逻辑分成若干小块。这项重构很重要,因为它使得“分支逻辑”和“操作细节”分离。
本章的其余重构手法可用以处理另一些重要问题;如果你发现代码中有多处测试有相同的结果,应该实施Consolidate Conditional Expression;如果条件代码中有任何重复,可以运用Consolidate Duplicate Conditional Fragment将重复成分去掉。
较之于过程化程序而言,面向对象程序的条件表达式通常比较少,这是因为很多条件行为都被多态机制处理掉了。多态之所以更好,是因为调用者无需了解条件行为的细节,因此条件的拓展更容易。所以面向对象程序中很少出现switch语句。一旦出现,就应该考虑运用Replace Conditional with Polymorphism将它替换为多态。多态还有一种十分有用但鲜为人知的用途,通过Introduce Null Object去除对于Null值的检验。
分解条件表达式 Decompose Conditional
- 动机
- 复杂的条件逻辑会使得程序难以阅读和理解。
- 通过提取条件逻辑为独立函数,可以更清楚的表达意图和每个分支的作用。
- 做法
- 将if段落提炼出来,构成一个独立函数。
- 将then段落和else段落都提炼出来,各自构成一个独立函数。
合并条件表达式 Consolidate Conditional Expression
- 动机
- 如果有一连串的检查,检查条件不同但是结果相同,就应该使用逻辑或和逻辑与将它们合并为一个条件表达式。
- 检查条件提取到一个独立函数对于厘清代码有着重要的意义。
- 做法
- 确定这些条件语句都没有副作用。
- 使用适当的逻辑操作符,将一系列的相关表达式合并为一个。
- 编译、测试。
- 对合并后的条件表达式实施Extract Method。
合并重复的条件片段 Consolidate Duplicate Conditional Fragments
- 动机
- 如果一组条件表达式的所有分支都执行了相同的某段代码,该代码则应该搬移到条件表达式外面。
- 做法
- 鉴别出执行方式不随条件变化而变化的代码
- 如果这些共通的代码位于条件表达式的起点,就将它移到条件表达式之前。
- 如果这些共通代码位于条件表达式尾端,就将它们移到条件表达式之后。
- 如果这些代码位于条件表达式中段,则分析共通代码之前或者之后是否改变了什么东西,如果确实有所改变,则需要将共通代码向前或者向后移动,再按照前面的方式处理。
- 如果共通代码不止一句,考虑使用Extract Method将共通代码提取到独立函数后再处理。
移除控制标记 Remove Control Flag
在一些布尔表达式中,某个变量带有控制标记的作用。以break或者return语句取代控制标记。
- 动机
- 控制标记会使得条件语句不清晰,不利于理解。使用return/break/continue去除控制标记可以提高条件表达式的可读性。
- 做法
- 找出让你跳出这段逻辑的控制标记值
- 找出对标记变量赋值的语句,代以break或者continue语句
- 每次替换后,编译并测试
在未能提供break或者continue的语句中,可以使用以下方法: - 运用Extract Method将整段逻辑提取到独立函数中
- 找出让你跳出这单逻辑的控制标记值
- 找出对标记量赋值的语句,代以恰当的return语句
- 每次替换后,编译并测试
以卫语句取代嵌套条件表达式 Replace Nested Conditional with Guard Clauses
- 动机
- 条件表达式通常有两种形式:第一种是所有分支都属于正常行为;第二种是,条件表达式提供的答案中只有一种是正常行为,其他都是不常见的情况。
- 上述两类条件表达式有不同的用途,这一点应该通过代码表现出来。如果条件分支都是正常行为,就应该使用if…else…的条件表达式;如果某个条件极其罕见,就应该单独检查该条件,并在该条件为真时立刻从函数中返回。这样的单独检查常常被称为“卫语句”。
- 以卫语句取代嵌套条件表达式的精髓在于:给某一个分支以特别的重视。如果使用if-then-else结构,你对if分支和else分支的重视时同等的。这样的代码结构传递给阅读者的消息就是:各个分支有同样的重要性。卫语句则能够告诉读者,这种情况很罕见,如果它真的发生了,请做一些必要的整理工作,然后退出。
- 做法
- 对于每个检查,放进一个卫语句。
- 卫语句要不就从函数中返回,要不就抛出一个异常。
- 每次讲条件检查替换为卫语句后,编译并测试。
- 对于每个检查,放进一个卫语句。
以多态取代条件表达式 Replace Conditional with Polymorphism
- 动机
- 多态能给你带来很多好处。如果同一组条件表达式在程序许多地点出现,那么使用多态的收益是最大的。使用条件表达式时,如果你想添加一种新类型,就必须查找并更新所有条件表达式。但如果改用多态,就只需要建立一个新的子类,并在其中提供适当的函数即可。类的用户不需要了解这个子类,这就大大降低了系统各部分之间的依赖,使系统升级更加容易。
- 做法
- 使用Replace Conditional with Polymorphism之前,首先必须要有一个继承结构。你可能已经通过先前的重构得到了这一结构。如果还没有,现在就需要建立它。
- 要建立继承结构,有两种选择:Replace Type Code with Subclass和Replace Type Code with State/Strategy。前一种做法比较简单,因此应该尽可能使用它。但是如果你需要在对象创建好之后修改类型码,就不能使用继承手法,只能使用State/Strategy。此外,如果若干switch语句针对的是同一个类型码,你只需要针对这个类型码建立一个继承结构就行了。
- 如果要处理的条件表达式时一个更大的函数中的一部分,首先对条件表达式进行分析,然后使用Extract Method将它提炼到一个独立函数去。
- 如果有必要,使用Move Method将条件表达式房知道继承结构的顶端。
- 任选一个子类,在其中建立一个函数,使之覆写超类中容纳条件表达式的那个函数,将与该子类相关的条件表达式分支复制到新建函数中,并对它进行适当调整。
- 编译、测试
- 在超类中删掉条件表达式内被复制了的分支
- 编译、测试
- 针对条件表达式的每个分支,重复上述过程,直到所有分支都被转移到子类内的函数为止。
- 将超类之中容纳条件表达式的函数声明为抽象函数。
引入Null对象 Introduce Null Object
- 动机
- 多态的最根本的好处在于:你不必再向对象询问你是什么类型,而后根据得到的答案调用对象的某个行为。你只管调用该行为就是,其他的一切多态机智会为你安排妥当。当某个字段内容是null,多态可以扮演另外一个较不直观的作用。
- 做法
- 为源类建立一个子类,使其行为就像是源类的null版本。在源类和null子类中都加上isNull()函数,前者的isNull()应该返回false,后者的isNull()应该返回true。
- 或者可以建立一个nullable接口,将isNull()函数放在其中,让源类实现这个接口。
- 另外,你可以可以创建一个测试接口,专门用于检查对象是否为null。
- 编译
- 找出所有索求源对象却获取了一个null的地方,修改这些地方,使它们改而获得一个空对象。
- 找出所有将源对象与null做比较的地方。修改这些地方,使他们调用isNull()函数。
- 你可以在不该出现null的地方放上一些断言,确保null的确不再出现。
- 编译、测试
- 找出这样的程序点,如果对象不是null,做A动作,否则做B动作。
- 对于上述每个程序点,在null类中覆写A动作,使其行为和B动作相同。
- 使用上述呗覆写的动作,然后删除对象是否等于null的条件测试,编译并测试。
- 为源类建立一个子类,使其行为就像是源类的null版本。在源类和null子类中都加上isNull()函数,前者的isNull()应该返回false,后者的isNull()应该返回true。