重构学习(三):代码重构的原则

2022/7/24 C重构

# 1 前言

最近因为一些乱七八糟的事情耽搁,所以有一段时间没看《重构:改善既有代码的设计》这本书,可以先看上两篇笔记重温一下:

本文接上两篇笔记,对重构的本质和基本原则做一个总结记录,梳理要点并加深印象。

# 2 重构的定义

重构这个词既可以用作名词也可以用作动词,它们分别的定义如下:

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

简单理解,重构既可以作为一种完善代码的手法(名词),又可以是一种开发者针对代码所作的行为(动词)。

例如:我会花几个小时重构(行为),期间会使用几十个不同的重构(手法)。

重构的关键在于运用大量微小且保持软件行为的步骤,一步步达成大规模的修改。每个单独的重构要么很小,要么由若干小步骤组合而成。因此,在重构的过程中,代码很少会进入不可工作的状态,即便重构没有完成,开发者也可以在任何时刻停下来。

重构与性能优化有很多相似之处:两者都需要修改代码,并且两者都不会改变程序的整体功能。两者的差别在于其目的:

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

# 3 重构开发中的两种行为

使用重构技术开发软件时候,通常有两种常见的行为:

  • 添加新功能:不应该修改既有代码,只管添加新的功能代码,并添加测试代码让其正常运行。
  • 重构:不添加任何功能,只管调整现有代码的结构,不添加任何测试(除非发现之前有遗漏),只在绝对必要时来修改测试,比如接口变化。

在软件开发的过程中,开发者的行为可能在二者之间反复横跳,但开发者必须时刻清楚自己到底在做什么,明白不同行为有不同的要求,避免边开发边重构导致代码混乱、测试无法通过的尴尬情况。

# 4 重构的目的

重构虽然不能包治百病,但它的确很有价值,可以帮我们始终良好地控制自己的代码。重构是一个工具,它可以(并且应该)用于达到以下几个目的。

# 4.1 改进软件的设计

如果没有重构,随着代码迭代(累计效应),程序的内部设计会逐渐腐败变质。当开发者只为短期目的而修改代码时,经常没有完全理解架构的整体设计,于是代码逐渐失去了自己的结构,开发者越来越难通过阅读源码来理解原来的设计。经常性的重构有助于代码维持自己该有的形态

完成同样一件事,设计欠佳的程序往往需要更多代码,这常常是因为代码在不同的地方使用完全相同的语句做同样的事,因此改进设计的一个重要方向就是消除重复代码

注意:代码量减少并不会使系统运行更快,因为这对程序的资源占用几乎没有任何明显影响。然而代码量减少将使未来可能的程序修改动作容易得多。

# 4.2 使软件更容易理解

程序设计的核心就在于开发者对计算机准确说出我想要的。但需要注意的是除了计算机外,源码还有其他读者:几个月之后可能会有另一位开发者尝试读懂我们的代码并对其做一些修改。

我们很容易忘记这这位读者,但他才是最重要的。计算机是否多花了更多的时间来编译,又有什么关系呢?如果一个程序员花费一周时间来修改某段代码,那就得不偿失了。如果他能快速理解这段代码,那么修改可能只需一小时。

重构可以帮我让代码更易读。开始进行重构前,代码可以正常运行,但结构不够理想。在重构上花一点点时间,就可以让代码更好地表达自己的意图,更清晰地表达我想要做的

# 4.3 帮助找到bug

对代码进行重构,通常可以让开发者深入理解代码的所作所为,并立即把新的理解反映在代码当中。搞清楚程序结构的同时,也可以验证了自己所做的一些假设,这样便可以更容易的发现bug。

# 4.4 提高编程速度

重构可以帮助我们更快速地开发程序。听起来有点儿反直觉。根据上两篇笔记的内容,我们很容易看出重构能够提高质量。改善设计、提升可读性、减少bug,这些都能提高质量。但花在重构上的时间,难道不是在降低开发速度吗?

根据我自己的工作经历,在完善一些大型的程序代码时,由于原来的代码设计有缺陷,在新增某些简单的新功能时,不得不去花大量的时间阅读并改善以前的代码逻辑,并且这个过程还会被不断蹦出来的小bug打断,最终代码就像一条打了许多补丁的裤子,虽然能穿(运行),但非常不美观,后续要再添加新功能时,又不得不在补丁上打补丁,代码就会变得越来越难维护,到最后开发者添加新功能可能恨不得从头开始重写整个系统。

《重构》的作者将这种现象称为"设计耐久性假说":通过投入精力改善内部设计,增加了软件的耐久性,从而可以更长时间地保持开发的快速。

# 5 重构的时机

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

事不过三,三则重构

# 5.1 预备性重构

重构的最佳时机就在添加新功能之前。在动手添加新功能之前,可以看看现有的代码库,如果发现对代码结构做一点微调,后续工作更容易,那么就值得事前进行重构。

这就好像我要往东去100公里。我不会往东一头把车开进树林,而是先往北开20公里上高速,然后再向东开100公里。后者的速度比前者要快上3倍。

# 5.2 帮助理解的重构

假如我们先需要先理解一段代码在做什么,然后才能着手修改,那么这个时候就可以考虑能否对这段代码进行重构了。比如,我们可能看见了一段结构糟糕的条件逻辑,也可能希望复用一个函数,但花费了几分钟才弄懂它到底在做什么,因为它的函数命名实在是太糟糕了。这些都是重构的机会。

# 5.3 捡垃圾式重构

帮助理解的重构还有一个变体:我已经理解代码在做什么,但发现它做得不好,例如逻辑不必要地迂回复杂,或者两个函数几乎完全相同,可以用一个参数化的函数取而代之。

这里有一个取舍:当前的任务比较紧急,但我们也不想把垃圾留在原地,给将来的修改增加麻烦。如果发现的垃圾很容易重构,我们可以马上重构它;如果重构需要花一些精力和时间,那么我们可以先把它记录下来,等完成紧急任务后,空闲时再回头重构它。

如果每次经过这段代码时都把它变好一点点,积少成多,垃圾总会被处理干净。重构的妙处就在于,每个小步骤都不会破坏代码——所以,有时一块垃圾在好几个月之后才终于清理干净,但即便每次清理并不完整,代码也不会被破坏。

# 5.4 有计划的重构

上面的例子——预备性重构、帮助理解的重构、捡垃圾式重构都是见机行事的:并不专门安排一段时间来重构,而是在添加功能或修复bug的同时顺便重构。

不管是要添加功能还是修复bug,重构对当下的任务有帮助,而且让未来的工作更轻松。这是一件很重要而又常被误解的事:重构不是与编程割裂的行为

还有一种常见的误解认为,重构就是人们弥补过去的错误或者清理肮脏的代码。当然,如果遇上了肮脏的代码,通常必须重构,但漂亮的代码也需要很多重构。

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

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

无论采用何种方式进行重构,只有当我们真的感到有益时,才值得这样做。

# 5.5 长期重构

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

即便在这样的情况下,也不建议将现有的工作停下来,专门让一支团队做重构。可以让整个团队达成共识,在未来几周时间里逐步解决这个问题。每当有人靠近需要重构的代码,就把它朝想要改进的方向推动一点。

这个策略的好处在于,重构不会破坏代码——每次小改动之后,整个系统仍然照常工作。例如,如果想替换掉一个正在使用的库,可以先引入一层新的抽象,使其兼容新旧两个库的接口。一旦调用方已经完全改为使用这层抽象,替换下面的库就会容易得多。

# 5.6 复审代码时重构

一些公司会做常规的代码复审(code review),因为这种活动可以改善开发状况。

代码复审通常在整个团队中开展,有助于在开发团队中传播知识,帮助更多人理解大型软件系统中的更多代码。像我们部门每个季度开展的培训会就有这种优势,可以让有经验的老员工进行分享,经验较少的新员工可以题出意见。

重构和代码复审二者之间是可以相互推动的,当然这是在复审者和代码作者在一起浏览代码时才有效,因为作者能提供关于代 码的上下文信息,并且充分认同复审者进行修改的意图。

# 5.7 何时不应该重构

如果有一块凌乱的代码,但并不需要修改它,那么就不需要重构它。如果丑陋的代码能被隐藏在一个API之下,也可以容忍它继续保持丑陋。只有当我们需要理解其工作原理时,对其进行重构才有价值

还有另一种情况,如果重写比重构还容易,就别重构了。

# 6 重构面临的阻力

当我们对代码进行重构时,整个过程并非没有代价,只要修改代码那么就肯定会面临取舍,何时运用、怎么运用 重构是我们在开发过程过程必须进行思考和判断的,需要充分了解重构面临的阻力,才能帮助我们做出有效的判断。

# 6.1 延缓新功能开发

根据上文,我们可以清楚的知道尽管重构的目的是加快开发速度,但是,仍旧很多人认为,花在重构的时间是在拖慢新功能的开发进度。

虽然重构的唯一目的就是让我们开发更快,用更少的工作量创造更大的价值。但在某些情况下,即使现有代码非常需要重构,我们仍然迫切的需要添加新的功能,这时面临的权衡取舍就需要根据实际的工作情况来看了。

准确的说就是综合自己的职级、任务安排以及领导的意思来决定。

# 6.2 代码所有权

很多重构手法不仅会影响一个模块内部,还会影响该模块与系统其他部分的关系。比如我想给一个函数改名,并且我也能找到该函数的所有调用者,那么我只需在一次重构中修改函数声明和调用者。但即便这么简单的一个重构,有时也无法实施:调用方代码可能由另一支团队拥有,而我没有权限写入他们的代码库。

这个函数也可能是一个提供给客户的API,这时我根本无法知道是否有人使用它,至于谁在用、用得有多频繁就更是一无所知。这样的函数属于已发布接口(published interface):接口的使用者与声明者彼此独立,声明者无权修改使用者的代码。

代码所有权的边界会妨碍重构,因为一旦开发者自作主张地修改,就一定会破坏使用者的程序。

这不会完全阻止重构,我们仍然可以做很多重构,但确实会对重构造成约束。为了给一个函数改名,我必须同时也要保留原来的函数声明,使其把调用传递给新的函数。这会让接口变复杂,但这就是为了避免破坏使用者的系统而不得不付出的代价。此时可以把旧的接口标记为"不推荐使用"(deprecated),等一段时间之后最终让其退休;但有些时候,旧的接口必须一直保留下去。

# 6.3 分支

如果使用过 Git 那么应该对分支很熟悉,很多团队采用 Git 这样的版本控制实践:每个团队成员各自在代码库的一条分支上工作,进行相当大量的开发之后,才把各自的修改合并回主线分支(这条分支通常叫master),从而与整个团队分享。

常见的做法是在分支上开发完整的功能,直到功能可以发布到生产环境,才把该分支合并回主线。这样能保持主线不受尚未完成的代码侵扰,能保留清晰的功能添加的版本记录,并且在某个功能出问题时能容易地撤销修改。

其缺点也很明显:在隔离的分支上工作得越久,将完成的工作合并到主线就会越困难。

如果重构发生在分支上,则必须频繁的去合并分支,如果同时还有其他开发者在其他分支上添加新的功能,那么最后合并又会增加额外的工作量。

# 6.4 测试

不会改变程序可观察的行为,这是重构的一个重要特征。如果仔细遵循重构手法的每个步骤,开发者应该不会破坏任 何东西,但万一犯了个错误怎么办?人总会有出错的时候,不过只要及时发现,就不会造成大问题。

这里的关键就在于"快速发现错误"。要做到这一点,代码应该有一套完备的测试套件,并且运行速度要快,否则开发者可能不愿意频繁运行它。也就是说,绝大多数情况下,如果想要重构,则得先有可以自动化的测试代码。而编写这些测试代码又会耗费时间。

# 6.5 遗留代码

大多数人会觉得,有一大笔遗产是件好事,但从程序员的角度来看就不同了。遗留代码往往很复杂,测试又不足,而且最关键的是,是别人写的,我们通常要花费时间去理解,并且为其编写测试代码,如果代码量大而且写得还烂的话那就更惨了,这种工作通常大部分开发者都避之不及。

对于这类代码,通常秉持的原则就是:没测试就加测试,然后再逐步重构。

# 7 重构与软件架构

早期观点:在任何人开始写代码之前,必须先完成软件的设计和架构。一旦代码写出来,架构就固定了,只会随着迭代逐渐腐败。

重构改变了这种观点。有了重构技术,即便是已经在生产环境中运行了多年的软件,我们也有能力大幅度修改其架构。重构可以改善既有代码的设计。但修改遗留代码阻力非常大,尤其当遗留代码缺乏恰当的测试时。

重构对架构最大的影响在于,通过重构,我们能得到一个设计良好的代码库,使其能够优雅地应对不断变化的需求。

# 8 重构与软件开发过程

重构是否有效,与团队采用的其他软件开发实践紧密相关。如果一支团队想要重构,那么每个团队成员都需要掌握重构技能,能在需要时开展重构,而不会干扰其他人的工作。

在持续集成下,每个成员的重构都能快速分享给其他同事,不会发生这边在调用一个接口那边却已把这个接口删掉的情况;如果一次重构会影响别人的工作,我们很快就会知道。自测试的代码也是持续集成的关键环节,所以三大实践:自测试代码、持续集成、重构彼此之间有着很强的协同效应。

# 9 重构与性能

重构可能使软件运行更慢,但它也使软件的性能优化更容易。除了对性能有严格要求的实时系统,其他任何情况下"编写快速软件"的秘密就是:先写出可调优的软件,然后调优它以求获得足够的速度。

提升性能的三种方法:

# 9.1 时间预算法

这通常只用于性能要求极高的实时系统。如果使用这种方法,分解你的设计时就要做好预算,给每个组件预先分配一定资源,包括时间和空间占用。每个组件绝对不能超出自己的预算,就算拥有组件之间调度预配时间的机制也不行。

# 9.2 持续关注法

这种方法要求任何程序员在任何时间做任何事时,都要设法保持系统的高性能。这种方式很常见,感觉也很棒,但通常不会起太大作用。

任何修改如果是为了提高性能,通常会使程序难以维护,继而减缓开发速度。如果最终得到的软件的确更快了,那么这点损失尚有所值,可惜通常事与愿违,因为性能改善一旦被分散到程序各个角落,每次改善都只不过是从对程序行为的一个狭隘视角出发而已,而且常常伴随着对编译器、运行时环境和硬件行为的误解。

# 9.3 分析统计数据

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

如果因为缺乏对程序的清楚认识而花费时间,那些时间就都被浪费掉了。

因此我们在编写写构造良好的程序时,可以不对性能投以特别的关注,直至进入性能优化阶段(那通常是在开发后期)。一旦进入该阶段,我们再遵循特定的流程来调优程序性能。

在性能优化阶段,首先应该用一个度量工具来监控程序的运行,让它告诉我们程序中哪些地方大量消耗时间和空间。这样就可以找出性能热点所在的一小段代码。然后我们应该集中关注这些性能热点,并使用持续关注法中的优化手段来优化它们。由于把注意力都集中在热点上,较少的工作量便可显现较好的成果。

短期看来,重构的确可能使软件变慢,但它使优化阶段的软件性能调优更容易,最终还是会得到好的效果。

# 10 自动化重构

作者在书中介绍了许多重构工具,比如 IntelliJ IDEA、Eclipse 内置的工具、Refactoring Browser、Resharper 等等。但目前,我自己还没有用到过自动化重构工具,基本都是手动重构,这一块需要后面不断摸索的一部分。

自动化重构工具的核心其实就是理解和修改语法树并且还要知道如何把修改后的代码写回编辑器视图。如今的编辑器和开发工具中常能找到一些对重构的支持,不过真实的重构能力各有高低。重构能力的差异既有工具的原因,也受限于不同语言对自动化重构的支持程度。

虽然使用自动化重构工具很方便,但这也需要配套的测试模块,让我们能对其进行验证,目前来讲完全信任自动化重构工具还为时过早。