National Taiwan University
Uedu Main Site
Explore Uedu
Student Console
Register as Member/Login
Research Informed Consent Center
Survey Center
Educational Big Data Challenge
Claim or create a course
Try a Scenario Task
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

2

Local AI

--

Uedu Code

--

台灣大學 AQI 70 24°C PM2.5 17
AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

Unit 12

例外處理

try/catch/finally、檢查型例外、throw 與自訂例外
Interaction實作 25 minute(s)

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),是除錯時最重要的線索。由上往下讀:

  1. 第一行:例外的類別(java.lang.ArithmeticException)與訊息(/ by zero)。先看這行,知道發生了什麼事
  2. at Main.divide(Main.java:4):例外是在 divide 方法、Main.java 第 4 行拋出的。最上面的 at 就是出事的地方
  3. 往下每一行是「誰呼叫了上面那一行」: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

1.

程式停止時印出下面的訊息。例外是在哪裡拋出的?

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)
2.

下面的程式會印出什麼?

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");
    }
}
3.

下面的程式會印出什麼?

public class Main {
    static int f() {
        try {
            return 1;
        } finally {
            System.out.print("F");
        }
    }

    public static void main(String[] args) {
        System.out.println(f());
    }
}
第 1 次作答

例外的家族與檢查型例外

所有例外都是類別,從 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

1.

下面的程式能不能編譯?

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");
    }
}
2.

下面的程式會印出什麼?

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());
        }
    }
}
3.

下列哪一個是檢查型例外(呼叫可能拋出它的方法時,編譯器要求你處理)?

4.

NumberFormatException 是 IllegalArgumentException 的子類別。要分別處理這兩種例外,catch 應該怎麼排?

第 1 次作答

動手寫:檢查成績資料

完成 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 吞掉例外,只接你知道怎麼處理的例外
這一課完成了!
Next lesson:集合框架
你目前沒有登入,學習進度只存在這個瀏覽器。 Login 後,進度會跟著帳號,換電腦也看得到。