tips摘录

第1章 重构,第一个示例

1.2 对程序的评估评价

  • 如果你要给程序添加一个特性,但发现代码因缺乏良好的结构而不易于进行更改,那就先重构那个程序,使其比较容易添加该特性,然后再添加该特性。
  • 需求的变化使重构变得必要。如果一段代码能正常工作,并且不会再被修改,那么完全可以不去重构它。能改进之当然很好,但若没人需要去理解它,它就不会真正妨碍什么。如果确实有人需要理解它的工作原理,并且觉得理解起来很费劲,那你就需要改进一下代码了。

1.3 重构的第一步

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

1.4 分解提炼函数

  • 每次想将一块代码抽取成一个函数时,都会遵循一个标准流程,最大程度减少犯错的可能。(作者把这个流程记录了下来,并将它命名为提炼函数(106),以便日后可以方便地引用。)

    • 首先, 需要检查一下,如果我将这块代码提炼到自己的一个函数里,有哪些变量会离开原本的作用域。
    • 其次, 如果离开作用域的变量会被提炼后的函数使用,但不会被修改,那么就可以将它们以参数方式传递进来。 不过, 作者更关心哪些离开作用域且会被修改的变量, 对于这种变量, 可以将它从函数中直接返回, 并且可以将其初始化放到提炼后的函数里, 并在原有函数中通过”调用提炼后的函数”来变更原有函数中对此变量的初始化方式。
    • 然后, 做完这个改动后,马上编译并执行一遍测试,看看有无破坏了其他东西。无论每次重构多么简单,养成重构后即运行测试的习惯非常重要。

      这就是重构过程的精髓所在:小步修改,每次修改后就运行测试。如果我改动了太多东西,犯错时就可能陷入麻烦的调试,并为此耗费大把时间。小步修改,以及它带来的频繁反馈,正是防止混乱的关键。

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

  • 我会使用诸如 git 或 mercurial 这样的版本控制系统,因为它们可以支持本地提交。每次成功的重构后我都会提交代码,如果待会不小心搞砸了,我便能轻松回滚到上一个可工作的状态。把代码推送(push)到远端仓库前,我会把零碎的修改压缩成一个更有意义的提交(commit)。

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

    好代码应能清楚地表明它在做什么,而变量命名是代码清晰的关键。只要改名能够提升代码的可读性,那就应该毫不犹豫去做。有好的查找替换工具在手,改名通常并不困难;此外,你的测试以及语言本身的静态类型支持,都可以帮你揪出漏改的地方。如今有了自动化的重构工具,即便要给一个被大量调用的函数改名,通常也不在话下。

  • 重构过程中, 会发现一些临时变量实际是可以通过其它已知变量计算得到的(即”拥有计算属性”), 对于”拥有计算属性”的临时字段(变量), 根本没必要将其作为参数传入, 或者说, 可以在提炼的函数内部通过计算来重新得到它。

  • 当作者分解一个长函数时,喜欢将 “拥有计算属性” 这样的变量给移除掉,因为它们创建了很多具有局部作用域的临时变量,这会使提炼函数更加复杂。这里作者要使用的重构手法是以查询取代临时变量(178)

  • 重构时要记住我们是为了代码的可读性和可维护性做重构, 因此不要过分担心性能会受影响的问题(这是性能优化阶段需要考虑的事情), 因为即便真的有所影响(甚至是严重的影响),后续再对一段结构良好的代码进行性能调优,也容易得多。

    这次重构可能在一些程序员心中敲响警钟:重构前,查找 play 变量的代码在每次循环中只执行了 1 次,而重构后却执行了 3 次。我会在后面探讨重构与性能之间的关系,但现在,我认为这次改动还不太可能对性能有严重影响,即便真的有所影响,后续再对一段结构良好的代码进行性能调优,也容易得多。

  • 移除局部变量的好处就是做提炼时会简单得多,因为需要操心的局部作用域变少了。实际上,在做任何提炼前,作者一般都会先移除局部变量。

  • 对于拥有”计算属性”的临时变量, 如果其之后不会再被修改, 则可以采用内联变量(123)手法内联它。(即 在原本使用此临时变量的地方做更改, 改为直接使用它所对应的计算函数。)

  • 尽管将”函数变量”改变成”函数声明”也是一种重构手法,但作者既未为此手法命名,也未将它纳入重构名录。还有很多的重构手法作者都觉得没那么重要。比如作者觉得之前上面的函数改名的手法既十分简单又不太常用,不值得在重构名录中占有一席之地。

  • 好的命名十分重要,但往往并非唾手可得。只有恰如其分地命名,才能彰显出将大函数分解成小函数的价值。有了好的名称,我就不必通过阅读函数体来了解其行为。但要一次把名取好并不容易,因此我会使用当下能想到最好的那个。如果稍后想到更好的,我就会毫不犹豫地换掉它。通常你需要花几秒钟通读更多代码,才能发现最好的名称是什么。

    作者认为函数命名真正需要强调的是,此函数真正的具体意图,因此作者在修改函数名时往往会选取一个能体现此意图的命名,应用了改变函数声明(124)手法。

  • 有的使用, 某段拥有”计算属性”的字段(变量), 其计算逻辑/过程在一个循环内部, 并且这段循环并不只单独服务与此字段。 此时想要完成重构的话, 第一步,就是应用拆分循环(227)将 字段的计算逻辑/过程 从原有循环中分离出来, 比如新开一个循环。

    • 由于拆分循环的缘故, 有可能使得原有临时变量的声明, 离我们的计算逻辑/过程所在位置过远(影响可读性), 此时可以使用移动语句(223)手法将变量声明挪动到紧邻新循环的位置。
    • 此外, 把临时变量相关的代码都集中到一起,有利于以查询取代临时变量(178)手法的施展。第一步同样是先对变量的计算过程应用提炼函数(106)手法。
    • 完成函数提炼后,再应用内联变量(123)手法内联对应字段(变量)的计算函数至对应位置。

原文引用:

重构至此,让我先暂停一下,谈谈刚刚完成的修改。首先,我知道有些读者会再次对此修改可能带来的性能问题感到担忧,我知道很多人本能地警惕重复的循环。但大多数时候,重复一次这样的循环对性能的影响都可忽略不计。如果你在重构前后进行计时,很可能甚至都注意不到运行速度的变化——通常也确实没什么变化。许多程序员对代码实际的运行路径都所知不足,甚至经验丰富的程序员有时也未能避免。在聪明的编译器、现代的缓存技术面前,我们很多直觉都是不准确的。软件的性能通常只与代码的一小部分相关,改变其他的部分往往对总体性能贡献甚微。

当然,“大多数时候”不等同于“所有时候”。有时,一些重构手法也会显著地影响性能。但即便如此,我通常也不去管它,继续重构,因为有了一份结构良好的代码,回头调优其性能也容易得多。如果我在重构时引入了明显的性能损耗,我后面会花时间进行性能调优。进行调优时,可能会回退我早先做的一些重构——但更多时候,因为重构我可以使用更高效的调优方案。最后我得到的是既整洁又高效的代码。

因此对于重构过程的性能问题,我总体的建议是:大多数情况下可以忽略它。如果重构引入了性能损耗,先完成重构,再做性能优化。

其次,我希望你能注意到:我们移除 带有计算属性的临时变量 的过程是多么小步。整个过程一共有 4 步,每一步都伴随着一次编译、测试以及向本地代码库的提交:

  • 使用拆分循环(227)分离出累加过程;
  • 使用移动语句(223)将累加变量的声明与累加过程集中到一起;
  • 使用提炼函数(106)提炼出计算总数的函数;
  • 使用内联变量(123)完全移除中间变量。

我得坦白,我并非总是如此小步——但在事情变复杂时,我的第一反应就是采用更小的步子。怎样算变复杂呢,就是当重构过程有测试失败而我又无法马上看清问题所在并立即修复时,我就会回滚到最后一次可工作的提交,然后以更小的步子重做。这得益于我如此频繁地提交。特别是与复杂代码打交道时,细小的步子是快速前进的关键。

1.6 拆分计算阶段与可视化阶段

  • 要实现复用有许多种方法,而作者最喜欢的技术是拆分阶段(154)。 比如分为两个阶段, 然后比对两个阶段的差异(一般第二个阶段为原有业务代码, 第一阶段为拆分后要提炼的计算属性-即可复用的逻辑), 最后利用中转数据结构, 将两个阶段联系起来。 最终实现将原有业务逻辑中的可复用逻辑, 完全拆分出来到第一阶段, 或者说逐渐提炼出来。
  • 因此, 要开始拆分阶段(154),作者会先对组成第二阶段的代码应用提炼函数(106), 将需要拆分的可复用逻辑逐步一个一个的提炼至第一阶段。

    在不熟悉 JavaScript 的人看来,result = Object.assign({}, 变量名)的写法可能十分奇怪。它返回的是一个浅副本。虽然作者更希望有个函数来完成此功能,但这个用法已经约定俗成,如果作者自己写个函数,在 JavaScript 程序员看来反而会格格不入。

  • 对于一些第二阶段业务逻辑中原有的计算函数, 我们需要应用搬移函数(198)的方式, 将其变作函数变量供第二阶段使用(替换原有的使用方式), 然后这个原有的计算函数也就被我们轻松的提取到第一阶段了–即变得能够复用。
  • 题外话: 作者会忍不住对一些循环逻辑做重构, 即使用管道取代循环(231)的方式重构它们。
  • 待第二阶段所有可复用代码全部提炼至第一阶段后, 便可继续使用提炼函数(106), 将第一阶段代码提炼到一个独立的函数中, 这样两个阶段便完成了彻底的分离。
  • 由于第一阶段已经彻底分离, 那么就完全可以将这个可复用的第一阶段的独立函数, 抽离到一个单独的文件中供其它代码调用。
  • 此时,在根据第一阶段的独立函数来写一个新的相关需求就变得非常容易了(因为, 此时原有业务需求和新的类似的业务需求之间存在的共同点都被抽离至第一阶段的独立函数中, 因此我们的代码结构是十分清晰的, 并且是越来越偏向模块化的, 更符合高内聚、低耦合的设计规范)。

1.7 分离到两个文件(和两个阶段)

虽然代码的行数增加了,但重构也带来了代码可读性的提高。

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

    作者经常需要在所有可做的重构与添加新特性之间寻找平衡。在当今业界,大多数人面临同样的选择时,似乎多以延缓重构而告终——当然这也是一种选择。作者的观点则与营地法则无异:保证离开时的代码库一定比你来时更加健康。完美的境界很难达到,但应该时时都勤加拂拭。

1.8 按类型重组计算过程

对于新的种类需求, 最好的方法是按类型重组计算过程。

新的类型指的是, 在原有计算属性与函数单侧不变的基础上, 我们需要引入新的计算属性以及单侧来扩展功能, 此时不可避免的要在原有的计算逻辑中加入if-else或switch等条件分支语句来使得我们的原有计算逻辑能够兼容持新的计算需求。–不过作者认为, 这样的分支逻辑很容易随代码堆积而腐坏, 除非编程语言提供了更基础的编程语言元素来防止代码堆积。

  • 基于上述前提作者需要用到多种重构方法,其中最核心的一招是以多态取代条件表达式(272),将多个同样的类型码分支用多态取代。–不过在施展以多态取代条件表达式(272)之前,往往得先创建一个基本的继承结构。(先从检查计算代码开始。(之前的重构带来的一大好处是,现在我大可以忽略那些格式化代码,只要不改变中转数据结构就行。我可以进一步添加测试来保证中转数据结构不会被意外修改。))
  • 创建好一个新的类, 把新计算需求相关的字段以及使用这些字段的主体逻辑函数提取到类中, 可使用改变函数声明(124)搬移函数(198)等手段完成逻辑的提取重构。
  • 确保移植后的代码逻辑能够正常工作后, 应用内联函数(115),让引用点直接调用新函数。(后续通过利用多态,使得即使新增计算需求逻辑, 也不会影响到任何引用点等无关地方的代码修改)
  • 此时所有的相关逻辑都被集成到了一个类中, 就容易将它多态化了。第一步是应用以子类取代类型码(362)引入子类,弃用类型代码。
  • 要得到正确的子类,需要将构造函数调用替换为一个普通的函数调用,因为 JavaScript 的构造函数里无法返回子类。于是我使用以工厂函数取代构造函数(334)。也就是对于原有类中的分支逻辑, 进行子类化, 是不同分支继承于同一父类, 以便通过工厂函数来重写对应分支逻辑, 根据不同的条件创建不同的子类来多态的调用相同名称不同逻辑的函数。(这么做的好处是, 对于原有switch中,多个分支的共用逻辑, 我们可以在父类中的对应函数实现, 从而使增加一个选择分支时, 不必再找到所有的switch, 来逐一更新了。多态给了我们对抗这种黑暗的力量。–基于简单工厂创建类的函数再加一层抽象即可消除一切判断分支。这就是我们常用的工厂模式。)
    • 工厂模式的相关视频(前几年初学时录制的, 有的啰嗦无重点哈, 不过开启倍速的话还是是不影响观看的):

那么继续回到重构。 最后一步完成后, 我们还另外发现, 原有的分支逻辑移动到了工厂函数中, 以不同的子类名称代替原有的对应逻辑, 或者说给不同的逻辑起了不同的别名。 此时虽然代码量便多了, 但是在后续的维护以及阅读过程中, 使得开发人员不必关注不同分支下的具体逻辑, 仅通过不同分支下所new的子类的名字, 就能够直观的知道此分支的作用, 快速理解代码, 更快的定位到需要更改的地方, 而不用在阅读时浪费过多的时间在无关紧要的逻辑理解中–去猜某段逻辑的作用。

因此, 重构过程中, 命名的重要性不言而喻, 所以阅读至此, 我想替作者再次向大家强调一遍以下tips:

好代码应能清楚地表明它在做什么,而变量命名是代码清晰的关键。只要改名能够提升代码的可读性,那就应该毫不犹豫去做。有好的查找替换工具在手,改名通常并不困难;此外,你的测试以及语言本身的静态类型支持,都可以帮你揪出漏改的地方。如今有了自动化的重构工具,即便要给一个被大量调用的函数改名,通常也不在话下。

参考

https://book-refactoring2.ifmicro.com/docs/