關於在 Java 語言中使用異常的大多數建議都認為,在確信異常可以被捕獲 的任何情況下,應該優先使用檢查型異常。語言設計(編譯器強制您在方法簽名 中列出可能被拋出的所有檢查型異常)以及早期關於樣式和用法的著作都支持該 建議。最近,幾位著名的作者已經開始認為非檢查型異常在優秀的 Java 類設計 中有著比以前所認為的更為重要的地位。在本文中,Brian Goetz 考察了關於使 用非檢查型異常的優缺點。
與 C++ 類似,Java 語言也提供異常的拋出和捕獲。但是,與 C++ 不一樣的 是,Java 語言支持檢查型和非檢查型異常。Java 類必須在方法簽名中聲明它們 所拋出的任何檢查型異常,並且對於任何方法,如果它調用的方法拋出一個類型 為 E 的檢查型異常,那麼它必須捕獲 E 或者也聲明為拋出 E(或者 E 的一個 父類)。通過這種方式,該語言強制我們文檔化控制可能退出一個方法的所有預 期方式。
對於因為編程錯誤而導致的異常,或者是不能期望程序捕獲的異常(解除引 用一個空指針,數組越界,除零,等等),為了使開發人員免於處理這些異常, 一些異常被命名為非檢查型異常(即那些繼承自 RuntimeException 的異常)並 且不需要進行聲明。
傳統的觀點
在下面的來自 Sun 的“The Java Tutorial”的摘錄中,總結了關於將一個 異常聲明為檢查型還是非檢查型的傳統觀點(更多的信息請參閱 參考資料):
因為 Java 語言並不要求方法捕獲或者指定運行時異常,因此編寫只拋出運 行時異常的代碼或者使得他們的所有異常子類都繼承自 RuntimeException ,對 於程序員來說是有吸引力的。這些編程捷徑都允許程序員編寫 Java 代碼而不會 受到來自編譯器的所有挑剔性錯誤的干擾,並且不用去指定或者捕獲任何異常。 盡管對於程序員來說這似乎比較方便,但是它回避了 Java 的捕獲或者指定要求 的意圖,並且對於那些使用您提供的類的程序員可能會導致問題。
檢查型異常代表關於一個合法指定的請求的操作的有用信息,調用者可能已 經對該操作沒有控制,並且調用者需要得到有關的通知 —— 例如,文件系統已 滿,或者遠端已經關閉連接,或者訪問權限不允許該動作。
如果您僅僅是因為不想指定異常而拋出一個 RuntimeException ,或者創建 RuntimeException 的一個子類,那麼您換取到了什麼呢?您只是獲得了拋出一 個異常而不用您指定這樣做的能力。換句話說,這是一種用於避免文檔化方法所 能拋出的異常的方式。在什麼時候這是有益的?也就是說,在什麼時候避免注明 一個方法的行為是有益的?答案是“幾乎從不。”
換句話說,Sun 告訴我們檢查型異常應該是准則。該教程通過多種方式繼續 說明,通常應該拋出異常,而不是 RuntimeException —— 除非您是 JVM。
在 Effective Java: Programming Language Guide一書中(請參閱 參考資 料),Josh Bloch 提供了下列關於檢查型和非檢查型異常的知識點,這些與 “ The Java Tutorial” 中的建議相一致(但是並不完全嚴格一致):
第 39 條:只為異常條件使用異常。也就是說,不要為控制流使用異常,比 如,在調用 Iterator.next() 時而不是在第一次檢查 Iterator.hasNext() 時 捕獲 NoSuchElementException 。
第 40 條:為可恢復的條件使用檢查型異常,為編程錯誤使用運行時異常。 這裡,Bloch 回應傳統的 Sun 觀點 —— 運行時異常應該只是用於指示編程錯 誤,例如違反前置條件。
第 41 條:避免不必要的使用檢查型異常。換句話說,對於調用者不可能從 其中恢復的情形,或者惟一可以預見的響應將是程序退出,則不要使用檢查型異 常。
第 43 條:拋出與抽象相適應的異常。換句話說,一個方法所拋出的異常應 該在一個抽象層次上定義,該抽象層次與該方法做什麼相一致,而不一定與方法 的底層實現細節相一致。例如,一個從文件、數據庫或者 JNDI 裝載資源的方法 在不能找到資源時,應該拋出某種 ResourceNotFound 異常(通常使用異常鏈來 保存隱含的原因),而不是更底層的 IOException 、 SQLException 或者 NamingException 。
重新考察非檢查型異常的正統觀點
最近,幾位受尊敬的專家,包括 Bruce Eckel 和 Rod Johnson,已經公開聲 明盡管他們最初完全同意檢查型異常的正統觀點,但是他們已經認定排他性使用 檢查型異常的想法並沒有最初看起來那樣好,並且對於許多大型項目,檢查型異 常已經成為一個重要的問題來源。Eckel 提出了一個更為極端的觀點,建議所有 的異常應該是非檢查型的;Johnson 的觀點要保守一些,但是仍然暗示傳統的優 先選擇檢查型異常是過分的。(值得一提的是,C# 的設計師在語言設計中選擇 忽略檢查型異常,使得所有異常都是非檢查型的,因而幾乎可以肯定他們具有豐 富的 Java 技術使用經驗。但是,後來他們的確為檢查型異常的實現留出了空間 。)
對於檢查型異常的一些批評
Eckel 和 Johnson 都指出了一個關於檢查型異常的相似的問題清單;一些是 檢查型異常的內在屬性,一些是檢查型異常在 Java 語言中的特定實現的屬性, 還有一些只是簡單的觀察,主要是關於檢查型異常的廣泛的錯誤使用是如何變為 一個嚴重的問題,從而導致該機制可能需要被重新考慮。
檢查型異常不適當地暴露實現細節
您已經有多少次看見(或者編寫)一個拋出 SQLException 或者 IOException 的方法,即使它看起來與數據庫或者文件毫無關系呢?對於開發人 員來說,在一個方法的最初實現中總結出可能拋出的所有異常並且將它們增加到 方法的 throws 子句(許多 IDE 甚至幫助您執行該任務)是十分常見的。這種 直接方法的一個問題是它違反了 Bloch 的 第 43 條 —— 被拋出的異常所位於 的抽象層次與拋出它們的方法不一致。
一個用於裝載用戶概要的方法,在找不到用戶時應該拋出 NoSuchUserException ,而不是 SQLException —— 調用者可以很好地預料到 用戶可能找不到,但是不知道如何處理 SQLException 。異常鏈可以用於拋出一 個更為合適的異常而不用丟棄關於底層失敗的細節(例如棧跟蹤),允許抽象層 將位於它們之上的分層同位於它們之下的分層的細節隔離開來,同時保留對於調 試可能有用的信息。
據說,諸如 JDBC 包的設計采取這樣一種方式,使得它難以避免該問題。在 JDBC 接口中的每個方法都拋出 SQLException ,但是在訪問一個數據庫的過程 中可能會經歷多種不同類型的問題,並且不同的方法可能易受不同錯誤模式的影 響。一個 SQLException 可能指示一個系統級問題(不能連接到數據庫)、邏輯 問題(在結果集中沒有更多的行)或者特定數據的問題(您剛才試圖插入行的主 鍵已經存在或者違反實體完整性約束)。如果沒有犯不可原諒的嘗試分析消息正 文的過失,調用者是不可能區分這些不同類型的 SQLException 的。( SQLException 的確支持用於獲取數據庫特定錯誤代碼和 SQL 狀態變量的方法, 但是在實踐中這些很少用於區分不同的數據庫錯誤條件。)
不穩定的方法簽名
不穩定的方法簽名問題是與前面的問題相關的 —— 如果您只是通過一個方 法傳遞異常,那麼您不得不在每次改變方法的實現時改變它的方法簽名,以及改 變調用該方法的所有代碼。一旦類已經被部署到產品中,管理這些脆弱的方法簽 名就變成一個昂貴的任務。然而,該問題本質上是沒有遵循 Bloch 提出的第 43 條的另一個症狀。方法在遇到失敗時應該拋出一個異常,但是該異常應該反映該 方法做什麼,而不是它如何做。
有時,當程序員對因為實現的改變而導致從方法簽名中增加或者刪除異常感 到厭煩時,他們不是通過使用一個抽象來定義特定層次可能拋出的異常類型,而 只是將他們的所有方法都聲明為拋出 Exception 。換句話說,他們已經認定異 常只是導致煩惱,並且基本上將它們關閉掉了。毋庸多言,該方法對於絕大多數 可任意使用的代碼來說通常不是一個好的錯誤處理策略。
難以理解的代碼
因為許多方法都拋出一定數目的不同異常,錯誤處理的代碼相對於實際的功 能代碼的比率可能會偏高,使得難以找到一個方法中實際完成功能的代碼。異常 是通過集中錯誤處理來設想減小代碼的,但是一個具有三行代碼和六個 catch 塊(其中每個塊只是記錄異常或者包裝並重新拋出異常)的方法看起來比較膨脹 並且會使得本來簡單的代碼變得模糊。
異常淹沒
我們都看到過這樣的代碼,其中捕獲了一個異常,但是在 catch 塊中沒有代 碼。盡管這種編程實踐很明顯是不好的,但是很容易看出它是如何發生的 —— 在原型化期間,某人通過 try...catch 塊包裝代碼,而後來忘記返回並填充 catch 塊。盡管這個錯誤很常見,但是這也是更好的工具可以幫助我們的地方之 一 —— 對於異常淹沒的地方,通過編輯器、編譯器或者靜態檢查工具可以容易 地檢測並發出警告。
極度通用的 try...catch 塊是另一種形式的異常淹沒,並且更加難以檢測, 因為這是 Java 類庫中的異常類層次的結構而導致的(可疑)。讓我們假定一個 方法拋出四個不同類型的異常,並且調用者遇到其中任何一個異常都將捕獲、記 錄它們,並且返回。實現該策略的一種方式是使用一個帶有四個 catch 子句的 try...catch 塊,其中每個異常類型一個。為了避免代碼難以理解的問題,一些 開發人員將重構該代碼,如清單 1 所示:
清單 1. 意外地淹沒 RuntimeException
try {
doSomething();
}
catch (Exception e) {
log(e);
}
盡管該代碼與四個 catch 塊相比更為緊湊,但是它具有一個問題 —— 它還 捕獲可能由 doSomething 拋出的任何 RuntimeException 並且阻止它們進行擴 散。
過多的異常包裝
如果異常是在一個底層的設施中生成的,並且通過許多代碼層向上擴散,在 最終被處理之前它可能被捕獲、包裝和重新拋出若干次。當異常最終被記錄的時 候,棧跟蹤可能有許多頁,因為棧跟蹤可能被復制多次,其中每個包裝層一次。 (在 JDK 1.4 以及後來的版本中,異常鏈的實現在某種程度上緩解了該問題。 )
替換的方法
Bruce Eckel, Thinking in Java(請參閱 參考資料)的作者,聲稱在使用 Java 語言多年後,他已經得出這樣的結論,認為檢查型異常是一個錯誤 —— 一個應該被聲明為失敗的試驗。Eckel 提倡將所有的異常都作為非檢查型的,並 且提供清單 2 中的類作為將檢查型異常轉變為非檢查型異常的一個方法,同時 保留當異常從棧向上擴散時捕獲特定類型的異常的能力(關於如何使用該方法的 解釋,請參閱他在 參考資料小節中的文章):
清單 2. Eckel 的異常適配器類
class ExceptionAdapter extends RuntimeException {
private final String stackTrace;
public Exception originalException;
public ExceptionAdapter(Exception e) {
super(e.toString());
originalException = e;
StringWriter sw = new StringWriter();
e.printStackTrace(new PrintWriter(sw));
stackTrace = sw.toString();
}
public void printStackTrace() {
printStackTrace(System.err);
}
public void printStackTrace(java.io.PrintStream s) {
synchronized(s) {
s.print(getClass().getName() + ": ");
s.print(stackTrace);
}
}
public void printStackTrace(java.io.PrintWriter s) {
synchronized(s) {
s.print(getClass().getName() + ": ");
s.print(stackTrace);
}
}
public void rethrow() { throw originalException; }
}
如果查看 Eckel 的 Web 站點上的討論,您將會發現回應者是嚴重分裂的。 一些人認為他的提議是荒謬的;一些人認為這是一個重要的思想。(我的觀點是 ,盡管恰當地使用異常確實是很難的,並且對異常用不好的例子大量存在,但是 大多數贊同他的人是因為錯誤的原因才這樣做的,這與一個政客位於一個可以隨 便獲取巧克力的平台上參選將會獲得十歲孩子的大量選票的情況具有相似之處。 )
Rod Johnson 是 J2EE Design and Development(請參閱 參考資料) 的作 者,這是我所讀過的關於 Java 開發,J2EE 等方面的最好的書籍之一。他采取 一個不太激進的方法。他列舉了異常的多個類別,並且為每個類別確定一個策略 。一些異常本質上是次要的返回代碼(它通常指示違反業務規則),而一些異常 則是“發生某種可怕錯誤”(例如數據庫連接失敗)的變種。Johnson 提倡對於 第一種類別的異常(可選的返回代碼)使用檢查型異常,而對於後者使用運行時 異常。在“發生某種可怕錯誤”的類別中,其動機是簡單地認識到沒有調用者能 夠有效地處理該異常,因此它也可能以各種方式沿著棧向上擴散而對於中間代碼 的影響保持最小(並且最小化異常淹沒的可能性)。
Johnson 還列舉了一個中間情形,對此他提出一個問題,“只是少數調用者 希望處理問題嗎?”對於這些情形,他也建議使用非檢查型異常。作為該類別的 一個例子,他列舉了 JDO 異常 —— 大多數情況下,JDO 異常表示的情況是調 用者不希望處理的,但是在某些情況下,捕獲和處理特定類型的異常是有用的。 他建議在這裡使用非檢查型異常,而不是讓其余的使用 JDO 的類通過捕獲和重 新拋出這些異常的形式來彌補這個可能性。
使用非檢查型異常
關於是否使用非檢查型異常的決定是復雜的,並且很顯然沒有明顯的答案。 Sun 的建議是對於任何情況使用它們,而 C# 方法(也就是 Eckel 和其他人所 贊同的)是對於任何情況都不使用它們。其他人說,“還存在一個中間情形。”
通過在 C++ 中使用異常,其中所有的異常都是非檢查型的,我已經發現非檢 查型異常的最大風險之一就是它並沒有按照檢查型異常采用的方式那樣自我文檔 化。除非 API 的創建者明確地文檔化將要拋出的異常,否則調用者沒有辦法知 道在他們的代碼中將要捕獲的異常是什麼。不幸的是,我的經驗是大多數 C++ API 的文檔化非常差,並且即使文檔化很好的 API 也缺乏關於從一個給定方法 可能拋出的異常的足夠信息。我看不出有任何理由可以說該問題對於 Java 類庫 不是同樣的常見,因為 Jav 類庫嚴重依賴於非檢查型異常。依賴於您自己的或 者您的合作伙伴的編程技巧是非常困難的;如果不得不依賴於某個人的文檔化技 巧,那麼對於他的代碼您可能得使用調用棧中的十六個幀來作為您的主要的錯誤 處理機制,這將會是令人恐慌的。
文檔化問題進一步強調為什麼懶惰是導致選擇使用非檢查型異常的一個不好 的原因,因為對於文檔化增加給包的負擔,使用非檢查型異常應該比使用檢查型 異常甚至更高(當文檔化您所拋出的非檢查型異常比檢查型異常變得更為重要的 時候)。
文檔化,文檔化,文檔化
如果決定使用非檢查型異常,您需要徹底地文檔化這個選擇,包括在 Javadoc 中文檔化一個方法可能拋出的所有非檢查型異常。Johnson 建議在每個 包的基礎上選擇檢查型和非檢查型異常。使用非檢查型異常時還要記住,即使您 並不捕獲任何異常,也可能需要使用 try...finally 塊,從而可以執行清除動 作例如關閉數據庫連接。對於檢查型異常,我們有 try...catch 用來提示增加 一個 finally 子句。對於非檢查型異常,我們則沒有這個支撐可以依靠。