重构:改善既有代码的设计——第1章

第1章 重构,第一个案例

我该从何说起呢?按照传统说法,一开始介绍某个东西时,首先应该大致讲讲它的历史、主要原理等等。可是每当有人在会场上介绍这些东西,总是诱发我的瞌睡虫。我的思绪开始游荡,我的眼神开始迷离,直到主讲人秀出实例,我才能够提起精神。实例之所以可以拯救我于太虚之中,因为它让我看见事情在真正进行。谈原理,很容易流于泛泛,又很难说明如何实际应用。给出一个实例,就可以帮助我把事情认识清楚。

所以我决定从一个实例说起。在此过程中我将告诉你很多重构的道理,并且让你对重构过程有一点感觉。然后我才能向你展开通常的原理介绍。

但是,面对这个介绍性实例,我遇到了一个大问题。如果我选择一个大型程序,那么对程序自身的描述和对整个重构过程的描述就太复杂了,任何读者都不忍卒读(我试了一下,哪怕稍微复杂一点的例子都会超过100页)。如果我选择一个容易理解的小程序,又恐怕看不出重构的价值。

和任何立志要介绍“应用于真实世界中的有用技术”的人一样,我陷入了一个十分典型的两难困境。我只能带引你看看如何在一个我所选择的小程序中进行重构,然而坦白说,那个程序的规模根本不值得我们那么做。但是如果我给你看的代码是大系统的一部分,重构技术很快就变得重要起来。所以请你一边观赏这个小例子,一边想象它身处一个大得多的系统。

1.1起点

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

我用了几个类来表现这个例子中的元素。图1-1是一张UML类图,用以显示这些类。

我会逐一列出这些类的代码。

image.png

Movie(影片)

Movie只是一个简单地纯数据类

package com.shenhuanjie.chatper._01;

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 arg) {
        _priceCode = arg;
    }

    public String getTitle() {
        return _title;
    }
}

Rental(租赁)

Rental表示某个顾客租了一部影片。

package com.shenhuanjie.chatper._01;

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;
    }

}

Customer(顾客)

Customer类用来表示顾客。就像其他类一样,它也拥有数据和相应的访问函数:

package com.shenhuanjie.chatper._01;

import java.util.Vector;

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;
    }
}

Customer还提供了一个用于生产详单的函数,图1-2显示这个函数带来的交互过程。完整代码显示于下一页。

image.png

    public String statement() {
        double totalAmount = 0;
        int frequentReterPoints = 0;
        Enumeration<Rental> rentals = _rentals.elements();
        String result = "Rental Record for " + getName() + "n";
        while (rentals.hasMoreElements()) {
            double thisAmount = 0;
            Rental each = rentals.nextElement();

            // determine amountsfor 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
            frequentReterPoints++;
            // add bonus for a two day new release rental
            if ((each.getMovie().getPriceCode() == Movie.NEW_RELEASE) && each.getDaysRented() > 1) {
                frequentReterPoints++;
            }

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

对此起始程序的评价

这个起始程序给你留下什么印象?我会说它设计得不好,而且很明显不符合面向对象精神。对于这样一个小程序,这些缺点其实没有什么大不了的。快速而随性地设计一个简单地程序并没有错。但如果这是复杂系统中具有代表性的一段,那么我就真的要对这个程序信心动摇了。Customer里头那个长长的statement()做的事情是在太多了,它做了很多原来应该由其他类完成的事情。

即便如此,这个程序还是能正常工作。所以这只是美学意义上的判断,只是对丑陋代码的厌恶,是吗?如果不去修改这个系统,那么的确如此,编译器才不会在乎代码好不好看呢。但是当我们打算修改系统的时候,就涉及了人,而人在乎这些。差劲的系统是很难修改的,因为很难找到修改点。如果很难找到修改点,程序员就很有可能犯错,从而引入bug。

在这个例子里,我们的用户希望对系统做一点修改。首先他们希望以HTML格式输出详单,这样就可以直接在网页上显示,这非常符合时下的潮流。现在请你想一想,这个变化会带来什么影响。看看代码你就会发现,根本不可能在打印HTML报表的函数中复用目前statement()的任何行为。你唯一可以做的就是编写一个全新的htmlStatement(),大量重复statement()的行为。当然,现在做这个还不太费力,你可以把statement()复制一份然后按需要修改就是了。

但如果计费标准发生变化,又会如何?你必须同时修改statement()和htmlStatement(),并确保两处修改的一致性。当你后续还要再修改时,复制粘贴带来的问题就浮现出来了。如果你编写的是一个永不需要修改的程序,那么渐渐贴贴就还好,但如果程序要保存很长时间,而且可能需要修改,复制粘贴行为就会造成潜在的威胁。

现在,第二个变化来了:用户希望改变影片分类规则,但是还没有决定怎么改。他们设想了几种方案,这些方案都会影响顾客消费和常客积分点的计算方式。作为一个经验丰富的开发者,你可以肯定:不论用户提出书面方案,你唯一能够获得的保证就是他们一定会在六个月之内再次修改它。

为了应付分类规则和计费规则的变化,程序必须对statement()做出修改。但如果我们把statement()内的代码复制到用以打印HTML详单的函数中,就必须确保将来的任何修改在两个地方保持一致。随着各种规则变得愈来愈复杂,适当的修改点愈来愈难找,不犯错的机会愈来愈少。

你的态度也许倾向于尽量少改程序:不管怎么说,它还运行得很好。你心里牢牢记着那句古老的工程谚语:“如果它没坏,就不要动它。”这个程序也许还没坏掉,但它造成了伤害。它让你的生活比较难过,因为你发现很难完成客户所需的修改。这时候,重构技术就该粉墨登场了。

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

1.2 重构的第一步

每当我要进行重构的时候,第一个步骤永远相同:我得为即将修改的代码建立一组可靠的测试环境。这些测试是必要的,因为尽管遵循重构手法可以使我避免绝大多数引入bug的情形,但我毕竟是人,毕竟有可能犯错。所以我需要可靠的测试。

由于statement()的运作结果是个字符串,所以我首先假设一些顾客,让他们每个人各租几部不同的影片,然后产生报表字符串。然后我就可以拿新字符串和手上已经检查过的参考字符串做比较。我把所有测试都设置好,只要在命令行输入一条Java命令就把它们统统运行起来。运行这些测试只需几秒钟,所以你会看到我经常运行它们。

测试过程中很重要的一部分,就是测试程序对于结果的报告方式。它们要么说“OK”,表示所有新字符串都和参考字符串一样,要么就列出失败清单,显示问题字符串的出现行号。这些测试都能够自我检验。是的,你必须让测试有能力自我检验,否则就得耗费大把时间来回比对,这会降低你的开发速度。

进行重构的时候,我们需要依赖测试,让它告诉我们是否引入了bug。好的测试是重构的根本。花时间建立一个优良的测试机制是完全值得的,因为当你修改程序时,好测试会给你必要的安全保障。测试机制在重构领域的地位实在太重要了,我将在第4章详细讨论它。

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

1.3 分解并重组statement()

第一个明显引起我注意的就是长得离谱的statement()。每当看到这样长长的函数,我就想把它大卸八块。要知道,代码块愈小,代码的功能就愈容易管理,代码的处理和移动也就愈轻松。

本章重构过程的第一阶段中,我将说明如何把长长的函数切开,并把较小块的代码移至更合适的类。我希望降低代码重复量,从而使新的(打印HTML详单用的)函数更容易撰写。

第一个步骤是找出代码的逻辑泥团并运用Extract Method。本例一个明显的逻辑泥团就是switch语句,把它提炼到独立函数中似乎比较好。

和任何重构手法一样,当我提炼一个函数时,我必须知道可能出什么错。如果提炼得不好,就可能给程序引入bug。所以从重构之前我需要先想出安全做法。由于先前我已经进行过数次这类重构,所以我已经把安全步骤记录于后面的重构列表中了。

首先我得在这段代码里找出函数内的局部变量和参数。我找到了两个,each和thisAmount,前者并未被修改,后者会被修改。任何不会被修改的变量都可以被我当成参数传入新的函数,至于会被修改的变量就需格外小心。如果只有一个变量会被修改,我可以把它当作返回值。thisAmount是个临时变量,其值在每次循环起始处被设为0,并且在switch语句之前不会改变,所以我可以直接把函数的返回值赋给它。

下面两页展示了重构前后的代码。重构前的代码在左页,重构后的代码在右页。凡是从函数提炼出来的代码,以及新代码所做的任何修改,只要我觉得不是明显到可以一眼看出,就以粗体字标示出来提醒你。本章剩余部分将延续这种左右比对形式。

    public String statement() {
        double totalAmount = 0;
        int frequentReterPoints = 0;
        Enumeration<Rental> rentals = _rentals.elements();
        String result = "Rental Record for " + getName() + "n";
        while (rentals.hasMoreElements()) {
            double thisAmount = 0;
            Rental each = rentals.nextElement();

            // determine amountsfor 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;
            }*/
            thisAmount=amountFor(each);

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

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

    private int 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()的返回值类型声明为int,而不是double。

    private double amountFor(Rental each) {
        double 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;
    }
}

我经常犯这种愚蠢可笑的错误,而这之中错误往往很难发现。在这里,Java无怨无尤地把double类型转换为int类型,而且还愉快地做了取整动作[Java Spec]。还好此处这个问题很容易发现,因为我做的修改都很小,所以任何错误都很容易发现。你不必耗费大把时间调试,哪怕你和我一样粗心。

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

由于我用的是Java,所以我需要对代码做一些分析,决定如何处理局部变量。如果拥有相应的工具,这个工作就超级简单了。Smalltalk的确拥有这样的工具:Refactoring Browser。运用这个工具,重构过程非常轻松,我只需标示出需要重构的代码,在菜单中选择Extract Method,输入新的函数名称,一切就自动搞定。而且工具决不会想我那样犯下愚蠢可笑的错误。我非常盼望早日出现在Java版本的重构工具!

本书写作于1999年。十年之后,各种主要的Java IDE都已经提供了良好的重构支持。——译者注

现在,我已经把原来的函数分为两块,可以分别处理它们。我不喜欢amountFor( )类的某些变量的名称,现在正是修改它们的时候。

下面是原来的代码:

    private double amountFor(Rental each) {
        double 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;
    }

下面是改名后的代码:

    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;
    }

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

更改变量名称是值得的行为吗?绝对值得。好的代码应该清楚表达出自己的功能,变量名称是代码清晰的关键。如果为了提高代码的清晰度,需要修改某些东西的名字,那么就大胆去做吧。只要有良好的查找/替换工具,更改名称并不困难。语言所提供的强类型检查以及你自己的测试机制会指出任何你遗漏的东西。记住:

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

代码应该表现自己的目的,这一点非常重要。阅读代码的时候,我经常进行重构。这样,随着对程序的理解逐渐加深,我也就不断地把这些理解嵌入代码中,这么一来才不会遗忘我曾经理解的东西。

搬移“金额计算”代码

观察amountFor()时,我发现这个函数使用了来自Rental类的信息,却没有使用来自Customer类的信息。

class Customer...
    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;
    }

这立刻使我怀疑它是否被放错了位置。绝大多数情况下,函数应该放在它所使用的数据的所属对象内,所以amountFor()应该移到Rental类去。为了这么做,我要运用Move Method(142)。首先把代码复制到Rental类,调整代码使之适应新家,然后重新编译。像下面这样:

class Rental...
    private 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;
    }

在这个例子里,“适应新家”意味着要去掉参数。此外,我还要在搬移的同时变更函数名称。

现在我可以测试新函数是否正常工作。只要改变Customer.amountFor()函数内容,使它委托调用新函数即可:

class Customer...
    private double amountFor(Rental aRental) {
        return aRental.getCharge();
    }

现在我可以编译并测试,看看有没有破坏什么东西。

下一个步骤是找出程序中对于旧函数的所有引用点,并修改它们,让它们改用新函数。下面是原本的程序:

class Customer...
    public String statement() {
        double totalAmount = 0;
        int frequentReterPoints = 0;
        Enumeration<Rental> rentals = _rentals.elements();
        String result = "Rental Record for " + getName() + "n";
        while (rentals.hasMoreElements()) {
            double thisAmount = 0;
            Rental each = rentals.nextElement();

            thisAmount=amountFor(each);

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

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

本例之中,这个步骤很简单,因为我才刚刚产生新函数,只有一个地方使用了它。一般情况下,你得在可能运用该函数的所有类中查找一遍。

    public String statement() {
        double totalAmount = 0;
        int frequentReterPoints = 0;
        Enumeration<Rental> rentals = _rentals.elements();
        String result = "Rental Record for " + getName() + "n";
        while (rentals.hasMoreElements()) {
            double thisAmount = 0;
            Rental each = rentals.nextElement();

            thisAmount = each.getCharge();

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

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

image.png

做完这些修改之后(图1-3),下一件事就是去掉旧函数。编译器会告诉我是否我漏掉了什么。然后我进行测试,看看有没有破坏什么东西。

有时候我会保留旧函数,让它调用新函数。如果旧函数是一个public函数,而我又不想修改其他类的接口,这便是一种有用的手法。

当然我还想对Rental.getCharge()做些修改,不过暂时到此为止,让我们回到Customer.statement()函数。

    public String statement() {
        double totalAmount = 0;
        int frequentReterPoints = 0;
        Enumeration<Rental> rentals = _rentals.elements();
        String result = "Rental Record for " + getName() + "n";
        while (rentals.hasMoreElements()) {
            double thisAmount = 0;
            Rental each = rentals.nextElement();

            thisAmount = each.getCharge();

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

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

下一件引我注意的事是:thisAmount如今变得多余了。它接受each.getCharge()的执行结果,然后就不再有任何改变。所以我可以运用Replace Temp with Query(120)把thisAmount除去:

    public String statement() {
        double totalAmount = 0;
        int frequentReterPoints = 0;
        Enumeration<Rental> rentals = _rentals.elements();
        String result = "Rental Record for " + getName() + "n";
        while (rentals.hasMoreElements()) {
            Rental each = rentals.nextElement();

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

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

做完这份修改,我理科编译并测试,保证自己没有破坏任何东西。

我喜欢尽量除去这一类临时变量。临时变量往往引发问题,它们会导致大量参数被传来传去,而其实完全没有这种必要。你很容易跟丢它们,尤其在长长的函数之中更是如此。当然我这么做也需付出性能上的代价,例如本例的费用就被算计了两次。但是这很容易在Rental类中被优化。而且如果代码有合理地组织和管理,优化就会有很好的效果。我将在第69页的“重构与性能”一节详谈这个问题。

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

下一步要对“常客积分计算”做类似处理。积分的计算视影片种类而有不同,不过不像收费规则有那么多变化。看来似乎有理由把积分计算责任放在Rental类身上。首先需要针对“常客积分计算”这部分代码(粗体部分)运用Extract Method(110)重构手法:

    public String statement() {
        double totalAmount = 0;
        int frequentReterPoints = 0;
        Enumeration<Rental> rentals = _rentals.elements();
        String result = "Rental Record for " + getName() + "n";
        while (rentals.hasMoreElements()) {
            Rental each = rentals.nextElement();

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

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

我们再来看局部变量。这里再一次用到了each,而它可以被当作参数传入新函数中。另一个临时变量是frequentRenterPoints。本例中的它在被使用之前已经先有初值,但提炼出来的函数并没有读取该值,所以我们不需要将它当作参数传进去,只需把新函数的返回值累加上去就行了。

我完成了函数的提炼,重新编译并测试,然后做一次搬移,再编译、再测试。重构时最好小步前进,如此一来犯错的几率最小。

class Customer...
    public String statement() {
        double totalAmount = 0;
        int frequentReterPoints = 0;
        Enumeration<Rental> rentals = _rentals.elements();
        String result = "Rental Record for " + getName() + "n";
        while (rentals.hasMoreElements()) {
            Rental each = rentals.nextElement();

            frequentReterPoints += each.getFrequentRenterPoints();

            // show figures for this rental
            result += "t" + each.getMovie().getTitle() + "t" + String.valueOf(each.getCharge()) + "n";
            totalAmount += each.getCharge();
        }
        // add footer lines
        result += "Amount owed is " + String.valueOf(totalAmount) + "n";
        result += "You earned " + String.valueOf(frequentReterPoints) + " frequent renter points";
        return result;
    }
class Rental...
    public int getFrequentRenterPoints() {
        if ((getMovie().getPriceCode() == Movie.NEW_RELEASE) && getDaysRented() > 1) {
            return 2;
        } else {
            return 1;
        }
    }

我利用重构前后的UML图(图1-4~图1-7)来总结刚才所做的修改。和先前一样,左页是修改前的图,右页是修改后的图。
image.png

image.png

image.png

image.png

去除临时变量

正如我在前面提过的,临时变量可能是个问题。它们只在自己所属的函数中有效,所以它们会助长冗长而复杂的函数。这里有两个临时变量,两者都是用来从Customer对象相关的Rental对象中获得某个总量。不论ASCII版或HTML版都需要这些总量。我打算运用Replace Temp with Query(120),并利用查询函数(query method)来取代totalAmount和frequentRentalPoints这两个临时变量。由于类中的任何函数都可以调用上述查询函数,所以它能够促成较干净的设计,而较少冗长复杂的函数:

class Customer...
    public String statement() {
        double totalAmount = 0;
        int frequentReterPoints = 0;
        Enumeration<Rental> rentals = _rentals.elements();
        String result = "Rental Record for " + getName() + "n";
        while (rentals.hasMoreElements()) {
            Rental each = rentals.nextElement();

            frequentReterPoints += each.getFrequentRenterPoints();

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

首先我用Customer类的getTotalCharge()取代totalAmount:

class Customer...
    public String statement() {
        double totalAmount = 0;
        int frequentReterPoints = 0;
        Enumeration<Rental> rentals = _rentals.elements();
        String result = "Rental Record for " + getName() + "n";
        while (rentals.hasMoreElements()) {
            Rental each = rentals.nextElement();

            frequentReterPoints += each.getFrequentRenterPoints();

            // show figures for this rental
            result += "t" + each.getMovie().getTitle() + "t" + String.valueOf(each.getCharge()) + "n";
            totalAmount += each.getCharge();
        }
        // add footer lines
        result += "Amount owed is " + String.valueOf(getTotalCharge()) + "n";
        result += "You earned " + String.valueOf(frequentReterPoints) + " 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;
    }

这并不是Replace Temp whith Query(120)的最简单情况。由于totalAmount在循环内部被赋值,我不得不把循环复制到查询函数中。

重构之中,重新编译并测试,然后以同样手法处理frequentRenterPoints:

class Customer...
    public String statement() {
        int frequentReterPoints = 0;
        Enumeration<Rental> rentals = _rentals.elements();
        String result = "Rental Record for " + getName() + "n";
        while (rentals.hasMoreElements()) {
            Rental each = rentals.nextElement();
            frequentReterPoints += each.getFrequentRenterPoints();

            // show figures for this rental
            result += "t" + each.getMovie().getTitle() + "t" + String.valueOf(each.getCharge()) + "n";
        }

        // add footer lines
        result += "Amount owed is " + String.valueOf(getTotalCharge()) + "n";
        result += "You earned " + String.valueOf(frequentReterPoints) + " frequent renter points";
        return result;
    }
class Customer...
    public String statement() {
        Enumeration<Rental> rentals = _rentals.elements();
        String result = "Rental Record for " + getName() + "n";
        while (rentals.hasMoreElements()) {
            Rental each = rentals.nextElement();

            // show figures for this rental
            result += "t" + each.getMovie().getTitle() + "t" + String.valueOf(each.getCharge()) + "n";
        }

        // add footer lines
        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;
    }

图1-8~图1-11分别以UML类图和交互图展示statement()重构前后的变化。
image.png

image.png

image.png

image.png

图1-8~图1-11分别以UML类图和交互图展示statement()重构前后的变化。

image.png

image.png

通过计算逻辑的提炼,我可以完成一个htmlStatement(),并复用原本statement()内的所有计算。我不必剪剪贴贴,所以如果计算规则发生改变,我只需在程序中做一处修改。完成其他任何类型的详单也都很快而且很容易。这次重构并没有花很多时间,其中大半时间我用来弄清楚代码所做的事,而这是我无论如何都得做的。

前面有些代码是从ASCII版本中复制过来的——主要是循环设置部分。更深入的重构动作可以清除这些重复代码。我可以把处理表头(header)、表尾(footer)和详单细目的代码都分别提炼出来。在Form Template Method(345)实例中,你可以看到如何做这些动作。但是,现在用户又开始嘀咕了,他们准备修改影片分类规则。我们尚未清楚他们想怎么做,但似乎新分类法很快就要引入,现有的分类法马上就要变更。与之相应的费用计算方式和常客积分计算方式都还有待决定,现在就对程序做修改,肯定是愚蠢地。我必须进入费用计算和常客积分计算中,把因条件而异的代码替换掉,这样才能为将来的改变镀上一层保护膜。现在,请重新戴回“重构”这顶帽子。

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

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

class 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;
    }

这暗示getCharge()应该移到Movie类里去:

class Movie...
    public double getCharge(int daysRented) {
        double result = 0;
        switch (getPriceCode()) {
        case Movie.REGULAR:
            result += 2;
            if (daysRented > 2) {
                result += (-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;
    }

为了让它得以运作,我必须把租期长度作为参数传递进去。当然,租期长度来自Rental对象。计算费用时需要两项数据:租期长度和影片类型。为什么我选择将租期长度传给Movie对象,而不是将影片类型传给Rental对象呢?因为本系统可能发生的变化是加入新影片类型,这种变化带有不稳定倾向。如果影片类型有所变化,我希望尽量控制它造成的影响,所以选择在Movie对象内计算费用。

我把上述计费方法放进Movie类,然后修改Rental的getCharge(),让它使用这个新函数(图1-12和图1-13):

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

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

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

image.png

重构后的代码如下:

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

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

image.png

终于……我们来到继承

我们有数种影片类型,它们以不同的方式回答相同的问题。这听起来很像子类的工作。我们可以建立Movie的三个子类,每个都有自己的计费法(图1-14)。

image.png

这么一来,我就可以用多态来取代switch语句了。很遗憾的是这里有个小问题,不能这么干。一部影片可以在生命周期内修改自己的分类,一个对象却不能在生命周期内修改自己所属的类。不过还是有一个解决的方法:State模式[Gang of Four]。运用它之后,我们的类看起来像图1-15。

image.png

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

如果你很熟悉GoF(Gang of Four,四巨头)所列的各种模式,可能会问:“这是一个State,还是一个Strategy?”答案取决于Price类究竟代表计费方式(此时我喜欢把它叫做Pricer还PricingStrategy),还是代表影片的某个状态(例如“Star Trek X是一部新片”)。在这个阶段,对于模式(和其名称)的选择反映出你对结构的想法。此刻我把它视为影片的某种状态。如果未来我觉得Strategy能更好地说明我的意图,我会再重构它,修改名字,以形成Strategy。

为了引入State模式,我使用三个重构手法。首先运用Replace Type Code with State/Strategy(227),将与类型相关的行为搬移至State模式内。然后运用Move Method(142)将switch语句移到Price类。最有运用Replace Conditional with Polymorphism(225)去掉switch语句。

首先我要使用Replace Type Code with State/Strategy(227)。第一步骤是针对类型代码使用Self Encapsulate Field(171),确保任何时候都通过取值函数和设值函数来访问类型代码。多数访问操作来自其他类,它们已经在使用取值函数。但构造函数仍然直接访问价格代码。

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

我可以用一个设值函数来代替:

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

然后编译并测试,确保没有破坏任何东西。现在我新建一个Price类,并在其中提供类型相关的行为。为了实现这一点,我在Price类内加入一个抽象函数,并在所有子类中加上对应的具体函数:

package com.shenhuanjie.chatper._01;

public abstract class Price {
    public abstract int getPriceCode();
}
package com.shenhuanjie.chatper._01;

public class ChildrensPrice extends Price {

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

}
package com.shenhuanjie.chatper._01;

public class NewReleasePrice extends Price {

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

}

package com.shenhuanjie.chatper._01;

public class RegularPrice extends Price {

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

}

然后就可以编译这些新建的类了。

现在,我需要修改Movie类内的“价格代号”访问函数(取值函数/设值函数,如下),让它们使用新类。下面是重构前的样子:

public int getPriceCode() {
    return _priceCode;
}

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

private int _priceCode;

这意味着我必须在Movie类内保存一个Price对象,而不再是保存一个_priceCode变量。此外我还需要修改访问函数:

    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;
        }
    }
    private Price _price;
发表评论

电子邮件地址不会被公开。