關(guān)于在 Java 語言中使用異常的大多數(shù)建議都認(rèn)為,在確信異常可以被捕獲的任何情況下,應(yīng)該優(yōu)先使用檢查型異常。語言設(shè)計(jì)(編譯器強(qiáng)制您在方法簽名中列出可能被拋出的所有檢查型異常)以及早期關(guān)于樣式和用法的著作都支持該建議。最近,幾位著名的作者已經(jīng)開始認(rèn)為非檢查型異常在優(yōu)秀的 Java 類設(shè)計(jì)中有著比以前所認(rèn)為的更為重要的地位。在本文中,Brian Goetz 考察了關(guān)于使用非檢查型異常的優(yōu)缺點(diǎn)。請(qǐng)?jiān)诟綆У挠懻撜搲信c作者和其他讀者一起分享您有關(guān)本文的心得體會(huì)(您也可以點(diǎn)擊文章頂部或底部的 討論來訪問該論壇。)
與 C++ 類似,Java 語言也提供異常的拋出和捕獲。但是,與 C++ 不一樣的是,Java 語言支持檢查型和非檢查型異常。Java 類必須在方法簽名中聲明它們所拋出的任何檢查型異常,并且對(duì)于任何方法,如果它調(diào)用的方法拋出一個(gè)類型為 E 的檢查型異常,那么它必須捕獲 E 或者也聲明為拋出 E(或者 E 的一個(gè)父類)。通過這種方式,該語言強(qiáng)制我們文檔化控制可能退出一個(gè)方法的所有預(yù)期方式。
對(duì)于因?yàn)榫幊体e(cuò)誤而導(dǎo)致的異常,或者是不能期望程序捕獲的異常(解除引用一個(gè)空指針,數(shù)組越界,除零,等等),為了使開發(fā)人員免于處理這些異常,一些異常被命名為非檢查型異常(即那些繼承自 RuntimeException
的異常)并且不需要進(jìn)行聲明。
傳統(tǒng)的觀點(diǎn)
在下面的來自 Sun 的“The Java Tutorial”的摘錄中,總結(jié)了關(guān)于將一個(gè)異常聲明為檢查型還是非檢查型的傳統(tǒng)觀點(diǎn)(更多的信息請(qǐng)參閱 參考資料):
因?yàn)?Java 語言并不要求方法捕獲或者指定運(yùn)行時(shí)異常,因此編寫只拋出運(yùn)行時(shí)異常的代碼或者使得他們的所有異常子類都繼承自 RuntimeException
,對(duì)于程序員來說是有吸引力的。這些編程捷徑都允許程序員編寫 Java 代碼而不會(huì)受到來自編譯器的所有挑剔性錯(cuò)誤的干擾,并且不用去指定或者捕獲任何異常。盡管對(duì)于程序員來說這似乎比較方便,但是它回避了 Java 的捕獲或者指定要求的意圖,并且對(duì)于那些使用您提供的類的程序員可能會(huì)導(dǎo)致問題。
檢查型異常代表關(guān)于一個(gè)合法指定的請(qǐng)求的操作的有用信息,調(diào)用者可能已經(jīng)對(duì)該操作沒有控制,并且調(diào)用者需要得到有關(guān)的通知 —— 例如,文件系統(tǒng)已滿,或者遠(yuǎn)端已經(jīng)關(guān)閉連接,或者訪問權(quán)限不允許該動(dòng)作。
如果您僅僅是因?yàn)椴幌胫付ó惓6鴴伋鲆粋€(gè) RuntimeException
,或者創(chuàng)建 RuntimeException
的一個(gè)子類,那么您換取到了什么呢?您只是獲得了拋出一個(gè)異常而不用您指定這樣做的能力。換句話說,這是一種用于避免文檔化方法所能拋出的異常的方式。在什么時(shí)候這是有益的?也就是說,在什么時(shí)候避免注明一個(gè)方法的行為是有益的?答案是“幾乎從不。”
換句話說,Sun 告訴我們檢查型異常應(yīng)該是準(zhǔn)則。該教程通過多種方式繼續(xù)說明,通常應(yīng)該拋出異常,而不是 RuntimeException
—— 除非您是 JVM。
在 Effective Java: Programming Language Guide一書中(請(qǐng)參閱 參考資料),Josh Bloch 提供了下列關(guān)于檢查型和非檢查型異常的知識(shí)點(diǎn),這些與 “The Java Tutorial” 中的建議相一致(但是并不完全嚴(yán)格一致):
-
第 39 條:只為異常條件使用異常。也就是說,不要為控制流使用異常,比如,在調(diào)用
Iterator.next()
時(shí)而不是在第一次檢查 Iterator.hasNext()
時(shí)捕獲 NoSuchElementException
。
-
第 40 條:為可恢復(fù)的條件使用檢查型異常,為編程錯(cuò)誤使用運(yùn)行時(shí)異常。這里,Bloch 回應(yīng)傳統(tǒng)的 Sun 觀點(diǎn) —— 運(yùn)行時(shí)異常應(yīng)該只是用于指示編程錯(cuò)誤,例如違反前置條件。
-
第 41 條:避免不必要的使用檢查型異常。換句話說,對(duì)于調(diào)用者不可能從其中恢復(fù)的情形,或者惟一可以預(yù)見的響應(yīng)將是程序退出,則不要使用檢查型異常。
-
第 43 條:拋出與抽象相適應(yīng)的異常。換句話說,一個(gè)方法所拋出的異常應(yīng)該在一個(gè)抽象層次上定義,該抽象層次與該方法做什么相一致,而不一定與方法的底層實(shí)現(xiàn)細(xì)節(jié)相一致。例如,一個(gè)從文件、數(shù)據(jù)庫或者 JNDI 裝載資源的方法在不能找到資源時(shí),應(yīng)該拋出某種
ResourceNotFound
異常(通常使用異常鏈來保存隱含的原因),而不是更底層的 IOException
、 SQLException
或者 NamingException
。
重新考察非檢查型異常的正統(tǒng)觀點(diǎn)
最近,幾位受尊敬的專家,包括 Bruce Eckel 和 Rod Johnson,已經(jīng)公開聲明盡管他們最初完全同意檢查型異常的正統(tǒng)觀點(diǎn),但是他們已經(jīng)認(rèn)定排他性使用檢查型異常的想法并沒有最初看起來那樣好,并且對(duì)于許多大型項(xiàng)目,檢查型異常已經(jīng)成為一個(gè)重要的問題來源。Eckel 提出了一個(gè)更為極端的觀點(diǎn),建議所有的異常應(yīng)該是非檢查型的;Johnson 的觀點(diǎn)要保守一些,但是仍然暗示傳統(tǒng)的優(yōu)先選擇檢查型異常是過分的。(值得一提的是,C# 的設(shè)計(jì)師在語言設(shè)計(jì)中選擇忽略檢查型異常,使得所有異常都是非檢查型的,因而幾乎可以肯定他們具有豐富的 Java 技術(shù)使用經(jīng)驗(yàn)。但是,后來他們的確為檢查型異常的實(shí)現(xiàn)留出了空間。)
對(duì)于檢查型異常的一些批評(píng)
Eckel 和 Johnson 都指出了一個(gè)關(guān)于檢查型異常的相似的問題清單;一些是檢查型異常的內(nèi)在屬性,一些是檢查型異常在 Java 語言中的特定實(shí)現(xiàn)的屬性,還有一些只是簡(jiǎn)單的觀察,主要是關(guān)于檢查型異常的廣泛的錯(cuò)誤使用是如何變?yōu)橐粋€(gè)嚴(yán)重的問題,從而導(dǎo)致該機(jī)制可能需要被重新考慮。
檢查型異常不適當(dāng)?shù)乇┞秾?shí)現(xiàn)細(xì)節(jié)
您已經(jīng)有多少次看見(或者編寫)一個(gè)拋出 SQLException
或者 IOException
的方法,即使它看起來與數(shù)據(jù)庫或者文件毫無關(guān)系呢?對(duì)于開發(fā)人員來說,在一個(gè)方法的最初實(shí)現(xiàn)中總結(jié)出可能拋出的所有異常并且將它們?cè)黾拥椒椒ǖ?throws 子句(許多 IDE 甚至幫助您執(zhí)行該任務(wù))是十分常見的。這種直接方法的一個(gè)問題是它違反了 Bloch 的 第 43 條 —— 被拋出的異常所位于的抽象層次與拋出它們的方法不一致。
一個(gè)用于裝載用戶概要的方法,在找不到用戶時(shí)應(yīng)該拋出 NoSuchUserException
,而不是 SQLException
—— 調(diào)用者可以很好地預(yù)料到用戶可能找不到,但是不知道如何處理 SQLException
。異常鏈可以用于拋出一個(gè)更為合適的異常而不用丟棄關(guān)于底層失敗的細(xì)節(jié)(例如棧跟蹤),允許抽象層將位于它們之上的分層同位于它們之下的分層的細(xì)節(jié)隔離開來,同時(shí)保留對(duì)于調(diào)試可能有用的信息。
據(jù)說,諸如 JDBC 包的設(shè)計(jì)采取這樣一種方式,使得它難以避免該問題。在 JDBC 接口中的每個(gè)方法都拋出 SQLException
,但是在訪問一個(gè)數(shù)據(jù)庫的過程中可能會(huì)經(jīng)歷多種不同類型的問題,并且不同的方法可能易受不同錯(cuò)誤模式的影響。一個(gè) SQLException
可能指示一個(gè)系統(tǒng)級(jí)問題(不能連接到數(shù)據(jù)庫)、邏輯問題(在結(jié)果集中沒有更多的行)或者特定數(shù)據(jù)的問題(您剛才試圖插入行的主鍵已經(jīng)存在或者違反實(shí)體完整性約束)。如果沒有犯不可原諒的嘗試分析消息正文的過失,調(diào)用者是不可能區(qū)分這些不同類型的 SQLException
的。( SQLException
的確支持用于獲取數(shù)據(jù)庫特定錯(cuò)誤代碼和 SQL 狀態(tài)變量的方法,但是在實(shí)踐中這些很少用于區(qū)分不同的數(shù)據(jù)庫錯(cuò)誤條件。)
不穩(wěn)定的方法簽名
不穩(wěn)定的方法簽名問題是與前面的問題相關(guān)的 —— 如果您只是通過一個(gè)方法傳遞異常,那么您不得不在每次改變方法的實(shí)現(xiàn)時(shí)改變它的方法簽名,以及改變調(diào)用該方法的所有代碼。一旦類已經(jīng)被部署到產(chǎn)品中,管理這些脆弱的方法簽名就變成一個(gè)昂貴的任務(wù)。然而,該問題本質(zhì)上是沒有遵循 Bloch 提出的第 43 條的另一個(gè)癥狀。方法在遇到失敗時(shí)應(yīng)該拋出一個(gè)異常,但是該異常應(yīng)該反映該方法做什么,而不是它如何做。
有時(shí),當(dāng)程序員對(duì)因?yàn)閷?shí)現(xiàn)的改變而導(dǎo)致從方法簽名中增加或者刪除異常感到厭煩時(shí),他們不是通過使用一個(gè)抽象來定義特定層次可能拋出的異常類型,而只是將他們的所有方法都聲明為拋出 Exception
。換句話說,他們已經(jīng)認(rèn)定異常只是導(dǎo)致煩惱,并且基本上將它們關(guān)閉掉了。毋庸多言,該方法對(duì)于絕大多數(shù)可任意使用的代碼來說通常不是一個(gè)好的錯(cuò)誤處理策略。
難以理解的代碼
因?yàn)樵S多方法都拋出一定數(shù)目的不同異常,錯(cuò)誤處理的代碼相對(duì)于實(shí)際的功能代碼的比率可能會(huì)偏高,使得難以找到一個(gè)方法中實(shí)際完成功能的代碼。異常是通過集中錯(cuò)誤處理來設(shè)想減小代碼的,但是一個(gè)具有三行代碼和六個(gè) catch 塊(其中每個(gè)塊只是記錄異常或者包裝并重新拋出異常)的方法看起來比較膨脹并且會(huì)使得本來簡(jiǎn)單的代碼變得模糊。
異常淹沒
我們都看到過這樣的代碼,其中捕獲了一個(gè)異常,但是在 catch
塊中沒有代碼。盡管這種編程實(shí)踐很明顯是不好的,但是很容易看出它是如何發(fā)生的 —— 在原型化期間,某人通過 try...catch
塊包裝代碼,而后來忘記返回并填充 catch
塊。盡管這個(gè)錯(cuò)誤很常見,但是這也是更好的工具可以幫助我們的地方之一 —— 對(duì)于異常淹沒的地方,通過編輯器、編譯器或者靜態(tài)檢查工具可以容易地檢測(cè)并發(fā)出警告。
極度通用的 try...catch
塊是另一種形式的異常淹沒,并且更加難以檢測(cè),因?yàn)檫@是 Java 類庫中的異常類層次的結(jié)構(gòu)而導(dǎo)致的(可疑)。讓我們假定一個(gè)方法拋出四個(gè)不同類型的異常,并且調(diào)用者遇到其中任何一個(gè)異常都將捕獲、記錄它們,并且返回。實(shí)現(xiàn)該策略的一種方式是使用一個(gè)帶有四個(gè) catch
子句的 try...catch
塊,其中每個(gè)異常類型一個(gè)。為了避免代碼難以理解的問題,一些開發(fā)人員將重構(gòu)該代碼,如清單 1 所示:
清單 1. 意外地淹沒 RuntimeException
try {
doSomething();
}
catch (Exception e) {
log(e);
}
|
盡管該代碼與四個(gè) catch
塊相比更為緊湊,但是它具有一個(gè)問題 —— 它還捕獲可能由 doSomething
拋出的任何 RuntimeException
并且阻止它們進(jìn)行擴(kuò)散。
過多的異常包裝
如果異常是在一個(gè)底層的設(shè)施中生成的,并且通過許多代碼層向上擴(kuò)散,在最終被處理之前它可能被捕獲、包裝和重新拋出若干次。當(dāng)異常最終被記錄的時(shí)候,棧跟蹤可能有許多頁,因?yàn)闂8櫩赡鼙粡?fù)制多次,其中每個(gè)包裝層一次。(在 JDK 1.4 以及后來的版本中,異常鏈的實(shí)現(xiàn)在某種程度上緩解了該問題。)
替換的方法
Bruce Eckel, Thinking in Java(請(qǐng)參閱 參考資料)的作者,聲稱在使用 Java 語言多年后,他已經(jīng)得出這樣的結(jié)論,認(rèn)為檢查型異常是一個(gè)錯(cuò)誤 —— 一個(gè)應(yīng)該被聲明為失敗的試驗(yàn)。Eckel 提倡將所有的異常都作為非檢查型的,并且提供清單 2 中的類作為將檢查型異常轉(zhuǎn)變?yōu)榉菣z查型異常的一個(gè)方法,同時(shí)保留當(dāng)異常從棧向上擴(kuò)散時(shí)捕獲特定類型的異常的能力(關(guān)于如何使用該方法的解釋,請(qǐng)參閱他在 參考資料小節(jié)中的文章):
清單 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 站點(diǎn)上的討論,您將會(huì)發(fā)現(xiàn)回應(yīng)者是嚴(yán)重分裂的。一些人認(rèn)為他的提議是荒謬的;一些人認(rèn)為這是一個(gè)重要的思想。(我的觀點(diǎn)是,盡管恰當(dāng)?shù)厥褂卯惓4_實(shí)是很難的,并且對(duì)異常用不好的例子大量存在,但是大多數(shù)贊同他的人是因?yàn)殄e(cuò)誤的原因才這樣做的,這與一個(gè)政客位于一個(gè)可以隨便獲取巧克力的平臺(tái)上參選將會(huì)獲得十歲孩子的大量選票的情況具有相似之處。)
Rod Johnson 是 J2EE Design and Development(請(qǐng)參閱 參考資料) 的作者,這是我所讀過的關(guān)于 Java 開發(fā),J2EE 等方面的最好的書籍之一。他采取一個(gè)不太激進(jìn)的方法。他列舉了異常的多個(gè)類別,并且為每個(gè)類別確定一個(gè)策略。一些異常本質(zhì)上是次要的返回代碼(它通常指示違反業(yè)務(wù)規(guī)則),而一些異常則是“發(fā)生某種可怕錯(cuò)誤”(例如數(shù)據(jù)庫連接失敗)的變種。Johnson 提倡對(duì)于第一種類別的異常(可選的返回代碼)使用檢查型異常,而對(duì)于后者使用運(yùn)行時(shí)異常。在“發(fā)生某種可怕錯(cuò)誤”的類別中,其動(dòng)機(jī)是簡(jiǎn)單地認(rèn)識(shí)到?jīng)]有調(diào)用者能夠有效地處理該異常,因此它也可能以各種方式沿著棧向上擴(kuò)散而對(duì)于中間代碼的影響保持最小(并且最小化異常淹沒的可能性)。
Johnson 還列舉了一個(gè)中間情形,對(duì)此他提出一個(gè)問題,“只是少數(shù)調(diào)用者希望處理問題嗎?”對(duì)于這些情形,他也建議使用非檢查型異常。作為該類別的一個(gè)例子,他列舉了 JDO 異常 —— 大多數(shù)情況下,JDO 異常表示的情況是調(diào)用者不希望處理的,但是在某些情況下,捕獲和處理特定類型的異常是有用的。他建議在這里使用非檢查型異常,而不是讓其余的使用 JDO 的類通過捕獲和重新拋出這些異常的形式來彌補(bǔ)這個(gè)可能性。
使用非檢查型異常
關(guān)于是否使用非檢查型異常的決定是復(fù)雜的,并且很顯然沒有明顯的答案。Sun 的建議是對(duì)于任何情況使用它們,而 C# 方法(也就是 Eckel 和其他人所贊同的)是對(duì)于任何情況都不使用它們。其他人說,“還存在一個(gè)中間情形。”
通過在 C++ 中使用異常,其中所有的異常都是非檢查型的,我已經(jīng)發(fā)現(xiàn)非檢查型異常的最大風(fēng)險(xiǎn)之一就是它并沒有按照檢查型異常采用的方式那樣自我文檔化。除非 API 的創(chuàng)建者明確地文檔化將要拋出的異常,否則調(diào)用者沒有辦法知道在他們的代碼中將要捕獲的異常是什么。不幸的是,我的經(jīng)驗(yàn)是大多數(shù) C++ API 的文檔化非常差,并且即使文檔化很好的 API 也缺乏關(guān)于從一個(gè)給定方法可能拋出的異常的足夠信息。我看不出有任何理由可以說該問題對(duì)于 Java 類庫不是同樣的常見,因?yàn)?Jav 類庫嚴(yán)重依賴于非檢查型異常。依賴于您自己的或者您的合作伙伴的編程技巧是非常困難的;如果不得不依賴于某個(gè)人的文檔化技巧,那么對(duì)于他的代碼您可能得使用調(diào)用棧中的十六個(gè)幀來作為您的主要的錯(cuò)誤處理機(jī)制,這將會(huì)是令人恐慌的。
文檔化問題進(jìn)一步強(qiáng)調(diào)為什么懶惰是導(dǎo)致選擇使用非檢查型異常的一個(gè)不好的原因,因?yàn)閷?duì)于文檔化增加給包的負(fù)擔(dān),使用非檢查型異常應(yīng)該比使用檢查型異常甚至更高(當(dāng)文檔化您所拋出的非檢查型異常比檢查型異常變得更為重要的時(shí)候)。
文檔化,文檔化,文檔化
如果決定使用非檢查型異常,您需要徹底地文檔化這個(gè)選擇,包括在 Javadoc 中文檔化一個(gè)方法可能拋出的所有非檢查型異常。Johnson 建議在每個(gè)包的基礎(chǔ)上選擇檢查型和非檢查型異常。使用非檢查型異常時(shí)還要記住,即使您并不捕獲任何異常,也可能需要使用 try...finally
塊,從而可以執(zhí)行清除動(dòng)作例如關(guān)閉數(shù)據(jù)庫連接。對(duì)于檢查型異常,我們有 try...catch
用來提示增加一個(gè) finally
子句。對(duì)于非檢查型異常,我們則沒有這個(gè)支撐可以依靠。