《重构--改善既有代码的设计》读书笔记(一)重构的原则

前言

  在前段时间,我被安排去负责一个重要模块的重构工作。惭愧的是,在开始这项重要的重构工作之前,虽然我曾花费过一些时间仔细翻阅本书,但后面重构的过程依旧十分坎坷,而且在最后进行测试的时候,发现了很多重构时新引入的bug。尽管这些bug都是一些小问题,但这足以说明这是一次颇为失败的重构(ノへ ̄、)。

一次失败的重构
  在重构开始之前,我和其他几个一起负责这个模块的同事一起商定了重构的目标——花费几周的时间按照新的设计架构一步到位地彻底地重构整个模块。因为在此期间需要保证该模块必须要能正常运行,所以只能采取在确保旧模块能正常运行的基础上,加上一个新的模块,并使用一个功能开关来控制启用新模块还是旧模块。这是一切噩梦的开端
  在重构刚刚开始的时候,我还能游刃有余地严格按照书中提及的一些技巧去重构依赖较少的部分。但随着重构工作的不断开展,重构涉及的外部业务模块越来越多,需要理解的上下文语义也越来越多。因为新旧两个模块的架构设计差别很大,所以在修改相关的外部业务模块时,常常需要在新模块提供一套功能类似、但实现截然不同的接口给外部业务模块用。此外,在重构工作开展期间,还有大量的业务需求需要在旧模块上添加新功能,而且这些新功能在新模块里也要有所体现,这无疑大大加大了重构的负担。
  为了减轻重构的负担,只能尽快让新模块能正常工作以替代旧模块。因此,在重构的中期,我采用了激进的重构方式——只确保新模块的主要功能能正常运行,等待新模块接入之后,再将遗漏的功能慢慢补齐。例如,在遇到一些外部业务模块的时候,我选择将其暂时屏蔽,不在新模块中为其提供所需的接口。这种激进的,不加小心的重构方式,为本次重构埋下了巨大的隐患
  当然,如果按照计划,在新模块接入之后我能及时将其遗漏的功能补上,再按照测试用例确保其没有bug,倒也问题不大。但可惜的是,接入新模块之后,我因为忙于处理其他的紧急事务而搁置了补上遗漏功能的任务。再加上旧模块并没有清晰完备的测试用例,且没有经过充分的测试验证,导致许多问题在重构的过程中没有及时暴露出来。直到接入新流程之后,在某一个重要的测试节点,大家才发现,这个模块怎么重构之后bug反而增多了

  之所以本书没能帮我顺利地完成重构工作,主要是因为我在翻阅本书的时候只重点学习了它里面介绍的重构技巧,而对介绍重构原则的章节只是囫囵吞枣地看了一遍。初看不知书中意,再看已是书中人。当我在对这次失败的重构进行复盘时,我又重新翻阅了介绍重构原则的章节,这时我才深刻地体会到了为什么会有这些原则。
  值得一提的是,虽然我对书中对于重构的一些见解赞不绝口,但作者阐述的方式确实有些过于冗杂和混乱,于是我提炼了一下本章节的内容,加入了自己的理解(事实上,本文中有相当多的内容都是自己对原文的补充、理解),并稍微地调整了一下本章节的结构(运用作者介绍的重构原则对本章节的内容进行重构,开个玩笑︿( ̄︶ ̄)︿)


什么是重构

  所谓重构(Refactring),是指运用大量微小且不改变代码原始功能的步骤,一步步地对代码做出修改,以改进代码的内部结构的过程。每个单独的重构要么很小,要么由若干小步骤组合而成。如果有人说他们在重构过程中,代码有一两天的时间不可用,那么基本可以肯定他们在做的事情不是重构。
  可能刚接触重构的人会觉得,用很多小步骤完成似乎可以一大步就能做完的事,会非常低效。之所以会有这样的错觉,是因为他们将每个小步骤都当成彼此割裂的小步骤。实际上,每个小步骤都是彼此之间都是联系紧密的。每个小步骤做出修改的目的都是为了让后面的修改能更加容易地朝着最后的目标靠拢,这就是重构的艺术。

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

  也正因如此,如果能在开发的过程中,时常花费时间对代码进行重构来提升代码质量,那么通过提升代码质量所节省的开发时间,将会远大于花在重构的时间。

设计耐久性假说
  通过投入精力改善软件的内部设计,可以提高软件的耐久性,从而可以更长时间地保持开发的快速。因为还没有科学的证据可以证实这个理论,所以在书中作者称其为“假说”。


为什么要重构

  对于程序员来说,大部分人都觉得应该先有一个良好的设计,然后才能开始编码。但随着人们不断修改代码,根据原先设计所得到的整体结构往往会逐渐衰弱,代码的质量也会慢慢沉沦。这是因为开发者经常会为了短期目的,在没有完全理解整体结构的设计意图的前提下去修改代码,于是代码逐渐失去了原有的结构,开发者也会越来越难以通过阅读源码来理解代码所代表的设计意图。此时,按照心理学上的破窗理论,在这个系统上的编码工作,会从一个严谨性的工程逐渐堕落为胡砍乱劈的随性行为,开发者的开发效率也会大大降低。

破窗理论
环境中的不良现象如果放任存在,会诱使人们效仿,甚至变本加厉。

  重构有助于维持代码本身该有的结构,甚至改进代码的结构,从而提高开发的效率,用更少的工作量创造更多的价值。


何时进行重构

  1. 如果在动手添加新功能之前,通过审视代码发现对已有的代码结构做一些微调,就能使添加新功能的工作轻松很多,那么此时就应该先对这一部分代码进行重构;

      如果我要往东去100公里,我不会往东一头把车开进树林。而是先往北开20公里上高速,然后再向东开到目的地(或许这里不止100公里)。(虽然后者需要行驶的距离更长),但后者的速度可能比前者要快上3倍。如果有人催着你“赶快直接去那儿”,有时你需要说:“等等,我要先看看地图,找出最快的路径。”

  2. 如果发现有大量重复的代码时,应该对这些代码进行重构,消除这些重复代码;

      重复代码越多,就意味着有更多的代码需要去理解,实现某一功能所需要作出的修改动作就越多,做出正确的修改也就越难。消除重复代码,既可以使需要理解的代码量减少,也可以确定代码行为在代码中只表述一次,只需要修改对应的地方即可。

  3. 如果发现功能正确,可以正常运行,但结构却不甚理想或者逻辑混乱的代码,让人需要花费几分钟甚至几小时才能明白这些代码在做什么,可以对它们进行重构,使得代码可以更清晰地描述代码所代表的设计意图;

      在修改代码之前,开发者需要先理解代码在做什么。如果一段代码可以清晰地描述其代表的意图,那么开发者可以节省大量为了理解代码原先的设计意图而去阅读相关代码的时间,提高效率,还能看到之前没曾注意到的设计问题,提前消除潜在的bug。


重写还是重构

  重构和重写的目标是一样的,都是通过提高代码质量以提高开发的效率。它们的区别在于,重构是一种增量式的活动,因此它每次只会接触到系统的一部分,只在系统的局部造成修改,易于控制。而重写是更具有攻击性的改变,它会修改整个系统,造成更大的、不易控制的影响。因此要想让重写的影响稳定下来,时间要比重构长得多。

  如果将重构比喻成大扫除的话,那么重写则是先按照新的设计建造一个新房间,然后再把东西从旧房间搬到新房间去重新摆放。

  值得一提的是,当团队决定重写系统的时候,就注定会有一段时间,新旧两个系统在并行运行。此时,如果旧系统需要重构或者增添新功能,而新系统正在被重写,那么这种组合就会变成一项极度复杂的任务。因此,重构是不断提升系统更好的方式。它是慢速前进的,通过小的、经常的提升来提高质量。虽然重写也有其自身的优势,但在很多情况下它是一种有风险的、并且团队可能永远都不确定产出物的选择。

  重要的是要记住,当你从零开始的时候,没有绝对的理由相信你会比你第一次做得好。


何时不进行重构

  1. 如果有一个大规模的重构很有必要进行,需要花费一些精力,为了不从眼下正要完成的任务跑题太多,可以先不重构,等待完成当下的任务之后再回来重构它;

  2. 如果丑陋的代码能被隐藏在一个API之下,并不需要修改它,那么就不需要重构它。只有当需要理解其工作原理时,对其进行重构才有价值;

  3. 如果还没想清楚究竟应该如何优化代码,那么此时应该先做些实验,试试看能否有所改进,然后延迟重构;

  4. 当代码难于理解,并且不能确定它做什么的时候进行重构。但当很清楚知道代码做什么,但是很难理解那些代码的时候就重写;


对重构的一些误解

  1. 重构不一定会让代码变得更快;

      重构与性能优化有很多相似之处:两者都需要修改代码,并且两者都不会改变代码的整体功能。两者的差别在于其目的:重构是为了让代码更容易理解更易于修改。这可能使程序运行得更快,也可能使程序运行得更慢。而性能优化只关心如何让程序运行得更快,最终得到的代码有可能更难理解和维护。

  2. 重构不是与编程割裂的行为。正常来说,并不应该安排一段时间来专门重构,绝大多数重构都应该在做其他事情的过程中自然发生;

      对于一些不需要花费太多时间就可以完成的重构(见机行事的重构)来说,这不难理解。而对于那些需要耗费几天甚至几周的的重构(有计划的重构)来说,相比安排专门的人花费大量时间去完成重构的工作,让整个团队达成共识,然后每次有人靠近需要重构的代码时,就把它们朝想要改进的方向推动一点,逐步完成重构,可能更为有效。这个策略的好处在于,每次小的重构不会破坏代码的行为,在小改动之后,整个系统仍能照常工作。

    Branch By Abstraction
      如果想要替换掉一个正在使用的苦,可以先引入一层新的抽象,使其兼容新旧两个库的接口。一旦调用方已经完全改为使用这层抽象,那么替换下面的库就会容易得多。

  3. 重构不是在弥补过去的错误或者清理肮脏的代码。肮脏的代码必须重构,但整洁的代码也需要重构;

      在写代码时,开发者需要做出很多权衡取舍:参数化需要做到什么程度?函数之间的边界应该划在哪里?可能对于昨天的功能完全合理的权衡,在今天要添加新功能时可能就不再合理。当然,当开发者需要改变这些权衡以添加新的功能时,整洁的代码重构起来会更容易。

  4. 重构不是为了写出整洁的代码,重构的唯一目的是提高开发的效率,用更少的工作量创造更多的价值;

      有些人试图用整洁的代码或者良好的工程实践之类的理由来论证重构的必要性,这是对重构的误解。重构的意义不在于把代码打磨得闪闪发光,而是纯粹从经济角度出发的考量。我们之所以重构,因为它能让我们更快——添加功能更快,修复bug更快。


重构面临的挑战

  1. 项目经理并不理解重构的重要性;

      毋庸讳言,项目经理会认为重构要么是开发者在弥补过去犯下的过错,要么是开发者在做不增加价值的无用功。项目要交付给客户的,是可以有效运行的代码,而不是漂亮整洁的代码。原先的代码既然运行起来还算正常,那么耗费多余的时间进行重构,做的工作却与要交付的功能毫不相关,这只会拖缓项目的进度。受进度驱动的的项目经理需要开发者尽可能快速地完成任务,至于怎么完成项目经理并不关心。
      因此,开发者不应该安排专门的时间去重构(通常情况下也不会被允许),只有在开发者认为最快完成任务的方式是重构的时候,再去将重构当成完成任务的一个必不可少的环节并完成它。

  2. 代码所有权的边界会妨碍重构;

      很多重构手法不仅会影响一个模块内部,还会影响该模块与系统其他部分的关系。例如在修改函数声明的时候,可能并没有权限提交调用方的修改(有些团队喜欢给每段代码都指定唯一的所有者,只有指定的所有者才能修改这段代码)。此时,在声明一个新函数的同时,还需要保留原来的函数声明,并让其调用新函数,让接口变复杂。这就是
    为了避免破坏使用者的系统而不得不付出的代价。虽然可以把旧的接口标记为不推荐使用”(deprecated),等一段时间之后最终让其退休,但有些时候,旧的接口必须一直保留下去(例如这个函数可能是一个提供给客户的API)
      因此,正常来讲,应该允许团队里的成员都可以修改这个团队拥有的所有代码。虽然每个程序员可能各自分工负责系统的不同区域,但这种责任应该体现为监控自己责任区内发生的修改,而不是简单粗暴地禁止别人修改。

  3. 分支开发会加大重构的难度;

      很多团队会让每个成员各自在代码库的一条分支上开发完整的功能,直到功能可以发布到生产环境,才把该分支上的修改合并回主线(这条分支通常叫master或trunk),从而与整个团队分享。这种做法的拥趸声称,这样能保持主线不受尚未完成的代码侵扰,能保留清晰的功能添加的版本记录,并且在某个功能出问题时能容易地撤销修改。
      但显而易见的是,这种协作方式最大的缺点是,在隔离的分支上工作得越久,将完成的工作集成(integrate)回主线就会越困难。为了减轻集成的痛苦,大多数人的办法是频繁地从主线合并(merge)或者变基(rebase)到分支。但如果有几个人同时在各自的特性分支上工作,这个办法并不能真正解决问题,因为合并与集成是两回事。
    合并只是一个单向的代码移动——分支发生了修改,但主线并没有。而集成是一个双向的过程:不仅要把主线的修改拉取(pull)到分支上,而且要把分支修改的结果推送(push)回到主线上,两边都会发生修改。
      假如程序员A正在她的分支上对某一部分代码进行重构,程序员B看不见程序员A的修改,直到程序员A将自己的修改与主线集成;此时程序员B就必须把程序员A的修改合并到他的分支上,这可能需要相当的工作量。其中困难的部分是处理重构时发生的语义变化,例如程序员B修改了一个函数声明,但程序员A又增添了一个对原函数的调用。
      分支合并本来就是一个复杂的问题,随着特性分支存在的时间加长,合并的难度会指数上升。所以很多人认为,应该尽量缩短特性分支的生存周期,采用诸如持续集成(Continuous Integration,CI,也叫基于主干开发(Trunk-Based Development))的方法,确保每个团队成员每天至少向主线集成一次,避免任何分支彼此差异太大,从而极大地降低了合并的难度。不过CI也有其代价:每个团队成员必须使用相关的实践以确保主线随时处于健康状态,必须学会将大功能拆分成小块,还必须使用特性开关(feature toggle,也叫特性旗标(feature flag))将尚未完成又无法拆小的功能隐藏掉。

  4. 在重构时对代码做出了不加小心的结构调整,破坏代码原来的功能;

      不会改变程序可观察的行为,这是重构的一个重要特征。但人总会有出错的时候,不过只要及时发现,就不会造成大问题。既然每个重构都是很小的修改,即便真的造成了破坏,也只需要检查最后一步的小修改——就算找不到出错的原因,只要回滚到版本控制中最后一个可用的版本就行了。
      这里的关键就在于快速发现错误。要做到这一点,代码应该有一套完备的测试套件,并且运行速度要快,否则开发者会不愿意频繁运行它。也就是说,绝大多数情况下,如果想要重构,得先有可以自测试的代码。这也回答了重构风险太大,可能引入bug的担忧,如果没有自测试的代 码,这种担忧就是完全合理的。

      自测试的代码不仅使重构成为可能,而且使添加新功能更加安全,因为开发者可以很快发现并干掉新近引入的bug。这里的关键在于,一旦测试失败,开发者只需要查看上次测试成功运行之后修改的这部分代码。如果测试运行得很频繁,每次查看的范围就只有少量代码,加入知道必定是这部分代码造成bug的话,排查起来则会容易得多。

  5. 难以重构复杂且没有测试的遗留代码;

      从程序员的角度来看就不同了。遗留代码往往很复杂,测试又不足,而且最关键的是,是别人写的(瑟瑟发抖)。虽然重构可以很好地帮助开发者理解遗留系统,慢慢理顺糟糕的程序结构,使其更好地反映代码用途。但如果面对一个庞大而又缺乏测试的遗留系统,很难安全地重构清理它。
      对于这个问题,显而易见的答案是没测试就加测试,其中的工作量必定很大。这个问题没有简单的解决办法,最好建议就是按照《修改代码的艺术》里的指导,先找到程序的接缝,在接缝处插入测试,如此将系统置于测试覆盖之下。当然,大多数时候开发者需要运用重构手法创造出接缝(这样的重构很危险,因为没有测试覆盖,但这是为了取得进展必要的风险)

  6. 数据库是重构经常出问题的一个领域;

      跟通常的重构一样,数据库重构的关键也是小步修改并且每次修改都应该完整,这样每次迁移之后系统仍然能运行。由于每次迁移涉及的修改都很小,写起来应该容易;将多个迁移串联起来,就能对数据库结构及其中存储的数据做很大的调整。例如,改名一个字段的步骤如下(这种修改数据库的方式是并行修改(Parallel Change,也叫扩展协议(expandcontract))的一个实例):

    1. 新添一个字段,但暂时不使用它,然后提交;
    2. 修改数据写入的逻辑,使其同时写入新旧两个字段,然后提交;
    3. 修改读取数据的地方,将它们逐个改为使用新字段,然后提交看看是否有bug冒出来;
    4. 确定没有bug之后,再删除已经没人使用的旧字段;

    这里的数据库,也可以是存储数据的Excel表或者其他自定义的数据结构。


重构、架构和YAGNI

  重构对架构最大的影响在于,通过重构,开发者能得到一个设计良好的代码库,使其能够优雅地应对不断变化的需求。在编码之前先完成架构这种做法最大的问题在于,它假设了软件的需求可以预先充分理解。但经验显示,这个假设很多时候甚至可以说大多数时候是不切实际的。只有真正使用了软件、看到了软件对工作的影响,人们才会想明白自己到底需要什么。
  应对未来变化的办法之一,就是在软件里植入灵活性机制。例如在编写一个函数时,开发者会考虑它是否有更通用的用途。为了应对开发者预期的应用场景,开发者预测可以给这个函数加上十多个参数,这些参数就是灵活性机制。但天下没有免费的午餐,把这些参数都加上的话,函数在当前的使用场景下就会非常复杂。另外,如果未来的需求变更并非以开发者期望的方式发生,已有的一堆参数可能会使新添参数更麻烦。此外,即便真的需求按照开发者的预期进行变更,但如果对灵活机制的设计不好,反而拖慢响应需求变化的速度。
  有了重构技术,开发者就可以采取不同的策略。与其猜测未来需要哪些灵活性、需要什么机制来提供灵活性,不如只根据当前的需求来构造软件,同时把软件的设计质量做得很高。随着对用户需求的理解加深,开发者会对架构进行重构,使其能够应对新的需要。
  这种设计方法有很多名字:简单设计增量式设计或者YAGNI(you arenʼt going to need it的缩写)。YAGNI并不是不做预先架构性思考的意思,总有一些时候,如果缺少预先的思考,重构会难以开展。通俗地讲,YAGNI可以理解为等一等,待到对问题理解更充分,再来着手解决的一种演进式架构方式。


为什么重构会失败

  在前面,我提到了一次我自己亲身经历的、失败的重构。如果你已经花费了一点时间看完了本文的所有内容,那么你就不难理解为什么那次重构会以失败告终。
  首先,在一开始决定如何达到预定的重构目标时就做了一个最为错误的选择。既然旧模块需要在长达几周的重构期间保持运行,那么在旧模块上不断地小步修改让其逐渐向新的设计架构靠拢不失为一个很好的决策。一方面,该模块的重构能随时暂停来响应增添新功能的需求,另一方面,则是在重构完成之后,不需要花费额外的时间去清理旧模块相关的代码。当然,即便是按照一开始的选择,按照新的设计架构加上一个新模块,也有比只是单纯加上功能开关更好的选择。例如使用前面提到的Branch By Abstraction,提供一个抽象层来隔离旧模块与各个外部业务模块的联系,然后借助旧模块来测试这一抽象层的稳定性,后面在开发新模块时,就只需要考虑如何实现这一抽象层提供的各个接口,而不需要在每个相关的外部业务模块里加上功能开关,侵染其原来的代码。可以说,一开始没想到重构的方式,是重构失败的根本原因
  其次,选择使用新模块来代替旧模块,而不是直接修改旧模块,就意味着选择了重写而不是重构。此时,旧模块上不断增加的新功能会使得重写的过程变成一个极其复杂的工作。因为既确保在旧模块上增加新功能时不会影响到新模块,又要在不影响旧模块的基础上在新模块上增加对应的新功能。这大大加大了工作量,是重构失败的直接原因
  最后,在重写时,缺少旧模块完善的功能文档以及测试用例,也就是在没有充分理解旧模块的遗留代码前提下就开始了重写工作。这无疑加大了重写的风险,因为无法确定在重写的过程中是否遗漏了某些重要的细节,也无法通过完善从测试用例来及时发现问题。更要命的是,为了减轻负担,尽快让新模块代替旧模块,还主动屏蔽了一些不知道具体有什么用的细节,让本来就风险很高的重写工作雪上加霜。因此,在最后的测试阶段,出现各种各样的小bug,也自然不是什么稀奇的事。在没有充分了解遗留代码的前提下就贸然开始重写工作,是重构失败的重要原因


《重构--改善既有代码的设计》读书笔记(一)重构的原则
https://asancai.github.io/posts/37143617/
作者
RainbowCyan
发布于
2021年4月18日
许可协议