重构 改善既有代码的设计:带你认识重构

编程/开发
262
0
0
2023-11-27

1 何谓重构

我总是不太喜欢下定义,因为每个人对每样东西都有自己的定义。但是既然在写书,总得选择自己满意的定义。在重构这个概念上,我的定义以Ralph Johnson团队和其他相关研究成果为基础。

首先要说明的是:视上下文不同,“重构”这个词有两种不同的定义。你可能会觉得这挺烦人的(我就是这么想的),不过处理自然语言本来就是件烦人的事,这只不过是又一个实例而已。

第一个定义是名词形式。

“重构”的另一个用法是动词形式。

重构 (动词):使用一系列重构手法,在不改变软件可观察行为的前提下,调整其结构。

所以,在软件开发过程中,你可能会花上数小时进行重构,其间可能用上数十种重构手法。

曾经有人这样问我:“重构就只是整理代码吗?”从某种角度来说,是的。但我认为重构不止于此,因为它提供了一种更高效且受控的代码整理技术。自从运用重构技术后,我发现自己对代码的整理比以前更有效率。这是因为我知道该使用哪些重构手法,也知道以怎样的方式使用它们才能够将错误减到最少,而且在每一个可能出错的地方我都加以测试。

我的定义还需要往两方面扩展。首先,重构的目的是使软件更容易被理解和修改。你可以在软件内部做很多修改,但必须对软件可观察的外部行为只造成很小变化,或甚至不造成变化。与之形成对比的是性能优化。和重构一样,性能优化通常不会改变组件的行为(除了执行速度),只会改变其内部结构。但是两者出发点不同:性能优化往往使代码较难理解,但为了得到所需的性能你不得不那么做。

我要强调的第二点是:重构不会改变软件可观察的行为——重构之后软件功能一如以往。任何用户,不论最终用户或其他程序员,都不知道已经有东西发生了变化。

两顶帽子

上述第二点引出了Kent Beck的“两顶帽子”比喻。使用重构技术开发软件时,你把自己的时间分配给两种截然不同的行为:添加新功能,以及重构。添加新功能时,你不应该修改既有代码,只管添加新功能。通过测试(并让测试正常运行),你可以衡量自己的工作进度。重构时你就不能再添加功能,只管改进程序结构。此时你不应该添加任何测试(除非发现有先前遗漏的东西),只在绝对必要(用以处理接口变化)时才修改测试。

软件开发过程中,你可能会发现自己经常变换帽子。首先你会尝试添加新功能,然后会意识到:如果把程序结构改一下,功能的添加会容易得多。于是你换一顶帽子,做一会儿重构工作。程序结构调整好后,你又换上原先的帽子,继续添加新功能。新功能正常工作后,你又发现自己的编码造成程序难以理解,于是又换上重构帽子……整个过程或许只花十分钟,但无论何时你都应该清楚自己戴的是哪一顶帽子。

2 为何重构

我不想把重构说成是包治百病的万灵丹,它绝对不是所谓的“银弹”。不过它的确很有价值,虽不是一颗银子弹却是一把“银钳子”,可以帮助你始终良好地控制自己的代码。重构是个工具,它可以(并且应该)用于以下几个目的。

重构改进软件设计

如果没有重构,程序的设计会逐渐腐败变质。当人们只为短期目的,或是在完全理解整体设计之前,就贸然修改代码,程序将逐渐失去自己的结构,程序员越来越难通过阅读源码而理解原来的设计。重构很像是在整理代码,你所做的就是让所有东西回到应处的位置上。代码结构的流失是累积性的。越难看出代码所代表的设计意图,就越难保护其中设计,于是该设计就腐败得越快。经常性的重构可以帮助代码维持自己该有的形态。

完成同样一件事,设计 不良 的程序往往需要更多代码,这常常是因为代码在不同的地方使用完全相同的语句做同样的事。因此改进设计的一个重要方向就是消除重复代码。这个动作的重要性在于方便未来的修改。代码量减少并不会使系统运行更快,因为这对程序的运行轨迹几乎没有任何明显影响。然而代码量减少将使未来可能的程序修改动作容易得多。代码越多,正确的修改就越困难,因为有更多代码需要理解。你在这儿做了点修改,系统却不如预期那样工作,是因为你没有修改另一处——那儿的代码做着几乎完全一样的事情,只是所处环境略有不同。如果消除重复代码,你就可以确定所有事物和行为在代码中只表述一次,这正是优秀设计的根本。

重构使软件更容易理解

所谓程序设计,很大程度上就是与计算机交谈:你编写代码告诉计算机做什么事,它的响应则是精确按照你的指示行动。你得及时填补“想要它做什么”和“告诉它做什么”之间的缝隙。这种编程模式的核心就是“准确说出我所要的”。除了计算机外,你的源码还有其他读者:几个月之后可能会有另一位程序员尝试读懂你的代码并做一些修改。我们很容易忘记这第二位读者,但他才是最重要的。计算机是否多花了几个小时来编译,又有什么关系呢?如果一个程序员花费一周时间来修改某段代码,那才要命呢——如果他理解了你的代码,这个修改原本只需一小时。

问题在于,当你努力让程序运转的时候,不会想到未来出现的那个开发者。是的,我们应该改变一下开发节奏,对代码做适当修改,让代码变得更易理解。重构可以帮助我们让代码更易读。一开始进行重构时,你的代码可以正常运行,但结构不够理想。在重构上花一点点时间,就可以让代码更好地表达自己的用途。这种编程模式的核心就是“准确说出我所要的”。

关于这一点,我没必要表现得如此无私。很多时候那个未来的开发者就是我自己。此时重构就显得尤其重要了。我是个很懒惰的程序员,我的懒惰表现形式之一就是:总是记不住自己写过的代码。事实上,对于任何能够立刻查阅的东西,我都故意不去记它,因为我怕把自己的脑袋塞爆。我总是尽量把该记住的东西写进程序里,这样我就不必记住它了。这么一来我就不必太担心Old Peculier[1][Jackson]杀光我的脑细胞。

这种可理解性还有另一方面的作用。我利用重构来协助我理解不熟悉的代码。每当看到不熟悉的代码,我必须试着理解其用途。我先看两行代码,然后对自己说:“噢,是的,它做了这些那些……”有了重构这个强大武器在手,我不会满足于这么一点体会。我会真正动手修改代码,让它更好地反映出我的理解,然后重新执行,看它是否仍然正常运作,以此检验我的理解是否正确。

一开始我所做的重构都像这样停留在细枝末节上。随着代码渐趋简洁,我发现自己可以看到一些以前看不到的设计层面的东西。如果不对代码做这些修改,也许我永远看不见它们,因为我的聪明才智不足以在脑子里把这一切都想象出来。Ralph Johnson把这种“早期重构”描述为“擦掉窗户上的污垢,使你看得更远”。研究代码时我发现,重构把我带到更高的理解层次上。如果没有重构,我达不到这种层次。

重构帮助找到bug

对代码的理解,可以帮助我找到bug。我承认我不太擅长调试。有些人只要盯着一大段代码就可以找出里面的bug,我可不行。但我发现,如果对代码进行重构,我就可以深入理解代码的 作为 ,并恰到好处地把新的理解反馈回去。搞清楚程序结构的同时,我也清楚了自己所做的一些假设,于是想不把bug揪出来都难。

这让我想起了Kent Beck经常形容自己的一句话:“我不是个伟大的程序员,我只是个有着一些优秀习惯的好程序员。”重构能够帮助我更有效地写出强健的代码。

重构提高编程速度

终于,前面的一切都归结到了这最后一点:重构帮助你更快速地开发程序。

听起来有点违反直觉。当我谈到重构,人们很容易看出它能够提高质量。改善设计、提升可读性、减少错误,这些都是提高质量。但这难道不会降低开发速度吗?

我绝对相信:良好的设计是快速开发的根本——事实上,拥有良好设计才可能做到快速开发。如果没有良好设计,或许某一段时间内你的进展迅速,但恶劣的设计很快就让你的速度慢下来。你会把时间花在调试上面,无法添加新功能。修改时间越来越长,因为你必须花越来越多的时间去理解系统、寻找重复代码。随着你给最初程序打上一个又一个的补丁,新特性需要更多代码才能实现。真是个恶性循环。

良好设计是维持软件开发速度的根本。重构可以帮助你更快速地开发软件,因为它阻止系统腐败变质,它甚至还可以提高设计质量。

3 何时重构

当我谈论重构,常常有人问我应该怎样安排重构时间表。我们是不是应该每两个月就专门安排两个星期来进行重构呢?

几乎任何情况下我都反对专门拨出时间进行重构。在我看来,重构本来就不是一件应该特别拨出时间做的事情,重构应该随时随地进行。你不应该为重构而重构,你之所以重构,是因为你想做别的什么事,而重构可以帮助你把那些事做好。

三次法则

Don Roberts给了我一条准则:第一次做某件事时只管去做;第二次做类似的事会产生反感,但无论如何还是可以去做;第三次再做类似的事,你就应该重构。

事不过三,三则重构。

添加功能时重构

最常见的重构时机就是我想给软件添加新特性的时候。此时,重构的直接原因往往是为了帮助我理解需要修改的代码——这些代码可能是别人写的,也可能是我自己写的。无论何时,只要我想理解代码所做的事,我就会问自己:是否能对这段代码进行重构,使我能更快地理解它。然后我就会重构。之所以这么做,部分原因是为了让我下次再看这段代码时容易理解,但最主要的原因是:如果在前进过程中把代码结构理清,我就可以从中理解更多东西。

在这里,重构的另一个原动力是:代码的设计无法帮助我轻松添加我所需要的特性。我看着设计,然后对自己说:“如果用某种方式来设计,添加特性会简单得多。”这种情况下我不会因为自己过去的错误而懊恼——我用重构来弥补它。之所以这么做,部分原因是为了让未来增加新特性时能够更轻松一些,但最主要的原因还是:我发现这是最快捷的途径。重构是一个快速流畅的过程,一旦完成重构,新特性的添加就会更快速、更流畅。

修补错误时重构

调试过程中运用重构,多半是为了让代码更具可读性。当我看着代码并努力理解它的时候,我用重构帮助加深自己的理解。我发现以这种程序来处理代码,常常能够帮助我找出bug。你可以这么想:如果收到一份错误报告,这就是需要重构的信号,因为显然代码还不够清晰——没有清晰到让你能一眼看出bug。

复审代码时重构

很多公司都会做常规的代码复审,因为这种活动可以改善开发状况。这种活动有助于在开发团队中传播知识,也有助于让较有经验的开发者把知识传递给比较欠缺经验的人,并帮助更多人理解大型软件系统中的更多部分。代码复审对于编写清晰代码也很重要。我的代码也许对我自己来说很清晰,对他人则不然。这是无法避免的,因为要让开发者设身处地为那些不熟悉自己所做所为的人着想,实在太困难了。代码复审也让更多人有机会提出有用的建议,毕竟我在一个星期之内能够想出的好点子很有限。如果能得到别人的帮助,我的生活会滋润得多,所以我总是期待更多复审。

我发现,重构可以帮助我复审别人的代码。开始重构前我可以先阅读代码,得到一定程度的理解,并提出一些建议。一旦想到一些点子,我就会考虑是否可以通过重构立即轻松地实现它们。如果可以,我就会动手。这样做了几次以后,我可以把代码看得更清楚,提出更多恰当的建议。我不必想象代码应该是什么样,我可以“看见”它是什么样。于是我可以获得更高层次的认识。如果不进行重构,我永远无法得到这样的认识。

重构还可以帮助代码复审工作得到更具体的结果。不仅获得建议,而且其中许多建议能够立刻实现。最终你将从实践中得到比以往多得多的成就感。

为了让过程正常运转,你的复审团队必须保持精练。就我的经验,最好是一个复审者搭配一个原作者,共同处理这些代码。复审者提出修改建议,然后两人共同判断这些修改是否能够通过重构轻松实现。果真能够如此,就一起着手修改。

如果是比较大的设计复审工作,那么在一个较大团队内保留多种观点通常会更好一些。此时直接展示代码往往不是最佳办法。我喜欢运用UML示意图展现设计,并以CRC卡展示软件情节。换句话说,我会和某个团队进行设计复审,而和单个复审者进行代码复审。

极限编程 [Beck,XP]中的“结对编程”形式,把代码复审的积极性发挥到了极致。一旦采用这种形式,所有正式开发任务都由两名开发者在同一台机器上进行。这样便在开发过程中形成随时进行的代码复审工作,而重构也就被包含在开发过程内了。

为什么重构有用

——Kent Beck
程序有两面价值:“今天可以为你做什么”和“明天可以为你做什么”。大多数时候,我们都只关注自己今天想要程序做什么。不论是修复错误或是添加特性,我们都是为了让程序能力更强,让它在今天更有价值。
但是系统当下的行为,只是整个故事的一部分,如果没有认清这一点,你无法长期从事编程工作。如果你为求完成今天的任务而不择手段,导致不可能在明天完成明天的任务,那么最终还是会失败。但是,你知道自己今天需要什么,却不一定知道自己明天需要什么。也许你可以猜到明天的需求,也许吧,但肯定还有些事情出乎你的意料。
对于今天的工作,我了解得很充分;对于明天的工作,我了解得不够充分。但如果我纯粹只是为今天工作,明天我将完全无法工作。
重构是一条摆脱困境的道路。如果你发现昨天的决定已经不适合今天的情况,放心改变这个决定就是,然后你就可以完成今天的工作了。明天,喔,明天回头看今天的理解也许觉得很幼稚,那时你还可以改变你的理解。

是什么让程序如此难以相与? 眼下我能想起下述四个原因,它们是:

  • 难以阅读的程序,难以修改;
  • 逻辑重复的程序,难以修改;
  • 添加新行为时需要修改已有代码的程序,难以修改;
  • 带复杂条件逻辑的程序,难以修改。

因此,我们希望程序:(1) 容易阅读;(2) 所有逻辑都只在唯一地点指定;(3) 新的改动不会危及现有行为;(4) 尽可能简单表达条件逻辑。

重构是这样一个过程:它在一个目前可运行的程序上进行,在不改变程序行为的前提下使其具备上述美好性质,使我们能够继续保持高速开发,从而增加程序的价值。

4 怎么对经理说

“该怎么跟经理说重构的事?”这是我最常被问到的一个问题。如果这位经理懂技术,那么向他介绍重构应该不会很困难。如果这位经理只对质量感兴趣,那么问题就集中到了“质量”上面。此时,在复审过程中使用重构就是一个不错的办法。大量研究结果显示,技术复审是减少错误、提高开发速度的一条重要途径。随便找一本关于复审、审查或软件开发程序的书看看,从中找些最新引证,应该可以让大多数经理认识复审的价值。然后你就可以把重构当作“将复审意见引入代码内”的方法来使用,这很容易。

当然,很多经理嘴巴上说自己“质量驱动”,其实更多是“进度驱动”。这种情况下我会给他们一个较有争议的建议:不要告诉经理!

这是在搞破坏吗?我不这样想。软件开发者都是专业人士。我们的工作就是尽可能快速创造出高效软件。我的经验告诉我,对于快速创造软件,重构可带来巨大帮助。如果需要添加新功能,而原本设计却又使我无法方便地修改,我发现先重构再添加新功能会更快些。如果要修补错误,就得先理解软件的工作方式,而我发现重构是理解软件的最快方式。受进度驱动的经理要我尽可能快速完事,至于怎么完成,那就是我的事了。我认为最快的方式就是重构,所以我就重构。

间接层和重构

——Kent Beck
“计算机科学是这样一门科学:它相信所有问题都可以通过增加一个间接层来解决。”
——Dennis DeBruler

由于软件工程师对间接层如此醉心,你应该不会惊讶大多数重构都为程序引入了更多间接层。重构往往把大型对象拆成多个小型对象,把大型函数拆成多个小型函数。

但是,间接层是一柄双刃剑。每次把一个东西分成两份,你就需要多管理一个东西。如果某个对象委托另一对象,后者又委托另一对象,程序会愈加难以阅读。

基于这个观点,你会希望尽量减少间接层。

别急,伙计!间接层有它的价值。下面就是间接层的某些价值。

  • 允许逻辑共享。比如说一个子函数在两个不同的地点被调用,或超类中的某个函数被所有子类共享。
  • 分开解释意图和实现。你可以选择每个类和函数的名字,这给了你一个解释自己意图的机会。类或函数内部则解释实现这个意图的做法。如果类和函数内部又以更小单元的意图来编写,你所写的代码就可以描述其结构中的大部分重要信息。
  • 隔离变化。很可能我在两个不同地点使用同一对象,其中一个地点我想改变对象行为,但如果修改了它,我就要冒同时影响两处的风险。为此我做出一个子类,并在需要修改处引用这个子类。现在,我可以修改这个子类而不必承担无意中影响另一处的风险。
  • 封装条件逻辑。对象有一种奇妙的机制:多态消息,可以灵活而清晰地表达条件逻辑。将条件逻辑转化为消息形式,往往能降低代码的重复、增加清晰度并提高弹性。

这就是重构游戏:在保持系统现有行为的前提下,如何才能提高系统的质量或降低其成本,从而使它更有价值?

这个游戏中最常见的变量就是:你如何看待你自己的程序。找出一个缺乏“间接层利益”之处,在不修改现有行为的前提下,为它加入一个间接层。现在你获得了一个更有价值的程序,因为它有较高的质量,让我们在明天(未来)受益。

请将这种方法与“小心翼翼的事前设计”做个比较。推测性设计总是试图在任何一行代码诞生之前就先让系统拥有所有优秀质量,然后程序员将代码塞进这个强健的骨架中就行了。这个过程的问题在于:太容易猜错。如果运用重构,你就永远不会面临全盘错误的危险。程序自始至终都能保持一致的行为,而你又有机会为程序添加更多价值不菲的质量。

还有一种比较少见的重构游戏:找出不值得的间接层,并将它拿掉。这种间接层常以中介函数形式出现,它也许曾经有过贡献,但芳华已逝。它也可能是个组件,你本来期望在不同地点共享它,或让它表现出多态性,最终却只在一处用到。如果你找到这种“寄生式间接层”,请把它扔掉。如此一来你会获得一个更有价值的程序,不是因为它取得了更多的优秀质量,而是因为它以更少的间接层获得一样多的优秀质量。

5 重构的难题

学习一种可以大幅提高生产力的新技术时,你总是难以察觉其不适用的场合。通常你在一个特定场景中学习它,这个场景往往是个项目。这种情况下你很难看出什么会造成这种新技术成效不彰甚或形成危害。十年前,对象技术的情况也是如此。那时如果有人问我何时不要使用对象,我很难回答。并非我认为对象十全十美、没有局限性——我最反对这种盲目态度,而是尽管我知道它的好处,但确实不知道其局限性在哪儿。

现在,重构的处境也是如此。我们知道重构的好处,我们知道重构可以给我们的工作带来立竿见影的改变。但是我们还没有获得足够的经验,我们还看不到它的局限性。

随着更多人学会重构技巧,我们也将对它有更多了解。对你而言这意味着:虽然我坚决认为你应该尝试一下重构,获得它所提供的利益,但与此同时,你也应该时时监控其过程,注意寻找重构可能引入的问题。请让我们知道你所遭遇的问题。随着对重构的了解日益增多,我们将找出更多解决办法,并清楚知道哪些问题是真正难以解决的。

数据库

重构经常出问题的一个领域就是数据库。绝大多数商用程序都与它们背后的数据库结构紧密耦合在一起,这也是数据库结构如此难以修改的原因之一。另一个原因是数据迁移(migration)。就算你非常小心地将系统分层,将数据库结构和对象模型间的依赖降至最低,但数据库结构的改变还是让你不得不迁移所有数据,这可能是件漫长而 烦琐 的工作。

在非对象数据库中,解决这个问题的办法之一就是:在对象模型和数据库模型之间插入一个分隔层,这就可以隔离两个模型各自的变化。升级某一模型时无需同时升级另一模型,只需升级上述的分隔层即可。这样的分隔层会增加系统复杂度,但可以给你带来很大的灵活度。如果你同时拥有多个数据库,或如果数据库模型较为复杂使你难以控制,那么即使不进行重构,这分隔层也是很重要的。

你无需一开始就插入分隔层,可以在发现对象模型变得不稳定时再产生它,这样你就可以为你的改变找到最好的平衡点。

对开发者而言, 对象 数据库既有帮助也有妨碍。某些面向对象数据库提供不同版本的对象之间的自动迁移功能,这减少了数据迁移时的工作量,但还是会损失一定时间。如果各数据库之间的数据迁移并非自动进行,你就必须自行完成迁移工作,这个工作量可是很大的。这种情况下你必须更加留神类中的数据结构变化。你仍然可以放心将类的行为转移过去,但转移字段时就必须格外小心。数据尚未被转移前你就得先运用访问函数造成“数据已经转移”的假象。一旦你确定知道数据应该放在何处,就可以一次性地将数据迁移过去。这时唯一需要修改的只有访问函数,这也降低了错误风险[2]。

修改接口

关于对象,另一件重要事情是:它们允许你分开修改软件模块的实现和接口。你可以安全地修改对象内部实现而不影响他人,但对于接口要特别谨慎——如果接口被修改了,任何事情都有可能发生。

一直对重构带来困扰的一件事就是:许多重构手法的确会修改接口。像Rename Method (273)这么简单的重构手法所做的一切就是修改接口。这对极为珍贵的封装概念会带来什么影响呢?

如果某个函数的所有调用者都在你的控制之下,那么即使修改函数名称也不会有任何问题。哪怕面对一个public函数,只要能取得并修改其所有调用者,你也可以安心地将这个函数改名。只有当需要修改的接口被那些“找不到,即使找到也不能修改”的代码使用时,接口的修改才会成为问题。如果情况真是如此,我就会说:这个接口是个已发布接口(published interface)——比公开接口(public interface)更进一步。接口一旦发布,你就再也无法仅仅修改调用者而能够安全地修改接口了。你需要一个更复杂的流程。

这个想法改变了我们的问题。如今的问题是:该如何面对那些必须修改“已发布接口”的重构手法?

简言之,如果重构手法改变了已发布接口,你必须同时维护新旧两个接口,直到所有用户都有时间对这个变化做出反应。幸运的是,这不太困难。你通常都有办法把事情组织好,让旧接口继续工作。请尽量这么做:让旧接口调用新接口。当你要修改某个函数名称时,请留下旧函数,让它调用新函数。千万不要复制函数实现,那会让你陷入重复代码的泥淖中难以自拔。你还应该使用 Java 提供的deprecation(不建议使用)设施,将旧接口标记为deprecated。这么一来你的调用者就会注意到它了。

这个过程的一个好例子就是Java容器类(集合类,collection classes)。Java 2的新容器取代了原先一些容器。当Java 2容器发布时,JavaSoft花了很大力气来为开发者提供一条顺利迁徙之路。

“保留旧接口”的办法通常可行,但很烦人。起码在一段时间里你必须构造并维护一些额外的函数。它们会使接口变得复杂,使接口难以使用。还好我们有另一个选择:不要发布接口。当然我不是说要完全禁止,因为很明显你总得发布一些接口。如果你正在建造供外部使用的API(就像Sun公司所做的那样),就必须发布接口。之所以说尽量不要发布,是因为我常常看到一些开发团队公开了太多接口。我曾经看到一支三人团队这么工作:每个人都向另外两人公开发布接口。这使他们不得不经常来回维护接口,而其实他们原本可以直接进入程序库,径行修改自己管理的那一部分,那会轻松许多。过度强调代码所有权的团队常常会犯这种错误。发布接口很有用,但也有代价。所以除非真有必要,不要发布接口。这可能 意味需 要改变你的代码所有权观念,让每个人都可以修改别人的代码,以适应接口的改动。以结对编程的方式完成这一切通常是个好主意。

不要过早发布接口。请修改你的代码所有权政策,使重构更顺畅。

Java还有一种特别的接口修改:在 throws 子句中增加一个异常。这并不是对函数签名的修改,所以你无法以委托的办法隐藏它;但如果用户代码不做出相应修改,编译器不会让它通过。这个问题很难解决。你可以为这个函数选择一个新名字,让旧函数调用它,并将这个新增的受控异常转换成一个非受控异常。你也可以抛出一个非受控异常,不过这样你就会失去检验能力。如果你那么做,你可以警告调用者:这个非受控异常日后会变成一个受控异常。这样他们就有时间在自己的代码中加上对此异常的处理。出于这个原因,我总是喜欢为整个包(package)定义一个异常基类(就像java.sql的SQLException),并确保所有public函数只在自己的 throws 子句中声明这个异常。这样我就可以随心所欲地定义异常子类,不会影响调用者,因为调用者永远只知道那个更具一般性的异常基类。

难以通过重构手法完成的设计改动

通过重构,可以排除所有设计错误吗?是否存在某些核心设计决策,无法以重构手法修改?在这个领域里,我们的统计数据尚不完整。当然某些情况下我们可以很有效地重构,这常常令我们倍感惊讶,但的确也有难以重构的地方。比如说在一个项目中,我们很难(但还是有可能)将不考虑安全性需求时构造起来的系统重构为具备良好安全性系统。

这种情况下我的办法就是:先想象重构的情况。考虑候选设计方案时,我会问自己:将某个设计重构为另一个设计的难度有多大?如果看上去很简单,我就不必太担心选择是否得当,于是我就会选最简单的设计,哪怕它不能覆盖所有潜在需求也没关系。但如果预先看不到简单的重构办法,我就会在设计上投入更多力气。不过我发现,后一种情况很少出现。

何时不该重构

有时候你根本不应该重构,例如当你应该重新编写所有代码的时候。有时候既有代码实在太混乱,重构它还不如重新写一个来得简单。作出这种决定很困难,我承认我也没有什么好准则可以判断何时应该放弃重构。

重写(而非重构)的一个清楚讯号就是:现有代码根本不能正常运作。你可能只是试着做点测试,然后就发现代码中满是错误,根本无法稳定运作。记住,重构之前,代码必须起码能够在大部分情况下正常运作。

一个折中办法就是:将“大块头软件”重构为封装良好的小型组件。然后你就可以逐一对组件做出“重构或重建”的决定。这是一个颇有希望的办法,但我还没有足够数据,所以也无法写出好的指导原则。对于一个重要的遗留系统,这肯定会是一个很好的方向。

另外,如果项目已近最后期限,你也应该避免重构。在此时机,从重构过程赢得的生产力只有在最后期限过后才能体现出来,而那个时候已经为时晚矣。Ward Cunningham对此有一个很好的看法。他把未完成的重构工作形容为“债务”。很多公司都需要借债来使自己更有效地运转。但是借债就得付利息,过于复杂的代码所造成的维护和扩展的额外成本就是利息。你可以承受一定程度的利息,但如果利息太高你就会被压垮。把债务管理好是很重要的,你应该随时通过重构来偿还一部分债务。

如果项目已经非常接近最后期限,你不应该再分心于重构,因为已经没有时间了。不过多个项目经验显示:重构的确能够提高生产力。如果最后你没有足够时间,通常就表示你其实早该进行重构。

6 重构与设计

重构肩负一项特殊使命:它和设计彼此互补。初学编程的时候,我埋头就写程序,浑浑噩噩地进行开发。然而很快我便发现,事先做好设计可以让我节省返工的高昂成本。于是我很快加强这种“预先设计”风格。许多人都把设计看做软件开发的关键环节,而把编程看做只是机械式的低级劳动。他们认为设计就像画工程图而编码就像施工。但是你要知道,软件和机器有着很大的差异:软件的可塑性更强,而且完全是思想产品。正如Alistair Cockburn所说:“有了设计,我可以思考得更快,但是其中充满小漏洞。”

有一种观点认为:重构可以取代预先设计。这意思是你根本不必做任何设计,只管按照最初想法开始编码,让代码有效运作,然后再将它重构成型。事实上这种办法真的可行。我的确看过有人这么做,最后获得设计良好的软件。极限编程[Beck,XP]的支持者极力提倡这种办法。

尽管如上所言,只运用重构也能收到效果,但这并不是最有效的途径。是的,就连极限编程的爱好者们也会进行预先设计。他们会使用CRC卡或类似的东西来检验各种不同想法,然后才得到第一个可被接受的解决方案,然后才能开始编码,然后才能重构。关键在于:重构改变了预先设计的角色。如果没有重构,你就必须保证预先做出的设计正确无误,这个压力太大了。这意味如果将来需要对原始设计做任何修改,代价都将非常高昂。因此你需要把更多时间和精力放在预先设计上,以避免日后修改。

如果你选择重构,问题的重点就转变了。你仍然做预先设计,但是不必一定找出正确的解决方案。此刻的你只需要得到一个足够合理的解决方案就够了。你很肯定地知道,在实现这个初始解决方案的时候,你对问题的理解也会逐渐加深,你可能会察觉最佳解决方案和你当初设想的有些不同。只要有重构这把利器在手,就不成问题,因为重构让日后的修改成本不再高昂。

这种转变导致一个重要结果:软件设计向简化前进了一大步。过去未曾运用重构时,我总是力求得到灵活的解决方案。任何一个需求都让我提心吊胆地猜疑:在系统的有生之年,这个需求会导致怎样的变化?由于变更设计的代价非常高昂,所以我希望建造一个足够灵活、足够牢靠的解决方案,希望它能承受我所能预见的所有需求变化。问题在于:要建造一个灵活的解决方案,所需的成本难以估算。灵活的解决方案比简单的解决方案复杂许多,所以最终得到的软件通常也会更难维护——虽然它在我预先设想的方向上的确是更加灵活。就算幸运地走在预先设想的方向上,你也必须理解如何修改设计。如果变化只出现在一两个地方,那不算大问题。然而变化其实可能出现在系统各处。如果在所有可能的变化出现地点都建立起灵活性,整个系统的复杂度和维护难度都会大大提高。当然,如果最后发现所有这些灵活性都毫无必要,这才是最大的失败。你知道,这其中肯定有些灵活性的确派不上用场,但你却无法预测到底是哪些派不上用场。为了获得自己想要的灵活性,你不得不加入比实际需要更多的灵活性。

有了重构,你就可以通过一条不同的途径来应付变化带来的风险。你仍旧需要思考潜在的变化,仍旧需要考虑灵活的解决方案。但是你不必再逐一实现这些解决方案,而是应该问问自己:“把一个简单的解决方案重构成这个灵活的方案有多大难度?”如果答案是“相当容易”(大多数时候都如此),那么你就只需实现目前的简单方案就行了。

重构可以带来更简单的设计,同时又不损失灵活性,这也降低了设计过程的难度,减轻了设计压力。一旦对重构带来的简单性有更多感受,你甚至可以不必再预先思考前述所谓的灵活方案——一旦需要它,你总有足够的信心去重构。是的,当下只管建造可运行的最简化系统,至于灵活而复杂的设计,唔,多数时候你都不会需要它。

劳而无获

——Ron Jeffries
克莱斯勒综合薪资系统的支付过程太慢了。虽然我们的开发还没结束,这个问题却已经开始困扰我们,因为它已经拖累了测试速度。

Kent Beck、Martin Fowler和我决定解决这个问题。等待大伙儿会合的时间里,凭着我对这个系统的全盘了解,我开始推测:到底是什么让系统变慢了?我想到数种可能,然后和伙伴们谈了几种可能的修改方案。最后,我们就“如何让这个系统运行更快”,提出了一些真正的好点子。

然后,我们拿Kent的工具度量了系统性能。我一开始所想的可能性竟然全都不是问题肇因。我们发现:系统把一半时间用来创建“日期”实例(instance)。更有趣的是,所有这些实例都有相同的值。

于是我们观察日期的创建逻辑,发现有机会将它优化。日期原本是由字符串转换而成,即使无外部输入也是如此。之所以使用字符串转换方式,完全是为了方便键盘输入。好,也许我们可以优化它。

于是我们观察这个程序如何使用日期对象。我们发现,很多日期对象都被用来产生“日期区间”实例——后者由一个起始日期和一个结束日期组成。仔细追踪下去,我们发现绝大多数日期区间是空的!

处理日期区间时我们遵循这样一个规则:如果结束日期在起始日期之前,这个日期区间就该是空的。这是一条很好的规则,完全符合这个类的需要。采用此一规则后不久,我们意识到,创建一个“起始日期在结束日期之后”的日期区间,仍然不算是清晰的代码,于是我们把这个行为提炼成一个工厂函数,由它专门创建“空的日期区间”。

我们做了上述修改,使代码更加清晰,也意外得到了一个惊喜:可以创建一个固定不变的“空日期区间”对象,并让上述调整后的工厂函数始终返回该对象,而不再每次都创建新对象。这一修改把系统速度提升了几乎一倍,足以让测试速度达到可接受程度。这只花了我们大约五分钟。

我和团队成员(Kent和Martin谢绝参加)认真推测过:我们了若指掌的这个程序中可能有什么错误?我们甚至凭空做了些改进设计,却没有先对系统的真实情况进行度量。我们完全错了。除了一场很有趣的交谈,我们什么好事都没做。

教训:哪怕你完全了解系统,也请实际度量它的性能,不要臆测。臆测会让你学到一些东西,但十有八九你是错的。

7 重构与性能

关于重构,有一个常被提出的问题:它对程序的性能将造成怎样的影响?为了让软件易于理解,你常会做出一些使程序运行变慢的修改。这是个重要的问题。我并不赞成为了提高设计的纯洁性而忽视性能,把希望寄托于更快的硬件身上也绝非正道。已经有很多软件因为速度太慢而被用户拒绝,日益提高的机器速度也只不过略微放宽了速度方面的限制而已。但是,换个角度说,虽然重构可能使软件运行更慢,但它也使软件的性能优化更容易。除了对性能有严格要求的实时系统,其他任何情况下“编写快速软件”的秘密就是:首先写出可调的软件,然后调整它以求获得足够速度。

我看过三种编写快速软件的方法。其中最严格的是时间预算法,这通常只用于性能要求极高的实时系统。如果使用这种方法,分解你的设计时就要做好预算,给每个组件预先分配一定资源——包括时间和执行轨迹。每个组件绝对不能超出自己的预算,就算拥有组件之间调度预配时间的机制也不行。这种方法高度重视性能,对于心律调节器一类的系统是必须的,因为在这样的系统中迟来的数据就是错误的数据。但对其他系统(例如我经常开发的企业信息系统)而言,如此追求高性能就有点过分了。

第二种方法是持续关注法。这种方法要求任何程序员在任何时间做任何事时,都要设法保持系统的高性能。这种方式很常见,感觉上很有吸引力,但通常不会起太大作用。任何修改如果是为了提高性能,通常会使程序难以维护,继而减缓开发速度。如果最终得到的软件的确更快了,那么这点损失尚有所值,可惜通常事与愿违,因为性能改善一旦被分散到程序各角落,每次改善都只不过是从对程序行为的一个狭隘视角出发而已。

关于性能,一件很有趣的事情是:如果你对大多数程序进行分析,就会发现它把大半时间都耗费在一小半代码身上。如果你一视同仁地优化所有代码,90%的优化工作都是白费劲的,因为被你优化的代码大多很少被执行。你花时间做优化是为了让程序运行更快,但如果因为缺乏对程序的清楚认识而花费时间,那些时间就都被浪费掉了。

第三种性能提升法就是利用上述的90%统计数据。采用这种方法时,你编写构造良好的程序,不对性能投以特别的关注,直至进入性能优化阶段——那通常是在开发后期。一旦进入该阶段,你再按照某个特定程序来调整程序性能。

在性能优化阶段,你首先应该用一个度量工具来监控程序的运行,让它告诉你程序中哪些地方大量消耗时间和空间。这样你就可以找出性能热点所在的一小段代码。然后你应该集中关注这些性能热点,并使用持续关注法中的优化手段来优化它们。由于你把注意力都集中在热点上,较少的工作量便可显现较好的成果。即便如此你还是必须保持谨慎。和重构一样,你应该小幅度进行修改。每走一步都需要编译、测试、再次度量。如果没能提高性能,就应该撤销此次修改。你应该继续这个“发现热点、去除热点”的过程,直到获得客户满意的性能为止。关于这项技术,McConnell[McConnell]为我们提供了更多信息。

一个构造良好的程序可从两方面帮助这一优化形式。首先,它让你有比较充裕的时间进行性能调整,因为有构造良好的代码在手,你就能够更快速地添加功能,也就有更多时间用在性能问题上(准确的度量则保证你把这些时间投资在恰当地点)。其次,面对构造良好的程序,你在进行性能分析时便有较细的 粒度 ,于是度量工具把你带入范围较小的程序段落中,而性能的调整也比较容易些。由于代码更加清晰,因此你能够更好地理解自己的选择,更清楚哪种调整起关键作用。

我发现重构可以帮助我写出更快的软件。短期看来,重构的确可能使软件变慢,但它使优化阶段的软件性能调整更容易,最终还是会得到好的效果。

8 重构起源何处

我曾经努力想找出重构(refactoring)一词的真正起源,但最终失败了。优秀程序员肯定至少会花一些时间来清理自己的代码。这么做是因为,他们知道简洁的代码比杂乱无章的代码更容易修改,而且他们知道自己几乎无法一开始就写出简洁的代码。

[1] 一种有名的麦芽酒。——译者注

[2] 数据库重构的经验也已经由Soctt Ambler等人总结成书,相关内容请参考《数据库重构》()。——译者注


本文节选自《重构:改善既有代码的设计》

重构 改善既有代码的设计:带你认识重构

内容简介

本书清晰揭示了重构的过程,解释了重构的原理和最佳实践方式,并给出了何时以及何地应该开始挖掘代码以求改善。书中给出了70 多个可行的重构,每个重构都介绍了一种经过验证的代码变换手法的动机和技术。本书提出的重构准则将帮助你一次一小步地修改你的代码,从而减少了开发过程中的风险。
本书适合软件开发人员、项目管理人员等阅读,也可作为高等院校计算机及相关专业师生的参考读物。