五十七、只針對異常情況才使用異常:
不知道你否則遇見過下面的代碼:
try {
int i = 0;3
while (true)
range[i++].climb();
}
catch (ArrayIndexOutOfBoundsException e) {
}
這段代碼的意圖不是很明顯,其本意就是遍歷變量數(shù)組range中的每一個元素,并執(zhí)行元素的climb方法,當下標超出range的數(shù)組長度時,將會直接拋出ArrayIndexOutOfBoundsException異常,catch代碼塊將會捕獲到該異常,但是未作任何處理,只是將該錯誤視為正常工作流程的一部分來看待。這樣的寫法確實給人一種匪夷所思的感覺,讓我們再來看一下修改后的寫法:
for (Mountain m : range) {
m.climb();
}
和之前的寫法相比其可讀性不言而喻。那么為什么又有人會用第一種寫法呢?顯然他們是被誤導了,他們企圖避免for-each循環(huán)中JVM對每次數(shù)組訪問都要進行的越界檢查。這無疑是多余的,甚至適得其反,因為將代碼放在try-catch塊中反而阻止了JVM的某些特定優(yōu)化,至于數(shù)組的邊界檢查,現(xiàn)在很多JVM實現(xiàn)都會將他們優(yōu)化掉了。在實際的測試中,我們會發(fā)現(xiàn)采用異常的方式其運行效率要比正常的方式慢很多。
除了剛剛提到的效率和代碼可讀性問題,第一種寫法還會掩蓋一些潛在的Bug,假設數(shù)組元素的climb方法中也會訪問某一數(shù)組,并且在訪問的過程中出現(xiàn)了數(shù)組越界的問題,基于該錯誤,JVM將會拋出ArrayIndexOutOfBoundsException異常,不幸的是,該異常將會被climb函數(shù)之外catch語句捕獲,在未做任何處理之后,就按照正常流程繼續(xù)執(zhí)行了,這樣Bug也就此被隱藏起來。
這個例子的教訓很簡單:"異常應該只用于異常的情況下,它們永遠不應該用于正常的控制流"。雖然有的時候有人會說這種怪異的寫法可以帶來性能上的提升,即便如此,隨著平臺實現(xiàn)的不斷改進,這種異常模式的性能優(yōu)勢也不可能一直保持。然而,這種過度聰明的模式帶來的微妙的Bug,以及維護的痛苦卻依然存在。
根據(jù)這條原則,我們在設計API的時候也是會有所啟發(fā)的。設計良好的API不應該**它的客戶端為了正常的控制流而使用異常。如Iterator,JDK在設計時充分考慮到這一點,客戶端在執(zhí)行next方法之前,需要先調(diào)用hasNext方法已確認是否還有可讀的集合元素,見如下代碼:
for (Iterator i = collection.iterator(); i.hasNext(); ) {
Foo f = i.next();
}
如果Iterator缺少hasNext方法,客戶端則將**改為下面的寫法:
try {
Iterator i = collection.iterator();
while (true)
Foo f = i.next();
}
catch (NoSuchElementException e) {
}
這應該非常類似于本條目開始時給出的遍歷數(shù)組的例子。在實際的設計中,還有另外一種方式,即驗證可識別的錯誤返回值,然而該方式并不適合于此例,因為對于next,返回null可能是合法的。那么這兩種設計方式在實際應用中有哪些區(qū)別呢?
1. 如果是缺少同步的并發(fā)訪問,或者可被外界改變狀態(tài),使用可識別返回值的方法是非常必要的,因為在測試狀態(tài)(hasNext)和對應的調(diào)用(next)之間存在一個時間窗口,在該窗口中,對象可能會發(fā)生狀態(tài)的變化。因此,在該種情況下應選擇返回可識別的錯誤返回值的方式。
2. 如果狀態(tài)測試方法(hasNext)和相應的調(diào)用方法(next)使用的是相同的代碼,出于性能上的考慮,沒有必要重復兩次相同的工作,此時應該選擇返回可識別的錯誤返回值的方式。
3. 對于其他情形則應該盡可能考慮"狀態(tài)測試"的設計方式,因為它可以帶來更好的可讀性。
五十八、對可恢復的情況使用受檢異常,對編程錯誤使用運行時異常:
Java中提供了三種可拋出結構:受檢異常、運行時異常和錯誤。該條目針對這三種類型適用的場景給出了一般性原則。
1. 如果期望調(diào)用者能夠適當?shù)鼗謴停瑢τ谶@種情況就應該使用受檢異常,如某人打算網(wǎng)上購物,結果余額不足,此時可以拋出自定義的受檢異常。通過拋出受檢異常,將**調(diào)用者在catch子句中處理該異常,或繼續(xù)向上傳播。因此,在方法中聲明受檢異常,是對API用戶的一種潛在提示。
2. 用運行時異常來表明編程錯誤。大多數(shù)的運行時異常都表示"前提違例",即API的使用者沒有遵守API設計者建立的使用約定。如數(shù)組訪問越界等問題。
3. 對于錯誤而言,通常是被JVM保留用于表示資源不足、約束失敗,或者其他使程序無法繼續(xù)執(zhí)行的條件。
針對自定義的受檢異常,該條目還給出一個非常實用的技巧,當調(diào)用者捕獲到該異常時,可以通過調(diào)用該自定義異常提供的接口方法,獲取更為具體的錯誤信息,如當前余額等信息。
五十九、避免不必要的使用受檢異常:
受檢異常是Java提供的一個很好的特征。與返回值不同,它們**程序員必須處理異常的條件,從而大大增強了程序的可靠性。然而,如果過分使用受檢異常則會使API在使用時非常不方便,畢竟我們還是需要用一些額外的代碼來處理這些拋出的異常,倘若在一個函數(shù)中,它所調(diào)用的五個API都會拋出異常,那么編寫這樣的函數(shù)代碼將會是一項令人沮喪的工作。
如果正確的使用API不能阻止這種異常條件的產(chǎn)生,并且一旦產(chǎn)生異常,使用API的程序員可以立即采用有用的動作,這種負擔就被認為是正當?shù)?。除非這兩個條件都成立,否則更適合使用未受檢異常,見如下測試:
try {
dosomething();
} catch (TheCheckedException e) {
throw new AssertionError();
}
try {
donsomething();
} catch (TheCheckedException e) {
e.printStackTrace();
System.exit(1);
}
當我們使用受檢異常時,如果在catch子句中對異常的處理方式僅僅如以上兩個示例,或者還不如它們的話,那么建議你考慮使用未受檢異常。原因很簡單,它們在catch子句中,沒有做出任何用于恢復異常的動作。
六十、優(yōu)先使用標準異常:
使用標準異常,不僅可以更好的復用已有的代碼,同時也使你設計的API更加容易學習和使用,因為它和程序員已經(jīng)熟悉的習慣用法更為一致。另外一個優(yōu)勢是,代碼的可讀性更好,程序員在閱讀時不會出現(xiàn)更多的不熟悉的代碼。該條目給出了一些非常常用且容易被復用的異常,見下表:
異常 應用場合
IllegalArgumentException 非null的參數(shù)值不正確。
IllegalStateException 對于方法調(diào)用而言,對象狀態(tài)不合適。
NullPointerException 在禁止使用null的情況下參數(shù)值為null。
IndexOutOfBoundsException 下標參數(shù)值越界
ConcurrentModificationException 在禁止并發(fā)修改的情況下,檢測到對象的并發(fā)修改。
UnsupportedOperationException 對象不支持用戶請求的方法。
當然在Java中還存在很多其他的異常,如ArithmeticException、NumberFormatException等,這些異常均有各自的應用場合,然而需要說明的是,這些異常的應用場合在有的時候界限不是非常分明,至于該選擇哪個比較合適,則更多的需要依賴上下文環(huán)境去判斷。
最后需要強調(diào)的是,一定要確保拋出異常的條件和該異常文檔中描述的條件保持一致。
六十一、拋出與抽象相對應的異常:
如果方法拋出的異常與它所執(zhí)行的任務沒有明顯的關系,這種情形將會使人不知所措。特別是當異常從底層開始拋出時,如果在中間層沒有做任何處理,這樣底層的實現(xiàn)細節(jié)將會直接污染高層的API接口。為了解決這樣的問題,我們通常會做出如下處理:
try {
doLowerLeverThings();
} catch (LowerLevelException e) {
throw new HigherLevelException(...);
}
這種處理方式被稱為異常轉譯。事實上,在Java中還提供了一種更為方便的轉譯形式--異常鏈。試想一下上面的示例代碼,在調(diào)試階段,如果高層應用邏輯可以獲悉到底層實際產(chǎn)生異常的原因,那么對找到問題的根源將會是非常有幫助的,見如下代碼:
try { doLowerLevelThings();
} catch (LowerLevelException cause) {
throw new HigherLevelException(cause);
}
底層異常作為參數(shù)傳遞給了高層異常,對于大多數(shù)標準異常都支持異常鏈的構造器,如果沒有,可以利用Throwable的initCause方法設置原因。異常鏈不僅讓你可以通過接口函數(shù)getCause訪問原因,它還可以將原因的堆棧軌跡集成到更高層的異常中。
通過這種異常鏈的方式,可以非常有效的將底層實現(xiàn)細節(jié)與高層應用邏輯徹底分離出來。
六十三、在細節(jié)中包含能捕獲失敗的信息:
當程序由于未被捕獲的異常而失敗的時候,系統(tǒng)會自動地打印出該異常的堆棧軌跡。在堆棧軌跡中包含該異常的字符串表示法,即toString方法的返回結果。如果我們在此時為該異常提供了詳細的出錯信息,那么對于錯誤定位和追根溯源都是極其有意義的。比如,我們將拋出異常的函數(shù)的輸入?yún)?shù)和函數(shù)所在類的域字段值等信息格式化后,再打包傳遞給待拋出的異常對象。假設我們的高層應用捕捉到IndexOutOfBoundsException異常,如果此時該異常對象能夠攜帶數(shù)組的下界和上界,以及當前越界的下標值等信息,在看到這些信息后,我們就能很快做出正確的判斷并修訂該Bug。
特別是對于受檢異常,如果拋出的異常類型還能提供一些額外的接口方法用于獲取導致錯誤的數(shù)據(jù)或信息,這對于捕獲異常的調(diào)用函數(shù)進行錯誤恢復是非常重要的。
六十四、努力使失敗保持原子性:
這是一個非常重要的建議,因為在實際開發(fā)中當你是接口的開發(fā)者時,經(jīng)常會忽視他,認為不保證的話估計也沒有問題。相反,如果你是接口的使用者,也同樣會忽略他,會認為這個是接口實現(xiàn)者理所應當完成的事情。
當對象拋出異常之后,通常我們期望這個對象仍然保持在一種定義良好的可用狀態(tài)之中,即使失敗是發(fā)生在執(zhí)行某個操作的過程中間。對于受檢異常而言,這尤為重要,因為調(diào)用者希望能從這種異常中進行恢復。一般而言,失敗的方法調(diào)用應該使對象保持在被調(diào)用之前的狀態(tài)。具有這種屬性的方法被稱為具有"失敗原子性"。
有以下幾種途徑可以保持這種原子性。
1. 最簡單的方法是設計不可變對象。因為失敗的操作只會導致新對象的創(chuàng)建失敗,而不會影響已有的對象。
2. 對于可變對象,一般方法是在操作該對象之前先進行參數(shù)的有效性驗證,這可以使對象在被修改之前,拋出更為有意義的異常,如:
public Object pop() {
if (size == 0)
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null;
return result;
}
如果沒有在操作之前驗證size,elements的數(shù)組也會拋出異常,但是由于size的值已經(jīng)發(fā)生了變化,之后再繼續(xù)使用該對象時將永遠無法恢復到正常狀態(tài)了。
3. 預先寫好恢復性代碼,在出現(xiàn)錯誤時執(zhí)行帶段代碼,由于此方法在代碼編寫和代碼維護的過程中,均會帶來很大的維護開銷,再加之效率相對較低,因此很少會使用該方法。
4. 為該對象創(chuàng)建一個臨時的copy,一旦操作過程中出現(xiàn)異常,就用該復制對象重新初始化當前的對象的狀態(tài)。
雖然在一般情況下都希望實現(xiàn)失敗原子性,然而在有些情況下卻是難以做到的,如兩個線程同時修改一個可變對象,在沒有很好同步的情況下,一旦拋出ConcurrentModificationException異常之后,就很難在恢復到原有狀態(tài)了。
六十五、不要忽略異常: 這是一個顯而易見的常識,但是經(jīng)常會被違反,因此該條目重新提出了它,如:
try {
dosomething();
} catch (SomeException e) {
}
可預見的、可以使用忽略異常的情形是在關閉FileInputStream的時候,因為此時數(shù)據(jù)已經(jīng)讀取完畢。即便如此,如果在捕獲到該異常時輸出一條提示信息,這對于挖出一些潛在的問題也是非常有幫助的。否則一些潛在的問題將會一直隱藏下去,直到某一時刻突然爆發(fā),以致造成難以彌補的后果。
該條目中的建議同樣適用于受檢異常和未受檢的異常。