圖片截自三立新聞
與筆者年紀相當的朋友,肯定還記得小時候有個非常紅的電示節目叫「龍兄虎弟」吧。當時可謂萬人空巷,紅到整個節目被挖角到友台去變身「龍虎綜藝王」,搞得原電視台不得不臨時找來徐乃麟與黃安接棒主持,最後徐黃兩人鬧翻,至今老死不相往來…
今天要來聊兩個很常見,很常一起出現,也能很快破壞程式可讀性的壞味道:Data Clump 與 Primitive Obsession。當然,這兩傢伙就肯定不是龍兄虎弟了,頂多只能算是「難兄難弟」而已…
在物件導向的程式裡,我們喜歡把相關的值和行為「封裝」在同一個物件裡,讓它們就可以自己拿自己身上的值,用「介面」來與外界互動,而不用倚賴他人幫忙。
現下程式語言大多都支援特定幾種「基本型別」,它們沒有物理意義、不具商業邏輯、沒有行為,甚至不能修改。這些屬性同時形成了它們的優點與缺點。然而在一些場合,需要表達較清楚的「物理意義」時,過度使用基本型別來表現,將使得程式不易閱讀。好的命名可以稍稍緩解此現象,但終究不能取代「物件」能提供的行為與商業邏輯。
舉例,在 Java 的程式裡拿 long 來表示一個時間點,是準確可靠的做法,卻因此喪失了「時間」的觀念,還得另外用一些運算來補足;拿 兩個 double 來代表經緯度,一樣能表現物體在地球上的位置,但是他們就變得老是得綁在一起,而且,也還是要另外找算式來運算他們,才能表現出「位置」這個物理意義。
因此,在程式裡(尤其是在介面上),如果大量使用基本型別,將會使得程式喪失表達力,變得不好理解。
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 自己算就好。
從這裡我們不難看出,真的要找壞味道,短短幾行就可以找得到,只是要不要改,得看你覺不覺得困擾。以這段程式來說,筆者其實覺得,重構到這裡,如果你不覺得困擾,也可以不改,或是等哪天真的受不了再回頭改也行,反正有測試保護嘛!
謎之聲:「壞味道是改不完的,大家找個覺得舒服的平衡點就好,不然你想幾點下班啊?」
ithelp2021