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