CodeGym /課程 /Kotlin SELF /認識例外(Exceptions)

認識例外(Exceptions)

Kotlin SELF
等級 12, 課堂 1
開放

1. 編譯錯誤與執行錯誤

如果你之前把錯誤理解成「IDE 裡的紅色底線」,現在重要的是把它分成兩個世界。有些錯誤是 Kotlin 在執行之前(編譯階段)就能找到的;也有些問題只會在程式實際執行時才浮現。 例外屬於第二種——它們出現在程式已經跑起來,並遇到一個無法再「照計畫」繼續的情況時。

為了直觀起見,我們可以用表格來對比:

是什麼 何時發生 你會看到什麼 例子
編譯錯誤 程式啟動之前 IDE 會報錯,kotlinc 無法編譯
val x: Int = "你好"
例外(runtime exception) 執行期間 程式崩潰,印出訊息與 stack trace
println(10 / 0)

例外不是「又一個 if」。它是一種緊急機制,就像火警警報。 警報響了而且沒人知道怎麼處理時,人(也就是程式的執行)就會往出口跑(也就是程式結束)。

在 Kotlin 中,例外以物件表示,並且都是 Throwable 的子類別。 概略來說:Throwable → Exception / Error。 一般在應用層程式碼中,我們多半遇到的是 Exception;而 Error 更像是執行環境層級的嚴重問題(例如記憶體不足)。

什麼是例外

想像你的程式是一列火車,沿著程式碼的軌道前進。正常執行流程就像火車按路線行駛:第 1 行、第 2 行、第 3 行…… 例外就是:軌道上突然出現一塊混凝土障礙,前面過不去,火車也不會「小心地」跨過它。 它會停下來,並把控制權交給一套專門的錯誤處理機制。

形式上(而且非常重要,請記住):當例外被拋出時,正常執行會被中斷。 字面意思就是:「問題那一行之後的下一行不會執行」,除非例外被捕捉(handled)。

迷你範例——經典的「除以零」:

fun main() {
    println("start")
    val a = 10
    val b = 0
    println(a / b)        // ArithmeticException
    println("end")        // 不會執行
}

注意:println("end") 這行看起來很無辜,但它不會被執行。 因為程式在 a / b 這行就從正常路線「跳出」了。

2. 例外會中斷執行,讓程式無法「繼續往下」

對新手來說,例外最容易困惑的一點是:我們習慣把程式當成「命令清單」,它會按順序一條一條執行。 但突然發現,這個順序可以被硬生生打斷。 所以我再用另一種說法重申一次:例外不是「錯誤訊息」,它是控制流程的改變。

當例外出現時,目前這個函式會不是用 return結束,而是以異常方式終止。 接著 Kotlin/JVM 會嘗試尋找一個願意處理該例外的位置。 如果找不到——程式就會結束,而你會看到錯誤訊息與呼叫堆疊。

另一個重要細節:例外可能不是發生在你寫下「危險」程式碼的那一行,而是藏在你呼叫的函式深處。 例如,你呼叫 toInt(),它在內部嘗試解析字串;如果解析失敗,就會拋出 NumberFormatException。 這個例外會一路「冒泡」回到你的程式碼。

例如:

fun main() {
    val text = "12a"
    val n = text.toInt()  // NumberFormatException
    println("n = $n")     // 不會執行
}

也就是說,你沒有自己建立它,但例外依然會出現,因為函式庫判斷:「不能再繼續了」。

3. 例外到底從哪裡來

在這個階段,與其背一百個例外名稱,不如先看清楚「會導致例外的情境類型」。 在簡單的教學程式中,最常冒出的三大類問題是:算術、字串轉換,以及陣列索引。

算術:除以零

算術直覺上很清楚:有些運算在數學上沒有定義。 JVM 不會「猜」你想表達什麼,於是會拋出例外。

fun main() {
    val total = 100
    val days = 0
    val perDay = total / days
    println(perDay) // 不會到這裡
}

把字串轉成數字:「這不是數字」

你已經知道安全路徑 toIntOrNull()。 但 toInt() 是「嚴格」版本。 如果字串不是合法的整數,就會出現 NumberFormatException。

fun main() {
    val raw = "forty two"
    val n = raw.toInt()      // NumberFormatException
    println(n)
}

陣列索引:超出界限

陣列大小固定,索引必須落在 0..lastIndex 這個範圍內。 否則你會得到執行期例外(在 JVM 上常見的是 ArrayIndexOutOfBoundsException)。

fun main() {
    val xs = intArrayOf(10, 20, 30)
    println(xs[2])  // 30
    println(xs[5])  // ArrayIndexOutOfBoundsException
}

這裡常見的心理陷阱是:「索引也是數字啊,我都解析成功了!」 沒錯,是數字,但不是任意數字——而是合法的數字。例外正是在告訴你:「規則被違反了」。

4. 例外的階層:Throwable、Exception、Error

當你看到錯誤訊息時,幾乎一定會看到例外型別名稱。 為了不要把它當成一串隨機咒語,理解最上層的階層會很有幫助。

階層的根是 Throwable。 它有兩個重要的「第一層子類」:Exception 和 Error。 概念是:Exception 是在正常程式生命週期中「可能發生」的狀況(輸入錯誤、資源不可用等等), 而 Error 通常是環境層級的嚴重問題,用你的業務邏輯去「修」往往沒有意義(例如 OutOfMemoryError)。

在這個階段,你先記住這張圖就夠了:

flowchart TD
    T[Throwable]
    E[Exception]
    ER[Error]
    T --> E
    T --> ER
    E --> NFE[NumberFormatException]
    E --> AE[ArithmeticException]
    E --> AIOOBE[ArrayIndexOutOfBoundsException]

重點是:這不表示你必須把整個階層背下來。 但當你看到 ArithmeticException 時,你應該聯想到:「哦,這是 Exception,表示我的執行情境在執行期出了問題」。

原因與位置:例外名稱與 stack trace

程式崩潰時,新手常有一種感覺:「好像壞在某個地方」。 但例外機制就是為了提供你兩個提示:發生了什麼(原因)以及 在哪裡發生(位置)。

原因通常藏在型別名稱與訊息裡。 例如,NumberFormatException 字面意思就是:「字串不符合數字格式」。 位置通常能在 stack trace 中看到——它是一條導向錯誤的函式呼叫鏈。 Kotlin/JVM 在例外未被處理時會自動印出 stack trace。

一個刻意製造錯誤的迷你例子:

fun main() {
    throw ArithmeticException("例外示範")
}

大致輸出(外觀可能不同,但意思一樣):

Exception in thread "main" java.lang.ArithmeticException: 例外示範
    at MainKt.main(Main.kt:2)

目前你不需要完美閱讀 stack trace(我們會另外練習)。 但現在就先知道:程式一旦崩潰,通常會留下「收據」,告訴你大概是在哪裡出問題。

5. 實作範例

為了讓例外不只是抽象概念,我們來繼續寫一個小型主控台程式。 就叫它 BudgetBuddy:使用者輸入多天的支出,程式計算總和以及「每日平均」。

我們先用最簡單的方式寫,不做例外處理(下一講才會加)。 此處的目標是:觀察執行流程到底是怎麼壞掉的。

天真版本:「一切照計畫」

先從最基本的流程:讀取天數,接著把每天支出讀進陣列,再計算總和。

fun main() {
    print("幾天? ")
    val days = readln().toInt()

    val expenses = IntArray(days)

    for (i in 0 until days) {
        print("第 ${i + 1} 天支出:")
        expenses[i] = readln().toInt()
    }

    var total = 0
    for (x in expenses) total += x

    println("合計:$total")
}

如果使用者輸入的都是正常數字,那就沒問題。 但「正常數字」通常只是美好的幻想。

崩潰情境:使用者輸入了非數字

假設使用者這樣輸入:

  • 「幾天?」 → 3
  • 「第 1 天支出」 → 100
  • 「第 2 天支出」 → 一百(或 100 元)

在 readln().toInt() 這行會發生 NumberFormatException,程式隨即結束。 而且它發生在迴圈內,因此我們甚至走不到加總,更走不到最後的 println("合計...")。

崩潰情境:使用者輸入 0 天(而我們想算平均)

加入平均值。天真寫法:

fun main() {
    print("幾天? ")
    val days = readln().toInt()

    print("支出總額:")
    val total = readln().toInt()

    val avg = total / days
    println("每日平均:$avg")
}

如果 days = 0,在 total / days 這行就會拋出 ArithmeticException。 同樣地:println(...) 不會被執行。

這裡特別陰險的是:即使 days 解析成功(也就是它確實是數字),它仍可能對你的邏輯而言是不可用的值。 例外常常不是發生在解析,而是發生在使用這個值的時候。

崩潰情境:索引用錯

假設我們想印出「最後一筆輸入的支出」,但不小心拿了索引 days,而不是 days - 1。 (這是無數代程式設計師都踩過的坑,傳統會延續下去。)

fun main() {
    val expenses = intArrayOf(100, 200, 300)
    val days = expenses.size

    println(expenses[days]) // days == 3,但索引是 0..2
}

這會得到 ArrayIndexOutOfBoundsException。程式再次以異常方式結束。

例外發生時會怎樣:迷你流程圖

為了加深模型印象,可以把程式行為想成下面這張圖。 程式一行一行執行,直到一切正常。 某行拋出例外時,就會「緊急退出」目前的函式,並沿著呼叫鏈往上找。

flowchart TD
    A[逐行執行] --> B{發生錯誤了嗎?}
    B -->|否| A
    B -->|是:拋出例外| C[中斷目前函式]
    C --> D[往上沿著呼叫堆疊尋找處理器]
    D --> E{找到處理器了嗎?}
    E -->|否| F[印出 stack trace 並結束程式]
    E -->|是| G[把控制權交給處理器]

今天你只要記住「找不到處理器 → 程式結束」這條支線即可。 「找到處理器」我們會在下一講詳細談,當 try/catch 出現時就會用到。

6. 常見錯誤

錯誤 1:把編譯錯誤和執行期例外混為一談。
如果 IDE 在你啟動之前就畫紅線——那是編譯階段,程式甚至不會開始跑。 如果程式已經跑起來、也輸出了一些內容,接著突然「崩」掉並印出一大段文字——那就是例外,也就是執行期問題。

錯誤 2:以為「問題那行之後」的程式碼還是會執行。
這是最常見的錯誤。直覺會覺得 println("end") 一定會印出來,因為它在除法「後面」。 但例外會打破線性順序:執行會被中斷。 腦中要切換開關:「例外 = 緊急退出」。

錯誤 3:以為用了 toIntOrNull() 就再也不會有例外。
toIntOrNull() 的確能避免 NumberFormatException,但它不會保護你免於除以零、陣列越界與邏輯錯誤。 它不是「萬能護甲」,而是針對特定原因的工具。

錯誤 4:忽略例外名稱,把 stack trace 當成「可怕的長篇大論」。
即使不會除錯,例外名稱往往非常有資訊量: NumberFormatException — 不是數字, ArithmeticException — 算術問題, ArrayIndexOutOfBoundsException — 索引越界。 Kotlin/JVM 會刻意提供這些線索。

錯誤 5:把例外當成「世界末日」,所以乾脆「別碰它」。
其實例外是很正常的工作機制。 問題不在它存在,而在於我們還不會正確處理並把它轉成可理解的程式行為。 接下來我們就會做這件事:學會捕捉可預期的例外並繼續執行,不把主控台變成悲劇舞台。

1
任務
Kotlin SELF, 等級 12, 課堂 1
上鎖
兩人分攤的收銀
兩人分攤的收銀
1
任務
Kotlin SELF, 等級 12, 課堂 1
上鎖
損壞的優惠券
損壞的優惠券
1
任務
Kotlin SELF, 等級 12, 課堂 1
上鎖
倉庫貨架
倉庫貨架
1
任務
Kotlin SELF, 等級 12, 課堂 1
上鎖
故障電梯
故障電梯
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION