概述#

模式(Pattern)的核心洞察之一是:我們解決的大多數問題其實來自於所使用的工具,而非外部問題本身。因此,即便在千變萬化的外部問題情境中,我們也能找到共同的子問題和共同的解法。

物件導向程式設計正是這種現象的最佳例證。設計模式的巨大成功,證明了物件程式設計師之間存在高度的共通性。然而,《Design Patterns》一書似乎對「設計即階段」有著微妙的偏見,完全沒有提及「重構也是一種設計活動」。TDD 中的設計需要用略微不同的角度來看待設計模式。

以下模式並非全面涵蓋,僅是足以完成書中範例的設計模式。

模式在 TDD 中的使用分類#

模式撰寫測試重構
CommandX
Value ObjectX
Null ObjectX
Template MethodX
Pluggable ObjectX
Pluggable SelectorX
Factory MethodXX
ImposterXX
CompositeXX
Collecting ParameterXX

Command#

問題: 當計算的呼叫需要比簡單的方法調用更複雜時,怎麼辦?

解法: 將計算表示為一個物件,而非僅僅一個訊息。

發送訊息很好用,但有時候光發訊息不夠。例如:想要記錄訊息是否被發送,或想要延遲執行某個計算。複雜的計算呼叫需要昂貴的機制,但大多數時候我們不需要那麼多複雜度。

做法是:建立一個代表呼叫的物件,將計算所需的所有參數注入其中,準備好時使用通用協議(如 run())來呼叫它。

Java 的 Runnable 介面就是一個絕佳的範例:

interface Runnable
  public abstract void run();

run() 的實作中,你可以做任何事。可惜 Java 沒有語法上輕量級的方式來建立和呼叫 Runnable,因此它不像 Smalltalk/Ruby 的 block 或 LISP 的 lambda 那樣被廣泛使用。

Value Object#

問題: 如何設計被廣泛共享、但身份(identity)不重要的物件?

解法: 在建立時設定其狀態,之後永遠不再改變。對物件的操作總是返回一個新物件。

這是為了解決經典的別名問題(aliasing problem):如果兩個物件共享對第三個物件的參考,其中一個改變了共享物件,另一個就不能再依賴該共享物件的狀態。

解決別名問題的幾種方式:

解法特性
永遠複製時間和空間成本高昂
Observer 模式控制流難以追蹤,建立和移除依賴的邏輯很醜陋
Value Object將物件視為不可變的值,消除「隨時間變化」的可能性

重點: Value Object 的每個操作都必須返回一個全新的物件,保持原始物件不變。使用者必須意識到自己在使用 Value Object,並儲存操作結果。

Value Object 特別適合具有代數性質的場景:幾何圖形的交集和聯集、帶單位的數值、符號運算等。只要 Value Object 有一點道理,就值得嘗試,因為它讓閱讀和除錯變得容易許多。

所有 Value Object 都必須實作相等性(equality),在許多語言中也意味著要實作 hashing。

Null Object#

問題: 如何用物件表示特殊情況?

解法: 建立一個代表特殊情況的物件,給它與一般物件相同的協議。

java.io.File 為例,其中有 18 處檢查 guard != null

public boolean setReadOnly() {
  SecurityManager guard = System.getSecurityManager();
  if (guard != null) {
     guard.canWrite(path);
  }
  return fileSystem.setReadOnly(this);
}

替代方案是建立一個新類別 LaxSecurity,永遠不拋出例外:

public void canWrite(String path) {
}

當沒有 SecurityManager 時,回傳 LaxSecurity

public static SecurityManager getSecurityManager() {
  return security == null ? new LaxSecurity() : security;
}

原始程式碼因此變得簡潔許多:

public boolean setReadOnly() {
  SecurityManager security = System.getSecurityManager();
  security.canWrite(path);
  return fileSystem.setReadOnly(this);
}

技巧: Null Object 的價值在於消除散布各處的 null 檢查。需要權衡引入 Null Object 的程式碼成本與消除條件判斷的收益。

Template Method#

問題: 如何表示計算的不變序列,同時允許未來的細化?

解法: 撰寫一個完全由其他方法實作的方法。

程式設計中充滿經典的序列:輸入/處理/輸出、發送訊息/接收回覆、讀取命令/返回結果。我們希望能清楚傳達這些序列的普遍性,同時允許步驟的實作有所變化。

繼承提供了一種簡單但有限的機制。例如 JUnit 的測試執行基本序列:

public void runBare() throws Throwable {
  setUp();
  try {
     runTest();
  }
  finally {
     tearDown();
  }
}

子類別可以自由實作 setUp()runTest()tearDown()

關於子方法是否需要預設實作:

  • 如果沒有子步驟就無法計算,在 Java 中宣告為 abstract,在 Smalltalk 中拋出 SubclassResponsibility 錯誤
  • setUp()tearDown() 預設是 no-op;runTest() 動態找到並呼叫基於測試名稱的方法

重點: Template Method 最好是透過經驗「發現」,而非從一開始就設計。當你在兩個子類別中找到序列的兩個變體時,逐漸讓它們靠近,提取不同的部分為方法,剩下的就是 Template Method,然後將它移到超類別中消除重複。

Pluggable Object#

問題: 如何表達變化?

最簡單的方式是用顯式條件判斷:

if (circle) then {
  . . . circley stuff. . .
} else {
  . . . non circley stuff
}

但這種顯式決策很快就會擴散。因為 TDD 的第二條命令是消除重複,當你第二次看到同一個條件判斷時,就該使用 Pluggable Object。

以圖形編輯器的選取功能為例,初始程式碼中有重複的條件判斷:

Figure selected;
public void mouseDown() {
  selected= findFigure();
  if (selected != null)
     select(selected);
}
public void mouseMove() {
  if (selected != null)
     move(selected);
  else
     moveSelectionRectangle();
}
public void mouseUp() {
  if (selected == null)
     selectAll();
}

解法是建立 Pluggable Object:SelectionMode,有兩個實作 SingleSelectionMultipleSelection

SelectionMode mode;
public void mouseDown() {
  selected= findFigure();
  if (selected != null)
     mode= SingleSelection(selected);
  else
     mode= MultipleSelection();
}
public void mouseMove() {
  mode.mouseMove();
}
public void mouseUp() {
  mode.mouseUp();
}

條件判斷只在建立時出現一次,後續操作全部委派給多型物件。

Pluggable Selector#

問題: 如何為不同的實例調用不同的行為?

解法: 儲存方法名稱,動態呼叫該方法。

當你有十個子類別各只實作一個方法時,繼承顯得太重量級。一個替代方案是在單一類別中用 switch 語句,但方法名稱會出現在三個地方:建立實例時、switch 語句中、方法本身。

Pluggable Selector 用反射動態呼叫方法:

void print() {
  Method runMethod= getClass().getMethod(printMessage, null);
  runMethod.invoke(this, new Class[0]);
}

注意: Pluggable Selector 容易被過度使用。最大的問題是追蹤程式碼時難以判斷方法是否被呼叫。只在清理明確的場景時使用——每個子類別只有一個方法的情況。

Factory Method#

問題: 當需要建立物件的彈性時怎麼辦?

解法: 在方法中建立物件,而非使用建構子。

建構子缺乏表達力和彈性。例如在 Money 範例中,我們想引入 Money 類別,但只要測試鎖定建立 Dollar 的實例就做不到。透過引入一層間接,我們獲得了返回不同類別實例的彈性:

public void testMultiplication() {
  Dollar five = Money.dollar(5);
  assertEquals(new Dollar(10), five.times(2));
  assertEquals(new Dollar(15), five.times(3));
}
static Dollar dollar(int amount) {
  return new Dollar(amount);
}

技巧: Factory Method 的缺點正是其間接性。你必須記住該方法實際上是在建立物件。只有在需要彈性時才使用 Factory Method,否則建構子就夠用了。

Imposter#

問題: 如何在計算中引入新的變化?

解法: 引入一個與現有物件具有相同協議但不同實作的新物件。

在程序式程式中引入變化需要加入條件邏輯,但這種邏輯傾向於擴散。Imposter 在 TDD 中以兩種方式出現:

  1. 撰寫測試時:需要表示新場景。例如圖形編輯器已有矩形繪製,現在要顯示橢圓:
testOval() {
  Drawing d= new Drawing();
  d.addFigure(new OvalFigure(0, 10, 50, 100));
  RecordingMedium brush= new RecordingMedium();
  d.display(brush);
  assertEquals("oval 0 10 50 100\n", brush.log());
}
  1. 重構時:兩個常見的 Imposter 範例:
    • Null Object:將資料的缺失視同資料的存在
    • Composite:將物件的集合視同單一物件

補充: 第一次發現 Imposter 的可能性需要洞察力。Ward Cunningham 發現一個 Money 的向量可以像一個 Money 一樣運作,正是這樣的頓悟時刻——你原以為它們不同,現在卻看到它們是相同的。

Composite#

問題: 如何實作一個物件,其行為是一系列其他物件行為的組合?

解法: 讓它成為元件物件的 Imposter。

以 Account 和 Transaction 為例。Transaction 儲存一個增量值:

Transaction(Money value) {
  this.value= value;
}

Account 透過加總 Transaction 的值來計算餘額:

Transaction transactions[];
Money balance() {
  Money sum= Money.zero();
  for (int i= 0; i < transactions.length; i++)
    sum= sum.plus(transactions[i].value);
  return sum;
}

當客戶有多個帳戶想看總餘額時,最明顯的做法是建立 OverallAccount 加總各帳戶餘額。但這就是重複

讓 Account 和 Transaction 都實作同一個介面 Holding

interface Holding
  Money balance();

Transaction 的 balance() 返回其 value;Account 改為由 Holding 組成:

Holding holdings[];
Money balance() {
  Money sum= Money.zero();
  for (int i= 0; i < holdings.length; i++)
    sum= sum.plus(holdings[i].balance());
  return sum;
}

OverallAccount 就只是一個包含 Account 的 Account。

補充: Composite 的氣味在於概念上的不協調——Transaction 在現實世界中沒有「餘額」。但對程式設計的好處巨大:資料夾包含資料夾、TestSuite 包含 TestSuite、Drawing 包含 Drawing,這些都不能良好地映射到現實世界,但都讓程式碼簡單得多。

Collecting Parameter#

問題: 如何收集散布在多個物件上的操作結果?

解法: 在操作中加入一個用於收集結果的參數。

java.io.Externalizable 介面就是一個簡單範例:

public interface Externalizable extends java.io.Serializable {
  void writeExternal(ObjectOutput out) throws IOException;
}

Collecting Parameter 是 Composite 的常見後果。在開發 JUnit 時,直到有了多個測試才需要 TestResult 來彙整結果。

隨著預期結果的複雜度增長,可能需要引入 Collecting Parameter。例如列印 Expression:

  • 簡單的平面字串用連接即可:"5 USD + 7 CHF"
  • 但若需要縮排的樹狀形式,就需要引入 Collecting Parameter:
String toString() {
  IndentingStream writer= new IndentingStream();
  toString(writer);
  return writer.contents();
}

void toString(IndentingWriter writer) {
  writer.println("+");
  writer.indent();
  augend.toString(writer);
  writer.println();
  addend.toString(writer);
  writer.exdent();
}

Singleton#

問題: 如何在沒有全域變數的語言中提供全域變數?

解法: 不要。你的程式會因為你花時間思考設計而感謝你。