重構(Refactoring)的目標#

接續上一章,當我們透過測試聞到重複(Duplication)的味道後,就要在測試保護下動手重構(Refactoring)。理想狀態下,重構幾乎不應該動到測試。

事前分析#

我們檢視原本長達七十幾行的 calculate 方法:

public int calculate(Transcript transcript) throws UnknownProgramTypeException {

    String programType = transcript.getProgramType();

    if (programType.equals("Bachelor")) {

        List courses = transcript.getCourses();
        if (courses.isEmpty()) return 0;

        int total = courses.size();
        int achieved = 0;
        for (Course course : courses) {
            if (course.getScore() >= 80) {
                achieved++;
            }
        }
        double rate = (double) achieved / total;

        if (rate >= (double) 1 / 2) {
            return 10_000;
        } else if (rate >= (double) 1 / 3) {
            return 5_000;
        } else {
            return 0;
        }
    }

    if (programType.equals("Master")) {

        List courses = transcript.getCourses();
        if (courses.isEmpty()) return 0;

        double totalCredit = 0.001D;
        double totalWeightedScore = 0D;

        for (Course course : courses) {
            totalCredit += course.getCredit();
            totalWeightedScore += course.getScore() * course.getCredit();
        }

        double weightedAverage = totalWeightedScore / totalCredit;

        if (weightedAverage >= 90D) {
            return 15_000;
        } else if (weightedAverage >= 80D) {
            return 7_500;
        } else {
            return 0;
        }
    }

    // PhD 區塊類似,略
    throw new UnknownProgramTypeException(programType);
}

高階邏輯其實只是:

  • 從成績單取得身份
  • 依身份套用對應公式
  • 都不是就丟錯誤

最大問題是太長,讀到後面就忘了前面,於是先處理「太長」這件事。

提取方法(Extract Method)#

使用 Martin Fowler 在 Refactoring 書中介紹的提取方法(Extract Method),把每段判斷後的細節包裝成方法:

public int calculate(Transcript transcript) throws UnknownProgramTypeException {

    String programType = transcript.getProgramType();

    if (programType.equals("Bachelor")) {
        return calculateBachelor(transcript);
    }

    if (programType.equals("Master")) {
        return calculateMaster(transcript);
    }

    if (programType.equals("PhD")) {
        return calculatePhD(transcript);
    }

    throw new UnknownProgramTypeException(programType);
}

請務必使用 IDE 的重構功能,不要手寫或複製貼上。每個機械式動作省 10 秒,累積起來會省下大量時間,這也是許多人覺得「重構很花時間」的真正元凶。

每一步重構後,都花幾十毫秒跑一次測試確認沒壞掉。

委託(Delegate)給專屬計算機#

接著處理「做太多事」。把每種身份的計算委託給對應的類別:

private final BachelorScholarshipCalculator bachelorScholarshipCalculator = new BachelorScholarshipCalculator();
private final MasterScholarshipCalculator masterScholarshipCalculator = new MasterScholarshipCalculator();
private final PhDScholarshipCalculator phDScholarshipCalculator = new PhDScholarshipCalculator();

public int calculate(Transcript transcript) throws UnknownProgramTypeException {

    String programType = transcript.getProgramType();

    if (programType.equals("Bachelor")) {
        return bachelorScholarshipCalculator.calculateBachelor(transcript);
    }

    if (programType.equals("Master")) {
        return masterScholarshipCalculator.calculateMaster(transcript);
    }

    if (programType.equals("PhD")) {
        return phDScholarshipCalculator.calculatePhD(transcript);
    }

    throw new UnknownProgramTypeException(programType);
}

未來修改任何身份的計算公式,都不需要動到 Service。

抽取統一介面#

為了符合開放封閉原則,再抽出共同 Calculator 介面:

private final Calculator bachelorScholarshipCalculator = new BachelorScholarshipCalculator();
private final Calculator masterScholarshipCalculator = new MasterScholarshipCalculator();
private final Calculator phDScholarshipCalculator = new PhDScholarshipCalculator();

public int calculate(Transcript transcript) throws UnknownProgramTypeException {

    String programType = transcript.getProgramType();

    if (programType.equals("Bachelor")) {
        return bachelorScholarshipCalculator.calculate(transcript);
    }

    if (programType.equals("Master")) {
        return masterScholarshipCalculator.calculate(transcript);
    }

    if (programType.equals("PhD")) {
        return phDScholarshipCalculator.calculate(transcript);
    }

    throw new UnknownProgramTypeException(programType);
}

重構每一步都不要走太大步。每一步都會「暫時」讓程式暴露在風險之中,所以盡量小步前進,萬一出事必須中止重構,也只要丟棄一點點就好。

改寫成符合高階抽象的形式#

最後再進一步改寫,把「找計算機」與「執行計算機」分離:

public int calculate(Transcript transcript) throws UnknownProgramTypeException {
    Calculator calculator = findCalculator(transcript.getProgramType());
    return calculator.calculate(transcript);
}

private Calculator findCalculator(String programType) throws UnknownProgramTypeException {
    switch (programType) {
        case "Bachelor":
            return new BachelorScholarshipCalculator();
        case "Master":
            return new MasterScholarshipCalculator();
        case "PhD":
            return new PhDScholarshipCalculator();
        default:
            throw new UnknownProgramTypeException(programType);
    }
}

calculate 方法現在只做流程協調。新增學生身份、修改任一計算公式、修改金額,都不再需要動 calculate,這個方法只會因為「流程改變」這一個原因而被修改。

整個過程中測試完全沒改動,因為對外行為從未變化。這才是重構的真正樣貌。

原文出處#

  • 原書/iThome:https://ithelp.ithome.com.tw/articles/10262382