# 1 前言
本文接上文 重构学习(三):代码重构的原则 的内容,继续学习《重构:改善既有代码的设计》。
之前的文章对重构的本质和基本原则做一个总结记录,今天这篇文章主要是总结一下"何时重构",即该在时候才需要去重构?什么样的代码需要重构?
作者在这里用了"味道"一词,做了形象的比喻,当在代码中嗅到"坏味道"时,则需要进行重构了。在上一篇文章中,我们说过,随着代码迭代(累计效应),程序的内部设计会逐渐腐败变质,这里所谓的"代码的坏味道"就是由腐败变质散发出来的,由于累计效应,时间越久,味道越大。
这样说来重构就有点类似于定时打扫房间,清除垃圾。接下来我们来看看代码中常见的散发"坏味道"的垃圾。
# 2 神秘命名(Mysterious Name)
我们在写代码时总会花费大量时间来想函数、模块、变量和类的命名,让代码能清晰地表明自己的功能和用法。好的命名能大幅的提高代码的可读性,大多数时候我们只需要通过类名、函数名以及变量名旧可以知道它们具体是干什么的,剩下大量猜谜或阅读源码的时间。
当代码中出现难以理解的命名时,通常我们都需要花功夫去修改,这是最常见也是最常用的重构手段,如果在修改时想不出一个好名字,说明背后很可能潜藏着更深的设计问题,因此改名常常能推动我们对代码进行完善与精简。
# 3 重复代码(Duplicated Code)
完成同样一件事,设计欠佳的程序往往需要更多代码,这常常是因为代码在不同的地方使用完全相同的语句做同样的事,因此改进设计的一个重要方向就是消除重复代码。阅读重复代码时必须加倍仔细,留意它们之间的细微差异。如果要修改重复代码,我们必须找出所有的副本来修改,避免漏改引出更多的问题。
- 最简单得到重复代码就是一个类中有两个函数包含一段相同的代码逻辑,此时我们将这段重复的代码提炼出来作为函数,然后在两个地方调用就好了。
- 如果这段代码只是相似,并不是完全一样,那么可以先尝试重组代码顺序,把相似的部分放在一起方便提炼。
- 如果这段代码在同一个基类的不同子类中,那么可以把这段代码提取到基类里面,避免在两个子类之间相互调用。
# 4 过长函数(Long Function)
函数越长,就越难理解。将又长又臭的函数分解为多个短小精悍的函数能提高代码的可读性,当然,前提是这些小函数都具有良好的命名,能让人快速理解这些函数的功能,不必去看里面写了什么代码。
在平时开发的过程中,我们应该积极地分解函数,遵循原则:每当感觉需要写注释来说明时,就可以考虑把说明的东西写进一个独立函数中,并以其用途命名。
注意:哪怕替换后的函数调用动作比函数自身还长,只要函数名称能够解释它的用途,那么我们也应该毫不犹豫地这么做。
- 大部分场景下,把函数变短只需要拆解并提取新函数就可以了。
- 如果函数内有大量的参数和临时变量,通过传参的方式提炼新函数会导致参数列表过长,这对可读性基本没有提升,此时通常使用"查询"手段代替临时变量,并通过引入参数对象来让参数列表变得更简洁。
- 如果函数中有大量的条件表达式(if else),也可以对它们进行分解。
- 如果函数中有庞大的 switch 语句,其中的每一个分支都应该变成独立的函数调用。
- 如果有多个 switch 语句基于用一个条件判断,那么就应该使用多态取代这些条件表达式。
- 如果函数中存在循环,那么应该将循环和循环内的代码提炼到一个独立的函数中,如果发现提炼出来的循环很难命名,可能是因为其中做了几件不同的事,此时需要拆分循环,让它们各自处理独立的任务。
# 5 过长参数列表(Long ParameterList)
当函数的参数列表过长时,通常会增加理解函数的难度,参数列表往往越简洁越好。
- 如果可以向某个参数发起查询而获取另一个参数,那么就可以使用"查询"手段代替传参。
- 如果发现自己正在从现有的数据结构中抽取出很多数据项,就可以考虑直接传入原来的数据结构,保存对象的完整。
- 如果有几项参数总是同时出现,可以用引入新的参数对象,将它们合并起来。
- 如果某个参数被用作区分函数行为的标记,可以使用移除参数标记。
- 使用类可以有效地缩短参数列表,如果有多个函数有同样的几个参数,引入一个类有很有意义。
# 6 全局数据(Global Data)
在程序中使用全局数据是一个非常危险的行为,主要问题在于,代码中的任何一个地方都可以修改它,并且没有任何机制可以探测出是什么地方修改了它,导致出现一些诡异bug非常难调试。
当然,在一些场景特殊的场景下,我们有时必须使用全局变量,比如AWTK中的 window_manager、image_manager、assets_manager 等一系列全局管理器。只能说在开发过程中,我们要尽量避免使用全局变量,如果使用可以通过以下手段来降低它的危险性:
- 封装全局变量,提供对外的get/set接口,外部获取或修改这个全局变量时需要调用接口实现,这样至少我们能知道什么地方修改了它。
- 封装好的全局变量 get/set 接口最好放到一个类或模块中,只允许模块内部的代码调用它,从而尽量控制它的作用域。
如果一个全局变量能保证在程序启动后就不会再被修改,那么它还是相对比较安全的,否则就需要非常注意管理访问以及修改这个全局变量的权限。
# 7 可变数据(Mutable Data)
可变数据在程序开发中经常被用到,出现问题的场景主要是在一处更新数据,却没有意识到程序中的另一处期望着完全不同的数据,从而导致功能异常。因此,对可变数据的访问和修改最好都加以限制,降低风险。
- 可以通过封装变量的方式来确保所有数据更新操作都通过很少几个函数来进行,使其更容易监控。
- 如果一个变量在不同时候被用于存储不同的东西,可以将其拆分为各自不同用途的变量,从而避免危险的更新操作。
- 可以通过移动语句和提炼函数尽量把逻辑从处理更新操作的代码中搬出来,将没有副作用的代码与执行数据更新操作的代码分离开。
- 设计API时,可以将查询函数和修改函数分离,确保调用者不会调到有副作用的代码,即所谓的变量 get/set 函数。
- 如果可变数据的值能在其他地方计算出来,那这个可变数据就毫无必要了,可以使用"查询"手段取代它。
- 随着变量作用域的扩展,风险也会随之增大,此时,可以通过把函数组合成类或者提取转换逻辑来限制修改变量的代码。
- 如果一个变量在其内部结构中包含了数据,通常最好不要直接修改其中的数据,而是将引用对象改为值对象令其直接替换整个数据结构。
# 8 发散式变化(Divergent Change)
我们在开发过程中都希望程序代码更容易被修改,也就所谓的可维护性。一旦需要修改,我们肯定希望能直接跳到系统的某一个点,在一处地方进行修改。
当我们修改某一模块时,发现经常因为不同的原因在不同的方向上发生变化,导致最终要修改的地方非常多,那么这就是发散式变化。出现发散式变化时,我们需要考虑将这些变化隔离开,让每个模块单独变化,它们之间尽量互不影响。每当要对某个上下文做修改时,我们只需要理解这个上下文,而不必操心另一个,遵循原则:每次只关心一个上下文。
- 如果发生变化的两个方向存在先后次序(比如说,先从模块一取出数据,模块二再进行逻辑处理),就通过拆分阶段将两者分开,两者之间通过一个清晰的数据结构进行沟通。
- 如果两个方向之间有更多的来回调用,就应该先创建适当的模块,然后通过搬移函数把处理逻辑分开。
- 如果函数内部混合了两类处理逻辑,应该先提炼函数将其分开,然后再做搬移。
- 如果模块是以类的形式定义的,就可以用提炼类来做拆分。
# 9 霰弹式修改(Shotgun Surgery)
霰弹式修改与发散式修改类似,但又恰恰相反。如果每遇到某种变化,我们都必须再许多不同的类里面做出许多小修改,那么这就是霰弹式修改。需要修改的代码散布四处,不但很难找到它们,也很容易错过某个重要的修改。这是非常糟糕的,表示程序的可维护性非常差,我们需要花更多的时间去阅读代码并修改多处代码,可能仅仅是为了修改一个小功能点。
- 通常遇到这种情况,我们可以通过搬移函数和搬移字段的方式把所有需要修改的代码放进同一个模块里。
- 如果有很多函数都在操作相似的数据,则可以把函数组合成类。
- 如果有些函数的功能是转化或者充实数据结构,则可以将函数组合成变换。
- 如果一些函数的输出可以组合后提供一段专门使用这些计算结果的逻辑,这种时候常常可以使用拆分阶段。
面对霰弹式修改,一个常用的策略就是使用与内联相关的重构——如内联函数或是内联类,把本不该分散的逻辑拽回一处。完成内联之后,可能会产生过长的函数或者过大的类,但这些都是暂时的,我们可以再使用提炼相关的重构手法将它们拆解成更小的更合理的块。
# 10 依恋情结(Feature Envy)
所谓模块化,就是力求将代码分出区域,最大化区域内部的交互、最小化跨区域的交互。但有时你我们会发现,一个函数跟另一个模块中的函数或者数据交流格外频繁,远胜于在自己所处模块内部的交流,这就是依恋情结的典型情况。
- 如果我们看到某个函数为了计算一个值,从另一个对象那边调用一大堆取值函数,那么就可以把这个函数搬过去。
- 如果只是函数中的一部分代码有这个问题,那么可以把它们提炼出来作为独立的函数,再搬到它要去的地方。
当然,并非所有情况都这么简单。一个函数往往会用到几个模块的功能,那么它究竟该被置于何处呢?我们的原则是:判断哪个模块拥有的此函数使用的数据最多,然后就把这个函数和那些数据摆在一起。
备注:如果将这个函数分解为数个较小的函数并分别置放于不同地点,上述步骤会比较容易完成。
此外,有一些设计模式与该规则相反,比如的策略模式和访问者模式,它们的原则是将变化的东西放在一起,方便维护。数据和引用这些数据的行为总是一起变化的,但也有例外。如果例外出现,我们就搬移那些行为,保持变化只在一地发生。
# 11 数据泥团(Data Clumps)
我们常常可以在很多地方看到相同的三四项数据:两个类中相同的字段、许多函数签名中相同的参数。这些总是绑在一起出现的数据应该拥有属于它们自己的对象。
- 首先我们可以找出这些数据以字段形式出现的地方,通过提炼类将它们提炼到一个独立对象中;
- 然后通过引入参数对象或保持对象完整为它瘦身,这样做可以将很多参数列表缩短,简化函数调用。
针对这个问题,作者提倡新建一个类,而不是简单的记录结构,因为一旦拥有新的类,我们就可以着手寻找"依恋情结",这可以帮我们指出能够移至新类中的种种行为,是一种强大的动力。
# 12 基本类型偏执(Primitive Obsession)
大多数编程环境都大量使用基本类型,即整数、浮点数和字符串等。一些库会引入一些小对象,如日期。并且在开发过程中,很多程序员不愿意创建对自己的问题域有用的基本类型,如钱、坐标、范围等。
因此,我们经常会看到把钱当作普通数字来计算的情况、计算物理量时无视单位(如把英寸与毫米相加)的情况等等。
- 我们可以使用对象取代基本类型,将原本单独存在的数据值替换为对象;
- 如果想要替换的数据值是控制条件行为的类型码,则可以运用以子类取代类型码加上以多态取代条件表达式的组合将它换掉。
- 如果有一组总是同时出现的基本类型数据,这就是数据泥团的征兆,应该运用提炼类和引入参数对象来处理。
# 13 重复的switch (Repeated Switches)
重复的 switch 是指在不同的地方反复使用同样的 switch 逻辑(可能是以 switch/case 语句的形式,也可能是以连续的 if/else 语句的形式)。
重复的 switch 的问题在于:每当我们想增加一个选择分支时,必须找到所有的switch,并逐一更新。使用多态取代条件表达式可以有效解决这个问题。
# 14 循环语句(Loops)
从最早的编程语言开始,循环就一直是程序设计的核心要素。但如今已经有点过时了,我们可以使用以管道取代循环,管道操作(如 filter 和 map)可以帮助我们更快地看清被处理的元素以及处理它们的动作。
# 15 冗赘的元素(Lazy Element)
程序元素(如类和函数)能给代码增加结构,从而支持变化、促进复用或者哪怕只是提供更好的名字也好,但有时我们真的不需要这层额外的结构。
通常冗赘的元素的表现形式如下:
- 有这样一个函数,它的名字就跟实现代码看起来一模一样;
- 有这样一个类,根本就是一个简单的函数。
这可能是因为,起初在编写这个函数时,程序员也许期望它将来有一天会变大、变复杂,但那一天从未到来;也可能是因为,这个类原本是有用的,但随着重构的进行越变越小,最后只剩了一个函数。
无论上述哪一种原因,我们都应该把它们精简掉。通常我们只需要使用内联函数或是内联类。如果这个类处于一个继承体系中,可以使用折叠继承体系。
# 16 夸夸其谈通用性(Speculative Generality)
有时,为了所谓的通用性("我想我们总有一天需要做这事"),程序中会出现各式各样的钩子和特殊代码来处理一些不必要的事情,这么做往往造成系统更难理解和维护。
如果所有代码都会被用到,就值得那么做;如果用不到,就不值得。用不上的代码只会降低可读性和可维护性,所以,把它删掉吧。
- 如果某个抽象类其实没有太大作用,可以使用折叠继承体系。
- 不必要的委托可运用内联函数和内联类除掉。
- 如果函数的某些参数未被用上(或者只是为不知远在何处的将来而塞进去的参数),可以改变函数声明去掉这些参数。
- 如果函数或类的唯一用户是测试用例,可以先删掉测试用例,然后移除死代码。
# 17 临时字段(Temporary Field)
有时我们会看到这样的类:其内部某个字段仅为某种特定情况而设。这样的代码让人不易理解,因为我们通常认为对象在所有时候都需要它的所有字段。在字段未被使用的情况下猜测当初设置它的目的,会让人发疯。
- 我们可以使用提炼类把这个临时字段提取出来;
- 然后用搬移函数把所有和这些字段相关的代码都放进这个新类。
- 也许我们还可以使用引入特例,在"变量不合法"的情况下创建一个替代对象,从而避免写出条件式代码。
# 18 过长的消息链(Message Chains)
当我们看到用户向一个对象请求另一个对象,然后再向后者请求另一个对象,然后再请求另一个对象,这就是消息链。
在实际代码中你看到的可能是一长串取值函数或一长串临时变量。采取这种方式,意味客户端代码将与查找过程中的导航结构紧密耦合。一旦对象间的关系发生任何变化,客户端就不得不做出相应修改。此时应该使用隐藏委托关系。我们可以在消息链的不同位置采用这种重构手法。理论上,我们可以重构消息链上的所有对象,但这么做就会把所有中间对象都变成"中间人"。
通常更好的选择是:
- 先观察消息链最终得到的对象是用来干什么的,看看能否把使用该对象的代码提炼到一个独立的函数中;
- 再运用搬移函数把这个函数推入消息链。
- 如果还有许多客户端代码需要访问链上的其他对象,同样添加一个函数来完成此事。
# 19 中间人(Middle Man)
对象的基本特征之一就是封装:对外部世界隐藏其内部细节。封装往往伴随着委托。比如,我问主管是否有时间参加一个会议,他就把这个消息"委托"给他的记事本,然后才能回答我。我不需要知道这个记事本是怎么实现的,也不需要知道它是怎么工作的。
但是开发的时候可能会过度运用委托。如果看到某个类的接口有一半的函数都委托给其他类,这样就是过度运用。这时应该移除中间人,直接和真正负责的对象打交道。
- 如果这样的函数只有少数几个,可以运用内联函数把它们放进调用端。
- 如果这些中间人还有其他行为,可以运用以委托取代超类或者以委托取代子类把它变成真正的对象,这样我们既可以扩展原对象的行为,又不必负担那么多的委托动作。
# 20 内幕交易(Insider Trading)
软件开发者喜欢在模块之间建起高墙,极其反感在模块之间大量交换数据,因为这会增加模块间的耦合。在实际情况里,一定的数据交换不可避免,但我们必须尽量减少这种情况,并把这种交换都放到明面上来。
- 如果两个模块总是在隐晦的地方私下交流,就应该用搬移函数和搬移字段减少它们的私下交流。
- 如果两个模块有共同的兴趣,可以尝试再新建一个模块,把这些共用的数据放在一个管理良好的地方;
- 或者用隐藏委托关系,把另一个模块变成两者的中介。
注意:继承常会造成密谋,因为子类对父类的了解总是超过后者的主观愿望。如果我们觉得该让这个子类独立了,可以以委托取代子类或以委托取代父类让它离开继承体系。
# 21 过大的类(Large Class)
如果想利用单个类做太多事情,其内往往就会出现太多字段。一旦如此,重复代码也就接踵而至了。我们可以将几个变量一起提炼至新类内,提炼时应该选择类内彼此相关的变量,将它们放在一起。
- 如果类内的数个变量有着相同的前缀或后缀,这就意味着有机会把它们提炼到某个组件内。
- 如果这个组件适合作为一个子类,提炼父类或者以子类取代类型码(其实就是提炼子类)往往比较简单。、
- 有时候类并非在所有时刻都使用所有字段。如果真的是这样,我们可以进行多次提炼。
类内如果有太多代码,是代码重复、混乱并最终走向死亡的源头。最简单的解决方案是把多余的东西消弭于类内部。如果有5个"百行函数",它们之中很多代码都相同,那么我们可以把它们变成5个"十行函数"和10个提炼出来的"双行函数"。
观察一个大类的使用者,经常能找到如何拆分类的线索:
- 看看使用者是否只用到了这个类所有功能的一个子集,每个这样的子集都可能拆分成一个独立的类。
- 一旦识别出一个合适的功能子集,就试用提炼类、提炼父类或是以子类取代类型码将其拆分出来。
# 22 异曲同工的类(Alternative Classes with Different Interfaces)
使用类的好处之一就在于可以替换:今天用这个类,未来可以换成用另一个类。但只有当两个类的接口一致时,才能做这种替换。
可以用改变函数声明将函数签名变得一致。但这往往还不够,请反复运用搬移函数将某些行为移入类中,直到两者的协议一致为止。如果搬移过程造成了重复代码,或许可运用提炼父类补偿一下。
# 23 纯数据类(Data Class)
所谓纯数据类是指:它们拥有一些字段,以及用于访问(读写)这些字段的函数,除此之外一无长物(即只有属性,没有行为)。这样的类只是一种不会说话的数据容器,它们几乎一定被其他类过分细琐地操控着。
- 如果类中有 public 字段,应该在别人注意到它们之前,运用封装记录将它们封装起来。
- 对于那些不该被其他类修改的字段,可以运用移除设值函数。
- 找出这些取值/设值函数被其他类调用的地点。尝试把那些调用行为搬移到纯数据类里来。
- 如果无法搬移整个函数,就使用提炼函数产生一个可被搬移的函数。
纯数据类常常意味着行为被放在了错误的地方。也就是说,只要把处理数据的行为从客户端搬移到纯数据类里来,就能使情况大为改观。但也有例外情况,一个最好的例外情况就是,纯数据记录对象被用作函数调用的返回结果,比如使用拆分阶段之后得到的中转数据结构就是这种情况。
这种结果数据对象有一个关键的特征:它是不可修改的(至少在拆分阶段的实际操作中是这样)。不可修改的字段无须封装,使用者可以直接通过字段取得数据,无须通过取值函数。
# 24 被拒绝的遗赠(Refused Bequest)
子类应该继承超类的函数和数据。但如果它们不想或不需要继承,又该怎么办呢?它们得到所有礼物,却只从中挑选几样来玩!
如果子类复用了父类的行为(实现),却又不愿意支持父类的接口,这个坏味道就非常明显了。拒绝继承超类的实现,这一点我们不介意;但如果拒绝支持超类的接口,这就难以接受了。既然不愿意支持超类的接口,就不要虚情假意地糊弄继承体系,应该运用以委托取代子类或者以委托取代父类彻底划清界限。
# 25 注释(Comments)
这里并不是说在写代码的时候不该写注释。相反,适当的注释可以帮助我们快速理解代码,作者这里把注释比作"除臭剂"。
如果我们看到一段代码有着长长的注释,然后发现,这些注释之所以存在乃是因为代码很糟糕。注释可以带我们找到本文先前提到的各种坏味道。找到坏味道后,我们首先应该以各种重构手法把坏味道去除。完成之后我们常常会发现:注释已经变得多余了,因为代码已经清楚地说明了一切。
- 如果我们需要注释来解释一块代码做了什么,可以尝试提炼函数;
- 如果函数已经提炼出来,但还是需要注释来解释其行为,可以尝试用改变函数声明为它改名;
- 如果我们需要注释说明某些系统的需求规格,可以尝试引入断言。
核心原则:当我们感觉需要撰写注释时,请先尝试重构,试着让所有注释都变得多余。