報告班長,圖片截自網路
大家有聽過「報告班長」嗎?這部 1987 年的電影,當年推出後一炮而紅,帶領一陣中華民國軍教片的風潮,由庾澄慶演唱的同名片尾曲也為這位歌手打響名號。但你有發現,有句歌詞唱錯了嗎?
歌詞裡有句唱著:「出槍快、轉槍慢」,但是根據中華民國單兵作戰準則,標準用槍技巧,為了使單兵記得出槍動作不要太大驚動敵人,且準備好後立即進入備射姿勢,其實應該要是「出槍慢、轉槍面快」才對。
你想,要是在戰場上,阿兵哥想起歌詞,先快速出槍吸引敵人注意,再慢慢準備射擊動作,那還怎麼活命啊?幸好大家都沒有很認真在背歌詞,不然我們要怎麼反共救國呢?
好,總之我們要來提槍上陣了。本文將實際找一個案例,在程式與測試都具備的情況下,試著透過一些重構手法,將原程式調整成可讀性與易修改性都比較好的樣貌,同時不破壞原邏輯。最後驗證當新需求來臨時,如此重構後的新設計,是否真能限縮修改範圍,以更快速地因應。
Console Interaction 是筆者在網路上找到的一個 TDD 練習題,這裡我們還沒有要聊 TDD 的部份,所以我們假設已經有了可運行的程式,並且有測試保護。接下來我們會先了解題目內容,再進行下一步:
大家可能像筆者年紀夠大,有玩過像 Mud 這種「線上文字遊戲」,如果沒有,那應該也使用過坊間一些使用 CLI 介面來互動的電腦軟體。這種介面沒有視窗介面那麼美觀親切,但在某些場合還蠻能快速解決問題的,譬如安裝系統應用程式。
圖片截自 Wikipedia
Console Interaction 的題目要求我們為一個「計算周長與面積」的應用程式編寫 CLI 程式。此程式先問使用者要計算矩型(Rectangle)還是圓型(Circle),再依使用者輸入,決定下一個問題,直到問完必須的問題,就會秀出該圖型的周長與面積。
這個題目要完成不難,但要寫得好測不容易,關鍵在開發者有沒有發現陷阱,把「使用者介面」與「主邏輯」分開。什麼意思?其實題目雖名為 Console Interaction,但其實使用者介面應該要是可以隨時抽換的。意即,你就算把介面換成 GUI,甚至是 Web,都不該影響核心的「狀態」與「資料」。
看穿了這一點,我們發現我們其實可以只管「主邏輯」的程式與測試就好。上文有提到這篇文章著重在「重構」,於是我們假設有個小天使為我們完成了初版的程式如下:
public class Module {
private final String RECTANGLE_B_SELECTED = "RectangleBSelected";
private final String RECTANGLE_A_SELECTED = "RectangleASelected";
private final String RECTANGLE_SELECTED = "RectangleSelected";
private final String INITIAL = "Initial";
private String status = INITIAL;
private int a;
private int b;
public String print() {
if (this.status.equals(RECTANGLE_SELECTED)) {
return "Rectangle side A length?";
}
if (this.status.equals(RECTANGLE_A_SELECTED)) {
return "Rectangle side B length?";
}
if (this.status.equals(RECTANGLE_B_SELECTED)) {
return "Area=" + (a * b) + ", Circumference=" + (2 * (a + b));
}
return "Shape: (C)ircle or (R)ectangle?";
}
public void input(String answer) {
if (this.status.equals(INITIAL) && answer.equals("R")) {
this.status = RECTANGLE_SELECTED;
} else if (this.status.equals(RECTANGLE_SELECTED)) {
try {
Integer answerInt = Integer.valueOf(answer);
this.a = answerInt;
this.status = RECTANGLE_A_SELECTED;
} catch (NumberFormatException e) {
return;
}
} else if (this.status.equals(RECTANGLE_A_SELECTED)) {
try {
Integer answerInt = Integer.valueOf(answer);
this.b = answerInt;
this.status = RECTANGLE_B_SELECTED;
} catch (NumberFormatException e) {
return;
}
}
}
}
上面的程式,把主邏輯命名為 Module,並且定義 Module 的狀態,每當 User Interface 呼叫 Module 的 print 或 input 指令, Module 就先判斷自己現在處於什麼狀態,並進行對應的行為。
程式完成了,測試當然也不能少,於是補上測試,確保功能正常。這裡礙於篇幅,只列出大約 1/3 的測項,但應能足夠看出測試的設計與意圖:
class ConsoleInteractionTest {
@Test
void initial_and_print() {
Module module = new Module();
String printed = module.print();
Assertions.assertEquals("Shape: (C)ircle or (R)ectangle?", printed);
}
@Test
void initial_and_R() {
Module module = new Module();
module.input("R");
String printed = module.print();
Assertions.assertEquals("Rectangle side A length?", printed);
}
@Test
void initial_and_R_5() {
Module module = new Module();
module.input("R");
module.input("5");
String printed = module.print();
Assertions.assertEquals("Rectangle side B length?", printed);
}
// ... 後略
}
至此,我停下來了。讀者應該有發現,我只完成一半工作,Circle 的邏輯還沒做。為什麼不繼續?因為這裡已經有非常明顯的壞味道了,如果硬著頭皮繼續,晚點重構成本會太高。因此,我決定在此暫停一下,聞聞壞味道,重構一下,然後再繼續。
首先,If 太多了!每次 User Interface 與 Module 互動,Module 就要經過一連串的 If 判斷,才能找到對應的行為。這裡還沒把 Circle 相關的狀態們放進來,print 與 input 就要承擔這麼多責任,很明顯違反了「單一職責原則」,使得這兩個方法同時變成了修改的熱點,需要改善。
另外,在 input 的字串處理的邏輯也重複了,重複永遠是我們要極力避免的壞味道,這點也需要改善。
其實說到底,這個 Module 的「資料」與「狀態」被綁在一起了,但「狀態」與「狀態對應的處理邏輯」又被分開了。換句話說,在 Module 的方法被呼叫時,「現在該怎麼處理」應該要與「現在是什麼狀態」應該要被獨立抽到不同類別去才對,而 Module 只要負責「放一個狀態在身上」就好,其他該做什麼事、什麼時候該跳狀態,讓狀態他們自己去協調就好。
聰明如你,可能已經想到了,這不就是「狀態模式」的場景嗎?沒錯,你果真有讀書!既然如此我們就來試著重構成狀態模式吧!決定了重構的方向,又有測試保護,那就直接開工吧!因為重構步驟繁多(我們沒有要「重寫」),用文字不容易表達,這裡用一段影片,來示範重構細節:
現在我們再來試著加入剛剛未完成的 Circle 邏輯,看看已經重構成「狀態模式」的這段程式,在需要加入新狀態時好不好加。都已經使用了狀態模式,理應符合「開放封閉原則」,這時應該只需新增類,不需修改舊類別才對。試了就知道:
今天我們藉由一個程式題,走了一遍分析、寫程式、寫測試、發現有 Code Smell、停下來再分析、接著再重構的流程。在日常工作中,不一定會經常遇到這麼複雜的場景,但這只是個練習,主要是希望讀者們參考一下這樣的工作方式,未來在工作上可以也試著照這樣的流程,一小步一小步慢慢前進,而使每一步都走得穩健而安全,才能做自己老是在「安全的環境」中進行每天的工作。最後,惟有看出設計模式的必要性,才改寫成設計模式。
謎之聲:「設計模式是重構出來的,不是規劃出來的。」
ithelp2021