重构

概要:

重构是对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。

摘自《重构:改善既有代码的设计》一书中的第1-2章,作者【美】Martin Fowler。

| |目录

案例介绍

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

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

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

图1-1 本例一开始的各个类。此图只显示最重要的特性。图中所用符号是UML。

Movie(影片)

Movie只是一个简单的纯数据类。

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

    private String title;
    private int priceCode;

    public Movie(String title, int priceCode) {
        super();
        this.title = title;
        this.priceCode = priceCode;
    }

    public String getTitle() {
        return title;
    }

    public int getPriceCode() {
        return priceCode;
    }

    public void setPriceCode(int priceCode) {
        this.priceCode = priceCode;
    }
}

Rental(租赁)

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

class Rental {
    private Movie movie;
    private int daysRented;

    public Rental(Movie movie, int daysRented) {
        super();
        this.movie = movie;
        this.daysRented = daysRented;
    }

    public Movie getMovie() {
        return movie;
    }

    public int getDaysRented() {
        return daysRented;
    }
}

Customer(顾客)

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

class Customer {
    private String name;
    private Vector<Rental> rentals = new Vector<Rental>();

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

    public void addRental(Rental rental) {
        rentals.addElement(rental);
    }

    public String getName() {
        return name;
    }
}

Customer还提供了一个用于生成详单的函数。

public String statement() {
    double totalAmount = 0;
    int frequentRenterPoints = 0;
    Enumeration<Rental> rentals = this.rentals.elements();
    String result = "Rental Record for " + getName() + "\n";
    while (rentals.hasMoreElements()) {
        double thisAmount = 0;
        Rental each = 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" + String.valueOf(thisAmount) + "\n";
        totalAmount += thisAmount;
    }
    
    // add footer lines
    result += "Amount owed is " + String.valueOf(totalAmount) + "\n";
    result += "You earned " + String.valueOf(frequentRenterPoints) + " frequent renter points";
    return result;
}

对此起始程序的评价

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

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

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

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

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

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

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

TIPS

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

重构的第一步

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

分解并重组statement()

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

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

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

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

重构后的代码如下:

public String statement() {
    double totalAmount = 0;
    int frequentRenterPoints = 0;
    Enumeration<Rental> rentals = this.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
        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" + String.valueOf(thisAmount) + "\n";
        totalAmount += thisAmount;
    }

    // add footer lines
    result += "Amount owed is " + String.valueOf(totalAmount) + "\n";
    result += "You earned " + String.valueOf(frequentRenterPoints) + " 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;
    default:
        break;
    }
    return thisAmount;
}

每次做完这样的修改,我都要编译并测试。这一次起头不算好——测试失败了,有2条测试数据告诉我发生了错误。一阵迷惑后,我明白了自己犯的错误。我愚蠢的将amountFor()的返回值声明为int,而不是double。

我经常犯这种愚蠢可笑的错误,而这种错误往往很难发现。在这里,Java无怨无尤地把double类型转换为int类型,而且而且还愉快地做了取整动作。还好此处这个问题很容易发现,因为我做的修改很小,而且我又很好的测试。借着这个意外疏忽,我要阐述重构的本质重构就是以微小的步伐修改程序。如果你犯下错误,很容易便可发现它。

现在我已经把原来的函数分为两块,可以分别处理它们。我不喜欢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;
    default:
        break;
    }
    return result;
}

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

改变变量名称是值得的行为吗?绝对值得。好的代码应该清楚表达出自己的功能,变量名称是代码清晰的关键。如果为了提高代码的清晰度,需要修改某些东西的名字,那么久大胆去做吧。只要有良好的查找/替换工具(TIPS:Eclipse中可以将光标放在变量上,然后按下ALT+SHIFT+R),改名称并不困难。语言所提供的强类型检查以及你自己的测试机制会指出任何你遗漏的东西。记住:任何一个傻瓜都能写出计算机可以理解的代码。唯有写出人类容易理解的代码,才是优秀的程序员。 

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

搬移“金额计算”代码

观察amountFor()时,我发现这个函数使用了来自Rental类的信息,却没有使用来自Customer类的信息。这立刻使我怀疑它是否被放错了位置。绝大多数情况下,函数应该放在它所使用的数据的所属对象内,所以amountFor()应该移到Rental类去。

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;
    default:
        break;
    }
    return result;
}

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

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

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

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

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

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

class Customer...
public String statement() {
    ...
    thisAmount = each.getCharge();
    ...
}

图1-2 搬移“金额计算”函数后,所有类的状态

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

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

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

下一件引起我注意的事是:thisAmount如今变得多余了。它接受each.getCharge()的执行结果,然后就不再有任何改变。所以我可以使用查询函数替换临时变量把thisAmount除去:

public String statement() {
    double totalAmount = 0;
    int frequentRenterPoints = 0;
    Enumeration<Rental> rentals = this.rentals.elements();
    String result = "Rental Record for " + getName() + "\n";
    while (rentals.hasMoreElements()) {
        Rental each = 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" + String.valueOf(each.getCharge()) + "\n";
        totalAmount += each.getCharge();
    }

    // add footer lines
    result += "Amount owed is " + String.valueOf(totalAmount) + "\n";
    result += "You earned " + String.valueOf(frequentRenterPoints) + " frequent renter points";
    return result;
}

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

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

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

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

// 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++;

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

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

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

        frequentRenterPoints += 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(frequentRenterPoints) + " 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-3 “常客积分计算”函数被提炼及搬移之前的类图

图1-4 “常客积分计算”函数被提炼搬移之后的类图

去除临时变量

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

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

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

        frequentRenterPoints += 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(frequentRenterPoints) + " frequent renter points";
    return result;
}

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

这并不是使用查询函数替换临时变量的最简单情况。由于totalAmount在循环内部被赋值,我不得不把循环复制到查询函数中。 

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

class Customer...
public String statement() {
    Enumeration<Rental> rentals = this.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<Rental> rentals = this.rentals.elements();
    while (rentals.hasMoreElements()) {
        Rental each = rentals.nextElement();
        result += each.getFrequentRenterPoints();
    }
    return result;
}

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

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

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


做完这次重构,有必要停下来思考一下。大多数重构都会减少代码总量,但这次却增加了代码总量,那是因为Java需要大量语句来设置一个累加循环。哪怕只是一个简单的累加循环,每个元素只需一行代码,外围的支持代码也需要六行之多。这其实是任何程序员都熟悉的习惯写法,但代码量还是太多了。

这次重构存在另一个问题,那就是性能。原本代码只执行while循环一次,新版本要执行三次。如果while循环耗时很多,就可能大大降低程序的性能。单单为了这个原因,许多程序员不愿进行这个重构动作,但是请注意我的用词:“如果”和“可能”。除非我进行评测,否则我无法确定循环的执行时间,也无法知道这个循环是否被经常使用以至于影响系统的整体性能。重构时你不必担心这些,优化时你才需要担心它们,但那时你已处于一个比较有利的位置,有更多选择可以完成有效优化。

现在,Customer类内的任何代码都可以调用这些查询函数了,如果系统其他部分需要这些信息,也可以轻松地将查询函数加入Customer类接口。如果没有这些查询函数,其他函数就必须了解Rental类,并自行建立循环。在一个复杂系统中,这将使程序的编写难度和维护难度大大增加。

你可以很明显看出来,htmlStatement()和statement()是不同的。现在,我应该脱下“重构”的帽子,带上“添加功能”的帽子。我可以像下面这样编写htmlStatement(),并添加相应测试:

public String htmlStatement() {
    Enumeration<Rental> rentals = this.rentals.elements();
    String result = "<H1>Rentals for <EM>" + getName() + "</EM></H1><P>\n";
    while (rentals.hasMoreElements()) {
        Rental each = rentals.nextElement();
        // show figures for each rental
        result += each.getMovie().getTitle() + ": " + String.valueOf(each.getCharge()) + "<BR>\n";
    }

    // add footer lines
    result += "<P> You owe <EM>" + String.valueOf(getTotalCharge()) + "</EM><P>\n";
    result += "On this rental you earned <EM>" + String.valueOf(getTotalFrequentRenterPoints())
            + "</EM> frequent renter points<P>";
    return result;
}

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

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

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

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

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

class Movie...
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;
    default:
        break;
    }
    return result;
}

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

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

class Rental...
public double getCharge() {
    return movie.getCharge(daysRented);
}

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

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

图1-7 本节所讨论的两个函数被移到Movie类内之前系统的类图

图1-8 本节所讨论的两个函数被移到Movie类内之后系统的类图

终于……我们来到继承

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

图1-9 以继承机制表现不同的影片类型


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

图1-10 运用State模式表现不同的影片


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

现在我新建一个Price类,并在其中提供类型相关的行为。为了实现这一点,我在Price类内加入一个抽象函数,并在所有子类中加上对应的具体函数:

abstract class Price {
    abstract int getPriceCode();
}

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

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

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

现在,我们需要修改Movie类内的“价格代号”访问函数,让他们使用新类。这意味着我必须在Movie类内保存一个Price对象,而不再是保存一个priceCode变量,此外,我还需要修改访问函数:

class Movie...
private Price price;
    
public Movie(String title, int priceCode) {
    this.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 ChildrenPrice();
        break;
    case NEW_RELEASE:
        price = new NewReleasePrice();
        break;
    default:
        throw new IllegalArgumentException("Incorrect Price Code");
    }
}

现在我可以重新编译并测试,那些比较复杂的函数根本不知道世界已经变了个样。

现在我要对getCharge()实施搬移函数,搬移动作很简单。下面是重构后的代码:

class Movie...
public double getCharge(int daysRented) {
    return price.getCharge(daysRented);
}

class 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;
    default:
        break;
    }
    return result;
}

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

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

这个函数覆盖了父类中的case语句,而我暂时还把后者留在原处不动。现在编译并测试,然后取出下一个case分支,再编译并测试。

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

class NewReleasePrice...
@Override
public double getCharge(int daysRented) {
    return daysRented * 3;
}

处理完所有case分支之后,我就把Price.getCharge()声明为abstract:

class Price...
abstract double getCharge(int daysRented);

现在我可以运用同样的手法处理getFrequentRenterPoints()。首先我把这个函数移到Price类:

class Movie...
public int getFrequentRenterPoints(int daysRented) {
    return price.getFrequentRenterPoints(daysRented);
}

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

但是这一次,我不把超类函数声明为abstract。我只是为新片类型增加一个覆写函数,并在超类内留下一个已定义的函数,使它成为一种默认行为。

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

class Price...
public int getFrequentRenterPoints(int daysRented) {
    return 1;
}

引入State模式花了不少力气,值得吗?这么做的收获是:如果我要修改任何与价格有关的行为,或者添加新的定价标准,或是加入其他取决于价格的行为,程序的修改会容易得多。这个程序的其余部分并不知道我运用了State模式。对于我目前拥有的这么几个小量行为来说,任何功能或特性上的修改也许都不合算,但如果在一个更复杂的系统中,有十多个与价格相关的函数,程序的修改难易度就会有很大的区别。以上所有修改都是小步骤进行,进度似乎太过缓慢,但是我一次都没有打开过调试器,所以整个过程实际上很快就过去了。

现在我已经完成了第二个重要的重构行为。从此,修改影片分类结构,或是改变费用计算规则、改变常客积分计算规则,都容易多了。图1-11描述State模式对于价格信息所起的作用。

图1-11 加入State模式后的类图

重构与性能

关于重构,有一个常被提出的问题:它对程序的性能将造成怎样的影响?为了让软件易于理解,你常会做出一些使程序运行变慢的修改。这是个重要的问题。我并不赞成为了提高设计的纯洁性而忽视性能,把希望寄托于更快的硬件身上也绝非正道。已经有很多软件因为速度太慢而被用户拒绝,日益提高的机器速度也只不过略微放宽了速度方面的限制而已。但是,换个角度说,虽然重构可能使软件运行更慢,但它也使软件的性能优化更容易。除了对性能有严格要求的实时系统,其他任何情况下“编写快速软件”的秘密就是:首先写出可调的软件,然后调整它以求获得足够速度。

我看过三种编写快速软件的方法。其中最严格的是时间预算法,这通常只用于性能要求极高的实时系统。如果使用这种方法,分解你的设计时就要做好预算,给每个组件预先分配一定资源——包括时间和执行轨迹。每个组件绝对不能超出自己的预算,就算拥有组件之间调度预配时间的机制也不行。这种方法高度重视性能,对于心律调节器一类的系统是必须的,因为在这样的系统中迟来的数据就是错误的数据。但对其他系统(例如我经常开发的企业信息系统)而言,如此追求高性能就有点过分了。

第二种方法是持续关注法。这种方法要求任何程序员在任何时间做任何事时,都要设法保持系统的高性能。这种方式很常见,感觉上很有吸引力,但通常不会起太大作用。任何修改如果是为了提高性能,通常会使程序难以维护,继而减缓开发速度。如果最终得到的软件的确更快了,那么这点损失尚有所值,可惜通常事与愿违,因为性能改善一旦被分散到程序各角落,每次改善都只不过是从对程序行为的一个狭隘视角出发而已。

关于性能,一件很有趣的事情是:如果你对大多数程序进行分析,就会发现它把大半时间都耗费在一小半代码身上。如果你一视同仁地优化所有代码,90%的优化工作都是白费劲的,因为被你优化的代码大多很少被执行。你花时间做优化是为了让程序运行更快,但如果因为缺乏对程序的清楚认识而花费时间,那些时间就都被浪费掉了。

第三种性能提升法就是利用上述的90%统计数据。采用这种方法时,你编写构造良好的程序,不对性能投以特别的关注,直至进入性能优化阶段——那通常是在开发后期。一旦进入该阶段,你再按照某个特定程序来调整程序性能。

在性能优化阶段,你首先应该用一个度量工具来监控程序的运行,让它告诉你程序中哪些地方大量消耗时间和空间。这样你就可以找出性能热点所在的一小段代码。然后你应该集中关注这些性能热点,并使用持续关注法中的优化手段来优化它们。由于你把注意力都集中在热点上,较少的工作量便可显现较好的成果。即便如此你还是必须保持谨慎。和重构一样,你应该小幅度进行修改。每走一步都需要编译、测试、再次度量。如果没能提高性能,就应该撤销此次修改。你应该继续这个“发现热点、去除热点”的过程,直到获得客户满意的性能为止。关于这项技术,McConnell[McConnell]为我们提供了更多信息。

一个构造良好的程序可从两方面帮助这一优化形式。首先,它让你有比较充裕的时间进行性能调整,因为有构造良好的代码在手,你就能够更快速地添加功能,也就有更多时间用在性能问题上(准确的度量则保证你把这些时间投资在恰当地点)。其次,面对构造良好的程序,你在进行性能分析时便有较细的粒度,于是度量工具把你带入范围较小的程序段落中,而性能的调整也比较容易些。由于代码更加清晰,因此你能够更好地理解自己的选择,更清楚哪种调整起关键作用。

我发现重构可以帮助我写出更快的软件。短期看来,重构的确可能使软件变慢,但它使优化阶段的软件性能调整更容易,最终还是会得到好的效果。

评论关闭
评论 还能输入200
评论关闭
评论 还能输入200
资料加载中...
已关注 , 取消