代码重构

最近由于项目需要准备多现有项目进行二次重构,整理了一些关于重构的知识。

主要参考的是 Martin Fowler 的《重构:改善既有代码设计》和极客时间的王争在《设计模式之美》中关于重构的介绍。

一、重构的原则

什么是重构

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

重构是这样一个过程:它在一个目前可运行的程序上进行,企图在「不改变程序行为」的情况下(1)容易理解;(2)所有逻辑都只在唯一地点指定;(3)新的改动不会危及现有行为;(4)尽可能简单表达条件逻辑(conditional logic),使我们能够继续保持高速开发,从而增加程序的价值。

为什么重构

  • 改进软件设计
  • 使软件更易被理解
  • 助你找到bugs
  • 提高编程速度

何时重构

  • 三次法则〔The Rule of Three〕
    • Don Roberts 给了一条准则:
      • 第一次做某件事时只管去做;
      • 第二次做类似的事会产生反感,但无论如何还是做了;
      • 第三次再做类似的事,你就应该重构。
    • Tip: 事不过三,三则重构(Three strikes and you refactor)
  • 添加功能时一并重构
    • 帮助理解
    • 代码的设计无法帮助我轻松添加我所需要的特性
    • 新特性的添加就会更快速、更流畅。
  • 修补错误吋一并重构
  • 复审代码吋一并重构
  • 为什么重构有用
    • 原因
      • 难以阅读的程序,难以修改。
      • 逻辑复杂(duplicated logic)的程序,难以修改。
      • 添加新行为时需要修改既有代码者,难以修改。
      • 带复杂条件逻辑(complex conditional logic)的程序,难以修改。
    • 方法
      • 容易理解
      • 所有逻辑都只在唯一地点指定
      • 新的改动不会危及现有行为
      • 尽可能简单表达条件逻辑

如何发现代码质量问题

常规checklist

  • 目录设置是否合理、模块划分是否清晰、代码结构是否满足“高内聚、松耦合”?
  • 是否遵循经典的设计原则和设计思想(SOLID、DRY、KISS、YAGNI、LOD等)?
  • 设计模式是否使用得当?是否有过度设计?
  • 代码是否容易扩展?如果要添加新功能,是否容易实现?
  • 代码是否可以复用?是否可以复用已有的项目代码或者类库?是否有重复造轮子?
  • 代码是否容易测试?单元测试是否全面覆盖各种正常和异常的情况?
  • 代码是否易读?是否符合编码规范(比如命名和注释是否使用得当、代码风格是否一致)?

业务需求checklist

  • 代码是否实现了预期的业务需求?
  • 逻辑是否正确?是否处理了各种异常情况?
  • 日志打印是否得当?是否方便debug排查问题?
  • 接口是否易用?是否支持幂等事务等
  • 代码是否存在并发问题?是否线程安全?
  • 性能是否有优化空间,比如,SQL、算法是否可以优化?
  • 是否有安全漏洞?比如,输入输出校验是否全面?

幂等:就是用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用。

知会经理

不要告诉经理!

重构的难题

  • 数据库(Database)
  • 修改接口(Changing Interfaces)
  • 难以通过重构手法完成的设计改动
  • 何吋不该重构?——有时候既有代码实在太混乱,重构它还不如重新写一个来得简单

重构与设计

  • 「事先设计」(upfront design)可 以助我节省回头工的高昂成本。
  • 哪怕你完全了解系统,也请实际量测它的性能,不要臆测。臆测会让你学到一些东西,但十有八九你是错的。

重构与性能(Performance)

  • 三种「编写快速软件」的方法
    • 「时间预算法」(time budgeting)——性能要求极高的实时系统
    • 「持续关切法」( constant attention)——设法保持系统的高性能
    • 「良好的分解方式」(well-factored manner)
      • 不对性能投以任何关切,直至进入性能优化阶段——那通常是在开发后期
      • 有比较充裕的时间进行性能调整(performance tuning)
      • 在进行性能分析时便有较细的粒度(granularity)

重构这个概念起源何处?

Ward Cunningham 和 Kent Beck 的 Smalltalk

二、单元测试

什么是单元测试?

单元测试是代码层面的测试,由研发自己来编写,用于测试“自己”编写的代码的逻辑的正 确性。单元测试顾名思义是测试一个“单元”,有别于集成测试,这个“单元”一般是类或 函数,而不是模块或者系统。

为什么要写单元测试?

写单元测试的过程本身就是代码 Code Review 和重构的过程,能有效地发现代码中的 bug 和代码设计上的问题。除此之外,单元测试还是对集成测试的有力补充,还能帮助我们快速 熟悉代码,是 TDD 可落地执行的改进方案。

如何编写单元测试?

写单元测试就是针对代码设计各种测试用例,以覆盖各种输入、异常、边界情况,并将其翻 译成代码。我们可以利用一些测试框架来简化单元测试的编写。除此之外,对于单元测试, 我们需要建立以下正确的认知:

单元测试为何难落地执行?

一方面,写单元测试本身比较繁琐,技术挑战不大,很多程序员不愿意去写;另一方面,国 内研发比较偏向“快、糙、猛”,容易因为开发进度紧,导致单元测试的执行虎头蛇尾。最 后,关键问题还是团队没有建立对单元测试正确的认识,觉得可有可无,单靠督促很难执行 得很好。 编写单元测试尽管繁琐,但并不是太耗时; 我们可以稍微放低对单元测试代码质量的要求; 覆盖率作为衡量单元测试质量的唯一标准是不合理的; 单元测试不要依赖被测代码的具体实现逻辑; 单元测试框架无法测试,多半是因为代码的可测试性不好。

三、可测试性

什么是代码的可测试性?

粗略地讲,所谓代码的可测试性,就是针对代码编写单元测试的难易程度。对于一段代码, 如果很难为其编写单元测试,或者单元测试写起来很费劲,需要依靠单元测试框架中很高级 的特性,那往往就意味着代码设计得不够合理,代码的可测试性不好。

编写可测试性代码的最有效手段

依赖注入是编写可测试性代码的最有效手段。通过依赖注入,我们在编写单元测试的时候, 可以通过 mock 的方法解依赖外部服务,这也是我们在编写单元测试的过程中最有技术挑 战的地方。

常见的 Anti-Patterns

常见的测试不友好的代码有下面这 5 种:

  • 代码中包含未决行为逻辑
  • 滥用可变全局变量
  • 滥用静态方法
  • 使用复杂的继承关系
  • 高度耦合的代码

四、大型重构——解耦

“解耦”为何如此重要?

过于复杂的代码往往在可读性、可维护性上都不友好。解耦保证代码松耦合、高内聚,是控 制代码复杂度的有效手段。代码高内聚、松耦合,也就是意味着,代码结构清晰、分层模块 化合理、依赖关系简单、模块或类之间的耦合小,那代码整体的质量就不会差。

代码是否需要“解耦”?

间接的衡量标准有很多,比如,看修改代码是否牵一发而动全身。直接的衡量标准是把模块 与模块、类与类之间的依赖关系画出来,根据依赖关系图的复杂性来判断是否需要解耦重 构。

如何给代码“解耦”?

给代码解耦的方法有:封装与抽象、中间层、模块化,以及一些其他的设计思想与原则,比 如:单一职责原则、基于接口而非实现编程、依赖注入、多用组合少用继承、迪米特法则 等。当然,还有一些设计模式,比如观察者模式。

五、重构的建议

  • 关于命名

    命名的关键是能准确达意。对于不同作用域的命名,我们可以适当地选择不同的长度。
    作用域小的变量(比如临时变量),可以适当地选择短一些的命名方式。
    除此之外,命名中也可以使用一些耳熟能详的缩写。
    我们可以借助类的信息来简化属性、函数的命名,利用函数的信息来简化函数参数的命 名。 命名要可读、可搜索。不要使用生僻的、不好读的英文单词来命名。
    除此之外,命名要符合项目的统一规范,不要用些反直觉的命名。
    接口有两种命名方式:一种是在接口中带前缀“I”;另一种是在接口的实现类中带后缀“Impl”。
    对于抽象类的命名,也有两种方式,一种是带上前缀“Abstract”,一种是不带前缀。这两种命名方式都可以,关键是要在项目中统一。

  • 关于注释

    注释的目的就是让代码更容易看懂。只要符合这个要求的内容,你就可以将它写到注释里。
    总结一下,注释的内容主要包含这样三个方面:做什么、为什么、怎么做。对于一些复杂的类和接口,我们可能还需要写明“如何用”。
    注释本身有一定的维护成本,所以并非越多越好。
    类和函数一定要写注释,而且要写得尽可能全面、详细,而函数内部的注释要相对少一些,一般都是靠好的命名、提炼函数、解释性变量、总结性注释来提高代码可读性。

  • 函数、类多大才合适?

    函数的代码行数不要超过一屏幕的大小,比如 50 行。
    类的大小限制比较难确定。

  • 一行代码多长最合适?

    最好不要超过 IDE 显示的宽度。当然,限制也不能太小,太小会导致很多稍微长点的语句被折成两行,也会影响到代码的整洁,不利于阅读。

  • 善用空行分割单元块

    对于比较长的函数,为了让逻辑更加清晰,可以使用空行来分割各个代码块。
    在类内部,成员变量与函数之间、静态成员变量与普通成员变量之间、函数之间,甚至成员变量之间,都可以通过添加空行的方式,让不同模块的代码之间的界限更加明确。

  • 四格缩进还是两格缩进?

    王争比较推荐使用两格缩进,这样可以节省空间,特别是在代码嵌套层次比较深的情况下。
    除此之外,值得强调的是,不管是用两格缩进还是四格缩进,一定不要用 tab 键缩进。

  • 大括号是否要另起一行?

    比较推荐将大括号放到跟上一条语句同一行的风格,这样可以节省代码行数。
    但是,将大括号另起一行,也有它的优势,那就是,左右括号可以垂直对齐,哪些代码属于哪一个代码块,更加一目了然。

  • 类中成员的排列顺序

    在 Google Java 编程规范中,依赖类按照字母序从小到大排列。类中先写成员变量后写函数。
    成员变量之间或函数之间,先写静态成员变量或函数,后写普通变量或函数,并且按照作用域大小依次排列。
    代码风格都没有对错和优劣之分,只要能在团队、项目中统一即可,不过,最好能跟业内推荐的风格、开源项目的代码风格相一致。

  • 关于编码技巧

    将复杂的逻辑提炼拆分成函数和类。
    通过拆分成多个函数或将参数封装为对象的方式,来处理参数过多的情况。
    函数中不要使用参数来做代码执行逻辑的控制。
    函数设计要职责单一。

  • 统一编码规范

    项目、团队,甚至公司,一定要制定统一的编码规范,并且通过 Code Review 督促执行,这对提高代码质量有立竿见影的效果。
    移除过深的嵌套层次,方法包括:去掉多余的 if 或 else 语句,使用 continue、break、return 关键字提前退出嵌套,调整执行顺序来减少嵌套,将部分嵌套逻辑抽象成函数。
    用字面常量取代魔法数。

  • 建立一组可靠的测试环境。

    这些测试必须有自我检验(self-checking)能力。

  • 分解并重组长函数

    以微小的步伐修改程序。如果你犯下错误,很容易便可发现它。
    修改变量名
    搬移函数位置
    去除临时变量 Replace Temp with Query

  • 运用多态(Polymorphism)取代相关的条件逻辑

六、错误码

对于函数出错返回数据类型,王争总结了 4 种情况,它们分别是:错误码、NULL 值、空对 象、异常对象。

  1. 返回错误码
    C 语言没有异常这样的语法机制,返回错误码便是最常用的出错处理方式。而 Java、 Python 等比较新的编程语言中,大部分情况下,我们都用异常来处理函数出错的情况,极 少会用到错误码。
  2. 返回 NULL 值
    在多数编程语言中,我们用 NULL 来表示“不存在”这种语义。对于查找函数来说,数据 不存在并非一种异常情况,是一种正常行为,所以返回表示不存在语义的 NULL 值比返回 异常更加合理。
  3. 返回空对象
    返回 NULL 值有各种弊端,对此有一个比较经典的应对策略,那就是应用空对象设计模 式。当函数返回的数据是字符串类型或者集合类型的时候,我们可以用空字符串或空集合替 代 NULL 值,来表示不存在的情况。这样,我们在使用函数的时候,就可以不用做 NULL 值判断。
  4. 抛出异常对象
    尽管前面讲了很多函数出错的返回数据类型,但是,最常用的函数出错处理方式是抛出异 常。异常有两种类型:受检异常和非受检异常。 对于应该用受检异常还是非受检异常,网上的争论有很多,但也并没有一个非常强有力的理 由,说明一个就一定比另一个更好。
    所以,我们只需要根据团队的开发习惯,在同一个项目 中,制定统一的异常处理规范即可。 对于函数抛出的异常,我们有三种处理方法:直接吞掉、直接往上抛出、包裹成新的异常抛出。