重构:改善既有代码的设计

Chapter 1

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

重构前,先检查自己是否有一套可靠的测试集。这些测试必须有自我检验能力。进行重构时,我需要依赖测试。我将测试视为bug检测器,它们能保护我不被自己犯的错误所困扰。通过测试对当前工作进行二次确认。

重构技术就是以微小的步伐修改程序。如果你犯下错误,很容易便可发现它。

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

好代码的检验标准就是人们是否能轻而易举地修改它。小的步子可以更快前进,请保持代码永远处于可工作状态,小步修改累积起来也能大大改善系统的设计。

Chapter 2 重构

WHY

  • 改进软件设计
  • 使软件更容易理解。通过重构,我就把脑子里的理解转移到了代码本身
  • 帮助找到 bug
  • 提高编程速度

见机行事的重构

大部分重构应该是不起眼的、见机行事的。

  • 帮助理解
  • 捡垃圾式重构

???

我听过的一条建议是:将重构与添加新功能在版本控制的提交中分开。这样做的一大好处是可以各自独立地审阅和批准这些提交。但我并不认同这种做法。重构常常与新添功能紧密交织,不值得花工夫把它们分开。并且这样做也使重构脱离了上下文,使人看不出这些“重构提交”的价值。每个团队应该尝试并找出适合自己的工作方式,只是要记住:分离重构提交并不是毋庸置疑的原则,只有当你真的感到有益时,才值得这样做

============================

有计划的重构

  • 添加新功能最快的方法往往是先修改现有的代码,使新功能容易被加入。

每当有人靠近“重构区”的代码,就把它朝想要改进的方向推动一点。这个策略的好处在于,重构不会破坏代码——每次小改动之后,整个系统仍然照常工作。例如,如果想替换掉一个正在使用的库,可以先引入一层新的抽象,使其兼容新旧两个库的接口。一旦调用方已经完全改为使用这层抽象,替换下面的库就会容易得多。(这个策略叫作Branch By Abstraction[mf-bba]。)

  • 复审代码时重构
  • 重写

重构的唯一目的就是让我们开发更快,用更少的工作量创造更大的价值。

发布接口 public interface

可以把旧的接口标记为“不推荐使用”(deprecated),等一段时间之后最终让其退休;但有些时候,旧的接口必须一直保留下去。

分支

在隔离的分支上工作得越久,将完成的工作集成(integrate)回主线就会越困难。

持续集成(Continuous Integration,CI),也叫“基于主干开发”(Trunk-Based Development)。在使用CI时,每个团队成员每天至少向主线集成一次。这个实践避免了任何分支彼此差异太大,从而极大地降低了合并的难度。不过CI也有其代价:你必须使用相关的实践以确保主线随时处于健康状态,必须学会将大功能拆分成小块,还必须使用特性开关(feature toggle,也叫特性旗标,feature flag)将尚未完成又无法拆小的功能隐藏掉。

YAGNI

简单设计、增量式设计或者YAGNI[mf-yagni]——“你不会需要它”(you arenʼt going to need it)的缩写

把YAGNI视为将架构、设计与开发过程融合的一种工作方式,这种工作方式必须有重构作为基础才可靠。

”演进式架构“

自测试代码、持续集成、重构

重构(及其前置实践)是YAGNI的基础,YAGNI又让重构更易于开展

Chapter 3 Bad Smell

清楚的命名

提炼函数

函数越长,就越难理解

注释

每当感觉需要以注释来说明点什么的时候,我们就把需要说明的东西写进一个独立函数中,并以其用途(而非实现手法)命名*。关键不在于函数的长度,而在于函数“做什么”和“如何做”之间的语义距离。

如果代码前方有一行注释,就是在提醒你:可以将这段代码替换成一个函数,而且可以在注释的基础上给这个函数命名。

循环

条件表达式和循环常常也是提炼的信号。你可以使用分解条件表达式(260)处理条件表达式。对于庞大的switch语句,其中的每个分支都应该通过提炼函数(106)变成独立的函数调用。如果有多个switch语句基于同一个条件进行分支选择,就应该使用以多态取代条件表达式(272)。

至于循环,你应该将循环和循环内的代码提炼到一个独立的函数中。如果你发现提炼出的循环很难命名,可能是因为其中做了几件不同的事。如果是这种情况,请勇敢地使用拆分循环(227)将其拆分成各自独立的任务。

全局变量

把全局数据用一个函数封装装起来

全局数据印证了帕拉塞尔斯的格言:良药与毒药的区别在于剂量。有少量的全局数据或许无妨,但数量越多,处理的难度就会指数上升。

可以用封装变量来确保所有数据更新操作都通过很少几个函数来进行,查询函数和修改函数分离

如果一个变量在其内部结构中包含了数据,通常最好不要直接修改其中的数据,而是用将引用对象改为值对象令其直接替换整个数据结构

循环

使用以管道取代循环,管道操作(如filter和map)可以帮助我们更快地看清被处理的元素以及处理它们的动作

过大的类

是把多余的东西消弭于类内部。如果有5个“百行函数”,它们之中很多代码都相同,那么或许你可以把它们变成5个“十行函数”和10个提炼出来的“双行函数”。

注释

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

如果你需要注释来解释一块代码做了什么,试试提炼函数;如果函数已经提炼出来,但还是需要注释来解释其行为,试试用改变函数声明为它改名;如果你需要注释说明某些系统的需求规格,试试引入断言。

应该添加的情况:如果你不知道该做什么,记述将来的打算,标记你并无十足把握的区域,写下自己“为什么做某某事”。这类信息可以帮助将来的修改者,尤其是那些健忘的家伙。

Chapter 4 测试

WHY

  • 编写代码的时间仅占所有时间中很少的一部分。有些时间用来决定下一步干什么,有些时间花在设计上,但是,花费在调试上的时间是最多的

  • 修复bug通常是比较快的,但找出bug所在却是一场噩梦。当修复一个bug时,常常会引起另一个bug,却在很久之后才会注意到它。那时,你又要花上大把时间去定位问题。

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

  • 除非体会到编写测试是如何提升编程速度,否则自测试似乎就没有什么意义
  • 编写未臻完善的测试并经常运行,好过对完美测试的无尽等待。

  • 一个测试语句中最好只有一个验证语句,否则测试可能在进行第一个验证时就失败,这通常会掩盖一些重要的错误信息,不利于你了解测试失败的原因。

错误,脏数据

直接 assert

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

探测边界条件

  • 目前为止我的测试都聚焦于正常的行为上,这通常也被称为“正常路径”(happy path),它指的是一切工作正常、用户使用方式也最符合规范的那种场景。同时,把测试推到这些条件的边界处也是不错的实践,这可以检查操作出错时软件的表现。
  • 考虑可能出错的边界条件,把测试火力集中在那儿。

你应该把测试集中在可能出错的地方。观察代码,看哪儿变得复杂;观察函数,思考哪些地方可能出错。

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

单元测试

负责测试一小块代码,运行速度足够快。它们是自测试代码的支柱,是一个系统中占绝大多数的测试类型

测试三问

与编程的许多方面类似,测试也是一种迭代式的活动。除非你技能非常纯熟,或者非常幸运,否则你很难第一次就把测试写对。我发觉我持续地在测试集上工作,就与我在主代码库上的工作一样多。很自然,这意味着我在增加新特性时也要同时添加测试。

有时还需要回顾已有的测试:它们足够清晰吗?我需要重构它们,以帮助我更好地理解吗?我拥有的测试是有价值的吗?

代码足够清晰吗?我需要重构它们,以帮助我更好地理解吗?

什么时候应该添加测试

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