圖片來源:https://disp.cc/b/115-9Z5x
從這一篇起,我們會一連進行幾篇跟「重構與壞味道」有關的討論。其中會列出幾個在工作中非常容易遇到的壞味道。我們將用一些實際案例,來顯示這些壞味道是在什麼場景下較常發生,他的影響會是什麼,以及該如何重構他。當然,是在「單元測試」的保護下進行。
關於 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 這個壞味道,怎麼辦?再往下重構啊!反正有測試保護,怕什麼。「重構」本來就不該是一次性的大功能,他是一步一步,分階段慢慢進行的。不過,這不是本篇要講的內容,我就暫時就此打住了,各位可以下載下來自己繼續往下重構看看。
謎之音:「最後的疼愛是手放開。」
ithelp2021