學完本章你將能夠:
說明例外機制相較於「回傳錯誤碼」的優勢,並描繪 Throwable 的類別階層。
分辨受檢(checked)與未受檢(unchecked)例外,理解兩者背後的設計哲學。
熟練使用 try-catch、多重 catch、multi-catch、finally 與 try-with-resources。
分辨 throw 與 throws,設計帶欄位的自訂例外並正確選擇父類別。
用例外鏈保留錯誤因果,並能逐行讀懂堆疊追蹤。
遵循業界最佳實務:不吞例外、精準捕獲、訊息帶上下文。
程式不會永遠走在快樂路徑上:檔案可能不存在、輸入可能是亂碼、餘額可能不足。一個系統的品質,往往不是看它正常運作時多優雅,而是看它出錯時多可靠。Java 的例外(exception)機制把「錯誤的表達、傳遞與處理」從一般的回傳值中獨立出來,讓錯誤既不會被忽略,也不會淹沒主要邏輯。
前兩章打造的類別已經開始拋出 |IllegalArgumentException|,本章正式解釋這一切如何運作:從機制的動機、類別階層、語法全家桶,一路到自訂例外與業界實務,最後用一台 ATM 提款機整合所有概念。
為什麼需要例外
在例外機制普及之前,函式用「特殊回傳值」回報失敗:找不到就回傳 |-1|、失敗就回傳 |null| 或錯誤碼。這種做法有三個致命缺陷,看一個典型案例:
/** 用「錯誤碼」回報失敗的傳統做法:問題重重 */ public class ErrorCodeStyle
/** 找不到就回傳 -1:呼叫端很容易忘記檢查 */ static int indexOf(int[] data, int target) for (int i = 0; i < data.length; i++) if (data[i] == target) return i; return -1; // 魔術數字:錯誤訊號混在正常回傳值裡
public static void main(String[] args) int[] scores = 70, 85, 90 ; int index = indexOf(scores, 100); // 忘了檢查 -1 就直接使用:錯誤悄悄往下游擴散 System.out.println("找到成績,位於索引 " + index);
找到成績,位於索引 -1
程式「正常結束」,卻印出一個荒謬的結果。錯誤碼的缺陷在於:(1)可以被忽略——編譯器不會強迫呼叫端檢查回傳值,錯誤默默往下游擴散,等到真正炸開時已離案發現場很遠;(2)訊號與資料混在同一條通道——|-1| 既是錯誤也是合法的 |int|,語意全靠註解與默契;(3)錯誤資訊貧乏——一個整數說不清楚「什麼壞了、為什麼、在哪裡」。例外機制針對性地解決這三點:例外不處理就會讓程式終止(無法被無聲忽略)、例外走獨立的傳遞通道(不佔用回傳值)、例外是完整的物件(能攜帶訊息、欄位與堆疊追蹤)。
Throwable 家族
所有能被 |throw| 拋出、被 |catch| 接住的東西,都是 |Throwable| 的後代。整個家族的版圖如下:
|Throwable|:一切可拋出物的始祖
|Error|:JVM 層級的嚴重問題,程式不應捕捉。代表:|OutOfMemoryError|(記憶體耗盡)、|StackOverflowError|(呼叫堆疊溢位)。
|Exception|:程式可以合理應對的狀況
受檢例外(checked):|Exception| 的直接後代。代表:|IOException|、|FileNotFoundException|、|SQLException|。
|RuntimeException| 及其後代(unchecked,未受檢):|NullPointerException|、|ArrayIndexOutOfBoundsException|、|IllegalArgumentException|、|ArithmeticException|、|ClassCastException|。
受檢與未受檢的差別,是 Java 例外設計的核心:
| 比較面向 | 受檢例外(checked) | 未受檢例外(unchecked) |
|---|---|---|
| 繼承位置 | |Exception|(非 Runtime 分支) | |RuntimeException| 分支 |
| 代表例外 | |IOException|、|SQLException| | |NullPointerException| 等 |
| 編譯器強制? | 是:必須 catch 或宣告 throws | 否:可處理也可放行 |
| 語意 | 可預期的外部狀況 | 程式本身的邏輯錯誤 |
| 典型場景 | 檔案、網路、商業規則 | 參數不合法、索引越界 |
| 正確反應 | 就地處理或明確上報 | 修程式,而不是包 try |
設計哲學是這樣的:受檢例外代表「就算程式寫得完美也可能發生」的外部狀況(磁碟滿了、網路斷了、餘額不足),編譯器強迫你正視它;未受檢例外代表「程式寫錯了」(對 |null| 呼叫方法、陣列越界),正確的對策是修正程式,而不是到處包 try-catch 遮掩。這也是第 1 章的 setter 拋 |IllegalArgumentException| 而不是自訂受檢例外的原因——傳錯參數是呼叫端的臭蟲。
try-catch:接住例外
基本語法:把可能出事的程式放進 |try| 區塊,用一個以上的 |catch| 區塊分類接住。例外發生的當下,|try| 中剩餘的敘述全部跳過,控制權轉移到第一個「型別相符」的 |catch|:
/** try-catch 基本流程與多重 catch 的比對順序 */ public class TryCatchDemo
/** 取出陣列元素、解析成整數、再拿來當除數 */ static int compute(String[] data, int index) int value = Integer.parseInt(data[index]); // 可能拋兩種例外 return 100 / value; // 也可能除以零
public static void main(String[] args)
String[] data = "4", "abc", "0" ; int[] tests = 0, 1, 2, 9 ; // 用不同索引觸發不同例外
for (int i : tests) try System.out.println("結果:" + compute(data, i)); catch (NumberFormatException e) // 較特定的例外放前面(它是 RuntimeException 的後代) System.out.println("格式錯誤:" + e.getMessage()); catch (ArithmeticException e) System.out.println("算術錯誤:" + e.getMessage()); catch (ArrayIndexOutOfBoundsException e) System.out.println("索引錯誤:" + e.getMessage()); System.out.println("程式正常結束");
結果:25 格式錯誤:For input string: "abc" 算術錯誤:/ by zero 索引錯誤:Index 9 out of bounds for length 3 程式正常結束
四輪迭代各自展示一條路徑:索引 0 一路順利;索引 1 在 |parseInt| 拋出 |NumberFormatException|;索引 2 解析成功但除以零;索引 9 連取值都失敗。注意例外發生在 |compute| 內部、卻在 |main| 被接住——例外會沿著呼叫鏈向上傳遞,直到遇見合適的 catch,這讓「出錯的地方」與「決定怎麼辦的地方」可以分離。
多個 catch 的比對順序是由上而下、先中先贏,因此子類別例外必須放在父類別之前。如果把 |catch (Exception e)| 放在第一個,後面的 catch 永遠輪不到——Java 編譯器會直接報錯「exception has already been caught」,但若父子關係比較隱晦(例如 |IllegalArgumentException| 與它的子類別 |NumberFormatException|),順序寫反就是一個編譯錯誤等著你。口訣:先子後父,由特殊到一般。
當多種例外的處理方式一模一樣時,用 multi-catch 把它們併在同一個 catch 中(以直線符號分隔),避免複製貼上:
try int value = Integer.parseInt(args[0]); // 可能拋 NumberFormat System.out.println(100 / value); // 可能拋 Arithmetic catch (NumberFormatException | ArithmeticException e) // 兩種例外共用同一段處理;e 的型別是兩者最近的共同父類別 System.out.println("輸入無法處理:" + e.getMessage());
finally 與 try-with-resources
finally:保證執行的收尾區塊
|finally| 區塊無論 try 成功、失敗、甚至中途 |return|,都保證執行,傳統上用來釋放資源。最反直覺的一點是它與 |return| 的互動——先看再解釋:
/** finally 的執行時機:即使 try 中有 return,finally 仍會先執行 */ public class FinallyDemo
static String load(boolean fail) try if (fail) throw new IllegalStateException("讀取失敗"); System.out.println(" try:成功讀取資料"); return "資料"; // 準備回傳前,finally 仍會插隊執行 catch (IllegalStateException e) System.out.println(" catch:" + e.getMessage()); return "預設值"; finally // 無論成功、失敗、甚至已經 return,這裡都保證執行 System.out.println(" finally:關閉資源");
public static void main(String[] args) System.out.println("回傳:" + load(false)); System.out.println("回傳:" + load(true));
try:成功讀取資料 finally:關閉資源 回傳:資料 catch:讀取失敗 finally:關閉資源 回傳:預設值
執行到 |return "資料";| 時,回傳值先被計算並保存,接著執行 |finally|,最後才真正把值交回呼叫端——所以「finally:關閉資源」印在「回傳:資料」之前。也因此絕對不要在 finally 裡寫 return:它會覆蓋 try 與 catch 的回傳值、甚至吃掉尚未傳遞的例外。
try-with-resources:自動關閉資源
手動在 finally 關資源既囉嗦又容易寫錯(關閉動作本身也可能拋例外)。Java 7 起,任何實作 |AutoCloseable| 介面的物件,都能宣告在 try 的小括號中,離開區塊時自動呼叫 |close()|。自訂一個資源類別來觀察時機與順序:
/** 自訂資源:實作 AutoCloseable 就能交給 try-with-resources 管理 */ class Resource implements AutoCloseable
private final String name;
Resource(String name) this.name = name; System.out.println("開啟 " + name);
/** 使用資源 */ void use() System.out.println("使用 " + name);
/** try 區塊結束時被自動呼叫,不必手動釋放 */ @Override public void close() System.out.println("關閉 " + name);
/** 驗證多個資源的關閉順序:後開的先關(後進先出) */ public class TryWithResourcesDemo public static void main(String[] args) // 小括號中可以宣告多個資源,以分號分隔 try (Resource a = new Resource("資源A"); Resource b = new Resource("資源B")) a.use(); b.use(); // 這裡自動先呼叫 b.close(),再呼叫 a.close() System.out.println("try 區塊之後");
開啟 資源A 開啟 資源B 使用 資源A 使用 資源B 關閉 資源B 關閉 資源A try 區塊之後
關閉順序是後進先出:後宣告的資源可能依賴先宣告的(例如包裝流依賴底層流),所以必須先關。即使 try 中途拋出例外,所有已開啟的資源仍會依序關閉。第 6 章的檔案操作將全面使用這個語法。
throw、throws 與自訂例外
兩個關鍵字一字之差,角色完全不同:|throw|(動詞,無 s)用在方法內部,真正拋出一個例外物件,例如 |throw new IllegalArgumentException("...")|;|throws|(宣告,有 s)用在方法簽章上,聲明「本方法可能拋出這些受檢例外,呼叫我的人請自行處理」。未受檢例外不需要列在 throws(列了也無妨,可作為文件)。
當標準例外的語意不夠精確時,就該自訂例外。選擇父類別的準則承襲前面的哲學:可預期的商業狀況繼承 Exception(受檢),呼叫端的程式錯誤繼承 RuntimeException(未受檢)。以下兩個自訂例外將用於本章壓軸的 ATM 範例:
/** * 提款金額不合法:呼叫端的程式錯誤(傳了不該傳的值), * 因此繼承 RuntimeException(unchecked),不強迫捕捉。 */ public class InvalidAmountException extends RuntimeException
public InvalidAmountException(String message) super(message); // 把錯誤訊息交給父類別保管
/** * 餘額不足:正常營運中「可預期」的商業狀況, * 繼承 Exception(checked),強迫呼叫端正視並處理。 * 例外可以帶欄位:把「差多少錢」這種上下文一併帶給呼叫端。 */ public class InsufficientFundsException extends Exception
private final int shortage; // 不足的金額(元)
public InsufficientFundsException(int shortage) // 先組出對人類友善的訊息,交給父類別保管 super("餘額不足,還差 " + shortage + " 元"); this.shortage = shortage;
/** 呼叫端可以取回差額,做進一步的補救處理 */ public int getShortage() return shortage;
注意 |InsufficientFundsException| 帶著一個 |shortage| 欄位:例外是完整的類別,除了訊息字串,還能攜帶結構化的資料,讓呼叫端不必去「解析錯誤訊息」就能取得差額——這是自訂例外相較於標準例外最大的加值空間。
例外鏈:保留因果
分層的系統中,低階例外常需要「翻譯」成高階例外:資料層的 |NumberFormatException| 對使用者毫無意義,包成「設定檔錯誤」才有溝通價值。但翻譯時絕不能丟掉原始例外——把它作為 |cause|(肇因)傳給新例外的建構子,形成例外鏈:
/** 高階例外:對呼叫端有意義的「設定檔錯誤」 */ class ConfigException extends RuntimeException public ConfigException(String message, Throwable cause) super(message, cause); // cause 會被記住,形成因果鏈
/** 例外鏈:把底層例外包成高階例外,因果完整保留 */ public class ChainDemo
/** 讀取連接埠設定:把低階的解析錯誤翻譯成高階錯誤 */ static int readPort(String raw) try return Integer.parseInt(raw); catch (NumberFormatException e) // 用第二個參數把原始例外接上因果鏈,證據不丟失 throw new ConfigException("連接埠設定值無效:" + raw, e);
public static void main(String[] args) try readPort("eighty"); catch (ConfigException e) System.out.println("捕獲:" + e.getMessage()); // getCause() 可以取回最初的元凶 System.out.println("肇因:" + e.getCause());
捕獲:連接埠設定值無效:eighty 肇因:java.lang.NumberFormatException: For input string: "eighty"
有例外鏈時,|printStackTrace| 會連同所有 cause 一起印出(以「Caused by:」開頭的段落),除錯時可以一路追到最初的案發現場。
閱讀堆疊追蹤
例外一路沒人接住時,執行緒終止,JVM 把堆疊追蹤(stack trace)印到標準錯誤。讀懂它是每位 Java 工程師的基本功:
/** 產生一個未捕獲的例外,觀察堆疊追蹤的結構 */ public class StackTraceDemo
/** 第三層:例外真正的發源地 */ static int level3(String s) return Integer.parseInt(s); // 這裡拋出 NumberFormatException
/** 第二層:只是路過,沒有處理例外 */ static int level2(String s) return level3(s);
public static void main(String[] args) level2("oops"); // 呼叫鏈的起點
Exception in thread "main" java.lang.NumberFormatException: For input string: "oops" at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67) at java.base/java.lang.Integer.parseInt(Integer.java:662) at java.base/java.lang.Integer.parseInt(Integer.java:778) at StackTraceDemo.level3(StackTraceDemo.java:6) at StackTraceDemo.level2(StackTraceDemo.java:11) at StackTraceDemo.main(StackTraceDemo.java:15)
逐行解讀:第一行是「哪條執行緒、哪種例外、什麼訊息」;接下來每一行 |at| 是呼叫堆疊的一格,由上而下代表由深到淺——最上面是例外誕生的位置,最下面是 |main|。括號內是「檔名加行號」:|StackTraceDemo.java:6| 正是 |parseInt| 那一行。實戰讀法:由上往下掃,找到第一行屬於你自己程式碼的 at(跳過 |java.base| 開頭的函式庫內部),那裡通常就是要修的地方。函式庫內部的行號(如 |Integer.java:662|)會因 JDK 版本而略有不同,不必在意。
例外處理最佳實務
最嚴重的反模式是吞掉例外:|catch (Exception e) | 空區塊讓錯誤憑空消失,系統帶著壞狀態繼續跑,數週後在毫不相干的地方炸開,而所有證據早已湮滅。最低限度也要記錄下來(開發階段可用 |e.printStackTrace()|,正式系統用日誌框架);若當下真的無法處理,就讓它繼續往上拋。同樣危險的是一把抓:|catch (Exception e)| 會把你根本沒預期到的錯誤(包括程式臭蟲)也攔下來,掩蓋真正的問題。
三條正向原則:(1)捕獲要精準——只 catch 你知道怎麼處理的型別,處理不了的讓它上拋;(2)訊息要有上下文——「餘額不足,還差 1000 元」遠勝於「錯誤」,建立例外時把關鍵變數放進訊息與欄位;(3)及早失敗(fail fast)——像第 1 章的 setter 一樣在入口就驗證並拋出例外,錯誤越早現形,離病灶越近,修起來越便宜。
綜合範例:ATM 提款機
把本章所有概念組裝起來:|Atm| 類別的 |withdraw| 以防衛子句依序檢查規則,違反時分別拋出未受檢的 |InvalidAmountException|(金額格式錯誤,屬於呼叫端臭蟲)與受檢的 |InsufficientFundsException|(餘額不足,屬於可預期的商業狀況);呼叫端用多重 catch 分類處理,流程不會因為單筆失敗而中斷。
/** 模擬一連串提款操作,示範兩類例外各自的處理方式 */ public class AtmDemo
public static void main(String[] args)
Atm atm = new Atm(5000); int[] requests = 3000, 500, 3000, 2000 ;
for (int amount : requests) System.out.println("== 申請提款 " + amount + " 元 =="); try atm.withdraw(amount); catch (InvalidAmountException e) // unchecked:不接也能編譯,但這裡選擇就地處理 System.out.println("輸入有誤:" + e.getMessage()); catch (InsufficientFundsException e) // checked:編譯器強迫我們面對這個商業狀況 System.out.println(e.getMessage() + "(本帳戶可提上限 " + atm.getBalance() + " 元)"); System.out.println("最終餘額:" + atm.getBalance() + " 元");
== 申請提款 3000 元 == 吐鈔 3000 元,餘額 2000 元 == 申請提款 500 元 == 輸入有誤:金額必須是 1000 的正倍數,收到:500 == 申請提款 3000 元 == 餘額不足,還差 1000 元(本帳戶可提上限 2000 元) == 申請提款 2000 元 == 吐鈔 2000 元,餘額 0 元 最終餘額:0 元
追蹤流程:初始餘額 5000,第一筆 3000 成功(餘額 2000);第二筆 500 不是 1000 的倍數,被防衛子句擋下;第三筆 3000 超過餘額 2000,差額恰為 1000;第四筆 2000 成功,餘額歸零。特別觀察:|withdraw| 把所有檢查放在任何狀態修改之前——這保證例外發生時物件狀態毫髮無傷,不會出現「錢扣了一半」的慘劇,與第 1 章的不變條件一脈相承。
本章小結
錯誤碼的三宗罪:可被忽略、訊號與資料混雜、資訊貧乏;例外機制對症下藥。
階層:|Throwable| 分為不應捕捉的 |Error| 與可以處理的 |Exception|;後者再分受檢與未受檢(|RuntimeException| 分支)。
受檢例外表達「可預期的外部與商業狀況」,編譯器強迫處理;未受檢例外表達「程式邏輯錯誤」,正解是修程式。
多重 catch 由上而下比對:先子後父;處理邏輯相同時用 multi-catch 合併。
|finally| 保證執行,連 try 中的 |return| 都攔得住;不要在 finally 中寫 return。
try-with-resources 自動關閉 |AutoCloseable| 資源,多個資源後進先出。
|throw| 拋出例外物件;|throws| 在簽章上聲明可能拋出的受檢例外。
自訂例外:商業狀況繼承 |Exception|,程式錯誤繼承 |RuntimeException|;善用欄位攜帶結構化上下文。
翻譯例外時用 cause 建構子保留因果鏈;|getCause()| 可取回元凶。
讀堆疊追蹤:由上往下找到第一行自己的程式碼;絕不吞例外、捕獲要精準、訊息要有上下文。
本章練習
觀念題:|FileNotFoundException| 設計成受檢、|NullPointerException| 設計成未受檢,請分別說明理由。(提示:哪一個是「程式再完美也可能發生」?哪一個是「程式寫錯了」?)
觀念題:以下程式印出什麼?|try| 中 |return 1;|,|finally| 中 |System.out.println("F");| ——若把 |finally| 改成 |return 2;| 又會如何?為什麼後者是危險寫法?(提示:回傳值先保存、finally 插隊執行。)
實作題:寫一個 |safeDivide(int a, int b)| 方法:|b| 為零時拋出帶訊息的 |ArithmeticException|,並在 |main| 中示範捕獲;再改寫成回傳錯誤碼的版本,比較兩者的呼叫端程式碼。(提示:體會「無法忽略」的價值。)
實作題:自訂 |AgeOutOfRangeException|(帶 |age| 欄位),讓 |Person| 的 setter 在年齡不在 0 到 150 之間時拋出。思考它該是受檢還是未受檢,說明理由。(提示:傳入非法年齡是誰的錯?)
實作題:自訂 |Connection| 類別實作 |AutoCloseable|,在建構子與 |close()| 各印一行,用 try-with-resources 同時開啟三個,驗證關閉順序為後進先出。(提示:模仿本章 |Resource| 範例。)
挑戰題:擴充 ATM:新增每日提款上限 60000 元的規則與對應自訂例外 |DailyLimitExceededException|,並讓它與 |InsufficientFundsException| 共用一個自訂父類別 |AtmException|,呼叫端只用一個 catch 接住兩者。(提示:例外也能形成繼承階層。)