《重构:改善既有代码的设计》读书笔记

前言: 捧读像这一类的书对于自己来说总带着一些神圣感,感谢自己并没有被这么宏大的主题吓退,看完了这里分享输出一下自己的笔记。

一、理解重构


什么是重构?

按书中 P45 中的说法,重构这个概念被分成了动词和名词的方面被分别阐述:

  • 重构(名词): 对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。
  • 重构(动词): 使用一系列重构手法,在不改变软件可观察行为的前提下,调整其结构。

在过去的几十年时间里,重构这个词似乎被用来代指任何形式的代码清理,但上面的定义所指的是一种特定的清理代码的方式。重构的关键在于运用大量微小且保持软件行为的步骤,一步一步达成大规模的修改。

每一次的重构要么很小,要么包含了若干个小步骤,即使重构没有完成,也应当可以在任何时刻停下来,所以如果有人说它们的代码在重构过程中有一两天时间不可用,基本上可以确定,他们做的事不是重构。

与性能优化的区别

重构与性能优化有很多相似的地方:两者都需要修改代码,并且两者都不会改变程序的整体功能。

两者的差别在于起目的:

  • 重构是为了让代码 “更容易理解,更容易修改”。这可能使程序运行得更快,也可能使程序运行的更慢。
  • 性能优化则只关心程序是否运行的更快。对于最终得到的代码是否容易理解和维护就不知道了。

为什么重构?

重构不是包治百病的灵丹妙药,也绝对不是所谓的“银弹”。重构只是一种工具,能够帮助你始终良好的控制代码而已。使用它,可能基于下面的几个目的。

这里有一个有意思的科普(引用自百度百科:没有银弹 ):在民俗传说里,所有能让我们充满梦靥的怪物之中,没有比狼人更可怕的了,因为它们会突然地从一般人变身为恐怖的怪兽,因此人们尝试着查找能够奇迹似地将狼人一枪毙命的银弹。我们熟悉的软件项目也有类似的特质(以一个不懂技术的管理者角度来看),平常看似单纯而率直,但很可能一转眼就变成一只时程延误、预算超支、产品充满瑕疵的怪兽,所以,我们听到了绝望的呼唤,渴望有一种银弹,能够有效降低软件开发的成本,就跟电脑硬件成本能快速下降一样。

1. 改进软件的设计

当人们只为短期目的而修改代码时,他们经常没有完全理解架构的整体设计。于是代码逐渐失去了自己的结构。程序员越来越难以通过阅读代码来理解原来的设计。代码结构的流失有累积效应。越难看出代码所代表的设计企图,就越难以保护其设计,于是设计就腐败得越快。

完成同样一件事,设计欠佳的程序往往需要更多代码,这常常是因为代码在不同的地方使用完全相同的语句做同样的事情,因此改进设计的一个重要方向就是消除重复代码。消除重复代码,我就可以确定所有事物和行为在代码中只表述一次,这正是优秀设计的根本。

2. 使软件更容易理解

所谓程序设计,很大程度上就是与计算机对话:我编写代码告诉计算机做什么,而它的响应是按照我的指示精确行动。一言以蔽之,我所做的就是填补“我想要它做什么”和“我告诉它做什么”之间的缝隙。编程的核心就在于“准确说出我想要的”。

然而别忘了,除计算机之外,源码还有其他读者,并且很大概率还是几个月后的自己,如何更清晰地表达我想要做的,这可能就需要一些重构的手法。

这里我联想到了软件设计的 KISS 原则:KISS 原则,Keep It Simple and Stupid ,简单的理解这句话就是,要把一个系统做的连白痴都会用。

3. 帮助找到 BUG

对代码的理解,可以帮助找到系统中存在的一些 BUG。搞清楚程序结构的同时,也可以对自己的假设做一些验证,这样一来 BUG 想不发现都难。

Kent Beck 经常形容自己的一句话是:“我不是一个特别好的程序员,我只是一个有着一些特别好的习惯的还不错的的程序员。”重构能够帮助我们更有效地写出健壮的代码。

4. 提高编程速度

听起来可能有些反直觉,因为重构可能会花大量的时间改善设计、提高阅读性、修改 BUG,难道不是在降低开发速度嘛?

软件开发者交谈时的故事:一开始他们进展很快,但如今想要添加一个新功能需要的时间就要长得多。他们需要花越来越多的时间去考虑如何把新功能塞进现有的代码库,不断蹦出来的bug修复起来也越来越慢。代码库看起来就像补丁摞补丁,需要细致的考古工作才能弄明白整个系统是如何工作的。这份负担不断拖慢新增功能的速度,到最后程序员恨不得从头开始重写整个系统。

下面这幅图可以描绘他们经历的困境。

但有些团队的境遇则截然不同。他们添加新功能的速度越来越快,因为他们能利用已有的功能,基于已有的功能快速构建新功能。

两种团队的区别就在于软件的内部质量。需要添加新功能时,内部质量良好的软件让我可以很容易找到在哪里修改、如何修改。良好的模块划分使我只需要理解代码库的一小部分,就可以做出修改。如果代码很清晰,我引入 BUG 的可能性就会变小,即使引入了 BUG,调试也会容易得多。理想情况下,代码库会逐步演化成一个平台,在其上可以很容易地构造与其领域相关的新功能。

这种现象被作者称为“设计耐久性假说”:通过投入精力改善内部设计,我们增加了软件的耐久性,从而可以更长时间地保持开发的快速。目前还无法科学地证明这个理论,所以说它是一个“假说”。

20年前,行业的陈规认为:良好的设计必须在开始编程之前完成,因为一旦开始编写代码,设计就只会逐渐腐败。重构改变了这个图景。现在我们可以改善已有代码的设计,因此我们可以先做一个设计,然后不断改善它,哪怕程序本身的功能也在不断发生着变化。由于预先做出良好的设计非常困难,想要既体面又快速地开发功能,重构必不可少。

什么时候重构?

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

什么时候不应该重构?

重构并不是必要,当然也有一些不那么需要重构的情况:

  • 不需要修改,那些丑陋的代码能隐藏在一个 API 之下。 只有当我需要理解其工作原理时,对其进行重构才会有价值;
  • 重写比重构容易。 这可能就需要良好的判断力和丰富的经验才能够进行抉择了。

二、重构的几种姿势


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

重构的最佳时机就在添加新功能之前。在动手添加新功能之前,我会看看现有的代码库,此时经常会发现:如果对代码结构做一点微调,我的工作会容易得多。也许已经有个函数提供了我需要的大部分功能,但有几个字面量的值与我的需要略有冲突。如果不做重构,我可能会把整个函数复制过来,修改这几个值,但这就会导致重复代码—如果将来我需要做修改,就必须同时修改两处(更麻烦的是,我得先找到这两处)。而且,如果将来我还需要一个类似又略有不同的功能,就只能再复制粘贴一次,这可不是个好主意。

这就好像我要往东去100公里。我不会往东一头把车开进树林,而是先往北开20公里上高速,然后再向东开100公里。后者的速度比前者要快上3倍。如果有人催着你“赶快直接去那儿”,有时你需要说:“等等,我要先看看地图,找出最快的路径。”这就是预备性重构于我的意义。 ——Jessica Kerr

修复bug时的情况也是一样。在寻找问题根因时,我可能会发现:如果把3段一模一样且都会导致错误的代码合并到一处,问题修复起来会容易得多。或者,如果把某些更新数据的逻辑与查询逻辑分开,会更容易避免造成错误的逻辑纠缠。用重构改善这些情况,在同样场合再次出现同样bug的概率也会降低。

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

我需要先理解代码在做什么,然后才能着手修改。这段代码可能是我写的,也可能是别人写的。一旦我需要思考“这段代码到底在做什么”,我就会自问:能不能重构这段代码,令其一目了然?我可能看见了一段结构糟糕的条件逻辑,也可能希望复用一个函数,但花费了几分钟才弄懂它到底在做什么,因为它的函数命名实在是太糟糕了。这些都是重构的机会。

看代码时,我会在脑海里形成一些理解,但我的记性不好,记不住那么多细节。正如 Ward Cunningham 所说,通过重构,我就把脑子里的理解转移到了代码本身。随后我运行这个软件,看它是否正常工作,来检查这些理解是否正确。如果把对代码的理解植入代码中,这份知识会保存得更久,并且我的同事也能看到。

重构带来的帮助不仅发生在将来——常常是立竿见影。是我会先在一些小细节上使用重构来帮助理解,给一两个变量改名,让它们更清楚地表达意图,以方便理解,或是将一个长函数拆成几个小函数。当代码变得更清晰一些时,我就会看见之前看不见的设计问题。如果不做前面的重构,我可能永远都看不见这些设计问题,因为我不够聪明,无法在脑海中推演所有这些变化。Ralph Johnson说,这些初步的重构就像扫去窗上的尘埃,使我们得以看到窗外的风景。在研读代码时,重构会引领我获得更高层面的理解,如果只是阅读代码很难有此领悟。有些人以为这些重构只是毫无意义地把玩代码,他们没有意识到,缺少了这些细微的整理,他们就无法看到隐藏在一片混乱背后的机遇。

捡垃圾式重构

帮助理解的重构还有一个变体:我已经理解代码在做什么,但发现它做得不好,例如逻辑不必要地迂回复杂,或者两个函数几乎完全相同,可以用一个参数化的函数取而代之。这里有一个取舍:我不想从眼下正要完成的任务上跑题太多,但我也不想把垃圾留在原地,给将来的修改增加麻烦。如果我发现的垃圾很容易重构,我会马上重构它;如果重构需要花一些精力,我可能会拿一张便笺纸把它记下来,完成当下的任务再回来重构它。

当然,有时这样的垃圾需要好几个小时才能解决,而我又有更紧急的事要完成。不过即便如此,稍微花一点工夫做一点儿清理,通常都是值得的。正如野营者的老话所说:至少要让营地比你到达时更干净。如果每次经过这段代码时都把它变好一点点,积少成多,垃圾总会被处理干净。重构的妙处就在于,每个小步骤都不会破坏代码——所以,有时一块垃圾在好几个月之后才终于清理干净,但即便每次清理并不完整,代码也不会被破坏。

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

上面的例子——预备性重构、帮助理解的重构、捡垃圾式重构——都是见机行事的:我并不专门安排一段时间来重构,而是在添加功能或修复 BUG 的同时顺便重构。这是我自然的编程流的一部分。不管是要添加功能还是修复 BUG,重构对我当下的任务有帮助,而且让我未来的工作更轻松。这是一件很重要而又常被误解的事:重构不是与编程割裂的行为。你不会专门安排时间重构,正如你不会专门安排时间写 if 语句。我的项目计划上没有专门留给重构的时间,绝大多数重构都在我做其他事的过程中自然发生。

还有一种常见的误解认为,重构就是人们弥补过去的错误或者清理肮脏的代码。当然,如果遇上了肮脏的代码,你必须重构,但漂亮的代码也需要很多重构。在写代码时,我会做出很多权衡取舍:参数化需要做到什么程度?函数之间的边界应该划在哪里?对于昨天的功能完全合理的权衡,在今天要添加新功能时可能就不再合理。好在,当我需要改变这些权衡以反映现实情况的变化时,整洁的代码重构起来会更容易。

长久以来,人们认为编写软件是一个累加的过程:要添加新功能,我们就应该增加新代码。但优秀的程序员知道,添加新功能最快的方法往往是先修改现有的代码,使新功能容易被加入。所以,软件永远不应该被视为“完成”。每当需要新能力时,软件就应该做出相应的改变。越是在已有代码中,这样的改变就越显重要。

不过,说了这么多,并不表示有计划的重构总是错的。如果团队过去忽视了重构,那么常常会需要专门花一些时间来优化代码库,以便更容易添加新功能。在重构上花一个星期的时间,会在未来几个月里发挥价值。有时,即便团队做了日常的重构,还是会有问题在某个区域逐渐累积长大,最终需要专门花些时间来解决。但这种有计划的重构应该很少,大部分重构应该是不起眼的、见机行事的。

长期重构

大多数重构可以在几分钟—最多几小时—内完成。但有一些大型的重构可能要花上几个星期,例如要替换一个正在使用的库,或者将整块代码抽取到一个组件中并共享给另一支团队使用,再或者要处理一大堆混乱的依赖关系,等等。

即便在这样的情况下,我仍然不愿让一支团队专门做重构。可以让整个团队达成共识,在未来几周时间里逐步解决这个问题,这经常是一个有效的策略。每当有人靠近“重构区”的代码,就把它朝想要改进的方向推动一点。这个策略的好处在于,重构不会破坏代码—每次小改动之后,整个系统仍然照常工作。例如,如果想替换掉一个正在使用的库,可以先引入一层新的抽象,使其兼容新旧两个库的接口。一旦调用方已经完全改为使用这层抽象,替换下面的库就会容易得多。(这个策略叫作Branch By Abstraction[mf-bba]。)

复审代码时重构

至于如何在代码复审的过程中加入重构,这要取决于复审的形式。在常见的pull request模式下,复审者独自浏览代码,代码的作者不在旁边,此时进行重构效果并不好。如果代码的原作者在旁边会好很多,因为作者能提供关于代码的上下文信息,并且充分认同复审者进行修改的意图。对我个人而言,与原作者肩并肩坐在一起,一边浏览代码一边重构,体验是最佳的。这种工作方式很自然地导向结对编程:在编程的过程中持续不断地进行代码复审。

三、坏代码长什么样?


这让我想起之前在捧读《阿里巴巴 Java 开发手册》时学习的代码规范的问题(传送门) ,只不过当时学习的是好的代码应该长什么样,而现在讨论的事情是:坏的代码长什么样?

其实大部分的情况应该作为程序员的我们都有一定的共识,所以我觉得简单列一下书中提到的情况就足以说明:

  • 神秘命名
  • 重复代码
  • 过长函数
  • 过长参数列表
  • 全局数据: 全局数据的问题在于,从代码库的任何一个角落都可以修改它,而且没有任何机制可以探测出到底哪段代码做出了修改。一次又一次,全局数据造成了一些诡异的 BUG,而问题的根源却在遥远的别处。
  • 可变数据: 对数据的修改经常导致出乎意料的结果和难以发现的 BUG。我在一处更新数据,却没有意识到软件中的另一处期望着完全不同的数据。
  • 发散式变化: 模块经常因为不同的原因在不同的方向上发生变化。
  • 散弹式修改: 每遇到某种变化,你都必须在许多不同的类内做出许多小修改。
  • 依恋情结: 所谓模块化,就是力求将代码分出区域,最大化区域内部的交互、最小化跨区域的交互。但有时你会发现,一个函数跟另一个模块中的函数或者数据交流格外频繁,远胜于在自己所处模块内部的交流。
  • 数据泥团: 你经常在很多地方看到相同的三四项数据:两个类中相同的字段、许多函数签名中相同的参数。
  • 基本类型偏执: 很多程序员不愿意创建对自己的问题域有用的基本类型,如钱、坐标、范围等。
  • 重复的 switch: 在不同的地方反复使用相同的 switch 逻辑。问题在于:每当你想增加一个选择分支时,必须找到所有的 switch,并逐一更新。
  • 循环语句: 我们发现,管道操作(如 filter 和 map)可以帮助我们更快地看清被处理的元素一级处理它们的动作。
  • 冗余的元素
  • 夸夸其谈通用性: 函数或类的唯一用户是测试用例。
  • 临时字段: 有时你会看到这样的类:其内部某个字段仅为某种特定情况而定。这样的代码让人不理解,因为你通常认为对象在所有时候都需要它的所有字段。在字段未被使用的情况下猜测当初设置它的目的,会让你发疯。
  • 过长的消息链
  • 中间人: 过度运用委托。
  • 内幕交易: 软件开发者喜欢在模块之间筑起高墙,极其反感在模块之间大量交换数据,因为这会增加模块间的耦合。在实际情况里,一定的数据交换不可避免,但我们必须尽量减少这种情况,并把这种交换都放到明面上来。
  • 过大的类
  • 异曲同工的类
  • 纯数据类: 所谓纯数据类是指:他们拥有一些字段,以及用于访问(读写)这些字段的函数,除此之外一无长物。纯数据类常常意味着行为被放在了错误的地方。也就是说,只要把处理数据的行为从客户端搬移到纯数据类里来,就能使情况大为改观。
  • 被拒绝的遗赠: 拒绝继承超类的实现,我们不介意:但如果拒绝支持超类的接口,这就难以接受了。
  • 注释: 当你感觉需要纂写注释时,请先尝试重构,试着让所有注释都变得多余。

四、重构的一些方法


书中花了大量的章节介绍我们应该如何重构我们的程序,有几个关键的点是我自己能够提炼出来的:找出代码中不合理的地方、结构化、容易理解、测试确保正确。总之围绕这几个点,书中介绍了大量的方法,下面结合自己的一些理解来简单概述一下吧。

结构化代码

结构化的代码更加便于我们阅读和理解,例如最常使用的重构方法:提炼函数

  • 动机:把意图和实现分开
 void printOwing(double amount) {
     printBanner();
     //print details
     System.out.println ("name:" + _name);
     System.out.println ("amount" + amount);
 }

=>

 void printOwing(double amount) {
     printBanner();
     printDetails(amount);
 }
 void printDetails (double amount) {
     System.out.println ("name:" + _name);
     System.out.println ("amount" + amount);
 }

更清楚的表达用意

要保持软件的 KISS 原则是不容易的,但是也有一些方法可以借鉴,例如:引入解释性变量

动机:用一个良好命名的临时变量来解释对应条件子句的意义,使语义更加清晰。

 if ( (platform.toUpperCase().indexOf("MAC") > -1) &&
     (browser.toUpperCase().indexOf("IE") > -1) &&
      wasInitialized() && resize > 0 )
{
     // do something
}

=>

   final boolean isMacOs     = platform.toUpperCase().indexOf("MAC") > -1;
   final boolean isIEBrowser = browser.toUpperCase().indexOf("IE")  > -1;
   final boolean wasResized  = resize > 0;
   if (isMacOs && isIEBrowser && wasInitialized() && wasResized) {
       // do something
   }

另外由于 lambda 表达式的盛行,我们现在有一些更加优雅易读的方法使我们的代码保持可读:以管道取代循环就是这样一种方法。

   const names = [];
   for (const i of input) {
      if (i.job === "programer")
         names.push(i.name);
   }

=>

   const names = input
      .filter(i => i.job === "programer")
      .map(i => i.name)
   ;

合理的组织结构

例如上面介绍的提炼函数的方法,固然是一种很好的方式,但也应该避免过度的封装,如果别人使用了太多间接层,使得系统中的所有函数都似乎只是对另一个函数的简单委托(delegation),造成我在这些委托动作之间晕头转向,并且内部代码和函数名称同样清晰易读,那么就应该考虑内联函数。

动机:①去处不必要的间接性;②可以找出有用的间接层。

 int getRating() {
     return (moreThanFiveLateDeliveries()) ? 2 : 1;
 }
 boolean moreThanFiveLateDeliveries() {
     return _numberOfLateDeliveries > 5;
 }

=>

 int getRating() {
     return (_numberOfLateDeliveries > 5) ? 2 : 1;
 }

合理的封装

封装能够帮助我们隐藏细节并且,能够更好的应对变化,当我们发现我们的类太大而不容易理解的时候,可以考虑使用提炼类的方法。

动机:类太大而不容易理解。

 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; }
 }

反过来,如果我们发现一个类不再承担足够责任,不再有单独存在的理由的时候,我们会进行反向重构:内敛类

 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; }
 }

=>

 class Person {
     get officeAreaCode() { return this._officeAreaCode; }
     get officeNumber() { return this._officeNumber; }
 }

简化条件表达式

分解条件式: 我们能通过提炼代码,把一段 「复杂的条件逻辑」 分解成多个独立的函数,这样就能更加清楚地表达自己的意图。

     if (date.before (SUMMER_START) || date.after(SUMMER_END))
         charge = quantity * _winterRate + _winterServiceCharge;
     else charge = quantity * _summerRate;

=>

     if (notSummer(date))
         charge = winterCharge(quantity);
     else charge = summerCharge (quantity);

另外一个比较受用的一条建议就是:以卫语句取代嵌套条件式。根据经验,条件式通常有两种呈现形式。第一种形式是:所有分支都属于正常行为。第二种形式则是:条件式提供的答案中只有一种是正常行为,其他都是不常见的情况。

精髓是:给某一条分支以特别的重视。如果使用 if-then-else 结构,你对 if 分支和 else 分支的重视是同等的。 这样的代码结构传递给阅读者的消息就是:各个分支有同样的重要性。卫语句(guard clauses)就不同了,它告诉阅读者:「这种情况很罕见,如果它真的发生了,请做 一些必要的整理工作,然后退出。」

「每个函数只能有一个入口和一个出口」的观念,根深蒂固于某些程序员的脑海里。 我发现,当我处理他们编写的代码时,我经常需要使用 Replace Nested Conditional with Guard Clauses。现今的编程语言都会强制保证每个函数只有一个入口, 至于「单一出口」规则,其实不是那么有用。在我看来,保持代码清晰才是最关键的:如果「单一出口」能使这个函数更清楚易读,那么就使用单一出口;否则就不必这么做。

 double getPayAmount() {
   double result;
   if (_isDead) result = deadAmount();
   else {
       if (_isSeparated) result = separatedAmount();
       else {
           if (_isRetired) result = retiredAmount();
           else result = normalPayAmount();
       };
   }
 return result;
 };

=>

 double getPayAmount() {
   if (_isDead) return deadAmount();
   if (_isSeparated) return separatedAmount();
   if (_isRetired) return retiredAmount();
   return normalPayAmount();
 };

自我测试代码

如果认真观察程序员把最多时间耗在哪里,你就会发现,编写代码其实只占非常小的一部分。有些时间用来决定下一步干什么,另一些时间花在设计上面,最多的时间则是用来调试(debug)。每个程序员都能讲出「花一整天(甚至更多)时间只找出一只小小臭虫」的故事。修复错误通常是比较快的,但找出错误却是噩梦一场。当你修好一个错误,总是会有另一个错误出现,而且肯定要很久以后才会注意到它。 彼时你又要花上大把时间去寻找它。

「频繁进行测试」是极限编程( extreme programming XP)[Beck, XP]的重要一 环。「极限编程」一词容易让人联想起那些编码飞快、自由而散漫的黑客(hackers), 实际上极限编程者都是十分专注的测试者。他们希望尽可能快速开发软件,而他们也知道「测试」可协助他们尽可能快速地前进。

在重构之前,先保证一组可靠的测试用例(有自我检验的能力),这不仅有助于我们检测 BUG,其中也有一种以终为始的思想在里面,实际上,我们可以通过编写测试用例,更加清楚我们最终的函数应该长什么样子,提供什么样的服务。

结束语


感谢您的耐心阅读,以上就是整个学习的笔记了。

重构不是一个一蹴而就的事,需要长期的实践和经验才能够完成得很好。重构强调的是 Be Better,那在此之前我们首先需要先动起手来搭建我们的系统,而不要一味地“完美主义”,近些时间接触的敏捷式开发也正是这样的一种思想。

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券