# 第1章:重构,第一个案例

loading

# 一、起点

一个影片出租店的应用程序,根据顾客组的影片,计算消费金额。

影片类:

public class Movie {

    public static final int CHILDRENS = 2;
    public static final int REGULAR = 0;
    public static final int NEW_RELEASE = 1;

    private String _title;
    private int _priceCode;

    public Movie(String title, int priceCode) {
        _title = title;
        _priceCode = priceCode;
    }

    public int getPriceCode() {
        return _priceCode;
    }

    public void setPriceCode(int priceCode) {
        _priceCode = priceCode;
    }

    public String getTitle() {
        return _title;
    }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

租赁类:

public class Rental {

    private Movie _movie;
    private int _daysRented;

    public Rental(Movie movie, int daysRented) {
        _movie = movie;
        _daysRented = daysRented;
    }

    public int getDaysRented() {
        return _daysRented;
    }

    public Movie getMovie() {
        return _movie;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

顾客类:

public class Customer {

    private String _name;
    private Vector _rentals = new Vector();

    public Customer(String name) {
        _name = name;
    }

    public void addRental(Rental arg) {
        _rentals.addElement(arg);
    }

    public String getName() {
        return _name;
    }

    /**
     * 生成详单
     *
     * @return
     */
    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();

            // determine amounts for each line
            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;
            }

            // add frequent renter points
            frequentRenterPoints++;
            // add bonus for a two day new release rental
            if ((each.getMovie().getPriceCode() == Movie.NEW_RELEASE) && each.getDaysRented() > 1)
                frequentRenterPoints++;

            // show figures for this rental
            result += "\t" + each.getMovie().getTitle() + "\t" + thisAmount + "\n";
            totalAmount += thisAmount;
        }
        // add footer lines
        result += "Amount owed is " + totalAmount + "\n";
        result += "You earned " + frequentRenterPoints + " frequent renter points";
        return result;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64

在上面的 statement() 方法中做了很多很多了逻辑,有的其实并不属于这个类应该处理的。加入后期需要让这个方法返回一个 html 格式的详单,那么修改这个方法是不可能的了,肯定要单独写一个返回 html 输出的方法,那么其中的计费标准如果有变动的话,那么 statement() 和 新写的这个方法都会要改动,以后就得维护两个地方,改的越多越容易出错。

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

# 二、重构的第一步

由于 statement() 的运作结果是个字符串,所以先假设一些顾客租赁不同的影片,产生报表字符串,然后用这个字符串和重构后产生的字符串进行对比,看是否一致。

重构之前,首先检查自己是否有一套可靠的测试机制。这些测试必须有自我检验能力

下面是我的测试类:

public class ShopTest {

    @Test
    public void test() {
        Customer customer = new Customer("jerry");

        Movie movie1 = new Movie("爱乐之城", Movie.REGULAR);
        Rental rental1 = new Rental(movie1, 4);
        Movie movie2 = new Movie("千与千寻", Movie.CHILDRENS);
        Rental rental2 = new Rental(movie2, 2);

        customer.addRental(rental1);
        customer.addRental(rental2);

        System.out.println(customer.statement());
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

执行结果:

Rental Record for jerry
	爱乐之城	5.0
	千与千寻	1.5
Amount owed is 6.5
You earned 2 frequent renter points
1
2
3
4
5

# 三、分解并重组 statement()

将长的离谱的方法进行拆分。代码块俞小,代码的功能就俞容易管理,代码的处理和移动也就俞轻松。

首先,在方法内找到局部变量和参数,eachthisAmount,前者违背修改,后者会被修改,任何不会被修改的变量都可以被当成参数传入新的方法中,至于会被修改的变量就需格外小心。如果只有一个变量会被修改,可以当做返回值。thisAmount 是个临时变量,其值在每次循环起始处被设置0,并在 switch 语句之前不会被改变,所以可以直接把新方法的返回值赋给它。

重构后的代码:

public class Customer {

    private String _name;
    private Vector _rentals = new Vector();

    public Customer(String name) {
        _name = name;
    }

    public void addRental(Rental arg) {
        _rentals.addElement(arg);
    }

    public String getName() {
        return _name;
    }

    /**
     * 生成详单
     *
     * @return
     */
    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);

            // add frequent renter points
            frequentRenterPoints++;
            // add bonus for a two day new release rental
            if ((each.getMovie().getPriceCode() == Movie.NEW_RELEASE) && each.getDaysRented() > 1)
                frequentRenterPoints++;

            // show figures for this rental
            result += "\t" + each.getMovie().getTitle() + "\t" + thisAmount + "\n";
            totalAmount += thisAmount;
        }
        // add footer lines
        result += "Amount owed is " + totalAmount + "\n";
        result += "You earned " + frequentRenterPoints + " frequent renter points";
        return result;
    }

    private double amountFor(Rental each) {
        double thisAmount = 0;
        // determine amounts for each line
        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;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70

注意这里重构的时候可能会将 amountFor() 返回值定义成 int,那么就会出错,实际上应当是 double,做好测试后就会发现这个问题。

重构技术就是以微笑的步伐修改程序。如果你犯下错误,很容易便可发现它。

对于 amountFor() 方法中的变量名还不是很满意,于是对于 amountFor() 方法继续重构:

    private double amountFor(Rental aRental) {
        double result = 0;
        // determine amounts for aRental line
        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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

任何一个傻瓜都能写出计算机可以理解的代码。唯有写出人类容易理解的代码,才是优秀的程序员。

继续观察发现 amountFor() 方法使用了来自 Rental 类的信息,却没有使用来自 Customer 类的信息,所以这个方法应当已到 Rental 类中:

public class Rental {

    private Movie _movie;
    private int _daysRented;

    public Rental(Movie movie, int daysRented) {
        _movie = movie;
        _daysRented = daysRented;
    }

    public int getDaysRented() {
        return _daysRented;
    }

    public Movie getMovie() {
        return _movie;
    }
    
    double getCharge() {
        double result = 0;
        // determine amounts for aRental line
        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;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

现在就可以去掉 Customer 类中的 amountFor() 方法,直接采用 Rental 类的 getCharge() 方法了。

    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 = each.getCharge();

            // add frequent renter points
            frequentRenterPoints++;
            // add bonus for a two day new release rental
            if ((each.getMovie().getPriceCode() == Movie.NEW_RELEASE) && each.getDaysRented() > 1)
                frequentRenterPoints++;

            // show figures for this rental
            result += "\t" + each.getMovie().getTitle() + "\t" + thisAmount + "\n";
            totalAmount += thisAmount;
        }
        // add footer lines
        result += "Amount owed is " + totalAmount + "\n";
        result += "You earned " + frequentRenterPoints + " frequent renter points";
        return result;
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

下一步,其实发现 statement() 方法中的 thisAmount 可以直接使用 each.getCharge() 来替换就可以了:

    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();

            // add frequent renter points
            frequentRenterPoints++;
            // add bonus for a two day new release rental
            if ((each.getMovie().getPriceCode() == Movie.NEW_RELEASE) && each.getDaysRented() > 1)
                frequentRenterPoints++;

            // show figures for this rental
            result += "\t" + each.getMovie().getTitle() + "\t" + each.getCharge() + "\n";
            totalAmount += each.getCharge();
        }
        // add footer lines
        result += "Amount owed is " + totalAmount + "\n";
        result += "You earned " + frequentRenterPoints + " frequent renter points";
        return result;
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

虽然这么做会在 getCharse() 中进行两次计算,但是这个计算可以在 Rental 类中进行优化。

接下来开始优化计算常客积分的这部分代码。

依旧是在 Rental 类中新增一个方法:

    int getFrequentRenterPoints() {
        int frequentRenterPoints = 0;
        // add frequent renter points
        frequentRenterPoints++;
        // add bonus for a two day new release rental
        if ((getMovie().getPriceCode() == Movie.NEW_RELEASE) && getDaysRented() > 1)
            frequentRenterPoints++;
        return frequentRenterPoints;
    }
1
2
3
4
5
6
7
8
9

用于替换在 Customer 类中计算常客积分部分代码:

    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();

            // show figures for this rental
            result += "\t" + each.getMovie().getTitle() + "\t" + each.getCharge() + "\n";
            totalAmount += each.getCharge();
        }
        // add footer lines
        result += "Amount owed is " + totalAmount + "\n";
        result += "You earned " + frequentRenterPoints + " frequent renter points";
        return result;
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

接下来处理临时变量的问题,在 statement() 方法中还存在两个临时变量 totalAmountfrequentRenterPoints ,首先用 getTotalCharge() 取代 totalAmount

    public String statement() {
        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();

            // show figures for this rental
            result += "\t" + each.getMovie().getTitle() + "\t" + each.getCharge() + "\n";
        }
        // add footer lines
        result += "Amount owed is " + getTotalCharge() + "\n";
        result += "You earned " + 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;
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

然后以同样的方法处理 frequentRenterPoints

    public String statement() {
        Enumeration rentals = _rentals.elements();
        String result = "Rental Record for " + getName() + "\n";
        while (rentals.hasMoreElements()) {
            Rental each = (Rental) rentals.nextElement();

            // show figures for this rental
            result += "\t" + each.getMovie().getTitle() + "\t" + each.getCharge() + "\n";
        }
        // add footer lines
        result += "Amount owed is " + getTotalCharge() + "\n";
        result += "You earned " + 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;
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

现在利用现有的这些方法,可以轻松的实现一个 htmlStatement() 方法出来,并且如果计算规则发生改变,只需要在程序中做一处修改。

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

现在在 Rental 类的 getCharge() 方法中还有一部分 switch 语句:

    double getCharge() {
        double result = 0;
        // determine amounts for aRental line
        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;
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

最好不要再另一个对象的属性基础上运用 switch 语句。如果不得不使用,,也应该在对象自己的数据上使用,而不是在别人的数据上使用。也就是说这部分 switch 语句应当移动到 Movie 类中,若如此,就得将 getDaysRented() 作为一个参数传递到 Movie 类中将要创建的方法中去。先看 Movie 新增的方法:

    double getCharge(int daysRented) {
        double result = 0;
        // determine amounts for aRental line
        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;
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

计算费用时需要两项数据:租期长度和影片类型,一种方式是将租期长度传递给 Movie 计算,另一种方式是将影片类型传递给 Rental 计算,这里为什么要将租期作为参数传递给 Movie 类呢?因为系统后期可能会增加新的影片类型,这种变化带有不稳定倾向,所以选择在 Movie 对象内计算费用。

Rental 的计算方法简化为:

    double getCharge() {
        return _movie.getCharge(_daysRented);
    }
1
2
3

同样的方式处理常客积分,在 Movie 类中新增方法:

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

Rental 的计算方法简化为:

    int getFrequentRenterPoints() {
        return _movie.getFrequentRenterPoints(_daysRented);
    }
1
2
3

我们有数种影片类型,它们以不同的方式回答相同的问题。这听起来像子类的工作。可以建立 Movie 的三个子类,每个都有自己的计费方法。这样一来就可以使用多态来取代 switch 语句了,这里有个问题:一部影片可以在声明周期内修改自己的分类,一个对象却不能在生命周期内修改自己所属的类。这里就可以使用设计模式中的状态模式,新建一个抽象类 Price,让几种不同的计费规则继承这个类。

先将与类型相关的行为搬移到状态模式中,然后将 switch 语句移动到 Price 类中,最后去掉 switch

Movie 的构造方法修改为:

    public Movie(String title, int priceCode) {
        _title = title;
        setPriceCode(priceCode);
    }
1
2
3
4

新建 Price 类:

public abstract class Price {

    abstract int getPriceCode();
}
1
2
3
4

在创建3个类,继承自 Price,每个类中都提供类型相关的行为:

public class ChildrensPrice extends Price{

    @Override
    int getPriceCode() {
        return Movie.CHILDRENS;
    }
}
1
2
3
4
5
6
7
public class NewReleasePrice extends Price{

    @Override
    int getPriceCode() {
        return Movie.NEW_RELEASE;
    }
}
1
2
3
4
5
6
7
public class RegularPrice extends Price{

    @Override
    int getPriceCode() {
        return Movie.REGULAR;
    }
}
1
2
3
4
5
6
7

现在修改 Movie 类中访问价格代号的方法,使用新创建的类,此时 Movie 类还得新增一个 Price 对象:

    private Price _price;

    public Movie(String title, int priceCode) {
        _title = title;
        setPriceCode(priceCode);
    }

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

    public void setPriceCode(int priceCode) {
        switch (priceCode) {
            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");
        }
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

Price 类新增一个实现好的方法:

    double getCharge(int daysRented) {
        double result = 0;
        // determine amounts for aRental line
        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;
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

这样 Movie 中的 getCharge() 方法就可以直接引用 PricegetCharge() 方法了:

    double getCharge(int daysRented) {
       return _price.getCharge(daysRented);
    }
1
2
3

又因为每个 Price 的子类实现计费的方法还是放在各自类中比较合理,所以将 PricegetCharge() 方法分发到各自子类中去吧:

public class RegularPrice extends Price {

    @Override
    int getPriceCode() {
        return Movie.REGULAR;
    }

    @Override
    double getCharge(int daysRented) {
        double result = 2;
        if (daysRented > 2)
            result += (daysRented - 2) * 1.5;
        return result;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class NewReleasePrice extends Price {

    @Override
    int getPriceCode() {
        return Movie.NEW_RELEASE;
    }

    @Override
    double getCharge(int daysRented) {
        return daysRented * 3;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
public class ChildrensPrice extends Price {

    @Override
    int getPriceCode() {
        return Movie.CHILDRENS;
    }

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

    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

替换完所有的子类之后,就可以将 Price 中的 getCharge() 方法声明为抽象的了:

public abstract class Price {
    
    abstract int getPriceCode();

    abstract double getCharge(int daysRented);
}
1
2
3
4
5
6

同样的手法处理 Movie 中的 getFrequentRenterPoints() 方法吧。在 Price 类中新增 getFrequentRenterPoints() 方法:

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

Movie 中就可以直接调用这个方法:

    int getFrequentRenterPoints(int daysRented) {
        return _price.getFrequentRenterPoints(daysRented);
    }
1
2
3

因为 getFrequentRenterPoints() 方法中只对类型是 Movie.NEW_RELEASE 的有特殊处理,所以这次决定在 Price 类中保留一个默认实现,然后让 NewReleasePrice 类覆盖这个方法,实现它自己的逻辑即可。

Movie 类中的默认实现:

public abstract class Price {

    abstract int getPriceCode();

    abstract double getCharge(int daysRented);

    int getFrequentRenterPoints(int daysRented) {
        return 1;
    }
}
1
2
3
4
5
6
7
8
9
10

NewReleasePrice 类覆盖 getFrequentRenterPoints() 方法:

public class NewReleasePrice extends Price {

    @Override
    int getPriceCode() {
        return Movie.NEW_RELEASE;
    }

    @Override
    double getCharge(int daysRented) {
        return daysRented * 3;
    }

    @Override
    int getFrequentRenterPoints(int daysRented) {
        return daysRented > 1 ? 2 : 1;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

这下,如果需要修改任何与价格有关的行为,或者是添加新的计费规则,或者是加入其它决定价格的行为,程序的修改都会容易的多。


上次更新: 2020-08-21 09:02:51(10 小时前)