《Effective Java》學習筆記八——異常
企圖利用java的錯誤判斷機制來提高效能是錯誤的:
-
把程式碼放在try-catch塊中反而阻止了現在JVM實現本來可能要執行的某些特定優化。
-
對陣列進行遍歷的標準模式並不會導致冗餘的檢查。有些現在的JVM實現會將它們優化掉。
異常應該只用於異常的情況下;它們永遠不應該用於正常的流程控制。
設計良好的API不應該強迫它的客戶端為了正常的控制流而使用異常。
“狀態測試方法”和“可識別的返回值”:
如果物件將在缺少外部同步的情況下被併發訪問,或者可被外界改變狀態,使用可被識別的返回值可能是很有必要的,因為在呼叫“測試狀態”方法和呼叫對應的“狀態相關”方法的時間間隔之中,物件的狀態有可能發生變化。如果單獨的“狀態測試”方法必須重複“狀態相關”方法的工作,從效能的角度考慮,就應該使用可被識別的返回值。如果所有其他方面都是等同的,那麼“狀態測試”方法則略猶豫可被識別的返回值。
總而言之,異常是為了在異常情況下使用而設計的。不要將它們用於普通的控制流,也不要編寫迫使他們這麼做的API。
對可恢復的情況使用受檢異常,對程式設計錯誤使用執行時異常
如果期望呼叫者能夠適當地恢復,對於這種情況就應該使用受檢的異常。通過丟擲受檢的異常,強迫呼叫者在一個catch子句中處理該異常,或者將它傳播出去。
用執行時異常來表明程式設計錯誤。你實現的所含有未受檢的丟擲結構都應該是RuntimeException的子類。
避免不必要地使用受檢的異常
受檢的異常是Java設計語言的一項很好的特性。與返回程式碼不同,他們強迫程式員處理異常的條件,大大增強了可靠性。也就是說,過分使用受檢的異常會使API使用起來非常不方便。如果方法丟擲一個或者多個受檢的異常,或者他必須宣告他丟擲這些異常,並讓他們傳播出去。無論哪種方法,都給程式設計師增添了不可忽視的負擔。
如果正確的使用API並不能組織這種異常條件的產生,並且一點產生異常,使用API的程式設計師可以立即採取有用的工作,這這哦那個負擔就被認為是正當的。除非這兩個條件都成立,否則更適合於使用未受檢的異常。
在實踐中,catch塊幾乎總是具有斷言失敗的特徵。異常受檢的本質並沒有為程式設計師提供任何好處,他反而需要付出努力,還使程式更為複雜。
被一個方法單獨丟擲的受檢異常,會給程式設計師帶來非常高的額外負擔。如果這個方法還有其他的受檢異常,他被呼叫的時候一定已經出現在一個try塊中,所以這個異常只需要另外一個catch塊。如果方法只丟擲單個受檢的異常,僅僅一個異常就會導致該方法不得不外於try塊中,在這些情況下,應該問自己,是否有別的途徑來避免使用受檢的異常。
“把受檢的異常程式設計未受檢的異常”的一種方法是,把這個丟擲異常的方法分成兩個方法,其中第一個方法返回一個boolean,表明是否應該丟擲異常。
優先使用標準的異常
專家級程式設計師與缺乏經驗的程式設計師一個最主要的區別在於,專家追求並且通常也能夠實現高度的程式碼重用。程式碼重用是值得提倡的,這是一條通用的規則,異常也不例外。Java平臺類庫提供了一組基本的未受檢的異常,他們滿足了絕大多數API的異常丟擲需要。
重用現有的異常有多方面的好處。其中最主要的好處是,他使你的API更加易於學習和使用,因為他與程式設計師已經熟悉的習慣用法是一致的。第二個好處是,對於用到這些API的程式而言,他們的可讀性會更好,因為他們不會出現很多程式員不熟悉的異常。最後(也是最不重要的)一點是,異常類越少,意味著記憶體印跡就越小,裝載這些類的時間開銷也越少。
異常 | 使用場合 |
---|---|
IllegalArgumentException | 非null的引數值不正確 |
IllegalStateException | 對於方法呼叫而言,物件狀態不合適 |
NullPointerException | 在禁止使用null的情況下引數值為null |
IndexOutOfBoundsException | 下標引數值越界 |
ConcurrentModificationException | 在禁止併發修改的情況下,檢測到物件的併發修改 |
UnsupportedOperationException | 物件不支援使用者請求的方法 |
丟擲與抽象相對應的異常
如果方法丟擲的異常與他所執行的任務沒有明顯的聯絡,這種情形將會使人不知所措。當方法傳遞由底層抽象丟擲的異常時,往往會發生這種情況。除了使人感到困惑之外,這也讓實現細節汙染了更高層的API。如果高層的實現在後續的發行版本中發生了變化,他所丟擲的異常也可能會跟著發生變化,從而潛在的破壞現有的客戶端程式。
為了避免這個問題,更高層的實現應該捕獲低層的異常,同時丟擲可以按照高層抽象進行解釋的異常。這種做法被稱為異常轉譯。
一種特殊的異常轉譯形式稱為異常鏈,如果低層的異常對於除錯導致高層異常的問題非常有幫助,使用異常鏈就很適合。低層的異常(原因)被傳到高層的異常,高層的異常提供訪問方法(Throwable.getCause)來獲得低層的異常。
高層異常的構造器將原因傳到支援連的超級構造器,因此他最終將被傳給Throwable的其中一個執行異常鏈的構造器。
大多數標準的異常都有支援鏈的構造器。對於沒有支援鏈的異常,可以利用Throwable的initCause方法設定原因。異常鏈不僅讓你可以通過程式(用getCause)訪問原因,它還可以將原因的堆疊軌跡繼承到更高層的異常中。
儘管異常轉移與不加選擇的從低層傳遞異常的做法相比有所改進,但是他也不能被濫用。如有可能,處理來自低層異常的最好做法是,在呼叫低層方法之前確保他們會成功執行,從而避免他們丟擲異常。有時候,可以在給低層傳遞引數之前,檢查更高層方法的引數的有效性,從而避免低層方法丟擲異常。
如果無法避免低層異常,次選方案是,讓更高層來悄悄地繞開這些異常,從而將高層方法的呼叫者與低層的問題隔離開來。在這種情況下,可以用某種適當的記錄機制(如java.util.logging)將異常記錄下來。這樣有助於管理員調查問題,同時又將客戶端程式碼和終端使用者與問題隔離開來。
總而言之,如果不能阻止或者處理來自更低層的異常,一般的做法是使用異常轉譯,除非低層方法碰巧可以保證他丟擲的所有異常對高層也適合才可以將異常從低層傳播到高層。異常鏈對高層和低層異常都提供了最佳的功能:他允許丟擲適當的高層異常,同時又能捕獲低層的原因進行失敗分析。
每個方法丟擲的異常都要有文件
始終要單獨地宣告受檢的異常,並且利用Javadoc的@throws標記,準確的記錄下丟擲每個異常的條件。如果一個方法可以丟擲多個異常類,則不要使用“快捷方式”宣告它會丟擲這些異常類的某個超類。永遠不要宣告一個方法“throws Exception”,或者更糟糕的是宣告它“throws Throwable”,因為它實際上掩蓋了該方法在同樣的執行環境下可能丟擲的任何其他異常。
對於方法可能丟擲的未受檢異常,如果將這些異常資訊很好地組織成列表文件,就可以有效地描述出這個方法被成功執行的前提條件(precondition)。每個方法的文件應該描述它的前提條件,這是很重要的,在文件中記錄下未受檢的異常就是滿足前提條件的最佳做法。
使用Javadoc的@throws便籤記錄下一個方法可能丟擲的每個未受檢異常,但是不要使用throws關鍵字講未受檢的異常包含在方法的宣告中。
如果一個類中的許多方法處於同樣的原因而丟擲同一個異常,在該類的文件註釋中對這個異常建立文旦,這是可以接受的,而不是為每個方法單獨建立文件。
總而言之,要為你變得每個方法所能丟擲的每個異常建立文件。對於未受檢和受檢的異常,以及對於抽象的和具體的方法也都一樣。要為每個受檢異常提供單獨的throws子句,不要為未受檢的異常提供throws子句。如果沒有為可以丟擲的異常建立文件,其他人就很難或者根本不可能有效地使用你的類和介面。
在細節訊息中包含能捕獲失敗的資訊
當程式由於未被捕獲的異常而失敗的時候,系統會自動地打印出該異常的堆疊軌跡。在堆疊軌跡中包含該異常的字串表示法(string representation),即它的toString方法的呼叫結果。它通常包含該異常的類名,緊隨其後的是細節訊息(detail message)。通常,這只是程式設計師或者域服務人員(field service personnel,指檢查軟體失敗的人)在調查軟體失敗原因時必須檢查的資訊。如果失敗的情形不容易重現,要想獲得更多的資訊會非常困難,甚至是不可能的。因此,異常型別的toString方法應該儘可能多地返回有關失敗原因的資訊,這一點特別重要。換句話說,異常的細節訊息應該捕獲住失敗,便於以後分析。
為了捕獲失敗,異常的細節資訊應該包含所有“對該異常有貢獻”的引數和域的值。例如,IndexOutOfBoundsException異常的細節訊息應該包含下界、上界以及沒有落在界內的下標值。該細節訊息提供了許多關於失敗的資訊。這三個值中任何一個或者全部都有可能是錯的。實標的下標值可能小於下界或等於上界(“越界錯誤”),或者它可能是個無效值,太小或太大。下界也有可能大於上界(嚴重違反內部約束條件的一種情況)。每一種情形都代表了不同的問題,如果程式設計師知道應該去查詢哪種錯誤,就可以極大地加速診斷過程。
雖然在異常的細節訊息中包含所有相關的“硬資料(hard data)”是非常重要的,但是包含大量的描述資訊往往沒有什麼意義。堆疊軌跡的用途是與原始檔結合起來進行分析,它通常包含丟擲該異常的確切檔案和行數,以及堆疊中所有其他方法呼叫所在的檔案和行數。關於失敗的冗長描述資訊通常是不必要的,這些資訊可以通過閱讀原始碼而獲得。
異常的細節訊息不應該與“使用者層次的錯誤訊息”混為一談,後者對於終端使用者而言必須是可理解的。與使用者層次的錯誤訊息不同,異常的字串表示法主要是讓程式設計師或者域服務人員用來分析失敗的原因。因此,資訊的內容比可理解性要重要得多。
為了確保在異常的細節訊息中包含足夠的能捕獲失敗的資訊,一種辦法是在異常的構造器而不是字串細節訊息中引入這些資訊。然後,有了這些資訊,只要把它們放到訊息描述中,就可以自動產生細節訊息。
努力使失敗保持原子性
這是我們需要遵守的規則:
- 失敗的方法呼叫應該使物件保持在被呼叫之前的狀態。
- 錯誤通常是不可恢復的,當方法丟擲錯誤時,不需要保持原子性。
- 作為方法規範的一部分,方法產生的任何異常都應該讓物件保持在該方法呼叫之前的狀態。如果違反這條規則,API文件就應該清楚地指明物件將會處於什麼樣的狀態。
失敗原子性實現方法:
-
物件為不可變物件,那麼物件創建出來就不能被修改了,也不需要維護。
-
在執行操作之前檢查引數的有效性。在物件狀態被修改之前,先丟擲異常。
-
調整計算處理的過程,使得任何可能會失敗的計算部分都在物件狀態被修改之前發生。
-
編寫恢復程式碼,由其攔截操作過程中發生的失敗,以及使物件回滾到操作開始之前的狀態上。這樣做並不提倡,因為錯誤程式碼編寫遇到複雜的場景會很繁瑣。
-
在物件的一份臨時拷貝上執行操作,操作完成後,在使用臨時拷貝的的結果代替物件的內容。也就是備份操作。
對於以上五種方法,我們更推薦前3種,我們應該先考慮防患於未然,才考慮如何錯誤恢復。對於大型專案,錯誤恢復也是可用性重要的戰術之一。
不要忽略異常
當API的設計者宣告一個方法將丟擲某個異常的時候,他們等於正在試圖說明某些事情。所以,請不要忽略他!要忽略一個異常非常容易,只需將方法呼叫通過try語句包圍起來,幷包含一個空的catch塊。
空的catch塊會使異常達不到應有的目的,即強迫你處理異常的情況。忽略異常就如同忽略火警訊號一樣——若把火警訊號器關掉了,當真正的火災發生時,就沒有人能看到火警訊號了。或許你會僥倖逃過劫難,或許結果將是災難性的。每當見到空的catch時,應該警鐘長鳴。至少,catch塊也應該包含一條說明,解釋為什麼可以忽略這個異常。
有一種情形可以忽略異常,即關閉FileInputStream的時候。因為你還沒有改變檔案的狀態,因此不必執行任何恢復動作,並且已經從檔案中讀取到所需要的資訊,因此不必終止正在進行的操作。即使在這種情況下,把異常記錄下來還是明智的做法,因為如果這些異常經常發生,你就可以調查異常的原因。
本文建議同樣適用於受檢異常和未受檢異常。不管異常代表了可預見的異常條件,還是程式設計錯誤,用空的catch塊忽略他。將會導致程式在遇到錯誤的情況下悄然的執行下去。然後,有可能在將來的某個點上,當程式不能再容忍與錯誤源明顯相關的問題時,他就會失敗。正確的處理異常能夠徹底挽回始變。只要將異常傳播給外界,至少會導致程式迅速的失敗,從而保留了有助於除錯該失敗條件的資訊。