iT邦幫忙

2021 iThome 鐵人賽

DAY 13
2
Software Development

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

Day 13 「難兄難弟」 單元測試、Code Smell 與重構 - Data Clump 與 Primitive Obsession 篇


圖片截自三立新聞

與筆者年紀相當的朋友,肯定還記得小時候有個非常紅的電示節目叫「龍兄虎弟」吧。當時可謂萬人空巷,紅到整個節目被挖角到友台去變身「龍虎綜藝王」,搞得原電視台不得不臨時找來徐乃麟與黃安接棒主持,最後徐黃兩人鬧翻,至今老死不相往來…

今天要來聊兩個很常見,很常一起出現,也能很快破壞程式可讀性的壞味道:Data Clump 與 Primitive Obsession。當然,這兩傢伙就肯定不是龍兄虎弟了,頂多只能算是「難兄難弟」而已…

Primitive Obsession

在物件導向的程式裡,我們喜歡把相關的值和行為「封裝」在同一個物件裡,讓它們就可以自己拿自己身上的值,用「介面」來與外界互動,而不用倚賴他人幫忙。

現下程式語言大多都支援特定幾種「基本型別」,它們沒有物理意義、不具商業邏輯、沒有行為,甚至不能修改。這些屬性同時形成了它們的優點與缺點。然而在一些場合,需要表達較清楚的「物理意義」時,過度使用基本型別來表現,將使得程式不易閱讀。好的命名可以稍稍緩解此現象,但終究不能取代「物件」能提供的行為與商業邏輯

舉例,在 Java 的程式裡拿 long 來表示一個時間點,是準確可靠的做法,卻因此喪失了「時間」的觀念,還得另外用一些運算來補足;拿 兩個 double 來代表經緯度,一樣能表現物體在地球上的位置,但是他們就變得老是得綁在一起,而且,也還是要另外找算式來運算他們,才能表現出「位置」這個物理意義。

因此,在程式裡(尤其是在介面上),如果大量使用基本型別,將會使得程式喪失表達力,變得不好理解。

Data Clump

Refactoring 書中對 Data Clump 的定義是:「...就像小孩子,喜歡成群結隊地待在一塊兒」。這不能怪它們,它們就是得要待在一起才有意義。譬如,市內電話與區域號碼、座標平面上的 X 與 Y、API 網址的 Domain Name 與 port,以及剛剛提到的經度與緯度等。

這些資料有個特性:它們「老是一起出現」。如果在程式裡經常使用,或是程式的介面需要拿它們當參數,那它們就永遠在強迫使用者老是寫「重複的程式碼」。這很正常,譬如,沒有人做平面繪圖只看 X 座標的。我要拿 X,我就不得不拿 Y,重複程式碼就出現了,我還沒得選!這也應驗了我們先前說過的:「重複的程式碼」乃程式的萬惡之淵藪。

難兄難弟

經驗上,這兩個壞味道很常一起出現,互為因果。譬如剛剛提過的經度跟緯度,他們本身就是基本型別(double),這使得使用者在地圖上的「位置」這個商業概念不見了,形成了 Primitive Obsession。

而功能還是得開發,為了表達「位置」這個商業概念,開發者就不得不老是讓這兩個數字一起出現,就形成了 Data Clump。更麻煩的是,當「位置」必須在物件之間傳遞時,又老是強佔人家兩個參數的位置。

Uncle Bob 曾說:「一個方法,最理想的參數個數,就是沒有參數,其次是一個,以此類推,最多不要超過三個。」你一個經度緯度一出現,一下子就佔掉人家兩個參數的位置,人家當然不喜歡啦。

解決之道

當多個基本型別老是一起出現,才能代表一個商業概念,那我們就乾脆把它們送作堆,新創一個物件來裝起來就得了。如果這個物件出現在介面上,我們就把它稱為「Parameter Object」。

舉例

我們來到教務處網站,學校現在想做一個「線上簽到」的功能。當學生在課堂上打開網頁或 App,點一下「簽到」,系統就能自動為你簽到,而不用擺簽到簿或一一點名。為了防止學生人不在教室,卻在家偷簽,我們在學生簽到時,檢查他的地理位置是否離教室夠近。夠近才能成立。

public class DistanceChecker {

    // 中略

    public boolean checkDistance(long courseId, double longitude, double latitude) {

        Course course = courseRepository.find(courseId);

        ClassRoom classRoom = course.getClassRoom();

        double classRoomLongitude = classRoom.getLongitude();
        double classRoomLatitude = classRoom.getLatitude();

        double distance = distanceCalculator.calculate(longitude, latitude, classRoomLongitude, classRoomLatitude);

        return distance < 50D;
    }

}

簡單來說,這段程式就是拿著 classId 找出 ClassRoom,跟 ClassRoom 要了教室的經緯度之後,再拿著學生打卡時的經緯度,丟給一個專門算距離的工具 DistanceCalculator 去算距離,最後判斷學生是否在距教室 50 公尺以內的地方打卡,是就成功,不是就失敗。

程式碼不長,但可讀性略差(至少在筆者任職的單位,這種程式碼 Code Review 是不會過的)。其實有些讀者已經看出三、四個壞味道以上了,但是沒關係,我們一個一個來,要重構,先加測試。

class DistanceCheckerTest {
    @Test
    void closed_enough() {

        DistanceChecker distanceChecker = new DistanceChecker(
                dummy_repository(9527L, 0D, 0D),
                dummy_calculator(1D, 1D, 0D, 0D, 49D));

        assertTrue(distanceChecker.checkDistance(9527L, 1D, 1D));

    }


    @Test
    void too_far() {

        DistanceChecker distanceChecker = new DistanceChecker(
                dummy_repository(9527L, 0D, 0D),
                dummy_calculator(99D, 99D, 0D, 0D, 51D));

        assertFalse(distanceChecker.checkDistance(9527L, 99D, 99D));

    }
    
    // ...後略
}

這裡為了方便示範,我們分別測成功(49 公尺)與失敗(51 公尺)的案例各一就好,,其他什麼找不到教室,經緯度超出範圍的情況,我們暫時先不管。各位哪天真的在工作上遇上了,還是得測嘿!

各位看看這個測試,有夠煩的啦,數字有夠多!這是好事,因為你才剛寫完就發現它很難用,而不是等到用戶來跟你抱怨難用。而且,你程式才剛寫完印象還很新,又有了測試保護,這是重構最佳時機啊!怎麼做?在 Refactoring 書中,作者建議的手法之一就是「提取參數物件」(Extract Parameter Object),讓經度跟緯度兩個數字,變身成具物理意義的物件,我們就命名為「Position」吧!

注意,注意,注意!這裡還是要不厭其煩地提醒各位,盡量使用 IDE 提供的重構功能來做,才能節省時間、避免出錯!


善用 IDE 重構功能示意圖

我們直接來看看重構完變成什麼樣子,記得先跑測試:

public class DistanceChecker {

    // 中略
    
    public boolean checkDistance(long courseId, Position studentPosition) {

        Position classroomPosition = courseRepository.find(courseId).getClassRoom().getPosition();

        double distance = distanceCalculator.calculate(studentPosition, classroomPosition);

        return distance < 50D;
    }

}

果真簡潔很多。

出現了!其他壞味道!

在消除完 Data Clump 與 Primitive Obsession 兩個 Code Smell 之後,程式介面的「意圖表達力」就變好了,程式碼也變簡潔了。這時,我們可以選擇 push 然後下班,也可以選擇繼續重構。

「還要重構什麼?剩三行的 code 是能有什麼壞味道?」當然還有!隨便喵兩眼就看到兩個壞味道:「Message Chains」與「Feature Envy」。

Message Chains 指的是使用者跟 A 物件要完 B 物件,再跟 B 物件要 C 物件,再向 C 物件要 D 物件...以此類推,這明顯地違反了「迪米特法則」,你可以在取得 classroomPosition 時看到。而 Feature Envy 的定義我們上一篇講過了,它就藏在計算距離的邏輯中。 我們可以看到這個 DistanceCalculator 從頭到尾只做跟 Position 有關的事,這是很明顯的 Feature Envy。距離應該要叫 Position 自己算就好。

從這裡我們不難看出,真的要找壞味道,短短幾行就可以找得到,只是要不要改,得看你覺不覺得困擾。以這段程式來說,筆者其實覺得,重構到這裡,如果你不覺得困擾,也可以不改,或是等哪天真的受不了再回頭改也行,反正有測試保護嘛!

謎之聲:「壞味道是改不完的,大家找個覺得舒服的平衡點就好,不然你想幾點下班啊?」

Reference

  1. Martin Fowler, Refactoring : Improving the Design of Existing Code, Addison-Wesley, 2000
  2. Robert C. Martin, Clean Code: A Handbook of Agile Software Craftsmanship. Upper Saddle River, NJ: Prentice Hall, 2009.
  3. 搞笑談軟工部落格:https://teddy-chen-tw.blogspot.com/
  4. 迪米特法則:https://en.wikipedia.org/wiki/Law_of_Demeter
  5. GitHub Repository:https://github.com/bearhsu2/ithelp2021.git
tags: ithelp2021

上一篇
Day 12「可惡想要」單元測試、Code Smell 與重構 - Feature Envy 篇
下一篇
Day 14 「不殘而廢」單元測試、Code Smell 與重構 - Data Class 篇
系列文
你就是都不寫測試才會沒時間:Kuma 的 30 天 Unit Test 手把手教學,從理論到實戰 (Java 篇)30

尚未有邦友留言

立即登入留言