之前写过一篇文章找出那些代码里的坏味道吧——《重构》笔记(一)。简单总结了一下《重构》这本书中的重点以及作者总结的“代码里的坏味道”。

这样的总结比较抽象,这里我将写一个系列文章,主要想通过案例来让大家具体的感受一下小步重构的魅力。使用的例子也是Martin Fowler《重构》一书中第一章的案例。不过书中案例是用Java写的,这里我将其改为了C++程序。希望这样对C++程序员能更好的理解重构。

起点

考虑实现一个租碟店需要用的程序,需求是:

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

这是程序的一个简单实现的UML类图。
本例第一个UML类图,只显示最重要的特性。

下面是UML类图中各个类的代码:

Movie(影片)

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

enum PriceCode
{
    REGULAR = 0,
    NEW_RELEASE = 1, 
    CHILDREN = 2
};

class Movie 
{
public:
    Movie() {}
    Movie(const string& title, int price_code) {
        title_ = title;
        price_code_ = price_code;
    }

    int GetPriceCode() { return price_code_; }
    void SetPriceCode(int price_code) { price_code_ = price_code; }
    string GetTitle() { return title_; }

private:
    string title_;
    int price_code_;
};

Rental(租赁)

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

class Rental
{
public:
    Rental() {}
    Rental(const Movie& movie, int days_rentaed) {
        movie_ = movie;
        days_rented_ = days_rentaed;
    }

    int GetDaysRented() { return days_rented_; }
    Movie GetMovie() { return movie_; }

private:
    Movie movie_;
    int days_rented_;
};

Customer(顾客)

Customer类用来表示顾客。就像其他类一样,它也拥有数据和相应的访问函数。同时Customer还提供了一个用于生成详单的函数statement()。
statement() 时序图

class Customer
{
public:
    Customer(const string& name) {
        name_ = name;
    }

    void AddRental (const Rental& arg) { rentals_.emplace_back(arg); }
    string GetName() { return name_; }
    string Statement() {
        double total_amount = 0;
        int frequent_renter_points = 0;
        string result = "Rental Record for " + GetName() + "\n";
        for (auto& each : rentals_) {
            double this_amount = 0;
            
            // determine amounts for each line
            switch (each.GetMovie().GetPriceCode())
            {
            case PriceCode::REGULAR:
                this_amount += 2;
                if (each.GetDaysRented() > 2)
                    this_amount += (each.GetDaysRented() - 2) * 1.5;
                break;
            case PriceCode::NEW_RELEASE:
                this_amount += each.GetDaysRented() * 3;
                break;
            case PriceCode::CHILDREN:
                this_amount += 1.5;
                if (each.GetDaysRented() > 3)
                    this_amount += (each.GetDaysRented() - 3) * 1.5;
                break;
            }

            // add frequent renter points
            frequent_renter_points ++;
            // add bonus for a two day new release rental
            if (each.GetMovie().GetPriceCode() == PriceCode::NEW_RELEASE &&
                each.GetDaysRented() > 1)
                frequent_renter_points++;

            
            // show figures for this rental
            result += "\t" + each.GetMovie().GetTitle() + "\t" +
                to_string(this_amount) + "\n";
            total_amount += this_amount;
        }

        // add footer lines
        result += "Amount owed is " + to_string(total_amount) + "\n";
        result += "You earned " + to_string(frequent_renter_points) +
            " frequent renter points";
        
        return result;
    }

private:

    string name_;
    vector<Rental> rentals_;
};

对于这个我们最初版本的程序,首先,可以肯定的是它是能正常运行的,没有bug。如果这是个测试程序,或者这个程序就这样了,以后不会再修改它,那么我们甚至可以说它是完美的,因为我们快速的实现了我们想要的功能,并且没有bug。但是我们很容易的可以“闻到”《重构》这本书中总结的——Long Method(过长函数)这个“代码的坏味道”。当所有的业务逻辑都堆积在这个长函数中时,就会使得这个程序难以修改,难以扩展,并且可能会越变越糟糕。这时候,很明显的我们需要重构了。

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

重构第一步

每当我要进行重构的时候,第一个步骤永远相同:我得为即将修改的代码建立一组可靠的测试环境。

这里作者强调了单元测试的重要性,因为人总是可能犯错的,重构有可能引起意想不到的bug,所以对于原有功能的接口部分进行单元测试就显得很有必要了。只是,在现实的项目中大部分时候因为需求的紧急,我们都忽略了这个。这里,我就在main函数中加了一段简单的测试代码。

int main() {
    Movie movie1{ "Pulp Fiction", PriceCode::REGULAR };
    Movie movie2{ "Joker", PriceCode::NEW_RELEASE };
    Movie movie3{ "Peppa Pig", PriceCode::CHILDREN };

    Rental rental1{ movie1, 3 };
    Rental rental2{ movie2, 3 };
    Rental rental3{ movie3, 3 };

    Customer customer{ "Caesar" };
    customer.AddRental(rental1);
    customer.AddRental(rental2);
    customer.AddRental(rental3);
    cout << customer.Statement().c_str() << endl;

    return 0;
}

运行结果如图。之后,每完成一个小步的重构我们都会运行一下测试代码以检测结果是否和最初的结果是一样的,如果有不一样,我们就得检查下重构的代码,看看是不是引入了新的bug。
运行结果

思路总结

  • 重构的第一步总是应该写一段单元测试代码以保证重构不会引入新的bug。

本系列文章

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


吴尼玛
32 声望11 粉丝

记问之学