本章目標#

實際拿一個帶有測試保護的程式,透過重構(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 自帶狀態,printinput 在被呼叫時,先判斷自己處於什麼狀態,再執行對應行為。

同步的測試#

同時補上對應測試(節錄):

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 還沒加進來,printinput 已經承擔太多責任,違反 SRP,成為修改熱點
  • input 中字串解析的邏輯重複出現

決定重構策略#

根本原因:Module 的「資料」與「狀態」綁在一起,但「狀態」與「狀態對應的處理邏輯」卻被分離開來。

合理的做法是:

  • Module 只負責「持有一個狀態物件」
  • 每個狀態自己決定該如何 print、如何 input、何時切換到下一個狀態

這正是 State pattern(狀態模式)的場景。決定方向後,在測試保護下逐步小步重構即可,每步都跑測試確認沒壞掉。

驗證:加入新狀態的成本#

Module 重構成狀態模式之後,再加入原本未完成的 Circle 邏輯。理想結果是符合開放封閉原則:只新增類別、不修改舊類別。

實際嘗試會發現確實如此:新增 CircleSelectedCircleRadiusSelected 等狀態類別,各自實作 printinput,原有的 Rectangle 相關狀態完全不需動。

結論#

整個流程示範了一條穩健的工作路徑:

  • 分析需求
  • 完成第一版程式與測試
  • 在測試保護下偵測 Code Smell
  • 暫停、再分析
  • 小步重構出設計模式
  • 驗證新需求加入時的修改範圍

設計模式是重構出來的,不是規劃出來的。先看出設計模式的必要性,再改寫成設計模式。

原文出處#

  • 原書/iThome:https://ithelp.ithome.com.tw/articles/10265964