前置脈絡#

在 legacy code 中新增功能時,最重要的考量是我們沒有測試。Chapter 6 的 sprout 和 wrap 技巧可以在沒有測試的情況下加入程式碼,但存在一些隱患:

  • 不會顯著改善既有程式碼
  • 可能引入重複(新程式碼與未測試區域的舊程式碼重複)
  • 恐懼與放棄是兩大心態危害

如果能將程式碼放入測試,就應該使用本章的技巧正面面對。Sprout 和 wrap 留在程式碼中的痕跡,是對我們當時無法做得更好的小小提醒。


Test-Driven Development (TDD)#

TDD 是作者認為最強大的功能新增技巧。核心演算法:

  1. Write a failing test case(寫一個失敗的測試)
  2. Get it to compile(讓它通過編譯)
  3. Make it pass(讓測試通過)
  4. Remove duplication(移除重複)
  5. Repeat(重複)

TDD 範例:InstrumentCalculator#

需求:計算 first statistical moment about a point。

第一輪 – 寫失敗的測試:

public void testFirstMoment() {
    InstrumentCalculator calculator = new InstrumentCalculator();
    calculator.addElement(1.0);
    calculator.addElement(2.0);

    assertEquals(-0.5, calculator.firstMomentAbout(2.0), TOLERANCE);
}

讓它編譯 – 加入空方法:

public class InstrumentCalculator {
    double firstMomentAbout(double point) {
        return Double.NaN;
    }
    ...
}

讓測試通過:

public double firstMomentAbout(double point) {
    double numerator = 0.0;
    for (Iterator it = elements.iterator(); it.hasNext(); ) {
        double element = ((Double)(it.next())).doubleValue();
        numerator += element - point;
    }
    return numerator / elements.size();
}

這在 TDD 中是異常大量的程式碼。通常步驟應該更小,除非你對演算法非常確定。

第二輪 – 處理邊界情況(除以零):

public void testFirstMoment() {
    try {
        new InstrumentCalculator().firstMomentAbout(0.0);
        fail("expected InvalidBasisException");
    }
    catch (InvalidBasisException e) {
    }
}

加入例外處理後測試通過。

第三輪 – secondMomentAbout:

先複製 firstMomentAbout 並修改為 secondMomentAbout

public double secondMomentAbout(double point)
        throws InvalidBasisException {
    if (elements.size() == 0)
        throw new InvalidBasisException("no elements");

    double numerator = 0.0;
    for (Iterator it = elements.iterator(); it.hasNext(); ) {
        double element = ((Double)(it.next())).doubleValue();
        numerator += Math.pow(element - point, 2.0);
    }
    return numerator / elements.size();
}

移除重複 – 抽取通用方法:

public double secondMomentAbout(double point)
        throws InvalidBasisException {
    return nthMomentAbout(point, 2.0);
}

private double nthMomentAbout(double point, double n)
        throws InvalidBasisException {
    if (elements.size() == 0)
        throw new InvalidBasisException("no elements");

    double numerator = 0.0;
    for (Iterator it = elements.iterator(); it.hasNext(); ) {
        double element = ((Double)(it.next())).doubleValue();
        numerator += Math.pow(element - point, n);
    }
    return numerator / elements.size();
}

然後讓 firstMomentAbout 也委託給 nthMomentAbout

public double firstMomentAbout(double point)
        throws InvalidBasisException {
    return nthMomentAbout(point, 1.0);
}

TDD 與 Legacy Code#

TDD 最有價值的一點是它讓你一次專注一件事。你不是同時寫程式碼和重構;你只做其中一件。

針對 legacy code,TDD 演算法可以擴展為:

  1. Get the class you want to change under test(將目標 class 放入測試)
  2. Write a failing test case
  3. Get it to compile
  4. Make it pass(盡量不要修改既有程式碼
  5. Remove duplication
  6. Repeat

Programming by Difference#

Programming by Difference 是一種利用繼承來快速引入變化的技巧。在 OO 中,我們可以透過子類別化來新增功能,而不需修改原有 class。

範例:MessageForwarder#

已測試的 MessageForwarder class 管理郵件列表,有一個 getFromAddress 方法。

新需求:支援匿名郵件列表,匿名列表的 “from” 地址應該基於 domain 而非原始發件者。

步驟一:寫失敗的測試

public void testAnonymous () throws Exception {
    MessageForwarder forwarder = new AnonymousMessageForwarder();
    forwarder.forwardMessage(makeFakeMessage());
    assertEquals("anon-members0" + forwarder.getDomain(),
        expectedMessage.getFrom()[0].toString());
}

步驟二:建立子類別

// AnonymousMessageForwarder 繼承 MessageForwarder
protected InternetAddress getFromAddress(Message message)
        throws MessagingException {
    String anonymousAddress = "anon-" + listAddress;
    return new InternetAddress(anonymousAddress);
}

這很快就讓測試通過了。

Figure 8.1: Subclassing MessageForwarder

潛在問題:設計退化#

如果持續使用繼承來新增功能,會遇到問題。例如又需要一個 OffListMessageForwarder,如果需要同時具備匿名和 off-list 兩個功能,就無法用單一繼承解決。

Figure 8.2: Subclassing for two differences

解決方案:及時重構

可以停下來,將匿名轉發改為透過 configuration property 實現:

Properties configuration = new Properties();
configuration.setProperty("anonymous", "true");
MessageForwarder forwarder = new MessageForwarder(configuration);

然後修改 getFromAddress 方法來處理配置:

Figure 8.3: Delegating to MailingConfiguration

private InternetAddress getFromAddress(Message message)
        throws MessagingException {
    String fromAddress = getDefaultFrom();
    if (configuration.getProperty("anonymous").equals("true")) {
        from = getAnonymousFrom();
    }
    else {
        from = getFrom(Message);
    }
    return new InternetAddress(from);
}

進一步可以將配置邏輯移到專門的 MailingConfiguration class,最終重新命名為 MailingList

Figure 8.4: Moving behavior to MailingConfiguration

Figure 8.5: Moving more behavior to MailingConfiguration

Figure 8.6: MailingConfiguration renamed as MailingList

Liskov Substitution Principle (LSP)#

使用 Programming by Difference 時要注意 LSP 違規

Liskov Substitution Principle 要求子類別的物件應該能替代父類別的物件而不產生錯誤。當我們覆寫具體方法時,可能改變了使用該 class 的程式碼的語意。

避免 LSP 問題的經驗法則:

  1. 盡可能避免覆寫具體方法
  2. 如果必須覆寫,看看是否能在覆寫方法中呼叫被覆寫的方法

Normalized Hierarchy(正規化層級)#

理想的類別層級應該是 normalized 的:沒有任何 class 的方法覆寫了從父類別繼承的具體方法。每個方法要嘛是 abstract 且由子類別實作,要嘛就只存在於那個 class 中。

Figure 8.7: Normalized hierarchy


總結#

  • TDD 讓你為任何能放入測試的程式碼新增功能,且能安全地移除重複
  • Programming by Difference 讓你透過繼承快速引入變化,再利用測試來重構到更乾淨的設計
  • 測試讓這些轉變變得非常快速和安全