改善既有代码的设计(一)----------小案例展示重构的意义

本文通过一个影片出租店程序的重构过程,展示了如何逐步改进代码结构,包括提炼函数、搬移函数、以多态取代条件表达式等技巧,最终引入State模式简化代码。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

最近在看一本书,关于重构的,改善既有代码的设计,感谢作者给我们翻译了这本还不错的书。

本书很好的一点就是上来没有讲历史渊源这一类的催人入睡的课题,而是先用一个小案例来展示重构的过程和意义,这也是我看着本书没有止于前言的主要原因,看完了本案例,才会觉得代码真是一项艺术,与难度无关,更多的好像与强迫症有关似的,初期开发是成型,后期重构是雕琢,这也推翻了我以前的想法,比如注释要尽量写的多啊,重构基本需要推到重做啊,其实感觉本书更像是给代码习惯和编程风格一个规范化,以前一些模棱两可的习惯在这里会有一个比较严谨的分析。

好了,不说废话了,翠花,上案例。

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

首先,得有三个类:Movie   Rental  Customer

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){
        this.title=title;
        this.priceCode=priceCode;
    }

    public int getPriceCode(){
        return priceCode;
    }

    public void setPriceCode(int arg){
        priceCode=arg;
    }
    public String getTitle(){
        return title;
    }

}
public class Rental {
    private com.demo.zxp.demo1.Movie movie;
    private int daysRented;

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

    public int getDaysRented(){
        return daysRented;
    }

    public Movie getMovie(){
        return movie;
    }

}

public class Customer1{
    private String name;
    private Vector _rentals=new Vector();
    private Customer1(String name){
        this.name=name;
    }
    public void addRental(Rental rental){
        _rentals.addElement(rental);
    }
    public String getName(){
        return name;
    }
    public String statement() {
        double totalAmount = 0;
        int frequentRentalPoints = 0;
        Enumeration rentals = _rentals.elements();
        String result = "Rental Record for" + getName() + "\n";
        while (rentals.hasMoreElements()) {
            double thisAmount = 0;
            Rental each = (Rental) rentals.nextElement();

            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;

            }
            //计算积分
            frequentRentalPoints++;
            if (each.getMovie().getPriceCode() == Movie.NEW_RELEASE && each.getDaysRented() > 1) {
                frequentRentalPoints++;
            }

            result+="\t"+each.getMovie().getTitle()+"\t"+String.valueOf(thisAmount)+"\n";
            totalAmount+=thisAmount;
        }
        result += "应收总金额是" + String.valueOf(totalAmount) + "\n";
        result += "您赚取了" + String.valueOf(frequentRentalPoints) + "积分";
        return result;

    }
}

这个程序设计的如何呢,很明显不符合面向对象的精神,其实这样一个小程序写成这样,可能也没啥大不了的但是如果他是一个复杂系统的一部分,那这个复杂系统的前途就危险了,在这个例子里

关于这个statement函数,太过于庞大,如果用户希望对系统进行修改一下,希望以HTML的格式输出详单,直接在网页上显示,现在想一下,这个变化会带来什么影响,看看代码就发现这个函数根本不能复用,唯一能做的就编写一个html()函数大量复用statement中的行为,当然现在做这个还不费力,如果计费标准发生变化呢,就必须同时修改statement()和html()函数了,并且还要确保两处修改一致,当后续还需要修改的时候,复制粘贴带来的问题就浮现了,如果用户还希望改变影片的分类规则呢,但是还没决定好怎么改,他们想到了几种方案,都会影响到积分和消费额的计算,作为一个开发者,我们可以肯定的就是:  
  不论用户提出什么方案,我们唯一能够获得保证就是他们一定会在六个月之内在此修改它。

重构第一步:就是为即将修改的代码建立一个可靠的测试环境,

2 分解并重组statement()

代码块越小,代码的功能越容易管理,代码的处理和移动就越轻松,首先,找出代码的逻辑泥团,并运用  Extract Method ,要在这段代码里找出函数内的局部变量和参数,任何不会被修改的变量可以当做参数传入新函数,至于被修改的就需要小心一点,如果只有一个变量会被修改,可以死把它当做函数返回值,

下面是重构后的代码

public class Customer {
    private String name;
    private Vector _rentals=new Vector();
    private Customer(String name){
        this.name=name;
    }
    public void addRental(Rental rental){
        _rentals.addElement(rental);
    }
    public String getName(){
        return name;
    }
    public String statement() {
        double totalAmount = 0;
        int frequentRentalPoints = 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);
            frequentRentalPoints++;

            if (each.getMovie().getPriceCode() == Movie.NEW_RELEASE && each.getDaysRented() > 1) {
                frequentRentalPoints++;
            }
            result+="\t"+each.getMovie().getTitle()+"\t"+String.valueOf(thisAmount)+"\n";
            totalAmount+=thisAmount;
        }
        result += "应收总金额是" + String.valueOf(totalAmount) + "\n";
        result += "您赚取了" + String.valueOf(frequentRentalPoints) + "积分";
        return result;

    }
    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;
    }
}
现在测试完没问题以后,就可以修改自己不喜欢的变量名字了,each-->aRental      thisAmount--> result

观察这个函数,我们使用了Rental类的信息,却没有使用Customer类的信息,这就立刻值得怀疑,它是否被放错了位置,函数应该放在它所使用的数据的所属对象内,所以这个函数应该搬到Rental类中,运用Move Method 

public class Rental {
    private Movie movie;
    private int daysRented;

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

    public int getDaysRented(){
        return daysRented;
    }

    public Movie getMovie(){
        return movie;
    }

    private double amountFor(){
        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类中的函数委托调用新函数即可(如果 我们不想修改对外的调用)

private double amountFor(Rental aRental){
        
        return aRental.amountFor();
    }
找到旧函数所有的调用点,修改 

thisAmount=each.getCharge();
下一个应该引起我们注意的是。thisAmount变得多余了,他接受了函数的赋值以后就没有再变化过,所以这里运用 Replace Temp with Query 把thisAmount 除去

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

            frequentRentalPoints++;

            if (each.getMovie().getPriceCode() == Movie.NEW_RELEASE && each.getDaysRented() > 1) {
                frequentRentalPoints++;
            }
            result+="\t"+each.getMovie().getTitle()+"\t"+String.valueOf(each.amountFor())+"\n";
            totalAmount+=each.amountFor();
        }
        result += "应收总金额是" + String.valueOf(totalAmount) + "\n";
        result += "您赚取了" + String.valueOf(frequentRentalPoints) + "积分";
        return result;

    }
临时变量往往容易引发问题,他们会导致大量参数被传来传去,而其实完全没有这种必要,因为你很容易跟丢他们,尤其是在长长的函数之中更是如此,当然,我们也需要付出性能上的代价,例如,本例子中费用的计算就被 计算了两次,但是这也很容易在Rental中被优化,而且如果代码有合理的组织和管理,优化就会有很好的效果,关于这个问题后边会有作者的详细解释

下一步对会员积分的计算做相似处理

Rental类中

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

去除临时变量

目前临时变量有两个 totalAmount 和frequentRentalPoints ,运用Replace Temp with Query 

public class Customer {
    private String name;
    private Vector _rentals=new Vector();
    private Customer(String name){
        this.name=name;
    }
    public void addRental(Rental rental){
        _rentals.addElement(rental);
    }
    public String getName(){
        return name;
    }
    public String statement() {
        Enumeration rentals = _rentals.elements();
        String result = "Rental Record for" + getName() + "\n";
        while (rentals.hasMoreElements()) {
            Rental each = (Rental) rentals.nextElement();
            result+="\t"+each.getMovie().getTitle()+"\t"+String.valueOf(each.getCharge())+"\n";
        }
        result += "应收总金额是" + String.valueOf(getTotalCharge()) + "\n";
        result += "您赚取了" + String.valueOf(getTotalFrequentRenterPoints()) + "积分";
        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;
    }
    private double getTotalFrequentRenterPoints(){
        double result=0;
        Enumeration rentals = _rentals.elements();
        while (rentals.hasMoreElements()) {
            Rental each = (Rental) rentals.nextElement();
            result+=each.getFrequentRenterPoints();
        }
        return result;
    }

}

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

现在Customer类内任何代码都可以调用这些查询函数了,如果系统其它部分需要这些信息,也可以轻松的将查询函数加入Customer类的接口。如果没有这些查询函数,其它函数就必须了解Rental类,并自行简历循环,在一个复杂的系统中,这将使程序的编写难度和维护难度大大增加,所以如果现在客户要求详单一html的格式输出,我们在编写htmlStatement()函数的时候,就不必剪贴复制了,所以如果计算规则发生了改变,只需要在程序中一处做修改,完成其他类型的详单也都是很快很容易的事情了,而这时我们无论如何都得做的。

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

这个问题的一部分就是switch语句,最好不要在另一个对象的属性基础上运用switch语句,如果不得不使用,也要在自己的数据上使用,这就暗示getCharge()方法需要移动到Movie类中,为了让它得以运作,必须把租期长度作为参数传递过去,当然,租期长度来自Rental类,计算费用的时候需要两项数据:租期长度和影片类型,为什么选择将租期长度传给Movie对象,而不是将影片类型喜欢递给Rental对象呢,因为本系统可能发生变化的是加入新影片类型,这种变化带有不稳定倾向,如果影片类型有所变化,我们希望尽量控制它造成的影响,所以选择在Movie中计算费用。同样的,积分计算也如此处理

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){
        this.title=title;
        this.priceCode=priceCode;
    }

    public int getPriceCode(){
        return priceCode;
    }

    public void setPriceCode(int arg){
        priceCode=arg;
    }
    public String getTitle(){
        return title;
    }
    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;

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

public class Rental {
    private Movie movie;
    private int daysRented;

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

    public int getDaysRented(){
        return daysRented;
    }

    public Movie getMovie(){
        return movie;
    }

    public double getCharge(){

        return movie.getCharge(daysRented);
    }
    public int getFrequentRenterPoints(){
       return movie.getFrequentRenterPoints(daysRented);
    }
}

终于来到继承

我们有数种影片类型,他们一不同的方式回答相同的问题,这听起来很像子类的工作,我们可以建立Movie的三个子类,每个都有自己的计费方法,这么一来就可以用多态来取代switch语句了,很遗憾这里有个小小的问题,一部影片可以在生命周期内修改自己的分类,一个对象却不能再生命周期内修改自己所属的类,不过还有一个解决办法:State模式,加入一层间接性,Price,我们可以再Price对象内进行子类动作化

那这个算是State还是Strategy,取决于Price类究竟代表计费方式(PriceStrategy)还是代表影片的某个状态,在这个阶段,对于模式名称的选择反映出你对结构的想法,此时我把他视为影片的某种状态,如果未来我觉得Strategy模式能更好的说明我的意图,我会再重构他,修改名字,以形成Strategy

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

第一步针对类型代码使用Self Encapsulate Field ,确保任何时候都通过取值函数和设置函数来访问类型代码,多数访问操作来自其他类,他们已经在使用取值函数,但是构造函数仍然直接访问价格代码,这里可以用一个设置函数来替代,

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

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

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

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

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

现在需要修改Movie类内的“价格代号”访问函数,这就意味着必须在Movie类中保存一个Price对象,而不再是一个price_Code变量,此外,还要修改访问函数

public class Movie ......
    
//    private int priceCode;
    private Price price;

    public Movie(String title,int priceCode){
        this.title=title;
//        this.priceCode=priceCode;
        setPriceCode(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;
            default:
                throw new IllegalArgumentException("Incorrect Price Code");
        }
    }
接下来就是要对getCharge方法实施Move Method,
public class Movie ......
 
    public double getCharge(int daysRentend){
        return price.getCharge(daysRentend);
    }

将getCharge方法具体实现搬到Price类中,这里就不贴代码了,继续,运用Replace Conditional with Polymorphism ,做法是一次取出一个分支,在相应的类建立一个覆盖函数,

这里就把是三个分支都弄了吧,毕竟这样贴代码好累 啊

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

        }
        return result;
    }
}

public class ChildrensPrice extends Price{
    @Override
    int getPriceCode() {
        return Movie.CHILDRENS;
    }
    public double getCharge(int daysRented){
        double result=1.5;
        if ( daysRented> 3) {
            result += (daysRented - 3) * 1.5;

        }
        return result;

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

        return daysRented * 3;
    }
    
}

处理完所有的分支以后,就把Price.getChar()声明为abstract,

public abstract class Price ......
    abstract int getPriceCode();
    public abstract double getCharge(int daysRented);
然后同样的手法处理getFrequentRenterPoints(),首先把这个函数移动到Price类,但是这一次不会声明成abstract,只为新片类型增加一个覆写函数,并在父类内留下一个已定义的函数,使他成为一种默认的行为

ublic abstract class Price {
    abstract int getPriceCode();
    public abstract double getCharge(int daysRented);
    public int getFrequentRenterPoints(int daysRentend){

            return 1;
    }
}

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

        return daysRented * 3;
    }
    public int getFrequentRenterPoints(int daysRentend){

        return (daysRentend>1) ? 2:1;
    }
}

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

结语:

作者希望用这个简单的例子让大家对代码重构有一点感觉,里边提到了很多重构手法,Extract Method(提炼函数) ,Move Method(搬移函数) ,Replace Conditional with Polymorphism (以多态取代条件表达式),Self Encapsulate Field (自封装字段),ReplaceType Code with State/Strategy(State/Strategy取代类型代码),这些重构行为都让责任分配更合理,代码维护更轻松。

因为我是这个本书的搬运者,所以在措辞和表达上想尽力表达出作者的最原始意图,所以描述的有点繁琐了,当我们熟悉了整个重构的套路,对程序的修改就会变得快多了,因为至少你不用想写博客的事情,也不用想别人知不知道你再用什么方法来重构。

通过读了这本书,对重构的理解感觉就像是教师的工作,纯粹是一种良心活,因为也许你做完一个版本就走了,这个程序的后期维护就与你没关系了,而你还要去为了后续人的方便来一遍一遍的review自己的代码,重构它,这良心,也是没谁了,所以提倡大家对代码有一种洁癖感,写一行有质量的代码,功德无量。

撒花,结束

对错别字真是忍不了啊



评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值