Skip to content

01-06

1. 重构,第一个示例

1.1 重点语句

1. 程序添加特性前的考虑

如果你要给程序添加一个特性,但发现代码因缺乏良好的结构而不易于进行更改,那就先重构那个程序,使其比较容易添加该特性,然后再添加该特性。

2.需求的变化使重构变得必要

若代码能正常工作且不会再被修改(没有妨碍到任何人),则不用重构,但如果有人需要理解它的工作原理且觉得理解起来很费劲,则就需要改进代码了(妨碍他人了)。

3. 重构前的第一步

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

4. 重构技术的特点之一

重构技术就是以微小的步伐(比如一开始的变量命名)修改程序。如果你犯下错误,很容易便可发现它。

5. 优秀的程序员

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

6. 营地法则

编程时,需要遵循营地法则:保证你离开时的代码库一定比来时更健康。

7. 好代码的检验标准之一

好代码的检验标准就是人们是否能轻而易举地修改它。

1.2 重构示例

1. 重构示例使用到的重构手法

  • 提炼函数
  • 内联变量
  • 搬移函数
  • 以多态取代条件表达式等

2. 重构示例的关键节点

  • 第一步:将原函数「分解成一组嵌套的函数」
  • 第二步:应用「拆分阶段」分离计算逻辑与输出格式化逻辑
  • 第三步:为计算器「引入多态性」来处理计算逻辑

2. 重构的原则

2.1 何谓重构

1. 重构的定义

  • 作为名词

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

  • 作为动词

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

2. 简单识别重构的方法

如果有人说他们的代码在重构过程中有一两天时间不可用,基本上可以确定,他们在做的事不是重构。

2.2 两顶帽子

1. 两顶帽子

  • 帽子一:添加新功能

添加新功能时,不应该修改既有代码,只管添加新功能。通过添加测试并让测试正常运行,可以衡量工作进度。

  • 帽子二:重构

重构时就不能再添加功能,只管调整代码的结构。此时不应该添加任何测试(除非发现有先前遗漏的东西),只在绝对必要(用以处理接口变化)时才修改测试。

2. 无论何时得清楚戴的是哪一顶帽子,并明白不同的帽子对编程状态提出的不同要求

2.3 为何重构

  1. 重构改进软件的设计
  2. 重构使软件更容易理解
  3. 重构帮助找到 Bug
  4. 重构提高编程速度

2.4 何时重构

1. 三次法则

  • 第一次做某件事时只管去做
  • 第二次做类似的事会产生反感,但无论如何还是可以去做
  • 第三次再做类似的事,你就应该重构

正如老话说的:事不过三,三则重构。

2. 预备性重构:让添加新功能更加容易

3. 帮助理解的重构:使代码更易懂

4. 捡垃圾式重构

5. 有计划的重构和见机行事的重构

肮脏的代码必须重构,但漂亮的代码也需要很多重构。

每次要修改时,首先令修改很容易(警告:这件事有时会很难),然后再进行这次容易的修改。

6. 长期重构

尽量让整个团队达成共识,来逐步解决重构的问题,而不建议一支团队专门做重构。

7. 复审代码时重构

8. 怎么对经理说

给团队一个较有争议的建议:不要告诉经理!

  • 对于快速创造软件,重构可带来大帮助。如果需要添加新功能,而原本设计却又使我无法方便地修改,我发现先重构再添加新功能会更快些
  • 如果要修补错误,就得先理解软件的工作方式,而我发现重构是理解软件的最快方式
  • 受进度驱动的经理要我尽可能快速完成任务,至于怎么完成,那就是我的事了

9. 何时不应该重构

主要建议如下:

  • 如果看见一块凌乱的代码,但并不需要修改它,那么就不需要重构它
  • 如果重写比重构还容易,就别重构了

2.5 重构的挑战

1. 延缓新功能的开发

  • 重构的唯一目的就是让我们开发更快,用更少的工作量创造更大的价值
  • 重构应该总是由经济利益驱动,重构的意义不在于把代码库打磨得闪闪发光,而是纯粹经济角度出发的考量,即之所以重构是因为它能让我们更快,添加功能和修复bug更快

2. 代码所有权

  • 很多重构手法不仅会影响一个模块内部,还会影响该模块与系统其他部分的关系
  • 可把旧的接口标记为“不推荐使用”(deprecated),等一段时间之后最终让其退休;但有些时候,旧的接口必须一直保留下去
  • 推荐团队代码所有制,这样一支团队里的成员都可以修改这个团队拥有的代码,即便最初写代码的是别人。这种较为宽容的代码所有制甚至可以应用于跨团队的场合

3. 分支

  • 特性分支有其缺点,即在隔离的分支上工作得越久,将完成的工作集成(integrate)回主线就会越困难。为了减轻集成的痛苦,大多数人的办法是频繁地从主线合并(merge)或者变基(rebase)到分支
  • 所以很多人认为,应该尽量缩短特性分支的生存周期,比如只有一两天。还有部分人认为应该更短,其采用的方法叫作持续集成(Continuous Integration,CI),也叫“基于主干开发”(Trunk-Based Development)
  • 并不是在说绝不应该使用特性分支,而是如果特性分支存在的时间足够短,它们就不会造成大问题(实际上,使用CI的团队往往同时也使用分支,但他们会每天将分支与主线合并)

4. 测试

  • 不会改变程序可观察的行为,这是重构的一个重要特征
  • 关键就在于“快速发现错误”,要做到这一点,其代码应该有一套完备的测试套件,并且运行速度要快,否则我会不愿意频繁运行它
  • 如果没有自测试的代码,“重构风险太大,可能引入bug”的担忧是完全合理的,这也是为什么如此重视可靠的测试
  • 缺乏测试的现状还催生了另一种重构的流派「只使用一组经过验证是安全的重构手法」。其要求严格遵循重构的每个步骤,并且可用的重构手法是特定于语言的。使用这种方法,团队得以在测试覆盖率很低的大型代码库上开展一些有用的重构

5. 遗留代码

  • 重构可以很好地帮助理解遗留系统
  • 需要运用重构手法创造出接缝,这样的重构很危险,因为没有测试覆盖,但这是为了取得进展必要的风险,而在这种情况下,安全的自动化重构简直就是天赐福音
  • 就算有了测试,也不建议你尝试一鼓作气把复杂而混乱的遗留代码重构成漂亮的代码,而是更愿意随时重构相关的代码「每次触碰一块代码时,我会尝试把它变好一点点」

6. 数据库

  • 一套渐进式数据库设计[mf-evodb]和数据库重构[Ambler & Sadalage]的办法,这项技术的精要在于「借助数据迁移脚本,将数据库结构的修改与代码相结合,使大规模的、涉及数据库的修改可以比较容易地开展」
  • 与常规的重构不同,很多时候数据库重构最好是分散到多次生产发布来完成,这样即便某次修改在生产数据库上造成了问题,也比较容易回滚

2.6 重构、架构和YAGNI

1. 重构对架构最大的影响

  • 在于通过重构,能得到一个设计良好的代码库,使其能够优雅地应对不断变化的需求

2. 应对未来变化的办法之一

  • 就是在软件里植入灵活性机制
  • 有了重构技术,可采取不同的策略。与其猜测未来需要哪些灵活性、需要什么机制来提供灵活性,我更愿意只根据当前的需求来构造软件,同时把软件的设计质量做得很高
  • 但如果一种灵活性会增加软件复杂度,就必须先证明自己值得被引入

3. YAGNI

  • “你不会需要它”(you arenʼt going to need it)的缩写,类似简单设计或增量式设计
  • 把YAGNI视为将架构、设计与开发过程融合的一种工作方式,这种工作方式必须有重构作为基础才可靠
  • 采用YAGNI并不表示完全不用预先考虑架构

2.7 重构与软件开发过程

  • 如果一支团队想要重构,那么每个团队成员都需要掌握重构技能,能在需要时开展重构,而不会干扰其他人的工作
  • 三大实践——自测试代码、持续集成、重构——彼此之间有着很强的协同效应,且有这三大核心实践打下的基础,才谈得上运用敏捷思想的其他部分

2.8 重构与性能

1. 重构与性能

  • 我并不赞成为了提高设计的纯洁性而忽视性能,把希望寄托于更快的硬件身上也绝非正道
  • 虽然重构可能使软件运行更慢,但它也使软件的性能优化更容易。除了对性能有严格要求的实时系统,其他任何情况下“编写快速软件”的秘密就是「先写出可调优的软件,然后调优它以求获得足够的速度」
  • 哪怕你完全了解系统,也请实际度量它的性能,不要臆测。臆测会让你学到一些东西,但十有八九你是错的
  • 短期看来,重构的确可能使软件变慢,但它使优化阶段的软件性能调优更容易,最终还是会得到好的效果

2. 编写快速软件的方法

  • 最严格的是时间预算法。这通常只用于性能要求极高的实时系统,如果使用这种方法,分解你的设计时就要做好预算,给每个组件预先分配一定资源,包括时间和空间占用
  • 持续关注法。这种方法要求任何程序员在任何时间做任何事时,都要设法保持系统的高性能

关于性能,一件很有趣的事情是:如果你对大多数程序进行分析,就会发现它把大半时间都耗费在一小半代码身上。如果你一视同仁地优化所有代码,90%的优化工作都是白费劲的,因为被你优化的代码大多很少被执行。

  • 性能提升法。利用上述的90%统计数据,前期不对性能投以特别的关注,直至进入性能优化阶段

3. 代码的坏味道

1. 神秘命名(Mysterious Name)

常用重构

改变函数声明|变量改名|字段改名

改名不仅仅是修改名字而已。如果你想不出一个好名字,说明背后很可能潜藏着更深的设计问题。为一个恼人的名字所付出的纠结,常常能推动我们对代码进行精简。

2. 重复代码(Duplicated Code)

常用重构

提炼函数|移动语句|函数上移

一旦有重复代码存在,阅读这些重复的代码时你就必须加倍仔细,留意其间细微的差异。如果要修改重复代码,你必须找出所有的副本来修改。

3. 过长函数(Long Function)

常用重构

提炼函数|查询取代临时变量|引入参数对象|保持对象完整|以命令取代函数|分解条件表达式|以多态取代条件表达式|拆分循环

  • 根据经验,活得最长、最好的程序,其中的函数都比较短
  • 小函数的价值所在,间接性带来的好处——更好的阐释力、更易于分享、更多的选择——都是由小函数来支持的
  • 固然,小函数也会给代码的阅读者带来一些负担,因为需要经常切换上下文,才能看明白函数在做什么。但现代的开发环境让你可以在函数的调用处与声明处之间快速跳转,或是同时看到这两处,让你根本不用来回跳转
  • 让小函数易于理解的关键还是在于良好的命名。如果能给函数起个好名字,阅读代码的人就可以通过名字了解函数的作用,根本不必去看其中写了些什么
  • 最终的效果是「你应该更积极地分解函数」。这里遵循这样一条原则「每当感觉需要以注释来说明点什么的时候,我们就把需要说明的东西写进一个独立函数中,并以其用途(而非实现手法)命名」,关键不在于函数的长度,而在于函数“做什么”和“如何做”之间的语义距离
  • 如何确定该提炼哪一段代码呢?一个很好的技巧是「寻找注释」

4. 过长参数列表(Long Parameter List)

常用重构

查询取代参数|保持对象完整|引入参数对象|移除标记参数|函数组合成类

过长的参数列表本身也经常令人迷惑。

5. 全局数据(Global Data)

常用重构

封装变量

全局数据仍然是最刺鼻的坏味道之一。 全局数据的问题在于,从代码库的任何一个角落都可以修改它,而且没有任何机制可以探测出到底哪段代码做出了修改。

全局数据印证了帕拉塞尔斯的格言:良药与毒药的区别在于剂量。

6. 可变数据(Mutable Data)

常用重构

封装变量|拆分变量|移动语句|提炼函数|以查询取代派生变量|函数组合成类|函数组合成变换|将引用对象改为值对象

  • 有一整个软件开发流派——函数式编程——完全建立在“数据永不改变”的概念基础上:如果要更新一个数据结构,就返回一份新的数据副本,旧的数据仍保持不变
  • 如果可变数据的值能在其他地方计算出来,这就是一个特别刺鼻的坏味道。它不仅会造成困扰、bug和加班,而且毫无必要
  • 如果一个变量在其内部结构中包含了数据,通常最好不要直接修改其中的数据,而是用将引用对象改为值对象令其直接替换整个数据结构

7. 发散式变化(Divergent Change)

常用重构

拆分阶段|搬移函数|提炼函数|提炼类

  • “每次只关心一个上下文”这一点一直很重要,在如今这个信息爆炸、脑容量不够用的年代就愈发紧要
  • 如果两个方向之间有更多的来回调用,就应该先创建适当的模块,然后用搬移函数把处理逻辑分开
  • 如果函数内部混合了两类处理逻辑,应该先用提炼函数将其分开,然后再做搬移
  • 如果模块是以类的形式定义的,就可以用提炼类来做拆分

8. 霰弹式修改(Shotgun Surgery)

常用重构

搬移函数|搬移字段|函数组合成类|函数组合成变换|拆分阶段|内联函数|内联类

面对霰弹式修改,一个常用的策略就是使用与内联(inline)相关的重构——如内联函数或是内联类——把本不该分散的逻辑拽回一处。

9. 依恋情结(Feature Envy)

常用重构

搬移函数|提炼函数

  • 所谓模块化,就是力求将代码分出区域,最大化区域内部的交互、最小化跨区域的交互。但有时你会发现,一个函数跟另一个模块中的函数或者数据交流格外频繁,远胜于在自己所处模块内部的交流,这就是依恋情结的典型情况
  • 我们的原则是:判断哪个模块拥有的此函数使用的数据最多,然后就把这个函数和那些数据摆在一起。如果先以提炼函数将这个函数分解为数个较小的函数并分别置放于不同地点,上述步骤也就比较容易完成了
  • 使用这些模式是为了对抗发散式变化这一坏味道。最根本的原则是:将总是一起变化的东西放在一块儿。数据和引用这些数据的行为总是一起变化的,但也有例外
    • 如果例外出现,我们就搬移那些行为,保持变化只在一地发生
    • 策略模式和和访问者模式使你得以轻松修改函数的行为,因为它们将少量需被覆写的行为隔离开来——当然也付出了“多一层间接性”的代价

10. 数据泥团(Data Clumps)

常用重构

引入参数对象|提炼类|保持对象完整

我们在这里提倡新建一个类,而不是简单的记录结构,因为一旦拥有新的类,你就有机会让程序散发出一种芳香。得到新的类以后,你就可以着手寻找“依恋情结”,这可以帮你指出能够移至新类中的种种行为。这是一种强大的动力:有用的类被创建出来,大量的重复被消除,后续开发得以加速,原来的数据泥团终于在它们的小社会中充分发挥价值。

11. 基本类型偏执(Primitive Obsession)

常用重构

以对象取代基本类型|以子类取代类型码|以多态取代条件表达式|提炼类|引入参数对象

  • 字符串是这种坏味道的最佳培养皿。一个体面的类型,至少能包含一致的显示逻辑,在用户界面上需要显示时可以使用
    • “用字符串来代表类似这样的数据”是如此常见的臭味,以至于人们给这类变量专门起了一个名字,叫它们“类字符串类型”(stringly typed)变量
  • 如果你有一组总是同时出现的基本类型数据,这就是数据泥团的征兆,应该运用提炼类和引入参数对象来处理

12. 重复的(switch Repeated Switch)

常用重构

以多态取代条件表达式

我们现在更关注重复的switch:在不同的地方反复使用同样的switch逻辑(可能是以switch/case语句的形式,也可能是以连续的if/else语句的形式)。

13. 循环语句(Loops)

常用重构

以管道取代循环

  • 如今函数作为一等公民已经得到了广泛的支持,因此我们可以使用以管道取代循环来让这些老古董退休
  • 管道操作(如filter和map)可以帮助我们更快地看清被处理的元素以及处理它们的动作

14. 冗赘的元素(Lazy Element)

常用重构

内联函数|内联类|折叠继承体系

可能有这样一个函数,它的名字就跟实现代码看起来一模一样;也可能有这样一个类,根本就是一个简单的函数。

15. 夸夸其谈通用性(Speculative Generality)

常用重构

折叠继承体系|内联类|内联函数|改变函数声明|移除死代码

如果所有装置都会被用到,就值得那么做;如果用不到,就不值得。用不上的装置只会挡你的路,所以,把它搬开吧。

16. 临时字段(Temporary Field)

常用重构

提炼类|搬移函数|引入特例

也许你还可以使用引入特例在“变量不合法”的情况下创建一个替代对象,从而避免写出条件式代码。

17. 过长的消息链(Message Chains)

常用重构

隐藏委托关系|提炼函数|搬移函数

通常更好的选择是:先观察消息链最终得到的对象是用来干什么的,看看能否以提炼函数把使用该对象的代码提炼到一个独立的函数中,再运用搬移函数把这个函数推入消息链。如果还有许多客户端代码需要访问链上的其他对象,同样添加一个函数来完成此事。

18. 中间人(Middle Man)

常用重构

移除中间人|内联函数|以委托取代超类|以委托取代子类

  • 封装往往伴随着委托
  • 但是人们可能过度运用委托。你也许会看到某个类的接口有一半的函数都委托给其他类,这样就是过度运用

19. 内幕交易(Insider Trading)

常用重构

搬移函数|搬移字段|隐藏委托关系|以委托取代子类|以委托取代超类

  • 在实际情况里,一定的数据交换不可避免,但我们必须尽量减少这种情况,并把这种交换都放到明面上来
  • 继承常会造成密谋,因为子类对超类的了解总是超过后者的主观愿望

20. 过大的类(Large Class)

常用重构

提炼类|提炼超类|以子类取代类型码|提炼类|提炼超类|以子类取代类型码

观察一个大类的使用者,经常能找到如何拆分类的线索。看看使用者是否只用到了这个类所有功能的一个子集,每个这样的子集都可能拆分成一个独立的类。

21. 异曲同工的类(Alternative Classes with Different Interfaces)

常用重构

改变函数声明|搬移函数|提炼超类

使用类的好处之一就在于可以替换:今天用这个类,未来可以换成用另一个类。但只有当两个类的接口一致时,才能做这种替换。

22. 纯数据类(Data Class)

常用重构

封装记录|移除设值函数|搬移函数|提炼函数|拆分阶段

这种结果数据对象有一个关键的特征:它是不可修改的(至少在拆分阶段的实际操作中是这样)。不可修改的字段无须封装,使用者可以直接通过字段取得数据,无须通过取值函数。

23. 被拒绝的遗赠(Refused Bequest)

常用重构

函数下移|字段下移|以委托取代子类|以委托取代超类

  • 按传统说法,这就意味着继承体系设计错误。你需要为这个子类新建一个兄弟类,再运用函数下移和字段下移把所有用不到的函数下推给那个兄弟
  • 如果子类复用了超类的行为(实现),却又不愿意支持超类的接口,“被拒绝的遗赠”的坏味道就会变得很浓烈
  • 拒绝继承超类的实现,这一点我们不介意;但如果拒绝支持超类的接口,这就难以接受了。既然不愿意支持超类的接口,就不要虚情假意地糊弄继承体系,应该运用以委托取代子类或者以委托取代超类彻底划清界限

24. 注释(Comments)

常用重构

提炼函数|改变函数声明|引入断言

  • 我们之所以要在这里提到注释,是因为人们常把它当作“除臭剂”来使用
  • 常常会有这样的情况:你看到一段代码有着长长的注释,然后发现,这些注释之所以存在乃是因为代码很糟糕。这种情况的发生次数之多,实在令人吃惊

当你感觉需要撰写注释时,请先尝试重构,试着让所有注释都变得多余。

4. 构筑测试体系

1. 自测试代码的价值

一套测试就是一个强大的bug侦测器,能够大大缩减查找bug所需的时间。

2. 自动化测试

确保所有测试都完全自动化,让它们检查自己的测试结果。

3. 频繁运行测试

频繁地运行测试。对于你正在处理的代码,与其对应的测试至少每隔几分钟就要运行一次,每天至少运行一次所有的测试。

4. 不用完美测试

编写未臻完善的测试并经常运行,好过对完美测试的无尽等待。

5. 探测边界条件

考虑可能出错的边界条件,把测试火力集中在那儿。

6. 脏数据传递

如果这个错误会导致脏数据在应用中到处传递,或是产生一些很难调试的失败,我可能会用引入断言手法,使代码不满足预设条件时快速失败。我不会为这样的失败断言添加测试,它们本身就是一种测试的形式。

7. 编写测试的心态

不要因为测试无法捕捉所有的bug就不写测试,因为测试的确可以捕捉到大多数bug。

8. Bug报告

每当你收到bug报告,请先写一个单元测试来暴露这个bug。

6. 第一组重构

6.1 提炼函数(Extract Function)

  • 关于“何时应该把代码放进独立的函数”的问题

“将意图与实现分开”:如果需要花时间浏览一段代码才能弄清它到底在干什么,那么就应该将其提炼到一个函数中,并根据它所做的事为其命名。以后再读到这段代码时,能一眼看到函数的用途,大多数时候根本不需要关心函数如何达成其用途(这是函数体内干的事)。

  • 写非常小的函数

通常只有几行的长度。在作者看来,一个函数一旦超过6行,就开始散发臭味。

  • 大量短函数导致的性能问题

短函数常常能让编译器的优化功能运转更良好,因为短函数可以更容易地 缓存。所以应该始终遵循性能优化的一般指导方针,不用过早担心性能问题。

  • 小函数的命名

小函数的命名需要有一个好名字。

有一个技巧是把带有注释的这段代码提炼到自己的函数中时,这里的注释往往会提示一个好名字。

具体重构步骤
  1. 创造一个新函数,根据这个函数的意图来对它命名(以它“做什么”来命名,而 不是以它“怎样做”命名)。

如果想要提炼的代码非常简单,例如只是一个函数调用,只要新函数的名称能够以更好的方式昭示代码意图,我还是会提炼它;但如果想不出一个更有意义的名称,这就是一个信号,可能我不应该提炼这块代码。不过不一定非得马上想出最好的名字,有时在提炼的过程中好的名字才会出现。有时我会提炼一个函数,尝试使用它,然后发现不太合适,再把它内联回去,这完全没问题。只要在这个过程中学到了东西,时间就没有白费。如果编程语言支持嵌套函数,就把新函数嵌套在源函数里,这能减少后面需要处理的超出作用域的变量个数。可稍后再使用搬移函数把它从源函数中搬移出去。

  1. 将待提炼的代码从源函数复制到新建的目标函数中

  2. 仔细检查提炼出的代码,看看其中是否引用了作用域限于源函数、在提炼出的新函数中访问不到的变量。若是,以参数的形式将它们传递给新函数

如果提炼出的新函数嵌套在源函数内部,就不存在变量作用域的问题了。

这些“作用域限于源函数”的变量通常是局部变量或者源函数的参数。最通用的做法是将它们都作为参数传递给新函数。只要没在提炼部分对这些变量赋值,处理起来就没什么难度。

如果某个变量是在提炼部分之外声明但只在提炼部分被使用,就把变量声明也搬移到提炼部分代码中去。

如果变量按值传递给提炼部分又在提炼部分被赋值,就必须多加小心。如果只有一个这样的变量,我会尝试将提炼出的新函数变成一个查询 (query),用其返回值给该变量赋值。

但有时在提炼部分被赋值的局部变量太多,这时最好是先放弃提炼。这种情况下,我会考虑先使用别的重构手法,例如拆分变量或者以查询取 代临时变量,来简化变量的使用情况,然后再考虑提炼函数。

  1. 所有变量都处理完之后,编译

如果编程语言支持编译期检查的话,在处理完所有变量之后做一次编译是很有用的,编译器经常会帮你找到没有被恰当处理的变量。

  1. 在源函数中,将被提炼代码段替换为对目标函数的调用

  2. 测试

  3. 查看其他代码是否有与被提炼的代码段相同或相似之处。如果有,考虑使用以函数调用取代内联代码令其调用提炼出的新函数

有些重构工具直接支持这一步。如果工具不支持,可以快速搜索一下,看看别处是否还有重复代码。

6.2 内联函数(Inline Function)

  • 非必要的间接性

间接性可能带来帮助,但非必要的间接性总是让人不舒服。

  • 先内联再提炼

有一群组织不甚合理的函数。可以将它们都内联到一个大型函数中,再以喜欢的方式重新提炼出小函数。

  • 简单委托的间接层

如果代码中有太多间接层,使得系统中的所有函数都似乎只是对另一个函数的简单委托,造成在这些委托动作之间晕头转向,那么通常都会使用内联函数。

  • 无用的间接层

间接层有其价值,但不是所有间接层都有价值。通过内联手法,可以找出那些有用的间接层,同时将无用的间接层去除。

具体重构步骤
  1. 检查函数,确定它不具多态性

  2. 找出这个函数的所有调用点

  3. 将这个函数的所有调用点都替换为函数本体

  4. 每次替换之后,执行测试

不必一次完成整个内联操作。如果某些调用点比较难以内联,可以等到时机成熟后再来处理。

  1. 删除该函数的定义

6.3 提炼变量(Extract Variable)

  • 局部变量

表达式有可能非常复杂而难以阅读。这种情况下,局部变量可帮助将表达式分解为比较容易管理的形式。

在面对一块复杂逻辑时,局部变量使能给其中的一部分命名,这样能更好地理解这部分逻辑是要干什么。

  • 表达式命名

一旦考虑使用提炼变量,则需要对表达式命名,而这样需要考虑这个名字所处的上下文。

如果这个名字只在当前的函数中有意义,那么提炼变量是不错的选择,但如果这个变量名在更宽的上下文中也有意义,可考虑将其暴露出来,通常以函数的形式。

  • 将新名字暴露得更宽

如果工作量很大,可先不考虑将新名字暴露,可后续使用以查询取代临时变量来处理它。

具体重构步骤
  1. 确认要提炼的表达式没有副作用

  2. 声明一个不可修改的变量,把你想要提炼的表达式复制一份,以该表达式的结果值给这个变量赋值

  3. 用这个新变量取代原来的表达式

  4. 测试

6.4 内联变量(Inline Variable)

  • 妨碍重构的命名

若变量名并不比表达式本身更具表现力,或会妨碍重构附近的代码,则应该通过内联的手法消除变量。

具体重构步骤
  1. 检查确认变量赋值语句的右侧表达式没有副作用

  2. 如果变量没有被声明为不可修改,先将其变为不可修改,并执行测试

这是为了确保该变量只被赋值一次。

  1. 找到第一处使用该变量的地方,将其替换为直接使用赋值语句的右侧表达式

  2. 测试

  3. 重复前面两步,逐一替换其他所有使用该变量的地方

  4. 删除该变量的声明点和赋值语句

  5. 测试

6.5 改变函数声明(Change Function Declaration)

  • 直观理解函数用途

对于这些关节而言,最重要的元素当属函数的名字。一个好名字能让我一眼看出函数的用途,而不必查看其实现代码。

  • 将注释变成名字

有一个改进函数名字的好办法:先写一句注释描述这个函数的用途,再把这句注释变成函数的名字。

  • 修改参数列表

修改参数列表不仅能增加函数的应用范围,还能改变连接一个模块所需的条件,从而去除不必要的耦合。

  • 没有正确答案

对这道难题,唯一正确的答案是“没有正确答案”,而且答案还会随着时间变化。

具体重构步骤
  1. 如果想要移除一个参数,需要先确定函数体内没有使用该参数;

  2. 修改函数声明,使其成为你期望的状态;

  3. 找出所有使用旧的函数声明的地方,将它们改为使用新的函数声明;

  4. 测试;

最好能把大的修改拆成小的步骤,所以如果你既想修改函数名,又想添加参数,最好分成两步来做。

(并且不论何时,如果遇到了麻烦,请撤销修改,并改用迁移式做法。)

  1. 如果有必要的话,先对函数体内部加以重构,使后面的提炼步骤易于开展;

  2. 使用提炼函数将函数体提炼成一个新函数;

如果你打算沿用旧函数的名字,可以先给新函数起一个易于搜索的临时名字。

  1. 如果提炼出的函数需要新增参数,用前面的简单做法添加即可;

  2. 测试;

  3. 对旧函数使用内联函数;

  4. 如果新函数使用了临时的名字,再次使用改变函数声明将其改回原来的名字;

  5. 测试;

  • 如果要重构的函数属于一个具有多态性的类,那么对于该函数的每个实现版本,你都需要通过“提炼出一个新函数”的方式添加一层间接,并把旧函数的调用转发给新函数

    • 如果该函数的多态性是在一个类继承体系中体现,那么只需要在超类上转发即可
    • 如果各个实现类之间并没有一个共同的超类,那么就需要在每个实现类上做转发
  • 如果要重构一个已对外发布的API,在提炼出新函数之后,你可以暂停重构,将原来的函数声明为“不推荐使用”(deprecated),然后给客户端一点时间转为使用新函数

    • 等你有信心所有客户端都已经从旧函数迁移到新函数,再移除旧函数的声明

6.6 封装变量(Encapsulate Variable)

  • 移除广泛使用的数据

如果想要搬移一处被广泛使用的数据,最好的办法往往是先以函数形式封装所有对该数据的访问。这样可以把“重新组织数据”的困难任务转化为“重新组织函数”这个相对简单的任务。

  • 可变数据的作用域

对于所有可变的数据,只要它的作用域超出单个函数,我就会将其封装起来,只允许通过函数访问。数据的作用域越大,封装就越重要。

处理遗留代码时,一旦需要修改或增加使用可变数据的代码,我就会借机把这份数据封装起来,从而避免继续加重耦合一份已经广泛使用的数据。

  • 不可变性是强大的代码防腐剂

封装数据很重要,不过不可变数据更重要。如果数据不能修改,就根本不需要数据更新前的验证或者其他逻辑钩子。

具体重构步骤
  1. 创建封装函数,在其中访问和更新变量值;

  2. 执行静态检查;

  3. 逐一修改使用该变量的代码,将其改为调用合适的封装函数。每次替换之后执行测试;

  4. 限制变量的可见性;

有时没办法阻止直接访问变量。若果真如此,可以试试将变量改名,再执行测试,找出仍在直接使用该变量的代码。

  1. 测试;

  2. 如果变量的值是一个记录,考虑使用封装记录;

6.7 变量改名(Rename Variable)

  • 解释程序的行为

好的命名是整洁编程的核心。变量可以很好地解释一段程序在干什么——如果变量名起得好的话。

  • 使用范围的影响

使用范围越广,名字的好坏就越重要。对于作用域超出一次函数调用的字段,则需要更用心命名。

具体重构步骤
  1. 如果变量被广泛使用,考虑运用封装变量将其封装起来;

  2. 找出所有使用该变量的代码,逐一修改;

如果在另一个代码库中使用了该变量,这就是一个“已发布变量”(published variable),此时不能进行这个重构。

如果变量值从不修改,可以将其复制到一个新名字之下,然后逐一修改使用代码,每次修改后执行测试。

  1. 测试;

6.8 引入参数对象(Introduce Parameter Object)

  • 数据泥团

一组数据项总是结伴同行,出没于一个又一个函数。这样一组数据就是所谓的数据泥团,我喜欢代之以一个数据结构。

  • 数据组织成结构

让数据项之间的关系变得明晰。使用新的数据结构,参数的参数列表也能缩短。并且经过重构之后,所有使用该数据结构的函数都会通过同样的名字来访问其中的元素,从而提升代码的一致性。

  • 代码中更深层次的改变

这项重构真正的意义在于,它会催生代码中更深层次的改变。这个过程会改变代码的概念图景,将这些数据结构提升为新的抽象概念,可以帮助我更好地理解问题域。

具体重构步骤
  1. 如果暂时还没有一个合适的数据结构,就创建一个;

倾向于使用类,因为稍后把行为放进来会比较容易。我通常会尽量确保这些新建的数据结构是值对象[mf-vo]。

  1. 测试;

  2. 使用改变函数声明给原来的函数新增一个参数,类型是新建的数据结构;

  3. 测试;

  4. 调整所有调用者,传入新数据结构的适当实例。每修改一处,执行测试;

  5. 用新数据结构中的每项元素,逐一取代参数列表中与之对应的参数项,然后删除原来的参数。测试。

6.9 函数组合成类(Combine Functions into Class)

  • 组建类的时机

如果发现一组函数形影不离地操作同一块数据(通常是将这块数据作为参数传递给函数),我就认为是时候组建一个类了。

  • 类提供的共用环境

类能明确地给这些函数提供一个共用的环境,在对象内部调用这些函数可以少传许多参数,从而简化函数调用,并且这样一个对象也可以更方便地传递给系统的其他部分。

  • 重构带来的机会

除了可以把已有的函数组织起来,这个重构还给我们一个机会,去发现其他的计算逻辑,将它们也重构到新的类当中。

  • 倾向于类而非嵌套函数

类似这样的一组函数不仅可以组合成一个类,而且可以组合成一个嵌套函数。但后者测试起来会比较困难。如果我想对外暴露多个函数,也必须采用类的形式。

  • 函数作为一等公民

在有些编程语言中,类不是一等公民,而函数则是。面对这样的语言,可用“函数作为对象”(Function As Object)[mf-fao]的形式来实现这个重构手法。

具体重构步骤
  1. 运用封装记录对多个函数共用的数据记录加以封装;

如果多个函数共用的数据还未组织成记录结构,则先运用引入参数对象将其组织成记录。

  1. 对于使用该记录结构的每个函数,运用搬移函数将其移入新类;

如果函数调用时传入的参数已经是新类的成员,则从参数列表中去除之。

  1. 用以处理该数据记录的逻辑可以用提炼函数提炼出来,并移入新类;

6.10 函数组合成变换(Combine Functions into Transform)

  • 数据变换函数

一个方式是采用数据变换 transform 函数:这种函数接受源数据作为输入,计算出所有的派生数据,将派生数据以字段形式填入输出数据。有了变换函数,我就始终只需要到变换函数中去检查计算派生数据的逻辑。

  • 替代方案的区别

函数组合成变换的替代方案是函数组合成类,后者的做法是先用源数据创建一个类,再把相关的计算逻辑搬移到类中。

两者有一个重要的区别:如果代码中会对源数据做更新,那么使用类要好得多;如果使用变换,派生数据会被存储在新生成的记录中,一旦源数据被修改,就会遭遇数据不一致。

  • 避免计算逻辑重复

为了避免计算派生数据的逻辑到处重复。从道理上来说,只用提炼函数也能避免重复,但孤立存在的函数常常很难找到,只有把函数和它们操作的数据放在一起,用起来才方便。引入变换(或者类)都是为了让相关的逻辑找起来方便。

具体重构步骤
  1. 创建一个变换函数,输入参数是需要变换的记录,并直接返回该记录的值;

这一步通常需要对输入的记录做深复制(deep copy)。此时应该写个测试,确保变换不会修改原来的记录。

  1. 挑选一块逻辑,将其主体移入变换函数中,把结果作为字段添加到输出记录中。修改客户端代码,令其使用这个新字段;

如果计算逻辑比较复杂,先用提炼函数提炼之。

  1. 测试;

  2. 针对其他相关的计算逻辑,重复上述步骤;

6.11 拆分阶段(Split Phase)

  • 拆分独立模块

每当看见一段代码在同时处理两件不同的事,就可以考虑将它拆分成各自独立的模块,因为这样到了需要修改的时候,我就可以单独处理每个主题,而不必同时在脑子里考虑两个不同的主题。

  • 拆分后明确差异

将这些代码片段拆分成各自独立的模块,能更明确地标示出它们之间的差异。

具体重构步骤
  1. 将第二阶段的代码提炼成独立的函数;

  2. 测试;

  3. 引入一个中转数据结构,将其作为参数添加到提炼出的新函数的参数列表中;

  4. 测试;

  5. 逐一检查提炼出的“第二阶段函数”的每个参数。如果某个参数被第一阶段用到,就将其移入中转数据结构。每次搬移之后都要执行测试;

有时第二阶段根本不应该使用某个参数。果真如此,就把使用该参数得到的结果全都提炼成中转数据结构的字段,然后用搬移语句到调用者把使用该参数的代码行搬移到“第二阶段函数”之外。

  1. 对第一阶段的代码运用提炼函数,让提炼出的函数返回中转数据结构;

也可以把第一阶段提炼成一个变换(transform)对象。