重构:改善既有代码的设计

978-7-115-36909-3
作者: 【美】Martin Fowler
译者: 熊节
编辑: 杨海玲

图书目录:

详情

本书清晰揭示了重构的过程,解释了重构的原理和最佳实践方式,并给出了何时以及何地应该开始挖掘代码以求改善。书中给出了 70多个可行的重构,每个重构都介绍了一种经过验证的代码变换手法的动机和技术。本书提出的重构准则将帮助你一次一小步地修改你的代码,从而减少了开发过程中的风险。

图书摘要

版权信息

书名:重构:改善既有代码的设计

ISBN:978-7-115-36909-3

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。

• 著    [美]Martin Fowler

  译    熊 节

  责任编辑 杨海玲

• 人民邮电出版社出版发行  北京市丰台区成寿寺路11号

  邮编 100164  电子邮件 315@ptpress.com.cn

  网址 http://www.ptpress.com.cn

• 读者服务热线:(010)81055410

  反盗版热线:(010)81055315


本书清晰揭示了重构的过程,解释了重构的原理和最佳实践方式,并给出了何时以及何地应该开始挖掘代码以求改善。书中给出了70 多个可行的重构,每个重构都介绍了一种经过验证的代码变换手法的动机和技术。本书提出的重构准则将帮助你一次一小步地修改你的代码,从而减少了开发过程中的风险。

本书适合软件开发人员、项目管理人员等阅读,也可作为高等院校计算机及相关专业师生的参考读物。


Authorized translation from the English language edition, entitled Refactoring: Improving the Design of Existing Code, 9780201485677 by Martin Fowler, published by Pearson Education, Inc, publishing as Addison Wesley, Copyright © 1999 by Addison Wesley Longman, Inc.

All rights reserved. No part of this book may be reproduced or transmitted in any form or by any means, electronic or mechanical, including photocopying, recording or by any information storage retrieval system, without permission from Pearson Education, Inc.

CHINESE SIMPLIFIED language edition published by PEARSON EDUCATION ASIA LTD., and POSTS & TELECOMMUNICATIONS PRESS Copyright © 2015.

本书中文简体字版由Pearson Education Asia Ltd.授权人民邮电出版社独家出版。未经出版者书面许可,不得以任何方式复制或抄袭本书内容。

本书封面贴有Pearson Education(培生教育出版集团)激光防伪标签,无标签者不得销售。

版权所有,侵权必究。


光阴荏苒,从当年译完这本《重构》,到如今重新整理译稿,不知不觉已经过去6年了。6年来,在各种大型系统中进行重构和指导别人重构,一直是我的一项工作。对于这本早已烂熟于心的书,也有了一些新的认识。

不得不遗憾地说,尽管“重构”已经成了常用词汇,但重构技术并没有像我当初乐观认为的那样“变得像空气与水一样普通”。一方面,一种甚嚣尘上的观点认为只要掌握重构的思想就足够了,没必要记住那些详细琐碎的重构手法;另一方面,倒是有很多人高擎“重构”大旗,刀劈斧砍进行着令人触目惊心的大胆修改——有些干脆就是在重做整个系统。

这些人常常忘了一个最基本的定义:重构是在不改变软件可观察行为的前提下改善其内部结构。当你面对一个最需要重构的遗留系统时,其规模之大、历史之久、代码质量之差,常会使得添加单元测试或者理解其逻辑都成为不可能的任务。此时你唯一能依靠的就是那些已经被证明是行为保持的重构手法:用绝对安全的手法从“焦油坑”中整理出可测试的接口,给它添加测试,以此作为继续重构的立足点。

六年来,在各种语言、各种行业、各种软件形态,包括规模达到上百万行代码的项目中进行重构的经验让我明白,“不改变软件行为”只是重构的最基本要求。要想真正让重构技术发挥威力,就必须做到“不需了解软件行为”——听起来很荒谬,但事实如此。如果一段代码能让你容易了解其行为,说明它还不是那么迫切需要被重构。那些最需要重构的代码,你只能看到其中的“坏味道”,接着选择对应的重构手法来消除这些“坏味道”,然后才有可能理解它的行为。而这整个过程之所以可行,全赖你在脑子里记录着一份“坏味道”与重构手法的对应表。

而且,尽管Java和.NET的自动化重构工具已经相当成熟,但另一些重要的面向对象语言(C++、Ruby、Python……)还远未享受到这样的便利。在重构这些语言编写的程序时,我们仍然必须遵循这些看似琐碎的做法指导(加上语言特有的细节调整),按部就班地进行——如果你还想以安全的方式重构的话。

所以,仅仅掌握思想是没用的。如果把重构比作一门功夫的话,它的威力全都来自日积月累的勤学苦练。记住所有的“坏味道”,记住它们对应的重构手法,记住常见的重构步骤,然后你才可能有信心面对各种复杂情况——学会所有的招式,才可能“无招胜有招”。我知道这听起来很难,但我也知道这并不像你想象的那么难。你所需要的只是耐心、毅力和不断重读这本书。

熊 节


第一次听到“重构”这个词,是在2001年10月。在当时,它的思想足以令我感到震撼。软件自有其美感所在。软件工程希望建立完美的需求与设计,按照既有的规范编写标准划一的代码,这是结构的美;快速迭代和RAD颠覆“全知全能”的神话,用近乎刀劈斧砍(crack)的方式解决问题,在混沌的循环往复中实现需求,这是解构的美;而Kent Beck与Martin Fowler两人站在一起,以XP那敏捷而又严谨的方法论演绎了重构的美——我不知道是谁最初把refactoring一词翻译为“重构”,或许无心插柳,却成了点睛之笔。

我一直是设计模式的爱好者。曾经在我的思想中,软件开发应该有一个“理想国”——当然,在这个理想国维持着完美秩序的,不是哲学家,而是模式。设计模式给我们的,不仅仅是一些具体问题的解决方案,更有追求完美“理型”的渴望。但是,Joshua Kerievsky在那篇著名的《模式与XP》(收录于《极限编程研究》一书)中明白地指出:在设计前期使用模式常常导致过度工程(over-engineering)。这是一个残酷的现实,单凭对完美的追求无法写出实用的代码,而“实用”是软件压倒一切的要素。从一篇《停止过度工程》开始,Kerievsky撰写了“Refactoring to Patterns”系列文章。这位犹太人用他民族性的睿智头脑,敏锐地发现了软件的后结构主义道路。而让设计模式在飞速变化的网络时代重新闪现光辉的,又是重构的力量。

在一篇流传甚广的帖子里,有人把《重构》与《设计模式》并列为“Java行业的圣经”。在我看来这种并列其实并不准确。实际上,尽管我如此喜爱这本《重构》,但自从完成翻译之后,就再也没有读过它。不,不是因为我已经对它烂熟于心,而是因为重构已经变成了我的另一种生活方式,变成了我每天的“面包与黄油”,变成了我们整个团队的空气与水,以至于无须再到书中寻找任何“神谕”。而《设计模式》,我倒是放在手边时常翻阅,因为总是记得不那么真切。

所以,在你开始阅读本书之前,我要给你两个建议:首先,把你的敬畏扔到太平洋里去,对于即将变得像空气与水一样普通的技术,你无须对它敬畏;其次,找到合适的开发工具(如果你和我一样是Java人,那么这个“合适的工具”就是Eclipse),学会使用其中的自动测试和重构功能,然后再尝试使用本书介绍的任何技术。懒惰是程序员的美德之一,绝不要因为这本书让你变得勤快。

最后,即使你完全掌握了这本书中的所有东西,也千万不要跟别人吹嘘。在我们的团队里,程序员常常会说:“如果没有单元测试和重构,我没办法写代码。”

好了,感谢你耗费一点点的时间来倾听我现在对重构、对《重构》这本书的想法。Martin Fowler经常说,花一点时间来重构是值得的,希望你会觉得花一点时间看我的文字也是值得的。

熊 节

于杭州


“重构”这个概念来自Smalltalk圈子,没多久就进入了其他语言阵营之中。由于重构是框架开发中不可缺少的一部分,所以当框架开发人员讨论自己的工作时,这个术语就诞生了。当他们精炼自己的类继承体系时,当他们叫喊自己可以拿掉多少多少行代码时,重构的概念慢慢浮出水面。框架设计者知道,这东西不可能一开始就完全正确,它将随着设计者的经验成长而进化;他们也知道,代码被阅读和被修改的次数远远多于它被编写的次数。保持代码易读、易修改的关键,就是重构——对框架而言如此,对一般软件也如此。

好极了,还有什么问题吗?问题很显然:重构具有风险。它必须修改运作中的程序,这可能引入一些不易察觉的错误。如果重构方式不恰当,可能毁掉你数天甚至数星期的成果。如果重构时不做好准备,不遵守规则,风险就更大。你挖掘自己的代码,很快发现了一些值得修改的地方,于是你挖得更深。挖得越深,找到的重构机会就越多,于是你的修改也越多……最后你给自己挖了个大坑,却爬不出去了。为了避免自掘坟墓,重构必须系统化进行。我在《设计模式》书中和另外三位作者曾经提过:设计模式为重构提供了目标。然而“确定目标”只是问题的一部分而已,改造程序以达到目标,是另一个难题。

Martin Fowler和本书另几位作者清楚揭示了重构过程,他们为面向对象软件开发所做的贡献难以衡量。本书解释了重构的原理和最佳实践,并指出何时何地你应该开始挖掘你的代码以求改善。本书的核心是一系列完整的重构方法,其中每一项都介绍一种经过实践检验的代码变换手法的动机和技术。某些项目如Extract Method和Move Field看起来可能很浅显,但不要掉以轻心,因为理解这类技术正是有条不紊地进行重构的关键。本书所提的这些重构手法将帮助你一次一小步地修改你的代码,这就减少了过程中的风险。很快你就会把这些重构手法和其名称加入自己的开发词典中,并且朗朗上口。

我第一次体验有讲究的、一次一小步的重构,是某次与Kent Beck在三万英尺高空的飞行旅途中结对编程。我们运用本书收录的重构手法,保证每次只走一步。最后,我对这种实践方式的效果感到十分惊讶。我不但对最后结果更有信心,而且开发压力也小了很多。所以,我极力推荐你试试这些重构手法,你和你的程序都将因此更美好。

Erich Gamma

《设计模式》第一作者,Eclipse平台主架构师


从前,有位咨询顾问造访客户调研其开发项目。系统核心是个类继承体系,顾问看了开发人员所写的一些代码。他发现整个体系相当凌乱,上层超类对于系统的运作做了一些假设,下层子类实现这些假设。但是这些假设并不适合所有子类,导致覆写(override)工作非常繁重。只要在超类做点修改,就可以减少许多覆写工作。在另一些地方,超类的某些意图并未被良好理解,因此其中某些行为在子类内重复出现。还有一些地方,好几个子类做相同的事情,其实可以把它们搬到继承体系的上层去做。

这位顾问于是建议项目经理看看这些代码,把它们整理一下,但是经理并不热衷于此,毕竟程序看上去还可以运行,而且项目面临很大的进度压力。于是经理说,晚些时候再抽时间做这些整理工作。

顾问也把他的想法告诉了在这个继承体系上工作的程序员,告诉他们可能发生的事情。程序员都很敏锐,马上就看出问题的严重性。他们知道这并不全是他们的错,有时候的确需要借助外力才能发现问题。程序员立刻用了一两天的时间整理好这个继承体系,并删掉了其中一半代码,功能毫发无损。他们对此十分满意,而且发现在继承体系中加入新的类或使用系统中的其他类都更快、更容易了。

项目经理并不高兴。进度排得很紧,有许多工作要做。系统必须在几个月之后发布,而这些程序员却白白耗费了两天时间,干的工作与要交付的多数功能毫无关系。原先的代码运行起来还算正常,他们的新设计看来有点过于追求完美。项目要交付给客户的,是可以有效运行的代码,不是用以取悦学究的完美东西。顾问接下来又建议应该在系统的其他核心部分进行这样的整理工作,这会使整个项目停顿一至二个星期。所有这些工作只是为了让代码看起来更漂亮,并不能给系统添加任何新功能。

你对这个故事有什么感想?你认为这个顾问的建议(更进一步整理程序)是对的吗?你会遵循那句古老的工程谚语“如果它还可以运行,就不要动它。”吗?

我必须承认自己有某些偏见,因为我就是那个顾问。六个月之后这个项目宣告失败,很大的原因是代码太复杂,无法调试,也无法获得可被接受的性能。

后来,项目重新启动,几乎从头开始编写整个系统,Kent Beck受邀做了顾问。他做了几件迥异以往的事,其中最重要的一件就是坚持以持续不断的重构行为来整理代码。这个项目的成功,以及重构在这个成功项目中扮演的角色,启发了我写这本书,如此一来我就能够把Kent和其他一些人已经学会的“以重构方式改进软件质量”的知识,传播给所有读者。

所谓重构(refactoring)是这样一个过程:在不改变代码外在行为的前提下,对代码做出修改,以改进程序的内部结构。重构是一种经千锤百炼形成的有条不紊的程序整理方法,可以最大限度地减少整理过程中引入错误的概率。本质上说,重构就是在代码写好之后改进它的设计。

“在代码写好之后改进它的设计”?这种说法有点奇怪。按照目前对软件开发的理解,我们相信应该先设计而后编码:首先得有一个良好的设计,然后才能开始编码。但是,随着时间流逝,人们不断修改代码,于是根据原先设计所得的系统,整体结构逐渐衰弱。代码质量慢慢沉沦,编码工作从严谨的工程堕落为胡砍乱劈的随性行为。

“重构”正好与此相反。哪怕你手上有一个糟糕的设计,甚至是一堆混乱的代码,你也可以借由重构将它加工成设计良好的代码。重构的每个步骤都很简单,甚至显得有些过于简单:你只需要把某个字段从一个类移到另一个类,把某些代码从一个函数拉出来构成另一个函数,或是在继承体系中把某些代码推上推下就行了。但是,聚沙成塔,这些小小的修改累积起来就可以根本改善设计质量。这和一般常见的“软件会慢慢腐烂”的观点恰恰相反。

通过重构,你可以找出改变的平衡点。你会发现所谓设计不再是一切动作的前提,而是在整个开发过程中逐渐浮现出来。在系统构筑过程中,你可以学习如何强化设计,其间带来的互动可以让一个程序在开发过程中持续保有良好的设计。

本书是一本为专业程序员而写的重构指南。我的目的是告诉你如何以一种可控制且高效率的方式进行重构。你将学会如何有条不紊地改进程序结构,而且不会引入错误,这就是正确的重构方式。

按照传统,图书应该以引言开头。尽管我也同意这个原则,但是我发现以概括性的讨论或定义来介绍重构,实在不是件容易的事。所以我决定用一个实例作为开路先锋。第1章展示了一个小程序,其中有些常见的设计缺陷,我把它重构为更合格的面向对象程序。其间我们可以看到重构的过程,以及几个很有用的重构手法。如果你想知道重构到底是怎么回事,这一章不可不读。

第2章讨论重构的一般性原则、定义,以及进行重构的原因,我也大致介绍了重构所存在的一些问题。第3章由Kent Beck介绍如何嗅出代码中的“坏味道”,以及如何运用重构清除这些坏味道。测试在重构中扮演着非常重要的角色,第4章介绍如何运用一个简单而且开源的Java测试框架,在代码中构筑测试环境。

本书的核心部分——重构列表——从第5章延伸至第12章。它不能说是一份全面的列表,只是一个起步,其中包括迄今为止我在工作中整理下来的所有重构手法。每当我想做点什么(例如Replace Conditional with Polymorphism (255) )的时候,这份列表就会提醒我如何一步一步安全前进。我希望这是值得你日后一再回顾的部分。

本书介绍了其他人的许多研究成果,最后几章就是由他们之中的几位所客串写就的。Bill Opdyke在第13章记述他将重构技术应用于商业开发过程中遇到的一些问题。Don Roberts和John Brant在第14章展望重构技术的未来——自动化工具。我把最后一章(第15章)留给重构技术的顶尖大师Kent Beck来压轴。

本书范例全部使用Java撰写。重构当然也可以在其他语言中实现,而且我也希望这本书能够给其他语言使用者带来帮助。但我觉得我最好在本书中只使用Java,因为那是我最熟悉的语言。我会不时写下一些提示,告诉读者如何在其他语言中进行重构,不过我真心希望看到其他人在本书基础上针对其他语言写出更多重构方面的书籍。

为了很好地与读者交流我的想法,我没有使用Java语言中特别复杂的部分。所以我避免使用内嵌类、反射机制、线程以及很多强大的Java特性。这是因为我希望尽可能清楚地展现重构的核心。

我应该提醒你,这些重构手法并不针对并发或分布式编程。那些主题会引出更多的考虑,本书并未涉及。

本书的目标读者是专业程序员,也就是那些以编写软件为生的人。书中的示例和讨论,涉及大量需要详细阅读和理解的代码。这些例子都以Java写成。之所以选择Java,因为它是一种应用范围越来越广的语言,而且任何具备C语言背景的人都可以轻易理解它。Java是一种面向对象语言,而面向对象机制对于重构有很大帮助。

尽管关注对象是代码,但重构对于系统设计也有巨大影响。资深设计师和架构师也很有必要了解重构原理,并在自己的项目中运用重构技术。最好是由老资格、经验丰富的开发人员来引入重构技术,因为这样的人最能够透彻理解重构背后的原理,并根据情况加以调整,使之适用于特定工作领域。如果你使用的不是Java,这一点尤其重要,因为你必须把我给出的范例以其他语言改写。

下面我要告诉你,如何能够在不通读全书的情况下充分用好它。

就在本书一开始的此时此刻,我必须说:这本书让我欠了一大笔人情债,欠那些在过去十年中做了大量研究工作并开创重构领域的人一大笔债。这本书原本应该由他们之中的某个人来写,但最后却是由我这个有时间有精力的人捡了便宜。

重构技术的两位最早倡导者是Ward Cunningham和Kent Beck。他们很早就把重构作为开发过程的一个核心成分,并且在自己的开发过程中运用它。尤其需要说明的是,正因为和Kent的合作,才让我真正看到了重构的重要性,并直接激励了我写出这本书。

Ralph Johnson在UIUC(伊利诺伊大学厄巴纳—尚佩恩分校)领导了一个小组,这个小组因其在对象技术方面的实际贡献而声名远扬。Ralph很早就是重构技术的拥护者,他的一些学生也一直在研究这个课题。Bill Opdyke的博士论文是重构研究的第一份详细的书面成果。John Brant和Don Roberts则早已不满足于写文章了,他们写了一个工具叫Refactoring Browser(重构浏览器),对Smalltalk程序实施重构工程。

尽管有这些研究成果可以借鉴,我还是需要很多协助才能写出这本书。首先,并且也是最重要的,Kent Beck给了我巨大的帮助。Kent在底特律的某个酒吧和我谈起他正在为Smalltalk Report撰写一篇论文[Beck,hanoi],从此播下本书的第一颗种子。那次谈话不但让我开始注意到重构技术,而且我还从中“偷”了许多想法放到本书第1章。Kent也在其他很多方面帮助我,想出“代码味道”这个概念的是他,当我遇到各种困难时,鼓励我的人也是他,常常和我一起工作助我完成这本书的,还是他。我常常忍不住这么想:他完全可以自己把这本书写得更好。可惜有时间写书的人是我,所以我也只能希望自己不要做得太差。

写这本书的时候,我希望能把一些专家经验直接与你分享,所以我非常感激那些花时间为本书添砖加瓦的人。Kent Beck、John Brant、William Opdyke和Don Roberts编撰或合写了本书部分章节。此外Rich Garzaniti和Ron Jeffries帮我添加了一些有用的文中注解。

在任何一本此类技术书里,作者都会告诉你,技术审阅者提供了巨大的帮助。一如既往,Addison-Wesley出版社的Carter Shanklin和他的团队组织了强大的审稿人阵容,他们是:

他们大大提高了本书的可读性和准确性,并且至少去掉了一些任何手稿都可能会藏有的错误。在此我要特别感谢两个效果显著的建议,它们让我的书看上去耳目一新:Ward和Ron建议我以重构前后效果并列对照的方式写第1章,Joshua Kerievsky建议我在重构列表中画出代码草图。

除了正式审阅小组,还有很多非正式的审阅者。这些人或看过我的手稿,或关注我的网页并留下对我很有帮助的意见。他们是Leif Bennett、Michael Feathers、Michael Finney、Neil Galarneau、Hisham Ghazouli、Tony Gould、John Isner、Brian Marick、Ralf Reissing、John Salt、Mark Swanson、Dave Thomas和Don Wells。我相信肯定还有一些被我遗忘的人,请容我在此向你们道歉,并致上我的谢意。

有一个特别有趣的审阅小组,就是“恶名昭彰”的UIUC读书小组。本书反映出他们的众多研究成果,我要特别感谢他们用录音记录的意见。这个小组成员包括Fredrico“Fred”Balaguer、John Brant、Ian Chai、Brian Foote、Alejandra Garrido、Zhijiang“John”Han、Peter Hatch、Ralph Johnson、Songyu“Raymond”Lu、Dragos-Anton Manolescu、Hiroaki Nakamura、James Overturf、Don Roberts、Chieko Shirai、Les Tyrell和Joe Yoder。

任何好想法都需要在严酷的生产环境中接受检验。我看到重构对于克莱斯勒综合薪资系统(Chrysler Comprehensive Compensation,C3)发挥了巨大的作用。我要感谢那个团队的所有成员:Ann Anderson、Ed Anderi、Ralph Beattie、Kent Beck、David Bryant、Bob Coe、Marie DeArment、Margaret Fronczak、Rich Garzaniti、Dennis Gore、Brian Hacker、Chet Hendrickson、Ron Jeffries、Doug Joppie、David Kim、Paul Kowalsky、Debbie Mueller、Tom Murasky、Richard Nutter、Adrian Pantea、Matt Saigeon、Don Thomas和Don Wells。和他们一起工作所获得的第一手数据,巩固了我对重构原理和作用的认识。他们使用重构技术所取得的进步极大程度地帮助我看到:重构技术应用于历时多年的大型项目中,可以起到何等的作用!

再提一句,我得到了Addison-Wesley出版社的J.Carter Shanklin及其团队的帮助,包括Krysia Bebick、Susan Cestone、Chuck Dutton、Kristin Erickson、John Fuller、Christopher Guzikowski、Simone Payment和Genevieve Rajewski。与优秀出版商合作是一个令人愉快的经历,他们为我提供了大量的支持和帮助。

谈到支持,为一本书付出最多的,总是距离作者最近的人。那就是现在已成为我妻子的Cindy。感谢她,当我埋首工作的时候,还是一样爱我。即使在我投入写书时,也总会不断想起她。

Martin Fowler

于马萨诸塞州Melrose市

fowler @acm.org

http://www.martinfowler.com

http://www.refactoring.com


我该从何说起呢?按照传统做法,一开始介绍某个东西时,首先应该大致讲讲它的历史、主要原理等等。可是每当有人在会场上介绍这些东西,总是诱发我的瞌睡虫。我的思绪开始游荡,我的眼神开始迷离,直到主讲人秀出实例,我才能够提起精神。实例之所以可以拯救我于太虚之中,因为它让我看见事情在真正进行。谈原理,很容易流于泛泛,又很难说明如何实际应用。给出一个实例,就可以帮助我把事情认识清楚。

所以我决定从一个实例说起。在此过程中我将告诉你很多重构的道理,并且让你对重构过程有一点感觉。然后我才能向你展开通常的原理介绍。

但是,面对这个介绍性实例,我遇到了一个大问题。如果我选择一个大型程序,那么对程序自身的描述和对整个重构过程的描述就太复杂了,任何读者都不忍卒读(我试了一下,哪怕稍微复杂一点的例子都会超过100页)。如果我选择一个容易理解的小程序,又恐怕看不出重构的价值。

和任何立志要介绍“应用于真实世界中的有用技术”的人一样,我陷入了一个十分典型的两难困境。我只能指引你看看如何在一个我所选择的小程序中进行重构,然而坦白说,那个程序的规模根本不值得我们那么做。但是如果我给你看的代码是大系统的一部分,重构技术很快就变得重要起来。所以请你一边观赏这个小例子,一边想象它身处于一个大得多的系统。

实例非常简单。这是一个影片出租店用的程序,计算每一位顾客的消费金额并打印详单。操作者告诉程序:顾客租了哪些影片、租期多长,程序便根据租赁时间和影片类型算出费用。影片分为三类:普通片、儿童片和新片。除了计算费用,还要为常客计算积分,积分会根据租片种类是否为新片而有不同。

我用了几个类来表现这个例子中的元素。图1-1是一张UML类图,用以显示这些类。

图1-1 本例一开始的各个类。此图只显示最重要的特性。图中所用符号是
UML([Fowler,UML])

我会逐一列出这些类的代码。

Movie只是一个简单的纯数据类。

public class Movie { 

  public static final int CHILDRENS = 2;
  public static final int REGULAR = 0;
  public static final int NEW_RELEASE = 1;

  private String _title;
  private int _priceCode;

  public Movie(String title, int priceCode) {
      _title = title;
      _priceCode = priceCode;
  }

  public int getPriceCode() {
      return _priceCode;
  }

  public void setPriceCode(int arg) {
      _priceCode = arg;
  }
  public String getTitle (){
      return _title;
  };
}

Rental表示某个顾客租了一部影片。

class Rental {
    private Movie _movie;
    private int _daysRented;

    public Rental(Movie movie, int daysRented) {
      _movie = movie;
      _daysRented = daysRented;
    }
    public int getDaysRented() {
      return _daysRented;
    }
    public Movie getMovie() {
      return _movie;
  }
}

Customer类用来表示顾客。就像其他类一样,它也拥有数据和相应的访问函数:

class Customer {
  private String _name;
  private Vector _rentals = new Vector();

  public Customer (String name){
    _name = name;
  };

  public void addRental(Rental arg) {
    _rentals.addElement(arg);
  }
  public String getName (){
    return _name;
  };

Customer还提供了一个用于生成详单的函数,图1-2显示这个函数带来的交互过程。完整代码显示于下一页。

图1-2 statement()的交互过程

public String statement() {
        double totalAmount = 0; 
        int frequentRenterPoints = 0;
        Enumeration rentals = _rentals.elements();
        String result = "Rental Record for " + getName() + "\n";
        while (rentals.hasMoreElements()) {
            double thisAmount = 0;
            Rental each = (Rental) rentals.nextElement();

            //determine amounts for each line
            switch (each.getMovie().getPriceCode()) {
                case Movie.REGULAR:
                    thisAmount += 2;
                    if (each.getDaysRented() > 2)
                        thisAmount += (each.getDaysRented() - 2) * 1.5;
                    break;
                case Movie.NEW_RELEASE:
                    thisAmount += each.getDaysRented() * 3;
                    break;
                case Movie.CHILDRENS:
                    thisAmount += 1.5;
                    if (each.getDaysRented() > 3)
                        thisAmount += (each.getDaysRented() - 3) * 1.5;
                    break;

            }

            // add frequent renter points
            frequentRenterPoints ++;
            // add bonus for a two day new release rental
            if ((each.getMovie().getPriceCode() == Movie.NEW_RELEASE) &&
                each.getDaysRented() > 1) frequentRenterPoints ++;

            //show figures for this rental
            result += "\t" + each.getMovie().getTitle()+ "\t" +
                String.valueOf(thisAmount) + "\n";
            totalAmount += thisAmount;

        }
        //add footer lines
        result += "Amount owed is " + String.valueOf(totalAmount) + "\n";
        result += "You earned " + String.valueOf(frequentRenterPoints)+
            " frequent renter points";
        return result;
    }

这个起始程序给你留下什么印象?我会说它设计得不好,而且很明显不符合面向对象精神。对于这样一个小程序,这些缺点其实没有什么大不了的。快速而随性地设计一个简单的程序并没有错。但如果这是复杂系统中具有代表性的一段,那么我就真的要对这个程序信心动摇了。Customer里头那个长长的statement()做的事情实在太多了,它做了很多原本应该由其他类完成的事情。

即便如此,这个程序还是能正常工作。所以这只是美学意义上的判断,只是对丑陋代码的厌恶,是吗?如果不去修改这个系统,那么的确如此,编译器才不会在乎代码好不好看呢。但是当我们打算修改系统的时候,就涉及了人,而人在乎这些。差劲的系统是很难修改的,因为很难找到修改点。如果很难找到修改点,程序员就很有可能犯错,从而引入bug。

在这个例子里,我们的用户希望对系统做一点修改。首先他们希望以HTML格式输出详单,这样就可以直接在网页上显示,这非常符合时下的潮流。现在请你想一想,这个变化会带来什么影响。看看代码你就会发现,根本不可能在打印HTML报表的函数中复用目前statement()的任何行为。你唯一可以做的就是编写一个全新的htmlStatement(),大量重复statement()的行为。当然,现在做这个还不太费力,你可以把statement()复制一份然后按需要修改就是了。

但如果计费标准发生变化,又会如何?你必须同时修改statement()htmlStatement(),并确保两处修改的一致性。当你后续还要再修改时,复制粘贴带来的问题就浮现出来了。如果你编写的是一个永不需要修改的程序,那么剪剪贴贴就还好,但如果程序要保存很长时间,而且可能需要修改,复制粘贴行为就会造成潜在的威胁。

现在,第二个变化来了:用户希望改变影片分类规则,但是还没有决定怎么改。他们设想了几种方案,这些方案都会影响顾客消费和常客积分点的计算方式。作为一个经验丰富的开发者,你可以肯定:不论用户提出什么方案,你唯一能够获得的保证就是他们一定会在六个月之内再次修改它。

为了应付分类规则和计费规则的变化,程序必须对statement()做出修改。但如果我们把statement()内的代码复制到用以打印HTML详单的函数中,就必须确保将来的任何修改在两个地方保持一致。随着各种规则变得越来越复杂,适当的修改点越来越难找,不犯错的机会也越来越少。

你的态度也许倾向于尽量少修改程序:不管怎么说,它还运行得很好。你心里牢牢记着那句古老的工程谚语:“如果它没坏,就不要动它。”这个程序也许还没坏掉,但它造成了伤害。它让你的生活比较难过,因为你发现很难完成客户所需的修改。这时候,重构技术就该粉墨登场了。

 

如果你发现自己需要为程序添加一个特性,而代码结构使你无法很方便地达成目的,那就先重构那个程序,使特性的添加比较容易进行,然后再添加特性。

每当我要进行重构的时候,第一个步骤永远相同:我得为即将修改的代码建立一组可靠的测试环境。这些测试是必要的,因为尽管遵循重构手法可以使我避免绝大多数引入bug的情形,但我毕竟是人,毕竟有可能犯错。所以我需要可靠的测试。

由于statement()的运作结果是个字符串,所以我首先假设一些顾客,让他们每个人各租几部不同的影片,然后产生报表字符串。然后我就可以拿新字符串和手上已经检查过的参考字符串做比较。我把所有测试都设置好,只要在命令行输入一条Java命令就把它们统统运行起来。运行这些测试只需几秒钟,所以你会看到我经常运行它们。

测试过程中很重要的一部分,就是测试程序对于结果的报告方式。它们要么说“OK”,表示所有新字符串都和参考字符串一样,要么就列出失败清单,显示问题字符串的出现行号。这些测试都能够自我检验。是的,你必须让测试有能力自我检验,否则就得耗费大把时间来回比对,这会降低你的开发速度。

进行重构的时候,我们需要依赖测试,让它告诉我们是否引入了bug。好的测试是重构的根本。花时间建立一个优良的测试机制是完全值得的,因为当你修改程序时,好测试会给你必要的安全保障。测试机制在重构领域的地位实在太重要了,我将在第4章详细讨论它。

 

重构前,先检查自己是否有一套可靠的测试机制。这些测试必须有自我检验能力。

第一个明显引起我注意的就是长得离谱的statement()。每当看到这样长长的函数,我就想把它大卸八块。要知道,代码块越小,代码的功能就愈容易管理,代码的处理和移动也就越轻松。

本章重构过程的第一阶段中,我将说明如何把长长的函数切开,并把较小块的代码移至更合适的类。我希望降低代码重复量,从而使新的(打印HTML详单用的)函数更容易撰写。

第一个步骤是找出代码的逻辑泥团并运用Extract Method (110)。本例一个明显的逻辑泥团就是switch语句,把它提炼到独立函数中似乎比较好。

和任何重构手法一样,当我提炼一个函数时,我必须知道可能出什么错。如果提炼得不好,就可能给程序引入bug。所以重构之前我需要先想出安全做法。由于先前我已经进行过数次这类重构,所以我已经把安全步骤记录于后面的重构列表中了。

首先我得在这段代码里找出函数内的局部变量和参数。我找到了两个,eachthisAmount,前者并未被修改,后者会被修改。任何不会被修改的变量都可以被我当成参数传入新的函数,至于会被修改的变量就需格外小心。如果只有一个变量会被修改,我可以把它当作返回值。thisAmount是个临时变量,其值在每次循环起始处被设为0,并且在switch语句之前不会改变,所以我可以直接把新函数的返回值赋给它。

下面两页展示了重构前后的代码。重构前的代码在左页,重构后的代码在右页。凡是从函数提炼出来的代码,以及新代码所做的任何修改,只要我觉得不是明显到可以一眼看出,就以粗体字标示出来特别提醒你。本章剩余部分将延续这种左右比对形式。

public String statement() {
    double totalAmount = 0;
    int frequentRenterPoints = 0;
    Enumeration rentals = _rentals.elements();
    String result = "Rental Record for " + getName() + "\n";
    while (rentals.hasMoreElements()) {
        double thisAmount = 0;
        Rental each = (Rental) rentals.nextElement();
 
        //determine amounts for each line
        switch (each.getMovie().getPriceCode()) {
            case Movie.REGULAR:
                thisAmount += 2;
                if (each.getDaysRented() > 2)
                    thisAmount += (each.getDaysRented() - 2) * 1.5;
                break;
            case Movie.NEW_RELEASE:
                thisAmount += each.getDaysRented() * 3;
                break;
            case Movie.CHILDRENS:
                thisAmount += 1.5;
                if (each.getDaysRented() > 3)
                    thisAmount += (each.getDaysRented() - 3) * 1.5;
                break;
 
            }
 
            // add frequent renter points
            frequentRenterPoints ++;
            // add bonus for a two day new release rental
            if ((each.getMovie().getPriceCode() == Movie.NEW_RELEASE)
                && each.getDaysRented() > 1) frequentRenterPoints ++;
 
            //show figures for this rental
            result += "\t" + each.getMovie().getTitle()+ "\t" + 
                String.valueOf(thisAmount) + "\n";
            totalAmount += thisAmount;
 
    }
    //add footer lines
    result += "Amount owed is " + String.valueOf(totalAmount) + "\n";
    result += "You earned " + String.valueOf(frequentRenterPoints)
        + " frequent renter points";
    return result;
}
public String statement() {
      double totalAmount = 0;
      int frequentRenterPoints = 0;
      Enumeration rentals = _rentals.elements();
      String result = "Rental Record for " + getName() + "\n";
      while (rentals.hasMoreElements()) {
          double thisAmount = 0;
          Rental each = (Rental) rentals.nextElement();
 
          thisAmount = amountFor(each);
 
          // add frequent renter points
          frequentRenterPoints ++;
          // add bonus for a two day new release rental
          if ((each.getMovie().getPriceCode() == Movie.NEW_RELEASE) &&
              each.getDaysRented() > 1) frequentRenterPoints ++;
 
          //show figures for this rental
          result += "\t" + each.getMovie().getTitle()+ "\t" +
              String.valueOf(thisAmount) + "\n";
          totalAmount += thisAmount;
 
      }
      //add footer lines
      result += "Amount owed is " + String.valueOf(totalAmount) + "\n";
      result += "You earned " + String.valueOf(frequentRenterPoints) +
          " frequent renter points";
      return result;
 
  }
}
private int amountFor(Rental each) {
    int thisAmount = 0;
    switch (each.getMovie().getPriceCode()) {
        case Movie.REGULAR:
            thisAmount += 2;
            if (each.getDaysRented() > 2)
                thisAmount += (each.getDaysRented() - 2) * 1.5;
            break;
        case Movie.NEW_RELEASE:
            thisAmount += each.getDaysRented() * 3;
            break;
        case Movie.CHILDRENS:
            thisAmount += 1.5;
            if (each.getDaysRented() > 3)
                thisAmount += (each.getDaysRented() - 3) * 1.5;
            break;
    }
    return thisAmount;
}

每次做完这样的修改,我都要编译并测试。这一次起头不算太好——测试失败了,有两条测试数据告诉我发生了错误。一阵迷惑之后,我明白了自己犯的错误。我愚蠢地将amountFor()的返回值类型声明为int,而不是double

private double amountFor(Rental each) { 
    double thisAmount = 0;
    switch (each.getMovie().getPriceCode()) {
        case Movie.REGULAR:
            thisAmount += 2;
            if (each.getDaysRented() > 2)
                thisAmount += (each.getDaysRented() - 2) * 1.5;
            break;
        case Movie.NEW_RELEASE:
            thisAmount += each.getDaysRented() * 3;
            break;
        case Movie.CHILDRENS:
            thisAmount += 1.5;
            if (each.getDaysRented() > 3)
                thisAmount += (each.getDaysRented() - 3) * 1.5;
            break;
    }
    return thisAmount;
}

我经常犯这种愚蠢可笑的错误,而这种错误往往很难发现。在这里,Java无怨无尤地把double类型转换为int类型,而且还愉快地做了取整动作[Java Spec]。还好此处这个问题很容易发现,因为我做的修改很小,而且我有很好的测试。借着这个意外疏忽,我要阐述重构步骤的本质:由于每次修改的幅度都很小,所以任何错误都很容易发现。你不必耗费大把时间调试,哪怕你和我一样粗心。

 

重构技术就是以微小的步伐修改程序。如果你犯下错误,很容易便可发现它。

由于我用的是Java,所以我需要对代码做一些分析,决定如何处理局部变量。如果拥有相应的工具,这个工作就超级简单了。Smalltalk的确拥有这样的工具:Refactoring Browser。运用这个工具,重构过程非常轻松,我只需标示出需要重构的代码,在菜单中选择Extract Method,输入新的函数名称,一切就自动搞定。而且工具决不会像我那样犯下愚蠢可笑的错误。我非常盼望早日出现Java版本的重构工具![1]

现在,我已经把原来的函数分为两块,可以分别处理它们。我不喜欢amountFor()内的某些变量名称,现在正是修改它们的时候。

下面是原本的代码:

private double amountFor(Rental each) {
    double thisAmount = 0;
    switch (each.getMovie().getPriceCode()) {
        case Movie.REGULAR:
            thisAmount += 2;
            if (each.getDaysRented() > 2)
                thisAmount += (each.getDaysRented() - 2) * 1.5;
            break;
        case Movie.NEW_RELEASE:
            thisAmount += each.getDaysRented() * 3;
            break;
        case Movie.CHILDRENS:
            thisAmount += 1.5;
            if (each.getDaysRented() > 3)
                thisAmount += (each.getDaysRented() - 3) * 1.5;
            break;
    }
    return thisAmount;
}

下面是改名后的代码:

    private double amountFor(Rental aRental) {
        double result = 0;
        switch (aRental.getMovie().getPriceCode()) {
            case Movie.REGULAR:
                result += 2;
                if (aRental.getDaysRented() > 2)
                    result += (aRental.getDaysRented() - 2) * 1.5;
                break;
            case Movie.NEW_RELEASE:
                result += aRental.getDaysRented() * 3;
                break;
            case Movie.CHILDRENS:
                result += 1.5;
                if (aRental.getDaysRented() > 3)
                    result += (aRental.getDaysRented() - 3) * 1.5;
                break;
    }
    return result;
}

改名之后,我需要重新编译并测试,确保没有破坏任何东西。

更改变量名称是值得的行为吗?绝对值得。好的代码应该清楚表达出自己的功能,变量名称是代码清晰的关键。如果为了提高代码的清晰度,需要修改某些东西的名字,那么就大胆去做吧。只要有良好的查找/替换工具,更改名称并不困难。语言所提供的强类型检查以及你自己的测试机制会指出任何你遗漏的东西。记住:

 

任何一个傻瓜都能写出计算机可以理解的代码。唯有写出人类容易理解的代码,才是优秀的程序员。

代码应该表现自己的目的,这一点非常重要。阅读代码的时候,我经常进行重构。这样,随着对程序的理解逐渐加深,我也就不断地把这些理解嵌入代码中,这么一来才不会遗忘我曾经理解的东西。

观察amountFor()时,我发现这个函数使用了来自Rental类的信息,却没有使用来自Customer类的信息。

class Customer...
  private double amountFor(Rental aRental) {
      double result = 0;
      switch (aRental.getMovie().getPriceCode()) {
          case Movie.REGULAR:
              result += 2;
              if (aRental.getDaysRented() > 2)
                  result += (aRental.getDaysRented() - 2) * 1.5;
              break;
          case Movie.NEW_RELEASE:
              result += aRental.getDaysRented() * 3;
              break;
          case Movie.CHILDRENS:
              result += 1.5;
              if (aRental.getDaysRented() > 3)
                  result += (aRental.getDaysRented() - 3) * 1.5;
              break;
    }
    return result;
}

这立刻使我怀疑它是否被放错了位置。绝大多数情况下,函数应该放在它所使用的数据的所属对象内,所以amountFor()应该移到Rental类去。为了这么做,我要运用Move Method (142)。首先把代码复制到Rental类,调整代码使之适应新家,然后重新编译。像下面这样:

class Rental...
  double getCharge() {
      double result = 0;
      switch (getMovie().getPriceCode()) {
          case Movie.REGULAR:
              result += 2;
              if (getDaysRented() > 2)
                  result += (getDaysRented() - 2) * 1.5;
              break;
          case Movie.NEW_RELEASE:
              result += getDaysRented() * 3;
              break;
          case Movie.CHILDRENS:
              result += 1.5;
              if (getDaysRented() > 3)
                  result += (getDaysRented() - 3) * 1.5;
              break;
    }
    return result;
}

在这个例子里,“适应新家”意味着要去掉参数。此外,我还要在搬移的同时变更函数名称。

现在我可以测试新函数是否正常工作。只要改变Customer.amountFor()函数内容,使它委托调用新函数即可:

class Customer...
  private double amountFor(Rental aRental) {
    return aRental.getCharge();
  }

现在我可以编译并测试,看看有没有破坏什么东西。

下一个步骤是找出程序中对于旧函数的所有引用点,并修改它们,让它们改用新函数。下面是原本的程序:

class Customer...
    public String statement() {
        double totalAmount = 0;
        int frequentRenterPoints = 0;
        Enumeration rentals = _rentals.elements();
        String result = "Rental Record for " + getName() + "\n";
        while (rentals.hasMoreElements()) {
            double thisAmount = 0;
            Rental each = (Rental) rentals.nextElement();

            thisAmount = amountFor(each);

            // add frequent renter points
            frequentRenterPoints++;
            // add bonus for a two day new release rental
            if ((each.getMovie().getPriceCode() == Movie.NEW_RELEASE) &&
                each.getDaysRented() > 1) frequentRenterPoints++;

            // show figures for this rental
            result += "\t" + each.getMovie().getTitle() + "\t" +
                String.valueOf(thisAmount) + "\n";
            totalAmount += thisAmount;

        }
        // add footer lines
        result += "Amount owed is " + String.valueOf(totalAmount) + "\n";
        result += "You earned " + String.valueOf(frequentRenterPoints) +
             " frequent renter points";
        return result;
    }

本例之中,这个步骤很简单,因为我才刚刚产生新函数,只有一个地方使用了它。一般情况下,你得在可能运用该函数的所有类中查找一遍。

class Customer
    public String statement() {
        double totalAmount = 0;
        int frequentRenterPoints = 0;
        Enumeration rentals = _rentals.elements();
        String result = "Rental Record for " + getName() + "\n";
        while (rentals.hasMoreElements()) {
            double thisAmount = 0;
            Rental each = (Rental) rentals.nextElement();

            thisAmount = each.getCharge();

            // add frequent renter points
            frequentRenterPoints++;
            // add bonus for a two day new release rental
            if ((each.getMovie().getPriceCode() == Movie.NEW_RELEASE) &&
                each.getDaysRented() > 1) frequentRenterPoints++;

            // show figures for this rental
            result += "\t" + each.getMovie().getTitle() + "\t" +
                String.valueOf(thisAmount) + "\n";
            totalAmount += thisAmount;

        }
        // add footer lines
        result += "Amount owed is " + String.valueOf(totalAmount) + "\n";
        result += "You earned " + String.valueOf(frequentRenterPoints) +
            " frequent renter points";
        return result;
    }

做完这些修改之后(图1-3),下一件事就是去掉旧函数。编译器会告诉我是否我漏掉了什么。然后我进行测试,看看有没有破坏什么东西。

图1-3 搬移“金额计算”函数后,所有类的状态

有时候我会保留旧函数,让它调用新函数。如果旧函数是一个public函数,而我又不想修改其他类的接口,这便是一种有用的手法。

当然我还想对Rental.getCharge()做些修改,不过暂时到此为止,让我们回到Customer.statement()函数。

public String statement() {
    double totalAmount = 0;
    int frequentRenterPoints = 0;
    Enumeration rentals = _rentals.elements();
    String result = "Rental Record for " + getName() + "\n";
    while (rentals.hasMoreElements()) {
        double thisAmount = 0;
        Rental each = (Rental) rentals.nextElement();

        thisAmount = each.getCharge();

        // add frequent renter points
        frequentRenterPoints++;
        // add bonus for a two day new release rental
        if ((each.getMovie().getPriceCode() == Movie.NEW_RELEASE) &&
            each.getDaysRented() > 1) frequentRenterPoints++;

        // show figures for this rental
        result += "\t" + each.getMovie().getTitle() + "\t" +
            String.valueOf(thisAmount) + "\n";
        totalAmount += thisAmount;

    }
    // add footer lines
    result += "Amount owed is " + String.valueOf(totalAmount) + "\n";
    result += "You earned " + String.valueOf(frequentRenterPoints) +
        " frequent renter points";
    return result;
}

下一件引我注意的事是:thisAmount如今变得多余了。它接受each.getCharge()的执行结果,然后就不再有任何改变。所以我可以运用Replace Temp with Query (120)把thisAmount除去:

    public String statement() {
        double totalAmount = 0;
        int frequentRenterPoints = 0;
        Enumeration rentals = _rentals.elements();
        String result = "Rental Record for " + getName() + "\n";
        while (rentals.hasMoreElements()) {
            Rental each = (Rental) rentals.nextElement();

            // add frequent renter points
            frequentRenterPoints++;
            // add bonus for a two day new release rental
            if ((each.getMovie().getPriceCode() == Movie.NEW_RELEASE) &&
                each.getDaysRented() > 1) frequentRenterPoints++;

           // show figures for this rental
           result += "\t" + each.getMovie().getTitle() + "\t" + String.valueOf
               (each.getCharge()) + "\n";
           totalAmount += each.getCharge();

        }
        // add footer lines
        result += "Amount owed is " + String.valueOf(totalAmount) + "\n";
        result += "You earned " + String.valueOf(frequentRenterPoints)
             + " frequent renter points";
        return result;

        }
    }

做完这份修改,我立刻编译并测试,保证自己没有破坏任何东西。

我喜欢尽量除去这一类临时变量。临时变量往往引发问题,它们会导致大量参数被传来传去,而其实完全没有这种必要。你很容易跟丢它们,尤其在长长的函数之中更是如此。当然我这么做也需付出性能上的代价,例如本例的费用就被计算了两次。但是这很容易在Rental类中被优化。而且如果代码有合理的组织和管理,优化就会有很好的效果。我将在第69页的“重构与性能”一节详谈这个问题。

下一步要对“常客积分计算”做类似处理。积分的计算视影片种类而有不同,不过不像收费规则有那么多变化。看来似乎有理由把积分计算责任放在Rental类身上。首先需要针对“常客积分计算”这部分代码(粗体部分)运用Extract Method(110)重构手法:

    public String statement() {
        double totalAmount = 0;
        int frequentRenterPoints = 0;
        Enumeration rentals = _rentals.elements();
        String result = "Rental Record for " + getName() + "\n";
        while (rentals.hasMoreElements()) {
            Rental each = (Rental) rentals.nextElement();

            // add frequent renter points
 frequentRenterPoints++;
 // add bonus for a two day new release rental
 if ((each.getMovie().getPriceCode() == Movie.NEW_RELEASE)
 && each.getDaysRented() > 1) frequentRenterPoints++;

            // show figures for this rental
            result += "\t" + each.getMovie().getTitle() + "\t"
                + String.valueOf(each.getCharge()) + "\n";
            totalAmount += each.getCharge();

        }
        // add footer lines
        result += "Amount owed is " + String.valueOf(totalAmount) + "\n";
        result += "You earned " + String.valueOf(frequentRenterPoints)
            + " frequent renter points";
        return result;
    }
}

我们再来看局部变量。这里再一次用到了each,而它可以被当作参数传入新函数中。另一个临时变量是frequentRenterPoints。本例中,它在被使用之前已经先有初值,但提炼出来的函数并没有读取该值,所以我们不需要将它当作参数传进去,只需把新函数的返回值累加上去就行了。

我完成了函数的提炼,重新编译并测试,然后做一次搬移,再编译、再测试。重构时最好小步前进,如此一来犯错的概率最小。

class Customer...
    public String statement() {
        double totalAmount = 0;
        int frequentRenterPoints = 0;
        Enumeration rentals = _rentals.elements();
        String result = "Rental Record for " + getName() + "\n";
        while (rentals.hasMoreElements()) {
            Rental each = (Rental) rentals.nextElement();
 frequentRenterPoints += each.getFrequentRenterPoints();

            // show figures for this rental
            result += "\t" + each.getMovie().getTitle() + "\t" +
                String.valueOf(each.getCharge()) + "\n";
            totalAmount += each.getCharge();
        }

        // add footer lines
        result += "Amount owed is " + String.valueOf(totalAmount) + "\n";
        result += "You earned " + String.valueOf(frequentRenterPoints) +
             " frequent renter points";
        return result;
    }

    class Rental...
      int getFrequentRenterPoints() {
          if ((getMovie().getPriceCode() == Movie.NEW_RELEASE)
              && getDaysRented() > 1) return 2;
          else
              return 1;
    }

我利用重构前后的UML图(图1-4~图1-7)来总结刚才所做的修改。和先前一样,左页是修改前的图,右页是修改后的图。

图1-4 “常客积分计算”函数被提炼及搬移之前的类图

图1-5 “常客积分计算”函数被提炼及搬移之前的序列图

图1-6 “常客积分计算”函数被提炼及搬移之后的类图

图1-7 “常客积分计算”函数被提炼及搬移之后的序列图

正如我在前面提过的,临时变量可能是个问题。它们只在自己所属的函数中有效,所以它们会助长冗长而复杂的函数。这里有两个临时变量,两者都是用来从Customer对象相关的Rental对象中获得某个总量。不论ASCII版或HTML版都需要这些总量。我打算运用Replace Temp with Query (120),并利用查询函数(query method)来取代totalAmountfrequentRentalPoints这两个临时变量。由于类中的任何函数都可以调用上述查询函数,所以它能够促成较干净的设计,而减少冗长复杂的函数:

class Customer...
    public String statement() {
        double totalAmount = 0;
        int frequentRenterPoints = 0;
        Enumeration rentals = _rentals.elements();
        String result = "Rental Record for " + getName() + "\n";
        while (rentals.hasMoreElements()) {
            Rental each = (Rental) rentals.nextElement();
            frequentRenterPoints += each.getFrequentRenterPoints();

            // show figures for this rental
            result += "\t" + each.getMovie().getTitle() + "\t" +
                String.valueOf(each.getCharge()) + "\n";
            totalAmount += each.getCharge();
        }

        // add footer lines
        result += "Amount owed is " + String.valueOf(totalAmount) + "\n";
        result += "You earned " + String.valueOf(frequentRenterPoints) +
            " frequent renter points";
        return result;
    }

首先我用Customer类的getTotalCharge()取代totalAmount

    class Customer...

        public String statement() {
            int frequentRenterPoints = 0;
            Enumeration rentals = _rentals.elements();
            String result = "Rental Record for " + getName() + "\n";
            while (rentals.hasMoreElements()) {
                Rental each = (Rental) rentals.nextElement();
                frequentRenterPoints += each.getFrequentRenterPoints();

                // show figures for this rental
                result += "\t" + each.getMovie().getTitle() + "\t" +
                    String.valueOf(each.getCharge()) + "\n";
            }

            // add footer lines
            result += "Amount owed is " + String.valueOf(getTotalCharge()) + "\n";
            result += "You earned " + String.valueOf(frequentRenterPoints)
                + " frequent renter points";
            return result;
        }

    private double getTotalCharge() {
        double result = 0;
        Enumeration rentals = _rentals.elements();
    while (rentals.hasMoreElements()) {
        Rental each = (Rental) rentals.nextElement();
        result += each.getCharge();
    }
    return result;
}

这并不是Replace Temp with Query (120)的最简单情况。由于totalAmount在循环内部被赋值,我不得不把循环复制到查询函数中。

重构之后,重新编译并测试,然后以同样手法处理frequentRenterPoints:

class Customer...
    public String statement() {
        int frequentRenterPoints = 0;
        Enumeration rentals = _rentals.elements();
        String result = "Rental Record for " + getName() + "\n"',
        while (rentals.hasMoreElements()) {
            Rental each = (Rental) rentals.nextElement();
            frequentRenterPoints += each.getFrequentRenterPoints();

            //show figures for this rental
            result += "\t" + each.getMovie().getTitle()+ "\t" +
                String.valueOf(each.getCharge()) + "\n";
        }

        //add footer lines
        result += "Amount owed is "+ String.valueOf(getTotalCharge()) + "\n";
        result += "You earned " + String.valueOf(frequentRenterPoints) +
          " frequent renter points";
        return result;
    }
public String statement() {
    Enumeration rentals = _rentals.elements();
    String result = "Rental Record for " + getName() + "\n";
    while (rentals.hasMoreElements()) {
        Rental each = (Rental) rentals.nextElement();

        //show figures for this rental
        result += "\t" + each.getMovie().getTitle()+ "\t" +
            String.valueOf(each.getCharge()) + "\n";
    }

    //add footer lines
    result += "Amount owed is " + String.valueOf(getTotalCharge()) + "\n";
    result += "You earned "+ String.valueOf(getTotaIFrequentRenterPoints()) +
        " frequent renter points";
    return result;
}

private int getTotaIFrequentRenterPoints(){
    int result = 0;
    Enumeration rentals = _rentals.elements();
    while (rentals.hasMoreElements()) {
        Rental each = (Rental) rentals.nextElement();
        result += each.getFrequentRenterPoints();
    }
    return result;
}

图1-8~图1-11分别以UML类图和交互图展示statement()重构前后的变化。

图1-8 “总量计算”函数被提炼前的类图

图1-9 “总量计算”函数被提炼前的序列图

图1-10 “总量计算”函数被提炼后的类图

图1-11 “总量计算”函数被提炼后的序列图

做完这次重构,有必要停下来思考一下。大多数重构都会减少代码总量,但这次却增加了代码总量,那是因为Java 1.1需要大量语句来设置一个累加循环。哪怕只是一个简单的累加循环,每个元素只需一行代码,外围的支持代码也需要六行之多。这其实是任何程序员都熟悉的习惯写法,但代码数量还是太多了。[2]

这次重构存在另一个问题,那就是性能。原本代码只执行while循环一次,新版本要执行三次。如果while循环耗时很多,就可能大大降低程序的性能。单单为了这个原因,许多程序员就不愿进行这个重构动作。但是请注意我的用词:“如果”和“可能”。除非我进行评测,否则我无法确定循环的执行时间,也无法知道这个循环是否被经常使用以至于影响系统的整体性能。重构时你不必担心这些,优化时你才需要担心它们,但那时候你已处于一个比较有利的位置,有更多选择可以完成有效优化(见第69页的讨论)。

现在,Customer类内的任何代码都可以调用这些查询函数了。如果系统其他部分需要这些信息,也可以轻松地将查询函数加入Customer类接口。如果没有这些查询函数,其他函数就必须了解Rental类,并自行建立循环。在一个复杂系统中,这将使程序的编写难度和维护难度大大增加。

你可以很明显看出来,htmlStatement()statement()是不同的。现在,我应该脱下“重构”的帽子,戴上“添加功能”的帽子。我可以像下面这样编写htmlStatement(),并添加相应测试:

public String htmlStatement() {
    Enumeration rentals = _rentals.elements();
    String result = "<H1>Rentals for <EM>" + getName() + "</EM></H1><P>\n";
    while (rentals.hasMoreElements()) {
        Rental each = (Rental) rentals.nextElement();
        // show figures for each rental
        result += each.getMovie().getTitle() + ": "+
                     String.valueOf(each.getCharge()) + "<BR>\n";
    }
    // add footer lines
    result += "<P>You owe <EM>" + String.valueOf(getTotalCharge())+
        "</EM><P>\n";
    result += "On this rental you earned <EM>"+
         String.valueOf(getTotalFrequentRenterPoints())
              + "</EM> frequent renter points<P>";
         return result;
    }

通过计算逻辑的提炼,我可以完成一个htmlStatement(),并复用原本statement()内的所有计算。我不必剪剪贴贴,所以如果计算规则发生改变,我只需在程序中做一处修改。完成其他任何类型的详单也都很快而且很容易。这次重构并没有花很多时间,其中大半时间我用来弄清楚代码所做的事,而这是我无论如何都得做的。

前面有些代码是从ASCII版本中复制过来的——主要是循环设置部分。更深入的重构动作可以清除这些重复代码。我可以把处理表头(header)、表尾(footer)和详单细目的代码都分别提炼出来。在Form Template Method (345)实例中,你可以看到如何做这些动作。但是,现在用户又开始嘀咕了,他们准备修改影片分类规则。我们尚未清楚他们想怎么做,但似乎新分类法很快就要引入,现有的分类法马上就要变更。与之相应的费用计算方式和常客积分计算方式都还有待决定,现在就对程序做修改,肯定是愚蠢的。我必须进入费用计算和常客积分计算中,把因条件而异的代码[3]替换掉,这样才能为将来的改变镀上一层保护膜。现在,请重新戴回“重构”这顶帽子。

这个问题的第一部分是switch语句。最好不要在另一个对象的属性基础上运用switch语句。如果不得不使用,也应该在对象自己的数据上使用,而不是在别人的数据上使用。

class Rental...
  double getCharge() {
      double result = 0;
      switch (getMovie().getPriceCode()) {
          case Movie.REGULAR:
              result += 2;
              if (getDaysRented() > 2)
                  result += (getDaysRented() - 2) * 1.5;
              break;
          case Movie.NEW_RELEASE:
            result += getDaysRented() * 3;
            break;
          case Movie.CHILDRENS:
            result += 1.5;
            if (getDaysRented() > 3)
                result += (getDaysRented() - 3) * 1.5;
            break;
        }
        return result;
    }

这暗示getCharge()应该移到Movie类里去:

class Movie...
    double getCharge(int daysRented) {
        double result = 0;
        switch (getPriceCode()) {
            case Movie.REGULAR:
                result += 2;
                if (daysRented > 2)
                    result += (daysRented - 2) * 1.5;
                break;
            case Movie.NEW_RELEASE:
                result += daysRented * 3;
                break;
            case Movie.CHILDRENS:
                result += 1.5;
                if (daysRented > 3)
                    result += (daysRented - 3) * 1.5;
                break;
            }
            return result;
    }

为了让它得以运作,我必须把租期长度作为参数传递进去。当然,租期长度来自Rental对象。计算费用时需要两项数据:租期长度和影片类型。为什么我选择将租期长度传给Movie对象,而不是将影片类型传给Rental对象呢?因为本系统可能发生的变化是加入新影片类型,这种变化带有不稳定倾向。如果影片类型有所变化,我希望尽量控制它造成的影响,所以选择在Movie对象内计算费用。

我把上述计费方法放进Movie类,然后修改RentalgetCharge(),让它使用这个新函数(图1-12和图1-13):

class Rental... 
  double getCharge() {
      return _movie.getCharge(_daysRented);
  }

图1-12 本节所讨论的两个函数被移到Movie类内之前系统的类图

图1-13 本节所讨论的两个函数被移到Movie类内之后系统的类图

搬移getCharge()之后,我以相同手法处理常客积分计算。这样我就把根据影片类型而变化的所有东西,都放到了影片类型所属的类中。以下是重构前的代码:

class Rental...
  int getFrequentRenterPoints() {
      if ((getMovie().getPriceCode() == Movie.NEW_RELEASE) && getDaysRented() > 1)
          return 2;
      else
          return 1;
  }

重构后的代码如下:

class Rental...
  int getFrequentRenterPoints() {
      return _movie.getFrequentRenterPoints(_daysRented);
  }

class Movie...
  int getFrequentRenterPoints(int daysRented) {
      if ((getPriceCode() == Movie.NEW_RELEASE) && daysRented > 1)
          return 2;
      else
          return 1;
  }

我们有数种影片类型,它们以不同的方式回答相同的问题。这听起来很像子类的工作。我们可以建立Movie的三个子类,每个都有自己的计费法(图1-14)。

图1-14 以继承机制表现不同的影片类型

这么一来,我就可以用多态来取代switch语句了。很遗憾的是这里有个小问题,不能这么干。一部影片可以在生命周期内修改自己的分类,一个对象却不能在生命周期内修改自己所属的类。不过还是有一个解决方法:State模式[Gang of Four]。运用它之后,我们的类看起来像图1-15。

图1-15 运用State模式表现不同的影片

加入这一层间接性,我们就可以在Price对象内进行子类化动作[4],于是便可在任何必要时刻修改价格。

如果你很熟悉GoF(Gang of Four,四巨头)[5]所列的各种模式,可能会问:“这是一个State,还是一个Strategy?”答案取决于Price类究竟代表计费方式(此时我喜欢把它叫作Pricer还PricingStrategy),还是代表影片的某个状态(例如“Star Trek X是一部新片”)。在这个阶段,对于模式(和其名称)的选择反映出你对结构的想法。此刻我把它视为影片的某种状态。如果未来我觉得Strategy能更好地说明我的意图,我会再重构它,修改名字,以形成Strategy。

为了引入State模式,我使用三个重构手法。首先运用Replace Type Code with State/Strategy (227),将与类型相关的行为搬移至State模式内。然后运用Move Method (142)将switch语句移到Price类。最后运用Replace Conditional with Polymorphism (255)去掉switch语句。

首先我要使用Replace Type Code with State/Strategy (227)。第一步骤是针对类型代码使用Self Encapsulate Field (171),确保任何时候都通过取值函数和设值函数来访问类型代码。多数访问操作来自其他类,它们已经在使用取值函数。但构造函数仍然直接访问价格代码[6]

class Movie...
    public Movie(String title, int priceCode) {
      _title= title;
      _priceCode = priceCode;
    }

我可以用一个设值函数来代替:

class Movie
    public Movie(String title, int priceCode) {
      _title = title;
      setPriceCode(priceCode);
    }

然后编译并测试,确保没有破坏任何东西。现在我新建一个Price类,并在其中提供类型相关的行为。为了实现这一点,我在Price类内加入一个抽象函数,并在所有子类中加上对应的具体函数:

abstract class Price {
  abstract int getPriceCode();
}
class ChildrensPrice extends Price {
  int getPriceCode() {
      return Movie.CHILDRENS;
  }
}
class NewReleasePrice extends Price {
  int getPriceCode() {
      return Movie.NEW_RELEASE;
  }
}

class RegularPrice extends Price {
    int getPriceCode() {
        return Movie.REGULAR;
    }
}

然后就可以编译这些新建的类了。

现在,我需要修改Movie类内的“价格代号”访问函数(取值函数/设值函数,如下),让它们使用新类。下面是重构前的样子:

public int getPriceCode() {
    return _priceCode;
}
public setPriceCode(int arg) {
    _priceCode = arg;
}
private int _priceCode;

这意味着我必须在Movie类内保存一个Price对象,而不再是保存一个_priceCode变量。此外我还需要修改访问函数:

class Movie...
  public int getPriceCode() {
        return _price.getPriceCode();
    }
    public void setPriceCode(int arg) {
        switch (arg) {
        case REGULAR:
            _price = new RegularPrice();
            break;
        case CHILDRENS:
            _price = new ChildrensPrice();
            break;
        case NEW_RELEASE:
            _price = new NewReleasePrice();
            break;
        default:
            throw new IllegalArgumentException("Incorrect Price Code");
        }
    }

    private Price _price;

现在我可以重新编译并测试,那些比较复杂的函数根本不知道世界已经变了个样儿。

现在我要对getCharge()实施Move Method (142)。下面是重构前的代码:

class Movie...
  double getCharge(int daysRented) {
      double result = 0;
      switch (getPriceCode()) {
          case Movie.REGULAR:
              result += 2;
              if (daysRented > 2)
                  result += (daysRented - 2) * 1.5;
              break;
          case Movie.NEW_RELEASE:
              result += daysRented * 3;
              break;
          case Movie.CHILDRENS:
              result += 1.5;
              if (daysRented > 3)
                  result += (daysRented - 3) * 1.5;
              break;
        }
        return result;
    }

搬移动作很简单。下面是重构后的代码:

class Movie...
  double getCharge(int daysRented) {
      return _price.getCharge(daysRented);
  }

class Price...
  double getCharge(int daysRented) {
      double result = 0;
      switch (getPriceCode()) {
          case Movie.REGULAR:
              result += 2;
              if (daysRented > 2)
                  result += (daysRented - 2) * 1.5;
              break;
          case Movie.NEW_RELEASE:
              result += daysRented * 3;
              break;
          case Movie.CHILDRENS:
              result += 1.5;
              if (daysRented > 3)
                  result += (daysRented - 3) * 1.5;
              break;
    }
    return result;
}

搬移之后,我就可以开始运用Replace Conditional with Polymorphism (255)了。

下面是重构前的代码:

class Price...
  double getCharge(int daysRented) {
      double result = 0;
      switch (getPriceCode()) {
          case Movie.REGULAR:
              result += 2;
              if (daysRented > 2)
                  result += (daysRented - 2) * 1.5;
              break;
          case Movie.NEW_RELEASE:
              result += daysRented * 3;
              break;
          case Movie.CHILDRENS:
              result += 1.5;
              if (daysRented > 3)
                  result += (daysRented - 3) * 1.5;
              break;
    }
    return result;
}

我的做法是一次取出一个case分支,在相应的类建立一个覆盖函数。先从RegularPrice开始:

class RegularPrice...
    double getCharge(int daysRented) {
        double result = 2;
        if (daysRented > 2)
            result += (daysRented - 2) * 1.5;
        return result;
  }

这个函数覆盖了父类中的case语句,而我暂时还把后者留在原处不动。现在编译并测试,然后取出下一个case分支,再编译并测试。(为了保证被执行的确实是子类中的代码,我喜欢故意丢一个错误进去,然后让它运行,让测试失败。噢,我是不是有点太偏执了?)

class ChildrensPrice
    double getCharge(int daysRented) {
        double result = 1.5;
        if (daysRented > 3)
            result += (daysRented - 3) * 1.5;
        return result;
    }

class NewReleasePrice...
    double getCharge(int daysRented) {
        return daysRented * 3;
    }

处理完所有case分支之后,我就把Price.getCharge()声明为abstract

class Price...
    abstract double getCharge(int daysRented);

现在我可以运用同样手法处理getFrequentRenterPoints()。重构前的样子如下[7]

class Movie...
  int getFrequentRenterPoints(int daysRented) {
      if ((getPriceCode() == Movie.NEW_RELEASE) && daysRented > 1)
          return 2;
      else
          return 1;
  }

首先我把这个函数移到Price类:

class Movie...
  int getFrequentRenterPoints(int daysRented) {
        return _price.getFrequentRenterPoints(daysRented);
  }
class Price...
  int getFrequentRenterPoints(int daysRented) {
      if ((getPriceCode() == Movie.NEW_RELEASE) && daysRented > 1)
          return 2;
      else
          return 1;
}

但是这一次我不把超类函数声明为abstract。我只是为新片类型增加一个覆写函数,并在超类内留下一个已定义的函数,使它成为一种默认行为。

class NewReleasePrice
  int getFrequentRenterPoints(int daysRented) {
        return (daysRented > 1) ? 2 : 1;
  }

class Price...
  int getFrequentRenterPoints(int daysRented) {
        return 1;
  }

引入State模式花了我不少力气,值得吗?这么做的收获是:如果我要修改任何与价格有关的行为,或是添加新的定价标准,或是加入其他取决于价格的行为,程序的修改会容易得多。这个程序的其余部分并不知道我运用了State模式。对于我目前拥有的这么几个小量行为来说,任何功能或特性上的修改也许都不合算,但如果在一个更复杂的系统中,有十多个与价格相关的函数,程序的修改难易度就会有很大的区别。以上所有修改都是小步骤进行,进度似乎太过缓慢,但是我一次都没有打开过调试器,所以整个过程实际上很快就过去了。我写本章文字所用的时间,远比修改那些代码的时间多得多。

现在我已经完成了第二个重要的重构行为。从此,修改影片分类结构,或是改变费用计算规则、改变常客积分计算规则,都容易多了。图1-16和图1-17描述State模式对于价格信息所起的作用。

图1-16 加入State模式后的交互图

图1-17 加入State模式后的类图

这是一个简单的例子,但我希望它能让你对于“重构怎么做”有一点感觉。例中我已经示范了数个重构手法,包括Extract Method (110)、Move Method (142)、Replace Conditional with Polymorphism (255)、Self Encapsulate Field (171)、Replace Type Code with State/Strategy (227)。所有这些重构行为都使责任的分配更合理,代码的维护更轻松。重构后的程序风格,将迥异于过程化风格——后者也许是某些人习惯的风格。不过一旦你习惯了这种重构后的风格,就很难再满足于结构化风格了。

这个例子给我们最大的启发是重构的节奏:测试、小修改、测试、小修改、测试、小修改……正是这种节奏让重构得以快速而安全地前进。

如果你看懂了前面的例子,就应该已经理解重构是怎么回事了。现在,让我们了解一些背景、原理和理论(好在不太多)。

[1] 本书写作于1999年。十年之后,各种主要的Java IDE都已经提供了良好的重构支持。——译者注

[2] 十年之后的今天,Java在这方面已经有所改进。——译者注

[3] 指的是switch语句内的case子句。——译者注

[4] 如图1-15。——译者注

[5] Ralph Johnson和另外三位先生Erich Gamma、Richard Helm、John Vlissides合写了软件开发界驰名的《设计模式》,人称四巨头(Gang of Four)。——译者注

[6] 程序中的_priceCode。——译者注

[7] 其中有类型相关的行为,也就是“判断是否为新片”那个动作。——译者注


前面所举的例子应该已经让你对重构有了一个良好的感受。现在,我们应该回头看看重构的关键原则,以及重构时需要考虑的某些问题。

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

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

第一个定义是名词形式。

 

重构(名词):对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。

你可以在后续章节中找到许多重构范例,诸如Extract Method(110)和Pull Up Field (320),等等。一般而言,重构都是对软件的小改动,但重构之中还可以包含另一个重构。例如Extract Class (149)通常包含Move Method(142)和Move Field(146)。

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

 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

 

事不过三,三则重构。

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

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

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

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

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

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

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

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

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

为什么重构有用

——Kent Beck

程序有两面价值:“今天可以为你做什么”和“明天可以为你做什么”。大多数时候,我们都只关注自己今天想要程序做什么。不论是修复错误或是添加特性,我们都是为了让程序能力更强,让它在今天更有价值。

但是系统当下的行为,只是整个故事的一部分,如果没有认清这一点,你无法长期从事编程工作。如果你为求完成今天的任务而不择手段,导致不可能在明天完成明天的任务,那么最终还是会失败。但是,你知道自己今天需要什么,却不一定知道自己明天需要什么。也许你可以猜到明天的需求,也许吧,但肯定还有些事情出乎你的意料。

对于今天的工作,我了解得很充分;对于明天的工作,我了解得不够充分。但如果我纯粹只是为今天工作,明天我将完全无法工作。

重构是一条摆脱困境的道路。如果你发现昨天的决定已经不适合今天的情况,放心改变这个决定就是,然后你就可以完成今天的工作了。明天,喔,明天回头看今天的理解也许觉得很幼稚,那时你还可以改变你的理解。

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

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

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

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

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

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

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

间接层和重构

——Kent Beck

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

——Dennis DeBruler

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

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

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

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

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

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

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

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

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

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

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

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

重构经常出问题的一个领域就是数据库。绝大多数商用程序都与它们背后的数据库结构紧密耦合在一起,这也是数据库结构如此难以修改的原因之一。另一个原因是数据迁移(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对此有一个很好的看法。他把未完成的重构工作形容为“债务”。很多公司都需要借债来使自己更有效地运转。但是借债就得付利息,过于复杂的代码所造成的维护和扩展的额外成本就是利息。你可以承受一定程度的利息,但如果利息太高你就会被压垮。把债务管理好是很重要的,你应该随时通过重构来偿还一部分债务。

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

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

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

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

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

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

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

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

劳而无获

——Ron Jeffries

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

重构不止如此。本书中我把重构看作整个软件开发过程的一个关键环节。最早认识重构重要性的两个人是Ward Cunningham和Kent Beck,他们早在20世纪80年代就开始使用Smalltalk,那是个特别适合重构的环境。Smalltalk是一个十分动态的环境,你可以很快写出极具功能的软件。Smalltalk的“编译/连接/执行”周期非常短,因此很容易快速修改代码。它支持面向对象,所以也能够提供强大的工具,最大限度地将修改的影响隐藏于定义良好的接口背后。Ward和Kent努力发展出一套适合这类环境的软件开发过程(如今,Kent把这种风格叫作极限编程[Beck,XP])。他们意识到:重构对于提高他们的生产力非常重要。从那时起他们就一直在工作中运用重构技术,在正式的软件项目中使用它,并不断精炼这个程序。

Ward和Kent的思想对Smalltalk社群产生了极大影响,重构概念也成为Smalltalk文化中的一个重要元素。Smalltalk社群的另一位领袖是Ralph Johnson,伊利诺斯大学乌尔班纳分校教授,著名的GoF [Gang of Four]之一。Ralph最大的兴趣之一就是开发软件框架。他揭示了重构对于灵活高效框架的开发帮助。

Bill Opdyke是Ralph的博士研究生,对框架也很感兴趣。他看到了重构的潜在价值,并看到重构应用于Smalltalk之外的其他语言的可能性。他的技术背景是电话交换系统的开发。在这种系统中,大量的复杂情况与日俱增,而且非常难以修改。Bill的博士研究就是从工具构筑者的角度来看待重构。通过研究,Bill发现:在C++的框架开发项目中,重构很有用。他也研究了极有必要的“语义保持性(semantics- preserving)重构”及其证明方式,以及如何用工具实现重构。时至今日,Bill的博士论文[Opdyke]仍然是重构领域中最有价值、最丰硕的研究成果。此外他为本书撰写了第13章。

我还记得1992年OOPSLA大会上见到Bill的情景。我们坐在一间咖啡厅里,讨论当时我正为保健业务构筑的一个概念框架中的某些工作。Bill跟我谈起他的研究成果,我还记得自己当时的想法:“有趣,但并非真的那么重要。”唉,我完全错了。

John Brant和Don Roberts将重构中的“工具”构想发扬光大,开发了一个名为Refactoring Browser(重构浏览器)的Smalltalk重构工具。他们撰写了本书第14章,其中对重构工具做了更多介绍。

那么,我呢?我一直有清理代码的倾向,但从来没有想到这会如此重要。后来我和Kent一起做个项目,看到他使用重构手法,也看到重构对生产性能和产品质量带来的影响。这份体验让我相信:重构是一门非常重要的技术。但是,在重构的学习和推广过程中我遇到了挫折,因为我拿不出任何一本书给程序员看,也没有任何一位专家打算写出这样一本书。所以,在这些专家的帮助下,我写下了这本书。

优化一个薪资系统

——Rich Garzaniti

将C3系统移至GemStone之前,我们用了相当长的时间开发它。开发过程中我们无可避免地发现程序不够快,于是找了Jim Haungs(GemSmith中的一位好手),请他帮我们优化这个系统。

Jim先用一点时间让他的团队了解系统运作方式,然后以GemStone的ProfMonitor特性编写出一个性能度量工具,将它插入我们的功能测试中。这个工具可以显示系统产生的对象数量,以及这些对象的诞生点。

令我们吃惊的是:创建量最大的对象竟是字符串。其中最大的工作量则是反复产生12 000字节大小的字符串。这很特别,因为这些字符串实在太大,连GemStone惯用的垃圾回收设施都无法处理它。由于它是如此巨大,每当被创建出来,GemStone都会将它分页至磁盘上。也就是说,字符串的创建竟然用上了I/O子系统,而每次输出记录时都要产生这样的字符串三次!

我们的第一个解决办法是把一个12 000字节大小的字符串缓存起来,这能解决一大半问题。后来我们又加以修改,将它直接写入一个文件流,从而避免产生字符串。

解决了“巨大字符串”问题后,Jim的度量工具又发现了一些类似问题,只不过字符串稍微小一些:800字节、500字节,等等,我们也都对它们改用文件流,于是问题都解决了。

使用这些技术,我们稳步提高了系统性能。开发过程中原本似乎需要1 000小时以上才能完成的薪资计算,实际运作时只花40小时。一个月后,我们把时间缩短到18小时。正式投入运转时只花12小时。经过一年的运行和改善后,全部计算只需9小时。

我们的最大改进就是:将程序放在多处理器计算机上,以多线程方式运行。最初这个系统并非按照多线程思维来设计,但由于代码构造良好,所以我们只花了三天时间就让它同时运行在多个线程上。现在,薪资的计算只需2小时。

在Jim提供工具使我们得以在实际操作中度量系统性能之前,我们也猜测过问题所在。但如果只靠猜测,我们需要很长的时间才能试出真正的解法。真实的度量指出了一个完全不同的方向,并大大加快了我们的进度。

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

[2] 数据库重构的经验也已经由Soctt Ambler等人总结成书,相关内容请参考《数据库重构》http://www.douban.com/subject/1954438/)。——译者注


相关图书

有限元基础与COMSOL案例分析
有限元基础与COMSOL案例分析
程序员的README
程序员的README
现代控制系统(第14版)
现代控制系统(第14版)
现代软件工程:如何高效构建软件
现代软件工程:如何高效构建软件
GitLab CI/CD 从入门到实战
GitLab CI/CD 从入门到实战
科学知识图谱:工具、方法与应用
科学知识图谱:工具、方法与应用

相关文章

相关课程