1. 編譯錯誤與執行錯誤
如果你之前把錯誤理解成「IDE 裡的紅色底線」,現在重要的是把它分成兩個世界。有些錯誤是 Kotlin 在執行之前(編譯階段)就能找到的;也有些問題只會在程式實際執行時才浮現。 例外屬於第二種——它們出現在程式已經跑起來,並遇到一個無法再「照計畫」繼續的情況時。
為了直觀起見,我們可以用表格來對比:
| 是什麼 | 何時發生 | 你會看到什麼 | 例子 |
|---|---|---|---|
| 編譯錯誤 | 程式啟動之前 | IDE 會報錯,kotlinc 無法編譯 | |
| 例外(runtime exception) | 執行期間 | 程式崩潰,印出訊息與 stack trace | |
例外不是「又一個 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:把例外當成「世界末日」,所以乾脆「別碰它」。
其實例外是很正常的工作機制。 問題不在它存在,而在於我們還不會正確處理並把它轉成可理解的程式行為。 接下來我們就會做這件事:學會捕捉可預期的例外並繼續執行,不把主控台變成悲劇舞台。
GO TO FULL VERSION