这是关于refactoring思考的第一部分内容。本文将介绍refactoring的基本概念、定义,同时解释正确、安全进行refactoring需要坚持的几个原则。
介绍
代码太容易变坏。代码总是趋向于有更大的类、更长的方法、更多的开关语句和更深的条件嵌套。重复代码随处可见,特别是那些初看相似细看又不同的代码泛滥于整个系统:条件表达式,循环结构、集合枚举….信息被共享于系统一些关系甚少的组成部分之间,通常,这使得系统中几乎所有的重要信息都变成全局或者重复。你根本不能看到这种代码还有什么良好的设计。(如果有的话,也已经不可辨识了。)
这样的代码难以理解,更不要说对它加以修改。如果你关心系统体系结构、设计,或者是一个好程序,你的第一反应就是拒绝工作于这样的代码。你会说:"这么烂的代码,让我修改,还不如重写。"然而,你不大可能完全重写已经能够甚至是正在运作的系统,你不能保证新的系统能够实现全部的原有功能。更何况,你不是生活在真空,还有更多的投资、交付、竞争压力。
于是你使用一种quick-and-dirty的方法,如果系统有问题,那么就直接找到这个问题,便当地修改它。如果要增加一个新功能,你会从原来的系统中找到一块相近的代码,拷出来,作些修改。对于原来的系统,你想,既然我不能重头写过,而且它们已经在运作,让它去吧。然后,你增加的代码变成了下一个程序员咒骂的对象。系统越来越难以理解,维护越来越困难、越来越昂贵。系统变成了一个十足的大泥球。
这种情况是每一个人都不愿意碰到的,但是奇怪的是,这样的情景一次又一次出现在大多数人的编程生涯中。这是因为我们不知道该如何解决。
解决这个问题的最好办法当然是让它不要发生。然而,要阻止代码的腐化,你需要付出额外的代价。每次在修改或增加代码之前,你都要看一看手上的这些代码。如果它有很好的味道,那么你应该能够很方便地加入新的功能。如果你需要花很长的时间去理解原来的代码,花更长的时间去增加和修改代码。那么,先放下手里的活,让我们来做Refactoring。
什么是Refactoring?
每个人似乎都有自己的Refactoring的定义,尽管他们讲的就是同一件事情。在那么多的定义中,最先对Refactoring进行理论研究的Raloh
Johnson的话显然更有说服力:
Refactoring是使用各种手段重新整理一个对象设计的过程,目的是为了让设计更加灵活并且/或者更可重用。你可能有几个理由来做这件事情,其中效率和可维护性可能是最重要的原因。
Martin Fowler[Fowler]把Refactoring定义为两部分,一部分为名词形式:Refactoring(名词):在不改变可观察行为的前提下,对软件内部结构的改变,目的是使它更易于理解并且能够更廉价地进行改变。另一部分则是动词形式:Refactor(动词):通过应用一系列不改变软件可观察行为的refactoring来重构一个软件。
Martin Fowler的名词形式就是说Refactoring是对软件内部结构的改变,这种改变的前提是不能改变程序的可观察的行为,这种改变的目的就是为了让它更容易理解,更容易被修改。动词形式则突出Refactor是一种软件重构行为,这种重构的方法就是应用一系列的refactoring。
软件结构可以因为各种各样的原因而被改变,如进行打印美化、性能优化等等,但只有出于可理解性、可修改、可维护目的的改变才是Refactoring。这种改变必须保持可观察的行为,按照Martin的话来说,就是Refactoring之前软件实现什么功能,之后照样实现什么功能。任何用户,不管是终端用户还是其他的程序员,都不需要知道某些东西发生了变化。
Refactoring原则
Two Hats(两顶帽子)
Kent Beck提出这个比方。他说,如果你在使用Refactoring开发软件,你把开发时间分给两个不同的活动:增加功能和refactoring。增加功能时,你不应该改变任何已经存在的代码,你只是在增加新功能。这个时候,你增加新的测试,然后让这些新测试能够通过。当你换一顶帽子refactoring时,你要记住你不应该增加任何新功能,你只是在重构代码。你不会增加新的测试(除非发现以前漏掉了一个)。只有当你的Refactoring改变了一个原先代码的接口时才改变某些测试。
在一个软件的开发过程中,你可能频繁地交换这两顶帽子。你开始增加一个新功能,这时你认识到,如果原来的代码结构更好一点,新功能就能够更方便地加入。因此,你脱下增加功能的帽子,换上refactoring的帽子。一会儿,代码结构变好了,你脱下refactoring的帽子,戴上增加功能的帽子。增加了新功能以后,你可能发现你的代码使得程序的结构难以理解,这时你又交换帽子。
关于两顶帽子交换的故事不断地发生在你的日常开发中,但是不管你带着哪一定帽子,一定要记住带一定帽子只做一件事情。
Unit Test
保持代码的可观察行为不变称为Refactoring的安全性。Refactoring工具用半形式化的理论证明来保证Refactoring的安全性。
但是,要从理论上完全证明系统的可观察行为保持不变,虽然不是说不可能,也是十分困难的。工具也有自己的缺陷。首先,目前对于Refactoring的理论研究并非十分成熟,某些曾经被证明安全的Refactoring最近被发现在特定的场合下并不安全。其次,目前的工具不能很好地支持"非正式"的Refactoring操作,如果你发现一种新的Refactoring技巧,工具不能立即让这种refactoring为你所用。
自动化的测试是检验Refactoring安全性非常方便而且有效的方法。虽然我们不能穷尽整个系统中所有的测试,但如果在Refactoring之前成功的测试现在失败了,我们就会知道刚刚做的Refactoring破坏了系统的可观察行为。自动化测试能够在程序员不进行人工干预的情况下自动检测到这样的行为破坏。
自动化测试中最实用的工具是XUnit系列单元测试框架,该框架最初由Kent Beck和Eric Gamma为Smalltalk社团而开发
。Eric Gamma对测试的重要性曾经有过这样的话:你写的测试越少,你的生产力就越低,同时你的代码就变得越不稳定。你越是没有生产力、越缺少准确性,你承受的压力就越大......
下面的片断来自Javaworld,两个Sun开发者展示了它们对单元测试的狂热以及展示了它们扩展单元测试来检查象EJB这样的分布式控件:
我们从来没有过度测试软件,相反我们很少做得足够。。。但愿测试是软件开发过程中关键但却经常被误解的一部分。对每一个代码单元而言,单元测试确保他自己能够工作,独立于其他单元。在面向对象语言中,一个单元通常,但并不总是,一个类的等价物。如果一个开发者确信应用程序的每一个片断能够按照它们被设计的方式正确工作,那么他们会认识到组装得到的应用程序发生的问题必定来自于把所有部件组合起来的过程中。单元测试告诉程序员一个应用程序'
pieces are working as designed'。
我曾经认为自己是很好的程序员。认为自己的代码几乎不可能出错。但事实上,我没有任何证据可以证明这一点,同样我也没有信心我的代码就一定不会出错,或者当我增加一项新功能时,原先的行为一定没有遭到破坏。另一方面,我认为太多的测试于事无补,测试只能停留在理论之上,或只有那些实力强劲的大公司才能做到。
这个观点在1999年我看到Kent Beck和Gamma的Junit测试框架之后被完全推翻了。JUnit是XP的重要工具之一。XP提倡一个规则叫做test-first
design。采用Test First Design方法,你在编写一个新功能前先写一个单元测试,用它来测试实现新功能需要但可能会出错的代码。这意味着,测试首先是失败的,写代码的目的就是为了让这些测试能够成功运行。
JUnit的简单、易用和强大的功能几乎让我立刻接纳了单元测试的思想,不但因为它可以让我有证据表明我的代码是正确的,更重要的是在我每次对代码进行修改的同时,我有信心所有的变化都不会影响原有的功能。测试已经成为我所有代码的一部分。关于这一点,Kent
Beck在它的《Extreme Programming Explained》中指出:
简直不存在一个不带自动化测试的程序。程序员编写单元测试,因而他们能够确信程序操作的正确性成为程序本身的一部分。同时,客户编写功能测试,因而他们能够确信程序操作的正确性成为程序本身的一部分。结果就是,随着时间的推移,一个程序变得越来越可信-他变得更加能够接受改变,而不是相反。
单元测试的基本过程如下:
- 设计一个应当失败的测试
- 编译器应当立刻反映出失败。因为测试中需要使用的类和方法还没有实现。
- 如果有编译错误,完成代码,只要让编译通过即可,这时的代码只反映了代码的意图而并非实现。
- 在JUnit中运行所有的测试,它应当指示测试失败
- 编写实际代码,目的是为了让测试能够成功。
- 在Junit中运行所有的测试,保证所有的测试全部通过,一旦所有的测试通过,停止编码。
- 考虑一下是否有其他情况没有考虑到,编写测试,运行它,必要时修改代码,直至测试通过
在编写测试的时候,要注意对测试的内容加以考虑,并不是测试越多越好.Kent Beck说:
你不必为每一个方法编写一个测试,只有那些可能出错的具有生产力的方法才需要。有时你仅仅想找出某些事情是可能的。你探索一半个小时。是的,它有可能发生。现在你抛弃你的代码并且从单元测试重新开始。
另一位作者Eric Gamma说:
你总是能够写更多的测试。但是,你很快就会发现,你能够想象到的测试中只有一部分才是真正有用的。你所需要的是为那些即使你认为它们应当工作还会出错的地方编写测试,或者是你认为可能会失败但最终还是成功的地方。另一种方法是以成本/收益的角度来考虑。你应该编写反馈信息物有所值的测试。
你可能会认为单元测试虽然好,但是它会增加你的编程负担,而别人花钱是请你来写代码,而不是来写测试的。但是WILLAM WAKE说:
编写单元测试可能是一件乏味的事情,但是它们为你节省将来的时间(通过捕获改变后的bug).相对不明显,但同样重要的是,他们能够节约你现在的时间:测试聚焦于设计和实现的简单性,它们支持refactoring,它们在你开发一项特性的同时对它进行验证。
你还会认为单元测试可能增加你的维护量,因为如果代码发生了改变,相应的测试也需要做出改变。事实上,测试只会让你的维护更快,因为它们让你对你所做出的改变更有信心,如果你做错了一件事,测试同时也会提醒你。如果接口发生了改变,你当然需要改变你的接口,但这一点并非太难。
单元测试是程序的一部分,而不是独立的测试部门所应完成的任务。这就是所谓的自测试代码。程序员可能花费一些时间在编写代码,花费一些时间在理解别人的代码,花费一些时间在做设计,但他们最多的时间是在做调试。任何一个人都有这样一种遭遇,一个小小的问题可能花费你一个下午、一天,甚至是几天的时间来调试。要改正一个bug往往很简单,但是要找到这样的bug却是一个大问题。如果你的代码能够带有自动化的自测试,那么一旦你加入一个新的功能,旧的测试会告诉你那些原来的代码存在着bug,而新加入的测试则告诉哪些新加入的代码引入了bug。
Small step
Refactoring的另一个原则就是每一步总是做很少的工作,每做少量修改,就进行测试,保证refactoring的程序是安全的。
如果你一次做了太多的修改,那么就有可能介入很多的bug,代码将难以调试。如果你发现修改并不正确,要想返回到原来的状态也十分困难。
这些细小的步骤包括:
- 寻找需要refactoring的地方。这些地方可能在理解代码、扩展系统的时候被发现。或者是通过闻代码的味道而找到。或者通过某些代码分析工具。
- 如果对需要Refactoring的代码还没有单元测试,首先编写单元测试。如果已经有了单元测试,看一看该单元测试是否考虑到了你所面对的问题,不然就完善它。
- 运行单元测试,保证原先的代码是正确的。
- 根据代码所呈现的现象在头脑中反映该做什么样的refactoring,或者找一本关于Refactoring分类目录的书放在面前,查找相似的情况,然后按照书中的指示一步一步进行Refactoring。
- 每一步完成时,都进行单元测试,保证它的安全性,也就是可观察行为没有发生改变。
- 如果Refactoring改变了接口,你需要修改测试套件
- 全部做完后,进行全部的单元测试,功能测试,保证整个系统的可观察行为不受影响。如果你按照这样的步骤去做Refactoring,那么可能出错的机会就很小,正如Kent
Beck所说:"I'm not a great programmer; I'm just a good programmer
with great habits".
要求使用小步骤渐进地Refactoring并不完全出于对实践易行的考虑。
Ralph Johnson在伊利诺斯州立大学领导的一个研究小组是Refactoring理论的引导者和最重要的理论研究团体。其中William
Opdyke 1992年的博士论文《Refactoring Object-Oriented Framework》是公认的Refactoring第一位正式提出者。
为什么要 Refactoring
为什么要去改变已经可以正确运行的软件?这样的改变是否影响到我们的设计,从而进一步改变我们对于面向对象系统进行设计的方法和思路?本部分试图回答这些问题。
Refactoring虽然需要更多的"额外工作",但是它给我们带来的各种好处显然值得我们做出这样的努力:
简化测试
一个好的Refactoring实现能够减少对新设计的测试量.因为Refactoring的每一步都保持可观察的行为,也就是保持系统的所有单元测试都能顺利通过。所以只有发生改变的代码需要测试.这种增量测试使得所有的后续测试都建立在坚实的基础之上,整个系统测试的复杂性大大降低。
更简单的设计
Refactoring降低初始设计的复杂程度.Gamma指出复杂设计模式的一个陷阱是过度狂热:"模式有其成本(间接性、复杂化),因此设计应该达到需求所要求的灵活性,而不是越灵活越好"。如果设计试图介入太多以后可能需要的灵活性,就会产生不必要的复杂和错误。Refactoring能够以多种方式扩展设计。他鼓励为手头的任务建立刚刚合适的解决方案,当新的需求来到时,可以通过Refactoring扩展设计。
Refactoring增进软件可理解性
程序的最终目的是为了指引计算机完成人们需要完成的事情。但是,要完成这个目标并非想象的那么容易。
程序编写是人的活动,人首先要理解才能行动。所以,源代码的另一个作用就是用于交流的工具。其他人可能会在几个月之后修改你的代码,如果连理解你的代码都做不到,他又如何完成所需的修改呢?我们通常会忘掉源代码的这种用处,尽管它可能是源代码更重要的用处。不然,我们为什么发展高级语言、面向对象语言,为什么我们不直接使用汇编语言甚至是机器语言来编写程序?难道我们真的在意计算机多花了几个CPU周期去完成一件事?
如果一个人能够理解我们的代码,他可能只需要一天的时间完成一个增加功能的任务,而如果他不理解我们的代码,可能需要花上一个礼拜或更长的时间。这里的问题是,我们在编写代码的时候不但需要考虑计算机CPU的想法,更要把以后的开发者放在心上。除非,你写代码的唯一目的就是把它丢掉。你不想让任何别的开发人员用到这段代码,包括你自己。因为你不可能记得所有你写过的代码,如果你经常回过头去看一下自己的代码,你就会体会到代码的可理解是如何重要。
Refactoring可以使得你的代码更理解,Refactoring支持更小的类、更短的方法、更少的局部变量、更小的系统耦合,Refactoring要求你更加小心自己的命名机制,让名字反映出你的意图。如果哪一块代码太复杂以至于哪一理解,你都需要对他进行Refactor。
你可能认为,反映代码的意图应当是注释和文档的责任。假设你写好了一段程序,给他加上注释,来说明你这段代码完成了什么。每次代码发生改变,你都需要修改注释。而其实代码本身应当足以说明这个问题。如果代码不能反映自己的意图,即使是再多的注释也不足以让你理解代码的所有机制,除非你把代码的每一句话都加上注释。甚之,这种重复的责任使得一旦注释和代码发生不一致,它反而会阻碍你对代码的理解。而任何一个程序员都不愿意写太多的注释和文档。所以Martin
fowler说:
When you feel the need to write a comment,first try to refactor
the code so that any comment becomes superfluous。
Martin Fowler同时指出,Refactoring不但能够增加别人对你代码的理解,而且是一种非常好的理解别人代码,学习别人代码的方法。通常,当你拿到一大堆代码时,你可能会觉得一片茫然,不知道从何处开始。学习别人代码最好的方法就是对代码进行进行Refactor。如果你发现自己不能理解一段代码,那么试图使用对自己说,哦,这段代码可能是在做什么事情。给他一个有意义的名字,refactor它。Refactor使得你对代码的理解不是仅仅停留在脑袋中,而是看到代码确实按照你的理解再发生变化。如果你的refactor改变了系统的行为,那么说明你的理解还有问题。你必须返回重来。
Refactoring 改进软件的设计
许多人把编程看作是一种低级活动。他们认为代码仅仅是设计的附属物。然而正是代码,而不是你脑袋里或纸上的设计,真正驱动计算机完成你想做的事情。而绝大多数的bug也产生于编码阶段。
很多方法学认为通过分析和设计的严格化就能产生更高质量的软件,这实际上是不可能的。正如Brain Foote和Joseph Yoder在《Big
Ball of MUD》一文中指出,虽然许多作者提出了很多理想上非常完美的体系结构,如Layer、PIPELINE等等。但在实践中,几乎从来都不能看到这么结构清晰的系统。
这一方面是由于分析和设计一个领域的应用程序需要对该领域丰富的知识,而这种知识不是在一开始都能获得的。我们通常需要在实践反馈的过程中才能一步步加深自己对该领域的理解。因而,我们一开始的设计可能并不能正确反映系统的内在本质,所以也不可能在代码中得到很好的反映。
另一方面,即使一开始的设计是完好的,随着用户对系统使用的深入,新的需求可能会被加入,旧的需求会被修改、删除。一个最先的设计不可能完全预料到这些变化。
一旦实现开始偏离最初的设计,那么它的代码将不受控制,从而不可避免地开始腐蚀。代码加入越多,腐蚀的速度越快。如果没有办法让设计尽可能地与实现保持一致,那么这种腐蚀的最后结果就是代码不得不被抛弃。
但是让代码实现和设计始终保持一致并非那么简单,在传统的软件方法中,一旦开发到了实现阶段,就很难对设计做出变化。所以最近发展的面向对象方法学把增量迭代(iterative)作为一个基本原则。
也许是题外话,在目前最为时行的一些"重型"软件方法学中,我们很难找到对迭代(包括设计的迭代)明确的支持、定义和操作过程,以及如何提高程序员适应这种动态开发能力的方法。在我看来,重型的软件方法学即使能够对软件开发起到一定的作用,他也不可能包罗万象,解决软件开发中的所有问题。开发方法学重视生命周期管理和控制,但软件的开发并不只有过程和生命周期,更重要的是,同时往往被"正规"的软件方法所忽略的是,软件开发是人的活动。不管你的过程控制是多么的严格,不管你的生命周期模型是多么的完美,如果不能提高人的生产力,提高产品的质量,那么一切都是毫无意义的。
在这里,要实现这样一种增量迭代的开发模型,你必须能够让分析人员、设计人员、程序人员、测试人员等等,所有参于开发活动的人有能力或者能够有切实可行的方法来实现迭代,实现增量。如果不是这样的话,你无法随时保持实现和设计之间的一致性,无法把编码实现中所发现的不合理设计反馈到初始设计,也无法在需求变化时对实现所产生的影响准确及时地得到反映。
如果没有切实可行的基本方法来支持迭代中所需要的改变,那么迭代将是非常困难的.我们可以设想,一旦新的需求到达,新的一轮迭代开始,而原先的系统设计被证明无法适应现在的变化,这个时候你如何能够使得迭代顺利地以增量的方式进行。
|