《Code Complete》ch.24 重构

时间:2021-04-19 23:36:08

WHAT?

重构(refactoring),Martin Fowler将其定义为“在不改变软件外部行为的前提下,对其内部结构进行改变,使之更容易理解并便于修改”。

WHY?

  • 神话:一个管理很完善的软件项目,应该首先以系统化的方法进行需求开发,定义一份严谨的列表来描述程序的功能。设计完全遵循需求,并且完成的相当仔细,这样就让程序员的代码编写工作从头到尾直线型地工作。表明绝大多数代码首次编写后就已完美,测试通过即可被抛诸脑后。代码被修改的惟一时机发生在交付使用后在新版本进行功能的添加
  • 现实:在初始开发阶段,代码会有实质性的进化。在初始的代码编写过程中,就会有很多剧烈的改变。即使是管理完善的项目,每个月都有大概1/4的需求发生变化,这不可避免地导致相关代码的改变——有时候是实质性的代码改变

重构的理由

  • 代码重复:无论何时你需要对一处进行修改,你都不得不对另一处进行相同的修改,“复制粘贴即为设计之谬”
  • 冗长的子程序:见到长度超过一屏的子程序就有种莫名的烦躁感
  • 循环过长或嵌套过深:循环内的代码往往有成为子程序的潜质
  • 内聚性太差的类:如果某个类包揽了太多太多任务,要考虑将它拆分为多个各司其职的类
  • 类的接口未能提供一致的抽象层次:多人维护的结果是造就了一只Frankenstein
  • 子程序的参数列表过长:不要超过7个葫芦娃
  • 类的内部修改往往被局限于某个部分:如果对于某个类的修改要么在这个部分,要么在那个部分,很少有同时修改两个部分的情况,这表明该类至少应该根据功能被拆分为两个类
  • 变化导致多个类的相同修改:这些类中的代码应该重新组织,使改动只影响到其中的一个类
  • 对继承体系的同样修改:每次对某个类添加派生类时,发现自己都不得不为另一个类进行相同的操作
  • 同时使用的相关数据并没有以类的形式进行组织
  • 成员函数使用其它类的特征比使用自身类还多:是时候考虑将函数转移到正确的类当中了
  • 某个类无所事事:如果一个类看起来名不副实,就将它的功能转交给其他类,然后干掉它
  • 一系列传递流浪数据的子程序:看看你的代码,把数据传给某个子程序,是否就只是为了让该子程序把数据转交给另一个子程序?这样被玩来玩去的数据称为“流浪数据/tramp data”,这时需要看看各个子程序接口的抽象概念是否一致
  • 中间人对象无事可做:如果某个类中绝大部分代码都只是去调用其它类中的成员函数,就考虑是否把这个middleman去掉,转而直接调用其它类
  • 某个类同其他类关系过于亲密:封装是强有力的工具,如果发现一个类对于她的小伙伴了解超过了应有的程度——包括派生类过度了解了基类中的内容,do it
  • 子程序命名不当:长痛不如短痛
  • 某个派生类只使用了基类的很少一部分成员函数:这样的情况表明这个派生类被创建出来仅仅是因为基类恰好有她所需要的某个函数,而不是逻辑上强烈的派生关系,可以用“has-a”来代替“is-a”
  • 为了解释糟糕的代码而存在的大段注释:不要为拙劣的代码编写文档——应当重写代码
  • 使用了全局变量:访问器子程序是个好的替代方案
  • 在子程序调用前使用了设置代码(setup code),或者在调用后使用了收尾代码(takedown code):下面的代码是个反面例子——为了调用子程序而特意实例化一个对象是不对的,为什么子程序的参数要使用一个对象呢?
    Contract contract;
    contract.setName("上海餐饮合同");
    contract.setPayRate(5);
    contract.setApproved(false);
    addContract(contract);
  • 程序中一些代码是为了未来某个时候才用到的:over-design不可取,这种猜测发生变数太多了;与其去准备那些没有发生的需求,不如尽力把当前代码写得清晰直白,使未来的程序员(包括你自己)在理解时不费力

HOW?

数据级的重构/Data-Level Refactorings

  • 用具名常量代替Magic Number:无论是数字还是字符串,都不应该以神秘数字的形式出现在代码中
  • 使变量的名字更清晰且传递更多信息
  • 将表达式内联化:把一个中间变量换成给他赋值的表达式本身(去除过多的中间变量)
  • 用函数来替代表达式
  • 引入中间变量:将表达式的值赋给中间变量(干!与上上条需要斟酌着使用)
  • 用多个单一用途的变量替换一个多用途的变量:如果你有一个多功能的x先后扮演了多个角色,去招聘更多演员吧
  • 在局部用途中使用局部变量而不是参数:尤其是当你需要改动参数值的时候,在java中可以用final关键字限定参数不可修改,但如果传入的是一个对象……
  • 将基础数据类型转化为类:若一个基础数据类型需要更多的操作或额外的数据,如Money、Temperature等
  • 将一组类型码转换为类:见下面的例子
    // before refactoring
    public static final int OUTPUT_SCREEN = 10;
    public static final int OUTPUT_FILE = 20;
    public static final int OUTPUT_PRINTER = 30; // after refactoring
    public class OutputType {
      public static final int SCREEN = 10;
      public static final int FILE = 20;
      public static final int PRINTER = 30;
    }
  • 将一组类型码转换为一个基类及其派生类:如以上例子,可有Screen、File、Printer三个派生类
  • 将数组转化为对象:若正在使用一个数组,且其中不同的成员有不同的类型,不妨建立一个对象代替此数组
  • 把群集(collection)封装起来:到处散布的多个群集实例会带来同步问题,请让你的类返回一个只读群集,并提供访问器子程序用于添加/删除成员
  • 用数据类来代替传统记录:建立一个包含记录成员的类

语句级的重构/Statement-Level Refactorings

  • 分解布尔表达式:通过引入命名准确的中间变量,帮助理解布尔表达式
  • 将复杂的布尔表达式转化为明明准确的布尔函数:提高复杂表达式的可读性,便于重用
  • 合并条件语句不同部分中的重复代码片段:若在if/else的block中有着相同的代码,那么把它从block中移到外面
  • 使用break或return而不是循环控制变量:不要通过stop等变量来判断循环结束
  • 用多态来替换条件语句(尤其是重复的case语句):case语句中的逻辑可以放到继承关系里,通过多态调用函数来实现
  • 在嵌套的if-then-else语句中,一旦知道答案就立即返回:而不是再啰哩啰嗦的设置一个返回值,经过一系列判断后再返回
  • 创建和使用null对象而不是去检测空值:把处理null值的功能从客户代码中抽离开来,放入相应的类中

子程序级的重构/Routine-Level Refactorings

  • 提炼子程序或方法:避免重复
  • 将子程序的代码内联化:与上一条恰好相反,若子程序本体非常简单且含义不言自明,不妨直接使用这些代码
  • 将冗长的子程序转化为类:从而改善代码的可读性
  • 用简单的算法替换复杂的算法:写出只有自己读得懂的代码并不是件光彩的事
  • 增加参数/删除参数
  • 将查询操作从修改操作中独立出来:一个方法只做一件事情
  • 合并相似的子程序,通过参数区分它们的功能:如果两个子程序知识用到的常量不同,不妨把常量作为参数传入
  • 将行为取决于参数的子程序拆分出来:别试图用参数中的标识位控制子程序行为
  • 传递整个对象而非特定成员:如果发现某个对象的多个特定成员被取出作为某个方法的参数,为什么不直接用这个对象呢
  • 传递特定成员而非整个对象:如果发现某个对象被创建出来只是为了传入方法作为参数,为什么不让方法直接获取对象中的特定成员作为参数呢
  • 包装向下转型的操作:当子程序返回一个对象时,应该返回已知的最精确的对象,尤其适用于Iterator、Collections

类实现的重构/Class Implementation Refactorings

  • 将值对象转化为引用对象:如果发现自己正在维护一个超大的复杂的对象,那么不妨在用到的地方都采用引用的方式
  • 将引用对象转化为值对象:如果对某个小型的简单对象进行了多次引用操作,也可以直接用值对象
  • 用数据初始化代替虚函数:与其在多个派生类中覆盖成员函数,不如让派生类在初始化时设定适当的常量值,然后使用基类中的通用代码处理这些值
  • 改变成员函数或成员变量的位置:将子程序/成员/构造函数 上移到基类/下移到派生类
  • 将特殊代码提取为派生类:如果某类中的部分代码仅仅对其一部分实例有用,应该把这部分代码放到派生类中
  • 将相似的代码结合起来放入基类:与上一条相反

类接口的重构/Class Interface Refactorings

  • 将成员函数移动到另一个类中:在目标类中创建一个新的成员函数,然后在原类中把函数体移动到目标类中,最后在原类中调用目标类的函数
  • 将一个类变为两个:如果一个类同时具备了两种截然不同的功能
  • 删除类:当他无所事事时
  • 去除委托关系:防止越级调用
  • 去掉中间人:before A->B->C,after A->C
  • 用“has-a”代替“is-a”:并不需要公开基类的全部成员函数
  • 用“is-a”代替“has-a”:需要公开基类的全部成员函数
  • 对暴露在外的成员变量进行封装:将数据成员改为私用,并提供访问器程序
  • 对于不能修改的成员,删除set()函数
  • 隐藏那些不会在类外面被用到的成员函数
  • 合并那些实现非常类似的基类和派生类

系统级重构/System-Level Refactorings

  • 为无法控制的数据创建明确的索引源:将数据组织为一体
  • 将单向的类联系改为双向的类联系:两个类需要彼此用到对方的功能
  • 将双向的类联系改为单向的类联系:实际上只有一个类需要访问另一个类
  • 用Fatory模式而并非简单的构造函数:在需要基于类型码创建对象,或者希望使用引用对象而非值对象的时候,需要使用工厂模式
  • 用异常机制代替错误码,或者做相反的替换:从实际需求出发,取决于错误处理策略

安全滴进行重构

刀很锋利。

这样的刀,割谁的头,都不会有一丝滞涩。

无论是别人的头,还是自己的头。

  • 保存初始代码:借助于成熟的VCS
  • 重构的步伐请小一些:小步快跑
  • 同一时间只做一项重构:同上一条
  • 把要做的事情一件件列出来:维护一份重构列表会让你清楚有哪些事情已经做了,有哪些事情急需去做
  • 多使用检查点:当你不小心把事情搞砸时,可以很快地恢复到上个可以工作的版本
  • 重新测试:保持一套优秀的测试用例
  • 在重构中实时增加/删除/修改测试用例
  • 代码审查
  • 根据重构的风险级别调整重构方法:像是“重构那些Magic Number”显然不需要投入过多的测试精力,更不需要进行code review;当涉及到类、接口、数据库构架的改变时,就要慎重了

重构策略

  • 在增加子程序时重构
  • 在增加类的时候进行重构
  • 在修补缺陷时进行重构
  • 关注易于出错的模块
  • 关注复杂的模块
  • 定义清楚干净代码和拙劣代码的边界,然后让代码跨越这个边界