前言
一、把它变成工具书
二、概述
何谓重构
注释
为何重构
何时重构
重构、架构和YAGNI
重构与软件开发过程
重构与性能
如何重构
两顶帽子
三、工具箱
3.1 坏味道
神秘命名(Mysterious Name)
重复代码(Duplicated Code)
过长函数(Long Function)
过长参数列表(Long Parameter List)
全局数据(Global Data)
可变数据(Mutable Data)
发散式变化(Divergent Change)
霰弹式修改(Shotgun Surgery)
数据泥团(Data Clumps)
基本类型偏执(Primitive Obsession)
重复的switch (Repeated Switches)
循环语句(Loops)
冗赘的元素(Lazy Element)
夸夸其谈通用性(Speculative Generality)
临时字段(Temporary Field)
过长的消息链(Message Chains)
中间人(Middle Man)
内幕交易(Insider Trading)
过大的类(Large Class)
异曲同工的类(Alternative Classes with DifferentInterfaces)
纯数据类(Data Class)
被拒绝的遗赠(Refused Bequest)
注释(Comments)
3.2 构筑测试体系
自测试代码
再添加一个测试
修改测试夹具
探测边界条件
测试远不止如此
3.3 重构目录
3.3.1 第一组重构
提炼函数(Extract Function)
内联函数(Inline Function)
提炼变量(Extract Variable)
内联变量(Inline Variable)
改变函数声明(Change Function Declaration)
封装变量(Encapsulate Variable)
变量改名(Rename Variable)
引入参数对象(Introduce Parameter Object)
函数组合成类(Combine Functions into Class)
函数组合成变换(Combine Functions into Transform)
拆分阶段(Split Phase)
3.3.2 封装
封装记录(Encapsulate Record)
封装集合(Encapsulate Collection)
以对象取代基本类型(Replace Primitive with Object)
以查询取代临时变量(Replace Temp with Query)
提炼类(Extract Class)
内联类(Inline Class)
隐藏委托关系(Hide Delegate)
移除中间人(Remove Middle Man)
3.3.3 搬移特性
搬移函数(Move Function)
搬移字段(Move Field)
搬移语句到函数(Move Statements into Function)
搬移语句到调用者(Move Statements to Callers)
以函数调用取代内联代码(Replace Inline Codewith Function Call)
移动语句(Slide Statements)
拆分循环(Split Loop)
以管道取代循环(Replace Loop with Pipeline)
移除死代码(Remove Dead Code)
3.3.4 重新组织数据
拆分变量(Split Variable)
字段改名(Rename Field)
以查询取代派生变量(Replace Derived Variablewith Query)
将引用对象改为值对象(Change Reference to Value)
将值对象改为引用对象(Change Value to Reference)
3.3.5 简化条件逻辑
分解条件表达式(Decompose Conditional)
合并条件表达式(Consolidate Conditional Expression)
以卫语句取代嵌套条件表达式(Replace Nested Conditional with Guard Clauses)
以多态取代条件表达式(Replace Conditional with Polymorphism)
引入特例(Introduce Special Case)
引入断言(Introduce Assertion)
3.3.6 重构API
将查询函数和修改函数分离(Separate Query from Modifier)
函数参数化(Parameterize Function)
移除标记参数(Remove Flag Argument)
保持对象完整(Preserve Whole Object)
以查询取代参数(Replace Parameter withQuery)
以参数取代查询(Replace Query with Parameter)
移除设值函数(Remove Setting Method)
以工厂函数取代构造函数(Replace Constructor with Factory Function)
以命令取代函数(Replace Function with Command)
以函数取代命令(Replace Command with Function)
3.3.7 处理继承关系
函数上移(Pull Up Method)
函数下移(Push Down Method)
字段上移(Pull Up Field)
字段下移(Push Down Field)
构造函数本体上移(Pull Up Constructor Body)
以子类取代类型码(Replace Type Code with Subclasses)
移除子类(Remove Subclass)
提炼超类(Extract Superclass)
折叠继承体系(Collapse Hierarchy)
以委托取代子类(Replace Subclass with Delegate)
以委托取代超类(Replace Superclass with Delegate)
四、常用技巧
五、拾真
前言
文章整理自《重构 改善既有代码的设计》第二版,文章中的代码和大部分文字皆整理自此。
一、把它变成工具书
- 通读本文,快速过一遍书籍的主要内容
- 买一本《重构》
- 读一下书籍第5章,了解这本书对重构手法的介绍格式
- 工作时,根据书中最后的"坏味道与重构手法速查表"和目录快速定位需要的手法
- 实践时最好看下对应的"做法(mechanics)"模块——作者对每种手法都写了详细步骤——这里一步一步介绍如何安全地进行此项重构。这是最佳实践,嗯,可能也是"最慢"实践。
二、概述
好代码的检验标准就是人们是否能轻而易举地修改它。
何谓重构
重构(名词):对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。
重构(动词):使用一系列重构手法,在不改变软件可观察行为的前提下,调整其结构。
注释
- 就用户应该关心的行为而言,重构之后的代码不一定与重构前行为完全一致。如果我在重构过程中发现了任何bug,重构完成后同样的bug应该仍然存在(不过,如果潜在的bug还没有被任何人发现,也可以当即把它改掉)。
- 重构与性能优化有很多相似之处:两者都需要修改代码,并且两者都不会改变程序的整体功能。两者的差别在于其目的:重构是为了让代码“更容易理解,更易于修改”。这可能使程序运行得更快,也可能使程序运行得更慢
为何重构
改进软件设计:如果没有重构,程序的设计会逐渐变质,代码会逐渐失去自己的结构。重构很像是在整理代码,你所做的就是让所有的东西回到应处的位置上。
帮助找到bug:对代码进行重构,可以深入理解代码的所作所为,并立即把新的理解反映在代码当中。搞清楚程序结构的同时,也验证了自己所做的一些假设,于是想不把bug揪出来都难。
消除重复代码:所有事物和行为在代码中只表述一次,这正是优秀设计的根本
提高编程速度:良好的设计是快速开发的根本,改善设计、提高可读性,减少错误,这些都是提高质量。
何时重构
任何情况下我都反对专门拨出时间进行重构。重构本来就不是一件应该特别拨出时间做的事情,重构应该随时随地的进行。
重构的最佳时机就在添加新功能之前。在动手添加新功能之前,我会看看现有的代码库,此时经常会发现:如果对代码结构做一点微调,工作会容易得多。也许已经有个函数提供了需要的大部分功能,但有几个字面量的值与需要略有冲突。
三次法则
第一次做某件事情是只管去做;第二次做类似的事情会产生反感;第三次再做类似的事,你就应该重构
添加新特性
代码的设计无法帮助我轻松的添加所需要的特性
review代码
修改错误的时候,review代码的时重构
何时不该重构
有时候既有代码实在太混乱,重构它还不如重新写一个来得简单。
重写而非重构的一个清楚讯号是:现有代码根本不能正常运作。
重构、架构和YAGNI
重构极大地改变了人们考虑软件架构的方式。
重构对架构最大的影响在于,通过重构,我们能得到一个设计良好的代码库,使其能够优雅地应对不断变化的需求。“在编码之前先完成架构”这种做法最大的问题在于,它假设了软件的需求可以预先充分理解。但经验显示,这个假设很多时候甚至可以说大多数时候是不切实际的。只有真正使用了软件、看到了软件对工作的影响,人们才会想明白自己到底需要什么,这样的例子不胜枚举。
灵活性
- 只根据当前的需求来构造软件,同时把软件的设计质量做得很高。随着对用户需求的理解加深,我会对架构进行重构,使其能够应对新的需要。
- 如果一种灵活性机制不会增加复杂度(比如添加几个命名良好的小函数),我可以很开心地引入它;但如果一种灵活性会增加软件复杂度,就必须先证明自己值得被引入。
- 评估“如果以后再重构有多困难”,只有当未来重构会很困难时,我才考虑现在就添加灵活性机制。
YAGNI
这种设计方法有很多名字:简单设计、增量式设计或者YAGNI[mf-yagni]——“你不会需要它”(you arenʼt going to need it)的缩写。YAGNI并不是“不做架构性思考”的意思,我把YAGNI视为将架构、设计与开发过程融合的一种工作方式,这种工作方式必须有重构作为基础才可靠。
采用YAGNI并不表示完全不用预先考虑架构。总有一些时候,如果缺少预先的思考,重构会难以开展。但两者之间的平衡点已经发生了很大的改变:如今我更倾向于等一等,待到对问题理解更充分,再来着手解决。演进式架构是一门仍在不断发展的学科,架构师们在不断探索有用的模式和实践,充分发挥迭代式架构决策的能力。
重构和YAGNI交相呼应、彼此增效,重构(及其前置实践)是YAGNI的基础,YAGNI又让重构更易于开展:比起一个塞满了想当然的灵活性的系统,当然是修改一个简单的系统要容易得多。在这些实践之间找到合适的平衡点,你就能进入良性循环,你的代码既牢固可靠又能快速响应变化的需求。
重构与软件开发过程
重构是否有效,与团队采用的其他软件开发实践紧密相关。
重构起初是作为极限编程(XP)[mf-xp]的一部分被人们采用的,XP本身就融合了一组不太常见而又彼此关联的实践,例如持续集成、自测试代码以及重构(后两者融汇成了测试驱动开发)。
极限编程是最早的敏捷软件开发方法[mf-nm]之一。如今已经有很多项目使用敏捷方法,甚至敏捷的思维已经被视为主流,但实际上大部分“敏捷”项目只是徒有其名。要真正以敏捷的方式运作项目,团队成员必须在重构上有能力、有热情,他们采用的开发过程必须与常规的、持续的重构相匹配
三大实践
三大实践——自测试代码、持续集成、重构——彼此之间有着很强的协同效应。
- 自测试代码:重构的第一块基石是自测试代码。我应该有一套自动化的测试,我可以频繁地运行它们,并且我有信心:如果我在编程过程中犯了任何错误,会有测试失败
- CI:有了CI,每个成员的重构都能快速分享给其他同事,不会发生这边在调用一个接口那边却已把这个接口删掉的情况;如果一次重构会影响别人的工作,我们很快就会知道。
重构与性能
除了对性能有严格要求的实时系统,其他任何情况下“编写快速软件”的秘密就是:先写出可调优的软件,然后调优它以求获得足够的速度。
短期看来,重构的确可能使软件变慢,但它使优化阶段的软件性能调优更容易,最终还是会得到好的效果。
3种编写快速软件的方法
- 时间预算法,这通常只用于性能要求极高的实时系统。如果使用这种方法,分解你的设计时就要做好预算,给每个组件预先分配一定资源,包括时间和空间占用。每个组件绝对不能超出自己的预算,就算拥有组件之间调度预配时间的机制也不行。这种方法高度重视性能,对于心律调节器一类的系统是必需的,因为在这样的系统中迟来的数据就是错误的数据。
- 持续关注法,这种方法要求任何程序员在任何时间做任何事时,都要设法保持系统的高性能。这种方式很常见,感觉上很有吸引力,但通常不会起太大作用。任何修改如果是为了提高性能,通常会使程序难以维护,继而减缓开发速度。如果最终得到的软件的确更快了,那么这点损失尚有所值,可惜通常事与愿违,因为性能改善一旦被分散到程序各个角落,每次改善都只不过是从对程序行为的一个狭隘视角出发而已,而且常常伴随着对编译器、运行时环境和硬件行为的误解。
- 利用90%统计数据,发现热点,去除热点:采用这种方法时,我编写构造良好的程序,不对性能投以特别的关注,直至进入性能优化阶段——那通常是在开发后期。一旦进入该阶段,我再遵循特定的流程来调优程序性能。
关于性能,一件很有趣的事情是:如果你对大多数程序进行分析,就会发现它把大半时间都耗费在一小半代码身上。如果你一视同仁地优化所有代码,90%的优化工作都是白费劲的,因为被你优化的代码大多很少被执行。你花时间做优化是为了让程序运行更快,但如果因为缺乏对程序的清楚认识而花费时间,那些时间就都被浪费掉了。
在性能优化阶段,我首先应该用一个度量工具来监控程序的运行,让它告诉我程序中哪些地方大量消耗时间和空间。这样我就可以找出性能热点所在的一小段代码。然后我应该集中关注这些性能热点,并使用持续关注法中的优化手段来优化它们。由于把注意力都集中在热点上,较少的工作量便可显现较好的成果。
如何重构
两顶帽子
Kent Beck提出了“两顶帽子”的比喻。使用重构技术开发软件时,我把自己的时间分配给两种截然不同的行为:添加新功能和重构。添加新功能时,我不应该修改既有代码,只管添加新功能。通过添加测试并让测试正常运行,我可以衡量自己的工作进度。重构时我就不能再添加功能,只管调整代码的结构。此时我不应该添加任何测试(除非发现有先前遗漏的东西),只在绝对必要(用以处理接口变化)时才修改测试。
无论何时我都清楚自己戴的是哪一顶帽子,并且明白不同的帽子对编程状态提出的不同要求。
三、工具箱
3.1 坏味道
这里主要介绍下有哪些常见的坏味道,它们坏在哪里,有哪些常见的处理思路。至于具体的处理细节,这里不做介绍,这部分知识应该是随用随查的。
神秘命名(Mysterious Name)
好的名字能节省未来用在猜谜上的大把时间。
改名不仅仅是修改名字而已。如果你想不出一个好名字,说明背后很可能潜藏着更深的设计问题。为一个恼人的名字所付出的纠结,常常能推动我们对代码进行精简。
处理
- 深思熟虑如何给函数、模块、变量和类命名,使它们能清晰地表明自己的功能和用法。
- 常用的重构手法,包括改变函数声明(124)(用于给函数改名)、变量改名(137)、字段改名(244)等
重复代码(Duplicated Code)
如果你在一个以上的地点看到相同的代码结构,那么可以肯定:设法将它们合而为一,程序会变得更好。
过长函数(Long Function)
识别
- 寻找注释:它们通常能指出代码用途和实现手法之间的语义距离。如果代码前方有一行注释,就是在提醒你:可以将这段代码替换成一个函数,而且可以在注释的基础上给这个函数命名
处理 - 如果重复的代码段位于同一个超类的不同子类中,可以使用函数上移(350)来避免在两个子类之间互相调用。
- 抽出函数的原则:每当感觉需要以注释来说明点什么的时候,我们就把需要说明的东西写进一个独立函数中,并以其用途(而非实现手法)命名。
过长参数列表(Long Parameter List)
处理
以查询取代参数(324)、保持对象完整(319)、引入参数对象(140)、移除标记参数(314)、函数组合成类(144)
全局数据(Global Data)
全局数据的问题在于,从代码库的任何一个角落都可以修改它。
处理
你把全局数据用一个函数包装起来,至少你就能看见修改它的地方,并开始控制对它的访问。随后,最好将这个函数(及其封装的数据)搬移到一个类或模块中,只允许模块内的代码使用它,从而尽量控制其作用域。
可变数据(Mutable Data)
对数据的修改经常导致出乎意料的结果和难以发现的bug。我在一处更新数据,却没有意识到软件中的另一处期望着完全不同的数据,于是一个功能失效了——如果故障只在很罕见的情况下发生,要找出故障原因就会更加困难
处理
- 函数式编程——完全建立在“数据永不改变”的概念基础上:如果要更新一个数据结构,就返回一份新的数据副本,旧的数据仍保持不变。
- 如果一个变量在其内部结构中包含了数据,通常最好不要直接修改其中的数据,而是用将引用对象改为值对象(252)令其直接替换整个数据结构。
- 封装变量(132)、拆分变量(240)、移动语句(223)、提炼函数(106)等等
发散式变化(Divergent Change)
改一处变多处。如果某个模块经常因为不同的原因在不同的方向上发生变化,发散式变化就出现了。当你看着一个类说:“呃,如果新加入一个数据库,我必须修改这3个函数;如果新出现一种金融工具,我必须修改这4个函数。”这就是发散式变化的征兆。数据库交互和金融逻辑处理是两个不同的上下文,将它们分别搬移到各自独立的模块中,能让程序变得更好:每当要对某个上下文做修改时,我们只需要理解这个上下文,而不必操心另一个。
“每次只关心一个上下文”这一点一直很重要,在如今这个信息爆炸、脑容量不够用的年代就愈发紧要。
处理
拆分阶段(154)、搬移函数(198)、提炼函数(106)等等
霰弹式修改(Shotgun Surgery)
变一处改多处,和发散式变化相反,如果每遇到某种变化,你都必须在许多不同的类内做出许多小修改,你所面临的坏味道就是霰弹式修改。
处理
- 一个常用的策略就是使用与内联(inline)相关的重构——如内联函数(115)或是内联类(186)——把本不该分散的逻辑拽回一处。
- 用与提炼相关的重构手法将其拆解成更合理的小块
- 搬移函数(198)、搬移字段(207)、函数组合成类(144)、函数组合成变换(149)、拆分阶段(154)
数据泥团(Data Clumps)
你常常可以在很多地方看到相同的三四项数据:两个类中相同的字段、许多函数签名中相同的参数。这些总是绑在一起出现的数据应该拥有属于它们自己的对象。
识别
一个好的评判办法是:删掉众多数据中的一项。如果这么做,其他数据有没有因而失去意义?如果它们不再有意义,这就是一个明确信号:你应该为它们产生一个新对象。
处理
最根本的原则是:将总是一起变化的东西放在一块儿
基本类型偏执(Primitive Obsession)
很多程序员不愿意创建对自己的问题域有用的基本类型,如钱、坐标、范围等。于是,我们看到了把钱当作普通数字来计算的情况、计算物理量时无视单位(如把英寸与毫米相加)的情况以及大量类似 if (a < upper && a > lower) 这样的代码。
一个合适的类型,至少能包含一致的显示逻辑,在用户界面上需要显示时可以使用。
处理
你可以运用以对象取代基本类型(174)将原本单独存在的数据值替换为对象,从而走出传统的洞窟,进入炙手可热的对象世界。
如果想要替换的数据值是控制条件行为的类型码,则可以运用以子类取代类型码(362)加上以多态取代条件表达式(272)的组合将它换掉。
如果你有一组总是同时出现的基本类型数据,这就是数据泥团的征兆,应该运用提炼类(182)和引入参数对象(140)来处理
重复的switch (Repeated Switches)
重复的 switch :在不同的地方反复使用同样的 switch逻辑(可能是以 switch/case 语句的形式,也可能是以连续的 if/else 语句的形式)
重复的switch的问题在于:每当你想增加一个选择分支时,必须找到所有的switch,并逐一更新。多态给了我们对抗这种黑暗力量的武器,使我们得到更优雅的代码库。
处理
多态
循环语句(Loops)
处理
我们可以使用以管道取代循环(231)来让这些老古董退休。我们发现,管道操作(如filter和map)可以帮助我们更快地看清被处理的元素以及处理它们的动作。
冗赘的元素(Lazy Element)
程序元素(如类和函数)能给代码增加结构,从而支持变化、促进复用或者哪怕只是提供更好的名字也好,但有时我们真的不需要这层额外的结构。
可能有这样一个函数,它的名字就跟实现代码看起来一模一样;也可能有这样一个类,根本就是一个简单的函数。这可能是因为,起初在编写这个函数时,程序员也许期望它将来有一天会变大、变复杂,但那一天从未到来;也可能是因为,这个类原本是有用的,但随着重构的进行越变越小,最后只剩了一个函数。不论上述哪一种原因,请让这样的程序元素庄严赴义吧。
处理
- 使用内联函数(115)或是内联类(186)。
- 如果这个类处于一个继承体系中,可以使用折叠继承体系(380)。
夸夸其谈通用性(Speculative Generality)
企图以各种各样的钩子和特殊情况来处理一些非必要的事情,这种怀味道就出现了。如果用到了那就值得去做,如果用不到那就不值得,只会挡你的路,所以把它挪开吧。
处理
折叠继承体系(380)、内联函数(115)和内联类(186)、改变函数声明(124)
临时字段(Temporary Field)
其内部某个字段仅为某种特定情况而设。这样的代码让人不易理解,因为你通常认为对象在所有时候都需要它的所有字段。在字段未被使用的情况下猜测当初设置它的目的,会让你发疯。
处理
提炼类(182)、搬移函数(198)、引入特例(289)
过长的消息链(Message Chains)
如果你看到用户向一个对象请求另一个对象,然后再向后者请求另一个对象,然后再请求另一个对象……这就是消息链。在实际代码中你看到的可能是一长串取值函数或一长串临时变量。采取这种方式,意味客户端代码将与查找过程中的导航结构紧密耦合。一旦对象间的关系发生任何变化,客户端就不得不做出相应修改。
处理
- 隐藏委托关系(189)
- 先观察消息链最终得到的对象是用来干什么的,看看能否以提炼函数(106)把使用该对象的代码提炼到一个独立的函数中,再运用搬移函数(198)把这个函数推入消息链。如果还有许多客户端代码需要访问链上的其他对象,同样添加一个函数来完成此事。
中间人(Middle Man)
封装往往伴随着委托。比如,你问主管是否有时间参加一个会议,他就把这个消息“委托”给他的记事簿,然后才能回答你。很好,你没必要知道这位主管到底使用传统记事簿还是使用电子记事簿抑或是秘书来记录自己的约会。
你也许会看到某个类的接口有一半的函数都委托给其他类,这样就是过度运用。这时应该使用移除中间人(192),直接和真正负责的对象打交道
处理
移除中间人(192)、内联函数(115)、委托取代超类(399)、以委托取代子类(381)
内幕交易(Insider Trading)
模块间的数据交换不可避免,但我们必须尽量减少这种情况,并把这种交换都放到明面上来。
处理
- 如果两个模块有共同的兴趣,可以尝试再新建一个模块,把这些共用的数据放在一个管理良好的地方;或者用隐藏委托关系(189),把另一个模块变成两者的中介
- 继承常会造成密谋,因为子类对超类的了解总是超过后者的主观愿望。如果你觉得该让这个孩子独立生活了,请运用以委托取代子类(381)或以委托取代超类(399)让它离开继承体系。
过大的类(Large Class)
如果想利用单个类做太多事情,其内往往就会出现太多字段。一旦如此,重复代码也就接踵而至了。
处理
- 类内的数个变量有着相同的前缀或后缀,这就意味着有机会把它们提炼到某个组件内
- 有时候类并非在所有时刻都使用所有字段。若果真如此,你或许可以进行多次提炼
- 观察一个大类的使用者,经常能找到如何拆分类的线索。看看使用者是否只用到了这个类所有功能的一个子集,每个这样的子集都可能拆分成一个独立的类。
异曲同工的类(Alternative Classes with DifferentInterfaces)
使用类的好处之一就在于可以替换:今天用这个类,未来可以换成用另一个类。但只有当两个类的接口一致时,才能做这种替换
处理
改变函数声明(124)、搬移函数(198)、提炼超类(375)
纯数据类(Data Class)
纯数据类是指:它们拥有一些字段,以及用于访问(读写)这些字段的函数,除此之外一无长物。这样的类只是一种不会说话的数据容器,它们几乎一定被其他类过分细琐地操控着。
纯数据类常常意味着行为被放在了错误的地方
处理
- 这些类早期可能拥有 public 字段,若果真如此,你应该在别人注意到它们之前,立刻运用封装记录(162)将它们封装起来
- 对于那些不该被其他类修改的字段,请运用移除设值函数(331)。
- 纯数据类常常意味着行为被放在了错误的地方,把处理数据的行为从客户端搬移到纯数据类里来,就能使情况大为改观。
- 纯数据记录对象被用作函数调用的返回结果,比如使用拆分阶段(154)之后得到的中转数据结构。这种结果数据对象有一个关键的特征:它是不可修改的(至少在拆分阶段(154)的实际操作中是这样)。不可修改的字段无须封装,使用者可以直接通过字段取得数据,无须通过取值函数。
被拒绝的遗赠(Refused Bequest)
子类应该继承超类的函数和数据。但如果它们不想或不需要继承,又该怎么办呢?它们得到所有礼物,却只从中挑选几样来玩!
处理
- 按传统做法,这就意味着继承体系设计错误。你需要为这个子类新建一个兄弟类,再运用函数下移(359)和字段下移(361)把所有用不到的函数下推给那个兄弟。这样一来,超类就只持有所有子类共享的东西
- 如果子类复用了超类的行为(实现),却又不愿意支持超类的接口,应该运用以委托取代子类(381)或者以委托取代超类(399)彻底划清界限。
注释(Comments)
我们之所以要在这里提到注释,是因为人们常把它当作“除臭剂”来使用。常常会有这样的情况:你看到一段代码有着长长的注释,然后发现,这些注释之所以存在乃是因为代码很糟糕
处理
- 如果你需要注释来解释一块代码做了什么,试试提炼函数(106)
- 如果函数已经提炼出来,但还是需要注释来解释其行为,试试用改变函数声明(124)为它改名
- 如果你需要注释说明某些系统的需求规格,试试引入断言(302)。
3.2 构筑测试体系
自测试代码
编写测试代码其实就是在问自己:为了添加这个功能,我需要实现些什么?编写测试代码还能帮我把注意力集中于接口而非实现(这永远是一件好事)。预先写好的测试代码也为我的工作安上一个明确的结束标志:一旦测试代码正常运行,工作就可以结束了。
Kent Beck将这种先写测试的习惯提炼成一门技艺,叫测试驱动开发(Test-Driven Development,TDD)[mf-tdd]。测试驱动开发的编程方式依赖于下面这个短循环:先编写一个(失败的)测试,编写代码使测试通过,然后进行重构以保证代码整洁。这个“测试、编码、重构”的循环应该在每个小时内都完成很多次
这里的代码示例使用的是Mocha框架
再添加一个测试
测试什么
观察被测试类应该做的所有事情,然后对这个类的每个行为进行测试,包括各种可能使它发生异常的边界条件。这不同于某些程序员提倡的“测试所有public函数”的风格。记住,测试应该是一种风险驱动的行为,我测试的目标是希望找出现在或未来可能出现的bug。所以我不会去测试那些仅仅读或写一个字段的访问函数,因为它们太简单了,不太可能出错。
要注意的
- 共享测试夹具(下面的asia)会使测试间产生交互,这是滋生bug的温床。如果未来有一个测试改变了这个共享对象,测试就可能时不时失败,因为测试之间会通过共享夹具产生交互,而测试的结果就会受测试运行次序的影响。
describe('province', function() {
const asia = new Province(sampleProvinceData()); // DON'T DO THIS
it('shortfall', function() {
expect(asia.shortfall).equal(5);
});
it('profit', function() {
expect(asia.profit).equal(230);
});
});
推荐使用下面的写法,beforeEach 子句会在每个测试之前运行一遍,将 asia 变量清空,每次都给它赋一个新的值。这样我就能在每个测试开始前,为它们各自构建一套新的测试夹具,这保证了测试的独立性,避免了可能带来麻烦的不确定性。
describe('province', function() {
let asia;
beforeEach(function() {
asia = new Province(sampleProvinceData());
});
it('shortfall', function() {
expect(asia.shortfall).equal(5);
});
it('profit', function() {
expect(asia.profit).equal(230);
});
});
既然我在 beforeEach 里运行的代码会对每个测试生效,那么为何不直接把它挪到每个 it 块里呢?让所有测试共享一段测试夹具代码的原因,是为了使我对公用的夹具代码感到熟悉,从而将眼光聚焦于每个测试的不同之处。
beforeEach 块旨在告诉读者,我使用了同一套标准夹具。你可以接着阅读 describe 块里的所有测试,并知道它们都是基于同样的数据展开测试的。
- 一个 it 语句中最好只有一个验证语句,否则测试可能在进行第一个验证时就失败,这通常会掩盖一些重要的错误信息,不利于你了解测试失败的原因。
修改测试夹具
加载完测试夹具后,我编写了一些测试来探查它的一些特性。但在实际应用中,该夹具可能会被频繁更新,因为用户可能在界面上修改数值
it('change production', function() {
asia.producers[0].production = 20;
expect(asia.shortfall).equal(-6);
expect(asia.profit).equal(292);
});
上面是一个常见的测试模式:拿到 beforeEach 配置好的初始标准夹具,然后对该夹具进行必要的检查,最后验证它是否表现出我期望的行为。如果你读过测试相关的资料,就会经常听到各种类似的术语,比如配置-检查-验证(setup-exercise-verify)、given-when-then或者准备-行为-断言(arrange-act-assert)等。
(其实还有第四个阶段,只是不那么明显,一般很少提及,那就是拆除阶段。此阶段可将测试夹具移除,以确保不同测试之间不会产生交互.因为我是在 beforeEach 中配置好数据的,所以测试框架会默认在不同的测试间将我的测试夹具移除,相当于我自动享受了拆除阶段带来的便利)
探测边界条件
对于这个业务领域来讲,这个边界条件有意义吗?编写这样的测试能帮助思考代码本应如何应对边界场景
如果这个错误会导致脏数据在应用中到处传递,或是产生一些很难调试的失败,我可能会用引入断言(302)手法,使代码不满足预设条件时快速失败。
什么时候应该停下来?
任何测试都不能证明一个程序没有bug
有些人拥护以测试覆盖率[mf-tc]作为指标,但测试覆盖率的分析只能识别出那些未被测试覆盖到的代码,而不能用来衡量一个测试集的质量高低
你应该把测试集中在可能出错的地方。观察代码,看哪儿变得复杂;观察函数,思考哪些地方可能出错。
请你试问自己:如果有人在代码里引入了一个缺陷,你有多大的自信它能被测试集揪出来?如果我重构完代码,看见全部变绿的测试就可以十分自信没有引入额外的bug,这样,我就可以高兴地说,我已经有了一套足够好的测试。
测试写得太多的一个征兆是,相比要改的代码,我在改动测试上花费了更多的时间——并且我能感到测试就在拖慢我。
测试远不止如此
每当你收到bug报告,请先写一个单元测试来暴露这个bug。
测试也是一种迭代式的活动。除非你技能非常纯熟,或者非常幸运,否则你很难第一次就把测试写对
3.3 重构目录
作者在6-12章详细介绍了常用的重构手法,本文只介绍下这些手法中容易被忽略或者纠结的点,让大家知道还可以这样,至于其他的,就随用随查吧,这里不赘述了。
3.3.1 第一组重构
首先介绍一组作者认为最有用的重构
提炼函数(Extract Function)
何时应该把代码放进独立的函数
观点1:一个函数应该能在一屏中显示
观点2:从复用的角度考虑,认为只要被用过不止一次的代码,就应该单独放进一个函数
作者观点:“将意图与实现分开”。如果你需要花时间浏览一段代码才能弄清它到底在干什么,那么就应该将其提炼到一个函数中,并根据它所做的事为其命名。以后再读到这段代码时,你一眼就能看到函数的用途,大多数时候根本不需要关心函数如何达成其用途(这是函数体内干的事)。
如果需要返回的变量不止一个,又该怎么办呢?
最好的选择通常是:挑选另一块代码来提炼。我比较喜欢让每个函数都只返回一个值,所以我会安排多个函数,用以返回多个值。如果真的有必要提炼一个函数并返回多个值,可以构造并返回一个记录对象—不过通常更好的办法还是回过头来重新处理局部变量,我常用的重构手法有以查询取代临时变量(178)和拆分变量(240)。
命名
创造一个新函数,根据这个函数的意图来对它命名(以它“做什么”来命名,而不是以它“怎样做”命名)。
如果想不出一个更有意义的名称,这就是一个信号,可能我不应该提炼这块代码。不过,我不一定非得马上想出最好的名字,有时在提炼的过程中好的名字才会出现
操作注意
仔细检查提炼出的代码,看看其中是否引用了作用域限于源函数、在提炼出的新函数中访问不到的变量。若是,以参数的形式将它们传递给新函数。
有时在提炼部分被赋值的局部变量太多,这时最好是先放弃提炼。这种情况下,我会考虑先使用别的重构手法,例如拆分变量(240)或者以查询取代临时变量(178),来简化变量的使用情况,然后再考虑提炼函数
内联函数(Inline Function)
OLD
function getRating(driver) {
return moreThanFiveLateDeliveries(driver) ? 2 : 1;
}
function moreThanFiveLateDeliveries(driver) {
return driver.numberOfLateDeliveries > 5;
}
NEW
function getRating(driver) {
return (driver.numberOfLateDeliveries > 5) ? 2 : 1;
}
使用场景
- 有时候你会遇到某些函数,其内部代码和函数名称同样清晰易读,你就应该去掉这个函数,直接使用其中的代码.
- 我手上有一群组织不甚合理的函数。可以将它们都内联到一个大型函数中,再以我喜欢的方式重新提炼出小函数。
操作注意
检查函数,确定它不具多态性。如果该函数属于一个类,并且有子类继承了这个函数,那么就无法内联。
提炼变量(Extract Variable)
OLD
return order.quantity * order.itemPrice -
Math.max(0, order.quantity - 500) * order.itemPrice * 0.05 +
Math.min(order.quantity * order.itemPrice * 0.1, 100);
NEW
const basePrice = order.quantity * order.itemPrice;
const quantityDiscount = Math.max(0, order.quantity - 500) * order.itemPrice * 0.05;
const shipping = Math.min(basePrice * 0.1, 100);
return basePrice - quantityDiscount + shipping;
操作注意
- 如果使用提炼变量,意味着要给代码中的一个表达式命名,这时要仔细考虑这个名字所处的上下文。如果在更宽的范围可以访问到这个名字,就意味着其他代码也可以用到这个表达式,而不用把它重写一遍,这样能减少重复,并且能更好地表达代码的意图:
- 如果这个名字只在当前的函数中有意义,那么提炼变量是个不错的选择;
- 如果这个变量名在更宽的上下文中也有意义,就要考虑将其暴露出来,通常以函数的形式。
- “将新的名字暴露得更宽”的坏处则是需要额外的工作量。如果工作量很大,我会暂时搁下这个想法
- 确认要提炼的表达式没有副作用。
- 最好是声明一个不可修改的变量,把你想要提炼的表达式复制一份,以该表达式的结果值给这个变量赋值
内联变量(Inline Variable)
OLD
let basePrice = anOrder.basePrice;
return (basePrice > 1000);
NEW
return anOrder.basePrice > 1000;
操作注意
检查确认变量赋值语句的右侧表达式没有副作用。
如果变量没有被声明为不可修改,先将其变为不可修改,并执行测试。(为了确保该变量只被赋值一次)
改变函数声明(Change Function Declaration)
function circum(radius) {...}
function circumference(radius) {...}
场景
函数名:看到一个函数的名字不对,一旦发现了更好的名字,就得尽快给函数改名。这样,下一次再看到这段代码时,我就不用再费力搞懂其中到底在干什么。有一个改进函数名字的好办法:先写一句注释描述这个函数的用途,再把这句注释变成函数的名字。
参数列表:对于函数的参数,道理也是一样。函数的参数列表阐述了函数如何与外部世界共处。函数的参数设置了一个上下文,只有在这个上下文中,我才能使用这个函数。修改参数列表不仅能增加函数的应用范围,还能改变连接一个模块所需的条件,从而去除不必要的耦合
操作注意
- 小步前进:最好能把大的修改拆成小的步骤,所以如果你既想修改函数名,又想添加参数,最好分成两步来做
- 注意多态:如果要重构的函数属于一个具有多态性的类,那么对于该函数的每个实现版本,你都需要通过“提炼出一个新函数”的方式添加一层间接,并把旧函数的调用转发给新函数。如果该函数的多态性是在一个类继承体系中体现,那么只需要在超类上转发即可;如果各个实现类之间并没有一个共同的超类,那么就需要在每个实现类上做转发。
- 对外api:如果要重构一个已对外发布的API,在提炼出新函数之后,你可以暂停重构,将原来的函数声明为“不推荐使用”(deprecated),然后给客户端一点时间转为使用新函数。等你有信心所有客户端都已经从旧函数迁移到新函数,再移除旧函数的声明。
- 不能一步到位的推荐使用迁移式做法
function circum(radius) {
return 2 * Math.PI * radius;
}
function circum(radius) {
return circumference(radius);
}
function circumference(radius) {
return 2 * Math.PI * radius;
}
封装变量(Encapsulate Variable)
let defaultOwner = {firstName: "Martin", lastName: "Fowler"};
let defaultOwnerData = {firstName: "Martin", lastName: "Fowler"};
export function defaultOwner() {return defaultOwnerData;}
export function setDefaultOwner(arg) {defaultOwnerData = arg;}
场景
- 如果想要搬移一处被广泛使用的数据,最好的办法往往是先以函数形式封装所有对该数据的访问。这样,我就能把“重新组织数据”的困难任务转化为“重新组织函数”这个相对简单的任务。
- 封装能提供一个清晰的观测点,可以由此监控数据的变化和使用情况;我还可以轻松地添加数据被修改时的验证或后续逻辑
- 对于所有可变的数据,只要它的作用域超出单个函数,就将其封装起来,只允许通过函数访问。数据的作用域越大,封装就越重要
操作注意
- 寻找直接使用变量的地方以进行替换时,可以试试将变量改名,再执行测试,找出仍在直接使用该变量的代码。(这时java这种强类型静态类型语言的优势就出来了)
- 当需要把封装做得更深入,不仅控制对变量引用的修改,还要控制对变量内容的修改时,有两个办法可以做到
- 最简单的办法是禁止对数据结构内部的数值做任何修改,修改取值函数,使其返回该数据的一份副本(其实设值函数也可以返回一份副本。这取决于数据从哪儿来,以及我是否需要保留对源数据的连接,以便知悉源数据的变化)
- 阻止对数据的修改,比如通过封装记录(162)就能很好地实现这一效果
变量改名(Rename Variable)
好的命名是整洁编程的核心。变量可以很好地解释一段程序在干什么——如果变量名起得好的话。
操作注意
- 如果在另一个代码库中使用了该变量,这就是一个“已发布变量”(published variable),此时不能进行这个重构。
- 如果变量值从不修改,可以将其复制到一个新名字之下,然后逐一修改使用代码,每次修改后执行测试
引入参数对象(Introduce Parameter Object)
function amountInvoiced(startDate, endDate) {...}
function amountReceived(startDate, endDate) {...}
function amountOverdue(startDate, endDate) {...}
function amountInvoiced(aDateRange) {...}
function amountReceived(aDateRange) {...}
function amountOverdue(aDateRange) {...}
场景
一组数据项总是结伴同行,出没于一个又一个函数。这样一组数据就是所谓的数据泥团,我喜欢代之以一个数据结构。
这项重构真正的意义在于,它会催生代码中更深层次的改变。一旦识别出新的数据结构,我就可以重组程序的行为来使用这些结构。我会创建出函数来捕捉围绕这些数据的共用行为——可能只是一组共用的函数,也可能用一个类把数据结构与使用数据的函数组合起来。这个过程会改变代码的概念图景,将这些数据结构提升为新的抽象概念,可以帮助我更好地理解问题域
函数组合成类(Combine Functions into Class)
function base(aReading) {...}
function taxableCharge(aReading) {...}
function calculateBaseCharge(aReading) {...}
class Reading {
base() {...}
taxableCharge() {...}
calculateBaseCharge() {...}
}
使用场景
如果发现一组函数形影不离地操作同一块数据(通常是将这块数据作为参数传递给函数),我就认为,是时候组建一个类了。类能明确地给这些函数提供一个共用的环境,在对象内部调用这些函数可以少传许多参数,从而简化函数调用,并且这样一个对象也可以更方便地传递给系统的其他部分。
函数组合成变换(Combine Functions into Transform)
function base(aReading) {...}
function taxableCharge(aReading) {...}
function enrichReading(argReading) {
const aReading = _.cloneDeep(argReading);
aReading.baseCharge = base(aReading);
aReading.taxableCharge = taxableCharge(aReading);
return aReading;
}
在软件中,经常需要把数据“喂”给一个程序,让它再计算出各种派生信息。
一个方式是采用数据变换(transform)函数:这种函数接受源数据作为输入,计算出所有的派生数据,将派生数据以字段形式填入输出数据。有了变换函数,我就始终只需要到变换函数中去检查计算派生数据的逻辑,这对只读数据尤其有用。
函数组合成变换的替代方案是函数组合成类(144),后者的做法是先用源数据创建一个类,再把相关的计算逻辑搬移到类中。这两个重构手法都很有用,我常会根据代码库中已有的编程风格来选择使用其中哪一个
两者有一个重要的区别:如果代码中会对源数据做更新,那么使用类要好得多;如果使用变换,派生数据会被存储在新生成的记录中,一旦源数据被修改,我就会遭遇数据不一致。
拆分阶段(Split Phase)
const orderData = orderString.split(/\s+/);
const productPrice = priceList[orderData[0].split("-")[1]];
const orderPrice = parseInt(orderData[1]) * productPrice;
const orderRecord = parseOrder(order);
const orderPrice = price(orderRecord, priceList);
function parseOrder(aString) {
const values = aString.split(/\s+/);
return ({
productID: values[0].split("-")[1],
quantity: parseInt(values[1]),
});
}
function price(order, priceList) {
return order.quantity * priceList[order.productID];
}
场景
每当看见一段代码在同时处理两件不同的事,我就想把它拆分成各自独立的模块,因为这样到了需要修改的时候,我就可以单独处理每个主题,而不必同时在脑子里考虑两个不同的主题
最简洁的拆分方法之一,就是把一大段行为分成顺序执行的两个阶段
3.3.2 封装
类是为隐藏信息而生的
分解模块时最重要的标准,也许就是识别出那些模块应该对外界隐藏的小秘密了[Parnas]。数据结构无疑是最常见的一种秘密,我可以用封装记录(162)或封装集合(170)手法来隐藏它们的细节
封装记录(Encapsulate Record)
organization = {name: "Acme Gooseberries", country: "GB"};
class Organization {
constructor(data) {
this._name = data.name;
this._country = data.country;
}
get name() {return this._name;}
set name(arg) {this._name = arg;}
get country() {return this._country;}
set country(arg) {this._country = arg;}
}
对于可变数据,我总是更偏爱使用类对象而非记录的原因:
- 对象可以隐藏结构的细节,仅为这3个值提供对应的方法。
- 该对象的用户不必追究存储的细节和计算的过程。
- 这种封装还有助于字段的改名:我可以重新命名字段,但同时提供新老字段名的访问方法,这样我就可以渐进地修改调用方,直到替换全部完成。
封装集合(Encapsulate Collection)
class Person {
get courses() {return this._courses;}
set courses(aList) {this._courses = aList;}
class Person {
get courses() {return this._courses.slice();}
addCourse(aCourse) { ... }
removeCourse(aCourse) { ... }
操作注意
- 只对集合变量的访问进行了封装,但依然让取值函数返回集合本身。这使得集合的成员变量可以直接被修改,而封装它的类则全然不知,无法介入。
- 集合封装使用数据代理和数据复制并无定式,最重要的是在同个代码库中做法要保持一致。我建议只用一种方案,这样每个人都能很快习惯它,并在每次调用集合的访问函数时期望相同的行为。
- 如果存在对该集合的设值函数,尽可能先用移除设值函数(331)移除它。如果不能移除该设值函数,至少让它返回集合的一份副本。
- 对不支持的集合操作,可以抛出错误,留给客户端一个自己处理的机会
以对象取代基本类型(Replace Primitive with Object)
orders.filter(o => "high" === o.priority
|| "rush" === o.priority);
orders.filter(o => o.priority.higherThan(new Priority("normal")))
场景
一旦我发现对某个数据的操作不仅仅局限于打印时(即这个数据是有交互行为的),我就会为它创建一个新类。一开始这个类也许只是简单包装一下简单类型的数据,不过只要类有了,日后添加的业务逻辑就有地可去了
以查询取代临时变量(Replace Temp with Query)
const basePrice = this._quantity * this._itemPrice;
if (basePrice > 1000)
return basePrice * 0.95;
else
return basePrice * 0.98;
get basePrice() {this._quantity * this._itemPrice;}
...
if (this.basePrice > 1000)
return this.basePrice * 0.95;
else
return this.basePrice * 0.98;
场景
- 如果分解一个冗长的函数,那么将变量抽取到函数里能使函数的分解过程更简单,因为不再需要将变量作为参数传递给提炼出来的小函数。
- 将变量的计算逻辑放到函数中,也有助于在提炼得到的函数与原函数之间设立清晰的边界,这能帮我发现并避免难缠的依赖及副作用。
提炼类(Extract Class)
class Person {
get officeAreaCode() {return this._officeAreaCode;}
get officeNumber() {return this._officeNumber;}
class Person {
get officeAreaCode() {return this._telephoneNumber.areaCode;}
get officeNumber() {return this._telephoneNumber.number;}
}
class TelephoneNumber {
get areaCode() {return this._areaCode;}
get number() {return this._number;}
}
内联类(Inline Class)
内联类正好与提炼类(182)相反。
场景
- 如果一个类不再承担足够责任,不再有单独存在的理由(这通常是因为此前的重构动作移走了这个类的责任),我就会挑选这一“萎缩类”的最频繁用户(也是一个类),以本手法将“萎缩类”塞进另一个类中。
- 我手头有两个类,想重新安排它们肩负的职责,并让它们产生关联。这时我发现先用本手法将它们内联成一个类再用提炼类(182)去分离其职责会更加简单。这是重新组织代码时常用的做法:有时把相关元素一口气搬移到位更简单,但有时先用内联手法合并各自的上下文,再使用提炼手法再次分离它们会更合适。//todo
隐藏委托关系(Hide Delegate)
manager = aPerson.department.manager;
manager = aPerson.manager;
class Person {
get manager() {return this.department.manager;}
每个模块都应该尽可能少了解系统的其他部分。如此一来,一旦发生变化,需要了解这一变化的模块就会比较少——这会使变化比较容易进行。
移除中间人(Remove Middle Man)
和隐藏委托关系相反
场景
封装也是有代价的,每当客户端要使用受托类的新特性时,你就必须在服务端添加一个简单委托函数。随着受托类的特性(功能)越来越多,更多的转发函数就会使人烦躁。服务类完全变成了一个中间人(81),此时就应该让客户直接调用受托类.
3.3.3 搬移特性
介绍如何在不同的上下文之间搬移元素
搬移函数(Move Function)
class Account {
get overdraftCharge() {...}
class AccountType {
get overdraftCharge() {...}
就是把函数换个位置,从一个地方搬到另一个地方.尽管为函数选择一个最好的去处不太容易,但决定越难做,通常说明“搬移这个函数与否”的重要性也越低
任何函数都需要具备上下文环境才能存活。这个上下文可以是全局的,但它更多时候是由某种形式的模块所提供的。
场景
- 频繁引用其他上下文中的元素,而对自身上下文中的元素却关心甚少
操作注意 - 如果发现有些被调用的函数也需要搬移,我通常会先搬移它们。这样可以保证移动一组函数时,总是从依赖最少的那个函数入手。
- 如果该函数拥有一些子函数,并且它是这些子函数的唯一调用者,那么你可以先将子函数内联进来,一并搬移到新家后再重新提炼出子函数
- 在面向对象的语言里,还需要考虑该函数是否覆写了超类的函数,或者为子类所覆写。
搬移字段(Move Field)
就是把类的字段换个位置
场景
- 总是一同出现、一同作为函数参数传递的数据,最好是规整到同一条记录中,以体现它们之间的联系。
- 如果修改一条记录时,总是需要同时改动另一条记录,那么说明很可能有字段放错了位置
搬移语句到函数(Move Statements into Function)
result.push(`<p>title: ${person.photo.title}</p>`);
result.concat(photoData(person.photo));
function photoData(aPhoto) {
return [
`<p>location: ${aPhoto.location}</p>`,
`<p>date: ${aPhoto.date.toDateString()}</p>`,
];
}
result.concat(photoData(person.photo));
function photoData(aPhoto) {
return [
`<p>title: ${aPhoto.title}</p>`,
`<p>location: ${aPhoto.location}</p>`,
`<p>date: ${aPhoto.date.toDateString()}</p>`,
];
}
场景
如果某些语句与一个函数放在一起更像一个整体,并且更有助于理解,那我就会毫不犹豫地将语句搬移到函数里去。
搬移语句到调用者(Move Statements to Callers)
和搬移语句到函数相反
场景
函数边界发生偏移的一个征兆是,以往在多个地方共用的行为,如今需要在某些调用点面前表现出不同的行为。于是,我们得把表现不同的行为从函数里挪出,并搬移到其调用处
以函数调用取代内联代码(Replace Inline Codewith Function Call)
let appliesToMass = false;
for(const s of states) {
if (s === "MA") appliesToMass = true;
}
appliesToMass = states.includes("MA");
判断内联代码与函数之间是否真正重复,从函数名往往可以看出端倪:如果一个函数命名得当,也确实与内联代码做了一样的事,那么这个名字用在内联代码的语境里也应该十分协调;如果函数名显得不协调,可能是因为命名本身就比较糟糕
移动语句(Slide Statements)
const pricingPlan = retrievePricingPlan();
const order = retreiveOrder();
let charge;
const chargePerUnit = pricingPlan.unit;
const pricingPlan = retrievePricingPlan();
const chargePerUnit = pricingPlan.unit;
const order = retreiveOrder();
let charge;
编写代码应该总是尽量遵循命令与查询分离(Command-Query Separation)[mf-cqs]原则,在这个前提下,我可以确定任何有返回值的函数都不存在副作用。
拆分循环(Split Loop)
let averageAge = 0;
let totalSalary = 0;
for (const p of people) {
averageAge += p.age;
totalSalary += p.salary;
}
averageAge = averageAge / people.length;
let totalSalary = 0;
for (const p of people) {
totalSalary += p.salary;
}
let averageAge = 0;
for (const p of people) {
averageAge += p.age;
}
averageAge = averageAge / people.length;
就是让每个循环只干一件事,如果你在一次循环中做了两件不同的事,那么每当需要修改循环时,你都得同时理解这两件事情
这项重构手法可能让许多程序员感到不安,因为它会迫使你执行两次循环。对此,我一贯的建议也与2.8节里所明确指出的一致:先进行重构,然后再进行性能优化。我得先让代码结构变得清晰,才能做进一步优化;如果重构之后该循环确实成了性能的瓶颈,届时再把拆开的循环合到一起也很容易。但实际情况是,即使处理的列表数据更多一些,循环本身也很少成为性能瓶颈,更何况拆分出循环来通常还使一些更强大的优化手段变得可能。
以管道取代循环(Replace Loop with Pipeline)
const names = [];
for (const i of input) {
if (i.job === "programmer")
names.push(i.name);
}
const names = input
.filter(i => i.job === "programmer")
.map(i => i.name)
;
集合管道[mf-cp]是这样一种技术,它允许我使用一组运算来描述集合的迭代过程,其中每种运算接收的入参和返回值都是一个集合。
map运算是指用一个函数作用于输入集合的每一个元素上,将集合变换成另外一个集合的过程;filter运算是指用一个函数从输入集合中筛选出符合条件的元素子集的过程
场景
发现一些逻辑如果采用集合管道来编写,代码的可读性会更强——我只消从头到尾阅读一遍代码,就能弄清对象在管道中间的变换过程
移除死代码(Remove Dead Code)
就是删掉些代码
3.3.4 重新组织数据
这里介绍一组专门用于组织数据结构的重构手法
拆分变量(Split Variable)
let temp = 2 * (height + width);
console.log(temp);
temp = height * width;
console.log(temp);
const perimeter = 2 * (height + width);
console.log(perimeter);
const area = height * width;
console.log(area);
场景
同一个变量承担两件不同的事情,会令代码阅读者糊涂。
字段改名(Rename Field)
以查询取代派生变量(Replace Derived Variablewith Query)
get discountedTotal() {return this._discountedTotal;}
set discount(aNumber) {
const old = this._discount;
this._discount = aNumber;
this._discountedTotal += old - aNumber;
}
get discountedTotal() {return this._baseTotal - this._discount;}
set discount(aNumber) {this._discount = aNumber;}
可变数据是软件中最大的错误源头之一。对数据的修改常常导致代码的各个部分以丑陋的形式互相耦合:在一处修改数据,却在另一处造成难以发现的破坏,所以作者强烈建议:尽量把可变数
据的作用域限制在最小范围。
计算常能更清晰地表达数据的含义,而且也避免了“源数据修改时忘了更新派生变量”的错误。
这是两种不同的编程风格:一种是对象风格,把一系列计算得出的属性包装在数据结构中;另一种是函数风格,将一个数据结构变换为另一个数据结构。如果源数据会被修改,而你必须负责管理派生数据结构的整个生命周期,那么对象风格显然更好。但如果源数据不可变,或者派生数据用过即弃,那么两种风格都可行。
场景
- 有些变量其实可以很容易地随时计算出来。如果能去掉这些变量,也算朝着消除可变性的方向迈出了一大步。
注意
- 如果计算的源数据是不可变的,并且我们可以强制要求计算的结果也是不可变的,那么就不必重构消除计算得到的派生变量。
将引用对象改为值对象(Change Reference to Value)
class Product {
applyDiscount(arg) {this._price.amount -= arg;}
class Product {
applyDiscount(arg) {
this._price = new Money(this._price.amount - arg, this._price.currency);
}
在把一个对象(或数据结构)嵌入另一个对象时,位于内部的这个对象可以被视为引用对象,也可以被视为值对象。两者最明显的差异在于如何更新内部对象的属性:如果将内部对象视为引用对象,在更新其属性时,我会保留原对象不动,更新内部对象的属性;如果将其视为值对象,我就会替换整个内部对象,新换上的对象会有我想要的属性值。
如果把一个字段视为值对象,我可以把内部对象的类也变成值对象[mf-vo]。值对象通常更容易理解,主要因为它们是不可变的
如果我想在几个对象之间共享一个对象,以便几个对象都能看见对共享对象的修改,那么这个共享的对象就应该是引用
操作注意
- 首先要检查重构目标是否为不可变对象,或者是否可修改为不可变对象。用移除设值函数(331)逐一去掉所有设值函数。
- 提供一个基于值的相等性判断函数,在其中使用值对象的字段
是不是真正的值对象,要看是否基于值判断相等性。在这个领域中,JavaScript做得不好:语言和核心库都不支持将“基于引用的相等性判断”换成“基于值的相等性判断”。我唯一能做的就是创建自己的equals函数。
将值对象改为引用对象(Change Value to Reference)
let customer = new Customer(customerData);
let customer = customerRepository.get(customerData.id);
场景
如果共享的数据需要更新,将其复制多份的做法就会遇到巨大的困难。此时必须找到所有的副本,更新所有对象。这种情况下,可以考虑将多份数据副本变成单一的引用,这样对数据的修改就会立即反映在所有需要的地方中。
操作注意
把值对象改为引用对象会带来一个结果:对于一个客观实体,只有一个代表它的对象。这通常意味着我会需要某种形式的仓库,在仓库中可以找到所有这些实体对象。只为每个实体创建一次对象,以后始终从仓库中获取该对象。
扩展
这里说下什么是值对象,值对象和实体区别,下面内容引自《领域驱动设计》:
如果一个对象代表了领域的某种描述性特征,并且没有概念性的标识,我们就称之为值对象。值对象就是那些在设计中我们只关心它们是什么,而不关心它们谁是谁的对象。
地址是值对象吗?邮购公司的软件需要用地址来证实信用卡的有效性,并用它作为发货目的地。但是,如果一个人的室友在同一个邮购公司下了订单,他们是否住在同一个地方并不重要。因此,地址在这个场景下是一个值对象。
一个对象所代表的事物是一个具有连续性和标识的概念(可以跟踪该事物经历的不同的状态,甚至可以让该事物跨越不同的实现),还是只是一个用来描述事物的某种状态的属性?这就是实体与值对象最基本的区别。
3.3.5 简化条件逻辑
分解条件表达式(Decompose Conditional)
if (!aDate.isBefore(plan.summerStart) && !aDate.isAfter(plan.summerEnd))
charge = quantity * plan.summerRate;
else
charge = quantity * plan.regularRate + plan.regularServiceCharge;
if (summer())
charge = summerCharge();
else
charge = regularCharge();
对于条件逻辑,将每个分支条件分解成新函数还可以带来更多好处:可以突出条件逻辑,更清楚地表明每个分支的作用,并且突出每个分支的原因。
场景
在带有复杂条件逻辑的函数中,代码(包括检查条件分支的代码和真正实现功能的代码)会告诉我发生的事,但常常让我弄不清楚为什么会发生这样的事,这就说明代码的可读性的确大大降低了
合并条件表达式(Consolidate Conditional Expression)
if (anEmployee.seniority < 2) return 0;
if (anEmployee.monthsDisabled > 12) return 0;
if (anEmployee.isPartTime) return 0;
if (isNotEligibleForDisability()) return 0;
function isNotEligibleForDisability() {
return ((anEmployee.seniority < 2)
|| (anEmployee.monthsDisabled > 12)
|| (anEmployee.isPartTime));
}
之所以要合并条件代码,有两个重要原因。
首先,合并后的条件代码会表述“实际上只有一次条件检查,只不过有多个并列条件需要检查而已”,从而使这一次检查的用意更清晰。当然,合并前和合并后的代码有着相同的效果,但原先代码传达出的信息却是“这里有一些各自独立的条件测试,它们只是恰好同时发生”。
其次,这项重构往往可以为使用提炼函数(106)做好准备。将检查条件提炼成一个独立的函数对于厘清代码意义非常有用,因为它把描述“做什么”的语句换成了“为什么这样做”。
条件语句的合并理由也同时指出了不要合并的理由:如果我认为这些检查的确彼此独立,的确不应该被视为同一次检查,我就不会使用本项重构。
操作注意
- 检查条件表达式有没有副作用(即其他用处),如果某个条件表达式有副作用,可以先用将查询函数和修改函数分离(306)处理。
以卫语句取代嵌套条件表达式(Replace Nested Conditional with Guard Clauses)
function getPayAmount() {
let result;
if (isDead)
result = deadAmount();
else {
if (isSeparated)
result = separatedAmount();
else {
if (isRetired)
result = retiredAmount();
else
result = normalPayAmount();
}
}
return result;
}
function getPayAmount() {
if (isDead) return deadAmount();
if (isSeparated) return separatedAmount();
if (isRetired) return retiredAmount();
return normalPayAmount();
}
条件表达式通常有两种风格。第一种风格是:两个条件分支都属于正常行为。第二种风格则是:只有一个条件分支是正常行为,另一个分支则是异常的情况
这两类条件表达式有不同的用途,这一点应该通过代码表现出来。如果两条分支都是正常行为,就应该使用形如if...else...的条件表达式;如果某个条件极其罕见,就应该单独检查该条件,并在该条件为真时立刻从函数中返回。这样的单独检查常常被称为“卫语句”(guard clauses)。
以卫语句取代嵌套条件表达式的精髓就是:给某一条分支以特别的重视。如果使用if-then-else结构,你对if分支和else分支的重视是同等的。这样的代码结构传递给阅读者的消息就是:各个分支有同样的重要性。卫语句就不同了,它告诉阅读者:“这种情况不是本函数的核心逻辑所关心的,如果它真发生了,请做一些必要的整理工作,然后退出。”
现今的编程语言都会强制保证每个函数只有一个入口,至于“单一出口”规则,其实不是那么有用。在我看来,保持代码清晰才是最关键的:如果单一出口能使这个函数更清楚易读,那么就使用单一出口;否则就不必这么做。
以多态取代条件表达式(Replace Conditional with Polymorphism)
switch (bird.type) {
case 'EuropeanSwallow':
return "average";
case 'AfricanSwallow':
return (bird.numberOfCoconuts > 2) ? "tired" : "average";
case 'NorwegianBlueParrot':
return (bird.voltage > 100) ? "scorched" : "beautiful";
default:
return "unknown";
class EuropeanSwallow {
get plumage() {
return "average";
}
class AfricanSwallow {
get plumage() {
return (this.numberOfCoconuts > 2) ? "tired" : "average";
}
class NorwegianBlueParrot {
get plumage() {
return (this.voltage > 100) ? "scorched" : "beautiful";
}
场景
发现可以将条件逻辑拆分到不同的场景(或者叫高阶用例),从而拆解复杂的条件逻辑。这种拆分有时用条件逻辑本身的结构就足以表达,但使用类和多态能把逻辑的拆分表述得更清晰。
引入特例(Introduce Special Case)
if (aCustomer === "unknown") customerName = "occupant";
class UnknownCustomer {
get name() {return "occupant";}
特例(Special Case)模式:创建一个特例元素,用以表达对这种特例的共用行为的处理。
特例有几种表现形式。如果我只需要从这个对象读取数据,可以提供一个字面量对象(literal object),其中所有的值都是预先填充好的。如果除简单的数值之外还需要更多的行为,就需要创建一个特殊对象,其中包含所有共用行为所对应的函数。特例对象可以由一个封装类来返回,也可以通过变换插入一个数据结构。
特例对象是值对象,因此应该始终是不可变的,即便它们替代的原对象本身是可变的。
一般的原则是:如果特例对象需要返回关联对象,被返回的通常也是特例对象。
场景
一个数据结构的使用者都在检查某个特殊的值,并且当这个特殊值出现时所做的处理也都相同。如果我发现代码库中有多处以同样方式应对同一个特殊值,我就会想要把这个处理逻辑收拢到一处。
引入断言(Introduce Assertion)
断言是一个条件表达式,应该总是为真。如果它失败,表示程序员犯了错误。断言的失败不应该被系统任何地方捕捉。整个程序的行为在有没有断言出现的时候都应该完全一样。实际上,有些编程语言中的断言可以在编译期用一个开关完全禁用掉。
断言是一种很有价值的交流形式——它们告诉阅读者,程序在执行到这一点时,对当前状态做了何种假设。另外断言对调试也很有帮助。而且,因为它们在交流上很有价值,即使解决了当下正在追踪的错误,我还是倾向于把断言留着
场景
如果你发现代码假设某个条件始终为真,就加入一个断言明确说明这种情况。
操作注意
- 不要滥用断言。我不会使用断言来检查所有“我认为应该为真”的条件,只用来检查“必须为真”的条件
- 我只用断言预防程序员的错误。如果要从某个外部数据源读取数据,那么所有对输入值的检查都应该是程序的一等公民,而不能用断言实现——除非我对这个外部数据源有绝对的信心。断言是帮助我们跟踪bug的最后一招,所以,或许听来讽刺,只有当我认为断言绝对不会失败的时候,我才会使用断言。
3.3.6 重构API
模块和函数是软件的骨肉,而API则是将骨肉连接起来的关节。好的API会把更新数据的函数与只是读取数据的函数清晰分开
如果两个函数的功能非常相似、只有一些数值不同,我可以用函数参数化(310)将其统一。但有些参数其实只是一个标记,根据这个标记的不同,函数会有截然不同的行为,此时最好用移除标记参数(314)将不同的行为彻底分开。
将查询函数和修改函数分离(Separate Query from Modifier)
function getTotalOutstandingAndSendBill() {
const result = customer.invoices.reduce((total, each) => each.amount + total, 0);
sendBill();
return result;
}
function totalOutstanding() {
return customer.invoices.reduce((total, each) => each.amount + total, 0);
}
function sendBill() {
emailGateway.send(formatBill(customer));
任何有返回值的函数,都不应该有看得到的副作用——命令与查询分离(Command-Query Separation)[mf-cqs].这样就可以任意调用这个函数,也可以把调用动作搬到调用函数的其他地方。这种函数的测试也更容易。简而言之,需要操心的事情少多了。
注意是“看得到的副作用”:有一种常见的优化办法是:将查询所得结果缓存于某个字段中,这样一来后续的重复查询就可以大大加快速度。虽然这种做法改变了对象中缓存的状态,但这一修改是察觉不到的,因为不论如何查询,总是获得相同结果
函数参数化(Parameterize Function)
function tenPercentRaise(aPerson) {
aPerson.salary = aPerson.salary.multiply(1.1);
}
function fivePercentRaise(aPerson) {
aPerson.salary = aPerson.salary.multiply(1.05);
}
function raise(aPerson, factor) {
aPerson.salary = aPerson.salary.multiply(1 + factor);
}
场景
如果我发现两个函数逻辑非常相似,只有一些字面量值不同,可以将其合并成一个函数,以参数的形式传入不同的值,从而消除重复。
移除标记参数(Remove Flag Argument)
function setDimension(name, value) {
if (name === "height") {
this._height = value;
return;
}
if (name === "width") {
this._width = value;
return;
}
}
function setHeight(value) {this._height = value;}
function setWidth (value) {this._width = value;}
标记参数是这样的一种参数:调用者用它来指示被调函数应该执行哪一部分逻辑。
如果调用者传入的是程序中流动的数据,这样的参数不算标记参数;只有调用者直接传入字面量值,这才是标记参数。另外,在函数实现内部,如果参数值只是作为数据传给其他函数,这就不是标记参数;只有参数值影响了函数内部的控制流,这才是标记参数。
我不喜欢标记参数,因为它们让人难以理解到底有哪些函数可以调用、应该怎么调用。拿到一份API以后,我首先看到的是一系列可供调用的函数,但标记参数却隐藏了函数调用中存在的差异性。使用这样的函数,我还得弄清标记参数有哪些可用的值。布尔型的标记尤其糟糕,因为它们不能清晰地传达其含义——在调用一个函数时,我很难弄清true到底是什么意思。如果明确用一个函数来完成一项单独的任务,其含义会清晰得多。
保持对象完整(Preserve Whole Object)
const low = aRoom.daysTempRange.low;
const high = aRoom.daysTempRange.high;
if (aPlan.withinRange(low, high))
if (aPlan.withinRange(aRoom.daysTempRange))
“传递整个记录”的方式能更好地应对变化:如果将来被调的函数需要从记录中导出更多的数据,我就不用为此修改参数列表。并且传递整个记录也能缩短参数列表,让函数调用更容易看懂。
场景
如果我看见代码从一个记录结构中导出几个值,然后又把这几个值一起传递给一个函数,我会更愿意把整个记录传给这个函数,在函数体内部导出所需的值。
以查询取代参数(Replace Parameter withQuery)
availableVacation(anEmployee, anEmployee.grade);
function availableVacation(anEmployee, grade) {
// calculate vacation...
availableVacation(anEmployee)
function availableVacation(anEmployee) {
const grade = anEmployee.grade;
// calculate vacation...
函数的参数列表应该总结该函数的可变性,标示出函数可能体现出行为差异的主要方式.如果调用函数时传入了一个值,而这个值由函数自己来获得也是同样容易,这就是重复
“同样容易”四个字,划出了一条判断的界限。去除参数也就意味着“获得正确的参数值”的责任被转移:有参数传入时,调用者需要负责获得正确的参数值;参数去除后,责任就被转移给了函数本身
以参数取代查询(Replace Query with Parameter)
targetTemperature(aPlan)
function targetTemperature(aPlan) {
currentTemperature = thermostat.currentTemperature;
// rest of function...
targetTemperature(aPlan, thermostat.currentTemperature)
function targetTemperature(aPlan, currentTemperature) {
// rest of function...
如果一个函数用同样的参数调用总是给出同样的结果,我们就说这个函数具有“引用透明性”(referential transparency),这样的函数理解起来更容易。如果一个函数使用了另一个元素,而后者不具引用透明性,那么包含该元素的函数也就失去了引用透明性。只要把“不具引用透明性的元素”变成参数传入,函数就能重获引用透明性
场景
需要使用本重构的情况大多源于我想要改变代码的依赖关系——为了让目标函数不再依赖于某个元素,我把这个元素的值以参数形式传递给该函数。
移除设值函数(Remove Setting Method)
场景
如果为某个字段提供了设值函数,这就暗示这个字段可以被改变。如果不希望在对象创建之后此字段还有机会被改变,那就不要为它提供设值函数(同时将该字段声明为不可变
以工厂函数取代构造函数(Replace Constructor with Factory Function)
leadEngineer = new Employee(document.leadEngineer, 'E');
leadEngineer = createEngineer(document.leadEngineer);
构造函数局限性,例如以Java为例:
- 构造函数只能返回当前所调用类的实例,也就是说,我无法根据环境或参数信息返回子类实例或代理对象;
- 构造函数的名字是固定的,因此无法使用比默认名字更清晰的函数名;
- 构造函数需要通过特殊的操作符来调用(在很多语言中是 new 关键字)
工厂函数就不受这些限制。工厂函数的实现内部可以调用构造函数,但也可以换成别的方式实现。
以命令取代函数(Replace Function with Command)
function score(candidate, medicalExam, scoringGuide) {
let result = 0;
let healthLevel = 0;
// long body code
}
class Scorer {
constructor(candidate, medicalExam, scoringGuide) {
this._candidate = candidate;
this._medicalExam = medicalExam;
this._scoringGuide = scoringGuide;
}
execute() {
this._result = 0;
this._healthLevel = 0;
// long body code
}
}
在这里,“命令”是指一个对象,其中封装了一个函数调用请求。这是遵循《设计模式》[gof]一书中的命令模式(command pattern)
将函数封装成自己的对象,有时也是一种有用的办法。这样的对象我称之为“命令对象”(command object),或者简称“命令”(command)。这种对象大多只服务于单一函数,获得对该函数的请求,执行该函数,就是这种对象存在的意义。
与普通的函数相比,命令对象提供了更大的控制灵活性和更强的表达能力:
- 除了函数调用本身,命令对象还可以支持附加的操作,例如撤销操作。
- 可以通过命令对象提供的方法来设值命令的参数值,从而支持更丰富的生命周期管理能力。
- 可以借助继承和钩子对函数行为加以定制。如果我所使用的编程语言支持对象但不支持函数作为一等公民,通过命令对象就可以给函数提供大部分相当于一等公民的能力。同样,即便编程语言本身并不支持嵌套函数,我也可以借助命令对象的方法和字段把复杂的函数拆解开,而且在测试和调试过程中可以直接调用这些方法。
- 借助命令对象,可以轻松地将原本复杂的函数拆解为多个方法,彼此之间通过字段共享状态;拆解后的方法可以分别调用;开始调用之前的数据状态也可以逐步构建
操作注意
命令对象的灵活性也是以复杂性作为代价的
以函数取代命令(Replace Command with Function)
与 以命令取代函数相反
如果这个函数不是太复杂,那么命令对象可能显得费而不惠,我就应该考虑将其变回普通的函数。
3.3.7 处理继承关系
继承机制十分实用,却也经常被误用,而且常得等你用上一段时间,遇见了痛点,才能察觉误用所在。
函数上移(Pull Up Method)
就是把子类间共有的方法,移到父类中
无论何时,只要系统内出现重复,你就会面临“修改其中一个却未能修改另一个”的风险
陷阱(trap)函数
若能明确传达出“继承Party类的子类需要提供一个monthlyCost实现”这个信息,无疑也有很大的价值,特别是对日后需要添加子类的后来者。其中一种好的传达方式是添加一个如下的陷阱(trap)函数。
class Party...get monthlyCost() {
throw new SubclassResponsibilityError();
}
我称上述抛出的错误为一个“子类未履行职责错误”,这是从Smalltalk借鉴来的名字。
函数下移(Push Down Method)
字段上移(Pull Up Field)
就是把子类间共有的字段,移到父类中.
如果各子类是分别开发的,或者是在重构过程中组合起来的,你常会发现它们拥有重复特性,特别是字段更容易重复。
场景
判断若干字段是否重复,唯一的办法就是观察函数如何使用它们。如果它们被使用的方式很相似,我就可以将它们提升到超类中去。
字段下移(Push Down Field)
构造函数本体上移(Pull Up Constructor Body)
class Party {...}
class Employee extends Party {
constructor(name, id, monthlyCost) {
super();
this._id = id;
this._name = name;
this._monthlyCost = monthlyCost;
}
}
class Party {
constructor(name){
this._name = name;
}
}
class Employee extends Party {
constructor(name, id, monthlyCost) {
super(name);
this._id = id;
this._monthlyCost = monthlyCost;
}
}
以子类取代类型码(Replace Type Code with Subclasses)
function createEmployee(name, type) {
return new Employee(name, type);
}
function createEmployee(name, type) {
switch (type) {
case "engineer": return new Engineer(name);
case "salesman": return new Salesman(name);
case "manager": return new Manager (name);
}
场景
- 需要用多态来处理条件逻辑。如果有几个函数都在根据类型码的取值采取不同的行为,多态就显得特别有用。引入子类之后,我可以用以多态取代条件表达式(272)来处理这些函数。
- 有些字段或函数只对特定的类型码取值才有意义,例如“销售目标”只对“销售”这类员工才有意义。此时我可以创建子类,然后用字段下移(361)把这样的字段放到合适的子类中去。
操作注意
在使用以子类取代类型码时,考虑一个问题:应该直接处理携带类型码的这个类,还是应该处理类型码本身呢?
以前面的例子来说,是应该让“工程师”成为“员工”的子类,还是应该在“员工”类包含“员工类别”属性、从后者继承出“工程师”和“经理”等子类型呢?
直接的子类继承(前一种方案)比较简单,但职位类别就不能用在其他场合了。另外,如果员工的类别是可变的,那么也不能使用直接继承的方案。如果想在“员工类别”之下创建子类,可以运用以对象取代基本类型(174)把类型码包装成“员工类别”类,然后对其使用以子类取代类型码(362)。
移除子类(Remove Subclass)
子类存在着就有成本,阅读者要花心思去理解它的用意,所以如果子类的用处太少,就不值得存在了。此时,最好的选择就是移除子类,将其替换为超类中的一个字段。
提炼超类(Extract Superclass)
如果我看见两个类在做相似的事,可以利用基本的继承机制把它们的相似之处提炼到超类。我可以用字段上移(353)把相同的数据搬到超类,用函数上移(350)搬移相同的行为。
另一种选择就是提炼类(182)。这两种方案之间的选择,其实就是继承和委托之间的选择,总之目的都是把重复的行为收拢一处。提炼超类通常是比较简单的做法,所以我会首选这个方案。即便选错了,也总有以委托取代超类(399)这瓶后悔药可吃。
折叠继承体系(Collapse Hierarchy)
在重构类继承体系时,我经常把函数和字段上下移动。随着继承体系的演化,我有时会发现一个类与其超类已经没多大差别,不值得再作为独立的类存在。此时我就会把超类和子类合并起来
以委托取代子类(Replace Subclass with Delegate)
class Order {
get daysToShip() {
return this._warehouse.daysToShip;
}
}
class PriorityOrder extends Order {
get daysToShip() {
return this._priorityPlan.daysToShip;
}
}
class Order {
get daysToShip() {
return (this._priorityDelegate)
? this._priorityDelegate.daysToShip
: this._warehouse.daysToShip;
}
}
class PriorityOrderDelegate {
get daysToShip() {
return this._priorityPlan.daysToShip
}
}
委托是对象之间常规的关系。与继承关系相比,使用委托关系时接口更清晰、耦合更少。因此,继承关系遇到问题时运用以委托取代子类是常见的情况。
有一条流行的原则:“对象组合优于类继承”(“组合”跟“委托”是同一回事)
熟悉《设计模式》一书的读者可以这样来理解本重构手法,就是用状态(State)模式或者策略(Strategy)模式取代子类。
以委托取代超类(Replace Superclass with Delegate)
class List {...}
class Stack extends List {...}
class Stack {
constructor() {
this._storage = new List();
}
}
class List {...}
在对象技术发展早期,有一个经典的误用继承的例子:让栈(stack)继承列表(list)。这个想法的出发点是想复用列表类的数据存储和操作能力。虽说复用是一件好事,但这个继承关系有问题:列表类的所有操作都会出现在栈类的接口上,然而其中大部分操作对一个栈来说并不适用。更好的做法应该是把列表作为栈的字段,把必要的操作委派给列表就行了。
这就是一个用得上以委托取代超类手法的例子——如果超类的一些函数对子类并不适用,就说明我不应该通过继承来获得超类的功能。
除了“子类用得上超类的所有函数”之外,合理的继承关系还有一个重要特征:子类的所有实例都应该是超类的实例,通过超类的接口来使用子类的实例应该完全不出问题。
即便在子类继承是合理的建模方式的情况下,如果子类与超类之间的耦合过强,超类的变化很容易破坏子类的功能,我还是会使用以委托取代超类。这样做的缺点就是,对于宿主类(也就是原来的子类)和委托类(也就是原来的超类)中原本一样的函数,现在我必须在宿主类中挨个编写转发函数
操作注意
每转发一个函数就可以测试,但一对设值/取值必须同时转移,然后才能测试。
四、常用技巧
如何确定该提炼哪一段代码呢?
一个很好的技巧是:寻找注释。它们通常能指出代码用途和实现手法之间的语义距离。如果代码前方有一行注释,就是在提醒你:可以将这段代码替换成一个函数,而且可以在注释的基础上给这个函数命名
何时应该把代码放进独立的函数
观点1:一个函数应该能在一屏中显示
观点2:从复用的角度考虑,认为只要被用过不止一次的代码,就应该单独放进一个函数
作者观点:“将意图与实现分开”。如果你需要花时间浏览一段代码才能弄清它到底在干什么,那么就应该将其提炼到一个函数中,并根据它所做的事为其命名。以后再读到这段代码时,你一眼就能看到函数的用途,大多数时候根本不需要关心函数如何达成其用途(这是函数体内干的事)。
怎么改进函数名
有一个改进函数名字的好办法:先写一句注释描述这个函数的用途,再把这句注释变成函数的名字。
封装有什么好处
- 如果想要搬移一处被广泛使用的数据,最好的办法往往是先以函数形式封装所有对该数据的访问。这样,就能把“重新组织数据”的困难任务转化为“重新组织函数”这个相对简单的任务。
- 封装能提供一个清晰的观测点,可以由此监控数据的变化和使用情况;我还可以轻松地添加数据被修改时的验证或后续逻辑
- 每个模块都应该尽可能少了解系统的其他部分。如此一来,一旦发生变化,需要了解这一变化的模块就会比较少——这会使变化比较容易进行。
怎样控制变量的修改
不仅控制对变量引用的修改,还要控制对变量内容的修改时,有两个办法可以做到
- 最简单的办法是禁止对数据结构内部的数值做任何修改,修改取值函数,使其返回该数据的一份副本(其实设值函数也可以返回一份副本。这取决于数据从哪儿来,以及我是否需要保留对源数据的连接,以便知悉源数据的变化)
- 阻止对数据的修改,比如通过封装记录(162)就能很好地实现这一效果
断言还是if
断言是一个条件表达式,应该总是为真。如果它失败,表示程序员犯了错误。断言不是用在“我认为应该为真”的条件,而是“必须为真”的条件
只用断言预防程序员的错误。如果要从某个外部数据源读取数据,那么所有对输入值的检查都应该是程序的一等公民,而不能用断言实现——除非我对这个外部数据源有绝对的信心
如果某个错误会导致脏数据在应用中到处传递,或是产生一些很难调试的失败,可以考虑用引入断言(302),使代码不满足预设条件时快速失败
五、拾真
- 好的命名是整洁编程的核心。变量可以很好地解释一段程序在干什么——如果变量名起得好的话。
- 函数的参数列表阐述了函数如何与外部世界共处。函数的参数设置了一个上下文,只有在这个上下文中,我才能使用这个函数。修改参数列表不仅能增加函数的应用范围,还能改变连接一个模块所需的条件,从而去除不必要的耦合。函数的参数列表应该总结该函数的可变性,标示出函数可能体现出行为差异的主要方式
- (引入参数对象)真正的意义在于,它会催生代码中更深层次的改变。一旦识别出新的数据结构,我就可以重组程序的行为来使用这些结构。我会创建出函数来捕捉围绕这些数据的共用行为——可能只是一组共用的函数,也可能用一个类把数据结构与使用数据的函数组合起来。这个过程会改变代码的概念图景,将这些数据结构提升为新的抽象概念,可以帮助我更好地理解问题域
- 只有把函数和它们操作的数据放在一起,用起来才方便。引入变换(或者类)都是为了让相关的逻辑找起来方便。
- 每当看见一段代码在同时处理两件不同的事,我就想把它拆分成各自独立的模块,因为这样到了需要修改的时候,我就可以单独处理每个主题,而不必同时在脑子里考虑两个不同的主题
- 类是为隐藏信息而生的。分解模块时最重要的标准,也许就是识别出那些模块应该对外界隐藏的小秘密了[Parnas]。数据结构无疑是最常见的一种秘密,我可以用封装记录(162)或封装集合(170)手法来隐藏它们的细节
- 我喜欢封装程序中的所有可变数据。这使我很容易看清楚数据被修改的地点和修改方式,这样当我需要更改数据结构时就非常方便
- 我手头有两个类,想重新安排它们肩负的职责,并让它们产生关联。这时我发现先用本手法将它们内联成一个类再用提炼类(182)去分离其职责会更加简单。这是重新组织代码时常用的做法:有时把相关元素一口气搬移到位更简单,但有时先用内联手法合并各自的上下文,再使用提炼手法再次分离它们会更合适。
- “封装”意味着每个模块都应该尽可能少了解系统的其他部分。如此一来,一旦发生变化,需要了解这一变化的模块就会比较少——这会使变化比较容易进行。
- 模块化是优秀软件设计的核心所在,好的模块化能够让我在修改程序时只需理解程序的一小部分。
- 任何函数都需要具备上下文环境才能存活。这个上下文可以是全局的,但它更多时候是由某种形式的模块所提供的
- 将一个值用于多个不同的用途,这就是催生混乱和bug的温床。引用和值的混淆经常会造成问题
- 模块和函数是软件的骨肉,而API则是将骨肉连接起来的关节。好的API会把更新数据的函数与只是读取数据的函数清晰分开
- 如果一个函数用同样的参数调用总是给出同样的结果,我们就说这个函数具有“引用透明性”(referential transparency),这样的函数理解起来更容易。如果一个函数使用了另一个元素,而后者不具引用透明性,那么包含该元素的函数也就失去了引用透明性。只要把“不具引用透明性的元素”变成参数传入,函数就能重获引用透明性