不要強制轉換這個類!
與可怕的 空指針異常(該異常除了報告空指針之外,對於將要發生的事情什麼也不說)不同,類強制轉換異常相對來說容易調試。
類強制轉換經常發生在遞歸下行數據結構的程序中,通常是當代碼的某些部分在每次方法調用中下行了兩級且在第二次下行時調度不當時發生的。程序員可通過學習 Double Descent 錯誤模式來識別這種問題。
Double Descent 錯誤模式
本周的專題是 Double Descent 錯誤模式。它通過類強制轉換異常來表明。它是由遞歸下行復合數據結構引起的,這種下行方式有時在一次遞歸調用中要下行多級。這樣做經常需要添加類型強制轉換來編譯代碼。但是,在這種下行中,很容易忘記檢查是否滿足了適當的不變量來保證這些類型強制轉換成功。
考慮以下的 int 二元樹的類層次結構。因為我們希望考慮到空樹的情況,所以將不把 value 字段放入 Leaf 類中。由於這一決定使所有的 Leaf 相同,我們將用一個靜態字段為 Leaf 保留一個單元素。
清單 1. int 二元樹的類層次結構
abstract class Tree {
}
class Leaf extends Tree {
public static final Leaf ONLY = new Leaf();
}
class Branch extends Tree {
public int value;
public Tree left;
public Tree right;
public Branch(int _value, Tree _left, Tree _right) {
this.value = _value;
this.left = _left;
this.right = _right;
}
}
現在,假定我們希望在 Tree 上添加一個方法,該方法確定任意兩個連貫的節點(比如一個分支和它的其中一個子分支)是否都包含一個 0 作為它們的值。我們可能添加以下方法(注意:最後一個方法將不以它的當前形式編譯):
清單 2. 確定兩個連貫的節點是否都包含值 0 的方法
// in class Tree:
public abstract boolean hasConsecutiveZeros();
// in class Leaf:
public boolean hasConsecutiveZeros() {
return false;
}
// in class Branch:
public boolean hasConsecutiveZeros() {
boolean foundOnLeft = false;
boolean foundOnRight = false;
if (this.value == 0) {
foundOnLeft = this.left.value == 0;
foundOnRight = this.right.value == 0;
}
if (foundOnLeft || foundOnRight) {
return true;
}
else {
foundOnLeft = this.left.hasConsecutiveZeros();
foundOnRight = this.right.hasConsecutiveZeros();
return foundOnLeft || foundOnRight;
}
}
類 Branch 中的方法將不編譯,因為 this.left 和 this.right 不保證具有 value 字段。
我們無法編譯強烈地表明我們對這些數據結構所進行的操作中有邏輯錯誤。但是假設我們忽略此警告,只是僅僅在適當的 if 語句中將 this.left 和 this.right 強制轉換為 Branch ,如下所示:
清單 3. 在適當的 if 語句中將 this.left 和 this.right 強制轉換為 Branch
public boolean hasConsecutiveZeros() {
boolean foundOnLeft = false;
boolean foundOnRight = false;
if (this.value == 0) {
foundOnLeft = ((Branch)this.left).value == 0;
foundOnRight = ((Branch)this.right).value == 0;
}
if (foundOnLeft || foundOnRight) {
return true;
}
else {
foundOnLeft = this.left.hasConsecutiveZeros();
foundOnRight = this.right.hasConsecutiveZeros();
return foundOnLeft || foundOnRight;
}
}
症狀
現在代碼將會編譯。實際上,在許多測試事例中它都會成功。但是假設我們要在圖 1 所示的樹上運行這段代碼,其中樹的分支都用圓形表示,值在中心,葉子用正方形表示。調用這棵樹上的 hasConsecutiveZeros 將導致類強制轉換異常。
圖 1. 在這棵樹上,調用 hasConsecutiveZeros 導致類強制轉換異常
起因
問題發生在左分支上。因為該分支的值為 0, hasConsecutiveZeros 將其子分支強制轉換為 Branch 類型,當然,轉換失敗。
治療和預防措施
修正上述問題的方法與預防這種問題的方法相同。但是,在討論這個修正方法之前,我先討論一種 不修正的方法。
一種快速但不正確的解決這個問題的方法是除去 Leaf 類並通過簡單地將空指針放在 Branch 的 left 和 right 字段中來表示 Leaf 節點。這種方法可除去上面代碼中類型強制轉換的需要,但不修正錯誤。
相反,在運行時發出的錯誤將會是一個空指針異常而不是類強制轉換異常。因為空指針異常更難診斷,這種“修正”實際上會降低代碼的質量。關於這個問題的更多討論,請參閱我的文章 空標志錯誤模式。
那麼,我們如何修正這個錯誤呢?一種方法是將每個類型強制轉換都包在 instanceof 檢查語句中。
清單 4. 一種修正方法:將每個類型強制轉換都包在 instanceof 檢查語句中
if (! (this.left instanceof Leaf)) {
// this.left instanceof Branch
foundOnLeft = ((Branch)this.left).value == 0;
}
if (! (this.right instanceof Leaf)) {
// this.right instanceof Branch
foundOnRight = ((Branch)this.right).value == 0;
}
順便注意一下斷定每個 if 語句正文中希望保留的不變量的注釋。在代碼中添加類似的注釋是個好習慣。這種習慣對於 else 子句尤其有用。因為我們很少對 else 子句中希望保留的不變量進行顯式檢查,所以在代碼中清楚說明該不變量是一個不錯的主意。
把類型強制轉換當作一種斷言,把不變量當做說明該斷言為 true 的原因的參數。
以這種方式使用 instanceof 檢查語句的一個缺點是,如果我們要添加 Tree 的另一個子類(比如一個 LeafWithValue 類),我們將不得不修改這些 instanceof 檢查語句。由於這個原因,只要可能我都會設法避開 instanceof 檢查語句。
相反,我向為每個子類執行適當的操作的子類添加額外的方法。畢竟,添加這種多態方法的能力是面向對象語言的關鍵優勢之一。
在目前的示例中,我們可以通過向 Tree 類中添加 valueIs 方法來完成這個操作,如下所示:
清單 5. 使用 valueIs 代替 instanceof
// in class Tree:
public abstract boolean valueIs(int n);
// in class Leaf:
public boolean valueIs(int n) { return false; }
// in class Branch:
public boolean valueIs(int n) {
return value == n;
}
// in class Branch, method hasConsecutiveZeros
if (this.valueIs(0)) {
foundOnLeft = this.left.valueIs(0);
foundOnRight = this.right.valueIs(0);
}
注意:我已經添加了 valueIs 方法來代替 getValue 方法。如果我們已經向 Leaf 類添加了 getValue 方法,我們要麼是不得不返回一些類型的標志值表明此方法應用是無意義的,要麼是實際拋出一個異常。
返回一個標志值將引起許多與我們上次討論的空標志錯誤模式一樣的錯誤。拋出一個異常在本例中幫不了什麼忙,因為我們將不得不在 hasConsecutiveZeros 中添加 instanceof 檢查語句以確保我們沒有觸發異常。而這正是在新方法中我們要設法避免的。
valueIs 通過封裝我們真正希望每個類單獨處理的內容:檢查類的一個實例是否包含給定的值,以避開所有這些問題。
總結
下面是本周的錯誤模式的小結:
模式:Double Descent
症狀:在數據結構上執行遞歸下行時拋出類強制轉換異常。
起因:代碼的某些部分在每次方法調用中下行了兩級且第二次下行時調度不當。
治療和預防措施:把類型強制轉換代碼分解到每個類的單獨方法中去。還有一種選擇是,檢查不變量以確保類型強制轉換將會成功。
簡言之,這些方法的本質總是使您確信代碼塊內部的不變量會確保代碼塊中的任何類型強制轉換都將成功。當對每個類型強制轉換進行這種級別的詳細審查時,您可能會發現通過向相關的子類添加方法,您將許多這些類型強制轉換分解了。
在下一篇文章中,我將討論與錯誤處理復雜的輸入數據相關的錯誤模式。