章節概述#

上一章用 fake implementation 讓 $5 + $5 = $10 的測試通過了,但 Bank.reduce() 只是硬編碼回傳 Money.dollar(10)。本章的任務是消除這個重複(測試中的 $5 + $5 和實作中的 $10 是同一份資料),讓實作變為真實的。

資料重複,而非程式碼重複#

這次的重複不是傳統的程式碼重複,而是資料重複

// Bank - fake implementation
Money reduce(Expression source, String to) {
    return Money.dollar(10);  // 這個 10...
}

// 測試中
Money five = Money.dollar(5);
Expression sum = five.plus(five);  // ...等於 5 + 5

以往的 fake implementation 可以直接「用變數取代常數」來倒推出真實實作。但這次無法直接倒推,所以改為正向推進(work forward)

建立 Sum 類別#

Money.plus() 應該回傳真正的 Sum,而非只是 Money。先寫測試:

public void testPlusReturnsSum() {
    Money five = Money.dollar(5);
    Expression result = five.plus(five);
    Sum sum = (Sum) result;
    assertEquals(five, sum.augend);
    assertEquals(five, sum.addend);
}

補充: 加法的第一個參數叫做 augend(被加數),第二個叫 addend(加數)。Kent Beck 說他也是寫書時才知道的。

這個測試深度關注實作細節,預期不會長久存活,但它幫助我們往目標邁進一步。

讓測試通過需要:

// Sum 類別
class Sum implements Expression {
    Money augend;
    Money addend;

    Sum(Money augend, Money addend) {
        this.augend = augend;
        this.addend = addend;
    }
}

// Money.plus() 改為回傳 Sum
Expression plus(Money addend) {
    return new Sum(this, addend);
}

實作 Bank.reduce() 處理 Sum#

Bank.reduce() 收到一個 Sum 時,如果幣別相同,結果應該是金額加總的 Money

public void testReduceSum() {
    Expression sum = new Sum(Money.dollar(3), Money.dollar(4));
    Bank bank = new Bank();
    Money result = bank.reduce(sum, "USD");
    assertEquals(Money.dollar(7), result);
}

先寫出能通過的實作:

// Bank
Money reduce(Expression source, String to) {
    Sum sum = (Sum) source;
    int amount = sum.augend.amount + sum.addend.amount;
    return new Money(amount, to);
}

這段程式碼有兩個明顯問題:強制轉型(cast)公開欄位的多層存取

重構:將邏輯搬到正確的位置#

把加總邏輯從 Bank 搬到 Sum

// Bank
Money reduce(Expression source, String to) {
    Sum sum = (Sum) source;
    return sum.reduce(to);
}

// Sum
public Money reduce(String to) {
    int amount = augend.amount + addend.amount;
    return new Money(amount, to);
}

處理 Bank.reduce(Money) 的情況#

Bank.reduce() 也需要能處理傳入 Money(而非 Sum)的情況:

public void testReduceMoney() {
    Bank bank = new Bank();
    Money result = bank.reduce(Money.dollar(1), "USD");
    assertEquals(Money.dollar(1), result);
}

初步實作用 instanceof 判斷:

// Bank
Money reduce(Expression source, String to) {
    if (source instanceof Money) return (Money) source;
    Sum sum = (Sum) source;
    return sum.reduce(to);
}

注意: 任何時候看到明確的類別檢查(instanceof),都應該用多型(polymorphism) 取代。

用多型消除類別檢查#

Money 也實作 reduce()

// Money
public Money reduce(String to) {
    return this;
}

reduce(String) 加入 Expression 介面:

// Expression
interface Expression {
    Money reduce(String to);
}

這樣 Bank.reduce() 就變得乾淨了:

// Bank
Money reduce(Expression source, String to) {
    return source.reduce(to);
}

技巧: Kent Beck 提到 Bank.reduce(Expression, String)Expression.reduce(String) 方法名稱相同但參數不同,在 Java 的位置參數語法中不容易表達兩者的差異。在有 keyword parameters 的語言中會更清楚。

本章小結#

  • 發現 fake implementation 中的資料重複,決定正向推進而非倒推
  • 寫測試來強制建立預期需要的物件(Sum),逐步讓實作變為真實
  • 先用 cast 讓程式跑起來,再透過重構把邏輯搬到正確的類別
  • 多型取代 instanceof,讓 Bank.reduce() 變得簡潔
  • 待辦清單新增了「Reduce Money with conversion」和「Reduce(Bank, String)」,為下一步的幣別轉換做準備