兩個常一起出現的壞味道#

這一章談兩個常一起出現、互為因果的程式碼異味(Code Smell):原始型別偏執(Primitive Obsession)與資料群(Data Clump)。

Primitive Obsession#

物件導向程式喜歡把相關的值與行為「封裝」在物件裡,透過介面與外界互動。但現代語言提供的基本型別(int、long、double 等)沒有物理意義,沒有商業邏輯,也沒有行為。

當需要表達清楚的物理意義時,過度依賴基本型別會使程式失去表達力:

  • long 表示時間點:準確可靠,但失去「時間」的概念
  • 用兩個 double 表示經緯度:能描述位置,但兩個數字老是被迫綁在一起

好的命名能稍微緩解,但無法取代值物件(Value Object)能提供的行為與商業邏輯。

Data Clump#

資料群(Data Clump)來自 Refactoring 書中的描述,意思是「就像小孩子,喜歡成群結隊地待在一塊兒」。例如:

  • 市內電話與區域號碼
  • 平面座標的 X 與 Y
  • API 網址的 domain name 與 port
  • 經度與緯度

它們的共通特性是「老是一起出現」。一旦某個方法的介面拿它們當參數,使用者就被迫一再重複寫類似的程式碼。

難兄難弟#

這兩個壞味道常互為因果:

  • 經緯度本身是基本型別 ➡️ Primitive Obsession
  • 為了表達「位置」這個概念,兩個數字被迫一起出現 ➡️ Data Clump
  • 在物件之間傳遞「位置」時,又佔掉兩個參數位置

Uncle Bob 在 Clean Code 中曾建議方法參數最理想是 0 個,最多不要超過 3 個。一組經緯度一出現就佔掉兩個參數位置。

解決之道#

當多個基本型別老是一起出現代表一個商業概念,就把它們封裝成新的物件。如果這個物件出現在介面上,稱為 Parameter Object。

範例:線上簽到的距離檢查#

學校要做線上簽到功能,需要驗證學生位置是否離教室夠近:

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

可讀性不佳,介面充斥基本型別。先補上測試:

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

測試很煩,數字一堆。這是好事,因為剛寫完就發現難用,是重構的最佳時機。

提取參數物件(Extract Parameter Object)#

把經緯度封裝成 Position

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

抽取請務必使用 IDE 提供的重構功能,節省時間並避免出錯。

還會帶出其他壞味道#

消除 Data Clump 與 Primitive Obsession 後,介面表達力立刻變好。但短短幾行還能找到其他味道:

  • Message Chains:取得 classroomPosition 時連續呼叫多層 getter,違反迪米特法則
  • Feature Envy:distanceCalculator 從頭到尾只在跟 Position 互動,距離應該叫 Position 自己算

壞味道是改不完的。找個舒服的平衡點即可,重要的是有測試保護,何時想再回頭改都可以。

原文出處#

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