本文为《重构:改善既有代码的设计(第2版)》的笔记, 是一本关于重构的通用书籍,只是采用了JavaScript作为示例。重构的手法很多需要应用到实践中才有意义。
重构的意义就在于:你永远不必说对不起——只要把出问题的地方修补好就行了。
所谓重构(refactoring)是这样一个过程:在不改变代码外在行为的前提下,对代码做出修改,以改进程序的内部结构。本质上说,重构就是在代码写好之后改进它的设计。
第1章 重构,第一个示例
差劲的系统是很难修改的,因为很难找到修改点,难以了解做出的修改与现有代码如何协作实现我想要的行为。如果很难找到修改点,我就很有可能犯错,从而引入bug。
如果你要给程序添加一个特性,但发现代码因缺乏良好的结构而不易于进行更改,那就先重构那个程序,使其比较容易添加该特性,然后再添加该特性。
我再强调一次,是需求的变化使重构变得必要。如果一段代码能正常工作,并且不会再被修改,那么完全可以不去重构它。能改进之当然很好,但若没人需要去理解它,它就不会真正妨碍什么。如果确实有人需要理解它的工作原理,并且觉得理解起来很费劲,那你就需要改进一下代码了。
程序越大,我的修改不小心破坏其他代码的可能性就越大——在数字时代,软件的名字就是脆弱。
重构前,先检查自己是否有一套可靠的测试集。这些测试必须有自我检验能力。
重构过程的精髓所在:小步修改,每次修改后就运行测试。如果我改动了太多东西,犯错时就可能陷入麻烦的调试,并为此耗费大把时间。小步修改,以及它带来的频繁反馈,正是防止混乱的关键。
每次成功的重构后我都会提交代码,如果待会不小心搞砸了,我便能轻松回滚到上一个可工作的状态。把代码推送(push)到远端仓库前,我会把零碎的修改压缩成一个更有意义的提交(commit)。
好代码应能清楚地表明它在做什么,而变量命名是代码清晰的关键。只要改名能够提升代码的可读性,那就应该毫不犹豫去做。
如果重构引入了性能损耗,先完成重构,再做性能优化。
好代码应该直截了当:有人需要修改代码时,他们应能轻易找到修改点,应该能快速做出更改,而不易引入其他错误。一个健康的代码库能够最大限度地提升我们的生产力,支持我们更快、更低成本地为用户添加新特性。为了保持代码库的健康,就需要时刻留意现状与理想之间的差距,然后通过重构不断接近这个理想。
开展高效有序的重构,关键的心得是:小的步子可以更快前进,请保持代码永远处于可工作状态,小步修改累积起来也能大大改善系统的设计。
第2章 重构的原则
重构与性能优化有很多相似之处:两者都需要修改代码,并且两者都不会改变程序的整体功能。两者的差别在于其目的:重构是为了让代码“更容易理解,更易于修改”。这可能使程序运行得更快,也可能使程序运行得更慢。在性能优化时,我只关心让程序运行得更快,最终得到的代码有可能更难理解和维护,对此我有心理准备。
重构是一个工具,它可以(并且应该)用于以下几个目的。重构改进软件的设计
改进设计的一个重要方向就是消除重复代码。代码量减少并不会使系统运行更快,因为这对程序的资源占用几乎没有任何明显影响。然而代码量减少将使未来可能的程序修改动作容易得多。代码越多,做正确的修改就越困难,因为有更多代码需要理解。
消除重复代码,我就可以确定所有事物和行为在代码中只表述一次,这正是优秀设计的根本。
重构使软件更容易理解。重构可以帮我让代码更易读。开始进行重构前,代码可以正常运行,但结构不够理想。在重构上花一点点时间,就可以让代码更好地表达自己的意图——更清晰地说出我想要做的。
重构帮助找到bug。
Kent Beck经常形容自己的一句话:“我不是一个特别好的程序员,我只是一个有着一些特别好的习惯的还不错的程序员。”重构能够帮助我更有效地写出健壮的代码。
重构提高编程速度。“设计耐久性假说”:通过投入精力改善内部设计,我们增加了软件的耐久性,从而可以更长时间地保持开发的快速。
由于预先做出良好的设计非常困难,想要既体面又快速地开发功能,重构必不可少。
● 预备性重构:让添加新功能更容易
● 帮助理解的重构:使代码更易懂
● 捡垃圾式重构
● 有计划的重构和见机行事的重构
这种有计划的重构应该很少,大部分重构应该是不起眼的、见机行事的。
● 长期重构
● 复审代码时重构
肮脏的代码必须重构,但漂亮的代码也需要很多重构。
每次要修改时,首先令修改很容易(警告:这件事有时会很难),然后再进行这次容易的修改。——Kent Beck
何时不应该重构:如果我看见一块凌乱的代码,但并不需要修改它,那么我就不需要重构它。只有当我需要理解其工作原理时,对其进行重构才有价值。另一种情况是,如果重写比重构还容易,就别重构了。
重构的挑战
● 延缓新功能开发
“重构会拖慢进度”这种看法仍然很普遍,这可能是导致人们没有充分重构的最大阻力所在。重构的唯一目的就是让我们开发更快,用更少的工作量创造更大的价值。我们之所以重构,因为它能让我们更快——添加功能更快,修复bug更快。
● 重构应该总是由经济利益驱动。
● 代码所有权
很多重构手法不仅会影响一个模块内部,还会影响该模块与系统其他部分的关系。代码所有权的边界会妨碍重构,因为一旦我自作主张地修改,就一定会破坏使用者的程序。这不会完全阻止重构,我仍然可以做很多重构,但确实会对重构造成约束。我可以把旧的接口标记为“不推荐使用”(deprecated),等一段时间之后最终让其退休;但有些时候,旧的接口必须一直保留下去。
● 分支
持续集成(Continuous Integration,CI),也叫“基于主干开发”(Trunk-Based Development)。在使用CI时,每个团队成员每天至少向主线集成一次。这个实践避免了任何分支彼此差异太大,从而极大地降低了合并的难度。不过CI也有其代价:你必须使用相关的实践以确保主线随时处于健康状态,必须学会将大功能拆分成小块,还必须使用特性开关(feature toggle,也叫特性旗标,feature flag)将尚未完成又无法拆小的功能隐藏掉。
● 测试
不会改变程序可观察的行为,这是重构的一个重要特征。“快速发现错误”。要做到这一点,我的代码应该有一套完备的测试套件,并且运行速度要快,否则我会不愿意频繁运行它。
● 遗留代码
重构可以很好地帮助我们理解遗留系统。遗留系统多半没测试。先找到程序的接缝,在接缝处插入测试,如此将系统置于测试覆盖之下。你需要运用重构手法创造出接缝——这样的重构很危险,因为没有测试覆盖,但这是为了取得进展必要的风险。我更愿意随时重构相关的代码:每次触碰一块代码时,我会尝试把它变好一点点——至少要让营地比我到达时更干净。如果是一个大系统,越是频繁使用的代码,改善其可理解性的努力就能得到越丰厚的回报。
● 数据库
借助数据迁移脚本,将数据库结构的修改与代码相结合,使大规模的、涉及数据库的修改可以比较容易地开展。数据库重构的关键也是小步修改并且每次修改都应该完整,这样每次迁移之后系统仍然能运行。由于每次迁移涉及的修改都很小,写起来应该容易;将多个迁移串联起来,就能对数据库结构及其中存储的数据做很大的调整。
很多时候,数据库重构最好是分散到多次生产发布来完成,这样即便某次修改在生产数据库上造成了问题,也比较容易回滚。
重构、架构和YAGNI
通过重构,我们能得到一个设计良好的代码库,使其能够优雅地应对不断变化的需求。“在编码之前先完成架构”这种做法最大的问题在于,它假设了软件的需求可以预先充分理解。
应对未来变化的办法之一,就是在软件里植入灵活性机制。与其猜测未来需要哪些灵活性、需要什么机制来提供灵活性,我更愿意只根据当前的需求来构造软件,同时把软件的设计质量做得很高。随着对用户需求的理解加深,我会对架构进行重构,使其能够应对新的需要。
要判断是否应该为未来的变化添加灵活性,我会评估“如果以后再重构有多困难”,只有当未来重构会很困难时,我才考虑现在就添加灵活性机制。
简单设计、增量式设计或者YAGNI——“你不会需要它”(you arenʼt going to need it)的缩写。YAGNI并不是“不做架构性思考”的意思,不过确实有人以这种欠考虑的方式做事。我把YAGNI视为将架构、设计与开发过程融合的一种工作方式,这种工作方式必须有重构作为基础才可靠。
采用YAGNI并不表示完全不用预先考虑架构。总有一些时候,如果缺少预先的思考,重构会难以开展。但两者之间的平衡点已经发生了很大的改变:如今我更倾向于等一等,待到对问题理解更充分,再来着手解决。
- 重构与软件开发过程
重构的第一块基石是自测试代码。自测试的代码也是持续集成的关键环节,所以这三大实践——自测试代码、持续集成、重构——彼此之间有着很强的协同效应。
重构和YAGNI交相呼应、彼此增效,重构(及其前置实践)是YAGNI的基础,YAGNI又让重构更易于开展:比起一个塞满了想当然的灵活性的系统,当然是修改一个简单的系统要容易得多。在这些实践之间找到合适的平衡点,你就能进入良性循环,你的代码既牢固可靠又能快速响应变化的需求。重构与性能
我并不赞成为了提高设计的纯洁性而忽视性能,把希望寄托于更快的硬件身上也绝非正道。虽然重构可能使软件运行更慢,但它也使软件的性能优化更容易。除了对性能有严格要求的实时系统,其他任何情况下“编写快速软件”的秘密就是:先写出可调优的软件,然后调优它以求获得足够的速度。
在性能优化阶段,我首先应该用一个度量工具来监控程序的运行,让它告诉我程序中哪些地方大量消耗时间和空间。这样我就可以找出性能热点所在的一小段代码。然后我应该集中关注这些性能热点,并使用持续关注法中的优化手段来优化它们。由于把注意力都集中在热点上,较少的工作量便可显现较好的成果。
和重构一样,我会小幅度进行修改。每走一步都需要编译、测试,再次度量。如果没能提高性能,就应该撤销此次修改。我会继续这个“发现热点,去除热点”的过程,直到获得客户满意的性能为止。
短期看来,重构的确可能使软件变慢,但它使优化阶段的软件性能调优更容易,最终还是会得到好的效果。自动化重构
如果我想给一个Java的方法改名,在IntelliJ IDEA或者Eclipse这样的开发环境中,我只需要从菜单里点选对应的选项,工具会帮我完成整个重构过程,而且我通常都可以相信,工具完成的重构是可靠的,所以用不着运行测试套件。
能借助语法树来分析和重构程序代码,这是IDE与普通文本编辑器相比具有的一大优势。第3章 代码的坏味道
决定何时重构及何时停止和知道重构机制如何运转一样重要。 - 1 神秘命名(Mysterious Name)
整洁代码最重要的一环就是好的名字,所以我们会深思熟虑如何给函数、模块、变量和类命名,使它们能清晰地表明自己的功能和用法。然而,很遗憾,命名是编程中最难的两件事之一。正因为如此,改名可能是最常用的重构手法,包括改变函数声明(124)(用于给函数改名)、变量改名(137)、字段改名(244)等。
改名不仅仅是修改名字而已。如果你想不出一个好名字,说明背后很可能潜藏着更深的设计问题。为一个恼人的名字所付出的纠结,常常能推动我们对代码进行精简。 - 2 重复代码(Duplicated Code)
如果你在一个以上的地点看到相同的代码结构,那么可以肯定:设法将它们合而为一,程序会变得更好。一旦有重复代码存在,阅读这些重复的代码时你就必须加倍仔细,留意其间细微的差异。如果要修改重复代码,你必须找出所有的副本来修改。 - 3 过长函数(Long Function)
函数越长,就越难理解。固然,小函数也会给代码的阅读者带来一些负担,因为你必须经常切换上下文,才能看明白函数在做什么。但现代的开发环境让你可以在函数的调用处与声明处之间快速跳转,或是同时看到这两处,让你根本不用来回跳转。不过说到底,让小函数易于理解的关键还是在于良好的命名。如果你能给函数起个好名字,阅读代码的人就可以通过名字了解函数的作用,根本不必去看其中写了些什么。
我们遵循这样一条原则:每当感觉需要以注释来说明点什么的时候,我们就把需要说明的东西写进一个独立函数中,并以其用途(而非实现手法)命名。
百分之九十九的场合里,要把函数变短,只需使用提炼函数(106)。找到函数中适合集中在一起的部分,将它们提炼出来形成一个新函数。如何确定该提炼哪一段代码呢?一个很好的技巧是:寻找注释。它们通常能指出代码用途和实现手法之间的语义距离。条件表达式和循环常常也是提炼的信号。 - 4 过长参数列表(Long Parameter List)
使用类可以有效地缩短参数列表。 - 5 全局数据(Global Data)
硫黄的故事不那么可信,全局数据仍然是最刺鼻的坏味道之一。全局数据的问题在于,从代码库的任何一个角落都可以修改它,而且没有任何机制可以探测出到底哪段代码做出了修改。全局数据最显而易见的形式就是全局变量,但类变量和单例(singleton)也有这样的问题。首要的防御手段是封装变量(132),每当我们看到可能被各处的代码污染的数据,这总是我们应对的第一招。你把全局数据用一个函数包装起来,至少你就能看见修改它的地方,并开始控制对它的访问。随后,最好将这个函数(及其封装的数据)搬移到一个类或模块中,只允许模块内的代码使用它,从而尽量控制其作用域。全局数据印证了帕拉塞尔斯的格言:良药与毒药的区别在于剂量。有少量的全局数据或许无妨,但数量越多,处理的难度就会指数上升。即便只是少量的数据,我们也愿意将它封装起来,这是在软件演进过程中应对变化的关键所在。 - 6 可变数据(Mutable Data)
对数据的修改经常导致出乎意料的结果和难以发现的bug。可以用封装变量(132)来确保所有数据更新操作都通过很少几个函数来进行,使其更容易监控和演进。
如果一个变量在不同时候被用于存储不同的东西,可以使用拆分变量(240)将其拆分为各自不同用途的变量,从而避免危险的更新操作。使用移动语句(223)和提炼函数(106)尽量把逻辑从处理更新操作的代码中搬移出来,将没有副作用的代码与执行数据更新操作的代码分开。
尽早使用移除设值函数(331)——有时只是把设值函数的使用者找出来看看,就能帮我们发现缩小变量作用域的机会。
如果可变数据的值能在其他地方计算出来,使用以查询取代派生变量(248)即可。 - 7 发散式变化(Divergent Change)
如果某个模块经常因为不同的原因在不同的方向上发生变化,发散式变化就出现了。
你看着一个类说:“呃,如果新加入一个数据库,我必须修改这3个函数;如果新出现一种金融工具,我必须修改这4个函数。”这就是发散式变化的征兆。数据库交互和金融逻辑处理是两个不同的上下文,将它们分别搬移到各自独立的模块中,能让程序变得更好:每当要对某个上下文做修改时,我们只需要理解这个上下文,而不必操心另一个。
如果发生变化的两个方向自然地形成了先后次序(比如说,先从数据库取出数据,再对其进行金融逻辑处理),就可以用拆分阶段(154)将两者分开,两者之间通过一个清晰的数据结构进行沟通。 - 8 霰弹式修改(Shotgun Surgery)
面对霰弹式修改,一个常用的策略就是使用与内联(inline)相关的重构——如内联函数(115)或是内联类(186)——把本不该分散的逻辑拽回一处。 - 9 依恋情结(Feature Envy)
所谓模块化,就是力求将代码分出区域,最大化区域内部的交互、最小化跨区域的交互。但有时你会发现,一个函数跟另一个模块中的函数或者数据交流格外频繁,远胜于在自己所处模块内部的交流,这就是依恋情结的典型情况。
判断哪个模块拥有的此函数使用的数据最多,然后就把这个函数和那些数据摆在一起。 - 10 数据泥团(Data Clumps)
数据项就像小孩子,喜欢成群结队地待在一块儿。首先请找出这些数据以字段形式出现的地方,运用提炼类(182)将它们提炼到一个独立对象中。
不必在意数据泥团只用上新对象的一部分字段,只要以新对象取代两个(或更多)字段,就值得这么做。一个好的评判办法是:删掉众多数据中的一项。如果这么做,其他数据有没有因而失去意义?如果它们不再有意义,这就是一个明确信号:你应该为它们产生一个新对象。我们在这里提倡新建一个类,而不是简单的记录结构,因为一旦拥有新的类,你就有机会让程序散发出一种芳香。 - 11 基本类型偏执(Primitive Obsession)
大多数编程环境都大量使用基本类型,即整数、浮点数和字符串等。一个体面的类型,至少能包含一致的显示逻辑,在用户界面上需要显示时可以使用。 - 12 重复的switch (Repeated Switches)
重复的switch的问题在于:每当你想增加一个选择分支时,必须找到所有的switch,并逐一更新。多态给了我们对抗这种黑暗力量的武器,使我们得到更优雅的代码库。 - 13 循环语句(Loops)
管道操作(如filter和map)可以帮助我们更快地看清被处理的元素以及处理它们的动作。 - 14 冗赘的元素(Lazy Element)
程序元素(如类和函数)能给代码增加结构,从而支持变化、促进复用或者哪怕只是提供更好的名字也好,但有时我们真的不需要这层额外的结构。 - 15 夸夸其谈通用性(Speculative Generality)
当有人说“噢,我想我们总有一天需要做这事”,并因而企图以各式各样的钩子和特殊情况来处理一些非必要的事情,这种坏味道就出现了。这么做的结果往往造成系统更难理解和维护。 - 16 临时字段(Temporary Field)
有时你会看到这样的类:其内部某个字段仅为某种特定情况而设。
因为你通常认为对象在所有时候都需要它的所有字段。在字段未被使用的情况下猜测当初设置它的目的,会让你发疯。 - 17 过长的消息链(Message Chains)
如果你看到用户向一个对象请求另一个对象,然后再向后者请求另一个对象,然后再请求另一个对象……这就是消息链。在实际代码中你看到的可能是一长串取值函数或一长串临时变量。采取这种方式,意味客户端代码将与查找过程中的导航结构紧密耦合。一旦对象间的关系发生任何变化,客户端就不得不做出相应修改。
这时候应该使用隐藏委托关系(189)。 - 18 中间人(Middle Man)
对象的基本特征之一就是封装——对外部世界隐藏其内部细节。封装往往伴随着委托。但是人们可能过度运用委托。你也许会看到某个类的接口有一半的函数都委托给其他类,这样就是过度运用。这时应该使用移除中间人(192),直接和真正负责的对象打交道。 - 19 内幕交易(Insider Trading)
软件开发者喜欢在模块之间建起高墙,极其反感在模块之间大量交换数据,因为这会增加模块间的耦合。
在实际情况里,一定的数据交换不可避免,但我们必须尽量减少这种情况,并把这种交换都放到明面上来 - 20 过大的类(Large Class)
如果想利用单个类做太多事情,其内往往就会出现太多字段。一旦如此,重复代码也就接踵而至了。你可以运用提炼类(182)将几个变量一起提炼至新类内。
提炼时应该选择类内彼此相关的变量,将它们放在一起。
观察一个大类的使用者,经常能找到如何拆分类的线索。 - 21 异曲同工的类(Alternative Classes with Different Interfaces)
使用类的好处之一就在于可以替换:今天用这个类,未来可以换成用另一个类。但只有当两个类的接口一致时,才能做这种替换。可以用改变函数声明(124)将函数签名变得一致。但这往往还不够,请反复运用搬移函数(198)将某些行为移入类中,直到两者的协议一致为止。 - 22 纯数据类(Data Class)
所谓纯数据类是指:它们拥有一些字段,以及用于访问(读写)这些字段的函数,除此之外一无长物。
纯数据类常常意味着行为被放在了错误的地方。也就是说,只要把处理数据的行为从客户端搬移到纯数据类里来,就能使情况大为改观。但也有例外情况,一个最好的例外情况就是,纯数据记录对象被用作函数调用的返回结果。不可修改的字段无须封装,使用者可以直接通过字段取得数据,无须通过取值函数。 - 23 被拒绝的遗赠(Refused Bequest)
如果子类复用了超类的行为(实现),却又不愿意支持超类的接口,“被拒绝的遗赠”的坏味道就会变得很浓烈。拒绝继承超类的实现,这一点我们不介意;但如果拒绝支持超类的接口,这就难以接受了。既然不愿意支持超类的接口,就不要虚情假意地糊弄继承体系,应该运用以委托取代子类(381)或者以委托取代超类(399)彻底划清界限。 - 24 注释(Comments)
常常会有这样的情况:你看到一段代码有着长长的注释,然后发现,这些注释之所以存在乃是因为代码很糟糕。
当你感觉需要撰写注释时,请先尝试重构,试着让所有注释都变得多余。
除了用来记述将来的打算之外,注释还可以用来标记你并无十足把握的区域。你可以在注释里写下自己“为什么做某某事”。这类信息可以帮助将来的修改者,尤其是那些健忘的家伙。第4章 构筑测试体系
修复bug通常是比较快的,但找出bug所在却是一场噩梦。当修复一个bug时,常常会引起另一个bug,却在很久之后才会注意到它。那时,你又要花上大把时间去定位问题。
确保所有测试都完全自动化,让它们检查自己的测试结果。
一套测试就是一个强大的bug侦测器,能够大大缩减查找bug所需的时间。编写测试程序,意味着要写很多额外的代码。除非你确实体会到这种方法是如何提升编程速度的,否则自测试似乎就没什么意义。事实上,撰写测试代码的最好时机是在开始动手编码之前。当我需要添加特性时,我会先编写相应的测试代码。编写测试代码还能帮我把注意力集中于接口而非实现(这永远是一件好事)。预先写好的测试代码也为我的工作安上一个明确的结束标志:一旦测试代码正常运行,工作就可以结束了。
Kent Beck将这种先写测试的习惯提炼成一门技艺,叫测试驱动开发(Test-Driven Development,TDD)。
Mocha框架组织测试代码的方式是将其分组,每一组下包含一套相关的测试。测试需要写在一个it块中。
● 总是确保测试不该通过时真的会失败。
● 频繁地运行测试。对于你正在处理的代码,与其对应的测试至少每隔几分钟就要运行一次,每天至少运行一次所有的测试。Mocha
框架允许使用不同的库(它称之为断言库)来验证测试的正确性。Chai
,它可以支持我编写不同类型的断言。环境不同,运行测试的方式也不同。使用Java编程时,我使用IDE的图形化测试运行界面。它有一个进度条,所有测试都通过时就会显示绿色;只要有任何测试失败,它就会变成红色。
观察被测试类应该做的所有事情,然后对这个类的每个行为进行测试,包括各种可能使它发生异常的边界条件。
记住,测试应该是一种风险驱动的行为,我测试的目标是希望找出现在或未来可能出现的bug。所以我不会去测试那些仅仅读或写一个字段的访问函数,因为它们太简单了,不太可能出错。
● 测试的重点应该是那些我最担心出错的部分,这样就能从测试工作中得到最大利益。编写未臻完善的测试并经常运行,好过对完美测试的无尽等待。
一个常见的测试模式。我拿到beforeEach配置好的初始标准夹具,然后对该夹具进行必要的检查,最后验证它是否表现出我期望的行为。
作为一个基本规则,一个it语句中最好只有一个验证语句,否则测试可能在进行第一个验证时就失败,这通常会掩盖一些重要的错误信息,不利于你了解测试失败的原因。
“正常路径”(happy path),它指的是一切工作正常、用户使用方式也最符合规范的那种场景。同时,把测试推到这些条件的边界处也是不错的实践,这可以检查操作出错时软件的表现。
如果这个错误会导致脏数据在应用中到处传递,或是产生一些很难调试的失败,我可能会用引入断言(302)手法,使代码不满足预设条件时快速失败。我不会为这样的失败断言添加测试,它们本身就是一种测试的形式。
“任何测试都不能证明一个程序没有bug。”确实如此,但这并不影响“测试可以提高编程速度”。
当测试数量达到一定程度之后,继续增加测试带来的边际效用会递减;如果试图编写太多测试,你也可能因为工作量太大而气馁,最后什么都写不成。你应该把测试集中在可能出错的地方。观察代码,看哪儿变得复杂;观察函数,思考哪些地方可能出错。
一个值得养成的好习惯是,每当你遇见一个bug,先写一个测试来清楚地复现它。仅当测试通过时,才视为bug修完。只要测试存在一天,我就知道这个错误永远不会再复现。
测试覆盖率的分析只能识别出那些未被测试覆盖到的代码,而不能用来衡量一个测试集的质量高低。
测试同样可能过犹不及。测试写得太多的一个征兆是,相比要改的代码,我在改动测试上花费了更多的时间——并且我能感到测试就在拖慢我。不过尽管过度测试时有发生,相比测试不足的情况还是稀少得多。
第6章 第一组重构
- 1 提炼函数(Extract Function)
反向重构:内联函数
对于“何时应该把代码放进独立的函数”这个问题,我认为最合理的观点是“将意图与实现分开”:如果你需要花时间浏览一段代码才能弄清它到底在干什么,那么就应该将其提炼到一个函数中,并根据它所做的事为其命名。以后再读到这段代码时,你一眼就能看到函数的用途,大多数时候根本不需要关心函数如何达成其用途(这是函数体内干的事)。
短函数常常能让编译器的优化功能运转更良好,因为短函数可以更容易地被缓存。所以,应该始终遵循性能优化的一般指导方针,不用过早担心性能问题。小函数得有个好名字才行,所以你必须在命名上花心思。起好名字需要练习,不过一旦你掌握了其中的技巧,就能写出很有自描述性的代码。
创造一个新函数,根据这个函数的意图来对它命名(以它“做什么”来命名,而不是以它“怎样做”命名)。
如果提炼出的新函数嵌套在源函数内部,就不存在变量作用域的问题了。这些“作用域限于源函数”的变量通常是局部变量或者源函数的参数。最通用的做法是将它们都作为参数传递给新函数。
如果变量按值传递给提炼部分又在提炼部分被赋值,就必须多加小心。如果只有一个这样的变量,我会尝试将提炼出的新函数变成一个查询(query),用其返回值给该变量赋值。
但有时在提炼部分被赋值的局部变量太多,这时最好是先放弃提炼。这种情况下,我会考虑先使用别的重构手法,例如拆分变量(240)或者以查询取代临时变量(178),来简化变量的使用情况,然后再考虑提炼函数。 - 2 内联函数(Inline Function)
反向重构:提炼函数(106)
间接层有其价值,但不是所有间接层都有价值。通过内联手法,我可以找出那些有用的间接层,同时将无用的间接层去除。如果该函数属于一个类,并且有子类继承了这个函数,那么就无法内联。不必一次完成整个内联操作。如果某些调用点比较难以内联,可以等到时机成熟后再来处理。 - 3 提炼变量(Extract Variable)曾用名:引入解释性变量(Introduce Explaining Variable)反向重构:内联变量(123)
表达式有可能非常复杂而难以阅读。这种情况下,局部变量可以帮助我们将表达式分解为比较容易管理的形式。在面对一块复杂逻辑时,局部变量使我能给其中的一部分命名,这样我就能更好地理解这部分逻辑是要干什么。这样的变量在调试时也很方便,它们给调试器和打印语句提供了便利的抓手。 - 4 内联变量(Inline Variable)曾用名:内联临时变量(Inline Temp)反向重构:提炼变量(119)
在一个函数内部,变量能给表达式提供有意义的名字,因此通常变量是好东西。但有时候,这个名字并不比表达式本身更具表现力。还有些时候,变量可能会妨碍重构附近的代码。若果真如此,就应该通过内联的手法消除变量。 - 5 改变函数声明(Change Function Declaration)
别名:函数改名(Rename Function)
曾用名:添加参数(Add Parameter)曾用名:移除参数(Remove Parameter)别名:修改签名(Change Signature)
一个好名字能让我一眼看出函数的用途,而不必查看其实现代码。但起一个好名字并不容易,我很少能第一次就把名字起对。(有一个改进函数名字的好办法:先写一句注释描述这个函数的用途,再把这句注释变成函数的名字。)
对于函数的参数,道理也是一样。函数的参数列表阐述了函数如何与外部世界共处。函数的参数设置了一个上下文,只有在这个上下文中,我才能使用这个函数。
修改参数列表不仅能增加函数的应用范围,还能改变连接一个模块所需的条件,从而去除不必要的耦合。如何选择正确的参数,没有简单的规则可循。最好能把大的修改拆成小的步骤,所以如果你既想修改函数名,又想添加参数,最好分成两步来做。(并且,不论何时,如果遇到了麻烦,请撤销修改,并改用迁移式做法。)如果你打算沿用旧函数的名字,可以先给新函数起一个易于搜索的临时名字。如果要重构的函数属于一个具有多态性的类,那么对于该函数的每个实现版本,你都需要通过“提炼出一个新函数”的方式添加一层间接,并把旧函数的调用转发给新函数。如果该函数的多态性是在一个类继承体系中体现,那么只需要在超类上转发即可;如果各个实现类之间并没有一个共同的超类,那么就需要在每个实现类上做转发。如果要重构一个已对外发布的API,在提炼出新函数之后,你可以暂停重构,将原来的函数声明为“不推荐使用”(deprecated),然后给客户端一点时间转为使用新函数。等你有信心所有客户端都已经从旧函数迁移到新函数,再移除旧函数的声明。这个重构的简单做法缺点在于,我必须一次性修改所有调用者和函数声明(或者说,所有的函数声明,如果有多态的话)。另外,如果函数的名字并不唯一,也可能造成问题。大多数重构手法只用于修改我有权修改的代码,但这个重构手法同样适用于已发布API——使用这些API的代码我无权修改。 - 6 封装变量(Encapsulate Variable)
曾用名:自封装字段(Self-Encapsulate Field)曾用名:封装字段(Encapsulate Field)
在改名或搬移函数的过程中,总是可以比较容易地保留旧函数作为转发函数(即旧代码调用旧函数,旧函数再调用新函数)。这样的转发函数通常不会存在太久,但的确能够简化重构过程。
数据就要麻烦得多,因为没办法设计这样的转发机制。如果我把数据搬走,就必须同时修改所有引用该数据的代码,否则程序就不能运行。如果数据的可访问范围很小,比如一个小函数内部的临时变量,那还不成问题。但如果可访问范围变大,重构的难度就会随之增大,这也是说全局数据是大麻烦的原因。
所以,如果想要搬移一处被广泛使用的数据,最好的办法往往是先以函数形式封装所有对该数据的访问。这样,我就能把“重新组织数据”的困难任务转化为“重新组织函数”这个相对简单的任务。
封装能提供一个清晰的观测点,可以由此监控数据的变化和使用情况;我还可以轻松地添加数据被修改时的验证或后续逻辑。我的习惯是:对于所有可变的数据,只要它的作用域超出单个函数,我就会将其封装起来,只允许通过函数访问。数据的作用域越大,封装就越重要。处理遗留代码时,一旦需要修改或增加使用可变数据的代码,我就会借机把这份数据封装起来,从而避免继续加重耦合一份已经广泛使用的数据。封装数据很重要,不过,不可变数据更重要。如果数据不能修改,就根本不需要数据更新前的验证或者其他逻辑钩子。
在JavaScript中,我可以把变量和访问函数搬移到单独一个文件中,并且只导出访问函数,这样就限制了变量的可见性。
数据封装很有价值,但往往并不简单。到底应该封装什么,以及如何封装,取决于数据被使用的方式,以及我想要修改数据的方式。
数据被使用得越广,就越是值得花精力给它一个体面的封装。 - 7 变量改名(Rename Variable)
好的命名是整洁编程的核心。变量可以很好地解释一段程序在干什么——如果变量名起得好的话。
使用范围越广,名字的好坏就越重要。只在一行的lambda表达式中使用的变量,跟踪起来很容易——像这样的变量,我经常只用一个字母命名,因为变量的用途在这个上下文中很清晰。同理,短函数的参数名也常常很简单。对于作用域超出一次函数调用的字段,则需要更用心命名。如果在另一个代码库中使用了该变量,这就是一个“已发布变量”(published variable),此时不能进行这个重构。 - 8 引入参数对象(Introduce Parameter Object)
将数据组织成结构是一件有价值的事,因为这让数据项之间的关系变得明晰。使用新的数据结构,参数的参数列表也能缩短。并且经过重构之后,所有使用该数据结构的函数都会通过同样的名字来访问其中的元素,从而提升代码的一致性。但这项重构真正的意义在于,它会催生代码中更深层次的改变。一旦识别出新的数据结构,我就可以重组程序的行为来使用这些结构。我会创建出函数来捕捉围绕这些数据的共用行为——可能只是一组共用的函数,也可能用一个类把数据结构与使用数据的函数组合起来。 - 9 函数组合成类(Combine Functions into Class)
类,在大多数现代编程语言中都是基本的构造。它们把数据与函数捆绑到同一个环境中,将一部分数据与函数暴露给其他程序元素以便协作。
如果发现一组函数形影不离地操作同一块数据(通常是将这块数据作为参数传递给函数),我就认为,是时候组建一个类了。类能明确地给这些函数提供一个共用的环境,在对象内部调用这些函数可以少传许多参数,从而简化函数调用,并且这样一个对象也可以更方便地传递给系统的其他部分。除了可以把已有的函数组织起来,这个重构还给我们一个机会,去发现其他的计算逻辑,将它们也重构到新的类当中。将函数组织到一起的另一种方式是函数组合成变换(149)。具体使用哪个重构手法,要看程序整体的上下文。使用类有一大好处:客户端可以修改对象的核心数据,通过计算得出的派生数据则会自动与核心数据保持一致。
类似这样的一组函数不仅可以组合成一个类,而且可以组合成一个嵌套函数。通常我更倾向于类而非嵌套函数,因为后者测试起来会比较困难。如果我想对外暴露多个函数,也必须采用类的形式。 - 10 函数组合成变换(Combine Functions into Transform)
采用数据变换(transform)函数:这种函数接受源数据作为输入,计算出所有的派生数据,将派生数据以字段形式填入输出数据。函数组合成变换的替代方案是函数组合成类(144),后者的做法是先用源数据创建一个类,再把相关的计算逻辑搬移到类中。两者有一个重要的区别:如果代码中会对源数据做更新,那么使用类要好得多;如果使用变换,派生数据会被存储在新生成的记录中,一旦源数据被修改,我就会遭遇数据不一致。
引入变换(或者类)都是为了让相关的逻辑找起来方便。创建一个变换函数,输入参数是需要变换的记录,并直接返回该记录的值。这一步通常需要对输入的记录做深复制(deep copy)。变换函数返回的本质上仍是原来的对象,只是添加了更多的信息在上面。对于这种函数,我喜欢用“enrich”(增强)这个词来给它命名。如果它生成的是跟原来完全不同的对象,我就会用“transform”(变换)来命名它。 - 11 拆分阶段(Split Phase)
如果一块代码中出现了上下几段,各自使用不同的一组数据和函数,这就是最明显的线索。将这些代码片段拆分成各自独立的模块,能更明确地标示出它们之间的差异。第7章 封装
类是为隐藏信息而生的。除了类的内部细节,使用隐藏委托关系(189)隐藏类之间的关联关系通常也很有帮助。但过多隐藏也会导致冗余的中间接口,此时我就需要它的反向重构——移除中间人(192)。 - 1 封装记录(Encapsulate Record)
曾用名:以数据类取代记录(Replace Record with Data Class)
对象可以隐藏结构的细节,
同时,这种封装还有助于字段的改名:我可以重新命名字段,但同时提供新老字段名的访问方法,这样我就可以渐进地修改调用方,直到替换全部完成。封装记录意味着,仅仅替换变量还不够,我还想控制它的使用方式。我可以用类来替换记录,从而达到这一目的。 - 2 封装集合(Encapsulate Collection)
封装程序中的所有可变数据。这使我很容易看清楚数据被修改的地点和修改方式,这样当我需要更改数据结构时就非常方便。通常鼓励封装——使用面向对象技术的开发者对封装尤为重视——但封装集合时人们常常犯一个错误:只对集合变量的访问进行了封装,但依然让取值函数返回集合本身。这使得集合的成员变量可以直接被修改,而封装它的类则全然不知,无法介入。为避免此种情况,我会在类上提供一些修改集合的方法——通常是“添加”和“移除”方法。这样就可使对集合的修改必须经过类,当程序演化变大时,我依然能轻易找出修改点。更好的做法是,不要让集合的取值函数返回原始集合,这就避免了客户端的意外修改。
以某种形式限制集合的访问权,只允许对集合进行读操作。比如,在Java中可以很容易地返回集合的一个只读代理,这种代理允许用户读取集合,但会阻止所有更改操作——Java的代理会抛出一个异常。也许最常见的做法是,为集合提供一个取值函数,但令其返回一个集合的副本。这样即使有人修改了副本,被封装的集合也不会受到影响。
如果集合很大,这个做法可能带来性能问题,好在多数列表都没有那么大,此时前述的性能优化基本守则依然适用
使用数据代理和数据复制的另一个区别是,对源数据的修改会反映到代理上,但不会反映到副本上。大多数时候这个区别影响不大,因为通过此种方式访问的列表通常生命周期都不长。
总的来讲,我觉得对集合保持适度的审慎是有益的,我宁愿多复制一份数据,也不愿去调试因意外修改集合招致的错误。 - 3 以对象取代基本类型(Replace Primitive with Object)
曾用名:以对象取代数据值(Replace Data Value with Object)
曾用名:以类取代类型码(Replace Type Code with Class) - 4 以查询取代临时变量(Replace Temp with Query)
如果我正在分解一个冗长的函数,那么将变量抽取到函数里能使函数的分解过程更简单,因为我就不再需要将变量作为参数传递给提炼出来的小函数。将变量的计算逻辑放到函数中,也有助于在提炼得到的函数与原函数之间设立清晰的边界,这能帮我发现并避免难缠的依赖及副作用。改用函数还让我避免了在多个函数中重复编写计算逻辑。
对于那些做快照用途的临时变量(从变量名往往可见端倪,比如oldAddress这样的名字),就不能使用本手法。 - 5 提炼类(Extract Class)
反向重构:内联类(186)
设想你有一个维护大量函数和数据的类。这样的类往往因为太大而不易理解。此时你需要考虑哪些部分可以分离出去,并将它们分离到一个独立的类中。
如果某些数据和某些函数总是一起出现,某些数据经常同时变化甚至彼此相依,这就表示你应该将它们分离出去。
另一个往往在开发后期出现的信号是类的子类化方式。
如果你发现子类化只影响类的部分特性,或如果你发现某些特性需要以一种方式来子类化,某些特性则需要以另一种方式子类化,这就意味着你需要分解原来的类。 - 6 内联类(Inline Class)
反向重构:提炼类(182)
如果一个类不再承担足够责任,不再有单独存在的理由(这通常是因为此前的重构动作移走了这个类的责任),我就会挑选这一“萎缩类”的最频繁用户(也是一个类),以本手法将“萎缩类”塞进另一个类中。
重新组织代码时常用的做法:有时把相关元素一口气搬移到位更简单,但有时先用内联手法合并各自的上下文,再使用提炼手法再次分离它们会更合适。 - 7 隐藏委托关系(Hide Delegate)
反向重构:移除中间人(192)
封装”意味着每个模块都应该尽可能少了解系统的其他部分。如此一来,一旦发生变化,需要了解这一变化的模块就会比较少——这会使变化比较容易进行。
如果某些客户端先通过服务对象的字段得到另一个对象(受托类),然后调用后者的函数,那么客户就必须知晓这一层委托关系。万一受托类修改了接口,变化会波及通过服务对象使用它的所有客户端。我可以在服务对象上放置一个简单的委托函数,将委托关系隐藏起来,从而去除这种依赖。这么一来,即使将来委托关系发生变化,变化也只会影响服务对象,而不会直接波及所有客户端。 - 8 移除中间人(Remove Middle Man)
反向重构:隐藏委托关系(189)
在隐藏委托关系(189)的“动机”一节中,我谈到了“封装受托对象”的好处。但是这层封装也是有代价的。每当客户端要使用受托类的新特性时,你就必须在服务端添加一个简单委托函数。随着受托类的特性(功能)越来越多,更多的转发函数就会使人烦躁。服务类完全变成了一个中间人(81),此时就应该让客户直接调用受托类。
很难说什么程度的隐藏才是合适的。还好,有了隐藏委托关系(189)和删除中间人,我大可不必操心这个问题,因为我可以在系统运行过程中不断进行调整。随着代码的变化,“合适的隐藏程度”这个尺度也相应改变。 - 9 替换算法(Substitute Algorithm)
使用这项重构手法之前,我得确定自己已经尽可能分解了原先的函数。替换一个巨大且复杂的算法是非常困难的,只有先将它分解为较简单的小型函数,我才能很有把握地进行算法替换工作。第8章 搬移特性
- 1 搬移函数(Move Function)
任何函数都需要具备上下文环境才能存活。搬移函数最直接的一个动因是,它频繁引用其他上下文中的元素,而对自身上下文中的元素却关心甚少。
同样,如果我在整理代码时,发现需要频繁调用一个别处的函数,我也会考虑搬移这个函数。尽管为函数选择一个最好的去处不太容易,但决定越难做,通常说明“搬移这个函数与否”的重要性也越低。在面向对象的语言里,还需要考虑该函数是否覆写了超类的函数,或者为子类所覆写。 - 2 搬移字段(Move Field)
编程活动中你需要编写许多代码,为系统实现特定的行为,但往往数据结构才是一个健壮程序的根基。一个适应于问题域的良好数据结构,可以让行为代码变得简单明了,而一个糟糕的数据结构则将招致许多无用代码,这些代码更多是在差劲的数据结构中间纠缠不清,而非为系统实现有用的行为。代码凌乱,势必难以理解;不仅如此,坏的数据结构本身也会掩藏程序的真实意图。我通常都会做些预先的设计,设法得到最恰当的数据结构,此时如果你具备一些领域驱动设计(domain-driven design)方面的经验和知识,往往有助于你更好地设计数据结构。但即便经验再丰富,技能再熟练,我仍然发现我在进行初版设计时往往还是会犯错。如果我发现数据结构已经不适应于需求,就应该马上修缮它。如果容许瑕疵存在并进一步累积,它们就会经常使我困惑,并且使代码愈来愈复杂。
总是一同出现、一同作为函数参数传递的数据,最好是规整到同一条记录中,以体现它们之间的联系。修改的难度也是引起我注意的一个原因,如果修改一条记录时,总是需要同时改动另一条记录,那么说明很可能有字段放错了位置。此外,如果我更新一个字段时,需要同时在多个结构中做出修改,那也是一个征兆,表明该字段需要被搬移到一个集中的地点,这样每次只需修改一处地方。
搬移字段的操作通常是在其他更大的改动背景下发生的。搬移字段这项重构手法对于类的实例对象通常较易进行,因为将数据访问包装到方法中,是类所天然支持的一种封装手段。
如果我要搬移的字段是裸记录,并且被许多函数直接访问,我可以先为记录创建相应的访问函数,并修改所有读取和更新记录的地方,使它们引用新创建的访问函数。 - 3 搬移语句到函数(Move Statements into Function)
反向重构:搬移语句到调用者(217)
要维护代码库的健康发展,需要遵守几条黄金守则,其中最重要的一条当属“消除重复”。 - 4 搬移语句到调用者(Move Statements to Callers)
反向重构:搬移语句到函数(213)
随着系统能力发生演进(通常只要是有用的系统,功能都会演进),原先设定的抽象边界总会悄无声息地发生偏移。对于函数来说,这样的边界偏移意味着曾经视为一个整体、一个单元的行为,如今可能已经分化出两个甚至是多个不同的关注点。函数边界发生偏移的一个征兆是,以往在多个地方共用的行为,如今需要在某些调用点面前表现出不同的行为。于是,我们得把表现不同的行为从函数里挪出,并搬移到其调用处。这个重构手法比较适合处理边界仅有些许偏移的场景,但有时调用点和调用者之间的边界已经相去甚远,此时便只能重新进行设计了。 - 5 以函数调用取代内联代码(Replace Inline Code with Function Call)
善用函数可以帮助我将相关的行为打包起来,这对于提升代码的表达力大有裨益—— 一个命名良好的函数,本身就能极好地解释代码的用途,使读者不必了解其细节。函数同样有助于消除重复,因为同一段代码我不需要编写两次,每次调用一下函数即可。此外,当我需要修改函数的内部实现时,也不需要四处寻找有没有漏改的相似代码。 - 6 移动语句(Slide Statements)
曾用名:合并重复的代码片段(Consolidate Duplicate Conditional Fragments
让存在关联的东西一起出现,可以使代码更容易理解。通常来说,把相关代码搜集到一处,往往是另一项重构(通常是在提炼函数(106))开始之前的准备工作。 - 7 拆分循环(Split Loop)
如果能够将循环拆分,让一个循环只做一件事情,那就能确保每次修改时你只需要理解要修改的那块代码的行为就可以了。拆分循环还能让每个循环更容易使用。
因此,一般拆分循环后,我还会紧接着对拆分得到的循环应用提炼函数(106)。我得先让代码结构变得清晰,才能做进一步优化;如果重构之后该循环确实成了性能的瓶颈,届时再把拆开的循环合到一起也很容易。但实际情况是,即使处理的列表数据更多一些,循环本身也很少成为性能瓶颈,更何况拆分出循环来通常还使一些更强大的优化手段变得可能。 - 8 以管道取代循环(Replace Loop with Pipeline)
集合管道(collection pipeline)
它允许我使用一组运算来描述集合的迭代过程,其中每种运算接收的入参和返回值都是一个集合。这类运算有很多种,最常见的则非map和filter莫属。 - 9 移除死代码(Remove Dead Code)
一旦代码不再被使用,我们就该立马删除它。
有可能以后又会需要这段代码,但我从不担心这种情况;就算真的发生,我也可以从版本控制系统里再次将它翻找出来。如果我真的觉得日后它极有可能再度启用,那还是要删掉它,只不过可以在代码里留一段注释,提一下这段代码的存在,以及它被移除的那个提交版本号——但老实讲,我已经记不得我上次撰写这样的注释是什么时候了,当然也未曾因为不写而感到过遗憾。在以前,业界对于死代码的处理态度多是注释掉它。但现在版本控制系统已经相当普及。如今哪怕是一个极小的代码库我都会把它放进版本控制,这些无用代码理应可以放心清理了。第9章 重新组织数据
- 1 拆分变量(Split Variable)
曾用名:移除对参数的赋值(Remove Assignments to Parameters)
曾用名:分解临时变量(Split Temp)
如果变量承担多个责任,它就应该被替换(分解)为多个变量,每个变量只承担一个责任。同一个变量承担两件不同的事情,会令代码阅读者糊涂。 - 2 字段改名(Rename Field)
记录结构中的字段可能需要改名,类的字段也一样。在类的使用者看来,取值和设值函数就等于是字段。对这些函数的改名,跟裸记录结构的字段改名一样重要。 - 3 以查询取代派生变量(Replace Derived Variable with Query)
可变数据是软件中最大的错误源头之一。尽量把可变数据的作用域限制在最小范围。 - 4 将引用对象改为值对象(Change Reference to Value)
反向重构:将值对象改为引用对象(256)
在把一个对象(或数据结构)嵌入另一个对象时,位于内部的这个对象可以被视为引用对象,也可以被视为值对象。两者最明显的差异在于如何更新内部对象的属性:如果将内部对象视为引用对象,在更新其属性时,我会保留原对象不动,更新内部对象的属性;如果将其视为值对象,我就会替换整个内部对象,新换上的对象会有我想要的属性值。
值对象通常更容易理解,主要因为它们是不可变的。一般说来,不可变的数据结构处理起来更容易。值对象和引用对象的区别也告诉我,何时不应该使用本重构手法。如果我想在几个对象之间共享一个对象,以便几个对象都能看见对共享对象的修改,那么这个共享的对象就应该是引用。 - 5 将值对象改为引用对象(Change Value to Reference)
反向重构:将引用对象改为值对象(252)
把值对象改为引用对象会带来一个结果:对于一个客观实体,只有一个代表它的对象。第10章 简化条件逻辑
- 1 分解条件表达式(Decompose Conditional)
大型函数本身就会使代码的可读性下降,而条件逻辑则会使代码更难阅读。对于条件逻辑,将每个分支条件分解成新函数还可以带来更多好处:可以突出条件逻辑,更清楚地表明每个分支的作用,并且突出每个分支的原因。本重构手法其实只是提炼函数(106)的一个应用场景。 - 2 合并条件表达式(Consolidate Conditional Expression)
之所以要合并条件代码,有两个重要原因。首先,合并后的条件代码会表述“实际上只有一次条件检查,只不过有多个并列条件需要检查而已”,从而使这一次检查的用意更清晰。
其次,这项重构往往可以为使用提炼函数(106)做好准备。条件语句的合并理由也同时指出了不要合并的理由:如果我认为这些检查的确彼此独立,的确不应该被视为同一次检查,我就不会使用本项重构。 - 3 以卫语句取代嵌套条件表达式(Replace Nested Conditional with Guard Clauses)
如果某个条件极其罕见,就应该单独检查该条件,并在该条件为真时立刻从函数中返回。这样的单独检查常常被称为“卫语句”(guard clauses)。以卫语句取代嵌套条件表达式的精髓就是:给某一条分支以特别的重视。 - 4 以多态取代条件表达式(Replace Conditional with Polymorphism)
使用类和多态能把逻辑的拆分表述得更清晰。多态是面向对象编程的关键特性之一。跟其他一切有用的特性一样,它也很容易被滥用。
在JavaScript中,多态不一定需要类型层级,只要对象实现了适当的函数就行。 - 5 引入特例(Introduce Special Case)
曾用名:引入Null对象(Introduce Null Object)
一种常见的重复代码是这种情况:一个数据结构的使用者都在检查某个特殊的值,并且当这个特殊值出现时所做的处理也都相同。处理这种情况的一个好办法是使用“特例”(Special Case)模式:创建一个特例元素,用以表达对这种特例的共用行为的处理。一个通常需要特例处理的值就是null,这也是这个模式常被叫作“Null对象”(Null Object)模式的原因——我喜欢说:Null对象是特例的一种特例。 - 6 引入断言(Introduce Assertion)
断言是一个条件表达式,应该总是为真。如果它失败,表示程序员犯了错误。断言的失败不应该被系统任何地方捕捉。断言是一种很有价值的交流形式——它们告诉阅读者,程序在执行到这一点时,对当前状态做了何种假设。另外断言对调试也很有帮助。自测试的代码降低了断言在调试方面的价值,因为逐步逼近的单元测试通常能更好地帮助调试,但我仍然看重断言在交流方面的价值。
注意,不要滥用断言。我不会使用断言来检查所有“我认为应该为真”的条件,只用来检查“必须为真”的条件。第11章 重构API
- 1 将查询函数和修改函数分离(Separate Query from Modifier)
下面是一条好规则:任何有返回值的函数,都不应该有看得到的副作用——命令与查询分离(Command-Query Separation)
将查询所得结果缓存于某个字段中,这样一来后续的重复查询就可以大大加快速度。虽然这种做法改变了对象中缓存的状态,但这一修改是察觉不到的,因为不论如何查询,总是获得相同结果。 - 2 函数参数化(Parameterize Function)
曾用名:令函数携带参数(Parameterize Method)
如果我发现两个函数逻辑非常相似,只有一些字面量值不同,可以将其合并成一个函数,以参数的形式传入不同的值,从而消除重复。这个重构可以使函数更有用,因为重构后的函数还可以用于处理其他的值。 - 3 移除标记参数(Remove Flag Argument)
曾用名:以明确函数取代参数(Replace Parameter with Explicit Methods)
“标记参数”是这样的一种参数:调用者用它来指示被调函数应该执行哪一部分逻辑。拿到一份API以后,我首先看到的是一系列可供调用的函数,但标记参数却隐藏了函数调用中存在的差异性。使用这样的函数,我还得弄清标记参数有哪些可用的值。只有调用者直接传入字面量值,这才是标记参数。另外,在函数实现内部,如果参数值只是作为数据传给其他函数,这就不是标记参数;只有参数值影响了函数内部的控制流,这才是标记参数。移除标记参数不仅使代码更整洁,并且能帮助开发工具更好地发挥作用。去掉标记参数后,代码分析工具能更容易地体现出“高级”和“普通”两种预订逻辑在使用时的区别。 - 4 保持对象完整(Preserve Whole Object)
如果我看见代码从一个记录结构中导出几个值,然后又把这几个值一起传递给一个函数,我会更愿意把整个记录传给这个函数,在函数体内部导出所需的值。从一个对象中抽取出几个值,单独对这几个值做某些逻辑操作,这是一种代码坏味道(依恋情结),通常标志着这段逻辑应该被搬移到对象中。保持对象完整经常发生在引入参数对象(140)之后,我会搜寻使用原来的数据泥团的代码,代之以使用新的对象。 - 5 以查询取代参数(Replace Parameter with Query)
曾用名:以函数取代参数(Replace Parameter with Method)
反向重构:以参数取代查询(327)
和任何代码中的语句一样,参数列表应该尽量避免重复,并且参数列表越短就越容易理解。不使用以查询取代参数最常见的原因是,移除参数可能会给函数体增加不必要的依赖关系——迫使函数访问某个程序元素,而我原本不想让函数了解这个元素的存在。如果想要去除的参数值只需要向另一个参数查询就能得到,这是使用以查询取代参数最安全的场景。如果可以从一个参数推导出另一个参数,那么几乎没有任何理由要同时传递这两个参数。另外有一件事需要留意:如果在处理的函数具有引用透明性(referential transparency,即,不论任何时候,只要传入相同的参数值,该函数的行为永远一致),这样的函数既容易理解又容易测试,我不想使其失去这种优秀品质。我不会去掉它的参数,让它去访问一个可变的全局变量。 - 6 以参数取代查询(Replace Query with Parameter)
反向重构:以查询取代参数(324)
需要使用本重构的情况大多源于我想要改变代码的依赖关系——为了让目标函数不再依赖于某个元素,我把这个元素的值以参数形式传递给该函数。这里需要注意权衡:如果把所有依赖关系都变成参数,会导致参数列表冗长重复;如果作用域之间的共享太多,又会导致函数间依赖过度。借助以参数取代查询,我可以提纯程序的某些组成部分,使其更容易测试、更容易理解。把查询变成参数以后,就迫使调用者必须弄清如何提供正确的参数值,这会增加函数调用者的复杂度,而我在设计接口时通常更愿意让接口的消费者更容易使用。
将一个依赖关系从一个模块中移出,就意味着将处理这个依赖关系的责任推回给调用者。这是为了降低耦合度而付出的代价。
JavaScript的类模型有一个问题:无法强制要求类的不可变性——始终有办法修改对象的内部数据。尽管如此,在编写一个类的时候明确说明并鼓励不可变性,通常也就足够了。尽量让类保持不可变通常是一个好的策略,以参数取代查询则是达成这一策略的利器。 - 7 移除设值函数(Remove Setting Method
如果不希望在对象创建之后此字段还有机会被改变,那就不要为它提供设值函数(同时将该字段声明为不可变)。有两种常见的情况需要讨论。一种情况是,有些人喜欢始终通过访问函数来读写字段值,包括在构造函数内也是如此。这会导致构造函数成为设值函数的唯一使用者。若果真如此,我更愿意去除设值函数,清晰地表达“构造之后不应该再更新字段值”的意图。另一种情况是,对象是由客户端通过创建脚本构造出来,而不是只有一次简单的构造函数调用。
设值函数只应该在起初的对象创建过程中调用。 - 8 以工厂函数取代构造函数(Replace Constructor with Factory Function)
需要新建一个对象时,客户端通常会调用构造函数。但与一般的函数相比,构造函数又常有一些丑陋的局限性。
构造函数的名字是固定的,因此无法使用比默认名字更清晰的函数名;构造函数需要通过特殊的操作符来调用(在很多语言中是new关键字),所以在要求普通函数的场合就难以使用。工厂函数就不受这些限制。工厂函数的实现内部可以调用构造函数,但也可以换成别的方式实现。 - 9 以命令取代函数(Replace Function with Command)
曾用名:以函数对象取代函数(Replace Method with Method Object)
反向重构:以函数取代命令(344)
“命令对象”(command object),或者简称“命令”(command)。这种对象大多只服务于单一函数,获得对该函数的请求,执行该函数,就是这种对象存在的意义。
与普通的函数相比,命令对象提供了更大的控制灵活性和更强的表达能力。除了函数调用本身,命令对象还可以支持附加的操作,例如撤销操作。
命令对象的灵活性也是以复杂性作为代价的。所以,如果要在作为一等公民的函数和命令对象之间做个选择,95%的时候我都会选函数。只有当我特别需要命令对象提供的某种能力而普通的函数无法提供这种能力时,我才会考虑使用命令对象。遵循编程语言的命名规范来给命令对象起名。如果没有合适的命名规范,就给命令对象中负责实际执行命令的函数起一个通用的名字,例如“execute”或者“call”。实际上,在JavaScript中运用此重构手法时,的确可以考虑用嵌套函数来代替命令对象。不过我还是会使用命令对象,不仅因为我对命令对象更熟悉,而且还因为我可以针对命令对象中任何一个函数进行测试和调试。 - 10 以函数取代命令(Replace Command with Function)
反向重构:以命令取代函数(337)
命令对象为处理复杂计算提供了强大的机制。借助命令对象,可以轻松地将原本复杂的函数拆解为多个方法,彼此之间通过字段共享状态;拆解后的方法可以分别调用;开始调用之前的数据状态也可以逐步构建。大多数时候,我只是想调用一个函数,让它完成自己的工作就好。如果这个函数不是太复杂,那么命令对象可能显得费而不惠,我就应该考虑将其变回普通的函数。第12章 处理继承关系
- 1 函数上移(Pull Up Method)
反向重构:函数下移(359)
函数上移常常紧随其他重构而被使用。函数上移过程中最麻烦的一点就是,被提升的函数可能会引用只出现于子类而不出现于超类的特性。此时,我就得用字段上移(353)和函数上移先将这些特性(类或者函数)提升到超类。如果两个函数工作流程大体相似,但实现细节略有差异,那么我会考虑先借助塑造模板函数(Form Template Method)
构造出相同的函数,然后再提升它们。 - 2 字段上移(Pull Up Field)
反向重构:字段下移(361)
判断若干字段是否重复,唯一的办法就是观察函数如何使用它们。如果它们被使用的方式很相似,我就可以将它们提升到超类中去。
本项重构可从两方面减少重复:首先它去除了重复的数据声明;其次它使我可以将使用该字段的行为从子类移至超类,从而去除重复的行为。 - 3 构造函数本体上移(Pull Up Constructor Body)
如果重构过程过于复杂,我会考虑转而使用以工厂函数取代构造函数(334)。 - 4 函数下移(Push Down Method)
反向重构:函数上移(350)
如果超类中的某个函数只与一个(或少数几个)子类有关,那么最好将其从超类中挪走,放到真正关心它的子类中去。这项重构手法只有在超类明确知道哪些子类需要这个函数时适用。如果超类不知晓这个信息,那我就得用以多态取代条件表达式(272),只留些共用的行为在超类。 - 5 字段下移(Push Down Field)
反向重构:字段上移(353)
如果某个字段只被一个子类(或者一小部分子类)用到,就将其搬移到需要该字段的子类中。 - 6 以子类取代类型码(Replace Type Code with Subclasses)
包含旧重构:以State/Strategy取代类型码(Replace Type Code with State/Strategy)
包含旧重构:提炼子类(Extract Subclass)
反向重构:移除子类(369)
表现分类关系的第一种工具是类型码字段——根据具体的编程语言,可能实现为枚举、符号、字符串或者数字。类型码的取值经常来自给系统提供数据的外部服务。
如果选择直接继承的方案,就用以工厂函数取代构造函数(334)包装构造函数,把选择器逻辑放在工厂函数里;如果选择间接继承的方案,选择器逻辑可以保留在构造函数里。 - 7 移除子类(Remove Subclass)
曾用名:以字段取代子类(Replace Subclass with Fields)
反向重构:以子类取代类型码(362)
子类很有用,它们为数据结构的多样和行为的多态提供支持,它们是针对差异编程的好工具。但随着软件的演化,子类所支持的变化可能会被搬移到别处,甚至完全去除,这时子类就失去了价值。如果子类的用处太少,就不值得存在了。此时,最好的选择就是移除子类,将其替换为超类中的一个字段。 - 8 提炼超类(Extract Superclass)
如果我看见两个类在做相似的事,可以利用基本的继承机制把它们的相似之处提炼到超类。提炼超类通常是比较简单的做法,所以我会首选这个方案。即便选错了,也总有以委托取代超类(399)这瓶后悔药可吃。 - 9 折叠继承体系(Collapse Hierarchy)
在重构类继承体系时,我经常把函数和字段上下移动。随着继承体系的演化,我有时会发现一个类与其超类已经没多大差别,不值得再作为独立的类存在。此时我就会把超类和子类合并起来。 - 10 以委托取代子类(Replace Subclass with Delegate)
如果一个对象的行为有明显的类别之分,继承是很自然的表达方式。导致行为不同的原因可能有多种,但继承只能用于处理一个方向上的变化。
更大的问题在于,继承给类之间引入了非常紧密的关系。在超类上做任何修改,都很可能破坏子类,所以我必须非常小心,并且充分理解子类如何从超类派生。如果两个类的逻辑分处不同的模块、由不同的团队负责,问题就会更麻烦。这两个问题用委托都能解决。对于不同的变化原因,我可以委托给不同的类。委托是对象之间常规的关系。与继承关系相比,使用委托关系时接口更清晰、耦合更少。因此,继承关系遇到问题时运用以委托取代子类是常见的情况。
有一条流行的原则:“对象组合优于类继承”(“组合”跟“委托”是同一回事)。这条原则之所以强调“组合优于继承”,其实是对彼时继承常被滥用的回应。 - 11 以委托取代超类(Replace Superclass with Delegate)
曾用名:以委托取代继承(Replace Inheritance with Delegation)
一个用得上以委托取代超类手法的例子——如果超类的一些函数对子类并不适用,就说明我不应该通过继承来获得超类的功能。
除了“子类用得上超类的所有函数”之外,合理的继承关系还有一个重要特征:子类的所有实例都应该是超类的实例,通过超类的接口来使用子类的实例应该完全不出问题。
使用委托关系能更清晰地表达“这是另一个东西,我只是需要用到其中携带的一些功能”这层意思。即便在子类继承是合理的建模方式的情况下,如果子类与超类之间的耦合过强,超类的变化很容易破坏子类的功能,我还是会使用以委托取代超类。这样做的缺点就是,对于宿主类(也就是原来的子类)和委托类(也就是原来的超类)中原本一样的函数,现在我必须在宿主类中挨个编写转发函数。不过还好,这种转发函数虽然写起来乏味,但它们都非常简单,几乎不可能出错。