重构:在不改变软件可观察行为的前提下,对代码进行修改,以改善其内部结构。

第1章 重构,第一个案例

1.1 起点

案例:影片出租店用的程序,计算每一位顾客的消费金额并打印详单。操作者告诉程序:顾客租了哪些影片、租期多长,程序便根据租赁时间和影片类型算出费用。影片分为三类:普通片、儿童片和新片。除了计算费用,还要为常客计算积分,积分会根据租片种类是否为新片而有所不同。

UML类图如下所示:

image-20210527232752556

Movie(影片)

public class Movie {
    public static final int CHILDREDS = 2;
    public static final int REGULAR = 2;
    public static final int NEW_RELEASE = 2;

    private String _title;
    private int _priceCode;

    public Movie(String _title, int _priceCode) {
        this._title = _title;
        this._priceCode = _priceCode;
    }

    public int get_priceCode() {
        return _priceCode;
    }

    public void set_priceCode(int _priceCode) {
        this._priceCode = _priceCode;
    }

    public String get_title() {
        return _title;
    }
}

Rental(租赁)

public class Rental {
    private Movie _movie;
    private int _daysRented;

    public Rental(Movie _movie, int _daysRented) {
        this._movie = _movie;
        this._daysRented = _daysRented;
    }

    public Movie get_movie() {
        return _movie;
    }

    public int get_daysRented() {
        return _daysRented;
    }
}

Customer(顾客):

public class Customer {
    private String _name;
    private Vector _rentals = new Vector();

    public Customer(String _name) {
        this._name = _name;
    }
    
    public void addRental(Rental arg){
        _rentals.addElement(arg);
    }
    
    public String getName(){
        return _name;
    }
}

Customer还提供了一个用于生成详单的函数,该函数交互过程如下图所示。

image-20210519072922928

代码如下:

public String statement() {
        double totalAmount = 0;
        int frequentRenterPoints = 0;
        Enumeration rentals = _rentals.elements();
        String result = "Rental Record for " + getName() + "\n";
        while (rentals.hasMoreElements()) {
            double thisAmount = 0;
            Rental each = (Rental) rentals.nextElement();
            switch (each.getMovie().getPriceCode()) {
                case Movie.REGULAR:
                    thisAmount += 2;
                    if (each.getDaysRented() > 2) {
                        thisAmount += (each.getDaysRented() - 2) * 1.5;
                    }
                    break;
                case Movie.NEW_RELEASE:
                    thisAmount += each.getDaysRented() * 3;
                    break;
                case Movie.CHILDRENS:
                    thisAmount += 1.5;
                    if (each.getDaysRented() > 3) {
                        thisAmount += (each.getDaysRented() - 3) * 1.5;
                    }
                    break;
            }
            frequentRenterPoints++;
            if ((each.getMovie().getPriceCode() == Movie.NEW_RELEASE) &&
                    each.getDaysRented() > 1) {
                frequentRenterPoints ++;
            }
            result += "\t" + each.getMovie().getTitle() + "\t" + String.valueOf(thisAmount)+"\n";
            totalAmount += thisAmount;
        }
        result +="Amount owed is " + String.valueOf(totalAmount)+"\n";
        result +="You earned "+ String.valueOf(frequentRenterPoints)+" frequent renter points";
        return result;
    }

评价:

不符合面向对象精神,statement()做的事情太多,容易产生bug,另外如果需求改变,比如希望以HTML格式输出详单,该方法没有任何作用,需要重写一个新的方法,导致大量重复工作产生。

关键点:如果你发现自己需要为程序添加一个特性,而代码结构使你无法很方便地达成目的,那就先重构那个程序,使特性的添加比较容易进行,然后再添加特性。

1.2 重构的第一步

第一步:为即将修改的代码建立一组可靠的测试环境,保证重构前后代码功能没有改变。要求这些测试都能够自我检测,要么输出“OK”,要么列出失败清单,显示问题出现的行号等。

1.3 分解并重组statement()

1.3.1 找出代码的逻辑泥团

如上例中的switch语句,将它提炼到独立函数中。

首先、找出这段代码内的局部变量和参数,这里是eachthisAmount。前者并未修改,后者会被修改。

记住:任何不会被修改的变量都可以当成参数传入新的函数,如果只有一个变量会被修改,可以将它作为返回值。

重构后的代码:

public String statement() {
        double totalAmount = 0;
        int frequentRenterPoints = 0;
        Enumeration rentals = _rentals.elements();
        String result = "Rental Record for " + getName() + "\n";
        while (rentals.hasMoreElements()) {
            double thisAmount = 0;
            Rental each = (Rental) rentals.nextElement();
            thisAmount = amountFor(each);
            frequentRenterPoints++;
            if ((each.getMovie().getPriceCode() == Movie.NEW_RELEASE) &&
                    each.getDaysRented() > 1) {
                frequentRenterPoints ++;
            }
            result += "\t" + each.getMovie().getTitle() + "\t" + String.valueOf(thisAmount)+"\n";
            totalAmount += thisAmount;
        }
        result +="Amount owed is " + String.valueOf(totalAmount)+"\n";
        result +="You earned "+ String.valueOf(frequentRenterPoints)+" frequent renter points";
        return result;
    }

    private double amountFor(Rental each) {
        int thisAmount = 0;
        switch (each.getMovie().getPriceCode()) {
            case Movie.REGULAR:
                thisAmount += 2;
                if (each.getDaysRented() > 2) {
                    thisAmount += (each.getDaysRented() - 2) * 1.5;
                }
                break;
            case Movie.NEW_RELEASE:
                thisAmount += each.getDaysRented() * 3;
                break;
            case Movie.CHILDRENS:
                thisAmount += 1.5;
                if (each.getDaysRented() > 3) {
                    thisAmount += (each.getDaysRented() - 3) * 1.5;
                }
                break;
        }
        return thisAmount;
    }
  }

然后、修改amountFor()内的某些变量名称。

private double amountFor(Rental aRental) {
        double result = 0;
        switch (aRental.getMovie().getPriceCode()) {
            case Movie.REGULAR:
                result += 2;
                if (aRental.getDaysRented() > 2) {
                    result += (aRental.getDaysRented() - 2) * 1.5;
                }
                break;
            case Movie.NEW_RELEASE:
                result += aRental.getDaysRented() * 3;
                break;
            case Movie.CHILDRENS:
                result += 1.5;
                if (aRental.getDaysRented() > 3) {
                    result += (aRental.getDaysRented() - 3) * 1.5;
                }
                break;
        }
        return result;
    }

改名之后也要重新编译并测试,确保没有破坏任何东西。

改名的重要性:提高代码的清晰度,写出人类容易理解的代码

1.3.2 搬移“金额计算”代码

amountFor()这个函数使用了来自Rental类的信息,却没有使用来自Customer类的信息,而函数应该放在它所使用的数据的所属对象内,因此需要将该函数移到Rental类去,并适当作出修改。

public double getCharge() {
    double result = 0;
    switch (getMovie().getPriceCode()) {
        case Movie.REGULAR:
            result += 2;
            if (getDaysRented() > 2) {
                result += (getDaysRented() - 2) * 1.5;
            }
            break;
        case Movie.NEW_RELEASE:
            result += getDaysRented() * 3;
            break;
        case Movie.CHILDRENS:
            result += 1.5;
            if (getDaysRented() > 3) {
                result += (getDaysRented() - 3) * 1.5;
            }
            break;
    }
    return result;
}

在这里去掉了多余的参数,并改变了函数名称,private变为public。

修改完之后,去掉旧函数,然后再进行测试。

再回到statement()函数中,发现thisAmount比较多余,去掉,直接改为each.getCharge()替代。

去掉临时变量的好处是避免引发问题,缺点是性能上差点。

1.3.3 提炼“常客积分计算”代码

积分的计算跟影片种类有关,可以将积分计算放在Rental类里。

“常客积分计算”代码如下:

frequentRenterPoints++;
if ((each.getMovie().getPriceCode() == Movie.NEW_RELEASE) &&
    each.getDaysRented() > 1) {
    frequentRenterPoints ++;
}

涉及两个局部变量,eachfrequentRenterPointseach可以被当做参数传入新函数中,frequentRenterPoints在使用前已经有初值,但提炼出来的函数并没有读取该值,所以我们不需要将它作为参数传进去,只需把新函数的返回值累加即可。

image-20210527230811817

“常客积分计算“函数被提炼及搬移之前的类图

image-20210527231218079

“常客积分计算“函数被提炼及搬移之后的类图

image-20210527232715018

序列图(前)

image-20210527232552508

序列图(后)

1.3.4 去掉临时变量

利用查询函数来取代totalAmountfrequentRentalPoints这两个临时变量。

首先用Customer类的getTotalCharge()取代totalAmount,由于totalAmount在循环内部被赋值,所以不得不把循环复制到查询函数中。

while (rentals.hasMoreElements()) {
          Rental each = (Rental) rentals.nextElement();
          frequentRenterPoints += each.getFrequentRenterPoints();
          result += "\t" + each.getMovie().getTitle() + "\t"
                  + String.valueOf(each.getCharge())+"\n";
         // totalAmount += each.getCharge();
      }
      result +="Amount owed is " + String.valueOf(getTotalCharge())+"\n";
      result +="You earned "+ String.valueOf(frequentRenterPoints)+" frequent renter points";
      return result;
  }

  private double getTotalCharge() {
      double result = 0;
      Enumeration rentals = _rentals.elements();
      while (rentals.hasMoreElements()){
          Rental each = (Rental) rentals.nextElement();
          result += each.getCharge();
      }
      return result;
  }

用同样的方法处理frequentRenterPoints

 public String statement() {
        //double totalAmount = 0;
//        int frequentRenterPoints = 0;
        Enumeration rentals = _rentals.elements();
        String result = "Rental Record for " + getName() + "\n";
        while (rentals.hasMoreElements()) {
            Rental each = (Rental) rentals.nextElement();
//            frequentRenterPoints += each.getFrequentRenterPoints();
            result += "\t" + each.getMovie().getTitle() + "\t"
                    + String.valueOf(each.getCharge())+"\n";
           // totalAmount += each.getCharge();
        }
        result +="Amount owed is " + String.valueOf(getTotalCharge())+"\n";
        result +="You earned "+ String.valueOf(getTotalFrequentRenterPoints())+" frequent renter points";
        return result;
    }
	private int getTotalFrequentRenterPoints() {
        int result = 0;
        Enumeration rentals = _rentals.elements();
        while(rentals.hasMoreElements()){
            Rental each = (Rental) rentals.nextElement();
            result += each.getFrequentRenterPoints();
        }
        return result;
    }

对比:

image-20210527231218079

“总量计算”函数被提炼前的类图

image-20210528065054433

“总量计算”函数被提炼后的类图

image-20210528065346215

“总量计算”函数被提炼前的时序图

image-20210528070115851

“总量计算”函数被提炼后的时序图

如果需要修改影片分类规则,但是与之相应的费用计算方式与常客积分计算方式还未确定,可以将费用计算和常客积分计算代码中因条件而异的代码替换掉。

1.4 运用多态取代与价格相关的条件逻辑

问题一:最好不要在另一个对象的属性基础上运用switch语句,如果不得不使用,也应该在对象自己的数据上使用。

计算费用时需要两项数据:租期长度和影片类型,这里选择将租期长度传给Movie对象,因为本系统可能发生的变化是加入新影片类型,这种变化带有不稳定倾向,如果影片类型有所变化,则需要尽量控制它造成的影响。

这里将计费方法放进Movie类,然后修改Rental的getCharge(),让它调用这个新函数。

 public double getCharge() {
     return _movie.getCharge(_daysRented);
 }

public double getCharge(int daysRented) {
    double result = 0;
    switch (getPriceCode()) {
        case Movie.REGULAR:
            result += 2;
            if (daysRented > 2) {
                result += (daysRented - 2) * 1.5;
            }
            break;
        case Movie.NEW_RELEASE:
            result += daysRented * 3;
            break;
        case Movie.CHILDRENS:
            result += 1.5;
            if (daysRented > 3) {
                result += (daysRented - 3) * 1.5;
            }
            break;
    }
    return result;
}

搬移getCharge()之后,以同样手法处理常客积分计算,这样就把根据影片类型而变化的所有东西,都放到影片类型所属的类中。

public int getFrequentRenterPoints() {
    return _movie.getFrequentRenterPoints(_daysRented);
}

public int getFrequentRenterPoints(int daysRented) {
    if ((getPriceCode() == Movie.NEW_RELEASE) &&
        daysRented > 1) {
        return 2;
    }else {
        return 1;
    }
}

image-20210528070449852

移动前的类图

image-20210528071049301

移动后的类图

1.4.1 终于…开始继承

建立Movie的三个子类,代表不同影片类型,每个都有自己的计费法。

image-20210528072142525

本来想法是利用多态取代switch语句,但是这里不允许这么做,因为一部影片可以在生命周期内修改自己的分类,但一个对象却不能在生命周期内修改自己所属的类。

解决方法:State模式

image-20210528072556454

加上这一层间接性,我们就可以在Price对象内进行子类化动作,于是便可以在任何必要时刻修改价格。

为了引入State模式,这里将使用三个重构手法,首先将与类型相关的行为搬移至State模式内,然后将switch语句移到Price类,最后去掉switch语句。

第一步骤需要确保任何时候都通过取值函数和设值函数来访问类型代码。

将Movie类中的构造函数进行修改,用一个设值函数来代替直接访问价格代码。

public Movie(String _title, int _priceCode) {
    this._title = _title;
    setPriceCode(_priceCode);
}

新建一个Price类,并在其中提供类型相关的行为。因此在Price类中加入一个抽象函数。

public abstract class Price {
    abstract int getPriceCode();
}

public class ChildrensPrice extends Price {
    @Override
    int getPriceCode(){
        return Movie.CHILDRENS;
    }
}	

public class NewReleasePrice extends Price {
    @Override
    int getPriceCode() {
        return Movie.NEW_RELEASE;
    }
}

public class RegularPrice extends Price {
    @Override
    int getPriceCode() {
        return Movie.REGULAR;
    }
}

现在需要修改Movie类内的“价格代号”访问函数(取值/设值函数),让它们使用新类。

因此需要在Movie类内保存一个Price对象,而不是一个_priceCode变量。

private Price _price;

public int getPriceCode() {
    return _price.getPriceCode();
}

public void setPriceCode(int arg) {
    switch (arg){
        case REGULAR:
            _price = new RegularPrice();
            break;
        case CHILDRENS:
            _price = new ChildrensPrice();
            break;
        case NEW_RELEASE:
            _price = new NewReleasePrice();
            break;
        default:
            throw new IllegalArgumentException("Incorrect Price Code");
    }
}

接着改写getCharge(),将Movie类中的getCharge代码搬移到Price类中。

//Movie类
public double getCharge(int daysRented) {
    return _price.getCharge(daysRented);
}
// Price类    
public double getCharge(int daysRented){
    double result = 0;
    switch (getPriceCode()){
        case Movie.REGULAR:
            result += 2;
            if(daysRented > 2){
                result += (daysRented - 2) * 1.5;
            }
            break;
        case Movie.NEW_RELEASE:
            result += daysRented * 3;
            break;
        case Movie.CHILDRENS:
            result += 1.5;
            if(daysRented > 3){
                result += (daysRented - 3) * 1.5;
            }
            break;
    }
    return result;
}

取出一个case分支,在相应的类建立一个覆盖函数,先从RegelarPrice开始:

@Override
public double getCharge(int daysRented){
    double result = 2;
    if(daysRented > 2){
        result += (daysRented - 2) * 1.5;
    }
    return result;
}

依次处理剩下两个分支:

//ChildrensPrice类
@Override
public double getCharge(int daysRented){
    double result = 1.5;
    if(daysRented > 3){
        result += (daysRented - 3) * 1.5;
    }
    return result;
}
// NewReleasePrice类
@Override
public double getCharge(int daysRented){
    return daysRented * 3;
}

处理完所有分支后,将Price.getCharge()声明为abstract:

public abstract double getCharge(int daysRented);

用同样手法处理getFrequentRenterPoints()。

首先将Movie类中的getFrequentRenterPoints()方法移到Price类中,默认情况下返回1。

public int getFrequentRenterPoints(int daysRented) {
    return 1;
}

在新片类型重写该方法。

@Override
public int getFrequentRenterPoints(int daysRented) {
    return (daysRented > 1) ? 2 : 1;
}

image-20210528075518286

重构后的时序图

image-20210528075024347

重构后的类图

第2章 重构原理

2.1 何谓重构

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

一般而言,重构都是对软件的小改动。

2.2 为何重构

  • 重构改进软件设计,经常性的重构可以帮助代码维持自己该有的形态,改进代码的一个重要方向就是消除重复代码,优秀设计的根本在于所有事物和行为在代码中只表述一次;
  • 重构使软件更容易理解;
  • 重构帮助找到bug;
  • 重构能提高编程效率,良好的设计是快速开发的根本。

2.3 何时重构

事不过三,三则重构。

  • 第一次做某件事时只管去做;第二次做类似的事会产生反感;第三次再做类似的事,就应该重构。
  • 添加功能时重构
  • 修补错误时重构
  • 复审代码时重构:最好是一个复审者搭配一个原作者,两人共同判断这些修改是否能通过重构轻松实现。

困扰程序员的四种程序:

  • 难以阅读的程序
  • 逻辑重复的程序
  • 添加新行为时需要修改已有代码的程序
  • 带复杂条件逻辑的程序

2.5 重构的难题

数据库

绝大多数商用程序都与它们背后的数据库结构紧密耦合在一起,另外就是数据迁移。

在非对象数据库中,解决该问题的一个办法是在对象模型和数据库模型之间插入一个分隔层,以隔离两个模型各自的变化。

修改接口

如果重构手法改变了已发布接口,那么就必须同时维护新旧两个接口,知道所有用户都有时间对这个变化做出反应。

可以让旧接口调用新接口,同时将旧接口标记为deprecated,当你要修改某个函数名称时,留下旧函数,让它调用新函数。

建议:不要过早发布接口,请修改你的代码所有权政策,使重构更顺畅。

何时不该重构

如果现有代码根本不能正常运作,建议重写!

折中办法是:将“大块头软件”重构为封装良好的小型组件,然后逐一对组件作出“重构或重建”的决定。

另外如果项目已近最后期限,也应该避免重构。

2.6 重构与设计

设计是软件开发的关键环节,编程只是机械式的低级劳动!!

有了重构,不必再逐一实现每一个风险的解决方案,而是在实现中不断优化。

2.7 重构与性能

大多数程序把大半时间都耗费在一小半代码身上。

在性能优化阶段,首先应该用度量工具来监控程序的运行,找出哪些地方大量消耗时间和空间,再去对代码进行优化。

第3章 代码的坏味道

3.1 重复代码(Duplicated Code)

如果在一个以上的地点看到相同的代码,设法将它们合而为一。

场景一:同一个类的两个函数含有相同的表达式。

场景二:两个互为兄弟的子类还有相同表达式。【将相同部分提炼出来,放入超类中】

场景三:两个毫不相干的类出现重复代码。【对其中一个类将重复代码提炼到一个独立类中,然后在另一个类内使用这个新类】

3.2 过长函数(Long Method)

有一条原则:每当感觉需要以注释来说明点什么的时候(就算只有一行代码),就把需要说明的东西写进一个独立函数中,并以其用途命名。

条件表达式和循环也是提炼的信号。

3.3 过大的类(Large Class)

如果某个类有太多实例变量,可以将几个相关联的变量提炼到新类。

如果有五个“百行函数”,它们之间有很多相同代码,则可以试着将它们变成五个“十行代码”和十个“双行代码”。

3.4 过长参数列(Long Parameter List)

全局参数是邪恶的东西。

将繁杂的参数整合成一个对象,有了对象,就不必将函数需要的所有东西都以参数传递给它,只需传给它足够的、让函数能从中获取自己所需要的东西即可。

3.5 发散式变化(Divergent Change)

针对某一外界变化的所有相应修改,都只应该发生在单一类中。如果一个类受多种变化的影响时,则需要考虑重构

3.6 霰弹式修改(Shotgun Surgery)

如果一种变化引发多个类相应修改,则需要整理代码,使“外界变化”和“需要修改的类”趋于一一对应。

3.7 依恋情结(Feature Envy)

函数对某个类的兴趣高于对自己所处类的兴趣,如:某个函数为了计算某个值,从另一个对象那儿调用几乎半打的取值函数。

解决办法:把这个函数移到它该去的地方,如果函数中只有一部分受这种依恋之苦,则可以将这部分提炼到独立函数中。

3.8 数据泥团(Data Clumps)

两个类中有相同的字段、许多函数签名中有相同的参数,这些总是绑在一起出现的数据真应该拥有属于它们自己的对象。

好处是:可以将很多参数列缩短,简化函数调用。

3.9 基本类型偏执(Primitive Obsession)

可以将原本单独存在的数据值替换为对象。

3.10 switch惊悚现身(Switch Statements)

少用switch语句。

看到switch语句,就应该考虑用多态来替换。

方法:将switch语句提炼到一个独立函数中,再将它搬移到需要多态性的那个类里。

3.11 平行继承体系(Parallel Inheritance Hierarchies)

现象:每当你为某个类增加一个子类,就必须也为另一个类相应增加一个子类。或者是你发现某个继承体系的类名称前缀和另一个继承体系的类名称前缀完全相同。

策略:让一个继承体系的实例引用另一个继承体系的实例。

3.12 冗赘类(Lazy Class)

对于几乎没用的组件,就应该让它消失。

3.13 夸夸其谈未来性(Speculative Generality)

企图以各式各样的钩子和特殊情况来处理一些非必要的事情。

3.14 令人迷惑的暂时字段(Temporary Field)

如果类中有一个复杂算法,需要好几个变量,而这些变量只有在使用该算法时才有效,往往就有可能导致坏味道的出现。

可以将这些变量和其相关的函数提炼到一个独立类中。

3.15 过度耦合的消息链(Message Chains)

用户向一个对象请求另一个对象,然后再向后者请求另一个对象,然后再请求另一个对象…这就是消息链。这样的后果就是一旦对象间的关系发生任何变化,客户端就不得不作出相应修改。

3.16 中间人(Middle Man)

如果某个类的接口有一半的函数都委托给其他类,这就是过度运用委托。

3.17 狎昵关系(Inappropriate Intimacy)

继承往往造成过度亲密,因为子类对超类的了解总是超过后者的主观愿望。

过分狎昵的类必须拆散,或者将两者共同点提炼到一个新类。

3.18 异曲同工的类(Alternative Classes with Different Interfaces)

如果两个函数做同一件事,却有着不同的签名,可以根据它们的用途重新命名。

3.19 不完美的库类(Incomplete Library Class)

3.20 纯粹的数据类(Data Class)

拥有一些字段,以及用于访问(读写)这些字段的函数,除此之外一无长物。

3.21 被拒绝的遗赠(Refused Bequest)

3.22 过多的注释(Comments)

当你感觉需要撰写注释时,请先尝试重构,试着让所有注释都变得多余。

第4章 构筑测试体系

4.1 自测试代码的价值

修复错误通常比较快,但找出错误却是噩梦一场。

确保所有测试都完全自动化,让它们检查自己的测试结果。

每写一个小功能,就立即添加测试。

一套测试就是一个强大的bug侦测器,能够大大缩减查找bug所需要的时间。

编写测试代码的最有用时机是在开始编程之前当你需要添加新特性的时候,先写相应测试代码。因为编写测试代码其实就是在问自己:添加这个功能需要做些什么?此外,编写测试代码还能使你把注意力集中于接口而非实现。预先写好的测试代码也为你的工作安上了一个明确的结束标志:一旦测试代码正常运行,工作就算完成了。

我们可以建立一个独立类用于测试,并在一个框架中运行它,使测试工作更轻松。

4.2 JUnit测试框架

单元测试需要我们去控制台查看测试结果,而引入单元测试框架后会自动帮我们校验结果的正确与否。

通常Java中常用的单元测试框架包含三个功能:

  • 测试工具:确保测试能够在共享且固定的环境中运行,保证测试结果的可重复性,具体负责初始化测试环境、准备测试数据和测试数据清理;
  • 测试套件:捆绑几个测试案例同时运行;
  • 测试允许器:用于执行测试案例,一般负责调用需要被测试的单元、收集结果并和期望值比较。

测试Demo:

public class CalculateServiceImplTest {

    public static final Logger logger = LoggerFactory.getLogger(CalculateServiceImplTest.class);

    private CalculateService calculateService;

    @Before
    public void setUp() throws Exception {
        logger.info("begin to test...");
        calculateService = new CalculateServiceImpl();
    }

    @Test
    public void testAdd(){
        Assert.assertEquals(calculateService.add(2,2),4);
    }

    @After
    public void tearDown() throws Exception {
        logger.info("end to test...");
    }
}

Assert类

该类提供了一系列用于检测测试结果的方法,只有失败的声明方法才会被记录。

void assertEquals(boolean expected, boolean actual)

检查两个变量或者等式是否平衡

void assertFalse(boolean condition)

检查条件是假的

void assertNotNull(Object object)

检查对象不是空的

JUnit中的注解

  • @BeforeClass:针对所有测试,只执行一次,且必须为static void
  • @Before:初始化方法
  • @Test:测试方法,在这里可以测试期望异常和超时时间
  • @After:释放资源
  • @AfterClass:针对所有测试,只执行一次,且必须为static void
  • @Ignore:忽略的测试方法

一个单元测试类执行顺序为:

@BeforeClass –> @Before –> @Test –> @After –> @AfterClass

每一个测试方法的调用顺序为:

@Before –> @Test –> @After

@Test(timeout = 1000):指定测试用例的执行时间,如果超过该时间,那么JUnit会将它标记为失败。

@Test(expected = NullPointerException.class):测试代码是否抛出了想要得到的异常。

4.3 测试技巧

测试的一项重要技巧是“寻找边界条件”,如第一个字符、最后一个字符、倒数第二个字符等。

对于文件相关测试,空文件也是一个不错的边界条件。

当事情认定会出错时,别忘了检查是否抛出了预期的异常。

单元测试基本准则:https://www.cnblogs.com/54chensongxia/p/12410239.html

第5章 重构列表

5.1 重构的记录格式

每个重构手法都包括如下五个部分:

  • 名称(name)
  • 简短概要(summary):简单介绍此重构手法的适用场景,以及它所做的事情;
  • 动机(motivation):为什么需要重构,以及什么情况下不该使用这个重构;
  • 做法(mechanics):进一步介绍如何进行此重构;
  • 范例(examples)

“概要”包括三部分:

  • 一句话介绍这个重构能够帮助解决的问题;
  • 一段简短陈述,介绍你应该做的事;
  • 一幅UML图或一段代码,简单展示重构前后示例。

5.2 寻找引用点

可以利用工具找到对于某个函数、某个字段或某个类的所有引用点,但是不要盲目地查找-替换,应该仔细检查每个引用点,确定它的确指向你想要替换的东西。

5.3 这些重构手法有多成熟

重构的基本技巧–小步前进、频繁测试。

设计模式是你希望到达的目标,重构则是到达之路。

第6章 重新组织函数

6.1 提炼函数(Extract Method)

如果有一段代码可以被组织在一起并独立出来,则将它们放入一个独立函数中,并让函数名称解释该函数的用途。

void printOwing(double amount) {
    printBanner();

    System.out.println("name:" + name);
    System.out.println("amount:" + amount);
}

替换为:

void printOwing(double amount) {
    printBanner();
    printDetails(amount);
}

private void printDetails(double amount) {
    System.out.println("name:" + name);
    System.out.println("amount:" + amount);
}

动机

如果每个函数的粒度都很小,那么函数被复用的机会就很大,而且高层函数读起来很容易理解,函数的重写也会更容易。

做法

  • 创造一个新函数,根据这个函数的意图(功能)来对它命名,而不是以它“怎么做”来命名。
  • 将提炼出的代码从源函数复制到新建的目标函数中;
  • 仔细检查提炼出的代码,看看其中是否引用了“作用域限于源函数”的变量;
  • 检查是否有“仅用于被提炼代码段”的临时变量,如果有,在目标函数中将它们声明为临时变量;
  • 检查被提炼代码段,看看是否有任何局部变量的值被它改变,如果有,看看是否可以将被提炼代码段处理为一个查询,并将结果赋给相关变量;
  • 将被提炼代码段中需要读取的局部变量,当做参数传给目标函数;
  • 处理完所有局部变量后,进行编译;
  • 在源函数中,将被提炼代码段替换为对目标函数的调用。(如果临时变量的声明在被提炼代码段的外围,记得删除这些声明)
  • 编译、测试

范例:对局部变量再赋值

分两种情况:

  • 这个变量只在被提炼代码段中使用:将这个临时变量的声明移到被提炼代码段中,然后一起提炼出去;
  • 被提炼代码段之外的代码也使用了这个变量,这里分两种情况:
    • 如果这个变量在被提炼代码段之后再未被使用:直接在目标函数中修改;
    • 如果这个变量在被提炼代码段之后也被使用:让目标函数返回该变量改变后的值。

初始代码:

void printOwing(double previousAmount){
    Enumeration e = _orders.elements();
    double outstanding = previousAmount * 2;
    printBanner();

    while(e.hasMoreElements()){
        Order each = (Order) e.nextElement();
        outstanding += each.getAmount();
    }
    printDetails(outstanding);
}

重构后的代码:

 void printOwing(double previousAmount){
     printBanner();
     outstanding = getOutstanding(previousAmount * 2);
     printDetails(outstanding);
 }
    
double getOutstanding(double initialValue){
    Double result = initialValue;
    Enumeration e = _orders.elements();
    while(e.hasMoreElements()){
        Order each = (Order) e.nextElement();
        result += each.getAmount();
    }
    return result;
}

如果需要返回的变量不止一个,最好的选择是:挑选另一块代码来提炼,让每个函数都只返回一个值。

6.2 内联函数(Inline Method)

动机

如果一个函数的本体与名称同样清楚易懂,在函数调用点插入函数本体,然后移除该函数。

如果有一群组织不甚合理的函数,可以将它们都内联到一个大型函数中,再从中提炼出组织合理的小型函数。

做法

  • 检查函数,确定它不具备多态性;
  • 找出这个函数的所有被调用点;
  • 将这个函数的所有被调用点都替换为函数主体;
  • 编译、测试
  • 删掉该函数的定义

6.3 内联临时变量(Inline Temp)

如果某个临时变量很少被用到,可以将所有对该变量的引用动作,替换为对它赋值的那个表达式自身。

double basicPrice = anOrder.basePrice();
return (basicPrice > 1000);

替换为:

return (anOrder.basePrice() > 1000);

做法

  • 检查给临时变量赋值的语句,确保等号右边的表达式没有副作用;
  • 如果在这个临时变量并未被声明为final,那就将它声明为final,然后编译,可以检查该变量是否真的只被赋值一次
  • 找到该临时变量的所有引用点,将它们替换为“为临时变量赋值”的表达式;
  • 每次修改后,编译并测试;修改完所有引用点之后,删除该临时变量的声明和赋值语句;
  • 编译、测试

6.4 以查询取代临时变量(Replace Temp with Query)

如果临时变量保存某一表达式的运算结果,则将这个表达式提炼到一个独立函数中,将这个临时变量的所有引用点替换为对新函数的调用。

double basePrice = quantity * itemPrice;
if(basePrice > 1000){
	return basePrice * 0.95;
}else {
	return basePrice * 0.98;
}

替换为:

if(basePrice() > 1000){
	return basePrice() * 0.95;
}else {
	return basePrice() * 0.98;
}

double basePrice(){
	return quantity * itemPrice;
}

做法

简单情况:

  • 找出只被赋值一次的临时变量(如果被赋值超过一次,考虑将它分割成多个变量);
  • 将该变量声明为final(确保只被赋值一次);
  • 编译
  • 将赋值语句等号右侧部分提炼到一个独立函数中;
    • 首先将函数声明为private,如果后面有其他类需要使用,可以再改变;
    • 确保该函数并不修改任何对象内容。
  • 编译、测试

6.5 引入解释性变量(Introduce Explaining Variable)

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

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

}

替换为:

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){

}

除非是要处理一个拥有大量局部变量的算法,否则一般情况下都会考虑Extract Method对函数进行提炼。

6.6 分解临时变量(Split Temporary Variable)

如果程序中有某个临时变量被赋值超过一次,它既不是循环变量,也不被用于收集计算结果,可以针对每次赋值,创造一个独立、对应的临时变量。

范例

double getDistanceTravelled(int time){
    double result;
    double acc = primaryForce / mass;
    int primaryTime = Math.min(time,delay);
    result = 0.5 * acc * primaryTime * primaryTime;
    int secondaryTime = time - delay;
    if(secondaryTime > 0){
        double primaryVel = acc * delay;
        acc = (primaryForce + secondaryForce) / mass;
        result += primaryVel * secondaryTime + 0.5 * acc * secondaryTime * secondaryTime;
    }
    return result;
}

第一个acc是保存第一个力造成的初始加速度,第二个是保存两个力共同造成的加速度。

  • 首先,在函数开始处修改这个临时变量名称,并声明为final;
  • 接着,把第二次赋值之前对acc变量的所有引用点全部改为新的临时变量;
  • 最后,在第二次赋值处重新声明acc变量。
double getDistanceTravelled(int time){
        double result;
        final double primaryAcc = primaryForce / mass;
        int primaryTime = Math.min(time,delay);
        result = 0.5 * primaryAcc * primaryTime * primaryTime;
        int secondaryTime = time - delay;
        if(secondaryTime > 0){
            double primaryVel = primaryAcc * delay;
            final double secondaryAcc = (primaryForce + secondaryForce) / mass;
            result += primaryVel * secondaryTime + 0.5 * secondaryAcc * secondaryTime * secondaryTime;
        }
        return result;
    }

6.7 移除对参数的赋值(Remove Assignments to Parameters)

代码对一个参数进行赋值,以一个临时变量取代该参数的位置。

int discount(int inputVal,int quantity,int yearToDate){
    if(inputVal>50){
        inputVal -= 2;
    }
}

替换为:

int discount(int inputVal,int quantity,int yearToDate){
	int result = inputVal;
    if(inputVal>50){
        result -= 2;
    }
}

这里需要明确“对参数赋值”的含义,如果是在“被传入对象”身上进行什么操作,那没问题,而如果该对象指向另一个对象,则最好进行修改。

void aMethod(Object foo){
	foo.modifyInSomeWay();  //that's OK
	foo = anotherObject;	//trouble and despair will follow you
}

Java的按值传递

public class Param {
    public static void main(String[] args) {
        Date d1 = new Date("1 Apr 98");
        nextDateUpdate(d1);
        System.out.println("d1 after nextDay:" + d1);

        Date d2 = new Date("1 Apr 98");
        nextDateReplace(d2);
        System.out.println("d2 after nextDay:" + d2);
    }

    private static void nextDateUpdate(Date arg) {
        arg.setDate(arg.getDate() + 1);
        System.out.println("arg in nextDay:" + arg);
    }

    private static void nextDateReplace(Date arg) {
        arg = new Date(arg.getYear(), arg.getMonth(), arg.getDate() + 1);
        System.out.println("arg in nextDay:" + arg);
    }
}

输出结果:

arg in nextDay:Thu Apr 02 00:00:00 CST 1998
d1 after nextDay:Thu Apr 02 00:00:00 CST 1998
arg in nextDay:Thu Apr 02 00:00:00 CST 1998
d2 after nextDay:Wed Apr 01 00:00:00 CST 1998

6.8 以函数对象取代函数(Replace Method with Method Object)

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

Class Order...
	double price(){
		double primaryBasePrice();
		double secondaryBasePrice();
		double tertiaryBasePrice();
	}

转变为:

image-20210528221101435

做法

  • 新建一个类,根据用途来命名;
  • 在新类中建立一个final字段,用以保存原先大型函数所在的对象,简称“源对象”,同时针对原函数的每个临时变量和每个参数,在新类中建立一个对应的字段保存;
  • 在新类中建立一个构造函数,接收源对象及原函数的所有参数作为参数;
  • 在新类中建立一个compute()函数;
  • 将原函数的代码复制到compute()函数中;
  • 编译
  • 将旧函数的函数本体替换为:创建新类的一个对象,然后调用compute()方法。

6.9 替换算法(Substitute Algorithm)

如果你发现做一件事可以有更清晰的方式,那就应该以较清晰的方式取代复杂的方式。

做法

  • 准备好另一个(替换用)算法,让它通过编译;
  • 针对现有测试,执行上述的新算法,比较两者的效果。

第7章 在对象之间搬移特性

7.1 搬移函数(Move Method)

如果一个类有太多行为,或如果一个类与另一个类有太多合作而形成高度耦合,就要考虑搬移函数。

做法

  • 检查源类中被源函数所使用的一切特性(包括字段和函数),考虑它们是否也该被搬移;
  • 检查源类的子类和超类,看看是否有该函数的其他声明;
  • 在目标类中声明这个函数;
  • 将源函数的代码复制到目标函数中,使其能正常工作;
  • 编译目标类;
  • 决定如何从源函数正确引入目标对象;
  • 修改源函数,使之成为一个纯委托函数;
  • 编译、测试;
  • 决定是否删除源函数,或将它当做一个委托函数保留下来;
  • 编译、测试。

范例

用一个表示“账户”的Account类来说明这项重构:

public class Account {
    private AccountType type;
    private int daysOverdrawn;

    /**
     * 透支金额计费规则
     * @return
     */
    double overdraftCharge() {
        if (type.isPremium()) {
            double result = 10;
            if (daysOverdrawn > 7) {
                result += (daysOverdrawn - 7) * 0.85;
            }
            return result;
        } else {
            return daysOverdrawn * 1.75;
        }
    }

    double bankCharge() {
        double result = 4.5;
        if (daysOverdrawn > 0) {
            result += overdraftCharge();
        }
        return result;
    }
}

因为“透支金额计费规则”随着账户类型而变化,所以将overdraftCharge()搬移到AccountType类去。

第一步:观察被overdraftCharge()使用的每一项特性,考虑是否值得将它们与overdraftCharge()一起移动,这里需要让daysOverdrawn留在Account类,因为这个值会随不同账户而变化。将函数复制到AccountType中,并进行相应调整。

double overdraftCharge(int daysOverdrawn) {
        if (isPremium()) {
            double result = 10;
            if (daysOverdrawn > 7) {
                result += (daysOverdrawn - 7) * 0.85;
            }
            return result;
        } else {
            return daysOverdrawn * 1.75;
        }
    }

当我们需要使用源类的特性时,有4种选择:

  1. 将这个特性也移到目标类;
  2. 建立或使用一个从目标类到源类的引用关系;
  3. 将源对象当做参数传给目标函数;
  4. 如果特性是个变量,将它当做参数传给目标函数。

调整目标函数使之通过编译,然后就可以将源函数的函数本体替换为一个简单的委托动作,然后编译并测试。

double overdraftCharge() {
        return type.overdraftCharge(daysOverdrawn);
    }

7.2 搬移字段(Move Field)

如果某个字段被其所驻类之外的类更多地用到,则需要在目标类新建一个字段,修改源字段的所有用户,令它们改用新字段。

范例

public class Account {
    private AccountType type;
    private int daysOverdrawn;
    private double interestRate;

    double interestForAmount_days(double amount,int days){
        return interestRate * amount * days / 365;
    }
}

这里我想把表示利率的interestRate搬移到AccountType类去。

在AccountType中建立interestRate字段以及相应的访问函数:

public class AccountType {
    private double interestRate;

    public double getInterestRate() {
        return interestRate;
    }

    public void setInterestRate(double interestRate) {
        this.interestRate = interestRate;
    }
}

现在需要让Account类中访问interestRate字段的函数转而使用AccountType对象,然后删除Account类中的interestRate字段。

double interestForAmount_days(double amount,int days){
    return type.getInterestRate() * amount * days / 365;
}

7.3 提炼类(Extract Class)

如果某个类做了应该由两个类做的事,则需要建立一个新类,将相关的字段和函数从旧类搬移到新类。

一个类应该是一个清楚的抽象,处理一些明确的责任。

7.4 将类内联化(Inline Class)

如果某个类没有做太多事情,则考虑将这个类的所有特性搬移到另一个类中,然后移除原类。

7.5 隐藏“委托关系”(Hide Delegate)

客户通过一个委托类来调用另一个对象,如在服务类上建立客户所需的所有函数,用以隐藏委托关系。

image-20210528221542715

做法

  • 对于每一个委托关系中的函数,在服务对象端建立一个简单的委托函数;
  • 调整客户,令它只调用服务对象提供的函数;
  • 每次调整后,编译并测试;
  • 如果将来不再有任何客户需要取用Delegate(受托类),便可移除服务对象中的相关访问函数;
  • 编译,测试。

范例

先编写代表“人”的Person和代表“部门”的Department:

public class Person {
    Department department;

    public Department getDepartment() {
        return department;
    }

    public void setDepartment(Department department) {
        this.department = department;
    }
}

public class Department {
    private String chargeCode;
    private Person manager;

    public Department(Person manager) {
        this.manager = manager;
    }

    public Person getManager() {
        return manager;
    }

    public void setManager(Person manager) {
        this.manager = manager;
    }
}

如果客户希望知道某人的经理是谁,他必须先取得Department对象:

manager = john.getDepartment().getManager();

这样的编码就是对客户揭露了Department的工作内容,而且属于高耦合代码,为了对客户隐藏Department,减少耦合,在Person类中建立一个简单的委托函数:

public Person getManager(){
	return department.getManager();
}

然后让Person的所有对象去调用新函数getManager,就可以将getDepartment()移除了。

7.6 移除中间人(Remove Middle Man)

某个类做了过多的简单委托动作,让客户直接调用受托类。

7.7 引入外加函数(Introduce Foreign Method)

当你需要为提供服务的类增加一个函数,但无法修改这个类的时候,可以在客户类中建立一个函数(nextDay),并以第一参数形式传入一个服务类实例(previousEnd)。

Date newStart = new Date(previousEnd.getYear(),previousEnd.getMonth(),previousEnd.getDate()+1);

替换为:

Date newStart = nextDay(previousEnd);

private static Date nextDay(Date arg){
	return new Date(arg.getYear(),arg.geteMonth(),arg.getDate()+1);
}

7.8 引入本地扩展(Introduce Local Extension)

当你需要为服务类提供一些额外函数,但你无法修改这个类的时候,可以建立一个新类,使它包含这些额外函数(新特性),让这个扩展品成为源类的子类或者包装类

范例

以Java1.0.1的Date类为例,假设我有一些功能需要增加。

使用子类:

class MfDateSub extends Date{
    public MfDateSub(Date arg){
        super(arg.getTime());
    }
	public MfDateSub nextDay(){
        return new Date(getYear(),getMonth(),getDate()+1);
    }
}

使用包装类则需要用上委托:

class MfDateWrap{
	private Date original;
    public MfDateWrap(String dateString){
        original = new Date(dateString);
    }
    public MfDateWrap(Date arg){
        original = arg;
    }
    public Date nextDay(){
        return new Date(getYear(),getMonth(),getDate()+1);
    }
}

第8章 重新组织数据

8.1 自封装字段(Self Encapsulate Field)

为字段建立取值/设置函数,并且只以这些函数来访问字段。

8.2 以对象取代数据值(Replace Data Value with Object)

比如说我们通常用字符串来存储电话号码,但是一旦需要将电话号码“格式化”或者“抽取区号”等,那我们就需要将电话号码转换为对象。

范例

订单类Order,用字符串customer来记录订单客户。

public class Order {
    private String customer;

    public Order(String customer) {
        this.customer = customer;
    }

    public String getCustomer() {
        return customer;
    }

    public void setCustomer(String customer) {
        this.customer = customer;
    }
}

改造,新建一个Customer类来表示“客户”概念,然后在这个类中建立一个final字段,用以保存一个字符串,记录客户名称。

public class Customer {
    private final String name;

    public Customer(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }
}

接着将Order中的customer字段的类型修改为Customer,并修改所有引用该字段的函数。

public class Order {
    private Customer customer;

    public Order(String customerName) {
        this.customer = new Customer(customerName);
    }

    public String getCustomerName(){
        return customer.getName();
    }

    public void setCustomer(String customerName){
        customer = new Customer(customerName);
    }
}

8.3 将值对象改为引用对象(Change Value to Reference)

8.4 将引用对象改为值对象(Change Reference to Value)

如果引用对象开始变得难以使用,就应该将其改为值对象。

值对象是不可变的,因此如果对象无法修改为不可变的,也就无法将引用对象改为值对象

8.5 以对象取代数组(Replace Array with Object)

如果数组中的元素代表不同的东西,可以利用对象来取代数组,数组中的每个元素,用一个字段来表示。

8.6 复制“被监视数据”(Duplicate Observed Data)


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!

Java全栈工程师的必修之路 Previous
IDEA Debug指南 Next