第69條 只針對異常的情況才使用異常
69.1 使用異常完成流程控制的問題
代碼
try {
int i = 0 ;
while ( true )
range[ i++ ] . climb ( ) ;
} catch ( ArrayIndexOutOfBoundsException e) {
}
for ( Mountain m : range)
m. climb ( ) ;
基於異常的數組循環的問題
異常設計的初衷是用於不正常的情況,所以幾乎沒有JVM實現會對它們進行優化
將代碼放在try-catch塊中反而會抑制JVM實現中,可能執行的某些優化
對數組進行遍歷的標準寫法,其實並不會導致冗餘的檢查,很多JVM實現會對這方面進行優化
在作者的機器上,基於異常的寫法要比標準的寫法,慢上兩倍
基於異常的寫法模糊了代碼的意圖、降低代碼性能、執行可能會出現問題,例如如果climb方法中也會產生數組越界異常,標準寫法下,會直接拋出異常,打印棧信息,但在基於異常的寫發下,這個拋出的數組越界,會被系統當做是循環range結束,然後代碼繼續正常執行
69.2 API設計
設計良好的API不應該導致它的客戶端只能通過使用異常來完成流程控制
"狀態相關"方法:只有在特定的不可預知的狀態下才可以被調用的方法
"狀態測試"方法:檢測當前狀態是否可以執行"狀態相關"方法
類具有狀態相關方法,就應該同時具備狀態測試方法,不然可以想象,客戶端只能通過狀態相關方法產生的異常,才能判斷出其狀態變化,控制器執行狀態變化後的代碼
例如Iterator接口,next爲狀態相關方法,hasNext爲狀態測試方法
for ( Iterator< Foo> i = collection. iterator ( ) ; i. hasNext ( ) ; ) {
Foo foo = i. next ( ) ;
. . .
}
try {
Iterator< Foo> i = collection. iterator ( ) ;
while ( true ) {
Foo foo = i. next ( ) ;
. . .
}
} catch ( NoSuchElementException e) {
}
可以不使用狀態測試方法,修改將狀態相關方法,不再拋出異常,而是在特定狀態下,返回一個空的Optional或返回一個可識別的返回值,比如null
如果調用狀態相關方法的對象,(比如上面Iterator類型的i)將在缺少外部同步的情況下被併發訪問,或其狀態可被外界所改變時,就應該使用Optional或返回可識別返回值null,這兩種方式,因爲在狀態訪問和狀態測試方法之間,對象狀態有可能被改變
狀態測試方法比Optional或返回可識別返回值,可讀性更強,對於使用不當的情形可能更加易於檢測和改正
如果忘了調用狀態測試方法,狀態相關方法會拋出異常
如果忘了檢查可識別的返回值,Bug就很難被發現
Optional要求必須檢查,並提供爲空時的默認值,所以也可以對不當情形檢測和改正
69.3 最佳實踐
不要使用異常來完成異常控制
也不要編寫那種導致客戶端,必須使用異常來完成流程控制的API
第70條 對可恢復的情況使用受檢異常,對編程錯誤使用運行時異常
70.1 Throwable的幾種類型
java提供了三種可拋出的結構,受檢異常(checked exception),運行時異常(run-time exception)、錯誤(error)。error和run-time exception,一起稱爲非受檢異常
如果期望調用者能夠適當的恢復,使用受檢異常
受檢異常,必須try-catch,或者方法聲明中,將其拋出,它強制用戶從這個異常中恢復
受檢異常,表示告訴API用戶,與這個拋出的異常相關聯的條件,是調用這個方法的一個可能結果
用運行時異常表明編程錯誤
運行時異常,表示違反了該方法的前提條件,例如數組訪問約定指明數組的下標必須在0和長度-1之間,ArrayIndexOutOfBoundsException就表示客戶違反了這個前提條件
有時一個異常情況,是應該被作爲受檢異常(需要其恢復),還是應被當做編程錯誤並不是很清晰,需要API設計者人爲判斷,如果認爲這種情況可以恢復,就用受檢異常,如果覺得不應被恢復,就使用運行時異常,如果實在判斷不了,使用未受檢異常,原因參考item 71
按照慣例,錯誤(error)往往被JVM本身使用,用於表明資源不足、約束失敗,或者其他使程序無法繼續執行的條件,所以自己寫代碼定義非受檢異常時,不要使用Error。也就是說自己定義的所有非受檢異常,都應該是RuntimeException的子類,不應該定義Error子類,也不應該拋出AssertError異常
雖然可以直接通過繼承Throwable來定義一個可拋出結構(throwable),但這種方式定義出來的可拋出結構行爲等同於受檢異常,因此永遠不要這樣做,這樣做只會讓使用這個API的用戶迷惑
異常中可以定義一些方法,這些方法的主要用途是獲取引發這個異常的條件信息(原因),如果沒有這些方法,就需要自己解析"該異常的字符串表示法",也就是異常對象的toString返回的字符串,但對於不同版本、不同實現下的字符串表示法都大相徑庭,自己編寫對其進行解析的代碼,移植性非常差,而且非常脆弱
對於受檢異常,一般都想恢復,所以這種輔助方法尤其重要,通過這些方法,調用者可以獲得一些有助於恢復的信息
例如假設用戶資金不足導致購買商品失敗,拋出一個受檢異常,那麼這個異常對象應該提供一個輔助方法,返回客戶差的金額,客戶端就可以方便地使用這個方法,更多用法參考item 75
異常的字符串表示法:就是這個異常對象的toString方法的返回值,比如對於java.lang.ArrayIndexOutOfBoundsException: 1,你需要自己寫方法,解釋這個字符串,“數組越界了,使用了下標爲1的元素”
70.2 最佳實踐
可恢復的情況,拋出受檢異常,對於編程錯誤拋出運行時異常,不確定是否可以恢復,拋出未受檢異常
不要定義既不是受檢異常,又不是運行時異常的Throwable子類
在受檢異常上應該提供輔助方法,方便其恢復
第71條 避免不必要地使用受檢異常
71.1 受檢異常的問題
受檢異常強迫程序員必須try-catch,或向上throw這種異常,提升了程序可靠性,但過分使用異常,會使API使用起來非常不方便
java8這個版本中,Stream中想拋出受檢異常非常麻煩
public static void main ( String[ ] args) throws IOException{
Stream. of ( "a" , "b" , "c" ) . forEach ( str - > {
throw new IOException ( ) ;
} ) ;
}
public static void main ( String[ ] args) {
Stream. of ( "a" , "b" , "c" ) . forEach ( str - > {
try {
throw new IOException ( ) ;
} catch ( Exception e) {
;
}
} ) ;
}
public static void main ( String[ ] args) throws IOException{
Stream. of ( "a" , "b" , "c" ) . forEach ( str - > {
throw new RuntimeException ( new IOException ( ) ) ;
} ) ;
}
當客戶端按要求使用API,卻還是能夠產生某種異常,或者某種異常一旦產生,希望被使用API的程序員人工干預如何處理時,這個異常就應該使用受檢異常。如果以上兩點都不符合,就可以使用非受檢異常
這裏作者說拋出一個受檢異常比拋出兩個更麻煩,意思其實是,如果原來有受檢異常,那麼多拋一個,不會造成太大的編程負擔,加個catch塊就行了,但如果原來一個沒有,突然增加一個受檢異常,這種會導致客戶端編程負擔變化更大。而不是說兩個受檢異常比一個受檢異常好
71.2 消除受檢異常
方法一:使用返回一個空的Optional來替代拋出受檢異常,但這種返回空Optional的方法,沒辦法返回該方法無法正確執行任務的原因。反觀拋出受檢異常的方法,不僅你可以通過拋出的異常類型來判斷方法無法正確執行的原因,又可以利用異常的一些輔助方法,來得到一些額外信息(item 70)
方法二:將受檢異常轉爲非受檢異常,這種方式不一定總適合,但這樣做會使API更容易使用。這種做法類似於item 69中提出的狀態測試方法
將拋出受檢異常的方法分成兩個方法
一個返回boolean值,表示是否應該拋出該異常
try {
obj. action ( args) ;
} catch ( TheCheckedException e) {
. . .
}
if ( obj. actionPermitted ( args) ) {
obj. action ( args) ;
} else {
. . .
}
obj. action ( args) ;
71.3 最佳實踐
少量、謹慎地使用受檢異常,可以提升程序的可靠性,過度使用會使API難以使用
如果調用者壓根沒法讓客戶端從調用的API產生的異常中恢復,API設計時,就不應該拋出受檢異常,而是應產生非受檢異常
即使可以恢復,而且你又希望客戶端強制對其恢復,API也應該首選編寫返回空Optional的方法,而不是拋出受檢異常的方法
只有在客戶端需要從失敗的API中獲取足夠的信息時(例如失敗原因等),您才應該拋出一個checked異常
第72條 優先使用標準的異常
72.1 標準的異常分類
專家級程序員與缺乏經驗的程序員最主要區別在於專家通常追求,而且能夠實現高度的代碼重用,代碼重用是很重要的,異常自然也應該重用
重用異常使API更容易學習和使用,因爲大家都知道這個異常什麼意思
對於用到這些API的程序,可讀性更強,因爲不會出現很多程序員不熟悉的異常
異常類越少,佔用內存越少,加載異常類的開銷越少(這條不重要,省不了多少內存)
IllegalArgumentException:調用者傳遞的參數不適合時,拋出該異常。比如一個參數代表"某個動作重複次數",程序員如果給這個參數傳遞了一個負數,就應該拋出該異常
IllegalStateException:調用該方法的對象的狀態不正確時,拋出該異常。例如某對象正確初始化之前就調用其方法A,那麼方法A就應該拋出該異常
其實所有錯誤的方法調用,都可以歸結爲IllegalArgument(非法參數)或IllegalState(非法狀態),其他一些標準異常,一般都表示某種特定情況下的非法參數或非法狀態
特定情況的非法參數
NullPointerException:爲某個不允許爲空的參數值,傳遞null值時拋出
IndexOutOfBoundsException:爲序列的下標的參數值傳遞了越界的值
特定情況的非法狀態
ConcurrentModificationException:專門設計用於單線程的類的對象,如果被檢測到,它被併發的修改,就應該拋出該異常。這個異常一般不會被真正拋出,它一般都是作爲一個提示,因爲不太可能可靠地檢測到併發的修改
UnsupportedOperationException:如果對象不支持所請求的操作,拋出這個異常。很少使用,因爲很少會出現一個對象可以調用某個方法, 而這個對象又不支持這個方法的
一般用於反射時沒有對應的方法
或類沒有實現由它們實現的接口所定義的所有可選操作。例如自定義一個只能添加元素的List實現,那麼其remove方法,就應該拋出這個異常
public class MyList extends ArrayList {
@Override
public Object remove ( int index) {
throw new UnsupportedOperationException ( ) ;
}
}
不要直接重用(使用)Exception,RuntimeException,Throwable,或Error。對待這些異常應該就像對待抽象類一樣
其他的一些異常,比如關於算數的ArithmeticException和NumberFormatException,但要注意,選擇重用異常時,這個異常的文檔介紹應該符合你的要求,而不是簡單的看這個異常的字面意思,覺得符合,就去選擇它
如果想讓返回的異常對象,可以新增方法來提供更多細節,可以新建一個異常類,這個類繼承標準異常類,但異常是可序列化的,所以如果沒有非常正當的理由,不要自己編寫異常類(第12章詳細講述了原因)
選擇重用哪種異常並不總是非常明確,比如一個紙牌對象,有一個發牌方法,該方法有一個參數表示本次發的牌的數量,如果調用者爲這個參數傳遞的值,大於剩餘紙牌數,既可以認爲是參數值過大(參數的問題),從而拋出IllegalArgumentException,也可以認爲是紙牌對象剩餘的牌太少(對象的問題),從而拋出IllegalStateException。一般這種情況,如果無論爲這個方法傳遞什麼參數,都沒發讓這個方法正確工作,就選擇拋出IllegalStateException,否則拋出IllegalArgumentException
第73條 拋出與抽象對應的異常
73.1 異常轉譯
如果方法拋出的異常,與它所執行的任務沒有明顯的聯繫,這種情形會讓人不知所措。
當一個低層的方法拋出的異常,傳遞給了高層的方法,然後又通過這個高層的方法拋出時,就會出現這種情況,這種情況不但使人困惑,而且會導致外層API被污染,可以想象客戶端會try-catch這些低層的方法所拋出的異常,但一旦高層的API在後續發行版本中改變了,拋出了新的種類的異常,那麼客戶端代碼也要進行修改
爲了避免這種問題,高層的方法,應該捕獲底層方法拋出的異常,並重新拋出一個客戶端可以理解的,高層方法應拋出的異常,這種做法稱爲異常轉譯(exception translation)
try {
lowerFunction ( ) ;
} catch ( LowerLevelException e) {
throw new HigherLevelException ( . . . ) ;
}
public E get ( int index) {
try {
return listIterator ( index) . next ( ) ;
} catch ( NoSuchElementException exc) {
throw new IndexOutOfBoundsException ( "Index: " + index) ;
}
}
73.2 異常鏈
如果高層方法中,需要獲取低層方法中所拋出的異常對象,比如低層拋出的異常對調試導致高層異常的問題非常有幫助的情況,可以使用異常鏈來處理。高層方法拋出的異常的getCause方法(這個方法其實來自Throwable),就可以獲取到底層的異常對象
高層方法拋出的異常類的定義
class HigherLevelException extends Exception {
HigherLevelException ( Throwable cause) {
super ( cause) ;
}
}
異常鏈的創建
try {
lowerFunction ( ) ;
} catch ( LowerLevelException cause) {
throw new HigherLevelException ( cause) ;
}
item 72中介紹的那些標準的異常一般都有chaining-aware構造器,少數那些沒有的,你可以通過調用Throwable的initCause方法,將低層方法的異常對象傳遞給高層方法的異常對象,從而完成異常鏈
異常鏈不僅可以讓你通過程序來獲取低層方法拋出的異常,也可以將低層方法的異常的異常棧信息,集成到高層方法的異常的異常棧中
雖然異常轉譯比起無腦地將低層方法的異常傳播出去,要好的多,但也不能濫用。
如果有可能,處理來自低層方法的異常的最好的做法,是調用低層方法前確保他們會執行成功,從而避免它們拋出異常。例如可以在給低層方法傳遞參數前,檢查更高層方法的參數有效性,從而避免低層方法拋出異常(這樣就不用對低層方法進行try-catch了,自然也不用向外拋了)
如果確實無法阻止低層方法拋出異常,儘量在高層代碼中就對這個異常進行處理(try-catch捕獲後恢復),並用日誌記錄下來,這樣有助於管理員調查問題,同時將客戶端代碼與這個異常進行隔離
73.3 最佳實踐
如果不能阻止或處理來自底層級方法的異常,一般做法是使用轉譯
只有在低層方法的規範碰巧可以保證"它所拋出的所有異常對於更高層也是合適的"情況下,纔可以將異常低層傳播到高層
異常鏈可以拋出適當的高層方法合適的異常的前提下,又允許客戶端捕獲低層方法拋出的異常,從而進行失敗分析
第74條 每個方法拋出的所有異常都要記入文檔
74.1 將異常信息記入文檔
始終要單獨地聲明受檢異常,即不能將兩個受檢異常合併成一個大異常聲明,並且利用Javadoc的@throws標籤,準確地記錄下引發每個受檢異常的所有條件
直接聲明拋出一個大異常例如throws Exception或throws Throwable,會掩蓋該方法可能拋出的具體異常種類,導致客戶不知道如何使用它,除了main方法外,不要這樣做,main方法不受這個限制是因爲main方法不會被客戶端調用,只會被虛擬機本身調用
java語言並沒有要求對未受檢的異常進行聲明,但和受檢異常一樣,我們都應該對他們建立文檔
未受檢異常通常代表編程上的錯誤(item 70),在文檔中寫明這些錯誤的前提條件,就可以讓其他程序員瞭解這些錯誤,從而幫助他們避免犯類似錯誤
將方法可能拋出的所有未受檢異常組織成一個好的文檔,就可以有效地描述該方法能夠執行成功的所有前提條件
對於接口中的方法,在文檔中記錄下它可能拋出的未受檢異常尤其重要,因爲這個文檔就相當於該接口的通用約定的一部分,也就是說該方法的具體實現應該能且只能拋出該未受檢異常
不要使用throws關鍵字將未受檢的異常包含在方法聲明中,因爲你的API使用者對方法拋出的受檢異常與非受檢異常的處理是完全不同的,所以方法的聲明,必須讓使用者一眼就分辨出哪些是該方法能拋出的受檢異常,哪些是非受檢異常。程序員一般認爲那些在@throws標籤中存在,而throws聲明中不存在的異常就是非受檢異常,如果在throws中聲明非受檢異常,會讓人誤會這是個受檢異常
在實踐中,可能無法做到爲一個方法的所有非受檢異常都建立文檔,因爲類一中的方法調用了類二中的方法,類一的編寫者爲其所有可能拋出的非受檢異常都建立了文檔,但一旦此時類二中方法的實現發生了變化,拋出了新的非受檢異常,這時類一也會拋出這個新的非受檢異常,但文檔卻沒有改變
如果一個類中很多方法,處於同一個原因,拋出同一種異常,可以在類級文檔註釋中,對其進行註釋,而不需要在方法級文檔註釋中,對其註釋
74.2 最佳實踐
爲編寫的每個方法所能拋出的每個異常都建立文檔,無論是抽象方法還是具體方法,無論是受檢異常還是非受檢異常
異常的註釋,在文檔中應該使用@throws標籤完成
要在方法的throws子句中,爲每個受檢異常提供單獨的聲明,不能合成一個大異常來聲明
不要通過throws子句聲明未受檢異常
如果沒有爲可拋出的異常建立文檔,API的使用者就很難或根本無法有效地使用你開發的API
第75條 在細節消息中包含失敗-捕獲信息
字符串表示法(string representation):就是異常對象的toString方法返回的字符串
細節消息(detail message):字符串表示法中,先是該異常的類名,緊隨其後的就是細節消息
失敗-捕獲信息(failure-capture information):異常對象收集(capture)到的有關失敗原因(failure)的信息
75.1 在細節消息中包含失敗-捕獲信息的總體原則
細節消息是程序員在調查程序失敗原因時,一定會檢查的消息
如果失敗的情形不容易重現,想要獲得更多失敗-捕獲信息就非常困難
因此應該在細節消息中包含儘可能多的失敗-捕獲信息
爲了捕獲失敗的信息,細節消息中應該包含所有引起這個異常的參數和屬性值,例如IndexOutOfBoundsException的細節消息中,應該包含下界、上界、沒有落在界內的下標值。因爲這三個值任何一個有問題都可能是引發這個異常的原因,如果下標沒在界內,那就是下標的問題,如果是下界比上界都要大,那就是界限自身的問題,在細節消息中記錄這些值,就能讓程序員快速定位錯誤原因,加快診斷過程
不要在細節消息中包含密碼、密鑰等私密信息,因爲這些會打印到堆棧軌跡中,而很多人都能看到這些堆棧軌跡
異常的細節消息不應該與用戶層級的錯誤消息混爲一談,細節消息是給程序員分析失敗原因用的,用戶層級錯誤消息是給用戶提示錯誤原因的,也就是說用戶必須能看懂。所以細節消息的內容比可讀性重要,而用戶層級錯誤消息可讀性要比內容重要
爲了確保細節消息中包含足夠的失敗-捕捉信息,可以使用所有需要的信息,建立一個異常的構造器,使用這個構造器來創建異常,就能保證所有需要信息都被傳入到異常
public IndexOutOfBoundsException ( int lowerBound, int upperBound, int index) {
super ( String. format ( "Lower bound: %d, Upper bound: %d, Index: %d" , lowerBound, upperBound, index) ) ;
this . lowerBound = lowerBound;
this . upperBound = upperBound;
this . index = index;
}
java9新提供了一個包含一個類型爲int的index參數的構造器,但沒要求該構造器傳入lowerBound和upperBound,Java平臺類並沒有廣泛使用這種做法, 但這種做法仍然值得大力推薦,這樣做可以令程序員很容易的進行失敗-捕獲。而且這種用法將代碼集中在異常類中以生成高質量的詳細消息,而不是要求類的每個用戶都編寫代碼生成詳細消息
應該爲異常提供可以獲取其失敗-捕獲信息的輔助方法(item 70),爲受檢的異常提供這些輔助方法尤其重要,因爲失敗-捕獲信息對於從失敗中恢復非常有用。雖然程序員很少需要在程序中通過輔助方法來查看非受檢方法中的細節,但根據一般原則爲非受檢方法提供這些輔助方法也是明智的
第76條 努力使失敗保持原子性
失敗原子性:失敗的方法調用,能夠使調用它的對象仍然保持在調用該方法之前的狀態,就說該方法具有失敗原子性
76.1 令方法具有失敗原子性的做法
調用方法的對象本身不可變,那麼其方法一定具有失敗原子性,因爲不可變對象的狀態根本無法改變
對於可變對象上的方法,可以在執行方法中的改變對象狀態的操作前,檢查參數有效性,發現無效的參數就拋出異常,從而獲得失敗原子性
調整方法內部的處理順序,將使得任何可能會失敗的計算都發生在對象狀態被修改之前,這種做法和上面的類似。例如爲TreeMap對象添加元素時,會先檢查加入的元素是否可以與其內其他元素進行比較,如果不能就拋出ClassCastException,檢查成功後,纔會真正修改TreeMap對象的內部狀態
public Object pop ( ) {
if ( size == 0 )
throw new EmptyStackException ( ) ;
Object result = elements[ -- size] ;
elements[ size] = null;
return result;
}
在調用方法的對象的一份臨時拷貝上,進行狀態的修改,當操作完全成功後,再用拷貝對象,來替代原對象並返回。有些排序函數會在執行排序前,先把傳入它的list放入到一個數組內,以便降低在排序的內循環中訪問元素所需要的的開銷,本身這樣做是處於性能考慮,但其實同時帶來了另一個好處,就是即使排序失敗,也能保持傳入的list對象保持原狀態
最不常用的方法:編寫一段恢復代碼(recovery code),由它攔截操作過程中發生的失敗,並將對象回滾到整個操作發生前的那個狀態。這種方法一般用於恢復持久化(基於磁盤)的數據結構
76.2 無需保證失敗原子性的情況
無法保證失敗原子性
多個線程在沒有適當同步機制情況下,併發修改某個對象,這個對象就可能處於一個相互矛盾的狀態,所以說假設一個方法拋出了ConcurrentModificationException,我們還假設其內修改的對象仍然是可用的,這本身就不正確,因此也就談不上保證失敗原子性
Errors本身就是不可恢復的,所以當方法拋出一個AssertionError時,你也不應該嘗試恢復調用該方法的對象的狀態
能保證失敗原子性,但不希望這樣做
爲了達成失敗原子性會大大地增加性能開銷和編程複雜度
76.3 最佳實踐
保證失敗原子性,應該當成是方法的規則一樣來遵守
如果方法沒能做到失敗原子性,API文檔就應該清楚地指明一旦發生異常,調用該方法的對象會處於什麼狀態
大量現有的API都沒能做到這一點
第77條 不要忽略異常
77.1 忽略異常
忽略異常:catch捕獲到異常後,內部什麼都不處理即可
try {
. . .
} catch ( SomeException e) {
}
77.2 允許忽略異常的情況
關閉FileInputStream時拋出的異常可以忽略,由於你並沒有修改文件的狀態(不是FileOutputStream),所以不必執行任何恢復動作,同時由於你已經從文件中讀取到了所需信息,因此也不必終止當前的操作,既不必恢復,也不必終止,因此可以選擇忽略這個異常
即使可以忽略異常,也還是應該把異常記錄下來,因爲如果這個異常經常放生,記錄該異常可以方便後續的原因調查
忽略異常時,catch塊中應該包含一條註釋,說明爲什麼可以這樣做,並且將異常變量命名爲ignored
Future< Integer> f = exec. submit ( planarMap: : chromaticNumber) ;
int numColors = 4 ;
try {
numColors = f. get ( 1 L, TimeUnit. SECONDS) ;
} catch ( TimeoutException | ExecutionException ignored) {
}
77.3 最佳實踐
無論受檢異常還是非受檢異常,都不應該用空的catch塊來忽略它
忽略異常會導致程序在遇到錯誤後,仍然悄然地執行,然後可能在將來的某個點上,當程序再不能容忍上面那個錯誤的時候,執行失敗,此時難以查找問題原因
正確地處理異常可以避免失敗,即使無法處理,至少將異常直接拋出,這樣程序可以立即失敗,同時又可以保存信息以幫助調試故障