整體和部分
還記得這條諺語嗎,“整體大於部分之和”?如果把一個個獨立的事件組合成一個相互作用的整體,產生的結果會比單個個體的作用之和要大得多。
程序也是一樣的道理。隨著一個個新方法被添加到程序中,整個程序可能的控制流程迅速增加。對於大型程序而言,很快局面就會無法控制了。就象是一個荒謬而又不可思議的戲法,有時您得到的最終結果並不是您所期望的方向 ― 這同您在重載方法或者覆蓋方法時遇到的情況有些類似。
Broken Dispatch 錯誤模式
面向對象語言的最強大的特性之一就是繼承多態性。這一特性允許我們根據參數類型重載和覆蓋方法。但是,象其它功能強大的工具一樣,這個特性也會引入新的危險。
雖然 Java 程序員們很快就能學會管理一次調用中將調用哪個方法的規則,但在大型程序中卻很容易出現這種情況:在一個類中重載了一個方法,結果卻是以前在另一個類中可以運行的代碼被中斷了。這樣的錯誤正符合我所說的 Broken Dispatch 模式。
該模式可以描述如下:
傳遞給某個重載方法,比如 foo 的參數,卻被傳給了另一個方法,比如 goo ,它支持更廣泛的參數類型。
goo 然後通過這些參數調用 foo 。
但是由於 goo 內的這些參數的靜態類型更為廣泛,因此,可能會調用方法 foo 的錯誤版本。
象這樣的錯誤很難診斷,因為可能只是添加了新的方法(而不是修改現有的方法)就引入了錯誤。而且,在發現問題之前,程序可能會繼續執行相當長的一段時間。
症狀
為了說明這種模式的本質,讓我們來看看下面這段示例代碼,它是為實現我前面的文章“ 空標志錯誤模式”中的不可變列表而編寫的。
清單 1. 實現不可變列表
interface List {
public Object getFirst();
public List getRest();
}
class Empty implements List {
public Object getFirst() { throw new NoSuchElementException(); }
public List getRest() { throw new NoSuchElementException(); }
public boolean equals(Object that) {
return this.getClass() == that.getClass();
}
}
class Cons implements List {
Object first;
List rest;
Cons(Object _first) {
this.first = _first;
this.rest = new Empty();
}
Cons(Object _first, List _rest) {
this.first = _first;
this.rest = _rest;
}
public Object getFirst() { return this.first; }
public List getRest() { return this.rest; }
...
}
在那篇文章中,我們把鏈表實現作為這些不可變列表的容器。
假設我們在一個獨立的包中實現鏈表,我們知道這個包中類 LinkedList 的所有實例都將是 String 列表。我們可以象下面這樣編寫構造函數來強制定義該不變量:
清單 2. 為鏈表定義強制參數
public class LinkedList {
private List value;
/**
* Constructs an empty LinkedList.
*/
public LinkedList() { this.value = new Empty(); }
/**
* Constructs a LinkedList containing only the given element.
*/
public LinkedList(String _first) { this.value = new Cons(_first); }
/**
* Constructs a LinkedList consisting of the given Object followed by
* all the elements in the given LinkedList.
*/
public LinkedList(String _first, LinkedList _rest) {
this.value = new Cons(_first, _rest.value);
}
public Object getFirst() { return this.value.getFirst(); }
public LinkedList getRest() {
return new LinkedList(this.value.getRest());
}
public void push(String s) { this.value = new Cons(s, this.value); }
public String pop() {
String result = (String)this.value.getFirst();
this.value = this.value.getRest();
return result;
}
public boolean isEmpty() { return this.value instanceof Empty; }
public String toString() {...}
...
}
假設我們寫了這些代碼,並且所有的測試案例都可以正常運行。(或者,更現實些,假設它起初並不能正常運行,可經過幾個調試周期後,我們使它變得能夠正常運行了。)
也許幾個月後,您開發了類 Cons 的一個新構造函數,它使用列表的 String 表達作為其唯一的參數。這種構造函數非常有用 ― 它允許我們用下面這樣的表達式構造新的列表:
清單 3. 新構造函數僅使用列表的 String 表示法作為參數
new Cons('(this is a list)")
new Cons('(so is this)")
這樣,我們寫了這個構造函數而且它的所有測試案例也正常運行了。太棒了!但是,接著,我們發現,太不可思議了,類 LinkedList 的方法測試中有一些突然中斷了。發生了什麼事?
起因
問題在於類 LinkedList 的構造函數,它只有一個 String 作為參數。
這個構造函數以前曾調用底層的類 Cons 的構造函數。但是,既然我們用一個更加明確的方法 ― 該方法只有一個 String 作為參數 ― 重載了這個構造函數,那麼被調用的就是這個更加特殊的方法了。
除非傳遞給 LinkedList 構造函數的 String 是一個有效的 Cons 表示法,否則試圖對其進行語法分析時就會導致程序崩潰。更糟糕的是,如果 String 正好是一個有效的 Cons 表示法,程序就會使用這些毀壞的數據繼續執行。如果那樣的話,我們就在數據中引入了一個破壞者數據。關於破壞者數據的討論,請參閱最後一部分,“ 破壞者數據錯誤模式”。
Broken Dispatch 錯誤,與所有的錯誤模式一樣,在“布滿測試”的代碼(借用自極端編程術語)中最容易診斷,在這種環境中連最微不足道的方法也有相應的單元測試。在這樣的環境裡,最為一般的症狀是為您從未碰過的代碼編寫的測試案例突然中斷運行。
如果這種情況發生,有可能是 Broken Dispatch 模式的一種情況。如果測試案例中斷是在您重載另一個方法後立即發生的,那就幾乎可以肯定。
如果這段代碼沒有經過布滿測試,情況就變得更加困難了。錯誤症狀可能表現為,比如,返回速度比預期快得多(並且結果錯誤)的方法調用。換句話說,您可能會發現本來應該發生的某些事件從未發生(因為正確的方法未曾被執行)。
要記住,盡管類似的症狀也可能是其它錯誤模式的緣故。但是,如果遇到這種的症狀,最好是開始寫更多的單元測試,從發現錯誤的方法開始,回退測試程序執行的過程。
治療和預防措施
關於這個錯誤模式的好消息是有一些簡單的解決方案。最直接的一種方法是把方法調用中的參數 上溯造型。在我們的示例中,這意味著重寫相關的 LinkedList 構造函數,如下所示:
清單 4. 在方法調用中上溯造型參數
public LinkedList(String _first) {
this.value = new Cons((Object)_first);
}
當然,這種辦法只解決了調用 Cons 構造函數這個問題。還有其它地方的調用,我們也得上溯造型,這種技術對於 Cons 類的客戶來說是很討厭的。在這樣的情形下,您就得權衡一下,應用這個方便的構造函數給您帶來的好處以及它引入錯誤的潛在危險,二者孰重孰輕了。
接口的表達性和健壯性之間的平衡也存在這種困境。一種兩全其美的方法是用一個 static 方法來替代這個 Cons 的 String 構造函數,該 static 方法只有一個 String 作為參數並返回一個新的 Cons 對象。
總結
以下是對上面這個錯誤模式的總結:
模式: Broken Dispatch
症狀:在重載了另一個方法之後,測試您從未碰過的代碼的測試案例突然發生中斷。
起因: 重載使得未碰過的方法調用了一個方法,而該方法不是您希望調用的那個方法。
治療和預防措施:插入顯式上溯造型。或者,重新考慮您提供給不同的類的方法集。
記住,當您重載或者覆蓋一個方法時,參數結構是確保按意圖調用方法的一個關鍵部分。
下個月,我們將暫停錯誤模式的討論轉而處理其它一些重要的課題。診斷 Java 代碼的下一部分將考查尾遞歸方法如何影響 Java 程序的性能。請不要擔心,我們很快就會回到錯誤模式上來。