章節概述#

本章的目標是將 DollarFranc 兩個子類別的 times() 方法統一,推進到只剩單一 Money 類別。過程中遇到了 equals() 的設計問題,必須先修正比較邏輯(從比較類別改為比較幣別),才能完成 times() 的統一。

統一 times() 的起點#

兩個子類別的 times() 實作非常接近,但不完全相同:

// Franc
Money times(int multiplier) {
    return Money.franc(amount * multiplier);
}

// Dollar
Money times(int multiplier) {
    return Money.dollar(amount * multiplier);
}

沒有明顯的方式能直接讓它們相同。Kent Beck 說:「有時你必須往後退才能往前進,就像解魔術方塊一樣。」

反向展開工廠方法#

策略是將剛剛才引入的工廠方法 反向展開(inline),把它們還原為直接建構子呼叫:

// Franc
Money times(int multiplier) {
    return new Franc(amount * multiplier, "CHF");
}

// Dollar
Money times(int multiplier) {
    return new Dollar(amount * multiplier, "USD");
}

因為幣別字串其實就是實體變數 currency,所以可以進一步用變數取代常數:

// Franc
Money times(int multiplier) {
    return new Franc(amount * multiplier, currency);
}

// Dollar
Money times(int multiplier) {
    return new Dollar(amount * multiplier, currency);
}

現在兩個方法幾乎一樣了,差別只在於回傳的是 Franc 還是 Dollar

用實驗代替推理#

關鍵問題:回傳 Money 而非 Franc 會不會破壞什麼?

重點: Kent Beck 強調,有了測試之後,與其花 5-10 分鐘推理一個問題,不如花 15 秒讓電腦回答你。直接改、跑測試、看結果。沒有測試時你別無選擇只能推理;有了測試,你可以選擇用實驗來回答問題。

嘗試讓 Franc.times() 回傳 Money

// Franc
Money times(int multiplier) {
    return new Money(amount * multiplier, currency);
}

編譯器要求 Money 必須是具體類別,因此加入:

class Money {
    Money times(int amount) {
        return null;
    }
}

沒有測試的 toString()#

測試跑出紅燈,錯誤訊息不夠清楚。為了改善除錯體驗,直接寫了 toString() 而沒有先寫測試

public String toString() {
    return amount + " " + currency;
}

Kent Beck 承認這是例外,理由是:

  • 結果馬上就會在螢幕上看到
  • toString() 僅用於除錯輸出,失敗風險很低
  • 已經在紅燈狀態,不想在紅燈時寫新測試

發現 equals() 的問題#

改善後的錯誤訊息顯示:「expected: <10 CHF> but was: <10 CHF>」——值相同但類別不同。問題出在 equals()getClass() 來比較:

public boolean equals(Object object) {
    Money money = (Money) object;
    return amount == money.amount
        && getClass().equals(money.getClass());
}

應該比較的是幣別(currency),而非類別(class)

保守策略:退回綠燈再修#

技巧: 在紅燈狀態下不要同時修改多處。保守做法是先退回綠燈,再寫新測試、修正實作,最後重試原始變更。

  1. 退回:將 Franc.times() 還原為回傳 new Franc(...),回到綠燈
  2. 寫測試:驗證不同類別但相同幣別與金額應該相等
public void testDifferentClassEquality() {
    assertTrue(new Money(10, "CHF").equals(new Franc(10, "CHF")));
}
  1. 修正 equals():改為比較 currency
public boolean equals(Object object) {
    Money money = (Money) object;
    return amount == money.amount
        && currency().equals(money.currency());
}
  1. 重試Franc.times()Dollar.times() 都可以回傳 Money
flowchart TD
    A["反向展開工廠方法"] --> B["實驗:用 Money 取代 Franc"]
    B --> C{"測試通過?"}
    C -->|"🔴 失敗"| D["發現 equals 比較的是<br/>class 而非 currency"]
    D --> E["保守策略:退回綠燈"]
    E --> F["寫新測試驗證<br/>幣別比較"]
    F --> G["修正 equals():<br/>比較 currency 而非 class"]
    G --> H["重新嘗試統一"]

統一 times() 到父類別#

兩個子類別的 times() 現在完全相同,可以上移到 Money

// Money
Money times(int multiplier) {
    return new Money(amount * multiplier, currency);
}

待辦清單更新,Common times 完成:

  • Common times
  • Compare Francs to Dollars
  • Currency?
  • Delete testFrancMultiplication?

本章小結#

  • 用 inline 再替換常數為變數的手法來統一兩個相似方法
  • 為除錯而寫了 toString(),沒有先寫測試——這是明確的例外情況
  • 嘗試一個變更(回傳 Money 而非 Franc),讓測試告訴我們是否可行
  • 實驗失敗時退回綠燈,補寫測試後再重試,最終成功