美文网首页
改善既有代码的设计笔记(一)重构世界的 Hello World

改善既有代码的设计笔记(一)重构世界的 Hello World

作者: 红发_SHANKS | 来源:发表于2018-07-11 13:48 被阅读12次

    ——曾几何时,对重构代码充满了恐惧,书的译序中说:把你的敬畏扔到太平洋去,对于即将变得像空气和水一样普通的技术,你无需敬畏。好吧,现在出发,去太平洋....

    作者采用了一种很有意思的方式来开始全书:放弃常规的从历史、原理开始讲解某个知识的方式,直接以某一个示例开始,让读者能够看见正在做什么,在过程中再慢慢的渗透历史、原理。这中方式更加容易深入,也能让读者明确的知道如何实际运用。

    我们在学习一个新的技术的时候,也常常采用这样的方式,首先寻找一个带求解的问题,然后带着问题去学习。这种方式,和传统的直接从原理开始学习的方式,两者比较起来,后者更让人有兴趣,而且不容易忘记。值得借鉴。

    为什么要重构

    代码,被阅读和修改的次数,远大于被编写的次数,要保证代码的易读、易修改,重构很有必要。当然,重构代码,有助于我们构建更加安全更加容易扩展的系统。

    什么是重构

    在不改变代码外在行为的前提下,对代码做出修改,以改进程序的内部结构。

    重构是一种经过千锤百炼形成的有条不紊的程序整理方法,可以最大限度减少整理过程中引入错误的几率。

    本质上说,重构就是在写好代码后,改进代码的设计。

    开始重构

    第一章中给出了一个小的示例,这里截取需要重构的方法。在这个方法中,根据电影片种不同,租赁天数不同计算了用户的消费金额和获得的积分。

        public String statement() {
            // 本次消费总金额
            double totalAmount = 0;
            // 用户积分
            int frequentRentalPoints = 0;
            Iterator<Rental> rentalIterator = mRentals.iterator();
            String result = "Rental record for " + getCustomerName() + "\n";
            while (rentalIterator.hasNext()) {
                double thisAmount = 0;
                Rental each = rentalIterator.next();
    
                switch (each.getMovie().getPriceCode()) {
                    // 老片两天以内 2 元,超出 2 天,每天1.5 元
                    case Movie.REGULAR:
                        thisAmount+=2;
                        if (each.getRentedDays() > 2) {
                            thisAmount+= (each.getRentedDays()-2)*1.5;
                        }
                        break;
                    // 新片每天 3 元
                    case Movie.NEW_RELEASE:
                        thisAmount += each.getRentedDays()*3;
                        break;
                    // 儿童片三天以内 3 元,超出 3 天,每天 1.5 元
                    case Movie.CHILDRENS:
                        thisAmount+=3;
                        if (each.getRentedDays() > 3) {
                            thisAmount+= (each.getRentedDays()-3)*1.5;
                        }
                        break;
                }
    
                frequentRentalPoints++;
    
                // 新片积分 +1
                if ((each.getMovie().getPriceCode() == Movie.NEW_RELEASE) && each.getRentedDays() > 1) {
                    frequentRentalPoints++;
                }
    
                // 展示本条租赁信息
                result += "\t" + each.getMovie().getTitle() + "\t" + String.valueOf(thisAmount) + "\n";
                totalAmount += thisAmount;
            }
    
            // 展示消费总数 和 租赁积分
            result += "Amount owed is " + String.valueOf(totalAmount) + "\n";
            result += "You earned " + String.valueOf(frequentRentalPoints) + " frequent rental points";
    
            return result;
        }
    

    上述代码存在两点问题:

    • 方法臃肿,一个方法中完成了太多的功能,不符合单一职责原则。
    • 不易于扩展,如果系统新增加了电影片种或者是换了不同的积分规则或者是用户要求打印不同样式的消费记录,那么只有重新修改方法,或者重新写一个新的方法,重复上述方法中的部分逻辑。

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

    分解并重组方法###

    statement() 方法长的离谱,代码块越短小,代码就越容易管理,代码的处理和移动就越轻松,代码的复用性更高

     private double executeEachAmount(Rental each) {
            double thisAmount = 0;
            switch (each.getMovie().getPriceCode()) {
                // 老片两天以内 2 元,超出 2 天,每天1.5 元
                case Movie.REGULAR:
                    thisAmount+=2;
                    if (each.getRentedDays() > 2) {
                        thisAmount+= (each.getRentedDays()-2)*1.5;
                    }
                    break;
                // 新片每天 3 元
                case Movie.NEW_RELEASE:
                    thisAmount += each.getRentedDays()*3;
                    break;
                // 儿童片三天以内 3 元,超出 3 天,每天 1.5 元
                case Movie.CHILDRENS:
                    thisAmount+=3;
                    if (each.getRentedDays() > 3) {
                        thisAmount+= (each.getRentedDays()-3)*1.5;
                    }
                    break;
            }
            return thisAmount;
        }
    

    将 switch 代码块单独提取成一个方法。thisAmount作为一个返回值,返回到 while 循环中。每次重构尽量粒度比较小,减少引入 bug 的几率。

    在 Intellij IDEA 中,可以使用快捷键 ctrl +alt +m 快速重构一个方法。

    重命名参数和变量名###

    任何人都可以写出计算机能够识别的代码,但是作为开发人员,我们要写出其他开发人员能够读懂的代码,所以,要尽量保持代码的可读性。上面重构的 executeEachAmount()方法的形参和 amount 参数就不能明确的表达他们的含义,我们给他们改个名字。在 Intellij IDEA 中 使用快捷键 shift +F6可以快速为变量和参数重命名。

        private double executeEachAmount(Rental aRental) {
            double result = 0;
            switch (aRental.getMovie().getPriceCode()) {
                // 老片两天以内 2 元,超出 2 天,每天1.5 元
                case Movie.REGULAR:
                    result+=2;
                    if (aRental.getRentedDays() > 2) {
                        result+= (aRental.getRentedDays()-2)*1.5;
                    }
                    break;
                // 新片每天 3 元
                case Movie.NEW_RELEASE:
                    result += aRental.getRentedDays()*3;
                    break;
                // 儿童片三天以内 3 元,超出 3 天,每天 1.5 元
                case Movie.CHILDRENS:
                    result+=3;
                    if (aRental.getRentedDays() > 3) {
                        result+= (aRental.getRentedDays()-3)*1.5;
                    }
                    break;
            }
            return result;
        }
    

    移动方法到合适的位置###

    上述我们重构的方法 executeEachAmount() 使用到了 Rental 对象,但是没有使用 Customer对象。绝大多数情况下,方法应该放在他所使用数据的对象内,所以我们移动 executeEachAmount() 方法到 Rental 类,并且重命名一个合适的名字,因为移动到了 Rental 类,所以方法的参数 Rental aRental不再需要了。

    在 Intellij IDEA 中,使用 shift + F6将方法重命名为 getCharge(),然后将光标移动到方法签名上,快捷键 F6,方法就自动移动到了 Rental 类了,并且智能的帮助我们去掉了 rental 参数,而且 IDEA 自动帮助我们修改了方法的调用。

        /**
         * 计算消费额
         */
        double getCharge() {
            double result = 0;
            switch (getMovie().getPriceCode()) {
                // 老片两天以内 2 元,超出 2 天,每天1.5 元
                case Movie.REGULAR:
                    result+=2;
                    if (getRentedDays() > 2) {
                        result+= (getRentedDays()-2)*1.5;
                    }
                    break;
                // 新片每天 3 元
                case Movie.NEW_RELEASE:
                    result += getRentedDays()*3;
                    break;
                // 儿童片三天以内 3 元,超出 3 天,每天 1.5 元
                case Movie.CHILDRENS:
                    result+=3;
                    if (getRentedDays() > 3) {
                        result+= (getRentedDays()-3)*1.5;
                    }
                    break;
            }
            return result;
        }
        ...
         double thisAmount  = each.getCharge();
    

    有时候,我们需要移动的方法是一个 public方法,为了不影响其他地方的调用,可以保留旧方法,让旧方法调用新的方法即可。

    我们留意到 while 循环中,常客积分计算也是这样的一种形式,那么对他采用同样的方法重构。原来的代码片段:

    frequentRentalPoints++;
    
    // 新片积分 +1
    if ((each.getMovie().getPriceCode() == Movie.NEW_RELEASE) && each.getRentedDays() > 1) {
        frequentRentalPoints++;
    }
    
    

    在 Intellij IDEA 中,选中代码块,按快捷键 ctrl + alt +m 重构出来一个新的 getFrequentRentalPoints(Rental each) 方法,重命名参数 each 为 aRental , 然后按快捷键 F6 新方法就会被移动到 Rental 类中,并且去除了参数

    重构后的效果:

    public class Rental {
        int getFrequentRentalPoints() {
            int frequentRentalPoints = 0;
            frequentRentalPoints++;
    
            // 新片积分 +1
            if ((getMovie().getPriceCode() == Movie.NEW_RELEASE) && getRentedDays() > 1) {
                frequentRentalPoints++;
            }
            return frequentRentalPoints;
        }
    }
    ...
    frequentRentalPoints = each.getFrequentRentalPoints();
    

    去除无用的临时变量###

    回头看看 statement() 方法,我们重构以后, double thisAmount = each.getCharge();thisAmount 变量就只被调用了两次,而且,他在接受了 getCharge()方法以后,就没有再做任何改变。对于这样的临时变量,我们应该尽可能的去除

    在 Intellij IDEA 中,点击 Refactor - Replace temp with query 就能自动去掉临时变量,而使用一个新方法(被调用了多次)/ 原方法(被调用一次)对使用到临时变量的地方进行替换

        private double getCharge(Rental aRental) {
            return aRental.getCharge();
        }
        ...
        totalAmount += getCharge(each);
    

    上一步的常客积分 frequentRentalPoints 重构后也是一个临时变量,但是因为他在 while 循环中还参与了计算,所以不能去掉。但是获取积分和 计算消费额应该是更加通用的功能,我们应该给提取出来。重构前:

        public String statement() {
            double totalAmount = 0;
            int frequentRentalPoints = 0;
            Iterator<Rental> rentalIterator = mRentals.iterator();
            String result = "Rental record for " + getCustomerName() + "\n";
            while (rentalIterator.hasNext()) {
                Rental each = rentalIterator.next();
                frequentRentalPoints += each.getFrequentRentalPoints();
                // 展示本条租赁信息
                result += "\t" + each.getMovie().getTitle() + "\t" + String.valueOf(getCharge(each)) + "\n";
                totalAmount += getCharge(each);
            }
    
            // 展示消费总数 和 租赁积分
            result += "Amount owed is " + String.valueOf(totalAmount) + "\n";
            result += "You earned " + String.valueOf(frequentRentalPoints) + " frequent rental points";
    
            return result;
        }
    

    重构以后:

        public String statement() {
            String result = "Rental record for " + getCustomerName() + "\n";
        
            for (Rental rental : mRentals) {
                result += "\t" + rental.getMovie().getTitle() + "\t" + String.valueOf(getCharge(rental)) + "\n";
            }
    
            result += "Amount owed is " + String.valueOf(getTotalAmount()) + "\n";
            result += "You earned " + String.valueOf(getTotalFrequentPoints()) + " frequent rental points";
            return result;
        }
    
        private int getTotalFrequentPoints() {
            int resultPoints = 0;
            for (Rental rental : mRentals) {
                resultPoints += rental.getFrequentRentalPoints();
            }
            return resultPoints;
        }
    
        private double getTotalAmount() {
            double result = 0;
            for (Rental mRental : mRentals) {
                result += mRental.getCharge();
            }
            return result;
        }
    

    每个方法的功能更加明确单一,方便我们后续功能拓展的时候进行复用。

    进一步让正确的对象进行操作###

    我们前面将计算消费和计算积分的代码都移动到了 Rental 类中,现在存在的问题是,光碟租赁,应该是以光碟类型类来决定价格和积分的,那么这一部分的逻辑,就应该交由 Movie 类自身来处理。在 getCharge() 方法上按快捷键 F6,将方法移动到 Movie 类中。

        double getCharge(Rental rental) {
            double result = 0;
            switch (rental.getMovie().getPriceCode()) {
                // 老片两天以内 2 元,超出 2 天,每天1.5 元
                case REGULAR:
                    result+=2;
                    if (rental.getRentedDays() > 2) {
                        result+= (rental.getRentedDays()-2)*1.5;
                    }
                    break;
                // 新片每天 3 元
                case NEW_RELEASE:
                    result += rental.getRentedDays()*3;
                    break;
                // 儿童片三天以内 3 元,超出 3 天,每天 1.5 元
                case CHILDRENS:
                    result+=3;
                    if (rental.getRentedDays() > 3) {
                        result+= (rental.getRentedDays()-3)*1.5;
                    }
                    break;
            }
            return result;
        }
    

    仔细观察代码,我们在方法中用到的只有 Rental 的 rentedDays 这个租赁时间的属性。那么我们就不应该在 Movie 中持有 rental 对象,只需要传入 rentedDays 参数就可以了。将光标移动到 rental.getRentedDays() 上按快捷键 ctrl + alt + v 提取变量。

        double getCharge(Rental rental) {
            double result = 0;
            int rentedDays = rental.getRentedDays();
            switch (rental.getMovie().getPriceCode()) {
                // 老片两天以内 2 元,超出 2 天,每天1.5 元
                case REGULAR:
                    result+=2;
                    if (rentedDays > 2) {
                        result+= (rentedDays -2)*1.5;
                    }
                    break;
                // 新片每天 3 元
                case NEW_RELEASE:
                    result += rentedDays *3;
                    break;
                // 儿童片三天以内 3 元,超出 3 天,每天 1.5 元
                case CHILDRENS:
                    result+=3;
                    if (rentedDays > 3) {
                        result+= (rentedDays -3)*1.5;
                    }
                    break;
            }
            return result;
        }
    

    继续将光标移动到 int rentedDays = rental.getRentedDays();这行代码上面,按快捷键ctrl + alt +p 提取参数,然后删除无用的 rental 对象参数:

        double getCharge(int rentedDays) {
            double result = 0;
            switch (getPriceCode()) {
                // 老片两天以内 2 元,超出 2 天,每天1.5 元
                case REGULAR:
                    result+=2;
                    if (rentedDays > 2) {
                        result+= (rentedDays -2)*1.5;
                    }
                    break;
                // 新片每天 3 元
                case NEW_RELEASE:
                    result += rentedDays *3;
                    break;
                // 儿童片三天以内 3 元,超出 3 天,每天 1.5 元
                case CHILDRENS:
                    result+=3;
                    if (rentedDays > 3) {
                        result+= (rentedDays -3)*1.5;
                    }
                    break;
            }
            return result;
        }
    

    对常客积分的方法进行同样的操作:

        int getFrequentRentalPoints(int rentedDays) {
            int frequentRentalPoints = 0;
            frequentRentalPoints++;
    
            // 新片积分 +1
            if ((getPriceCode() == NEW_RELEASE) && rentedDays > 1) {
                frequentRentalPoints++;
            }
            return frequentRentalPoints;
        }
    

    现在,计算消费额和计算积分的方法都在 Movie 类中,如果有任何新的影片类型,或者是需要修改 计价/计算积分 的规则,都只需要修改 Movie 类中的方法就可以了,对于外部调用者 Rental 和 Customer 类来说,一切都没有变。

    使用多态,是程序更加健壮###

    经过一系列的重构以后,依然存在一个问题,如果新增加了影片类型,那么消费计算和积分计算的代码我们都需要再次改动。这是很不友好的,我们希望,在新增加了影片类型以后,只需要重新新建一个价格计算的类型就可以了,而不是修改原有的代码。这就需要多态的支持了。

    public class Movie {
    
        private String mTitle;
        private Price mPrice;
    
        public Movie(String title, Price mPrice) {
            mTitle = title;
           this.mPrice = mPrice;
        }
    
        public String getTitle() {
            return mTitle;
        }
        
        double getCharge(int rentedDays) {
    
            return mPrice.getCharge(rentedDays);
        }
    
        int getFrequentRentalPoints(int rentedDays) {
            return mPrice.getAmount(rentedDays);
        }
    }
    

    重构后的 Movie 类,我们将计算价格和计算积分的工作都交给 Price 类进行处理。

    public abstract class Price {
        abstract double getCharge(int rentedDays);
        abstract int getAmount(int rentedDays);
    }
    ...
    public class RegularPrice extends Price {
    
        @Override
        double getCharge(int rentedDays) {
            int result = 0;
            result+=2;
            if (rentedDays > 2) {
                result+= (rentedDays -2)*1.5;
            }
            return result;
        }
    
        @Override
        int getAmount(int rentedDays) {
            return rentedDays*2;
        }
        
    }
    

    这样以后如果有什么新的影片类型,我们只需要创建不同的 Price 的子类就可以了,完全不用修改原有的 Movie 类了。积分也是一样的道理。

    总结##

    第一章节,只是带领我们大体的领略了一下重构的过程。学习到如下内容:

    • 方法应该尽量的短,保持职责清晰和单一
    • 方法应该尽量在所使用的对象内
    • 方法名、变量名、参数名要见名知义
    • 合理使用多态,是代码更加容易扩展和修改

    相关文章

      网友评论

          本文标题:改善既有代码的设计笔记(一)重构世界的 Hello World

          本文链接:https://www.haomeiwen.com/subject/zddhpftx.html