第一章作者通过一个示例讲解重构的过程。由于需求变化或代码不易于理解需要进行重构,重构前需要有一个可靠的测试,重构的过程应该是小步修改,每次修改后就运行测试,测试过程中可以先忽略性能问题。
是需求的变化使重构变得必要。如果一段代码能正常工作,并且不会再被修改,那么完全可以不去重构它。能改进之当然很好,但若没人需要去理解它,它就不会真正妨碍什么。如果确实有人需要理解它的工作原理,并且觉得理解起来很费劲,那你就需要改进一下代码了。
进行重构的时候,得确保即将修改的代码拥有一组可靠的测试。这些测试必须有自我检验能力。
无论每次重构多么简单,养成重构后即运行测试的习惯非常重要。这就是重构过程的精髓所在:小步修改,每次修改后就运行测试。如果我改动了太多东西,犯错时就可能陷入麻烦的调试,并为此耗费大把时间。小步修改,以及它带来的频繁反馈,正是防止混乱的关键。
大多数情况下可以忽略它。如果重构引入了性能损耗,先完成重构,再做性能优化。
好的命名十分重要,但往往并非唾手可得。但要一次把名取好并不容易,因此我会使用当下能想到最好的那个。如果稍后想到更好的,再将其换掉。
重构早期的主要动力是尝试理解代码如何工作。通常你需要先通读代码,找到一些感觉,然后再通过重构将这些感觉从脑海里搬回到代码中。清晰的代码更容易理解,使你能够发现更深层次的设计问题,从而形成积极正向的反馈环。
好代码的检验标准就是人们是否能轻而易举地修改它。好代码应该直截了当:有人需要修改代码时,他们应能轻易找到修改点,应该能快速做出更改,而不易引入其他错误。
一个健康的代码库能够最大限度地提升我们的生产力,支持我们更快、更低成本地为用户添加新特性。为了保持代码库的健康,就需要时刻留意现状与理想之间的差距,然后通过重构不断接近这个理想。
小的步子可以更快前进,请保持代码永远处于可工作状态,小步修改累积起来也能大大改善系统的设计。
重构的关键在于运用大量微小且保持软件行为的步骤,一步步达成大规模的修改。每个单独的重构要么很小,要么由若干小步骤组合而成。
Kent Beck提出了“两顶帽子”的比喻。使用重构技术开发软件时,我把自己的时间分配给两种截然不同的行为:添加新功能和重构。
添加新功能时,我不应该修改既有代码,只管添加新功能。通过添加测试并让测试正常运行,我可以衡量自己的工作进度。
重构时我就不能再添加功能,只管调整代码的结构。此时我不应该添加任何测试(除非发现有先前遗漏的东西),只在绝对必要(用以处理接口变化)时才修改测试。
软件开发过程中,可能会发现自己经常变换帽子。添加新功能和重构交替进行,无论何时都要清楚自己戴的是哪一顶帽子,并且明白不同的帽子对编程状态提出的不同要求。
如果没有重构,程序的内部设计(或者叫架构)会逐渐腐败变质。
当人们只为短期目的而修改代码时,他们经常没有完全理解架构的整体设计,于是代码逐渐失去了自己的结构。程序员越来越难通过阅读源码来理解原来的设计。
代码结构的流失有累积效应。越难看出代码所代表的设计意图,就越难保护其设计,于是设计就腐败得越快。
设计欠佳的程序往往需要更多代码,这常常是因为代码在不同的地方使用完全相同的语句做同样的事,因此改进设计的一个重要方向就是消除重复代码。
代码量减少将使未来可能的程序修改动作容易得多。代码越多,做正确的修改就越困难,因为有更多代码需要理解。
经常性的重构有助于代码维持自己该有的形态。和优化程序设计。
####3.2 重构使软件更容易理解
重构可以帮我让代码更易读。开始进行重构前,代码可以正常运行,但结构不够理想。在重构上花一点点时间,就可以让代码更好地表达自己的意图——更清晰地说出我想要做的。
对代码进行重构,我们可以深入理解代码的所作所为,并立即把新的理解反映在代码当中。搞清楚程序结构的同时,也验证了自己所做的一些假设,容易将bug揪出来。
Kent Beck经常形容自己的一句话:“我不是一个特别好的程序员,我只是一个有着一些特别好的习惯的还不错的程序员。”重构能够帮助我更有效地写出健壮的代码。
不注重重构的系统,一开始开发进展很快,随着版本的不断迭代,添加新功能需要花越来越多的时间去考虑如何把新功能塞进现有的代码库,不断蹦出来的bug修复起来也越来越慢。
代码库看起来就像补丁摞补丁,需要细致的考古工作才能弄明白整个系统是如何工作的。这份负担不断拖慢新增功能的速度,到最后程序员恨不得从头开始重写整个系统。
经常重构的系统他们添加新功能的速度越来越快,因为他们能利用已有的功能,基于已有的功能快速构建新功能。
两种团队的区别就在于软件的内部质量。需要添加新功能时,内部质量良好的软件让我可以很容易找到在哪里修改、如何修改。良好的模块划分使我只需要理解代码库的一小部分,就可以做出修改。如果代码很清晰,我引入bug的可能性就会变小,即使引入了bug,调试也会容易得多。理想情况下,我的代码库会逐步演化成一个平台,在其上可以很容易地构造与其领域相关的新功能。
通过投入精力改善内部设计,我们增加了软件的耐久性,从而可以更长时间地保持开发的快速。
20年前,行业的陈规认为:良好的设计必须在开始编程之前完成,因为一旦开始编写代码,设计就只会逐渐腐败。
重构改变了这个图景。现在我们可以改善已有代码的设计,因此我们可以先做一个设计,然后不断改善它,哪怕程序本身的功能也在不断发生着变化。由于预先做出良好的设计非常困难,想要既体面又快速地开发功能,重构必不可少。
正如老话说的:事不过三,三则重构。
重构的最佳时机就在添加新功能之前。在动手添加新功能之前,我会看看现有的代码库,此时经常会发现:如果对代码结构做一点微调,我的工作会容易得多。
修复bug时的情况也是一样。
重构的时机:当我们理解代码的时候需要思考“这段代码到底在做什么”。
通过重构达到的目的:重构这段代码,令其一目了然。
当我在重构过程中或者开发过程中,发现某一块不好,如果很容易修改可以顺手修改,但如果很麻烦,我又有紧急事情时候,可以选择记录下来(但不代表我就一点都做不到把他变好)。就像野营者的老话:至少让营地比你到达时更干净,久而久之,营地就非常干净(来自营地法则)
如果团队过去忽视了重构,那么常常会需要专门花一些时间来优化代码库,以便更容易添加新功能。在重构上花一个星期的时间,会在未来几个月里发挥价值。有时,即便团队做了日常的重构,还是会有问题在某个区域逐渐累积长大,最终需要专门花些时间来解决。但这种有计划的重构应该很少,大部分重构应该是不起眼的、见机行事的。
重构经常发生在我们日常开发中,随手可改的地方。当我们发现不好的味道,就要将他重构。
可以在一个团队内,达成共识。每当有人靠近“重构区”的代码,就把它朝想要改进的方向推动一点。例如,如果想替换一个正在使用的库,可以先引入一层新的抽象,使其兼容新旧两个库的接口,然后一旦调用方完全改为了使用这层抽象,替换下面的库就会如容易的多。
代码复审也让更多人有机会提出有用的建议,我们可以按照这些有用的建议对代码进行重构。
重构还可以帮助代码复审工作得到更具体的结果。不仅获得建议,而且其中许多建议能够立刻实现。最终你将从实践中得到比以往多得多的成就感。
实际上,这只是一部分不理解重构真正原因的人的想法,重构是为了从长效上见到收益,一段优秀的代码能让我们开发起来更顺手,要权衡好重构与新功能的时机,比如一段很少使用的代码。就没必要对他重构
有时候我们经常会遇到,接口发布者与调用者不是同一个人,并且甚至可能是用户与我们团队的区别,在这种情况下,需要使用函数改名手法,重构新函数,并且保留旧的对外接口来调用新函数,并且标记为不推荐使用。
经常会有长期不合并的分支,一旦存在时间过长,合并的可能性就越低,尤其是在重构时候,我们经常要对一些东西进行改名和变化,所以最好还是尽可能短的进行合并,这就要求我们尽可能的将功能颗粒化,如果遇到还没开发完成且又无法细化的功能,我们可以使用特性开关对其隐藏。
一组好的测试代码对重构很有意义,它能让我们快速发现错误,虽然实现比较复杂,但他很有意义。
不可避免,一组别人的代码使得我们很烦恼,如果是一套没有合理测试的代码则使得我们更加苦恼。这种情况下,我们需要增加测试,可以运用重构手法找到程序的接缝,再接缝处增加测试,虽然这可能有风险,但这是前进所必须要冒的风险,同时不建议一鼓作气的把整个都改完,更倾向于能够逐步地推进。
数据库重构最好是分散到多次生产发布来完成,这样即便某次修改在生产数据库上造成了问题,也比较容易回滚。
在任何人开始写代码之前,必须先完成软件的设计和架构。
“在编码之前先完成架构”这种做法最大的问题在于,它假设了软件的需求可以预先充分理解。但经验显示,这个假设很多时候甚至可以说大多数时候是不切实际的。只有真正使用了软件、看到了软件对工作的影响,人们才会想明白自己到底需要什么。
重构改变了这种观点。有了重构技术,即便是已经在生产环境中运行了多年的软件,我们也有能力大幅度修改其架构。
有了重构技术,我就可以采取不同的策略。与其猜测未来需要哪些灵活性、需要什么机制来提供灵活性,我更愿意只根据当前的需求来构造软件,同时把软件的设计质量做得很高。随着对用户需求的理解加深,我会对架构进行重构,使其能够应对新的需要。
重构起初是作为极限编程,极限编程是最早的敏捷软件开发方法之一。
如今已经有很多项目使用敏捷方法,但实际上大部分“敏捷”项目只是徒有其名。要真正以敏捷的方式运作项目,团队成员必须在重构上有能力、有热情,他们采用的开发过程必须与常规的、持续的重构相匹配。
三大实践:自测试代码、持续集成、重构
重构的第一块基石是自测试代码。我应该有一套自动化的测试,我可以频繁地运行它们,并且我有信心:如果我在编程过程中犯了任何错误,会有测试失败。
通过持续集成(CI)每个成员的重构都能快速分享给其他同事,不会发生这边在调用一个接口那边却已把这个接口删掉的情况;如果一次重构会影响别人的工作,我们很快就会知道。
有这三大核心实践打下的基础,才谈得上运用敏捷思想的其他部分。持续交付确保软件始终处于可发布的状态,很多互联网团队能做到一天多次发布,靠的正是持续交付的威力。即便我们不需要如此频繁的发布,持续集成也能帮我们降低风险,并使我们做到根据业务需要随时安排发布,而不受技术的局限。有了可靠的技术根基,我们能够极大地压缩“从好点子到生产代码”的周期时间,从而更好地服务客户。这些技术实践也会增加软件的可靠性,减少耗费在bug上的时间。
重构可能使软件运行更慢,但它也使软件的性能优化更容易。除了对性能有严格要求的实时系统,其他任何情况下“编写快速软件”的秘密就是:先写出可调优的软件,然后调优它以求获得足够的速度。
描述:包含那些随意的abc、汉语拼音或者不能准确反应含义的名字,总之一切我们看不懂的、烂的都算,好的名字能节省未来用在猜谜上的大把时间。
重构:改变函数声明、变量改名、字段改名。
描述:在一个以上的地点看到相同的代码结构。
重构:提炼函数、移动语句、函数上移等手法。
描述:函数越长,就越难理解。需要遵循的原则:每当感觉需要以注释来说明点什么的时候,我们就把需要说明的东西写进一个独立函数中,并以其用途(而非实现手法)命名。我们可以对一组甚至短短一行代码做这件事。哪怕替换后的函数调用动作比函数自身还长,只要函数名称能够解释其用途,我们也该毫不犹豫地那么做。关键不在于函数的长度,而在于函数“做什么”和“如何做”之间的语义距离。
重构:提炼函数(常用)、以查询取代临时变量、引入参数对象、保持对象完整性、以命令取代参数(消除一些参数)、分解条件表达式、以多态取代条件表达式(应对分支语句)、拆分循环(应对一个循环做了很多事情)。
描述:正常来说,函数中所需的东西应该以参数形式传入,避免全局变量的使用,但过长的参数列表其实也很恶心。
重构:查询取代参数、保持对象完整、引入参数对象、移除标记参数、函数组合成类。
描述:有少量的全局数据或许无妨,但数量越多,处理的难度就会指数上升。即便只是少量的数据,我们也愿意将它封装起来,这是在软件演进过程中应对变化的关键所在。
重构:封装变量
描述:数据的可变性和全局变量一样,如果我其他使用者修改了这个值,而引发不可理喻的bug。 这是很难排查的。
重构:封装变量,拆分变量,移动语句、提炼函数,查询函数和修改函数分离,移除设值函数,以查询取代变量函数组合成类。
描述:发散式变化是指某个模块经常因为不同的原因在不同的方向上变化了(可以理解为某一处修改了,造成其他模块方向错乱)。
重构:拆分阶段、搬移函数、提炼函数、提炼类。
描述:霰弹式修改类似于发散式变化,但又恰恰相反。如果每遇到某种变化,你都必须在许多不同的类内做出许多小修改,你所面临的坏味道就是霰弹式修改。如果需要修改的代码散布四处,你不但很难找到它们,也很容易错过某个重要的修改。
重构:搬移函数、搬移字段、函数组合成类、函数组合成变换、拆分阶段、内联函数、内联字段。
描述:一个函数跟另一个模块中的函数或者数据交流格外频繁,远胜于在自己所处模块内部的交流。也就是违反了高内聚低耦合,处理方法是将这个函数和这个数据摆在一起。一个函数往往会用到几个模块的功能,处置的原则是:判断哪个模块拥有的此函数使用的数据最多,然后就把这个函数和那些数据摆在一起。
重构:搬移函数、提炼函数。
描述:代码中我们可能经常看到三四个相同的数据:两个类中相同的字段、许多函数签名中相同的参数。这些总是绑在一起出现的数据真应该拥有属于它们自己的对象。
重构:提炼类、引入参数对象、保持对象完整性。
描述:一些基本类型无法表示一个数据的真实意义,例如如钱、坐标、范围等。
重构:以对象取代基本类型、以子类取代类型码、以多态取代条件表达式。
描述:重复的 switch :在不同的地方反复使用同样的switch 逻辑(可能是以 switch/case 语句的形式,也可能是以连续的 if/else 语句的形式)。重复的 switch 的问题在于:每当你想增加一个选择分支时,必须找到所有的 switch ,并逐一更新。多态给了我们对抗这种黑暗力量的武器,使我们得到更优雅的代码库。
重构:多态取代条件表达式。
描述:早期循环就一直是程序设计的核心要素,在java语言中现在我们可以使用以管道取代循环。
重构:用管道来取代循环(管道:map、forEach、reduce、filter等一系列)。
描述:程序元素(如类和函数)能给代码增加结构,从而支持变化、促进复用或者哪怕只是提供更好的名字也好,但有时我们真的不需要这层额外的结构。对于不需要的程序元素,请让他庄严赴义吧!
重构:内联函数、内联类、折叠继承类。
描述:为了将来某种需求而实现的某些特殊的处理,这么做的结果往往造成系统更难理解和维护。如果用不上就不需要这样处理,将其删除。
重构:折叠继承体系、内联函数、内联类、改变函数声明、移除死代码。
描述:类中某个字段仅为某种特定情况而设,不需要将其定义为类的成员变量。
重构:提炼类、提炼函数、引入特例。
描述:一个对象请求另一个对象,然后再向后者请求另一个对象,然后再请求另一个对象……这就是消息链。采取这种方式,意味客户端代码将与查找过程中的导航结构紧密耦合。一旦对象间的关系发生任何变化,客户端就不得不做出相应修改。处理方法:先观察消息链最终得到的对象是用来干什么的,看看能否以提炼函数把使用该对象的代码提炼到一个独立的函数中,再运用搬移函数把这个函数推入消息链。如果还有许多客户端代码需要访问链上的其他对象,同样添加一个函数来完成此事。
重构: 隐藏委托关系、提炼函数、搬移函数。
描述:如果一个类有大部分的接口(函数)委托给了同一个调用类。当过度运用这种封装就是一种代码的坏味道。
重构:移除中间人、内联函数。
描述:两个模块的数据频繁的私下交换数据(可以理解为在程序的不为人知的角落),这样会导致两个模块耦合严重,并且数据交换隐藏在内部,不易被察觉。
重构:搬移函数、隐藏委托关系、委托取代子类、委托取代超类。
描述:类内如果有太多代码,也是代码重复、混乱并最终走向死亡的源头。最简单的解决方案是把多余的东西消弭于类内部。
重构:提炼超类、以子类取代类型码。
描述:两个可以相互替换的类,只有当接口一致才可能被替换。
重构:改变函数声明、搬移函数、提炼超类。
描述:它们拥有一些字段,以及用于访问(读写)这些字段的函数,除此之外一无长物。一般这样的类往往被其他类频繁的调用,这样的类往往是我们没有把调用的行为封装进来,将行为封装进来这种情况就能得到很大改善。
重构:封装记录、移除取值函数、搬移函数、提炼函数、拆分阶段。
描述:子类不想或不需要继承某一些接口,我们可以用函数下移或者字段下移来解决,但不值得每次都这么做,只有当子类复用了超类的行为却又不愿意支持超类的接口时候我们才应该做出重构。
重构:委托取代子类、委托取代超类。
描述:这里提到注释并非是说注释是一种坏味道,只是有一些人经常将注释当作“除臭剂”来使用(一段很长的代码+一个很长的注释,来帮助解释)。往往遇到这种情况,就意味着:我们需要重构了。
重构:提炼函数、改变函数声明、引入断言。
重构是很有价值的工具,但只有重构还不行。要正确地进行重构,前提是得有一套稳固的测试集合,以帮我发现难以避免的疏漏。即便有工具可以帮我自动完成一些重构,很多重构手法依然需要通过测试集合来保障。
编写优良的测试程序,可以极大提高我的编程速度,即使不进行重构也一样如此。
重构手法都有如下5个部分:
如果说上面的味道是核心的话,那手法应该就是本书的重中之重。通常我们发现哪里味道不对之后,就要选择使用不同的手法进行重构。将他们变得味道好起来。
本文中每个手法通常包含三个模块:时机(遇到什么情况下使用)、做法(详细步骤的概括)、关键字(做法的缩影)。
曾用名:提炼函数(Extract Method)
反向重构:内联函数
时机:按照“将意图与实现分开”的原则,如果你需要花时间浏览一段代码才能弄清它到底在干什么,那么就应该将其提炼到一个函数中,并根据它所做的事为其命名。以后再读到这段代码时,你一眼就能看到函数的用途,大多数时候根本不需要关心函数如何达成其用途(这是函数体内干的事)。
做法:
作者的一个观点:
一旦接受了“将意图与实现分开”这个原则,会逐渐养成一个习惯:写非常小的函数——通常只有几行的长度。
小函数得有个好名字才行,所以你必须在命名上花心思。起好名字需要练习,不过一旦你掌握了其中的技巧,就能写出很有自描述性的代码。
曾用名:内联函数(Inline Method)
反向重构:提炼函数
时机:
曾用名:引入解释性变量(Introduce ExplainingVariable)
反向重构:内联变量
时机:
曾用名:内联临时变量(Inline Temp)
反向重构:提炼变量
时机:
作者建议:最好能把大的修改拆成小的步骤,所以如果你既想修改函数名,又想添加参数,最好分成两步来做。(并且,不论何时,如果遇到了麻烦,请撤销修改,并改用迁移式做法。)。
别名:函数改名(Rename Function)
曾用名:函数改名(Rename Method)
曾用名:添加参数(Add Parameter)
曾用名:移除参数(Remove Parameter)
别名:修改签名(Change Signature)
时机:
关于做法4——“限制变量的可见性”:
有时没办法阻止直接访问变量。若果真如此,可以试试将变量改名,再执行测试,找出仍在直接使用该变量的代码。
注意点:
有两种方案:
作者对这两个方案点评: