重构

91 阅读15分钟
前言

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

需求的变化使得重构变得必要。如果一段代码能正常工作并且不会再被修改,那么完全可以不去重构它。

1、重构第一步
  • 确保即将修改的代码拥有一组可靠的测试。
  • 小步修改,每次修改后就运行测试,在犯错时只需要考虑一个很小的改动范围。这会使得查错与修复问题易于反掌。

好代码的检验标准:

有人需要修改代码时,他们应能轻易找到修改点,应该能快速做出更改而不易引入其他错误。

一个健康的代码库能够最大限度地提升我们的生产力,支持我们更快、更低成本的为用户添加新特性。为了保持代码库的健康,就需要时刻留意现状与理想之间的差距,然后通过重构不断接近这个理想。

2、重构的原则

重构与性能优化的异同点

  • 同:

    • 两者都需要修改代码,并且两者都不会改变程序的整体性能。
  • 异:

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

Kent Beck 提出了”两顶帽子“的比喻:添加新功能和重构。

  • 添加新功能是,不应该修改既有代码,只管添加新功能。通过添加测试并让测试正常运行,我可以衡量自己的工作进度。
  • 重构是就不能再添加新功能,只管调整代码的结构。此时不应该添加任何测试(除非发现先前有遗漏的东西), 只在绝对必要(用以处理接口变化)时才修改测试。
2.2 为何重构
  • 重构改进软件的设计

    • 如果没有重构,程序的内部设计(或者叫架构)会逐渐腐败变质。当人们只为短期目的而修改代码时, 他们经常没有完全理解架构的整体设计,于是代码逐渐失去了自己的结构。改进设计的一个重要方向就是消除重复代码
    • 重构使软件更容易理解
    • 重构帮助找到bug
    • 重构提高编程速度
2.3 何时重构

三次法则:

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

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

  • 重构的最假时机就在添加新功能之前。

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

  • 如果一段代码的条件逻辑的结构很糟糕,也可能希望复用一个函数。但花费了一段时间才能弄懂他到底在做什么,这些都是重构的机会。

捡垃圾式重构

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

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

长期重构

复审代码时重构

2.4 何时不应该重构
  • 如果有一段凌乱的代码,但并不需要修改它,那么就不需要重构。只有需要理解其工作原理时,对其进行重构才有价值。
  • 如果重写比重构还容易,就不需重构了。
2.5 重构的挑战
  • 延缓新功能开发

    • 这种认知是错误的,重构的唯一目的就是让我们开发更快,用更少的工作量创造更大的价值。
  • 分支

    • 每个程序员在自己的分支上开发,可能较长时间才将代码合并到主分支上在 团队内部进行共享。这给重构造成了阻碍。
  • 测试

    • 不会改变程序可观察行为,这是重构的一个重要特性。
    • 如果想要重构,得先有可以自测试的代码。
  • 遗留代码

    • 遗留代码往往很复杂,测试也不足。
  • 数据库

    • 数据库是”重构经常出问题的一个领域“。
3、重构与性能

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

快速编写软件的方法:

  • 时间预算法

    • 这种方法,分解你的设计时就要做好预算,给每个组件预先分配一定资源,包括时间和空间占用。每个组件绝对不能超出自己的预算,就算拥有组件之间调度预配时间的机制也不行。
  • 持续关注法

    • 这种方法要求任何程序员在任何时间做任何事时,都要设法保持系统的高性能21·hi品牌是现代化佳节快乐;托管费的出现 了,
  • 利用百分之九十统计数据。

    • 采用这种方法时,编写构造良好的程序,不对性能投以特别的关注,直至进入性能优化阶段——通常是在开发后期。
4、代码的坏味道
4.1 神秘命名

整洁代码最重要的一环就是好的名字,所以我们会深思熟虑如何给函数、模块、变量和类命名,使他们能清晰地表明自己的功能和用法。

4.2 重复代码

如果在一个以上的地方看到相同的代码结构,那么可以肯定:设法将他们合二为一,程序会变得更好。一旦有重复代码存在,阅读这些重复代码时需加倍仔细,留意其间细微的差异。如果修改重复代码,必须找出所有的副本来修改。

4.3 过长函数

函数越长,越难理解。

每当感觉需要以注释来说明点什么的时候,我们就把需要说明的东西写进一个独立函数中,并以其用途(而非实现手法)命名。

4.4 过长参数列表

使用类可以有效地缩短参数列表。

4.5 全局数据

全局数据的问题在于,从代码库的任何一个角落都可以修改它,而且没有任何机制可以探测出到底哪段代码做出了修改。

4.6 可变数据

对数据的修改经常导致出乎意料的结果和难以发现的bug。我在一处更新数据,却没有意识到软件中的另一处期望着完全不同的数据,于是一个功能失效了——如果故障只是在很罕见的情况下发生,要找出故障原因就会更加困难。

4.7 发散式变化

一旦需要修改,只需要找到系统的某一点,只在该处做修改。

4.8 霰弹式修改

每遇到某种变化,必须在许多不同的类内做出许多小修改。如果需要修改的代码散步四处,不但很难找到它们,也很容易错过某个重要的修改。

4.6 此外还有

依恋情结、数据泥团、基本类型偏执、重复的Switch、循环语句、冗赘的元素、夸夸其谈通用性、临时字段、过长的消息链、过度封装、大量交换数据、过大的类、异曲同工的类、纯数据类等

5、构筑测试体系

要正确的进行重构,前提是得有一套稳固的测试集合,以帮助我们发现难以避免的疏漏。

编写优良的测试程序,可以极大提高我们的编程速度,即使不进行重构也一样如此。

频繁的运行测试。对于你正在处理的代码,与其对应的测试至少每隔几分钟就要运行一次,每天至少运行一次所有的测试。

6、介绍重构名录
6.1 重构的记录格式

重构的标准格式:

  • 名称

    • 要建造一个重构名词汇,名称很重要
  • 速写

    • 可以帮助我们更快找到所需要的重构手法
  • 动机

    • 介绍“为什么需要做这个重构”和“什么情况下不该做这个重构”
  • 做法

    • 简明扼要地一步一步介绍如何进行重构
  • 范例

    • 以一个十分简单的例子说明此重构手法如何运作
6.2 第一组重构

提炼函数、提炼变量、内联函数、内联变量、改变函数声明、封装变量、变量改名、引入参数对象、函数组合成类、函数组合成变换、拆分阶段

6.2.1 提炼函数

  • 曾用名:提炼函数

  • 反向重构:内联函数

  • 动机:

    • 如果你需要花时间浏览一段代码才能弄清楚它到底在干什么,那么就应该将其提炼到一个函数中,并根据他所做的事为其命名。
  • 做法:

    • 创造一个新函数,根据这个函数的意图来对他命名(以他“做什么”来命名,而不是以他”怎样做“命名)。
    • 将待提炼的代码从原函数复制到新建的目标函数中。
    • 仔细检查提炼出的代码,看看其中是否引用了作用域限于源函数、在提炼出的新函数中访问不到的变量。若是,以参数的形式将他们传递给新函数。
    • 所有变量都处理完之后,编译。
    • 在源函数中,将被提炼代码段替换为对目标函数的调用。
    • 测试。
    • 查看其他代码是否有与被提炼的代码段相同或相似之处。如果有,考虑使用以函数调用取代内联代码令其调用提炼出的心函数。 6.2.2 内联函数
  • 曾用名:内联函数

  • 反向重构:提炼函数

  • 动机:

    • 有些函数其内容和其名称同样清晰易读,也可能你重构了该函数的内部实现,使其内容和名称变得同样清晰,那就应该去掉这个函数,直接使用其中的代码。
  • 做法:

    • 检查函数,确定它不具多态性。
    • 找出这个函数的所有调用点。
    • 将这个函数的所有调用点都替换成函数本体。
    • 每次替换之后,执行测试。
    • 删除该函数的定义。 6.2.3 提炼变量
  • 曾用名:引入解释性变量

  • 反向重构:内联变量

  • 动机:

    • 表达式有可能非常复杂而难以阅读。这种情况下,局部变量可以帮助我们将表达式分解为比较容易管理的形式。在面对一块复杂逻辑时,局部变量使我能给其中的一部分命名,这样我就能更好的理解这部分逻辑是要干什么。
  • 做法:

    • 确认要提炼的表达式没有副作用。
    • 声明一个不可修改的变量,把你想要提炼的表达式复制一份,以该表达式的结果值给这个变量赋值。
    • 用这个新变量取代原来的表达式。
    • 测试。 6.2.4 内联变量
  • 曾用名:内联临时变量

  • 反向重构:提炼变量

  • 动机:

    • 在一个函数内部,变量能给表达式提供有意义的名字,因此通常变量是好东西。但有时候,这个名字并不比表达式本身更具有表现力。还有些时候,变量可能会妨碍重构附近的代码。若果真如此,就应该通过内联的手法消除变量。
  • 做法:

    • 检查确认变量赋值语句的右侧表达式没有副作用。
    • 如果变量没有被声明为不可修改,先将其变为不可修改,并执行测试。
    • 找到第一处使用该变量的地方,将其替换为直接使用赋值语句的右侧表达式。
    • 测试。
    • 重复前面两步,逐一替换其他所有使用该变量的地方。
    • 删除该变量的声明点和赋值语句。
    • 测试。 6.2.5 改变函数声明
  • 别名:函数改名

  • 曾用名:函数改名

  • 曾用名:添加参数

  • 曾用名:移除参数

  • 别名:修改签名

  • 动机:

    • 一个好的名字能让我们一眼看出函数的用途,而不必查看其实现代码。
  • 做法:

    • 简单的做法

      • 如果想要移除一个参数,需要先确定函数体内没有使用该参数。
      • 修改函数声明,使其称为你期望的状态。
      • 找出所有使用旧的函数声明的地方,将它们改为使用新的函数声明。
      • 测试
    • 迁移式做法

      • 如果有必要的话,先对函数体内部加以重构,使后面的提炼步骤易于开展。
      • 使用提炼函数将函数体提炼成一个新函数。
      • 如果提炼出的函数需要新增参数,用前面的简单做法添加即可。
      • 测试。
      • 对旧函数使用内联函数。
      • 如果新函数使用了临时的名字,再次使用改变函数声明将其改回原来的名字。
      • 测试。 6.2.6 封装变量
  • 曾用名:自封装字段

  • 曾用名:封装字段

  • 动机:

    • 封装能提供一个清晰地观测点,可以由此监控数据的变化和使用情况,还可以轻松的添加数据被修改时的验证或后续逻辑。
  • 做法:

    • 创建封装函数,在其中访问和更新变量值。
    • 执行静态检查。
    • 逐一修改使用该变量的代码,将其改为调用合适的封装函数。每次替换之后,执行测试。
    • 限制变量的可见性。
    • 测试。
    • 如果变量的值是一个记录,考虑使用封装记录。 6.2.7 变量改名
  • 动机:

    • 好的命名是整洁编程的核心。如果变量名起得好的话,变量可以很好地解释一段程序在干什么。
  • 机制:

    • 如果变量被广泛使用,考虑运用 封装变量将其封装起来。
    • 找出所有使用该变量的代码,逐一修改。
    • 测试。 6.2.8 引入参数对象
  • 动机:

    • 这项重构真正的意义在于,他会催生代码中更深层次的改变。
  • 做法:

    • 如果暂时还没有一个合适的数据结构,就创建一个。
    • 测试。
    • 使用改变函数声明给原来的函数新增一个 参数,类型是新建的数据结构。
    • 测试。
    • 调整所有调用者,传入新数据结构的适当实例。每修改一处,执行测试。
    • 用新数据结构中的每项元素,逐一取代参数列表中与之对应的参数项,然后删除原来的参数。
    • 测试。 6.2.9 函数组合成类
  • 动机:

    • 如果发现一组函数形影不离的操作同一块数据(通常是将这块数据作为参数传递给函数),我就认为,是时候组建一个类了。类能明确的给这些函数提供一个共用的环境,在对象内部调用这些函数可以少传许多参数,从而简化函数调用,并且这样一个对象也可以更方便的传递给系统的其他部分。
    • 除了可以把已有的函数组织起来,这个重构还给我们一个机会,去发现其他的计算逻辑,将它们也重构到新的类当中。
  • 做法:

    • 运用封装记录对多个函数共用的数据记录加以封装。
    • 对于使用该记录结构的每个函数,运用搬移函数将其移入新类。
    • 用以处理该数据记录的逻辑可以用提炼函数提炼出来,并移入新类。 6.2.10 函数组合成变换
  • 动机:

    • 避免计算派生数据的逻辑到处重复。
  • 做法:

    • 创建一个变换函数,输入参数是需要变换的记录,并直接返回该记录的值。
    • 挑选一块逻辑,将其主体移入变换函数中,把结果作为字段添加到输出记录中。
    • 修改客户端代码,令其使用这个新字段。
    • 测试。
    • 针对其他相关的计算逻辑,重复上述步骤。 6.2.11 拆分阶段
  • 动机:

    • 每当看见一段代码都在同时处理两件不同的事情,就想把它拆成各自独立的模块,因为这样到了修改的时候,就可以单独处理每个主题,而不必同时在脑子里考虑两个不同的主题。
  • 做法:

    • 将诶二阶段的代码提炼成独立的函数。
    • 测试。
    • 引入一个中转数据结构,将其作为参数添加到提炼出的新函数的参数列表中。
    • 测试。
    • 逐一检查提炼出的”第二阶段函数“的每个参数。如果某个参数被第一阶段用到,就将其移入中转数据结构。
    • 每次搬移之后都要执行测试。
    • 对第一阶段的代码运用提炼函数,让提炼出的函数返回中转数据结构。