概述#
模式(Pattern)的核心洞察之一是:我們解決的大多數問題其實來自於所使用的工具,而非外部問題本身。因此,即便在千變萬化的外部問題情境中,我們也能找到共同的子問題和共同的解法。
物件導向程式設計正是這種現象的最佳例證。設計模式的巨大成功,證明了物件程式設計師之間存在高度的共通性。然而,《Design Patterns》一書似乎對「設計即階段」有著微妙的偏見,完全沒有提及「重構也是一種設計活動」。TDD 中的設計需要用略微不同的角度來看待設計模式。
以下模式並非全面涵蓋,僅是足以完成書中範例的設計模式。
模式在 TDD 中的使用分類#
| 模式 | 撰寫測試 | 重構 |
|---|---|---|
| Command | X | |
| Value Object | X | |
| Null Object | X | |
| Template Method | X | |
| Pluggable Object | X | |
| Pluggable Selector | X | |
| Factory Method | X | X |
| Imposter | X | X |
| Composite | X | X |
| Collecting Parameter | X | X |
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,有兩個實作 SingleSelection 和 MultipleSelection:
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 中以兩種方式出現:
- 撰寫測試時:需要表示新場景。例如圖形編輯器已有矩形繪製,現在要顯示橢圓:
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());
}- 重構時:兩個常見的 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#
問題: 如何在沒有全域變數的語言中提供全域變數?
解法: 不要。你的程式會因為你花時間思考設計而感謝你。