《重构2》- 如何识别代码坏味道
一、神秘命名
整洁代码最重要的一环 就是好的名字,所以我们会深思熟虑如何给函数、模块、变量和类命名,使它们 能清晰地表明自己的功能和用法。
改名可能是最常用的重构手法,包括改变函数声明、变量改名、字段改名等。很多人经常不愿意给程序元素改名,觉得不值得费这个劲,但好的名字能节省未来用在猜谜上的大把时间。
改名不仅仅是修改名字而已。如果你想不出一个好名字,说明背后很可能潜藏着更深的设计问题。为一个恼人的名字所付出的纠结,常常能推动我们对代码 进行精简。
二、重复代码
如果你在一个以上的地点看到相同的代码结构,那么可以肯定:设法将它们合而为一,程序会变得更好。
-
最单纯的重复代码就是“同一个类的两个函数含有相同的表达式”。这时候你需要做的就是采用提炼函数提炼出重复的代码,然后让这两个地点都调用被提炼出来的那一段代码。
-
如果重复代码只是相似而不是完全相同,请首先尝试用移动语句重组代码顺序,把相似的部分放在一起以便提炼。
-
如果重复的代码段位于同一个超类的不同子类中,可以使用函数上移来避免在两个子类之间互相调用。
三、过长函数
据我们的经验,活得最长、最好的程序,其中的函数都比较短。间接性带来的好处——更好的阐释力、更易于分享、更多的选择——都是由小函数来支持的。
函数越长,就越难理解。在早期的编程语言中,子程序调用需要额外开销,这使得人们不太乐意使用小函数。 现代编程语言几乎已经完全免除了进程内的函数调用开销。
我们遵循这样一条原则:**每当感觉需要以注释来说明点什么的时候,我们就把需要说明的东西写进一个独立函数 中,并以其用途(而非实现手法)命名,我们可以对一组甚至短短一行代码做这件事。**哪怕替换后的函数调用动作比函数自身还长,只要函数名称能够解释其用 途,我们也该毫不犹豫地那么做。关键不在于函数的长度,而在于函数“做什 么”和“如何做”之间的语义距离。
- 百分之九十九的场合里,要把函数变短,只需使用提炼函数。找到函数中适合集中在一起的部分,将它们提炼出来形成一个新函数。
- 如果函数内有大量的参数和临时变量,它们会对你的函数提炼形成阻碍。如果你尝试运用提炼函数,最终就会把许多参数传递给被提炼出来的新函数,导致可读性几乎没有任何提升。此时,你可以经常运用以查询取代临时变量来消除这些临时元素。引入参数对象和保持对象完整则可以将过长的参数列表变得更简洁一些。
- 如果你已经这么做了,仍然有太多临时变量和参数,那就应该使出我们的杀 手锏——以命令取代函数。
- 条件表达式和循环常常也是提炼的信号。你可以使用分解条件表达式处理条件表达式。对于庞大的switch语句,其中的每个分支都应该通过提炼函数变成独立的函数调用。如果有多个switch语句基于同一个条件 进行分支选择,就应该使用以多态取代条件表达式。
- 至于循环,你应该将循环和循环内的代码提炼到一个独立的函数中。如果你发现提炼出的循环很难命名,可能是因为其中做了几件不同的事。如果是这种情况,请勇敢地使用拆分循环将其拆分成各自独立的任务。
四、过长参数列表
刚开始学习编程的时候,老师教我们:把函数所需的所有东西都以参数的形式传递进去。这可以理解,因为除此之外就只能选择全局数据,而全局数据很快就会变成邪恶的东西。但过长的参数列表本身也经常令人迷惑。
-
如果可以向某个参数发起查询而获得另一个参数的值,那么就可以使用以查询取代参数去掉这第二个参数。
-
如果你发现自己正在从现有的数据结构 中抽出很多数据项,就可以考虑使用保持对象完整手法,直接传入原来的数据结构。
-
如果有几项参数总是同时出现,可以用引入参数对象将其合并成一个对象。
-
如果某个参数被用作区分函数行为的标记(flag),可以使用移除标记参数。
五、全局数据
全局数据仍然是最刺鼻的坏味道之一。全局数据的问题在于,从代码库的任何一个角落都可以修改它,而且没有任何机制可以探测出到底哪段代码做出了修改。一次又一次,全局数据造成了那些诡异的bug,而问题的根源却在遥远的别处,想要找到出错的代码难于登天。全局数据最显而易见的形式就是全局变量,但类变量和单例也有这样的问题。
-
首要的防御手段是封装变量,每当我们看到可能被各处的代码污染的数据,这总是我们应对的第一招。你把全局数据用一个函数包装起来,至少你就能看见修改它的地方,并开始控制对它的访问。随后,最好将这个函数(及其封装的数据)搬移到一个类或模块中,只允许模块内的代码使用它,从而尽量控制其作用域。
-
即便只是少量的数据,我们也愿意将它封装起来,这是在软件演进过程中应对变化的关键所在。
六、可变数据
对数据的修改经常导致出乎意料的结果和难以发现的bug。我在一处更新数据,却没有意识到软件中的另一处期望着完全不同的数据,于是一个功能失效了,如果故障只在很罕见的情况下发生,要找出故障原因就会更加困难。
因此, 有一整个软件开发流派——函数式编程——完全建立在“数据永不改变”的概念基础上:如果要更新一个数据结构,就返回一份新的数据副本,旧的数据仍保持不变。不过这样的编程语言仍然相对小众,大多数程序员使用的编程语言还是允许修改变量值的。即便如此,我们也不应该忽视不可变性带来的优势——仍然有很多办法可以用于约束对数据的更新,降低其风险。
- 可以用封装变量来确保所有数据更新操作都通过很少几个函数来进行,使其更容易监控和演进。
- 如果一个变量在不同时候被用于存储不同的东西, 可以使用拆分变量将其拆分为各自不同用途的变量,从而避免危险的更 新操作。
- 使用移动语句和提炼函数尽量把逻辑从处理更新操作的代码中搬移出来,将没有副作用的代码与执行数据更新操作的代码分开。
- 设计API时,可以使用将查询函数和修改函数分离确保调用者不会调到有副作用的代码,除非他们真的需要更新数据。
- 我们还乐于尽早使用移除设值函数——有时只是把设值函数的使用者找出来看看,就能帮我们发现缩小变 量作用域的机会。
- 如果可变数据的值能在其他地方计算出来,这就是一个特别刺鼻的坏味道。它不仅会造成困扰、bug和加班,而且毫无必要。消除这种坏味道的办法很简单,使用以查询取代派生变量即可。
- 如果变量作用域只有几行代码,即使其中的数据可变,也不是什么大问题; 但随着变量作用域的扩展,风险也随之增大。可以用函数组合成类或者函数组合成变换来限制需要对变量进行修改的代码量。如果一个变量在其内部结构中包含了数据,通常最好不要直接修改其中的数据,而是用将引用对象改为值对象令其直接替换整个数据结构。
七、发散式变化
我们希望软件能够更容易被修改——毕竟软件本来就该是“软”的。一旦需要修改,我们希望能够跳到系统的某一点,只在该处做修改。如果不能做到这一 点,你就嗅出两种紧密相关的刺鼻味道中的一种了。如果某个模块经常因为不同的原因在不同的方向上发生变化,发散式变化就出现了。
当你看着一个类说:“呃,如果新加入一个数据库,我必须修改这3个函数;如果新出现一种金融工具,我必须修改这4个函数。”这就是发散式变化的征兆。数据库交互和金融逻辑处理是两个不同的上下文,将它们分别搬移到各自独立的模块中,能让程序变得更好:每当要对某个上下文做修改时,我们只需要理解这个上下文,而不必操心另一个。“每次只关心一个上下文”这一点一直很重要,在如今这个信息爆炸、脑容量不够用的年代就愈发紧要。当然,往往只有在加入新数据库或新金融工具后,你才能发现这个坏味道。在程序刚开发出来还在随着软件系统的能力不断演进时,上下文边界通常不是那么清晰。
-
如果发生变化的两个方向自然地形成了先后次序(比如说,先从数据库取出数据,再对其进行金融逻辑处理),就可以用拆分阶段将两者分开,两者之间通过一个清晰的数据结构进行沟通。
-
如果两个方向之间有更多的来回调 用,就应该先创建适当的模块,然后用搬移函数把处理逻辑分开。
-
如果函数内部混合了两类处理逻辑,应该先用提炼函数将其分开,然后再做搬移。
-
如果模块是以类的形式定义的,就可以用提炼类来做拆分。
八、霰弹式变化
霰弹式修改类似于发散式变化,但又恰恰相反。如果每遇到某种变化,你都必须在许多不同的类内做出许多小修改,你所面临的坏味道就是霰弹式修改。如果需要修改的代码散布四处,你不但很难找到它们,也很容易错过某个重要的修改。
-
这种情况下,你应该使用搬移函数和搬移字段把所有需要修改的代码放进同一个模块里。
-
如果有很多函数都在操作相似的数据,可以使用函数组合成类。
-
如果有些函数的功能是转化或者充实数据结构,可以使用函数组合成变换。
-
如果一些函数的输出可以组合后提供给一段专门使用这些计算结果的逻辑,这种时候常常用得上拆分阶段。
面对霰弹式修改,一个常用的策略就是使用与内联(inline)相关的重构—— 如内联函数或是内联类——把本不该分散的逻辑拽回一处。完成内联之后,你可能会闻到过长函数或者过大的类的味道,不过你总可以用与提炼 相关的重构手法将其拆解成更合理的小块。
用一个连贯的故事总结这两种坏味道:初来乍到,3个不同部门的主管都可以给我下工作指令,能让我变化的点很发散。经过十年努力,我开创了的3家公司,每做一次体制改革,我需要在三家公司都执行一遍,引发大范围修改。
九、依恋情节
所谓模块化,就是力求将代码分出区域,最大化区域内部的交互、最小化跨区域的交互。但有时你会发现,一个函数跟另一个模块中的函数或者数据交流格外频繁,远胜于在自己所处模块内部的交流,这就是依恋情结的典型情况。
-
无数次经验里,我们看到某个函数为了计算某个值,从另一个对象那儿调用几乎半打的取值函数。疗法显而易见:这个函数想跟这些数据待在一起,那就使用搬移函数把它移过去。
-
有时候,函数中只有一部分受这种依恋之苦,这时候应该使用提炼函数把这一部分提炼到独立的函数中,再使用搬移函数带它去它的梦想家园。
-
当然,并非所有情况都这么简单。一个函数往往会用到几个模块的功能,那么它究竟该被置于何处呢?我们的原则是:判断哪个模块拥有的此函数使用的数据最多,然后就把这个函数和那些数据摆在一起。 如果先以提炼函数将这个函数分解为数个较小的函数并分别置放于不同地点,上述步骤也就比较容易完成了。
-
策略 (Strategy)模式和访问者(Visitor)模式是为了对抗发散式变化这一 坏味道,但也能解决依恋情结问题,最根本的原则是:将总是一起变化的东西放在一块儿。 数据和引用这些数据的行为总是一起变化的,但也有例外。如果例外出现,我们就搬移那些行为,保持变化只在一地发生。
十、数据泥团
数据项就像小孩子,喜欢成群结队地待在一块儿。你常常可以在很多地方看到相同的三四项数据:两个类中相同的字段、许多函数签名中相同的参数。这些总是绑在一起出现的数据真应该拥有属于它们自己的对象。
首先请找出这些数据以字段形式出现的地方,运用提炼类将它们提炼到一个独立对象中。然后将注意力转移到函数签名上,运用引入参数对象或保持对象完整为它瘦身。这么做的直接好处是可以将很多参数列表缩短,简化函数调用。是的,不必在意数据泥团只用上新对象的一部分字段,只要以新对象取代两 个(或更多)字段,就值得这么做。
十一、基本类型偏执
大多数编程环境都大量使用基本类型,即整数、浮点数和字符串等。一些库会引入一些小对象,如日期。但我们发现一个很有趣的现象:很多程序员不愿意创建对自己的问题域有用的基本类型,如钱、坐标、范围等。于是,我们看到了把钱当作普通数字来计算的情况、计算物理量时无视单位(如把英寸与毫米相加)的情况以及大量类似if (a < upper && a > lower)这样的代码。
- 可以运用以对象取代基本类型将原本单独存在的数据值替换为对象,从而走出传统的洞窟,进入炙手可热的对象世界。
- 如果想要替换的数据值是控制条件行为的类型码,则可以运用以子类取代类型码加上以多态取代条件表达式的组合将它换掉。
- 如果你有一组总是同时出现的基本类型数据,这就是数据泥团的征兆,应该运用提炼类和引入参数对象来处理。
十二、重复的switch
如果你跟真正的面向对象布道者交谈,他们很快就会谈到switch语句的邪恶。在他们看来,任何switch语句都应该用以多态取代条件表达式消除掉。我们甚至还听过这样的观点:所有条件逻辑都应该用多态取代,绝大多数if 语句都应该被扫进历史的垃圾桶。
如今的程序员已经更多地使用多态,switch语句也不再像15年前那样有害无益,很多语言支持更复杂的switch语句,而不只是根据基本类型值来做条件判断。因此,我们现在更关注重复的switch:在不同的地方反复使用同样的switch 逻辑(可能是以switch/case语句的形式,也可能是以连续的if/else语句的形 式)。重复的switch的问题在于:每当你想增加一个选择分支时,必须找到所有的switch,并逐一更新。多态给了我们对抗这种黑暗力量的武器,使我们得到更优雅的代码库。
十三、循环语句
从最早的编程语言开始,循环就一直是程序设计的核心要素。但我们感觉如今循环已经有点儿过时,就像喇叭裤和植绒壁纸那样。如今,函数作为一等公民已经得到了广泛的支持,因此我们可以使用以管道取代循环来让这些老古董退休。我们发现,管道操作(如filter和map)可以帮助我们更快地看清被处理的元素以及处理它们的动作。
十四、冗赘的元素
程序元素(如类和函数)能给代码增加结构,从而支持变化、促进复用或者哪怕只是提供更好的名字也好,但有时我们真的不需要这层额外的结构。
可能有这样一个函数,它的名字就跟实现代码看起来一模一样;也可能有这样一个类,根本就是一个简单的函数。这可能是因为,起初在编写这个函数时,程序员也许期望它将来有一天会变大、变复杂,但那一天从未到来;也可能是因为,这个类原本是有用的,但随着重构的进行越变越小,最后只剩了一个函数。不论上述哪 一种原因,请让这样的程序元素庄严赴义吧。通常你只需要使用内联函数或是内联类。如果这个类处于一个继承体系中,可以使用折叠继承体系。
十五、夸夸其谈通用性
这个令我们十分敏感的坏味道,当有人说“噢,我想我们总有一天需要做这事”,并因而企图以各式各样的钩子和特殊情况来处理一些非必要的事情,这种坏味道就出现了。这么做的结果往往造成系统更难理解和维护。如果所有装置都会被用到,就值得那么做;如果用不到,就不值得。用不上的装置只会挡你的路,所以,把它搬开吧。
- 如果你的某个抽象类其实没有太大作用,请运用折叠继承体系。不必要的委托可运用内联函数和内联类除掉。
- 如果函数的某些参数未被用上,可以用改变函数声明去掉这些参数。
- 如果有并非真正需要、 只是为不知远在何处的将来而塞进去的参数,也应该用改变函数声明去掉。
- 如果函数或类的唯一用户是测试用例,这就飘出了坏味道“夸夸其谈通用性”。如果你发现这样的函数或类,可以先删掉测试用例,然后使用移除死代码。
十六、临时字段
有时你会看到这样的类:其内部某个字段仅为某种特定情况而设。这样的代码让人不易理解,因为你通常认为对象在所有时候都需要它的所有字段。在字段未被使用的情况下猜测当初设置它的目的,会让你发疯。
请使用提炼类给这个可怜的孤儿创造一个家,然后用搬移函数把所有和这些字段相关的代码都放进这个新家。也许你还可以使用引入特例在“变量不合法”的情况下创建一个替代对象,从而避免写出条件式代码。
十七、过长的消息链
如果你看到用户向一个对象请求另一个对象,然后再向后者请求另一个对象,然后再请求另一个对象……这就是消息链。在实际代码中你看到的可能是一长串取值函数或一长串临时变量。采取这种方式,意味客户端代码将与查找过程中的导航结构紧密耦合。一旦对象间的关系发生任何变化,客户端就不得不做出相应修改。
这时候应该使用隐藏委托关系。你可以在消息链的不同位置采用这种重构手法。理论上,你可以重构消息链上的所有对象,但这么做就会把所有中间对象都变成“中间人”。
通常更好的选择是:先观察消息链最终得到的对象是用来干什么的,看看能否以提炼函数把使用该对象的代码提炼到一个独立的函数中,再运用搬移函数把这个函数推入消息链。如果还有许多客户端代码需要访问链上的其他对象,同样添加一个函数来完成此事。
但并不是任何函数链都视为坏东西。
十八、中间人
对象的基本特征之一就是封装——对外部世界隐藏其内部细节。封装往往伴随着委托。比如,你问主管是否有时间参加一个会议,他就把这个消息“委托”给 他的记事簿,然后才能回答你。很好,你没必要知道这位主管到底使用传统记事簿还是使用电子记事簿抑或是秘书来记录自己的约会。
-
但是人们可能过度运用委托。你也许会看到某个类的接口有一半的函数都委托给其他类,这样就是过度运用。这时应该使用移除中间人,直接和真正负责的对象打交道。
-
如果这样“不干实事”的函数只有少数几个,可以运用内联函数把它们放进调用端。
-
如果这些中间人还有其他行为,可以运用以委托取代超类或者以委托取代子类把它变成真正的对象,这样你既可以扩展原对象的行为,又不必负担那么多的委托动作。
十九、内幕交易
软件开发者喜欢在模块之间建起高墙,极其反感在模块之间大量交换数据,因为这会增加模块间的耦合。在实际情况里,一定的数据交换不可避免,但我们必须尽量减少这种情况,并把这种交换都放到明面上来。
- 如果两个模块总是在咖啡机旁边窃窃私语,就应该用搬移函数和搬移字段减少它们的私下交流。
- 如果两个模块有共同的兴趣,可以尝试再新建一个模块,把这些共用的数据放在一个管理良好的地方;或者用隐藏委托关系,把另一个模块变成两者的中介。
继承常会造成密谋,因为子类对超类的了解总是超过后者的主观愿望。如果你觉得该让这个孩子独立生活了,请运用以委托取代子类或以委托取代超类让它离开继承体系。
二十、过大的类
如果想利用单个类做太多事情,其内往往就会出现太多字段。一旦如此,重复代码也就接踵而至了。
你可以运用提炼类将几个变量一起提炼至新类内。提炼时应该选择类内彼此相关的变量,将它们放在一起。例如,depositAmount和depositCurrency可能应该隶属同一个类。通常,如果类内的数个变量有着相同的前缀或后缀,这就意味着有机会把它们提炼到某个组件内。如果这个组件适合作为一个子类,你会发现提炼超类或者以子类取代类型码(其实就是提炼子类)往往比较简单。
有时候类并非在所有时刻都使用所有字段。若果真如此,你或许可以进行多次提炼。
和“太多实例变量”一样,类内如果有太多代码,也是代码重复、混乱并最终走向死亡的源头。最简单的解决方案是把多余的东西消弭于类内部。如果有5个“百行函数”,它们之中很多代码都相同,那么或许你可以把它们变成5个“十行函数”和10个提炼出来的“双行函数”。
观察一个大类的使用者,经常能找到如何拆分类的线索。看看使用者是否只用到了这个类所有功能的一个子集,每个这样的子集都可能拆分成一个独立的 类。一旦识别出一个合适的功能子集,就试用提炼类、提炼超类或是以子类取代类型码将其拆分出来。
二十一、异曲同工的类
使用类的好处之一就在于可以替换:今天用这个类,未来可以换成用另一个 类。但只有当两个类的接口一致时,才能做这种替换。可以用改变函数声明将函数签名变得一致。但这往往还不够,请反复运用搬移函数将某些行为移入类中,直到两者的协议一致为止。如果搬移过程造成了重复代码, 或许可运用提炼超类补偿一下。
二十二、纯数据类
所谓纯数据类是指:它们拥有一些字段,以及用于访问(读写)这些字段的函数,除此之外一无长物。这样的类只是一种不会说话的数据容器,它们几乎一 定被其他类过分细琐地操控着。这些类早期可能拥有public字段,若果真如此,你应该在别人注意到它们之前,立刻运用封装记录将它们封装起来。对于那些不该被其他类修改的字段,请运用移除设值函数。
然后,找出这些取值/设值函数被其他类调用的地点。尝试以搬移函数把那些调用行为搬移到纯数据类里来。如果无法搬移整个函数,就运用提炼函数产生一个可被搬移的函数。
纯数据类常常意味着行为被放在了错误的地方。也就是说,只要把处理数据的行为从客户端搬移到纯数据类里来,就能使情况大为改观。但也有例外情况, 一个最好的例外情况就是,纯数据记录对象被用作函数调用的返回结果,比如使用拆分阶段之后得到的中转数据结构就是这种情况。这种结果数据对象有一个关键的特征:它是不可修改的(至少在拆分阶段的实际操作中是 这样)。不可修改的字段无须封装,使用者可以直接通过字段取得数据,无须通过取值函数。
二十三、被拒绝的馈赠
子类应该继承超类的函数和数据。但如果它们不想或不需要继承,又该怎么办呢?它们得到所有礼物,却只从中挑选几样来玩!
按传统说法,这就意味着继承体系设计错误。你需要为这个子类新建一个兄弟类,再运用函数下移和字段下移把所有用不到的函数下推给那 个兄弟。这样一来,超类就只持有所有子类共享的东西。你常常会听到这样的建 议:所有超类都应该是抽象(abstract)的。
既然使用“传统说法”这个略带贬义的词,你就可以猜到,我们不建议你这么 做,起码不建议你每次都这么做。我们经常利用继承来复用一些行为,并发现这可以很好地应用于日常工作。 这也是一种坏味道,我们不否认,但气味通常并不强烈,所以我们说,如果“被拒绝的遗赠”正在引起困惑和问题,请遵循传统忠告。但不必认为你每次都得那么做。十有八九这种坏味道很淡,不值得理睬。
如果子类复用了超类的行为(实现),却又不愿意支持超类的接口,“被拒绝的遗赠”的坏味道就会变得很浓烈。拒绝继承超类的实现,这一点我们不介意;但如果拒绝支持超类的接口,这就难以接受了。既然不愿意支持超类的接 口,就不要虚情假意地糊弄继承体系,应该运用以委托取代子类或者以委托取代超类彻底划清界限。
二十四、注释
别担心,我们并不是说你不该写注释。从嗅觉上说,注释不但不是一种坏味道,事实上它们还是一种香味呢。我们之所以要在这里提到注释,是因为人们常把它当作“除臭剂”来使用。常常会有这样的情况:你看到一段代码有着长长的注释,然后发现,这些注释之所以存在乃是因为代码很糟糕。这种情况的发生次数之多,实在令人吃惊。
- 注释可以带我们找到本章先前提到的各种坏味道。找到坏味道后,我们首先应该以各种重构手法把坏味道去除。完成之后我们常常会发现:注释已经变得多余了,因为代码已经清楚地说明了一切。
- 如果你需要注释来解释一块代码做了什么,试试提炼函数;如果函数已经提炼出来,但还是需要注释来解释其行为,试试用改变函数声明 为它改名;如果你需要注释说明某些系统的需求规格,试试引入断言。
- 当你感觉需要撰写注释时,请先尝试重构,试着让所有注释都变得多余。
- 如果你不知道该做什么,这才是注释的良好运用时机。除了用来记述将来的打算之外,注释还可以用来标记你并无十足把握的区域。你可以在注释里写下自己“为什么做某某事”。这类信息可以帮助将来的修改者,尤其是那些健忘的家伙。