Home
Explore Uedu
Student Console
Register as Member/Login
(2) In future presentations of the research findings, in addition to the course project website and public presentations, your real name and personal information will not appear in this research report. If you are interested in the research results, we can provide you with an executive summary after the study is completed.
問卷中心
Teacher Console
Course Setup
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

0

Local AI

--

CISOSE26 本地 AI UG26
政治大學 AQI 18 29°C

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

例外處理

Java 例外處理:用編譯器強迫你正視錯誤

從例外階層、try/catch/finally 到 try-with-resources,理解 Java 招牌的 checked 例外設計與它二十年的爭議

當檔案讀到一半,硬碟突然被拔掉

想像你寫了一個程式,要從磁碟讀一份學生名單、逐行解析、再寫進資料庫。一切都很順——直到某天有人在程式跑到一半把隨身碟拔掉。檔案讀取會失敗,但你的程式接下來該怎麼辦?是直接整個崩潰、印出一堆看不懂的訊息,還是優雅地告訴使用者「檔案讀取中斷,已回復到安全狀態」?

這就是「例外處理(exception handling)」要解決的問題:把「正常邏輯」和「出錯時怎麼辦」這兩件事分開來寫,讓程式碼既清楚又穩健。Java 在這件事上做了一個其他主流語言幾乎都沒做的激進決定——它用編譯器強迫你正視某些錯誤。這個設計到今天還在被熱烈爭論,我們會在最後深入聊。

如果你讀過本專區的 Python 篇,會發現 Java 的例外觀念在「try/catch」層面神似,但 Java 多了一層 Python 沒有的東西:checked 例外。這正是 Java 作為「靜態強型別、編譯期就想抓住你錯誤」的語言哲學體現。

Java 例外處理概念示意圖

例外其實是「物件」:先看階層

在 Java 裡,例外不是某種神秘的訊號,它就是一個普通的物件,繼承自 Throwable。這一點和 Java「OOP-first(物件導向優先)」的本性完全一致——連「錯誤」都是類別。整個階層長這樣:

Throwable
├── Error              (JVM 層級的嚴重問題,例如 OutOfMemoryError,通常不該 catch)
└── Exception
    ├── RuntimeException   (unchecked:執行期才會爆,編譯器不強制處理)
    │   ├── NullPointerException
    │   ├── ArrayIndexOutOfBoundsException
    │   └── IllegalArgumentException
    └── IOException、SQLException…   (checked:編譯器強制你處理)

記住兩條分界線:

  1. Error vs ExceptionError 是 JVM 自己出事(記憶體爆了、堆疊溢位),一般應用程式不該去 catch 它,因為你也救不了。我們平常處理的都是 Exception 這一支。
  2. RuntimeException vs 其他 Exception:這條線就是 unchecked vs checked 的分界,是 Java 例外處理最核心、也最有 Java 特色的概念。

相較於 Python——Python 的例外也是物件、也有階層(BaseExceptionException),但 Python 完全沒有 checked/unchecked 的區分,所有例外都不需要事先宣告。Java 偏要在型別系統裡把這件事標記出來,這是兩種語言哲學的根本差異。

try / catch / finally:三段式的安全網

最基本的語法。我們把「可能出錯的程式碼」放進 try,把「出錯後的善後」放進 catch,把「不管成功失敗都要做的收尾」放進 finally

public class DivideDemo {
    public static void main(String[] args) {
        try {
            int[] data = {10, 20, 30};
            int result = data[0] / 0;        // 這行會丟 ArithmeticException
            System.out.println(result);       // 永遠不會執行到
        } catch (ArithmeticException e) {
            System.out.println("數學錯誤:" + e.getMessage());
        } finally {
            System.out.println("收尾:不管有沒有出錯,我都會跑。");
        }
        System.out.println("程式繼續往下。");
    }
}
// 輸出:
// 數學錯誤:/ by zero
// 收尾:不管有沒有出錯,我都會跑。
// 程式繼續往下。

幾個關鍵點:

  • 一旦 try 區塊裡某行丟出例外,該行之後的程式碼會被跳過,直接跳到對應的 catch
  • finally 幾乎保證執行(連 try 裡有 return 都擋不住它先跑),所以傳統上用來釋放資源(關檔案、關連線)。
  • catch 的型別有多型(polymorphism):寫 catch (Exception e) 可以接住所有 Exception 子類,但這通常太寬鬆——你會把真正該往上拋的錯也吃掉。

多重 catch 與順序陷阱

可以針對不同例外分別處理。注意:子類別的 catch 必須寫在父類別之前,否則編譯不過。

try {
    process(args);
} catch (NumberFormatException e) {   // 較具體的子類,放前面
    System.out.println("數字格式錯誤");
} catch (IllegalArgumentException e) { // 父類,放後面
    System.out.println("參數錯誤");
} catch (Exception e) {                // 最寬鬆的,墊底
    System.out.println("其他未預期錯誤");
}

Java 7 後也支援用 | 合併不會相互衝突的多個型別:

try {
    risky();
} catch (IOException | SQLException e) {   // 兩種都用同一段處理
    log.error("外部資源失敗", e);
    throw new ServiceException("服務暫時不可用", e);
}

checked vs unchecked:Java 最有個性的設計

這是 Java 區別於 Python、JavaScript、C# 的標誌性設計,務必搞懂。

checked 例外:繼承自 Exception繼承 RuntimeException(例如 IOExceptionSQLException)。編譯器強制你要嘛 catch 它、要嘛在方法簽章用 throws 宣告往外丟。漏掉就無法編譯。它代表「可預期、呼叫端理應有能力應對」的錯誤,例如檔案不存在、網路斷線。

unchecked 例外:繼承自 RuntimeException(例如 NullPointerExceptionIllegalArgumentException)。編譯器強制處理,通常代表「程式設計上的 bug」——是你該在開發時修掉的,而不是在執行期 catch 的。

看一段對照就懂:

import java.io.*;

public class CheckedDemo {

    // checked:readFile 內部可能丟 IOException,不處理就必須用 throws 宣告
    static String readFirstLine(String path) throws IOException {
        BufferedReader br = new BufferedReader(new FileReader(path));
        try {
            return br.readLine();
        } finally {
            br.close();
        }
    }

    // unchecked:age 不合法是「呼叫者的 bug」,不必宣告 throws
    static void setAge(int age) {
        if (age < 0) {
            throw new IllegalArgumentException("年齡不可為負:" + age);
        }
        System.out.println("年齡設為 " + age);
    }

    public static void main(String[] args) {
        // 呼叫 checked 方法,編譯器強迫我們處理 IOException
        try {
            System.out.println(readFirstLine("notes.txt"));
        } catch (IOException e) {
            System.out.println("讀檔失敗:" + e.getMessage());
        }

        // 呼叫 unchecked 方法,編譯器不管,但執行期會爆
        setAge(-5);  // 丟出 IllegalArgumentException
    }
}

如果你把 readFirstLinethrows IOException 拿掉、又不在內部 catch,程式根本編譯不過——這就是 checked 的「強迫正視」威力。相較之下,setAge 丟的 unchecked 例外,編譯器完全不囉嗦。

一句話心法:checked = 「外部世界可能出事,請你預先想好對策」;unchecked = 「你自己寫錯了,去把 bug 修掉」。

throws 宣告:把責任往上推

當一個方法自己不想(或不該)處理某個 checked 例外時,可以用 throws 在簽章上宣告,把責任「往呼叫它的人」推。這是一種契約:方法在告訴所有呼叫者「我可能丟這種例外,你得準備好」。

// 第一層:只負責讀,不負責決定出錯怎麼辦 → 往上拋
static String loadConfig(String path) throws IOException {
    return Files.readString(Path.of(path));   // 可能丟 IOException
}

// 第二層:同樣不決定,繼續往上拋
static Config parseConfig(String path) throws IOException {
    String raw = loadConfig(path);
    return Config.from(raw);
}

// 最上層(main):在這裡才真正決定怎麼辦
public static void main(String[] args) {
    try {
        Config cfg = parseConfig("app.conf");
        System.out.println("載入成功:" + cfg);
    } catch (IOException e) {
        System.out.println("設定載入失敗,改用預設值。");
    }
}

這展現了一個重要的設計原則:例外應該在「有足夠資訊與權力做決定」的那一層被處理。底層的讀檔函式不知道「失敗了該不該用預設值」,所以它不處理、只宣告往上拋;到了 main 才知道整體策略,於是在那裡 catch。

注意 throwsthrow 是兩個不同的東西,初學者很常搞混:

  • throw(無 s):在程式碼裡實際丟出一個例外物件,例如 throw new IllegalArgumentException(...)
  • throws(有 s):寫在方法簽章上,宣告這個方法可能丟出哪些 checked 例外。

try-with-resources:自動關門的現代寫法

前面用 finally 手動 close() 其實很容易出錯:忘了關、或者 close() 本身又丟例外把原本的錯蓋掉。Java 7 引入 try-with-resources 徹底解決這件事。只要資源實作了 AutoCloseable 介面,放進 try(...) 的括號裡,離開 try 區塊時 JVM 自動呼叫 close(),不論正常結束還是丟例外。

對照一下舊寫法和新寫法:

// 舊寫法:囉嗦又易錯
static String oldWay(String path) throws IOException {
    BufferedReader br = new BufferedReader(new FileReader(path));
    try {
        return br.readLine();
    } finally {
        br.close();   // 要記得寫;若 br 是 null 還會 NPE
    }
}

// 新寫法:簡潔且保證關閉
static String newWay(String path) throws IOException {
    try (BufferedReader br = new BufferedReader(new FileReader(path))) {
        return br.readLine();
    }   // ← 離開這裡時自動 br.close(),不用寫 finally
}

可以一次宣告多個資源,用分號隔開,關閉順序與宣告順序相反(後開先關,符合資源依賴的直覺):

try (FileInputStream in = new FileInputStream("src.dat");
     FileOutputStream out = new FileOutputStream("dst.dat")) {
    in.transferTo(out);
}   // 先關 out,再關 in

相較於 Python 的 with 陳述式,try-with-resources 是非常類似的概念(兩者都靠一個「進場/離場」協定,Python 用 __enter__/__exit__,Java 用 AutoCloseable.close())。差別在 Java 把它整合進既有的 try/catch 語法,可以無縫接上 catchfinally

動手寫一段:把所有觀念串起來

以下是一個完整可執行的程式。它模擬「解析一串使用者輸入的數字、計算平均」,故意混入會丟 unchecked 例外的壞資料,示範 try-with-resources、多重 catch、finally、以及自訂例外往外拋的完整流程。複製貼上就能跑。

import java.io.*;
import java.util.*;

public class AverageCalculator {

    // 自訂 checked 例外:代表「資料品質不合格」這種可預期的業務錯誤
    static class InvalidDataException extends Exception {
        InvalidDataException(String msg) { super(msg); }
    }

    static double average(String line) throws InvalidDataException {
        String[] tokens = line.split(",");
        if (tokens.length == 0) {
            throw new InvalidDataException("沒有任何數字");
        }
        long sum = 0;
        for (String t : tokens) {
            try {
                sum += Integer.parseInt(t.trim());   // 壞資料會丟 NumberFormatException(unchecked)
            } catch (NumberFormatException e) {
                throw new InvalidDataException("不是合法整數:'" + t.trim() + "'");
            }
        }
        return (double) sum / tokens.length;
    }

    public static void main(String[] args) {
        // 用 try-with-resources 模擬讀取一串輸入
        String input = "10, 20, 30, x, 40";
        try (BufferedReader br = new BufferedReader(new StringReader(input))) {
            String line = br.readLine();
            System.out.printf("平均 = %.2f%n", average(line));
        } catch (InvalidDataException e) {
            System.out.println("資料錯誤:" + e.getMessage());
        } catch (IOException e) {
            System.out.println("讀取錯誤:" + e.getMessage());
        } finally {
            System.out.println("計算流程結束。");
        }
    }
}
// 輸出:
// 資料錯誤:不是合法整數:'x'
// 計算流程結束。

input 改成 "10, 20, 30, 40" 再跑一次,輸出會變成:

平均 = 25.00
計算流程結束。

留意這支程式的設計:底層 Integer.parseInt 丟的是 unchecked 的 NumberFormatException,但我們在 average把它「翻譯」成一個有業務意義的 checked InvalidDataException 再往外拋。這個「例外轉譯(exception translation)」是很實務的技巧——不要讓底層的技術性例外原封不動地穿透到高層,那會讓上層程式碼依賴底層細節。

常見錯誤:初學者最常踩的五個雷

  1. 吞掉例外(swallowing):寫了 catch (Exception e) {} 卻什麼都不做。錯誤就這樣無聲消失,日後 debug 會崩潰。至少要 log 出來,包含完整的 e(堆疊軌跡),不要只印 e.getMessage()

  2. 用 catch 控制正常流程:例外是給「例外狀況」用的,不是 if-else 的替代品。例如「檢查 key 在不在 Map 裡」應該用 containsKey,而不是 catch NullPointerException。例外的建立與堆疊軌跡填充成本不低,濫用會拖慢效能。

  3. catch 範圍太寬:第一行就寫 catch (Exception e),把連同 NullPointerException 這種「你自己的 bug」都一起接住,等於把 bug 藏起來。只 catch 你真的有辦法處理的具體例外

  4. 在 finally 裡 return 或丟例外finally 裡的 return 會覆蓋 try 裡的 returnfinally 裡丟的例外會吞掉 try 裡原本的例外。這會造成極難追查的詭異行為,務必避免。

  5. 混淆 throwthrowsthrow 是動作(丟一個物件出去),throws 是宣告(在簽章上標記)。寫錯通常編譯就過不了,但有時會誤用 throw new IOException 卻忘了該方法沒有 throws IOException 宣告而卡關。

重點回顧

  • 例外是繼承自 Throwable物件Error 別碰,Exception 才是我們處理的對象。
  • checked(強制處理,代表外部可預期錯誤)vs unchecked / RuntimeException(不強制,多半代表程式 bug)是 Java 的招牌設計。
  • try/catch/finally 三段式;finally 幾乎必跑,傳統用於收尾。
  • throws 把處理責任往呼叫端推;應在「有能力做決定」的那層才 catch。
  • 開資源優先用 try-with-resources,讓 AutoCloseable 自動關閉,告別手寫 finally close()

深入探討(研究所視角)

checked 例外:一個被熱烈爭論了二十年的設計

Java 是主流語言中幾乎唯一堅持 checked 例外的。C#、Python、JavaScript、Kotlin、Scala 全部都放棄了它。為什麼這個看似「貼心地強迫你處理錯誤」的設計,會變成 Java 社群最大的爭議點之一?

支持方的論點:checked 例外把「這個方法可能失敗、以及怎麼失敗」寫進了型別簽章,成為編譯期可驗證的契約。呼叫者無法「假裝沒看到」一個 IOException,這對企業級系統的健壯性有實質幫助——你不會因為忘了處理網路逾時而讓整個交易系統默默損毀。它把錯誤處理從「文件上的約定」提升成「編譯器執行的約束」,這與 Java「靜態強型別、編譯期抓住盡可能多錯誤」的整體哲學一脈相承。

反對方的論點(這也是業界主流意見):

  • 破壞抽象與可組合性。當你在某個介面方法上宣告 throws SQLException,這個底層細節就「洩漏」到了所有上層介面。日後想把儲存層從 SQL 換成 NoSQL,整條呼叫鏈的簽章都得改。checked 例外讓介面與實作細節耦合。
  • 誘發「吞例外」的反模式。當編譯器逼著你處理一個你當下不知道怎麼處理的例外,最省事的做法就是寫個空的 catch {},反而比沒有 checked 例外更危險——錯誤被靜默吃掉了。
  • 與 lambda、Stream 等函數式特性嚴重衝突。Java 8 的 FunctionStream.map 等函數式介面不允許丟 checked 例外。一旦你想在 stream().map(this::readFile) 裡呼叫會丟 checked 例外的方法,就得寫一堆醜陋的 try/catch 包裝,或自訂 unchecked 包裝介面。這是 checked 例外與現代 Java 風格格格不入的最痛點。

業界目前的主流實務(例如 Spring 框架的設計哲學)是:盡量少用 checked 例外,傾向把它們在邊界處轉譯成 unchecked 的 runtime exception。Spring 著名地把 JDBC 的 checked SQLException 統一包成 unchecked 的 DataAccessException 階層,正是因為「呼叫端對一個 SQL 錯誤通常無能為力,強迫處理只會製造樣板程式碼」。

例外處理的最佳實務

綜合主流經驗,可以歸納幾條跨越語言的原則:

  1. Throw early, catch late(早丟、晚接):在偵測到問題的最早一刻就丟出例外(例如方法入口處驗證參數),但在擁有足夠情境能做決定的最高層才 catch。中間層一律往上拋。

  2. 保留因果鏈(exception chaining):轉譯例外時,務必把原始例外當作 cause 傳進去:throw new ServiceException("...", e),而不是 throw new ServiceException("...")。否則原始堆疊軌跡會遺失,debug 時你會只看到「服務失敗」卻不知道根因是磁碟滿了。Java 的 Throwable(String, Throwable) 建構子就是為此設計,列印時會顯示 Caused by: 鏈。

  3. 別用例外控制流程。承前所述,例外建立時要捕捉完整堆疊軌跡,這是有成本的操作(雖然 JIT 會優化,但相對於普通條件判斷的 $O(1)$ 仍貴上許多)。正常的「找不到」「不存在」狀況,優先用回傳值(如 Java 的 Optional<T>)表達。

  4. catch 具體型別,不要 catch ExceptionThrowable。catch ThrowableError(如 OutOfMemoryError)都接住,會掩蓋 JVM 的致命問題。

底層機制:例外處理「零成本」嗎?

從 JVM 實作角度,現代 Java 的例外處理採用例外表(exception table)機制。每個方法在編譯後的 bytecode 裡附帶一張表,記錄「哪段位元組碼範圍由哪個 handler 處理」。關鍵在於:只要不丟例外,這張表完全不影響正常執行路徑的速度——也就是說,包一層 try 本身在「沒出錯」時幾乎是零開銷,這和早期某些用 setjmp/longjmp 的 C 風格錯誤處理很不同。

真正昂貴的是丟出例外的那一刻:JVM 要建立例外物件、並透過 fillInStackTrace() 走訪當前呼叫堆疊把每一層的類別名、方法名、行號填進去。這步驟的成本與堆疊深度成正比。這也再次解釋了為什麼「不要用例外控制流程」——在熱路徑(hot path)上頻繁丟例外,fillInStackTrace 的開銷會非常顯著。(進階技巧:在確定不需要堆疊軌跡的高頻例外上,可以覆寫 fillInStackTrace() 回傳 this 來省下這筆成本,但這是要極度謹慎使用的優化。)

理解到這一層,你就能明白 Java 例外處理設計背後的每一個取捨:它把錯誤當物件、用型別系統做契約、用 JVM 機制讓正常路徑零成本——這一切都是「靜態強型別、企業級健壯性優先」這套價值觀的具體實踐。而 checked 例外的爭議,本質上是「編譯期安全」與「程式碼簡潔可組合」這兩個美德之間,至今仍未有定論的拉鋸。

AI 共讀助教正在陪你讀:Java 例外處理:用編譯器強迫你正視錯誤
嗨!我是這篇文章的共讀助教,只根據〈Java 例外處理:用編譯器強迫你正視錯誤〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。