Learning Objectives
- 讀懂沒被處理的例外印出的堆疊追蹤,找到出錯的那一行
- 用 try-catch 處理例外,安排多個 catch 的順序,並使用 multi-catch
- 說明 finally 一定會執行,以及例外如何沿著呼叫堆疊往外傳
- 分辨檢查型與非檢查型例外,用 throws 宣告、用 throw 丟出例外
- 寫自訂的例外類別,並用 try-with-resources 確保資源被關閉
為什麼需要例外處理
程式執行時總會遇到意料之外的狀況:使用者輸入了文字而不是數字、要讀的檔案不存在、陣列索引超出範圍、除數是 0。 這些狀況發生時,Java 會建立一個例外(exception)物件,描述發生了什麼事,然後「拋出」(throw)它。
如果沒有任何程式接住這個例外,程式會立刻停止,後面的程式都不會執行。這對使用者很不友善: 輸入錯一個字,整個程式就結束了。例外處理讓你可以接住例外、決定怎麼應對(提示使用者重新輸入、改用預設值、記錄錯誤), 讓程式繼續執行。
先看看沒有處理的例外長什麼樣子。下面的動畫最後會停在例外拋出的那一步,輸出區會出現 Java 印出的錯誤訊息。
沒被處理的例外
第一次 average(90, 3) 正常回傳 30。第二次 average(50, 0) 呼叫 divide(50, 0),第 4 行整數除以 0,拋出 ArithmeticException。沒有人接住它,程式停止,第 15 行不會執行。播到最後一步時,看輸出區最後幾行。
讀懂堆疊追蹤
上面的程式最後印出:
Exception in thread "main" java.lang.ArithmeticException: / by zero
at Main.divide(Main.java:4)
at Main.average(Main.java:9)
at Main.main(Main.java:14)
這段文字叫做堆疊追蹤(stack trace),是除錯時最重要的線索。由上往下讀:
- 第一行:例外的類別(
java.lang.ArithmeticException)與訊息(/ by zero)。先看這行,知道發生了什麼事 at Main.divide(Main.java:4):例外是在divide方法、Main.java第 4 行拋出的。最上面的 at 就是出事的地方- 往下每一行是「誰呼叫了上面那一行」:
average在第 9 行呼叫了divide,main在第 14 行呼叫了average
這個順序和動畫左邊的框架一樣,只是上下顛倒:例外發生時呼叫堆疊上有哪些方法,就列出哪些。
真正的 Java 在函式庫內部拋出例外時(例如 Integer.parseInt),最上面還會多幾行 at java.base/…,那是函式庫自己的程式;
往下找到第一行 at Main.…,就是你的程式裡出問題的地方。
常見的例外:
| 例外 | 什麼時候發生 |
|---|---|
ArithmeticException |
整數除以 0(double 除以 0 不會拋出,結果是 Infinity) |
ArrayIndexOutOfBoundsException |
陣列索引超出範圍:Index 5 out of bounds for length 3 |
NullPointerException |
對 null 呼叫方法或存取欄位 |
NumberFormatException |
Integer.parseInt("abc") 這類無法轉成數字的字串 |
ClassCastException |
向下轉型失敗(第 10 課) |
try-catch
把可能出錯的程式放進 try 區塊,在後面的 catch 寫出錯時要做什麼:
try {
int n = Integer.parseInt(s); // 可能拋出 NumberFormatException
System.out.println(n * 2); // 上一行出錯時,這一行不會執行
} catch (NumberFormatException e) { // e 指向被拋出的例外物件
System.out.println("不是數字:" + e.getMessage());
}
System.out.println("繼續執行"); // 不論有沒有出錯,都會執行到這裡
try裡某一行拋出例外時,這一行之後的 try 內容全部跳過,直接跳到型別相符的catch- 沒有拋出例外時,
catch完全不執行 e.getMessage()取得例外的訊息;e.printStackTrace()印出完整的堆疊追蹤(除錯時好用)
多個 catch 由上往下比對,第一個型別相符(同類別或它的父類別)的 catch 會執行,其他的跳過。
所以子類別要寫在前面、父類別寫在後面:如果 catch (Exception e) 寫在最前面,它會接住所有例外,後面的 catch 永遠輪不到,
javac 會直接報編譯錯誤(exception … has already been caught)。
好幾種例外的處理方式相同時,可以用 | 寫成一個 multi-catch:catch (ClassCastException | NullPointerException e)(Java 7 起)。
用 | 列出的型別不能有父子關係。
接住不同的例外
迴圈跑三次:第一次正常;第二次在第 9 行 parseInt("abc") 拋出 NumberFormatException,第 10 行被跳過;第三次在第 8 行索引 5 超出範圍,連 parseInt 都沒有執行。注意每次例外之後,執行跳到哪一個 catch,然後迴圈照常繼續。
finally 與例外的傳遞
finally 區塊寫在 catch 之後,不論 try 有沒有拋出例外、例外有沒有被接住,都一定會執行。
即使 try 或 catch 裡已經寫了 return,Java 也會先執行 finally,再真正離開方法。
它適合放「一定要做的收尾工作」,例如關閉檔案、釋放連線。
try {
return Integer.parseInt(s);
} catch (NumberFormatException e) {
return 0;
} finally {
System.out.println("一定會印"); // 兩個 return 之前都會先執行這裡
}
(不要在 finally 裡寫 return:它會蓋掉 try 或 catch 的回傳值,甚至讓例外消失。)
例外會沿著呼叫堆疊往外傳。 方法裡拋出的例外如果沒有被接住,這個方法會立刻結束,例外交給呼叫它的方法;
呼叫者也沒有接住,就再往外交,一直傳到 main。main 也沒有接住時,程式才停止並印出堆疊追蹤。
所以你可以選擇在「最適合處理的那一層」接住例外,不必在每一層都寫 try-catch。
return 之前先執行 finally
第一次 parse("42") 在第 6 行準備 return 42,但不會馬上回到 main,而是先跳到第 10 行的 finally。第二次 parse("4x") 拋出例外,走 catch 的 return 0,同樣先執行 finally。
想一想:堆疊追蹤、catch 與 finally
程式停止時印出下面的訊息。例外是在哪裡拋出的?
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "s" is null
at Main.countChars(Main.java:8)
at Main.report(Main.java:4)
at Main.main(Main.java:12)
下面的程式會印出什麼?
public class Main {
static void a() { b(); System.out.print("A"); }
static void b() { String s = null; s.length(); System.out.print("B"); }
public static void main(String[] args) {
try {
a();
} catch (NullPointerException e) {
System.out.print("C");
} finally {
System.out.print("D");
}
System.out.println("E");
}
}
下面的程式會印出什麼?
public class Main {
static int f() {
try {
return 1;
} finally {
System.out.print("F");
}
}
public static void main(String[] args) {
System.out.println(f());
}
}
例外的家族與檢查型例外
所有例外都是類別,從 Throwable 往下分支:
Throwable
├── Error 嚴重的系統問題,程式通常無法處理
│ ├── OutOfMemoryError
│ └── StackOverflowError
└── Exception
├── IOException 檢查型例外(還有很多)
│ └── FileNotFoundException
├── InterruptedException
└── RuntimeException 非檢查型例外
├── ArithmeticException
├── NullPointerException
├── ClassCastException
├── IndexOutOfBoundsException
│ └── ArrayIndexOutOfBoundsException
└── IllegalArgumentException
└── NumberFormatException
因為是繼承關係,catch (RuntimeException e) 會接住它底下所有的子類別;catch (Exception e) 幾乎什麼都接。
Java 把例外分成兩種:
- 非檢查型例外(unchecked):
RuntimeException和它的子類別,以及Error。大多是程式本身的錯誤(索引算錯、忘了檢查 null), 應該修正程式,編譯器不要求你處理 - 檢查型例外(checked):其他的
Exception,例如IOException。這類狀況不是程式寫錯,而是外在環境造成的(檔案不存在、網路中斷), 編譯器強制你處理:呼叫可能拋出檢查型例外的方法時,要嘛用 try-catch 接住,要嘛在自己的方法標頭用throws宣告「我不處理,交給呼叫者」
void load() throws IOException { // 宣告:這個方法可能拋出 IOException
…
}
兩種都沒做,就是編譯錯誤:unreported exception IOException; must be caught or declared to be thrown。第 15 課讀寫檔案時會常常遇到。
throw 與自訂例外
你也可以自己用 throw 丟出例外,表示「這個情況我的方法沒辦法正常完成」:
if (age < 0) {
throw new IllegalArgumentException("年齡不能是負數:" + age);
}
throw 後面接一個例外物件(所以要 new)。執行到 throw 時,方法立刻結束,例外開始往外傳,和 Java 自己拋出的例外一樣。
內建的例外不夠貼切時,可以自訂例外類別:繼承 Exception(檢查型)或 RuntimeException(非檢查型),
建構子用 super(message) 把訊息交給父類別,getMessage() 就能取得:
class InsufficientFundsException extends Exception {
InsufficientFundsException(String message) {
super(message);
}
}
例外的名稱本身就是說明,InsufficientFundsException 比籠統的 Exception 清楚得多,呼叫者也可以針對它單獨寫 catch。
習慣上類別名稱以 Exception 結尾。
自訂例外沿著堆疊往外傳
第二次結帳 400 元時,withdraw 在第 13 行丟出 InsufficientFundsException。withdraw 和 shop 都沒有接住,只在標頭寫了 throws,所以例外一路傳到 main 的 catch。注意 shop 第 24 行的「結帳完成」只印了一次。
try-with-resources
檔案、網路連線這類資源用完一定要關閉,否則會一直占用系統資源。傳統寫法是在 finally 裡呼叫 close(),
又長又容易出錯。Java 7 起可以用 try-with-resources:在 try 後面的小括號裡建立資源,離開 try 時 Java 自動呼叫它的 close(),
不論是正常結束還是發生例外:
try (BufferedReader reader = new BufferedReader(new FileReader("note.txt"))) {
System.out.println(reader.readLine());
} catch (IOException e) {
System.out.println("讀檔失敗:" + e.getMessage());
} // reader 已經自動關閉
小括號裡的物件必須實作 AutoCloseable 介面(它只有一個 close() 方法)。檔案相關的類別都已經實作好了,第 15 課會實際使用。
下面用一個自己寫的 Door 類別,看清楚 close() 是什麼時候被呼叫的。
自動關上的門
第一個 try 正常結束,離開時自動呼叫 close()。第二個 try 在第 23 行拋出例外:Java 先呼叫 close() 關門,再進入 catch。
不要吞掉例外
初學者常為了讓編譯器不再抱怨,寫出空的 catch:
try {
data = load("scores.txt");
} catch (IOException e) {
// 什麼都不做
}
這叫做「吞掉例外」:錯誤明明發生了,卻沒有任何訊息,程式帶著錯誤的資料繼續跑,之後在別的地方出問題時,你完全不知道原因。 接住例外時至少要做一件事:
- 告訴使用者發生了什麼事,或改用合理的預設值,並在註解說明為什麼可以這樣做
- 印出
e.getMessage()或e.printStackTrace(),留下除錯線索 - 不知道怎麼處理就不要接,用
throws交給更上層、知道怎麼處理的程式
同樣地,不要動不動就寫 catch (Exception e) 一次接住全部:它也會接住你沒預料到的程式錯誤(例如 NullPointerException),把 bug 藏起來。
只接你知道怎麼處理的例外。
想一想:檢查型例外與 throw
下面的程式能不能編譯?
class MyException extends Exception {
MyException(String m) { super(m); }
}
public class Main {
static void check(int n) {
if (n < 0) throw new MyException("負數");
}
public static void main(String[] args) {
check(-1);
System.out.println("ok");
}
}
下面的程式會印出什麼?
public class Main {
static int age(int n) {
if (n < 0) throw new IllegalArgumentException("年齡不能是 " + n);
return n;
}
public static void main(String[] args) {
try {
System.out.print(age(20) + " ");
System.out.print(age(-3) + " ");
System.out.print(age(5) + " ");
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage());
}
}
}
下列哪一個是檢查型例外(呼叫可能拋出它的方法時,編譯器要求你處理)?
NumberFormatException 是 IllegalArgumentException 的子類別。要分別處理這兩種例外,catch 應該怎麼排?
動手寫:檢查成績資料
完成 toScore:把字串轉成整數,不在 0~100 之間就丟出 IllegalArgumentException(訊息是「超出範圍:分數」)。再在 main 的迴圈裡用 try-catch 處理兩種錯誤,只把有效的分數加總。注意 NumberFormatException 是 IllegalArgumentException 的子類別,catch 的順序很重要。
重點回顧
- 沒被處理的例外會讓程式停止並印出堆疊追蹤:第一行是例外類別與訊息,最上面的
at是拋出的位置,往下是呼叫路徑 try裡拋出例外時,後面的內容跳過,進入型別相符的catch;多個 catch 由上往下比對,子類別寫在前面;multi-catch 用|finally一定會執行,即使 try 或 catch 裡已經return- 沒被接住的例外沿著呼叫堆疊往外傳,可以在最適合的那一層處理
RuntimeException與Error是非檢查型;其他Exception是檢查型,必須 catch 或用throws宣告throw new XxxException(…)自己丟出例外;自訂例外繼承Exception或RuntimeException,用super(message)保存訊息- try-with-resources 離開時自動呼叫
close();不要寫空的 catch 吞掉例外,只接你知道怎麼處理的例外