關於在 Java 語言中使用異常的大多數建議都認為,在確信異常可以被捕獲的任何情況下,應該優先使用檢查型異常。語言設計(編譯器強制您在方法簽名中列出可能被拋出的所有檢查型異常)以及早期關於樣式和用法的著作都支援該建議。最近,幾位著名的作者已經開始認為非檢查型異常在優秀的 Java 類設計中有著比以前所認為的更為重要的地位。在本文中,Brian Goetz 考察了關於使用非檢查型異常的優缺點。
??與 C++ 類似,Java 語言也提供異常的拋出和捕獲。但是,與 C++ 不一樣的是,Java 語言支援檢查型和非檢查型異常。Java 類必須在方法簽名中聲明它們所拋出的任何檢查型異常,並且對於任何方法,如果它調用的方法拋出一個類型為 E 的檢查型異常,那麼它必須捕獲 E 或者也聲明為拋出 E(或者 E 的一個父類)。通過這種方式,該語言強制我們文檔化控制可能退出一個方法的所有預期方式。
??對於因為編程錯誤而導致的異常,或者是不能期望程式捕獲的異常(解除引用一個null 指標,數組越界,除零,等等),為了使開發人員免於處理這些異常,一些異常被命名為非檢查型異常(即那些繼承自 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 子句。對於非檢查型異常,我們則沒有這個支撐可以依靠。