本章目標#
實際拿一個帶有測試保護的程式,透過重構(Refactoring)手法,將其調整成可讀性與易修改性都更好的樣貌,同時不破壞原本邏輯。最終目標是當新需求加入時,新設計能夠把修改範圍縮到最小。
Joshua Kerievsky 在 Refactoring to Patterns 中強調的觀念:設計模式是重構出來的,不是規劃出來的。
範例:Console Interaction#
題目要求為一個「計算周長與面積」的應用程式撰寫 CLI 程式:
- 程式先問使用者要算矩形(Rectangle)還是圓形(Circle)
- 依使用者輸入決定下一個問題
- 問完所有必要資訊後,輸出該圖形的周長與面積
隱藏的陷阱#
題目雖名為 Console Interaction,但使用者介面應該可以隨時抽換成 GUI 或 Web,不應影響核心狀態與資料。也就是說「使用者介面」與「主邏輯」必須分離。看穿這一點,就能把重點放在主邏輯。
這正是卑微物件(Humble Object)模式的精神:把難以測試的 IO 留在薄薄一層,把所有業務邏輯抽到可以單元測試的核心物件中。
第一版:先用醜方法完成功能#
假設已有人完成了初版主邏輯:
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 自帶狀態,print 與 input 在被呼叫時,先判斷自己處於什麼狀態,再執行對應行為。
同步的測試#
同時補上對應測試(節錄):
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 太多:每次互動
Module都要走一連串 If 才能找到對應行為。Circle 還沒加進來,print與input已經承擔太多責任,違反 SRP,成為修改熱點 - input 中字串解析的邏輯重複出現
決定重構策略#
根本原因:Module 的「資料」與「狀態」綁在一起,但「狀態」與「狀態對應的處理邏輯」卻被分離開來。
合理的做法是:
Module只負責「持有一個狀態物件」- 每個狀態自己決定該如何
print、如何input、何時切換到下一個狀態
這正是 State pattern(狀態模式)的場景。決定方向後,在測試保護下逐步小步重構即可,每步都跑測試確認沒壞掉。
驗證:加入新狀態的成本#
把 Module 重構成狀態模式之後,再加入原本未完成的 Circle 邏輯。理想結果是符合開放封閉原則:只新增類別、不修改舊類別。
實際嘗試會發現確實如此:新增 CircleSelected、CircleRadiusSelected 等狀態類別,各自實作 print 與 input,原有的 Rectangle 相關狀態完全不需動。
結論#
整個流程示範了一條穩健的工作路徑:
- 分析需求
- 完成第一版程式與測試
- 在測試保護下偵測 Code Smell
- 暫停、再分析
- 小步重構出設計模式
- 驗證新需求加入時的修改範圍
設計模式是重構出來的,不是規劃出來的。先看出設計模式的必要性,再改寫成設計模式。
原文出處#
- 原書/iThome:https://ithelp.ithome.com.tw/articles/10265964