iT邦幫忙

2021 iThome 鐵人賽

DAY 11
6
Software Development

你就是都不寫測試才會沒時間:Kuma 的 30 天 Unit Test 手把手教學,從理論到實戰 (Java 篇)系列 第 11

Day 11 「我以火力掩護你」在測試的保護下重構:消除重複

「班長:班長命令你實施敵火下作業,試問單兵該如何處置?」
『單兵:報告班長,請班長以火力掩護我,完成敵火下作業。』
「班長:好!我以火力掩護你。」
在寫這篇時,筆者突然想到當年拿著槍與小抄,在成功嶺光禿禿的山丘上滾來滾去,沾得全身是沙的往事(菸)

在上一篇透過測試,嗅到了「重複」程式碼的壞味道以後呢,我們就要來藉著測試的保護,來重構邏輯了。理想狀態下的重構應該是幾乎不會動到測試的。我們待會進行時要以此為目標。

事前分析

在開始重構之前,我們還是得先看一下這段程式到底做了什麼事情。在此之前,我們都盡量不在文章中完整顯示這個方法,因為他實在是太長了,讀起來相當辛苦,然而這裡為了重構,以及方便各位讀者比較重構前後的差異,只好「全文照登」。

來,別怕,我們一起來看一下,這個七十幾行的方法到底在做啥,以及讀起來有多辛苦:

public int calculate(Transcript transcript) throws UnknownProgramTypeException {

    String programType = transcript.getProgramType();

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

        List<Course> 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<Course> 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;
        }
    }


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

        List<Course> courses = transcript.getCourses();
        if (courses.isEmpty()) return 0; // 不修課跟人家領什麼獎學金!


        for (Course course : courses) {
            if (course.getScore() < 80) {
                return 0;
            }
            if (course.getScore() < 90) {
                return 20_000;
            }
        }
        return 40_000;
    }

    throw new UnknownProgramTypeException(programType);
}

好,我們終於讀完了。讀到這一行的你,肯定已經忘記前面在做啥了吧?沒關係,這很正常,這也就是我們為什麼必須重構的原因。其實說到底,整個 calculate 方法做了五件事:

  1. 從成績單中得到學生的身份
  2. 如果學生是大學生,就用大學生的公式算獎學金。
  3. 如果學生是碩士生,就用碩士生的公式算獎學金。
  4. 如果學生是博士生,就用博士生的公式算獎學金。
  5. 如果都不是,就丟錯誤。

我們已經提過很多次,一個方法只做一件事,這裡很明顯超過,怎麼辦?沒關係,在還沒頭緒的時候,先解決最困擾的:「這方法實在是太長了!」真的太長了,長到我每次讀到後面,就忘了前面在講什麼,一直在程式的細節實作與抽象邏輯裡來回跳動思考,非常痛苦。於是我決定先處理太長這件事。

提取方法

本篇將會使用 Martin Folwer 在重構一書中提到的一些手法。這裡會先用「提取方法」,來隱藏一些細節,目的是要使高階抽象邏輯暴露。我們把判斷完三種學生後要做的複雜事情提取出方法來放在旁邊,此時原方法就會變成這樣:

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);

}

酷吧!我們只是簡單地提取了三個方法,就讓原本冗長難以閱讀的方法,一下子就縮短到只剩不到 20 行。這下,我們就可以「一眼」把邏輯看完了!就算只做到這裡,我也覺得很划算了!

善用 IDE 的重構功能

題外話,這裡,我要建議各位為了效率與正確性,請「一定不要自己寫,也不要自己複製貼上」,請務必使用 IDE 的重構功能,像這樣:

提取一個方法也許只能省下 10 秒,但是在重構的過程中,你會遇到非常多這種小規模機械式操作。一個動作省 10 秒,長久下來可以省去你非常多時間。很多人不重構,也就是因為他認為重構很花時間,但其實不是的,浪費時間的元凶其實是這些機械式動作。

請記住,「跑測試是不用錢的」。進行下一步前,別忘了再花 65 ms 跑一下測試,確定一下功能沒被改壞:

委託

現在我們能一眼看出方法意圖,我們就再往下處理它「做太多事」這個問題。很明顯地,這裡不但管了「演算法的選擇」,也管了「演算法的內容」。這時只要三種演算法的任何一種有變,你都必須來改這個類。這個類就變成一個修改熱點。我們不喜歡,於是我們想要把計算「委託」給別的類來做。

如果讀者對「Delegate」的概念還不熟悉,我們複習一下:Delegate 意指,把原本在 A 類實作的行為,搬移到 B 類別去做,讓 A 去引用就好。一樣地,這邊也建議各位使用 IDE 提供的 Extract 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 是完全不用動的。

進行下一步前,不免俗地,還是花個 65 ms 跑一下測試,確認沒有東西壞掉。

抽取介面

現在程式的樣貌已經比一開始好很多了,但我們好還要更好。現在這個方法本身存在一個問題:「違反開放封閉原則」。如果我今天要加個「在職專班」,或是「夜間進修」的學生類別,我還是得進來改這個方法。這讓我們想要用更抽象的方式來寫這個方法。其實這個方法做的事,就是:

  1. 依照學生類別,找到對應的計算機
  2. 回傳計算機處理的結果

既然如此,那我們就把這個方法改寫成上述的樣貌就好。而要做到這一點,我們得先對計算機動手腳,讓計算機的介面統一,如此一來,原方法就可以用一模一樣的方式來操作不同計算機了。

有點難理解嗎?沒關係,先來抽看看就知道了。容筆者再提醒一次,這裡的抽取介面,也請直接使用 IDE 提供的重構功能,以節省時間與避免打字錯誤:

    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);

    }

眼尖的您應該能發現,這裡除了抽了 Calculator 這個介面以外,其他步驟幾乎沒有變動。是的,我故意的,重構,就是每步都不能走太大步。我們現在已經知道這個程式是對的了,我們踏出的每一步都會讓他「暫時」暴露在壞掉的風險之中。因此,我們要盡量讓每一步都小小的,這樣萬一改壞了,或講更實際的,萬一發生什麼事情,我們不得不放棄重構,必須馬上上線,我們也只要丟棄一點點東西,而不用整個重來。這點很重要。

將程式改寫成符合高階抽象邏輯的寫法

言歸正傳,到這裡為止,我們進行的都是簡單的、IDE 能一件完成的操作。這裡,我們就要稍微多寫點 Code 了。我們要把 calculate 這個方法,正式改寫成符合前述「依照學生類別,找到對應的計算機,再回傳計算機處理的結果」的樣貌。

記得先 Commit,這也很重要 x 3!

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 方法只做一件事:找到正確的 calculator 後回傳運算結果,也就是個管流程的。這也使得這個 Service 的設計,更為符合 Uncle Bob 在 Clean Architecture 一書中,對 Service 的定義,同時意外地,不小心套用了 Eric Evans 在 Domain-Driven Design 一書中,提到的「無副作用的函式」模式。

這兩個觀念不是本篇要討論的重點,所以筆者就不詳細論述了。重點是,現在不管你是要新增學生身份,修改任一獎學金的計算方式,還是要修改金額,都不用進來修改 calculate 方法了。而這方法只有一種原因會修改,就是當「流程有變」的時候。這就滿足我們想要的,「一個方法只會因為一種原因而被修改」的原則了。

恭喜各位,在完整測試的保護下,完成了一次幅度不算小的重構。各位回頭再比對一下重構之前的原始樣貌,差距很大吧!

「還可以再往下重構嗎?」

可以唷!其實這裡雖然已經把「挑選計算機」的邏輯抽成方法,但還是留在同一個 class 裡面。如果真要說,這個 findCalculator 方法也可以 delegate 出去給其他 class 做。不過這得看你覺不覺得困擾,而且主要是整理到這邊我也累了(躺),所以我決定暫時就先這樣,等哪天又看不順眼時,再委託出去也行。反正有測試嘛,不怕!

說到測試,各位有發現嗎?我們從頭到尾,測試都沒改唷!是的,因為我們沒有改變 calculate 方法對外的表現

謎之聲:「這,才叫重構!」

Reference

  1. Martin Fowler, Refactoring : Improving the Design of Existing Code, Addison-Wesley, 2000
  2. 開放封閉原則:https://en.wikipedia.org/wiki/Open%E2%80%93closed_principle
  3. The Clean Architecture:https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
  4. Eric Evans, Domain-Driven Design : Tackling Complexity in the Heart of Software, Addison-WesleyProfessional, 2003
tags: ithelp2021

上一篇
Day 10 「如入鮑魚之肆」從測試聞出 code smell:萬惡之源 ---「重複」
下一篇
Day 12「可惡想要」單元測試、Code Smell 與重構 - Feature Envy 篇
系列文
你就是都不寫測試才會沒時間:Kuma 的 30 天 Unit Test 手把手教學,從理論到實戰 (Java 篇)30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言