「班長:班長命令你實施敵火下作業,試問單兵該如何處置?」
『單兵:報告班長,請班長以火力掩護我,完成敵火下作業。』
「班長:好!我以火力掩護你。」
在寫這篇時,筆者突然想到當年拿著槍與小抄,在成功嶺光禿禿的山丘上滾來滾去,沾得全身是沙的往事(菸)
在上一篇透過測試,嗅到了「重複」程式碼的壞味道以後呢,我們就要來藉著測試的保護,來重構邏輯了。理想狀態下的重構應該是幾乎不會動到測試的。我們待會進行時要以此為目標。
在開始重構之前,我們還是得先看一下這段程式到底做了什麼事情。在此之前,我們都盡量不在文章中完整顯示這個方法,因為他實在是太長了,讀起來相當辛苦,然而這裡為了重構,以及方便各位讀者比較重構前後的差異,只好「全文照登」。
來,別怕,我們一起來看一下,這個七十幾行的方法到底在做啥,以及讀起來有多辛苦:
public int calculate(Transcript transcript) throws UnknownProgramTypeException {
String programType = transcript.getProgramType();
if (programType.equals("Bachelor")) {
List<Course> courses = transcript.getCourses();
if (courses.isEmpty()) return 0; // 不修課跟人家領什麼獎學金!
int total = courses.size();
int achieved = 0;
for (Course course : courses) {
if (course.getScore() >= 80) {
achieved++;
}
}
double rate = (double) achieved / total;
if (rate >= (double) 1 / 2) {
return 10_000;
} else if (rate >= (double) 1 / 3) {
return 5_000;
} else {
return 0;
}
}
if (programType.equals("Master")) {
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;
}
}
if (programType.equals("PhD")) {
List<Course> courses = transcript.getCourses();
if (courses.isEmpty()) return 0; // 不修課跟人家領什麼獎學金!
for (Course course : courses) {
if (course.getScore() < 80) {
return 0;
}
if (course.getScore() < 90) {
return 20_000;
}
}
return 40_000;
}
throw new UnknownProgramTypeException(programType);
}
好,我們終於讀完了。讀到這一行的你,肯定已經忘記前面在做啥了吧?沒關係,這很正常,這也就是我們為什麼必須重構的原因。其實說到底,整個 calculate 方法做了五件事:
我們已經提過很多次,一個方法只做一件事,這裡很明顯超過,怎麼辦?沒關係,在還沒頭緒的時候,先解決最困擾的:「這方法實在是太長了!」真的太長了,長到我每次讀到後面,就忘了前面在講什麼,一直在程式的細節實作與抽象邏輯裡來回跳動思考,非常痛苦。於是我決定先處理太長這件事。
本篇將會使用 Martin Folwer 在重構一書中提到的一些手法。這裡會先用「提取方法」,來隱藏一些細節,目的是要使高階抽象邏輯暴露。我們把判斷完三種學生後要做的複雜事情提取出方法來放在旁邊,此時原方法就會變成這樣:
public int calculate(Transcript transcript) throws UnknownProgramTypeException {
String programType = transcript.getProgramType();
if (programType.equals("Bachelor")) {
return calculateBachelor(transcript);
}
if (programType.equals("Master")) {
return calculateMaster(transcript);
}
if (programType.equals("PhD")) {
return calculatePhD(transcript);
}
throw new UnknownProgramTypeException(programType);
}
酷吧!我們只是簡單地提取了三個方法,就讓原本冗長難以閱讀的方法,一下子就縮短到只剩不到 20 行。這下,我們就可以「一眼」把邏輯看完了!就算只做到這裡,我也覺得很划算了!
題外話,這裡,我要建議各位為了效率與正確性,請「一定不要自己寫,也不要自己複製貼上」,請務必使用 IDE 的重構功能,像這樣:
提取一個方法也許只能省下 10 秒,但是在重構的過程中,你會遇到非常多這種小規模機械式操作。一個動作省 10 秒,長久下來可以省去你非常多時間。很多人不重構,也就是因為他認為重構很花時間,但其實不是的,浪費時間的元凶其實是這些機械式動作。
請記住,「跑測試是不用錢的」。進行下一步前,別忘了再花 65 ms 跑一下測試,確定一下功能沒被改壞:
現在我們能一眼看出方法意圖,我們就再往下處理它「做太多事」這個問題。很明顯地,這裡不但管了「演算法的選擇」,也管了「演算法的內容」。這時只要三種演算法的任何一種有變,你都必須來改這個類。這個類就變成一個修改熱點。我們不喜歡,於是我們想要把計算「委託」給別的類來做。
如果讀者對「Delegate」的概念還不熟悉,我們複習一下:Delegate 意指,把原本在 A 類實作的行為,搬移到 B 類別去做,讓 A 去引用就好。一樣地,這邊也建議各位使用 IDE 提供的 Extract Delegate 重構功能。重構後程式碼就會變成這樣:
private final BachelorScholarshipCalculator bachelorScholarshipCalculator = new BachelorScholarshipCalculator();
private final MasterScholarshipCalculator masterScholarshipCalculator = new MasterScholarshipCalculator();
private final PhDScholarshipCalculator phDScholarshipCalculator = new PhDScholarshipCalculator();
public int calculate(Transcript transcript) throws UnknownProgramTypeException {
String programType = transcript.getProgramType();
if (programType.equals("Bachelor")) {
return bachelorScholarshipCalculator.calculateBachelor(transcript);
}
if (programType.equals("Master")) {
return masterScholarshipCalculator.calculateMaster(transcript);
}
if (programType.equals("PhD")) {
return phDScholarshipCalculator.calculatePhD(transcript);
}
throw new UnknownProgramTypeException(programType);
}
各位可以看到,程式碼異動不大,但是剛剛提到的「修改熱點」問題已減緩許多,現在要修改各類獎學金的計算方式,只要動該類型的對應類就好,原本的 Service 是完全不用動的。
進行下一步前,不免俗地,還是花個 65 ms 跑一下測試,確認沒有東西壞掉。
現在程式的樣貌已經比一開始好很多了,但我們好還要更好。現在這個方法本身存在一個問題:「違反開放封閉原則」。如果我今天要加個「在職專班」,或是「夜間進修」的學生類別,我還是得進來改這個方法。這讓我們想要用更抽象的方式來寫這個方法。其實這個方法做的事,就是:
既然如此,那我們就把這個方法改寫成上述的樣貌就好。而要做到這一點,我們得先對計算機動手腳,讓計算機的介面統一,如此一來,原方法就可以用一模一樣的方式來操作不同計算機了。
有點難理解嗎?沒關係,先來抽看看就知道了。容筆者再提醒一次,這裡的抽取介面,也請直接使用 IDE 提供的重構功能,以節省時間與避免打字錯誤:
private final Calculator bachelorScholarshipCalculator = new BachelorScholarshipCalculator();
private final Calculator masterScholarshipCalculator = new MasterScholarshipCalculator();
private final Calculator phDScholarshipCalculator = new PhDScholarshipCalculator();
public int calculate(Transcript transcript) throws UnknownProgramTypeException {
String programType = transcript.getProgramType();
if (programType.equals("Bachelor")) {
return bachelorScholarshipCalculator.calculate(transcript);
}
if (programType.equals("Master")) {
return masterScholarshipCalculator.calculate(transcript);
}
if (programType.equals("PhD")) {
return phDScholarshipCalculator.calculate(transcript);
}
throw new UnknownProgramTypeException(programType);
}
眼尖的您應該能發現,這裡除了抽了 Calculator 這個介面以外,其他步驟幾乎沒有變動。是的,我故意的,重構,就是每步都不能走太大步。我們現在已經知道這個程式是對的了,我們踏出的每一步都會讓他「暫時」暴露在壞掉的風險之中。因此,我們要盡量讓每一步都小小的,這樣萬一改壞了,或講更實際的,萬一發生什麼事情,我們不得不放棄重構,必須馬上上線,我們也只要丟棄一點點東西,而不用整個重來。這點很重要。
言歸正傳,到這裡為止,我們進行的都是簡單的、IDE 能一件完成的操作。這裡,我們就要稍微多寫點 Code 了。我們要把 calculate 這個方法,正式改寫成符合前述「依照學生類別,找到對應的計算機,再回傳計算機處理的結果」的樣貌。
記得先 Commit,這也很重要 x 3!
public int calculate(Transcript transcript) throws UnknownProgramTypeException {
Calculator calculator = findCalculator(transcript.getProgramType());
return calculator.calculate(transcript);
}
private Calculator findCalculator(String programType) throws UnknownProgramTypeException {
switch (programType) {
case "Bachelor":
return new BachelorScholarshipCalculator();
case "Master":
return new MasterScholarshipCalculator();
case "PhD":
return new PhDScholarshipCalculator();
default:
throw new UnknownProgramTypeException(programType);
}
}
呼!打完收工!
圖片截自網路
經過我們一番操作,現在的 calculate 方法只做一件事:找到正確的 calculator 後回傳運算結果,也就是個管流程的。這也使得這個 Service 的設計,更為符合 Uncle Bob 在 Clean Architecture 一書中,對 Service 的定義,同時意外地,不小心套用了 Eric Evans 在 Domain-Driven Design 一書中,提到的「無副作用的函式」模式。
這兩個觀念不是本篇要討論的重點,所以筆者就不詳細論述了。重點是,現在不管你是要新增學生身份,修改任一獎學金的計算方式,還是要修改金額,都不用進來修改 calculate 方法了。而這方法只有一種原因會修改,就是當「流程有變」的時候。這就滿足我們想要的,「一個方法只會因為一種原因而被修改」的原則了。
恭喜各位,在完整測試的保護下,完成了一次幅度不算小的重構。各位回頭再比對一下重構之前的原始樣貌,差距很大吧!
「還可以再往下重構嗎?」
可以唷!其實這裡雖然已經把「挑選計算機」的邏輯抽成方法,但還是留在同一個 class 裡面。如果真要說,這個 findCalculator 方法也可以 delegate 出去給其他 class 做。不過這得看你覺不覺得困擾,而且主要是整理到這邊我也累了(躺),所以我決定暫時就先這樣,等哪天又看不順眼時,再委託出去也行。反正有測試嘛,不怕!
說到測試,各位有發現嗎?我們從頭到尾,測試都沒改唷!是的,因為我們沒有改變 calculate 方法對外的表現。
謎之聲:「這,才叫重構!」
ithelp2021