经过上一篇文章——重构,第一个案例(C++版)——分解并重组Statement()中的重构,我们的程序已经有了很大的进步了。但,我们重构的步伐并不能停歇下来。因为,很快客户就开始嘀咕起新需求了。让我们看看这次客户想要些什么。

注意,客户要提新需求了!

客户想要修改影片的分类规则。但,他们自己也没想好怎么改。可能会有新的分类方法被引入,原来的分类方法也可能会有改动(各位同学,这种情况是不是很熟悉!)。总之,他们有了新的想法,但是新的想法还在孵化中,我们需要做点什么来应对将要到来的变化。由于新的分类规则还没定,那么相应的费用计算和积分计算方式也都不确定。不过有一点可以确定,就是一旦有新分类出现,我们就得添加新的费用计算和积分计算的规则。那么我们进入到这两个计算函数中去,很容易就发现其中主要就是条件判断逻辑(包括switch和if...else...语句)。很明显,只要有新分类,我们就需要去增加新的条件。很好,我们闻到了“代码的坏味道”。我们需要重构价格相关的条件逻辑部分的代码,以应对将来的改变。

先让计算函数“回家”

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

这段话应用在我们的程序中就意味着GetCharge和GetFrequentRenterPoints这两个函数就不应该属于Rental类,而应该放到Movie类中。上面的需求分析我们已经清楚了程序主要可能的变化点在于影片新的分类规则的出现,而新的分类规则出现就很大的可能需要修改费用和积分的计算。将相应的计算函数放到Movie类中后就能使得变化控制在Movie类中。经过搬移后的代码如下。

class Movie 
{
    ...
    double GetCharge(int days_rented) {
        double result = 0;
        switch (GetPriceCode()) {
        case PriceCode::REGULAR:
            result += 2;
            if (days_rented > 2)
                result += (days_rented - 2) * 1.5;
            break;
        case PriceCode::NEW_RELEASE:
            result += days_rented * 3;
            break;
        case PriceCode::CHILDREN:
            result += 1.5;
            if (days_rented > 3)
                result += (days_rented - 3) * 1.5;
            break;
        }
        return result;                          
    }

    int GetFrequentRenterPoints(int days_rented) {
        if (GetPriceCode() == PriceCode::NEW_RELEASE &&
            days_rented > 1)
            return 2;
        else
            return 1;    
    }
    ...
};

class Rental
{
    ...
    double GetCharge() {
        return movie_.GetCharge(days_rented_);                
    }

    int GetFrequentRenterPoints() {
        return movie_.GetFrequentRenterPoints(days_rented_);    
    }
    ...
};

再次搬移计算函数后,我们的UML类图变成了下面这样。
再次搬移计算函数后的UML类图

终于……我们来到继承

下面我们就要正式对switch语句动手了,我们将引入继承,使用一种设计模式来消除switch语句。听到设计模式可能有些同学会觉得很复杂,其实跟着代码重构的思路走,一些设计模式是自然而然产生的。首先,我们switch语句中的类型变量就是影片类型的枚举,要消灭这个枚举值,我们会需要使用到继承,为每一个原来枚举中的类型实现一个子类,子类中可以实现自己的计算方法。这样我们就用多态替代了switch语句。使用继承后的影片类型UML类图如下。
使用继承后的影片类型UML类图

但,这样的设计会给我们留下一个小问题:一个影片是可以在其生命周期内修改自己的分类的,比如一部新片可能过几天之后就不是新片了而属于别的分类。然后,我们新写的子类对象却不能在其生命周期内修改自己所属的类。这时候我们就需要设计模式了——State(状态)模式。使用State模式后,影片类型的UML类图如下。
使用State模式后,影片类型的UML类图

这里增加了间接层——Prince对象,我们可以在对它进行子类化动作,就可以实现在任意时刻修改价格了。同样的手法我们也可以应用到积分计算中,不过因为积分计算比较简单,所以实际上我们会简化其实现方式。

接下来请跟紧,我们会一步一步的实现我上面说得重构。首先,我们使用的方法就是Replace Type Code With State/Strategy(以State/Strategy取代类型码)。

确保任何时候都通过取值函数和设值函数来访问类型代码。

这是作者希望我们第一步做的,在现在的程序中我们多少访问都是来自类外部的使用的取值函数,这个不需要修改了。在Movie类的构造函数中,我们还是直接使用了类型代码的变量。这里我们先将其修改为设值函数。(这点修改在C++中可能有争议,作者这里建议的是无论在类内还是类外都尽量使用Set、Get函数来访问数据型的成员变量,而不要直接使用或者修改其值。)

class Movie 
{
    ...
    Movie(const string& title, int price_code) {
        title_ = title;
        SetPriceCode(price_code);
    }
    ...
};

之后,我们就可以新增抽象类Price,增加相应的子类,在子类中实现各自的具体函数。新增代码如下。

class Price
{
public:
    virtual ~Price() {}
    virtual int GetPriceCode() { return -1; }
};
class ChildrensPrice : public Price
{
public:
    int GetPriceCode() {
        return PriceCode::CHILDREN;
    }
};
class NewReleasePrice : public Price
{
public:
    int GetPriceCode() {
        return PriceCode::NEW_RELEASE;
    }
};
class RegularPrice : public Price
{
public:
    int GetPriceCode() {
        return PriceCode::REGULAR;
    }
};

然后,我们就可以编译运行看看(每一小步重构都需要运行下测试代码验证,后面我就不强调了)。
下面我们就要修改Movie类中原来的类型码price_code_,将其改为我们刚刚定义的Price类。然后修改它的取值、设值函数。修改后的代码如下。

class Movie 
{
    ...
    int GetPriceCode() {
        int result = -1;
        if (price_.get())
        {
            result = price_->GetPriceCode();
        }
        return result;
    }
    void SetPriceCode(int arg) {
        switch (arg) {
        case PriceCode::REGULAR:
            price_.reset(new RegularPrice());
            break;
        case PriceCode::NEW_RELEASE:
            price_.reset(new NewReleasePrice());
            break;
        case PriceCode::CHILDREN:
            price_.reset(new ChildrensPrice());
            break;
        }
    }
    ...    
private:
    string title_;
    shared_ptr<Price> price_ = nullptr;
};

我们的重构已经接近尾声了,经过上面的修改,我们的程序已经基本具备了State模式的雏形了,接下来我们就需要完善它。首先,要将Movie类中的GetCharge函数搬移到Price类中。然后,使用Replace Conditional WIth Polymorophism(以多态取代条件表达式)的重构方法,消除掉switch语句,将其执行逻辑都放到相应子类的重载函数中去实现。同样的手法,我们也可以运用到积分计算GetFrequentRenterPoints函数上。并且,因为积分计算规则比较简单,只有新片的积分规则有所不同,所以我们只用在NewReleasePrice重载积分计算函数,而在Price类中实现通用的计算函数,使其成为一种默认行为。我们最后修改的代码如下。

class Price
{
    ...
    virtual double GetCharge(int days_rented) { return 0; }
    virtual int GetFrequentRenterPoints(int days_rented) { return 1; }
};
class ChildrensPrice : public Price
{
    ...
    double GetCharge(int days_rented) {
        double result = 1.5;
        if (days_rented > 3)
            result += (days_rented - 3) * 1.5;
        return result;
    }
};
class NewReleasePrice : public Price
{
    ...
    double GetCharge(int days_rented) {
        return days_rented * 3;
    }
    int GetFrequentRenterPoints(int days_rented) {
        return (days_rented > 1) ? 2 : 1;
    }
};
class RegularPrice : public Price
{
    ...
    double GetCharge(int days_rented) {
        double result = 2;
        if (days_rented > 2)
            result += (days_rented - 2) * 1.5;
        return result;
    }
};

class Movie
{
    ...
    double GetCharge(int days_rented) {
        double result = 0.0;
        if (price_.get())
        {
            result = price_->GetCharge(days_rented);
        }
        return result;
    }

    int GetFrequentRenterPoints(int days_rented) {
        int result = 1;
        if (price_.get())
        {
            result = price_->GetFrequentRenterPoints(days_rented);
        }
        return result;
    }
    ...
};

经过这轮重构我们的UML类图和时序图最终成为了下面这样。
最终UML类图

最终时序图

最终我们通过引入State模式得到了一个运行结果一样的程序,值得吗?当然值得,现在我们的程序无论客户是提出修改影片分类结构,还是计算费用或者是计算积分的规则有变化,我们都可以非常容易的应对了。这里我直接引用作者书上的总结。

这么做的收获是:如果我要修改任何与价格相关的行为,或是添加新的定价标准,或是加入其它取决于价格的行为,程序的修改会容易得多。这个程序的其余部分并不知道我运用了State模式。对于我目前拥有的这么几个小量行为来说,任何功能或特性上的修改也许都不合算,但如果在一个更复杂的系统中,有十多个与价格相关的函数,程序的修改难易度就会有很大的区别。

思路总结

  • 分析程序结构,让类的功能更合理。
  • 分析程序中的条件判断语句,运用多态取代可能会经常变化的条件判断。
  • 引入设计模式使得程序能更从容的应对变化。

最后我还想引用作者对于这个重构例子的一个最重要的总结。

这个例子给我们最大的启发是重构的节奏:测试、小修改、测试、小修改、测试、小修改……正是这种节奏让重构得以快速而安全地前进。

本系列文章

找出那些代码里的坏味道吧——《重构》笔记(一)
重构,第一个案例(C++版)——最初的程序
重构,第一个案例(C++版)——分解并重组Statement()
重构,第一个案例(C++版)——运用多态取代与价格相关的条件逻辑


吴尼玛
32 声望11 粉丝

记问之学