核心困境#

在 legacy code 中工作時,我們面臨一個根本的兩難:現在付出代價,還是以後付出代價(Pay now or pay later)。寫測試需要時間,但不寫測試的代價會隨時間累積。變更通常會聚集在同一區域,今天改的程式碼很快就會再被改到。

程式碼是你的家,你必須住在裡面。不要因為趕時間就放棄品質。

當你無法將 class 放入 test harness 時,可以透過撰寫全新的、經過測試的程式碼來進行變更。本章介紹四種技巧:Sprout MethodSprout ClassWrap MethodWrap Class

這些技巧讓你在系統中加入經過測試的程式碼,但它們不會測試呼叫端的程式碼。使用時要謹慎。


Sprout Method#

當你需要為系統新增功能,而該功能可以完全用新程式碼來表達時,將程式碼寫在一個新方法中,從需要的地方呼叫它。

範例#

原始的 TransactionGate 類別:

public class TransactionGate {
    public void postEntries(List entries) {
        for (Iterator it = entries.iterator(); it.hasNext(); ) {
            Entry entry = (Entry)it.next();
            entry.postDate();
        }
        transactionBundle.getListManager().add(entries);
    }
}

需求:在 post 之前過濾重複的 entries。不好的做法是直接在方法中混入新邏輯:

public void postEntries(List entries) {
    List entriesToAdd = new LinkedList();
    for (Iterator it = entries.iterator(); it.hasNext(); ) {
        Entry entry = (Entry)it.next();
        if (!transactionBundle.getListManager().hasEntry(entry)) {
            entry.postDate();
            entriesToAdd.add(entry);
        }
    }
    transactionBundle.getListManager().add(entriesToAdd);
}

更好的做法是使用 Sprout Method,將去重邏輯抽取為獨立方法:

public class TransactionGate {
    List uniqueEntries(List entries) {
        List result = new ArrayList();
        for (Iterator it = entries.iterator(); it.hasNext(); ) {
            Entry entry = (Entry)it.next();
            if (!transactionBundle.getListManager().hasEntry(entry)) {
                result.add(entry);
            }
        }
        return result;
    }

    public void postEntries(List entries) {
        List entriesToAdd = uniqueEntries(entries);
        for (Iterator it = entriesToAdd.iterator(); it.hasNext(); ) {
            Entry entry = (Entry)it.next();
            entry.postDate();
        }
        transactionBundle.getListManager().add(entriesToAdd);
    }
}

步驟#

  1. 找出需要變更的位置
  2. 如果變更可以用一段語句序列表達,寫下對新方法的呼叫並先註解掉
  3. 確定需要從原方法傳入的區域變數,作為參數
  4. 確定新方法是否需要回傳值給原方法
  5. 使用 TDD 開發新方法
  6. 取消註解,啟用呼叫

優缺點#

缺點:

  • 放棄了對原方法和 class 的改善機會
  • 原方法可能變得不好理解(為何有一個孤立的新方法呼叫?)

優點:

  • 新程式碼與舊程式碼有清楚的分界
  • 新程式碼可以獨立測試
  • 影響的變數更容易追蹤

當 class 依賴太深無法建構時,考慮使用 Pass Null 或將 sprout 做成 public static method。


Sprout Class#

當你需要新增功能,但無法將現有 class 放入 test harness,甚至無法使用 Sprout Method 時,可以建立一個全新的 class 來承載變更。

範例#

一個巨大的報表產生器 QuarterlyReportGenerator 需要新增 HTML 表頭:

using namespace std;

class QuarterlyReportTableHeaderProducer {
public:
    string makeHeader();
};

string QuarterlyReportTableHeaderProducer::makeHeader() {
    return "<tr><td>Department</td><td>Manager</td>"
           "<td>Profit</td><td>Expenses</td></tr>";
}

進一步可以抽取共同的 interface:

class HTMLGenerator {
public:
    virtual ~HTMLGenerator() = 0;
    virtual string generate() = 0;
};

class QuarterlyReportTableHeaderGenerator : public HTMLGenerator {
public:
    virtual string generate();
};

class QuarterlyReportGenerator : public HTMLGenerator {
public:
    virtual string generate();
};

步驟#

  1. 找出需要變更的位置
  2. 為新 class 想一個好名字,寫下建立物件和呼叫方法的程式碼(先註解掉)
  3. 確定需要從原方法傳入的區域變數,作為 constructor 參數
  4. 確定新 class 是否需要回傳值
  5. 使用 TDD 開發新 class
  6. 取消註解,啟用呼叫

優缺點#

優點:

  • 讓你更有信心地推進,比起直接修改更安全
  • 在 C++ 中不需修改現有 header 檔案,降低編譯負擔

缺點:

  • 增加概念複雜度 (conceptual complexity)
  • 新加入的人可能不理解為何有這些小 class

兩種情境導致 Sprout Class#

  1. 新增全新的職責 – 例如 TaxCalculator 中新增日期檢查,這可能本該是新 class
  2. 無法將現有 class 放入 test harness – 被迫將功能放到新 class 中

Wrap Method#

在既有方法中添加行為很容易,但這會造成 temporal coupling(時間耦合):把不相關的事情綁在一起,只因為它們需要同時發生。

形式一:重新命名原方法#

public class Employee {
    private void dispatchPayment() {
        Money amount = new Money();
        for (Iterator it = timecards.iterator(); it.hasNext(); ) {
            Timecard card = (Timecard)it.next();
            if (payPeriod.contains(date)) {
                amount.add(card.getHours() * payRate);
            }
        }
        payDispatcher.pay(this, date, amount);
    }

    public void pay() {
        logPayment();
        dispatchPayment();
    }

    private void logPayment() {
        ...
    }
}

原本的 pay() 被重新命名為 dispatchPayment() 並設為 private。新的 pay() 方法同時呼叫 log 和原邏輯。對客戶端來說完全透明。

形式二:新增獨立方法#

public class Employee {
    public void makeLoggedPayment() {
        logPayment();
        pay();
    }

    public void pay() {
        ...
    }

    private void logPayment() {
        ...
    }
}

步驟(形式一)#

  1. 找出需要變更的方法
  2. 重新命名該方法,建立一個同名同簽名的新方法
  3. 在新方法中呼叫舊方法
  4. 使用 TDD 開發新功能的方法,從新方法呼叫它

優缺點#

優點:

  • 不增加既有方法的大小
  • 新功能明確地獨立於既有功能

缺點:

  • 可能導致不好的命名(如 dispatchPayment 其實包含計算薪資)
  • 新功能必須在舊功能之前或之後執行,不能穿插其中

Wrap Class#

Wrap Class 是 Wrap Method 的 class 層級版本。用另一個 class 包裹原有 class,新增行為。

Decorator 模式#

class LoggingEmployee extends Employee {
    public LoggingEmployee(Employee e) {
        employee = e;
    }

    public void pay() {
        logPayment();
        employee.pay();
    }

    private void logPayment() {
        ...
    }
}

這就是 Decorator Pattern。包裝的 class 擁有與被包裝 class 相同的 interface,客戶端不需要知道差異。

適合用於有多個現有呼叫端的情境。可以使用 Extract ImplementerExtract Interface 來建立共用的 interface。

非 Decorator 形式#

當只有少數地方需要 log 時,可以不用 decorator:

class LoggingPayDispatcher {
    private Employee e;

    public LoggingPayDispatcher(Employee e) {
        this.e = e;
    }

    public void pay() {
        employee.pay();
        logPayment();
    }

    private void logPayment() {
        ...
    }
}

步驟#

  1. 找出需要變更的方法
  2. 建立一個 class,接受原 class 作為 constructor 參數
  3. 使用 TDD 在新 class 上建立方法,該方法呼叫新功能和原 class 的方法
  4. 在需要的地方建立 wrapper 實例

何時使用 Wrap Class#

  1. 要新增的行為完全獨立,不想用不相關的低層級行為污染既有 class
  2. Class 已經大到無法忍受,wrap 可以作為未來拆分的起點

Sprout vs. Wrap 的選擇#

情境建議技巧
既有方法的演算法清晰,新增的是明確的獨立步驟Sprout Method
新功能與既有功能同等重要,希望最終形成高階演算法Wrap Method
無法將 class 放入 test harnessSprout ClassWrap Class
有多個呼叫端需要透明地新增行為Wrap Class (Decorator)

總結#

這些技巧讓你在不先將既有 class 放入測試的情況下進行變更。它們在新責任與舊責任之間建立清楚的分界。雖然從設計角度看,這些做法不是最優雅的,但它們讓你開始累積測試。隨著時間推移,人們會厭倦繞道而行,開始將那些大的、未測試的 class 放入 test harness。