iT邦幫忙

2021 iThome 鐵人賽

DAY 12
3
Software Development

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

Day 12「可惡想要」單元測試、Code Smell 與重構 - Feature Envy 篇


圖片來源:https://disp.cc/b/115-9Z5x

從這一篇起,我們會一連進行幾篇跟「重構與壞味道」有關的討論。其中會列出幾個在工作中非常容易遇到的壞味道。我們將用一些實際案例,來顯示這些壞味道是在什麼場景下較常發生,他的影響會是什麼,以及該如何重構他。當然,是在「單元測試」的保護下進行。

Feature Envy(依戀情結)

關於 Feature Envy 的定義,Martin Fowler 在 Refactoring 書中指出:「函式對於某個 class 的興趣高過對自己所處之 host class 的興趣。」部落格「搞笑談軟工」作者 Teddy Chen 則延伸解釋道:「這個函數一天到晚跟『隔壁老王』或『小三』在那裏『line來line去』,對於「家務事」卻興趣缺缺。」

Martin Fowler 直指,這種迷戀最常發生在「資料」上。今天如果 A 類裡的某個方法,老是喜歡存取 B 類的資料來運算,這會導致 B 的細節一旦有變,A 就不得不跟著變。或是每當 B 想要改變自己身上資料的存取方式時,還得看 A 的臉色。這就造成了 A 與 B 緊密耦合,而我們並不樂見此事。

解決之道

正常來說,我們喜歡將「總是一起變化的東西」放在一塊兒。於是當發現 A 的方法對 B 的 Feature 有異常的 Envy,那就乾脆放他自由,把此方法移到 B 身上得了。

舉例

我們拿先前教務處網站後台,算獎學金的例子來看看,隨便找個「算碩士生獎學金」的方法來看看吧。

@Override
public int calculate(Transcript transcript) {

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

這裡有兩個運算,分別為「是否有修課」,以及「計算加權平均分」。在這裡我們可以看到,MasterScholarshipCalculator 身上 calculate 的方法,花了一半以上的篇幅在跟 Transcript 的資料溝通,而對自己身上的邏輯,只有在最後取幾個數字而已。套句小馬哥說的:「如果這不是 Feature Envy,那什麼才是 Feature Envy?」


圖片截自 YouTube

要對付 Feature Envy,起手式就是要提取方法(Extrace Method)。我們先把跟別人搞七捻三的邏輯區段用方法將其隔離開來,使高階業務邏輯浮現,晚點要搬移再來搬。容我再提醒各位一次:這一步驟,請務必使用 IDE 的重構工具來進行。結束後也別忘記花個 65 ms 跑個測試:

    @Override
    public int calculate(Transcript transcript) {

        List<Course> courses = transcript.getCourses();

        if (hasNoCourses(courses)) return 0; // 不修課跟人家領什麼獎學金!

        double weightedAverage = calculateWeightedAverage(courses);

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

    private double calculateWeightedAverage(List<Course> courses) {
        double totalCredit = 0.001D;
        double totalWeightedScore = 0D;

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

        double weightedAverage = totalWeightedScore / totalCredit;
        return weightedAverage;
    }

    private boolean hasNoCourses(List<Course> courses) {
        return courses.isEmpty();
    }

抽出方法後,Feature Envy 的味道更明顯了!提取出來的兩個方法,根本就從頭到尾都在跟 Transcript 身上的 courses 這個 List 互動嘛!既然如此,我們就成全他們,用 Move Method 手法,把它們搬到 Transcript 身上去吧!

@Override
public int calculate(Transcript transcript) {

    if (transcript.hasNoCourses()) return 0; // 不修課跟人家領什麼獎學金!

    double weightedAverage = transcript.calculateWeightedAverage();

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

呼,這下相愛的人終於光明正大在一起,不用在那邊「偷來暗去」了。Service 則是眼不見為淨,只要跟 Transcript 要最終運算結果,過程跟資料細節他就不用管了,皆大歡喜。

眼尖的讀者會發現,這裡其實還存在者 if 這個壞味道,怎麼辦?再往下重構啊!反正有測試保護,怕什麼。「重構」本來就不該是一次性的大功能,他是一步一步,分階段慢慢進行的。不過,這不是本篇要講的內容,我就暫時就此打住了,各位可以下載下來自己繼續往下重構看看。

謎之音:「最後的疼愛是手放開。」

Reference

  1. Martin Fowler, Refactoring : Improving the Design of Existing Code, Addison-Wesley, 2000
  2. 搞笑談軟工部落格:https://teddy-chen-tw.blogspot.com/
  3. GitHub Repository:https://github.com/bearhsu2/ithelp2021.git
tags: ithelp2021

上一篇
Day 11 「我以火力掩護你」在測試的保護下重構:消除重複
下一篇
Day 13 「難兄難弟」 單元測試、Code Smell 與重構 - Data Clump 與 Primitive Obsession 篇
系列文
你就是都不寫測試才會沒時間:Kuma 的 30 天 Unit Test 手把手教學,從理論到實戰 (Java 篇)30

尚未有邦友留言

立即登入留言