您經常會看到代碼不是僅僅通過調用構造函數對類進行初始化,它還通過一些緊接著的意在設置各個域的動作對類進行初始化。不幸的是,這樣緊接著的動作是錯誤的高發地帶,會帶來連續初始化(run-on initialization)類型的錯誤。
連續初始化
由於各種原因(多數是糟糕的),您經常會看到這樣的類定義,其中的類構造函數並不帶有足夠的參數來適當地初始化類的所有域。這樣的構造函數要求客戶機類用幾個步驟來對實例進行初始化(設置未被初始化的域的值),而不是用一個構造函數調用就行了。以這樣的方式初始化實例是一個易於出錯的過程,我把它稱為 連續初始化。這個過程產生的各種錯誤類型有相似的症狀和治療方法,所以我們可以將它們統統歸入一種稱為 連續初始化器錯誤模式的模式。
例如,考慮以下代碼:
清單 1. 一個簡單的連續初始化
class RestrictedInt {
public Integer value;
public boolean canTakeZero;
public RestrictedInt(boolean _canTakeZero) {
canTakeZero = _canTakeZero;
}
public void setValue(int _value) throws CantTakeZeroException {
if (_value == 0) {
if (canTakeZero) {
value = new Integer(_value);
}
else {
throw new CantTakeZeroException(this);
}
}
else {
value = new Integer(_value);
}
}
}
class CantTakeZeroException extends Exception {
public RestrictedInt ri;
public CantTakeZeroException(RestrictedInt _ri) {
super("RestrictedInt can't take zero");
ri = _ri;
}
}
class Client {
public static void initialize() throws CantTakeZeroException {
RestrictedInt ri = new RestrictedInt(false);
ri.setValue(0);
}
}
不幸的是,對這個類的實例的初始化序列很容易出錯。您可能已經注意到,在上面的代碼中,在第二個初始化步驟處拋出了一個異常。結果是,在執行該步驟後應該已經被設置了的域未被設置。
但是,所拋出的異常的處理程序可能並不知道該域未被設置。如果在從異常恢復的過程中,處理程序訪問 RestrictedInt 的有問題的 value 域,那麼連它自己都有可能在 NullPointerException 處受阻。
如果真發生了那樣的事情,倒不如處理程序根本不存在來得好些。至少被檢查的異常包含有一些關於它的起因的線索。但 NullPointerException 是臭名昭著的難以診斷的異常,因為它們(必然地)幾乎不包含關於某個值為什麼一開始被設置為空的信息。而且,這些異常僅在未被初始化的域被訪問時才發生。那個訪問很可能是在離該錯誤的起因(即一開始未能初始化該域)很遠的地方發生的。
當然,連續初始化錯誤還會引起其它錯誤。
更多因連續而產生的錯誤
可能引起的其它錯誤是:
編寫初始化代碼的程序員可能會忘記把某個初始化步驟包括進去。
初始化步驟中可能存在程序員並不知道的基於次序的依賴關系,程序員因而會不按次序執行這些語句。
正在被初始化的類可能會被更改。新的域被添加進來,或者舊的域被刪除。結果每個客戶機中的所有初始化代碼都必須修改,以適當地設置這些域。多數修改後的代碼都很相似,但就算只漏了一個副本,也會帶來錯誤。因此,連續初始化器很容易就會變成 rogue tile(請參閱我關於 Rogue Tile 錯誤模式的文章了解一些背景)。
由於所有問題都與連續初始化有關,所以,定義初始化所有域的構造函數會好得多。在上面的示例中, RestrictedInt 的構造函數應帶有一個 int ,以初始化它的 value 域。包含一個留有任何未被初始化的域的類構造函數,這種做法永遠不會有好的理由。當從頭編寫類時,這並不是難以遵循的原則。
但是,如果您 必須處理一大堆代碼庫,而其中某個類並未在它的構造函數中初始化其所有域,並且代碼庫中到處是連續初始化器,那又該怎麼辦呢?我已經不止一次陷入到了這種境地。
當您束手無策時
不幸的是,這樣一種情形,即處理其中某個類沒有在它的構造函數中初始化所有域的舊代碼庫,比多數程序員所願意處理的情形更常見。如果舊代碼庫很大,而且有損壞了的類的很多客戶機,那您可能不會想修改構造函數說明,特別是如果代碼的單元測試並不充足的話。不可避免地,您將在破壞了一些未編制文檔的不變量之後作罷。
通常,在這種情形中,最好是拋開那些舊代碼,從頭做起!這聽起來像瘋言瘋語,但您修補像那樣的代碼中的錯誤花去的時間很容易就能讓重新編寫代碼所用的時間相形見绌。有很多次,我都煞費苦心地處理帶有那種問題的龐大的舊代碼庫,但最終,我都只好放棄,但願自己要是從頭做起就好了。
但如果不能選擇拋開那些代碼的話,我們仍然可以通過結合以下簡單的做法來嘗試控制出錯的可能性:
把域初始化成(非空)缺省值。
包含額外的構造函數供使用。
在類中包含一個 isInitialized 方法。
構造特殊的類來代表缺省值。
讓我們來看看為什麼應該采用這些做法。
把域初始化成(非空)缺省值
通過把缺省值填充到域,您確保了類的實例在任何時候都處於定義良好的狀態。這一做法對於除非另行指定,否則就取空值的引用類型尤其重要。
為什麼?因為濫用空值不可避免地會導致 NullPointerException 。而 NullPointerException 是很糟糕的。一個原因是,這些異常幾乎不提供關於一個錯誤的真正起因的信息。另一個原因是,它們常常在離錯誤的實際起因很遠的地方被拋出。
要不惜一切代價避免它們。如果您決定要使用空值,以便您可以發出某個類尚未被完全初始化的信號,那請您參閱我的關於 Null Flag 錯誤模式的文章獲取幫助。
包含額外的構造函數
當您包含額外的構造函數時,您可以在新的上下文中使用它們,在那裡您不必包含新的連續初始化。僅僅是因為有些上下文被限制成必須使用連續初始化,其它上下文則不必為此付出代價。
在類中包含一個 isInitialized 方法
可以在類中包含一個 isInitialized 方法,以允許迅速判斷某個實例是否已經被初始化了。在編寫需要連續初始化的類時,這樣一個方法基本上總是一個好主意。
對於您不是自己維護這些類的情況,您甚至可以把這樣的 isInitialized 方法放到您自己的實用程序類中。畢竟,如果一個實例未被初始化,並且其結果可以從外部觀察到,那麼您就可以寫一個方法來檢查這個結果(即使它要求采用一般認為是不明智的實踐 ― 捕獲 RuntimeException )。
構造特殊的類來代表缺省值
不是允許用空值來填充域,而是允許構造特殊的類(很可能是用 Singletons)來代表缺省值。然後把這些類的實例填充到缺省構造函數的域。您不但將降低拋出 NullPointerException 的可能性,而且,如果這些域被不恰當地訪問了,您還將能夠精確地掌握真正發生了什麼錯誤。
例如,我們可以修改 RestrictedInt 類如下:
清單 2. 帶 NonValue 的 RestrictedInt
class RestrictedInt implements SimpleInteger {
public SimpleInteger value;
public boolean canTakeZero;
public RestrictedInt(boolean _canTakeZero) {
canTakeZero = _canTakeZero;
value = NonValue.ONLY;
}
public void setValue(int _value) throws CantTakeZeroException {
if (_value == 0) {
if (canTakeZero) {
value = new DefaultSimpleInteger(_value);
}
else {
throw new CantTakeZeroException(this);
}
}
else {
value = new DefaultSimpleInteger(_value);
}
}
public int intValue() {
return ((DefaultSimpleInteger)value).intValue();
}
}
interface SimpleInteger {
}
class NonValue implements SimpleInteger {
public static NonValue ONLY = new NonValue();
private NonValue() {}
}
class DefaultSimpleInteger implements SimpleInteger {
private int value;
public DefaultSimpleInteger(int _value) {
value = _value;
}
public int intValue() {
return value;
}
}
現在,如果您的任何訪問這個域的客戶機類要在結果元素上執行一個 intValue 操作,則由於 NonValues 不支持該操作,所以這些客戶機類必須首先強制轉型成 DefaultSimpleInteger 。
上述辦法的優點是您將不斷地在代碼中您忘記了強制轉型的各個地方得到提示(通過編譯器錯誤),指出這個方法調用無法在該缺省值上工作。而且,在運行時,如果您碰巧訪問了這個域,而它包含缺省值,那您就將得到一個 ClassCastException ,它將包含比您原來將會得到的 NullPointerException 多得多的信息 ― ClassCastException 將不僅告訴您那裡實際發生了什麼,而且還將告訴您程序在那裡應該是什麼樣子。
缺點是性能將有所損失。每當域被訪問時,程序都還要執行一個強制轉型。
如果您覺得不需要編譯錯誤消息也行,那另一種解決方案是在接口 SimpleInteger 中包含 intValue 方法。然後,您就可以用一個拋出任何您想拋出的錯誤在缺省類中實現這個方法,(而且您可以包含您想包含的任何信息)。為了說明這一點,請考察如下示例:
清單 3. 拋出異常的 NonValue
class RestrictedInt implements SimpleInteger {
public SimpleInteger value;
public boolean canTakeZero;
public RestrictedInt(boolean _canTakeZero) {
canTakeZero = _canTakeZero;
value = NonValue.ONLY;
}
public void setValue(int _value) throws CantTakeZeroException {
if (_value == 0) {
if (canTakeZero) {
value = new DefaultSimpleInteger(_value);
}
else {
throw new CantTakeZeroException(this);
}
}
else {
value = new DefaultSimpleInteger(_value);
}
}
public int intValue() {
return value.intValue();
}
}
interface SimpleInteger {
public int intValue();
}
class NonValue implements SimpleInteger {
public static NonValue ONLY = new NonValue();
private NonValue() {}
public int intValue() {
throw new
RuntimeException("Attempt to access an int from a NonValue");
}
}
class DefaultSimpleInteger implements SimpleInteger {
private int value;
public DefaultSimpleInteger(int _value) {
value = _value;
}
public int intValue() {
return value;
}
}
這個解決方案能夠比 ClassCastException 提供更好的錯誤診斷。而且它還更高效,因為在運行時不需要強制轉型。但是這個解決方案將不會要求您在每一個訪問點考慮域的可能值。
選擇使用哪一個解決方案,一部分取決於您的偏好,一部分取決於您的項目的性能和健壯性約束。
現在,讓我們來研究一種乍一看好像完全錯誤的技術。
包含只拋出異常的方法
一開始,您可能會覺得這種做法天生就是錯誤的,而且有悖常理 ― 類應該僅包含實際對數據進行有意義操作的方法。特別是當您在給程序員教授面向對象編程時,包含諸如這樣的類可能會令人大惑不解。
例如,考慮兩種可能的定義 List 的類層次結構的方式,如以下的清單 4 和清單 5 所示:
清單 4. 不帶通用 getter 的 List
abstract class List {}
class Empty extends List {}
class Cons extends List {
Object first;
List rest;
Cons(Object _first, List _rest) {
first = _first;
rest = _rest;
}
public Object getFirst() {
return first;
}
public List getRest() {
return rest;
}
}
清單 5. 在接口中帶有 getter 的 List
abstract class List {
public abstract Object getFirst();
public abstract Object getRest();
}
class Empty extends List {
public Object getFirst() {
throw new RuntimeException("Attempt to take first of an empty list");
}
public List getRest() {
throw new RuntimeException("Attempt to take rest of an empty list");
}
}
class Cons extends List {
Object first;
List rest;
Cons(Object _first, List _rest) {
first = _first;
rest = _rest;
}
public Object getFirst() {
return first;
}
public List getRest() {
return rest;
}
}
對於初學面向對象語言的程序員, List 的第一個版本(不帶有通用 getter 的那個)背後的動機不會很令人費解。就直覺而言,除非一個方法做實際工作,否則類就不應該包含這個方法。不過以上關於處理缺省類的考慮事項也同樣適用於這個示例。
不斷地往代碼插入強制轉型,效率是很低的,而且會使代碼變得拖泥帶水。此外,類強制轉型會給性能帶來嚴重的後患,尤其是對於像 List 這樣經常被調用的實用程序類。
就所有設計實踐來說,當要考慮實踐的深層動機時,這種做法是最適用的。這種動機並非總是適用的,所以,當它不適用時,就不應采用這種做法。
修正錯誤,情況會更好
您可能已經注意到(如果您閱讀過我的論述錯誤模式的其它文章的話)連續初始化器錯誤有一點點不同。這一次我提供了不少如何解決這些錯誤的根本起因的想法,而不是僅僅將它修正。這是因為,在很多場合,我必須解決它們。那些可不是好差使。
而且,正如我們提到過的考慮事項所表明的,完全避免連續初始化要好得多。但當您必須處理它們時,至少能夠保護您自己了。這裡是這個錯誤模式的總結:
模式:連續初始化器。
症狀:在未被初始化的域被訪問的地方拋出了一個 NullPointerException 。
起因:有某個類,其構造函數並未直接初始化所有域。
處方及預防措施:在一個構造函數中初始化所有域。當沒有更佳的值可使用時,使用代表缺省值的特殊類。對於有更佳的值可以使用的情況,包含多個構造函數。包含一個 isInitialized 方法。
在接下來的幾個月裡,我們將回到錯誤模式這個主題。下個月,我們將討論一些 Java 語言中出現的與平台相關的錯誤。與普遍的看法相反,Java 語言並不是不受那類錯誤的影響。