重构的基本原理

重构的起源

很难说清楚重构这个词究竟是什么时候诞生的。优秀的程序员肯定会花时间清理他们的代码的。这是因为他们知道干净的代码比那些复杂混乱的代码更容易修改,而且优秀的程序员知道一次就写出干净的代码这种事情是很少见的。 重构的意义还远不止于此。在本书里我主张重构是软件开发整个流程的一个关键因素。

重构的定义
  • 名词:对软件内部结构的一种修改,在不改变软件外观行为的条件下,使之易于理解和修改。
  • 动词:在不改变软件外观行为的条件下,通过运用一系列的重构技术重新组织软件结构。

什么是重构:重构是改变软件系统的过程,它不会改变代码的外部行为,但是可以改善其内部结构。有了重构,你可以把一个糟糕甚至混乱的设计,逐渐变成设计良好的代码。

重构的理由

我不想说重构是对付所有软件问题的仙丹妙药。这个世界上是“没有最优方法”的。但作为一款很有价值的工具,重构还是可以帮助你很好地把握自己的代码的。

重构可以改进软件的设计

如果没有重构,程序的设计就会逐渐衰败。当代码被不断修改时(不管是为了短期目标所做的改动,还是在没有彻底理解代码设计的情况下所做的修改),代码都会渐渐失去它原有的结构。仅仅通过阅读代码很难看明白它的设计思想。重构就好像是梳理代码,去掉那些不在正确位置上的东西。代码的结构损耗是一个累积的过程。代码的设计越难看明白,那么要它保留下来的难度也就越高,很快它就会被消磨殆尽。定期进行重构可以帮助代码保持应有的形态。 设计糟糕的代码通常需要更多的代码量来完成相同的事情,这是因为很多地方的代码其实都是在重复做同一件事情。因此改进设计中很重要的一个方面就是要消除冗余的代码。其重要性主要体现在未来对代码的修改上。减少代码量未必会让系统运行速度变快,这是因为它很少会对程序的运行轨迹产生什么影响。但是减少代码量却能对代码修改产生很大的影响。代码量越大,就越难把他改正确,因为需要理解的代码实在太多了。你改了这里忘了那里(那里完成基本一样的功能,只是上下文稍有不同而已),因此系统是不会按照你期望的方式运行的。而消除冗余后,你就可以保证代码完成没想功能只需要进行一次,这才是优秀设计的基石。

重构让软件变得易于理解

从很多层面上来讲,编程其实是在和计算机对话。你编写代码告诉计算机要做什么,它反馈给你所要的结果。但是可能还有别人要用你的代码。他们可能会在几个月后阅读你的代码并尝试做出修改。我们很容易忘记这些用户的存在,但其实他们才是最重要的。谁会在乎计算机多花几个时钟周期来运行一段程序?但是如果它花掉一个程序员一个星期的时间来做修改,那就完全是另一回事儿了。但是如果他能看明白你的代码,这个修改可能只需要一个小时就能完成。 重构可以帮助你提高代码的可读性。 应该把所有所有应该记住的东西放在代码里,这样就不用去记住它们了。

重构可以帮助你发现bug

如果对代码进行重构,在深入理解代码功能后,就可以在代码中加入自己新的理解。在弄清楚程序结构的同时,也明确了自己所做的各种假设,到这时再没眼力也能发现bug了。 Kent Beck经常形容自己的一句话:“我不是伟大的程序员,我只是一个有好习惯的好程序员而已”。重构可以帮助我更有效地编写健壮的代码。

重构可以帮助你更快地编程

如果缺乏这一点,刚开始你可能会进展迅速,但是很快糟糕的设计就会让你慢下来。你的时间将会浪费在查找和修复bug上,而不是添加新功能上。由于你需要更多时间来理解系统和查找冗余代码,因此你也就需要更多的时间进行修改工作。新功能可能需要更多的编码,因为你在原始的代码库上打了太多的补丁。 对软件开发速度来说,好的设计是不可或缺的。重构可以帮助你更迅速地开发软件,这是因为它能防止系统设计出现衰败。它甚至还能改善原有设计。

重构时机

几乎在所有的情况下,我都反对专门划出时间来进行重构。在我看来,重构不是那种专门腾出时间就可以做到的事情。重构是见缝插针不断进行的。重构不应是有意为之的,你要重构是因为你要做一些其他的事情,而重构可以帮助你完成它。

事不过三

第一次你想要做什么,直接去做就行了。第二次遇到类似的事情,虽然有点犹豫,但是你还可以重复一次。到第三次的时候,你就应该进行重构了。

在添加功能时重构

通常选择此时进行重构的首要原因是帮助我理解即将修改的代码。每当我要理解一段代码的功能时,我都会问自己是不是可以通过重构来让它更加直观。然后我就会重构。这一方面是为了下次再遇到这段代码的时候能够容易理解,但更重要的是如果我能在前进中把代码梳理清楚,就能从中理解更多东西。 另一个在此进行重构的动因是其设计不能帮助我轻松地添加功能。重构是一个快速流畅的过程。一旦重构完成,添加新功能就会又快速又流畅。

在修复bug时重构

在修复bug时所进行的重构大多都是为了让代码变得更容易理解。很多时候我都会发现这种处理代码的流程有助于发现bug。

在进行代码复审时重构

代码复审有助于在开发团队中传播知识。他们可以帮助更多的人理解一个大型软件系统的方方面面。它们在编写清晰的代码方面也是非常重要的。复审提供了让更多的人提出有用想法的机会。

为什么重构能起作用

程序拥有两种价值:今天可以为你做什么,以及明天可以为你做什么。绝大多数时候,我们在编程的时候只专注于今天要程序做什么。无论是修复bug还是添加新功能,我们都是在把今天的程序变得更有能力,更有价值。

让人难以应付的程序有什么特点?我可以想到四条:

  • 难读的程序也很难修改。
  • 有冗余逻辑的程序很难修改。
  • 在添加新功能时需要修改现有代码的程序很难修改。
  • 有复杂条件逻辑的程序很难修改。

所以,我们希望程序是容易阅读的,所有的逻辑都只在一处指定,修改不会危及现有的行为,以及尽可能简单地表达条件逻辑。 重构这种流程是为一个运行的程序添加价值,不是通过改变它的行为,而是为其提供更多的品质,以保证我们可以持续高速地进行开发。

抽象和重构
  • 共享逻辑。比如一个子方法在两个不同的地方调用,或是一个方法在父类中被所有子类共享。
  • 分开解释意图和实现。为每个类和方法起名都是让你解释自己的意图的机会。类或方法的内部则解释了意图的实现方法。
  • 隔离变化。我在两个不同的地方用到了一个对象。现在我要改变其中一处的行为。如果我修改了对象,那么我就要冒同时修改了两处的风险。所以我选择先继承一个子类,然后在要修改的地方引用它。这样我就可以任意修改子类而不用担心会无意中修改另一处了。
重构和设计

重构的特别之处在于它可以补充设计的不足。很多人认为设计才是最关键的,而编程只是体力活罢了。这就好比说,设计是工程绘图而编码是施工建设。 有一种说法认为重构可以作为先期设计的替代品。在这种场景下,你不需要进行任何设计。你可以按照你第一时间想到的方法来进行编程,让它运行起来,然后再通过重构进行改善。 任何后期对设计的修改都是很昂贵的。因此你要在前期设计上投入更多的时间和精力来避免这样的改变。

代码里的坏味道

重复代码

最常见的重复代码问题是在一个类中有两个包含相同表达式的方法。这时你只需要使用提炼方法把它们置于一处,并在那两个方法里调用这段代码即可。

方法过长

活得长久的对象程序都是那些拥有小方法的程序。 简化小函数理解的关键是良好的命名规则。如果你的方法有一个好名字,那么你就不用去看具体内容了。 每当觉得需要注释什么的时候,就写一个方法来代替注释。它包含了需要注释的代码,并以代码的意图而不是实现方法来命名。由于给出的方法名解释了代码的作用,即使这个方法调用比它实际替换掉的代码更长,我们也会这么做。这里的关键不是方法的长度,而是方法做什么和怎么做之间的语义距离。

类太大

当一个类要做的事情太多的时候,通常的表现形式是出现太多的实例变量。而当一个类有太多实例变量的时候,出现重复代码也就不远了。 你可以使用提炼类把一些变量打包起来。选择变量放到适合它们的组件里去。一个类并不一定会在所有时候使用所有的实例。因此,你可能需要多次使用提炼类,提炼模块,或者提炼子类。

参数列表太长

冗长的参数列表很难理解,它们会变得不一致并难以使用,随着需求数据越来越多,你会不停地改变它们。大多数修改都可以通过传递对象来消除,因为你很可能只需要发出一两条请求就可以获得新数据了。 你可以请求一个你早就知道的对象,这样就可以在一个参数里获取数据了。这时其实你可以使用方法来替换参数。这个对象可以是一个实例变量或是另一个参数。使用保留整个对象将使很多来自同一个对象的数据替换成那个对象本身。如果你有好几个不包含逻辑的数据对象,可以引入参数对象把它们组合到一起,或者引入命名参数来改善流畅性。 这些修改有一个重要的例外,就是当你明确不希望在被调用对象与更大的对象之间建立某种依赖的时候。在那些情况下,解开数据把它们作为参数传递是合理的,但是也请当心随之而来的麻烦。要是参数列表变得太长或是变化得太频繁你就需要重新考虑你的依赖结构了。

发散型变化

我们结构化软件是为了让它易于变化,毕竟软件应该是软的。当我们做修改的时候,我们希望能够干净利落地切入系统某个点并做出修改。 发散型变化出现在一个类经常因为不同的理由以不同的方式修改的时候。如每次得到一个新数据都要修改三个方法。那么这种情况下,把它拆成两个对象应该比较好。这样每个对象只会因为同一类变化而变化。任何处理一类变化的修改都应该在单个类或是模块里发生,而且这个新的类或者模块里的内容应该表达出这一变化。总结起来就是,你界定出为某类特殊原因而发生的所有变化,然后使用提炼类把它们全部提炼出来放到一起。

散弹型修改

散弹型修改和发散型变化类似,但又完全相反。当你每次要做出某类修改的时候,都要对很多不同类做很多小修改。当这些修改散落各处的时候,要找全它们可不容易,一不小心就会漏掉一个重要的修改。 这时你需要使用移动方法和移动字段把所有的修改几种到单个类里。如果现有的类里没有好的候选者,那就创建一个。 发散型变化是指一个类要承受多种类型的变化,而散弹型修改是指一个修改要改变很多类。不管你要怎样安排,(在理想情况下)最好是让常见变化的类之间有一一对应的关系。

特性依赖

对象的意义在于它们是一种讲数据和处理数据的流程打包在一起的技术。有一种很典型的坏味道就是某个方法似乎对另一个(而不是本身所在的)类更感兴趣。其中最常见的就是对数据的依赖。好在解决办法也很浅显,这个方法显然应该属于别的地方,因此你可以使用移动方法来满足它。 当然并非所有的情况都这么直观。通常一个方法会用到多个类的功能,那么究竟它属于哪个类呢?我们采用的准则是看哪个类含有最多的数据就把方法和那些数据放在一起。如果实现使用了提炼方法把这个方法拆成要送去不同位置的小块,这个步骤通常会容易很多。

数据泥团

数据项和孩子一样,总是喜欢成群结队地待在一起。你常常能看到相同的三四个数据项一起出现在很多地方:两个类里的实例变量,以及很多方法签名里的参数等。这种总是同进同出的数据其实应该放到同一个对象里去。对这些实例变量使用提炼类就可以把它们变成一个对象。 一个很好的测试方法是考虑删掉数据值之一:这么做以后,剩下的数据还有意义吗?如果答案是否定的。那么这就是一个很明显的信号告诉你这里应该创建一个对象了。

基本类型偏执

绝大多数编程环境里都有两种数据。记录类型允许你把数据组织成有意义的组群,而基本类型则是你手中的积木。记录总是带有一定的开销:它们可能表示数据库里的表格,又或者当你只需要它们做一两件事的时候,创建起来会很笨拙。 如果有一组应该待在一起的实例变量,那就使用提炼类。如果在参数列表里看到这些基本类型,不妨试一下引入参数对象,如果不喜欢数组,那就用对象来替换数组。

冗赘类

你创建的每个类都有维护和理解的成本。如果一个类滥竽充数,那就应该立刻去掉它。通常这种类原本是物有所值,但是重构之后价值就降低了。又或者这个类原本是为了某些计划但从未实现的修改而加入的。无论如何,让它体面的离开吧。

纯臆测的泛化

纯臆测的泛化是我们非常敏感的一种坏味道。你会听到人们这么说的时候闻到它。例如:“奥,我觉得有一天我们会需要这方面的功能”,进而要求预留各种各样的钩子和特殊情况来处理那些永远不需要的事情。最后的结果往往是代码里更难理解和维护。如果所有这些机制都用到,那么做还算值得,否则就太不值得了。这些机制纯粹就是绊脚石而已。因此去掉它们吧。

临时字段

临时字段常发的情况是当一个复杂的算法需要很多变量的时候。由于实现算法的人不想把一个巨型参数列表传来传去(谁也不想),因此他把它们都放在了实例变量里。但是这些实例变量只有在算法运行期间才是有效的,在其他情况下它们只会把人弄糊涂而已。这时你可以使用提炼类把这些变量以及需要它们的方法提炼出来。新的对象就是所谓的方法对象。

过分亲密

有时候一些类会变得过分亲密,在探究各自隐私上花费了太多时间。 继承通常会导致过分亲密。子类总是会知道太多它们父母不想让它们知道的东西。如果觉得是时候让它们离家了,就可以使用委托替换继承。

异曲同工的类

对任何完成同样工作但签名不同的方法使用重命名方法。继续使用移动方法把行为移到类里去,直到协议相同为止。若因此出现了冗余代码,则可以使用提炼模块或者引入继承来进行修正。

被拒绝的遗赠

子类会继承父母的方法和数据。但若它们不想要或者不需要该怎么办?得到所有的这些馈赠后,它们只看中了其中几件。 传统的说法是这表示继承体系有问题。你需要创建一个新的兄弟子类,并使用方法下移把所有不用的方法移到那个兄弟类里去。这样父类里只有公共的部分。 从我们用“传统”一词里你就能猜到这不是我们推荐的做法,至少这不是普遍适用的做法。我们的确总是通过派生来重用一些行为,而且我们也同意这个方法很好用。但这里的坏味道也是我们无法否认的,虽然通常它还没那么糟糕。我们说如果这些被拒绝的遗赠会产生迷惑的问题,那么就应该遵循传统的建议。 如果子类重用了行为但却不想支持父类里的公共方法时,被拒绝遗赠的坏味道就要浓烈的多。我们不介意你回绝具体实现,但是连公共方法也要拒绝就有点太傲慢了。但尽管如此,也不要乱动体系结构,而是应该使用为委托替换继承来去除它。

注释

别担心,我们没打算反对编写注释。在我们嗅觉比喻里,注释并不是坏味道,事实上它们还是很好的味道。我们在这里提到注释的原因是注释常常被当做除臭剂来使用了。令人惊奇的是你都记不清看过多少次这样经过详细注释的代码,最后却注意到注释之所以这么详细是因为代码写得太糟糕。 注释能引导我们找到那些糟糕的代码。说先要通过重构消除这些坏味道。当完成后,我们往往会发现这些注释已经是多余的了。 当你觉得需要注释的时候,应该首先尝试重构代码,这样任何注释都会变得多余。