重构改善既有的代码质量

domain-driven design DDD 领域驱动设计

  • 如何重构

    • SOLID原则
    • 设计模式
    • 代码分层
    • 命名规范
    • 重构技巧
  • 质量如何保证

    • 测试驱动开发
    • TDD的开发周期
    • 两个基本的原则
    • 分层测试点

大型重构

对顶层代码设计的重构,包括:系统、模块、代码结构、类与类之间的关系等的重构,重构的手段有:分层、模块化、解耦、抽象可复用组件等等。这类重构的工具就是我们学习过的那些设计思想、原则和模式。这类重构涉及的代码改动会比较多,影响面会比较大,所以难度也较大,耗时会比较长,引入bug的风险也会相对比较大。

小型重构

对代码细节的重构,主要是针对类、函数、变量等代码级别的重构,比如规范命名和注释、消除超大类或函数、提取重复代码等等。小型重构更多的是使用统一的编码规范。这类重构要修改的地方比较集中,比较简单,可操作性较强,耗时会比较短,引入bug的风险相对来说也会比较小。什么时候重构 新功能开发、修bug或者代码review中出现“代码坏味道”,我们就应该及时进行重构。持续在日常开发中进行小重构,能够降低重构和测试的成本。

代码重复

  • 实现逻辑相同、执行流程相同

方法过长

  • 方法中的语句不在同一个抽象层级
  • 逻辑难以理解,需要大量的注释
  • 面向过程编程而非面向对象

过大的类

  • 类做了太多的事情
  • 包含过多的实例变量和方法
  • 类的命名不足以描述所做的事情

逻辑分散

  • 发散式变化:某个类经常因为不同的原因在不同的方向上发生变化
  • 散弹式修改:发生某种变化时,需要在多个类中做修改

严重的情结依恋

  • 某个类的方法过多的使用其他类的成员

数据泥团/基本类型偏执

  • 两个类、方法签名中包含相同的字段或参数
  • 应该使用类但使用基本类型,比如表示数值与币种的Money类、起始值与结束值的Range类

不合理的继承体系

  • 继承打破了封装性,子类依赖其父类中特定功能的实现细节
  • 子类必须跟着其父类的更新而演变,除非父类是专门为了扩展而设计,并且有很好的文档说明

过多的条件判断

过长的参数列

临时变量过多

令人迷惑的暂时字段

  • 某个实例变量仅为某种特定情况而设置
  • 将实例变量与相应的方法提取到新的类中

纯数据类

  • 仅包含字段和访问(读写)这些字段的方法
  • 此类被称为数据容器,应保持最小可变性

不恰当的命名

  • 命名无法准确描述做的事情
  • 命名不符合约定俗称的惯例

过多的注释

坏代码的问题

  • 难以复用
  • 系统关联性过多,导致很难分离可重用部分
  • 难于变化
  • 一处变化导致其他很多部分的修改,不利于系统稳定
  • 难于理解
  • 命名杂乱,结构混乱,难于阅读和理解
  • 难以测试
  • 分支、依赖较多,难以覆盖全面

重构


单一职责原则

一个类只负责完成一个职责或者功能,不要存在多于一种导致类变更的原因。

单一职责原则通过避免设计大而全的类,避免将不相关的功能耦合在一起,来提高类的内聚性。同时,类职责单一,类依赖的和被依赖的其他类也会变少,减少了代码的耦合性,以此来实现代码的高内聚、松耦合。但是,如果拆分得过细,实际上会适得其反,反倒会降低内聚性,也会影响代码的可维护性。

开放-关闭原则

添加一个新的功能,应该是通过在已有代码基础上扩展代码(新增模块、类、方法、属性等),而非修改已有代码(修改模块、类、方法、属性等)的方式来完成。

开闭原则并不是说完全杜绝修改,而是以最小的修改代码的代价来完成新功能的开发。

很多设计原则、设计思想、设计模式,都是以提高代码的扩展性为最终目的的。特别是 23 种经典设计模式,大部分都是为了解决代码的扩展性问题而总结出来的,都是以开闭原则为指导原则的。最常用来提高代码扩展性的方法有:多态、依赖注入、基于接口而非实现编程,以及大部分的设计模式(比如,装饰、策略、模板、职责链、状态)。

里氏替换原则

子类对象(object of subtype/derived class)能够替换程序(program)中父类对象(object of base/parent class)出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。

子类可以扩展父类的功能,但不能改变父类原有的功能

父类中凡是已经实现好的方法(相对于抽象方法而言),实际上是在设定一系列的规范和契约,虽然它不强制要求所有的子类必须遵从这些契约,但是如果子类对这些非抽象方法任意修改,就会对整个继承体系造成破坏。

接口隔离原则

调用方不应该依赖它不需要的接口;一个类对另一个类的依赖应该建立在最小的接口上。接口隔离原则提供了一种判断接口的职责是否单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。

依赖反转原则

高层模块不应该依赖低层模块,二者都应该依赖其抽象;抽象不应该依赖细节,细节应该依赖抽象。

迪米特法则

一个对象应该对其他对象保持最少的了解

合成复用原则

尽量使用合成/聚合的方式,而不是使用继承。

单一职责原则告诉我们实现类要职责单一;里氏替换原则告诉我们不要破坏继承体系;依赖倒置原则告诉我们要面向接口编程;接口隔离原则告诉我们在设计接口的时候要精简单一;迪米特法则告诉我们要降低耦合。而开闭原则是总纲,告诉我们要对扩展开放,对修改关闭。

重构技巧

提炼方法

多个方法代码重复、方法中代码过长或者方法中的语句不在一个抽象层级。方法是代码复用的最小粒度,方法过长不利于复用,可读性低,提炼方法往往是重构工作的第一步。

意图导向编程 :把处理某件事的流程和具体做事的实现方式分开。

  • 把一个问题分解为一系列功能性步骤,并假定这些功能步骤已经实现
  • 我们只需把把各个函数组织在一起即可解决这一问题
  • 在组织好整个功能后,我们在分别实现各个方法函数

以函数对象取代函数

将函数放进一个单独对象中,如此一来局部变量就变成了对象内的字段。然后你可以在同一个对象中将这个大型函数分解为多个小型函数。

引入参数对象

方法参数比较多时,将参数封装为参数对象

将查询与修改分离

任何有返回值的方法,都不应该有副作用

  • 不要在convert中调用写操作,避免副作用
  • 常见的例外:将查询结果缓存到本地

移除不必要临时变量

临时变量仅使用一次或者取值逻辑成本很低的情况下

引入解释性变量

将复杂表达式(或其中一部分)的结果放进一个临时变量,以此变量名称来解释表达式用途

if ((platform.toUpperCase().indexOf("MAC") > -1)
    && (browser.toUpperCase().indexOf("IE") > -1) && wasInitialized() && resize > 0) {
  // do something
}

final boolean isMacOs = platform.toUpperCase().indexOf("MAC") > -1;
final boolean isIEBrowser = browser.toUpperCase().indexOf("IE") > -1;
final boolean wasResized = resize > 0;
if (isMacOs && isIEBrowser && wasInitialized() && wasResized) {
  // do something
}

使用卫语句替代嵌套条件判断

把复杂的条件表达式拆分成多个条件表达式,减少嵌套。嵌套了好几层的if - then-else语句,转换为多个if语句

//未使用卫语句
public void getHello(int type) {
    if (type == 1) {
        return;
    } else {
        if (type == 2) {
            return;
        } else {
            if (type == 3) {
                return;
            } else {
                setHello();
            }
        }
    }
}

//使用卫语句
public void getHello(int type) {
    if (type == 1) {
        return;
    }
    if (type == 2) {
        return;
    }
    if (type == 3) {
        return;
    }
    setHello();
}

使用多态替代条件判断断

当存在这样一类条件表达式,它根据对象类型的不同选择不同的行为。可以将这种表达式的每个分支放进一个子类内的复写函数中,然后将原始函数声明为抽象函数。

public int calculate(int a, int b, String operator) {
    int result = Integer.MIN_VALUE;

    if ("add".equals(operator)) {
        result = a + b;
    } else if ("multiply".equals(operator)) {
        result = a * b;
    } else if ("divide".equals(operator)) {
        result = a / b;
    } else if ("subtract".equals(operator)) {
        result = a - b;
    }
    return result;
}

当出现大量类型检查和判断时,if else(或switch)语句的体积会比较臃肿,这无疑降低了代码的可读性。另外,if else(或switch)本身就是一个“变化点”,当需要扩展新的类型时,我们不得不追加if else(或switch)语句块,以及相应的逻辑,这无疑降低了程序的可扩展性,也违反了面向对象的开闭原则。

基于这种场景,我们可以考虑使用“多态”来代替冗长的条件判断,将if else(或switch)中的“变化点”封装到子类中。这样,就不需要使用if else(或switch)语句了,取而代之的是子类多态的实例,从而使得提高代码的可读性和可扩展性。很多设计模式使用都是这种套路,比如策略模式、状态模式。

public interface Operation {
  int apply(int a, int b);
}

public class Addition implements Operation {
  @Override
  public int apply(int a, int b) {
    return a + b;
  }
}

public class OperatorFactory {
    private final static Map<String, Operation> operationMap = new HashMap<>();
    static {
        operationMap.put("add", new Addition());
        operationMap.put("divide", new Division());
        // more operators
    }

    public static Operation getOperation(String operator) {
        return operationMap.get(operator);
    }
}

public int calculate(int a, int b, String operator) {
    if (OperatorFactory .getOperation == null) {
       throw new IllegalArgumentException("Invalid Operator");
    }
    return OperatorFactory .getOperation(operator).apply(a, b);
}

引入断言

某一段代码需要对程序状态做出某种假设,以断言明确表现这种假设。

  • 不要滥用断言,不要使用它来检查“应该为真”的条件,只使用它来检查“一定必须为真”的条件
  • 如果断言所指示的约束条件不能满足,代码是否仍能正常运行?如果可以就去掉断言

提炼类

根据单一职责原则,一个类应该有明确的责任边界。但在实际工作中,类会不断的扩展。当给某个类添加一项新责任时,你会觉得不值得分离出一个单独的类。于是,随着责任不断增加,这个类包含了大量的数据和函数,逻辑复杂不易理解。

组合优先于继承

继承使实现代码重用的有力手段,但这并非总是完成这项工作的最佳工具,使用不当会导致软件变得很脆弱。与方法调用不同的是,继承打破了封装性。子类依赖于其父类中特定功能的实现细节,如果父类的实现随着发行版本的不同而变化,子类可能会遭到破坏,即使他的代码完全没有改变。

举例说明,假设有一个程序使用HashSet,为了调优该程序的性能,需要统计HashSet自从它创建以来添加了多少个元素。为了提供该功能,我们编写一个HashSet的变体。

// Inappropriate use of inheritance!
public class InstrumentedHashSet<E> extends HashSet<E> {
    // The number of attempted element insertions
    private int addCount = 0;

    public InstrumentedHashSet() { }

    public InstrumentedHashSet(int initCap, float loadFactor) {
        super(initCap, loadFactor);
    }

    @Override
    public boolean add(E e) {
        addCount++;
        return super.add(e);
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return super.addAll(c);
    }

    public int getAddCount() {
        return addCount;
    }
}

通过在新的类中增加一个私有域,它引用现有类的一个实例,这种设计被称为组合,因为现有的类变成了新类的一个组件。这样得到的类将会非常稳固,它不依赖现有类的实现细节。即使现有的类添加了新的方法,也不会影响新的类。许多设计模式使用就是这种套路,比如代理模式、装饰者模式

// Reusable forwarding class
public class ForwardingSet<E> implements Set<E> {
    private final Set<E> s;
    public ForwardingSet(Set<E> s) { this.s = s; }

    @Override
    public int size() { return s.size(); }
    @Override
    public boolean isEmpty() { return s.isEmpty(); }
    @Override
    public boolean contains(Object o) { return s.contains(o); }
    @Override
    public Iterator<E> iterator() { return s.iterator(); }
    @Override
    public Object[] toArray() { return s.toArray(); }
    @Override
    public <T> T[] toArray(T[] a) { return s.toArray(a); }
    @Override
    public boolean add(E e) { return s.add(e); }
    @Override
    public boolean remove(Object o) { return s.remove(o); }
    @Override
    public boolean containsAll(Collection<?> c) { return s.containsAll(c); }
    @Override
    public boolean addAll(Collection<? extends E> c) { return s.addAll(c); }
    @Override
    public boolean retainAll(Collection<?> c) { return s.retainAll(c); }
    @Override
    public boolean removeAll(Collection<?> c) { return s.removeAll(c); }
    @Override
    public void clear() { s.clear(); }
}

// Wrappter class - uses composition in place of inheritance
public class InstrumentedHashSet<E> extends ForwardingSet<E> {
    private int addCount = 0;

    public InstrumentedHashSet1(Set<E> s) {
        super(s);
    }

    @Override
    public boolean add(E e) {
        addCount++;
        return super.add(e);
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return super.addAll(c);
    }

    public int getAddCount() {
        return addCount;
    }
}

继承与组合如何取舍

  • 只有当子类真正是父类的子类型时,才适合继承。对于两个类A和B,只有两者之间确实存在“is-a”关系的时候,类B才应该继承A;
  • 在包的内部使用继承是非常安全的,子类和父类的实现都处在同一个程序员的控制之下;
  • 对于专门为了继承而设计并且具有很好的文档说明的类来说,使用继承也是非常安全的;
  • 其他情况就应该优先考虑组合的方式来实现

接口优于抽象类

Java提供了两种机制,可以用来定义允许多个实现的类型:接口和抽象类。自从Java8为接口增加缺省方法(default method),这两种机制都允许为实例方法提供实现。主要区别在于,为了实现由抽象类定义的类型,类必须称为抽象类的一个子类。因为Java只允许单继承,所以用抽象类作为类型定义受到了限制。

接口相比于抽象类的优势:

  • 现有的类可以很容易被更新,以实现新的接口。
  • 接口是定义混合类型(比如Comparable)的理想选择。
  • 接口允许构造非层次结构的类型框架。

接口虽然提供了缺省方法,但接口仍有有以下局限性:

  • 接口的变量修饰符只能是public static final的
  • 接口的方法修饰符只能是public的
  • 接口不存在构造函数,也不存在this
  • 可以给现有接口增加缺省方法,但不能确保这些方法在之前存在的实现中都能良好运行。
  • 因为这些默认方法是被注入到现有实现中的,它们的实现者并不知道,也没有许可

接口缺省方法的设计目的和优势在于:

为了接口的演化

  • Java 8 之前我们知道,一个接口的所有方法其子类必须实现(当然,这个子类不是一个抽象类),但是 java 8 之后接口的默认方法可以选择不实现,如上的操作是可以通过编译期编译的。这样就避免了由 Java 7 升级到 Java 8 时项目编译报错了。Java8在核心集合接口中增加了许多新的缺省方法,主要是为了便于使用lambda。

可以减少第三方工具类的创建

  • 例如在 List 等集合接口中都有一些默认方法,List 接口中默认提供 replaceAll(UnaryOperator)、sort(Comparator)、、spliterator()等默认方法,这些方法在接口内部创建,避免了为了这些方法而专门去创建相应的工具类。

可以避免创建基类

  • 在 Java 8 之前我们可能需要创建一个基类来实现代码复用,而默认方法的出现,可以不必要去创建基类。

由于接口的局限性和设计目的的不同,接口并不能完全替换抽象类。但是通过对接口提供一个抽象的骨架实现类,可以把接口和抽象类的优点结合起来。 接口负责定义类型,或许还提供一些缺省方法,而骨架实现类则负责实现除基本类型接口方法之外,剩下的非基本类型接口方法。扩展骨架实现占了实现接口之外的大部分工作。这就是模板方法(Template Method)设计模式。

通配符的使用

//List<? extends E>
// Number 可以认为 是Number 的 "子类"
List<? extends Number> numberArray = new ArrayList<Number>();
// Integer 是 Number 的子类
List<? extends Number> numberArray = new ArrayList<Integer>();
// Double 是 Number 的子类
List<? extends Number> numberArray = new ArrayList<Double>();

//List<? super E>
// Integer 可以认为是 Integer 的 "父类"
List<? super Integer> array = new ArrayList<Integer>();// Number 是 Integer 的 父类
List<? super Integer> array = new ArrayList<Number>();
// Object 是 Integer 的 父类
List<? super Integer> array = new ArrayList<Object>();

复杂业务的处理过程

问题的核心应该是如何分解问题和抽象问题

使用结构化分解将问题解构成一个有层级的金字塔结构, 通过合理的抽象,形成合适的阶段(Phase)和步骤(Step)

模型不是一次性设计出来的,而是迭代演化出来的。

沉下心来,夯实自己的基础技术能力、OO能力、建模能力... 不断提升抽象思维、结构化思维、思辨思维... 持续学习精进,写好代码。