Kotlin の例外処理は、try { 処理 } catch (e: 例外の型) { 対応 } finally { 後始末 } の形で書きます。 Java とよく似ていますが、try が値を返す「式」であること、検査例外(必ず catch か宣言が必要な例外)がないこと、結果を値として受け取る runCatching と Result があることが違いです。

この記事では、try-catch の基本から、finally の実行順、複数の catch を並べる順序、スタックトレースの読み方までを実行出力つきで確かめます。そのうえで、Result の関数を戻り値つきの一覧表にし、try-catch と runCatching をどう使い分けるかを表にまとめました。runCatching がコルーチンのキャンセルまで受け止めてしまう挙動も、kotlinx-coroutines で再現しています。

確認環境:Ubuntu 24.04 / Kotlin 2.4.20(kotlinc)/ OpenJDK 21.0.10 / kotlinx-coroutines-core 1.8.0。掲載した出力とエラー文は、すべてこの環境でコンパイル・実行して得たものです。

例外処理の書き方 早見表

やりたいこと 書き方 ポイント
例外を受け止める try { ... } catch (e: NumberFormatException) { ... } catch は型ごとに複数並べられる
必ず後始末をする finally { ... } 例外の有無にかかわらず実行される
例外のとき代わりの値にする val n = try { s.toInt() } catch (e: NumberFormatException) { -1 } try は式。値をそのまま変数に入れられる
例外を投げる throw IllegalArgumentException("理由") throw も式で、型は Nothing
結果を値として受け取る val r = runCatching { s.toInt() } Result 型。あとから getOrElse や fold で取り出す
引数を検査する require(count > 0) { "メッセージ" } IllegalArgumentException
状態を検査する check(!closed) { "メッセージ" } IllegalStateException
来ないはずの分岐で止める error("メッセージ") IllegalStateException
使い終わったら必ず閉じる resource.use { ... } ブロックを抜けると close() が呼ばれる
Java に例外を伝える @Throws(IOException::class) Java 側のシグネチャに throws が付く
独自の例外を作る class ConfigException(message: String, cause: Throwable? = null) : Exception(message, cause) cause に元の例外を渡す

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Kotlin例外処理 try-catchとrunCatchingの使い分け」

try-catch-finally の基本

次のコードを TryBasic.kt という名前で保存します。

fun parseQuantity(text: String): Int {
    try {
        val n = text.toInt()
        println("変換できた: $n")
        return n
    } catch (e: NumberFormatException) {
        println("数値ではない: ${e.message}")
        return 0
    } finally {
        println("finally: 必ず通る")
    }
}

fun main() {
    println("戻り値 = ${parseQuantity("12")}")
    println("---")
    println("戻り値 = ${parseQuantity("12個")}")
}

コンパイルして実行します。-include-runtime は Kotlin の標準ライブラリを jar に含める指定、-Dstdout.encoding=UTF-8 は日本語の出力を UTF-8 にそろえる指定です。

kotlinc TryBasic.kt -include-runtime -d trybasic.jar
java -Dstdout.encoding=UTF-8 -jar trybasic.jar
変換できた: 12
finally: 必ず通る
戻り値 = 12
---
数値ではない: For input string: "12個"
finally: 必ず通る
戻り値 = 0

"12個".toInt() は NumberFormatException を投げるので、try の残りは飛ばされて catch に移ります。どちらの場合も、return の前に finally が実行されています。

try は式:値をそのまま受け取れる

Kotlin の try は値を返します。try ブロックか catch ブロックの、最後に評価した式が結果になります。

fun main() {
    // try は式。最後に評価した値がそのまま結果になる
    val a: Int = try { "42".toInt() } catch (e: NumberFormatException) { -1 }
    val b: Int = try { "x".toInt() } catch (e: NumberFormatException) { -1 }
    println("a=$a b=$b")

    // catch の中で null を返せば、型は Int? になる
    val c: Int? = try { "x".toInt() } catch (e: NumberFormatException) { null }
    println("c=$c")

    // finally の値は結果にならない
    val d = try { 1 } finally { println("finally は実行されるが値は使われない"); 99 }
    println("d=$d")

    // 短く書くなら xxxOrNull とエルビス演算子
    val e = "x".toIntOrNull() ?: -1
    println("e=$e")
}
a=42 b=-1
c=null
finally は実行されるが値は使われない
d=1
e=-1
  • finally の値は結果になりません。 d は 99 ではなく 1 です(コンパイル時にも、99 に対して expression is unused. という警告が出ます)。
  • catch で null を返すなら、受け取る変数は Int? にします。Int のままだと initializer type mismatch: expected 'Int', actual 'Int?'. というコンパイルエラーになります。
  • 文字列を数値にするだけなら、例外を使わない toIntOrNull() とエルビス演算子 ?: の組み合わせがいちばん短く書けます。

finally の実行順を確かめる

例外が出ないとき、catch するとき、catch しないとき、return するときの4通りで、通る順番を番号で表示します。

fun step(label: String, block: () -> Unit) {
    println("[$label]")
    try {
        println("  1 try 開始")
        block()
        println("  2 try 終了")
    } catch (e: IllegalStateException) {
        println("  3 catch: ${e.message}")
    } finally {
        println("  4 finally")
    }
    println("  5 try の後ろ")
}

fun withReturn(): String {
    try {
        return "try の値"
    } finally {
        println("  return の前に finally が動く")
    }
}

fun main() {
    step("例外なし") { }
    step("catch する例外") { throw IllegalStateException("状態が不正") }
    try {
        step("catch しない例外") { throw IllegalArgumentException("引数が不正") }
    } catch (e: IllegalArgumentException) {
        println("  呼び出し元で捕まえた: ${e.message}")
    }
    println("[return と finally]")
    println("  " + withReturn())
}
[例外なし]
  1 try 開始
  2 try 終了
  4 finally
  5 try の後ろ
[catch する例外]
  1 try 開始
  3 catch: 状態が不正
  4 finally
  5 try の後ろ
[catch しない例外]
  1 try 開始
  4 finally
  呼び出し元で捕まえた: 引数が不正
[return と finally]
  return の前に finally が動く
  try の値
場面 通る順番
例外なし try(1 → 2)→ finally(4)→ 後ろの処理(5)
catch する例外 try(1)→ catch(3)→ finally(4)→ 後ろの処理(5)
catch しない例外 try(1)→ finally(4)→ 呼び出し元へ。後ろの処理(5)は実行されない
try の中で return finally が動いてから、呼び出し元に値が返る

throw は式、型は Nothing

throw は文ではなく式で、その型は Nothing(値を返して戻ることがない、という意味の型)です。そのため、エルビス演算子の右側や if の分岐にそのまま置けます。

class User(val name: String?, val age: Int)

fun fail(message: String): Nothing = throw IllegalArgumentException(message)

fun describe(user: User): String {
    // throw は式で、型は Nothing。エルビス演算子の右側に置ける
    val name: String = user.name ?: throw IllegalArgumentException("name が null です")
    // Nothing を返す関数も同じ位置に置ける
    val age: Int = if (user.age >= 0) user.age else fail("age が負です: ${user.age}")
    return "$name ($age)"
}

fun main() {
    println(describe(User("田中", 30)))
    for (u in listOf(User(null, 30), User("鈴木", -1))) {
        try {
            println(describe(u))
        } catch (e: IllegalArgumentException) {
            println("IllegalArgumentException: ${e.message}")
        }
    }
    val x = null ?: "既定値"
    println(x)
}
田中 (30)
IllegalArgumentException: name が null です
IllegalArgumentException: age が負です: -1
既定値

user.name ?: throw ... を通過できたなら name は null ではないので、name の型は String? ではなく String になります。必ず例外を投げる関数を自分で作るときは、fail のように戻り値の型を Nothing にしておくと、同じ位置で使えます。

投げられるのは Throwable(例外の親の型)を継承したものだけです。throw "文字列" と書くと、type mismatch: inferred type is 'String', but 'Throwable' was expected. というコンパイルエラーになります。

複数の catch は「具体的な型を先」に並べる

catch は上から順に調べられ、最初に型が合ったものだけが実行されます。

open class AppException(message: String) : Exception(message)
class NotFoundException(val id: Int) : AppException("ID $id が見つかりません")
class ValidationException(val field: String) : AppException("$field が不正です")

fun load(id: Int): String = when (id) {
    1 -> "商品A"
    2 -> throw ValidationException("price")
    3 -> throw IllegalStateException("接続が閉じています")
    else -> throw NotFoundException(id)
}

// 具体的な型を先に、広い型を後ろに書く
fun good(id: Int) = try {
    load(id)
} catch (e: NotFoundException) {
    "NotFound の処理 (id=${e.id})"
} catch (e: AppException) {
    "AppException の処理 (${e.message})"
} catch (e: Exception) {
    "その他の処理 (${e::class.simpleName})"
}

// 広い型を先に書くと、後ろの catch には届かない
fun wide(id: Int) = try {
    load(id)
} catch (e: Exception) {
    "その他の処理 (${e::class.simpleName})"
} catch (e: NotFoundException) {
    "NotFound の処理 (id=${e.id})"
}

// 複数の型を同じ処理にまとめたいときは when で分ける
fun merged(id: Int) = try {
    load(id)
} catch (e: Exception) {
    when (e) {
        is NotFoundException, is ValidationException -> "入力に関する例外 (${e.message})"
        else -> throw e
    }
}

fun main() {
    for (id in 1..4) println("good($id) = ${good(id)}")
    println("wide(4) = ${wide(4)}")
    println("merged(2) = ${merged(2)}")
    println("merged(4) = ${merged(4)}")
    println("merged(3) = ${merged(3)}")
}
good(1) = 商品A
good(2) = AppException の処理 (price が不正です)
good(3) = その他の処理 (IllegalStateException)
good(4) = NotFound の処理 (id=4)
wide(4) = その他の処理 (NotFoundException)
merged(2) = 入力に関する例外 (price が不正です)
merged(4) = 入力に関する例外 (ID 4 が見つかりません)
Exception in thread "main" java.lang.IllegalStateException: 接続が閉じています
    at CatchOrderKt.load(CatchOrder.kt:8)
    at CatchOrderKt.merged(CatchOrder.kt:34)
    at CatchOrderKt.main(CatchOrder.kt:47)
    at CatchOrderKt.main(CatchOrder.kt)
  • good は、NotFoundException → AppException → Exception の順に並べているので、それぞれ専用の処理に入ります。
  • wide は Exception を先に書いたため、NotFoundException も最初の catch に入りました。後ろの catch には届きません。 Kotlin 2.4.20 ではこの並びでもコンパイルは通り、警告も出なかったので、並べる順番は自分で確認します。
  • merged(3) は、when の else で throw e と投げ直した IllegalStateException が、誰にも受け止められずにプログラムを終わらせています。最後の5行がそのスタックトレースです。

複数の型を1つの catch にまとめる書き方

Java の catch (A | B e) にあたる書き方は Kotlin にはありません。そのまま書くと構文エラーになります。

E1.kt:5:39: error: syntax error: Expecting ')'.
    } catch (e: NumberFormatException | IllegalStateException) {
                                      ^

同じ処理にまとめたいときは、上の merged のように共通の親の型で受けて when と is で分け、対象でないものは throw e で投げ直します。

独自例外とスタックトレースの読み方

独自の例外は Exception を継承して作ります。別の例外を受けて投げ直すときは、元の例外を cause として渡すと、原因をたどれるようになります。

class ConfigException(message: String, cause: Throwable? = null) : Exception(message, cause)

fun parsePort(text: String): Int = text.toInt()

fun loadConfig(raw: Map<String, String>): Int {
    try {
        return parsePort(raw.getValue("port"))
    } catch (e: NumberFormatException) {
        // 元の例外を cause に渡して、原因をたどれるようにする
        throw ConfigException("port の値を読めません", e)
    }
}

fun main() {
    loadConfig(mapOf("port" to "80a"))
}
Exception in thread "main" ConfigException: port の値を読めません
    at TraceKt.loadConfig(Trace.kt:10)
    at TraceKt.main(Trace.kt:15)
    at TraceKt.main(Trace.kt)
Caused by: java.lang.NumberFormatException: For input string: "80a"
    at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
    at java.base/java.lang.Integer.parseInt(Integer.java:662)
    at java.base/java.lang.Integer.parseInt(Integer.java:778)
    at TraceKt.parsePort(Trace.kt:3)
    at TraceKt.loadConfig(Trace.kt:7)
    ... 2 more

読み方は次のとおりです。

行 意味
Exception in thread "main" ConfigException: port の値を読めません 最終的に投げられた例外の型とメッセージ
at TraceKt.loadConfig(Trace.kt:10) 例外を投げた場所。ファイル名と行番号(10行目の throw)
その下の at ... そこに至るまでの呼び出し元。下へ行くほど外側
Caused by: java.lang.NumberFormatException: For input string: "80a" cause に渡した元の例外。本当の原因はここ
at TraceKt.parsePort(Trace.kt:3) 元の例外が起きた自分のコードの行
... 2 more 上と同じ呼び出し元なので省略されている

TraceKt は、Trace.kt に書いたトップレベル関数が入るクラスの名前です。スタックトレースはいちばん下の Caused by から読むと、最初に何が起きたかが早くわかります。Caused by 以下は cause を渡したから出力されているので、受けて投げ直すときは必ず渡しておきます。

runCatching と Result:結果を値として受け取る

runCatching { ... } は、ブロックの結果を Result 型に包んで返します。例外が出なければ値を、出たらその例外を中に持ちます。

fun parse(text: String): Result<Int> = runCatching { text.toInt() }

fun main() {
    val ok = parse("42")
    val ng = parse("4x")

    println("そのまま表示        : $ok / $ng")
    println("isSuccess           : ${ok.isSuccess} / ${ng.isSuccess}")
    println("isFailure           : ${ok.isFailure} / ${ng.isFailure}")
    println("getOrNull()         : ${ok.getOrNull()} / ${ng.getOrNull()}")
    println("exceptionOrNull()   : ${ok.exceptionOrNull()} / ${ng.exceptionOrNull()}")
    println("getOrDefault(0)     : ${ok.getOrDefault(0)} / ${ng.getOrDefault(0)}")
    println("getOrElse { -1 }    : ${ok.getOrElse { -1 }} / ${ng.getOrElse { -1 }}")
    println("fold                : ${ok.fold({ "値 $it" }, { "例外 ${it::class.simpleName}" })} / ${ng.fold({ "値 $it" }, { "例外 ${it::class.simpleName}" })}")
    println("map { it * 2 }      : ${ok.map { it * 2 }} / ${ng.map { it * 2 }}")
    println("recover { 0 }       : ${ok.recover { 0 }} / ${ng.recover { 0 }}")
    println("mapCatching { 10/(it-42) }: ${ok.mapCatching { 10 / (it - 42) }}")
    println("recoverCatching { \"y\".toInt() }: ${ng.recoverCatching { "y".toInt() }}")

    // onSuccess / onFailure は元の Result を返すので、つなげて書ける
    ng.onSuccess { println("onSuccess: $it") }
      .onFailure { println("onFailure: ${it.message}") }
    ok.onSuccess { println("onSuccess: $it") }
      .onFailure { println("onFailure: ${it.message}") }

    // Result を自分で作る
    println(Result.success("done"))
    println(Result.failure<String>(IllegalStateException("閉じています")))

    // getOrThrow() は例外を持つとき、その例外を投げ直す
    try {
        ng.getOrThrow()
    } catch (e: NumberFormatException) {
        println("getOrThrow(): ${e::class.simpleName}")
    }
    println("getOrThrow()        : ${ok.getOrThrow()}")

    // map の中で例外が出ると、Result に包まれずに外へ出る
    try {
        ok.map { 10 / (it - 42) }
    } catch (e: ArithmeticException) {
        println("map の中の例外: ${e.message}")
    }
}
そのまま表示        : Success(42) / Failure(java.lang.NumberFormatException: For input string: "4x")
isSuccess           : true / false
isFailure           : false / true
getOrNull()         : 42 / null
exceptionOrNull()   : null / java.lang.NumberFormatException: For input string: "4x"
getOrDefault(0)     : 42 / 0
getOrElse { -1 }    : 42 / -1
fold                : 値 42 / 例外 NumberFormatException
map { it * 2 }      : Success(84) / Failure(java.lang.NumberFormatException: For input string: "4x")
recover { 0 }       : Success(42) / Success(0)
mapCatching { 10/(it-42) }: Failure(java.lang.ArithmeticException: / by zero)
recoverCatching { "y".toInt() }: Failure(java.lang.NumberFormatException: For input string: "y")
onFailure: For input string: "4x"
onSuccess: 42
Success(done)
Failure(java.lang.IllegalStateException: 閉じています)
getOrThrow(): NumberFormatException
getOrThrow()        : 42
map の中の例外: / by zero

Result の関数一覧(戻り値つき)

ok は runCatching { "42".toInt() }、ng は runCatching { "4x".toInt() } です。表では NumberFormatException を NFE と略しています。

関数 戻り値の型 ok のとき ng のとき 用途
isSuccess Boolean true false 値を持っているか
isFailure Boolean false true 例外を持っているか
getOrNull() 値または null 42 null 例外のときは null でよい
exceptionOrNull() 例外または null null NFE 例外を取り出す
getOrDefault(0) 値 42 0 例外のときは決まった値
getOrElse { -1 } 値 42 -1 例外を見て代わりの値を計算する
getOrThrow() 値 42 NFE を投げ直す 値を取り出し、例外はそのまま外へ
fold(成功時, 例外時) 2つのラムダの結果 値 42 例外 NumberFormatException 両方の場合を1つの値にまとめる
map { it * 2 } Result Success(84) Failure(NFE) のまま 値を変換する
mapCatching { ... } Result 変換中の例外も Failure になる Failure(NFE) のまま 変換が例外を出すかもしれないとき
recover { 0 } Result Success(42) のまま Success(0) 例外を値に置き換える
recoverCatching { ... } Result Success(42) のまま 置き換え中の例外も Failure になる 置き換えが例外を出すかもしれないとき
onSuccess { ... } 元の Result ラムダを実行 何もしない ログ出力など。つなげて書ける
onFailure { ... } 元の Result 何もしない ラムダを実行 同上

map と mapCatching の違いは実行結果の最後の行に出ています。map の中で起きた例外は Result に包まれず、そのまま外へ出ます(map の中の例外: / by zero)。変換の中でも例外がありうるなら mapCatching を使います。

Result はすべて Kotlin 1.3 以降の標準ライブラリに入っています。追加のライブラリは不要です。

try-catch と runCatching の使い分け

公式の API リファレンスには、runCatching は「ブロックから投げられた あらゆる Throwable を受け止めて Failure に包む」とあります。次のコードで、try-catch との違いを確かめます。

fun deep(n: Int): Int = deep(n + 1) + 1

fun main() {
    // runCatching は Throwable をすべて受け止める。Error も対象に含まれる
    val r = runCatching { deep(0) }
    println(r.exceptionOrNull()?.let { it::class.simpleName })

    // try-catch (e: Exception) では Error は受け止めない
    try {
        try {
            deep(0)
        } catch (e: Exception) {
            println("ここには来ない")
        }
    } catch (e: StackOverflowError) {
        println("catch (e: Exception) を素通りした: ${e::class.simpleName}")
    }
}
StackOverflowError
catch (e: Exception) を素通りした: StackOverflowError

catch (e: Exception) は StackOverflowError(Error の仲間)を受け止めませんが、runCatching は受け止めて Result に入れました。runCatching は「catch (e: Throwable) と同じ広さ」だと考えると、使いどころを判断しやすくなります。

場面 向いている書き方 理由
受け止めたい例外の型が決まっている try-catch 対象の型だけを catch でき、ほかは呼び出し元へ伝わる
型ごとに対応を変えたい try-catch(複数の catch) 上から順に型で振り分けられる
後始末が必要 try-finally / use finally と use は必ず実行される
例外のとき代わりの値にしたいだけ try 式、または xxxOrNull() と ?: 1行で書け、対象の型も絞れる
結果を値として渡したい・あとで判定したい runCatching Result を変数や戻り値にできる
変換をつなげたい(map → recover → getOrElse) runCatching メソッドチェーンで書ける
どんな例外でも止めずに次へ進めたい(複数件の処理を1件ずつ試すなど) runCatching Throwable をすべて受け止める
suspend 関数(コルーチン)の中 try-catch、またはキャンセルを通す自作関数 次の章のとおり、キャンセルまで受け止めるため

迷ったときの目安は、「型を絞って対応する」なら try-catch、「成功か例外かを値として持ち回る」なら runCatching です。

runCatching はコルーチンのキャンセルも受け止める

コルーチンのキャンセルは、CancellationException という例外で伝わります。kotlinx.coroutines の API リファレンス(Job)には、CancellationException を投げたコルーチンは「正常にキャンセルされた」と見なされる、とあります。つまりこの例外は、途中で受け止めずに外へ伝える必要があります。

ところが runCatching は Throwable をすべて受け止めるので、キャンセルも Result の中に入ってしまいます。次のコードで再現します。

import kotlinx.coroutines.*

suspend fun fetch(i: Int): String {
    delay(300)
    return "data$i"
}

fun main() = runBlocking {
    // A. runCatching がキャンセルまで受け止めてしまう
    val jobA = launch {
        for (i in 1..3) {
            val r = runCatching { fetch(i) }
            println("A: $i 回目 -> ${r.getOrNull() ?: r.exceptionOrNull()!!::class.simpleName}")
        }
        println("A: ループを最後まで実行した (isActive=$isActive)")
    }
    delay(450)
    jobA.cancelAndJoin()
    println("A: isCancelled=${jobA.isCancelled}")
    println("---")

    // B. CancellationException だけ投げ直す
    val jobB = launch {
        for (i in 1..3) {
            val r = runCatching { fetch(i) }
                .onFailure { if (it is CancellationException) throw it }
            println("B: $i 回目 -> ${r.getOrNull()}")
        }
        println("B: ここには来ない")
    }
    delay(450)
    jobB.cancelAndJoin()
    println("B: isCancelled=${jobB.isCancelled}")
    println("---")

    // C. try-catch で書く場合:CancellationException を先に書いて投げ直す
    val jobC = launch {
        for (i in 1..3) {
            try {
                println("C: $i 回目 -> ${fetch(i)}")
            } catch (e: CancellationException) {
                println("C: キャンセルを検知して投げ直す")
                throw e
            } catch (e: Exception) {
                println("C: その他の例外 ${e.message}")
            }
        }
    }
    delay(450)
    jobC.cancelAndJoin()
    println("C: isCancelled=${jobC.isCancelled}")
    println("---")

    // D. withTimeout の TimeoutCancellationException も CancellationException の一種
    val r = runCatching { withTimeout(100) { fetch(9) } }
    val ex = r.exceptionOrNull()
    println("D: ${ex?.let { it::class.simpleName }} / CancellationException か = ${ex is CancellationException}")
}

kotlinx-coroutines-core はコルーチン用の追加ライブラリです。Gradle(Kotlin DSL)では、kotlinx.coroutines の README にある次の形で依存を追加します(README 記載の版は 1.11.0。この記事の出力は 1.8.0 の jar をクラスパスに指定して得たものです)。

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0")
}

実行結果です。

A: 1 回目 -> data1
A: 2 回目 -> JobCancellationException
A: 3 回目 -> JobCancellationException
A: ループを最後まで実行した (isActive=false)
A: isCancelled=true
---
B: 1 回目 -> data1
B: isCancelled=true
---
C: 1 回目 -> data1
C: キャンセルを検知して投げ直す
C: isCancelled=true
---
D: TimeoutCancellationException / CancellationException か = true
例 書き方 キャンセル後の動き
A runCatching だけ 2回目・3回目の例外が JobCancellationException として Result に入り、ループが最後まで進んだ
B onFailure { if (it is CancellationException) throw it } を足す 1回目のあとで止まり、後ろの行は実行されない
C try-catch で CancellationException を先に書いて投げ直す キャンセルを検知して止まる
D withTimeout を runCatching で囲む TimeoutCancellationException も CancellationException の一種として Result に入る

A では、キャンセルしたはずのコルーチンが残りの処理を続けています。isActive=false と表示されているとおり、コルーチン自体はキャンセル済みなのに、コードだけが先へ進んでいる状態です。

何度も使うなら、キャンセルだけは通す関数を用意しておくと書き間違いが減ります。

import kotlinx.coroutines.*

// キャンセルだけは通す runCatching の自作版
inline fun <R> runCatchingCancellable(block: () -> R): Result<R> =
    try {
        Result.success(block())
    } catch (e: CancellationException) {
        throw e
    } catch (e: Throwable) {
        Result.failure(e)
    }

fun main() = runBlocking {
    val job = launch {
        for (i in 1..3) {
            val r = runCatchingCancellable {
                delay(300)
                if (i == 1) error("1 回目は例外") else "data$i"
            }
            println("$i 回目 -> $r")
        }
        println("ここには来ない")
    }
    delay(450)
    job.cancelAndJoin()
    println("isCancelled=${job.isCancelled}")
}
1 回目 -> Failure(java.lang.IllegalStateException: 1 回目は例外)
isCancelled=true

1回目の IllegalStateException は Result に入り、2回目の途中で届いたキャンセルはそのまま外へ伝わって、ループが止まりました。

検査例外がないことと @Throws

Java には、catch するか throws で宣言しないとコンパイルできない「検査例外」があります(IOException など)。Kotlin にはこの区別がなく、すべての例外が catch も宣言もなしで書けます。

package demo

import java.io.File
import java.io.IOException

// Java では throws IOException が必要な処理。Kotlin は try も宣言もなしで書ける
fun readFirstLine(path: String): String = File(path).readLines().first()

fun plainWrite() {
    throw IOException("書き込めません")
}

@Throws(IOException::class)
fun declaredWrite() {
    throw IOException("書き込めません")
}

fun main() {
    try {
        println(readFirstLine("/no/such/file.txt"))
    } catch (e: IOException) {
        println("${e::class.qualifiedName}: ${e.message}")
    }
}
java.io.FileNotFoundException: /no/such/file.txt (No such file or directory)

readFirstLine はファイルを読む関数ですが、try も宣言もなしでコンパイルできます。同じ内容を Java で書くと、javac は次のエラーを出します。

import java.nio.file.Files;
import java.nio.file.Path;

public class JavaRead {
    static String readFirstLine(String path) {
        return Files.readAllLines(Path.of(path)).get(0);
    }
    public static void main(String[] args) {
        System.out.println(readFirstLine("/no/such/file.txt"));
    }
}
JavaRead.java:6: error: unreported exception IOException; must be caught or declared to be thrown
        return Files.readAllLines(Path.of(path)).get(0);
                                 ^
1 error

Kotlin では「書かなくてよい」ぶん、どの関数がどんな例外を投げるかはコンパイラが教えてくれません。ファイルやネットワークを扱う関数を呼ぶときは、ドキュメントで投げる例外を確認し、必要な場所で catch します。なお、Java の throws にあたるキーワードも Kotlin にはなく、fun read(path: String): String throws java.io.IOException { と書くと構文エラー(syntax error: Expecting a top level declaration. など)になります。

Java から呼ばれる関数には @Throws を付ける

Kotlin の関数を Java から呼ぶ場合は、この違いが表に出ます。Checked.kt の plainWrite と declaredWrite を、javap で Java から見た形にすると次のとおりです。

public final class demo.CheckedKt {
  public static final java.lang.String readFirstLine(java.lang.String);
  public static final void plainWrite();
  public static final void declaredWrite() throws java.io.IOException;
  public static final void main();
  public static void main(java.lang.String[]);
}

@Throws(IOException::class) を付けた declaredWrite にだけ throws java.io.IOException が付いています。@Throws のない plainWrite を Java 側で try-catch すると、javac は「この try の中で IOException は投げられない」と判断してエラーにします。

import demo.CheckedKt;
import java.io.IOException;

public class CallPlain {
    public static void main(String[] args) {
        try {
            CheckedKt.plainWrite();
        } catch (IOException e) {
            System.out.println("caught: " + e.getMessage());
        }
    }
}
CallPlain.java:8: error: exception IOException is never thrown in body of corresponding try statement
        } catch (IOException e) {
          ^
1 error

呼び出す関数を CheckedKt.declaredWrite() に変えるとコンパイルでき、実行すると caught: 書き込めません と表示されました。逆に、declaredWrite() を try なしで呼ぶと unreported exception IOException; must be caught or declared to be thrown になり、Java 側に catch を促せます。

use:使い終わったら自動で閉じる

ファイルや接続のように close() が必要なものは、use を使うと閉じ忘れがなくなります。Java の try-with-resources にあたります。

class Resource(private val name: String) : AutoCloseable {
    init { println("open  $name") }
    fun read(): String = "data from $name"
    fun broken(): String = throw IllegalStateException("$name の読み取りで例外")
    override fun close() { println("close $name") }
}

class BadClose : AutoCloseable {
    override fun close() { throw IllegalStateException("close でも例外") }
}

fun main() {
    // 1. ブロックを抜けると close() が呼ばれ、ブロックの値が use の結果になる
    val text = Resource("A").use { it.read() }
    println("結果: $text")

    // 2. 例外が出ても close() は呼ばれる
    try {
        Resource("B").use { it.broken() }
    } catch (e: IllegalStateException) {
        println("捕まえた: ${e.message}")
    }

    // 3. 入れ子にすると、後に開いたものから閉じる
    Resource("C").use { c ->
        Resource("D").use { d ->
            println(c.read() + " / " + d.read())
        }
    }

    // 4. ブロックと close の両方で例外:ブロックの例外が残り、close の例外は suppressed に入る
    try {
        BadClose().use { throw IllegalArgumentException("ブロックの例外") }
    } catch (e: Exception) {
        println("${e::class.simpleName}: ${e.message} / suppressed=${e.suppressed.map { it.message }}")
    }

    // 5. 標準ライブラリのファイル読み書き
    val file = java.io.File.createTempFile("memo", ".txt")
    file.bufferedWriter().use { it.write("1行目\n2行目\n") }
    val lines = file.bufferedReader().use { it.readLines() }
    println(lines)
    file.delete()
}
open  A
close A
結果: data from A
open  B
close B
捕まえた: B の読み取りで例外
open  C
open  D
data from C / data from D
close D
close C
IllegalArgumentException: ブロックの例外 / suppressed=[close でも例外]
[1行目, 2行目]
番号 確かめたこと
1 ブロックを抜けると close() が呼ばれ、ブロックの最後の値が use の結果になる
2 ブロックの中で例外が出ても、close() が呼ばれてから例外が外へ伝わる
3 入れ子にすると、後に開いたもの(D)から先に閉じる
4 ブロックと close の両方で例外が出たら、ブロックの例外が伝わり、close の例外は suppressed に入る
5 bufferedWriter() や bufferedReader() も use で閉じられる

use は AutoCloseable(または Closeable)を実装したクラスなら、自分で作ったものにも使えます。try-finally で close() を書くよりも短く、閉じる順番や例外の扱いも任せられます。

require・check・error の違い

前提条件の検査には、標準ライブラリの3つの関数が使えます。投げる例外の型が違うので、「何が原因か」を型で伝えられます。

class Cart {
    private val items = mutableListOf<String>()
    private var closed = false

    fun add(item: String, count: Int) {
        require(count > 0) { "count は 1 以上にしてください: $count" }   // 引数の検査
        check(!closed) { "確定後は追加できません" }                    // 状態の検査
        repeat(count) { items += item }
    }

    fun close() { closed = true }

    fun shippingFee(area: String): Int = when (area) {
        "A" -> 500
        "B" -> 800
        else -> error("想定していない地域です: $area")                 // 来ないはずの分岐
    }
}

fun show(label: String, block: () -> Any?) {
    try {
        println("$label -> ${block()}")
    } catch (e: Exception) {
        println("$label -> ${e::class.simpleName}: ${e.message}")
    }
}

fun main() {
    val cart = Cart()
    show("require") { cart.add("ペン", 0) }
    cart.close()
    show("check") { cart.add("ペン", 1) }
    show("error") { cart.shippingFee("Z") }

    val name: String? = null
    show("requireNotNull") { requireNotNull(name) { "name は必須です" } }
    show("checkNotNull") { checkNotNull(name) { "name が未設定です" } }
    show("require(メッセージなし)") { require(1 > 2) }
    show("check(メッセージなし)") { check(1 > 2) }
    show("!!") { name!! }
    show("TODO") { try { TODO() } catch (e: NotImplementedError) { "${e::class.simpleName}: ${e.message}" } }

    // requireNotNull を通ると、その後は null でない型として使える
    val title: String? = "Kotlin"
    val t: String = requireNotNull(title)
    println(t.length)
}
require -> IllegalArgumentException: count は 1 以上にしてください: 0
check -> IllegalStateException: 確定後は追加できません
error -> IllegalStateException: 想定していない地域です: Z
requireNotNull -> IllegalArgumentException: name は必須です
checkNotNull -> IllegalStateException: name が未設定です
require(メッセージなし) -> IllegalArgumentException: Failed requirement.
check(メッセージなし) -> IllegalStateException: Check failed.
!! -> NullPointerException: null
TODO -> NotImplementedError: An operation is not implemented.
6
関数 投げる例外 意味 メッセージを省いたとき
require(条件) { メッセージ } IllegalArgumentException 渡された引数が正しくない Failed requirement.
requireNotNull(値) { メッセージ } IllegalArgumentException 引数が null ―
check(条件) { メッセージ } IllegalStateException オブジェクトの状態が正しくない Check failed.
checkNotNull(値) { メッセージ } IllegalStateException 状態として持つ値が null ―
error(メッセージ) IllegalStateException 来ないはずの分岐に来た ―
値!! NullPointerException null でないと決め打ちする メッセージは null
TODO() NotImplementedError まだ書いていない An operation is not implemented.

requireNotNull と checkNotNull は、通過したあとの値を null でない型として返します(上の例では String? から String を受け取り、length を呼べています)。!! でも同じことはできますが、メッセージが null になるので、理由を書ける requireNotNull / checkNotNull のほうが原因を追いやすくなります。

コンパイルエラーから引く早見表

書いたコード エラー文(Kotlin 2.4.20) 直し方
catch (e: A | B) syntax error: Expecting ')'. 親の型で受けて when と is で分ける
catch も finally もない try syntax error: Expecting 'catch' or 'finally'. catch か finally を付ける
fun f(): String throws IOException syntax error: Expecting a top level declaration. throws は書かない。Java 向けには @Throws
val n: Int = try { ... } catch (...) { null } initializer type mismatch: expected 'Int', actual 'Int?'. 変数を Int? にするか、catch で Int を返す
throw "文字列" type mismatch: inferred type is 'String', but 'Throwable' was expected. 例外クラスのインスタンスを投げる

よくある質問

Q. try-catch と runCatching は、結局どちらを基本にすればよいですか? A. 受け止めたい例外の型が決まっているなら try-catch、結果を値として持ち回りたい・変換をつなげたいなら runCatching が向いています。runCatching は Error やコルーチンのキャンセルも含めてすべて受け止めるので、suspend 関数の中では try-catch か、キャンセルを通す自作関数を選ぶと安心です。

Q. catch (e: Exception) と catch (e: Throwable) の違いは何ですか? A. Throwable は Exception と Error の両方の親です。この記事の実行結果では、catch (e: Exception) は StackOverflowError を受け止めませんでした。Error は続行が難しい状況を表すものが多いので、通常は Exception か、より具体的な型で catch します。

Q. finally と use は、どちらを使えばよいですか? A. close() を持つもの(ファイル、ストリームなど)を閉じるなら use が短く書けます。close() 以外の後始末(フラグを戻す、ログを出すなど)は finally に書きます。

Q. 独自例外は、どんなときに作ればよいですか? A. 呼び出す側が「この種類の例外だけ別の対応をしたい」と思う単位で作ると使いやすくなります。この記事の例では、AppException を親にして NotFoundException と ValidationException を分け、catch の順番で対応を振り分けました。別の例外を受けて投げ直すときは、cause に元の例外を渡します。

まとめ

  • try は式。 val n = try { ... } catch (e: ...) { 代わりの値 } と書ける。finally の値は結果にならない
  • finally は例外の有無や return にかかわらず実行される。 close() が必要なものは use に任せる
  • throw の型は Nothing。 エルビス演算子の右側に置くと、左側が null でない型として扱われる
  • 複数の catch は具体的な型を先に。 広い型を先に書くと後ろには届かない。Java の「A | B」は when と is で書く
  • runCatching は Throwable をすべて受け止めて Result にする。 getOrElse・fold・map・recover で取り出しや変換をつなげられる
  • コルーチンの中の runCatching はキャンセルも受け止める。 CancellationException は投げ直す
  • 検査例外はない。 Java から呼ばれる関数には @Throws。引数は require、状態は check、来ないはずの分岐は error

参考・出典

  • Kotlin 公式ドキュメント「Exceptions」 https://kotlinlang.org/docs/exceptions.html
  • Kotlin API リファレンス「runCatching」 https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/run-catching.html
  • Kotlin API リファレンス「Result」 https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/-result/
  • Kotlin API リファレンス「use」 https://kotlinlang.org/api/core/kotlin-stdlib/kotlin.io/use.html
  • Kotlin 公式ドキュメント「Calling Kotlin from Java」(@Throws) https://kotlinlang.org/docs/java-to-kotlin-interop.html
  • kotlinx.coroutines API リファレンス「Job」 https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-job/
  • kotlinx.coroutines README(Gradle の依存の書き方) https://github.com/Kotlin/kotlinx.coroutines/blob/master/README.md

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Kotlin例外処理 try-catchとrunCatchingの使い分け」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?