Skip to main content

例外と失敗の表し方

処理はいつも成功するとは限りません。注文の数量に数字でない文字列が届くこともあれば、在庫が足りないことも、指定された商品が存在しないこともあります。この章では、そうした失敗を Kotlin のコードでどう表すかを扱います。まず例外の投げ方と捕まえ方を見て、次に前提条件を確かめる require、check、error を扱います。続いて、例外を値として受け取る runCatching と Result、失敗の種類を sealed interface で型にする書き方、null で失敗を返す書き方を順に見ます。読み終えると、在庫の引き当てのような処理で、失敗ごとに例外と null と sealed のどれで表すかを選び、その理由を説明できるようになります。

Kotlin の例外​

検査例外が無い​

Kotlin には 検査例外 がありません。検査例外とは、関数が投げうる例外をシグネチャに宣言させ、呼び出し側に「捕まえるか、自分も宣言するか」をコンパイルの時点で強制する仕組みです。Kotlin の例外はすべてこの強制を受けないので、例外を投げる関数を try なしで呼んでもコンパイルできます。

例外は throw で投げます。次の例は、在庫が足りないときに自分で定義した例外を投げる関数です。

class InsufficientInventoryException(message: String) : RuntimeException(message)

/**
* 在庫の数 [available] から [quantity] 個を引き当て、残りの数を返す。
*
* @throws InsufficientInventoryException 在庫が [quantity] に足りないとき
*/
fun reserve(productId: String, available: Int, quantity: Int): Int {
if (available < quantity) {
throw InsufficientInventoryException("在庫が足りません: $productId")
}
return available - quantity
}

fun main() {
println(reserve("P-001", available = 5, quantity = 2))
try {
reserve("P-002", available = 1, quantity = 3)
} catch (e: InsufficientInventoryException) {
println("${e::class.simpleName}: ${e.message}")
}
}
実行結果
3
InsufficientInventoryException: 在庫が足りません: P-002

main の 1 行目は reserve を try で囲まずに呼んでいます。この呼び出しでは在庫が足りているので例外は出ず、実行結果の 1 行目に残りの数が出ます。2 回目の呼び出しは在庫が足りないので例外が投げられ、catch の中で例外のクラス名とメッセージを出しています。e::class.simpleName は、捕まえた例外の実際のクラス名を返します。

例外のクラスは Throwable を頂点にした継承の階層になっています。その下に、アプリケーションが投げて捕まえる Exception と、メモリ不足のように回復を見込まない問題を表す Error があります。自分で例外を定義するときは Exception の側を継承します。Kotlin の言語の中では、RuntimeException を継承しても Exception を直接継承しても扱いは変わりません。ただし、第 4 部で使う Spring の宣言的なトランザクション (@Transactional) は、既定では RuntimeException とその派生 (と Error) が投げられたときだけロールバックします。Exception を直接継承した例外では、既定ではロールバックしません (Spring Framework のリファレンス)。このガイドの例外は、この章の例と同じく RuntimeException を継承します。Exception や IllegalArgumentException などの標準の例外は kotlin パッケージにあるので、import なしで使えます。

検査例外が無いことには、裏返しの面があります。関数のシグネチャを見ても、どの例外が投げられるかは分かりません。 reserve の宣言 fun reserve(productId: String, available: Int, quantity: Int): Int には、在庫が足りないときに例外を投げることが書かれていません。そこで、呼び出し側が知っておくべき例外は、KDoc の @throws タグで書き残します。上の例の reserve の上にあるコメントがそれです。

捕まえなかった例外は、呼び出し元へ順にさかのぼって伝わります。最後まで誰も捕まえなければ、プログラムはその場で止まります。どこで捕まえるかは呼び出し側が決めることで、コンパイラは何も言いません。

try と catch と finally​

try が式として値を返せることは Kotlin の基本 で見ました。ここでは、catch を複数並べる場合と finally を扱います。

for (input in listOf("3", "0", "2個")) {
try {
val quantity = input.toInt()
if (quantity <= 0) {
throw IllegalArgumentException("数量は 1 以上にしてください")
}
println("数量: $quantity")
} catch (e: NumberFormatException) {
println("数字ではありません: $input")
} catch (e: IllegalArgumentException) {
println(e.message)
} finally {
println("確認済み: $input")
}
}
実行結果
数量: 3
確認済み: 3
数量は 1 以上にしてください
確認済み: 0
数字ではありません: 2個
確認済み: 2個

String.toInt() は、数として読めない文字列に対して NumberFormatException を投げます。catch は上から順に例外の型と照らし合わされ、最初に合ったものだけが実行されます。

並べる順番には意味があります。NumberFormatException は IllegalArgumentException を継承しているので、2 つの catch を逆に並べると、数字でない入力も IllegalArgumentException の catch に入ります。広い型の catch は下に置いてください。

finally のブロックは、例外が出たかどうかに関係なく、try を抜けるときに必ず実行されます。実行結果では、3 つの入力のどれでも「確認済み」の行が出ています。ファイルを閉じるような後片付けは finally に書きます。

前提条件を確かめる関数​

関数の引数やオブジェクトの状態が前提を満たしていないとき、処理を続けずに例外を投げて止めたい場面がよくあります。標準ライブラリには、そのための関数が 3 つあります。

関数確かめるもの満たさないときに投げる例外
require関数に渡された引数IllegalArgumentException
checkオブジェクトや処理の状態IllegalStateException
error(条件を取らず、呼んだ時点で止める)IllegalStateException

どれも、自分で if と throw を書いた場合と同じ例外を投げます。if と throw で書かずにこれらを使うのは、呼び出しの名前が「何を確かめているか」を表すからです。

require: 引数を確かめる​

require(条件) { メッセージ } は、条件が false のときに、ラムダが返したメッセージで IllegalArgumentException を投げます。ラムダは条件が false のときだけ呼ばれるので、メッセージを組み立てる処理は成功したときには動きません。

注文明細の数量と単価を、作るときに確かめてみます。この節から先の本文の例は、宣言だけのコード例と、それを使う文だけのコード例に分けて載せます。試すときは、宣言を main の外 (ファイルの一番外側) に、使う文を main の中に書いてください。enum class や sealed interface は main の中に書けません。

data class OrderLine(val productId: String, val quantity: Int, val unitPrice: Int) {
init {
require(quantity in 1..99) { "数量は 1 から 99 にしてください: $quantity" }
require(unitPrice >= 0) { "単価は 0 以上にしてください: $unitPrice" }
}
}

init ブロックはインスタンスを作るたびに実行されます (クラスとオブジェクト)。require を置いておけば、数量が範囲の外にある OrderLine は作れません。

println(OrderLine("P-001", quantity = 2, unitPrice = 1500))
try {
OrderLine("P-001", quantity = 0, unitPrice = 1500)
} catch (e: IllegalArgumentException) {
println("${e::class.simpleName}: ${e.message}")
}
実行結果
OrderLine(productId=P-001, quantity=2, unitPrice=1500)
IllegalArgumentException: 数量は 1 から 99 にしてください: 0

メッセージには、受け取った値を入れておきます。例外がログに残ったとき、どの値で失敗したかがメッセージだけで分かります。

require には、もう 1 つの働きがあります。条件に x != null を書くと、require から戻った後のコードでは x が null でない型としてスマートキャストされます。null でない値をそのまま受け取りたいときは requireNotNull を使います。requireNotNull は値が null なら IllegalArgumentException を投げ、null でなければ null でない型にした値を返します。

val inventories = mapOf("P-001" to 5)

val available = inventories["P-001"]
require(available != null) { "在庫の行がありません: P-001" }
println(available - 2)

try {
val missing = requireNotNull(inventories["P-002"]) { "在庫の行がありません: P-002" }
println(missing - 2)
} catch (e: IllegalArgumentException) {
println(e.message)
}
実行結果
3
在庫の行がありません: P-002

inventories["P-001"] の型は Int? です。require の後の available - 2 がコンパイルできるのは、require が戻った時点で available が null でないとコンパイラが判断し、Int として扱うからです。

check: 状態を確かめる​

check(条件) { メッセージ } は、条件が false のときに IllegalStateException を投げます。書き方は require と同じで、違うのは確かめる対象と投げる例外です。引数に問題が無くても、オブジェクトがその操作を受けられる状態にないことがあります。その場合に check を使います。

下書きの注文だけを確定できる Order を書きます。

enum class OrderStatus { DRAFT, CONFIRMED }

class Order(val id: String) {
var status = OrderStatus.DRAFT
private set

fun confirm() {
check(status == OrderStatus.DRAFT) { "下書きの注文だけを確定できます: $id は $status" }
status = OrderStatus.CONFIRMED
}
}
val order = Order("O-001")
order.confirm()
println(order.status)
try {
order.confirm()
} catch (e: IllegalStateException) {
println("${e::class.simpleName}: ${e.message}")
}
実行結果
CONFIRMED
IllegalStateException: 下書きの注文だけを確定できます: O-001 は CONFIRMED

confirm は引数を取りません。2 回目の呼び出しが失敗するのは、渡した値が悪いからではなく、注文がすでに確定していて状態が合わないからです。このような失敗を require で書くと、IllegalArgumentException を見た人は引数を調べ始めます。例外の型が原因の種類を表すように、引数なら require、状態なら check と使い分けてください。

null でないことを状態として確かめる checkNotNull もあります。requireNotNull と同じ形で使えて、投げる例外が IllegalStateException になります。

error: 起こらないはずの場合で止める​

error(メッセージ) は、条件を取らずに IllegalStateException を投げます。戻り値の型は Nothing なので (Kotlin の基本)、エルビス演算子 ?: の右側や when の分岐に、値の代わりとして書けます。

val unitPrices = mapOf("P-001" to 1500, "P-002" to 800)

fun unitPriceOf(productId: String): Int =
unitPrices[productId] ?: error("単価が登録されていません: $productId")
println(unitPriceOf("P-002") * 3)
try {
unitPriceOf("P-999")
} catch (e: IllegalStateException) {
println("${e::class.simpleName}: ${e.message}")
}
実行結果
2400
IllegalStateException: 単価が登録されていません: P-999

unitPrices[productId] は Int? を返しますが、?: error(...) を付けた式全体の型は Int になります。error が戻ってこないことをコンパイラが知っているからです。

error を使うのは、商品を登録するときに単価も必ず登録する作りになっていて、単価が無いことがデータかプログラムの誤りを意味する場合です。利用者の入力しだいで普通に起こる「無い」には使いません。その場合の書き方は、後の節の sealed と null で扱います。

runCatching と Result​

try と catch は、例外をその場で処理するための構文です。例外を値として受け取って、後の処理に渡したいこともあります。そのために標準ライブラリには Result 型と、それを作る runCatching 関数があります。

Result を作って中身を取り出す​

runCatching { ... } はラムダを実行し、ラムダが値を返せば成功の Result を、例外が出ればその例外を持つ失敗の Result を返します。例外は runCatching の外へ出ません。

val parsed = runCatching { "3".toInt() }
val failed = runCatching { "2個".toInt() }
println(parsed.isSuccess)
println(parsed.getOrNull())
println(failed.isFailure)
println(failed.getOrNull())
println(failed.exceptionOrNull()?.let { it::class.simpleName })
実行結果
true
3
true
null
NumberFormatException

isSuccess と isFailure で成否を調べ、getOrNull で成功の値を取り出します。失敗の Result に getOrNull を呼ぶと null が返ります。exceptionOrNull は逆に、失敗なら例外を、成功なら null を返します。実行結果の 5 行目のとおり、失敗の Result には toInt が投げた例外がそのまま入っています。

失敗のときに使う値を決めて取り出すなら、getOrDefault と getOrElse を使います。

val input = "2個"
println(runCatching { input.toInt() }.getOrDefault(1))

val quantity = runCatching { input.toInt() }.getOrElse { e ->
println("数量として読めないので既定の数量にします: ${e::class.simpleName}")
1
}
println(quantity)
実行結果
1
数量として読めないので既定の数量にします: NumberFormatException
1

getOrDefault は失敗のときに引数の値を返します。getOrElse はラムダに例外を渡し、ラムダの戻り値を返します。例外の種類を見て処理を変えたいときやログを出したいときは getOrElse を選びます。失敗のときに例外をそのまま投げ直す getOrThrow もあります。

成功と失敗の両方を 1 つの式で別の値に変えるなら fold を使います。

for (input in listOf("3", "2個")) {
val message = runCatching { input.toInt() }.fold(
onSuccess = { quantity -> "数量: $quantity" },
onFailure = { e -> "数量として読めません: $input (${e::class.simpleName})" },
)
println(message)
}
実行結果
数量: 3
数量として読めません: 2個 (NumberFormatException)

fold は成功なら onSuccess に値を、失敗なら onFailure に例外を渡し、呼ばれた側の戻り値を返します。fold の戻り値の型は 2 つのラムダの戻り値の型から決まるので、2 つのラムダは同じ型の値を返すように書きます。型が違うと、戻り値の型は両方に共通の上位の型に広がり、呼び出し側で元の型として扱えなくなります。

map と recover で処理をつなぐ​

Result から値を取り出さずに、成功の値だけを変換できます。map は成功の値をラムダで変換した新しい Result を返し、失敗の Result はそのまま通します。recover はその逆で、失敗を成功の値に置き換えます。

val unitPrice = 1500

val subtotal = runCatching { "3".toInt() }
.map { quantity -> quantity * unitPrice }
println(subtotal.getOrNull())

val fallback = runCatching { "2個".toInt() }
.recover { 1 }
.map { quantity -> quantity * unitPrice }
println(fallback.getOrNull())
実行結果
4500
1500

2 つ目の例では、recover が失敗を成功の値に置き換えたので、続く map の変換が実行されています。

map と recover には注意点が 1 つあります。渡したラムダの中で出た例外は捕まえず、そのまま外へ投げます。 ラムダの中の例外も失敗の Result にしたいなら、mapCatching と recoverCatching を使います。

val parsed = runCatching { "0".toInt() }

val caught = parsed.mapCatching { quantity ->
require(quantity > 0) { "数量は 1 以上にしてください" }
quantity
}
println(caught.exceptionOrNull()?.message)

try {
parsed.map { quantity ->
require(quantity > 0) { "数量は 1 以上にしてください" }
quantity
}
} catch (e: IllegalArgumentException) {
println("map の外へ投げられました: ${e.message}")
}
実行結果
数量は 1 以上にしてください
map の外へ投げられました: 数量は 1 以上にしてください

mapCatching は require の例外を失敗の Result に入れて返しました。map のほうは同じ例外を外へ投げたので、周りの try で捕まえています。fold と getOrElse も同じく、ラムダの中の例外を外へ投げます。

runCatching が捕まえる範囲​

runCatching は、ラムダの中で出た Throwable をすべて捕まえます。数として読めない入力のような想定した失敗だけでなく、プログラムの誤りによる例外や、Error の側の問題も、区別なく失敗の Result になります。

val inventories = mapOf("P-001" to 5)
val bug = runCatching { inventories["P-002"]!! - 2 }
val unfinished = runCatching<Int> { TODO("在庫の引き当ては未実装") }
println(bug.exceptionOrNull()?.let { it::class.simpleName })
println(unfinished.exceptionOrNull()?.let { it::class.simpleName })
実行結果
NullPointerException
NotImplementedError

1 つ目は、無いキーに !! を付けたプログラムの誤りです。2 つ目の TODO は、まだ書いていない処理の場所に置く標準ライブラリの関数で、呼ばれると NotImplementedError を投げます。NotImplementedError は Exception でなく Error の側のクラスですが、runCatching はこれも捕まえます。

どちらも、呼び出し側が「入力が悪かった」と同じ扱いをしてよい失敗ではありません。runCatching で受けて getOrDefault で既定の値に置き換えると、誤りが表に出ないまま処理が続きます。コルーチンの処理を取り消すときに使われる例外も Throwable の一種なので、runCatching はこれも捕まえます。

この章では runCatching を、例外を投げる関数を呼ぶ境界で、想定した失敗を受け取るためだけに使います。ラムダの中身はその関数の呼び出しだけにして、広い範囲の処理を囲まないでください。

sealed で失敗を型にする​

Result の失敗が持つのは Throwable です。Result<Int> を返す関数を見ても、どんな失敗がありうるのかは型から分かりません。呼び出し側が失敗の理由ごとに処理を変えたいなら、失敗の種類そのものを型にします。そのために使うのが、クラスとオブジェクト で扱った sealed interface です。

在庫の引き当ての結果を型にする​

在庫の引き当てには、成功のほかに 2 つの失敗があります。在庫が注文の数に足りない場合と、指定された商品の在庫がそもそも無い場合です。この 3 つを、ReservationResult を実装するクラスとして並べます。

sealed interface ReservationResult {
data class Reserved(val productId: String, val remaining: Int) : ReservationResult
data class InsufficientInventory(
val productId: String,
val requested: Int,
val available: Int,
) : ReservationResult
data class ProductNotFound(val productId: String) : ReservationResult
}

class Inventory(val productId: String, available: Int) {
var available = available
private set

fun reserve(quantity: Int): ReservationResult {
require(quantity > 0) { "数量は 1 以上にしてください: $quantity" }
if (available < quantity) {
return ReservationResult.InsufficientInventory(productId, quantity, available)
}
available -= quantity
return ReservationResult.Reserved(productId, available)
}
}

fun reserve(inventories: Map<String, Inventory>, productId: String, quantity: Int): ReservationResult {
val inventory = inventories[productId] ?: return ReservationResult.ProductNotFound(productId)
return inventory.reserve(quantity)
}

失敗のクラスは、呼び出し側が失敗を説明するのに要る値を持ちます。InsufficientInventory は注文した数と在庫の数を持つので、呼び出し側は「何個足りないか」を利用者に返せます。

Inventory.reserve の 1 行目には require が残っています。数量が 0 以下なのは、入力の検証を通った後の呼び出しでは起こらないはずの、呼び出し側の誤りだからです。在庫が足りないことは利用者の注文しだいで普通に起こるので、例外でなく ReservationResult の 1 つとして返します。1 つの関数の中でも、失敗の性質によって表し方を分けます。

when で結果を分ける​

呼び出し側は、when で結果の型ごとに分岐します。

val inventories = mapOf(
"P-001" to Inventory("P-001", available = 5),
"P-002" to Inventory("P-002", available = 1),
)
val requests = listOf("P-001" to 2, "P-002" to 3, "P-999" to 1)
for ((productId, quantity) in requests) {
val message = when (val result = reserve(inventories, productId, quantity)) {
is ReservationResult.Reserved ->
"引き当てました: ${result.productId} 残り ${result.remaining}"
is ReservationResult.InsufficientInventory ->
"在庫が足りません: ${result.productId} 注文 ${result.requested} 在庫 ${result.available}"
is ReservationResult.ProductNotFound ->
"商品がありません: ${result.productId}"
}
println(message)
}
実行結果
引き当てました: P-001 残り 3
在庫が足りません: P-002 注文 3 在庫 1
商品がありません: P-999

各分岐の中では、result がその分岐の型にスマートキャストされます。InsufficientInventory の分岐で result.requested を読めるのはそのためです。

この when には else がありません。ReservationResult は sealed なので、実装するクラスが 3 つだけであることをコンパイラが知っていて、3 つの is で網羅されていると判断できるからです。

失敗の種類を増やすとき​

商品の販売を止めたときの失敗 Discontinued を、後から足したとします。結果を分ける when を直さないままにすると、コンパイルが通りません。

sealed interface ReservationResult {
data class Reserved(val productId: String, val remaining: Int) : ReservationResult
data class InsufficientInventory(
val productId: String,
val requested: Int,
val available: Int,
) : ReservationResult
data class ProductNotFound(val productId: String) : ReservationResult
data class Discontinued(val productId: String) : ReservationResult
}

fun describe(result: ReservationResult): String = when (result) {
is ReservationResult.Reserved -> "引き当てました: ${result.productId}"
is ReservationResult.InsufficientInventory -> "在庫が足りません: ${result.productId}"
is ReservationResult.ProductNotFound -> "商品がありません: ${result.productId}"
}
コンパイルエラー
'when' expression must be exhaustive. Add the 'is Discontinued' branch or an 'else' branch.

コンパイラは、足りない分岐の名前を挙げて止めます。失敗の種類を足すと、それを処理していない場所がすべてコンパイルエラーとして見つかります。

このため、sealed の結果を分ける when には else を書かないことをすすめます。else を書くと、新しい失敗が黙って else の分岐に入り、コンパイラは何も知らせません。

例外で同じことを表した場合と比べると、違いがはっきりします。InsufficientInventoryException と ProductNotFoundException を投げる関数に 3 つ目の例外を足しても、呼び出し側のコンパイルは通ります。捕まえていない例外があることに気づくのは、実行してその例外が出たときです。

null で失敗を返す​

失敗の理由が 1 つしかなく、「値が無い」と言えば足りる場合は、戻り値の型を null 許容にして null を返すのがいちばん簡単です。標準ライブラリにもこの形の関数が多くあります。

val productNames = mapOf("P-001" to "ノート", "P-002" to "ボールペン")
println("3".toIntOrNull())
println("2個".toIntOrNull())
println(productNames["P-999"])

val quantity = "2個".toIntOrNull() ?: 1
val name = productNames["P-999"] ?: "不明な商品"
println("$name: $quantity 個")
実行結果
3
null
null
不明な商品: 1 個

toIntOrNull は、toInt なら例外を投げる入力に対して null を返します。Map の [] も、キーが無ければ null を返します。呼び出し側は Kotlin の基本 で見た ?. と ?: で null を処理できます。戻り値の型が null 許容なので、null を処理せずに null でない型の値として使おうとすると、その箇所でコンパイラが止めます。

標準ライブラリは、失敗を例外で表す関数と null で表す関数を対にして用意していることがよくあります。toInt と toIntOrNull、first と firstOrNull、Map の getValue と [] がその例です。名前に OrNull が付いている側は、失敗のときに null を返します。自分で関数を作るときも、この名前の付け方に合わせると、呼び出し側が戻り値の意味を名前から読み取れます。

null が表せるのは「無い」ということだけです。数量の文字列を読む関数が null を返したとき、それが「数字でない」からなのか「範囲の外」だからなのかを、呼び出し側は区別できません。理由によって利用者に返す内容を変えたくなったら、sealed に切り替えるときです。

失敗の表し方を選ぶ​

ここまでの書き方を、どの失敗にどれを使うかでまとめます。この表は書き手の判断で、第 2 部以降のコードもこの分け方に従って書きます。

失敗の性質例表し方
呼び出し側かプログラムの誤り数量に 0 を渡した。確定済みの注文を確定した例外 (require と check と error)
普通に起こり、理由が 1 つ商品名の表に商品 ID が無いnull を返す
普通に起こり、理由が複数ある在庫が足りない。商品が無いsealed interface を返す

表の 2 行目と 3 行目に当たる失敗でも、呼ぶ関数が例外でしか知らせないことがあります。例外を投げる版しか無いライブラリの関数などです。そのときは、runCatching が捕まえる範囲 の節のとおり runCatching でその呼び出しだけを囲み、すぐに null か sealed に変えます。ただし runCatching は、囲んだ呼び出しが投げる例外をすべて受けます。同じ呼び出しが 1 行目の誤りに当たる例外も投げうるなら、runCatching でなく、想定した例外の型だけを catch する try で受けます。ほかの型の例外は捕まえられず、そのまま呼び出し側へ伝わります。

分ける基準は、呼び出し側がその失敗を処理することを期待するかどうかです。期待するなら、失敗を戻り値の型に入れて、呼び出し側に処理を書かせます。null は、null でない型の値として使おうとした箇所でコンパイラが止めます。sealed は、when で分けたときに、すべての種類の分岐をコンパイラが求めます。ただし、is で成功の型だけを確かめる書き方や、戻り値を使わずに捨てる呼び出しには、コンパイラは既定では何も言いません。期待しないなら、例外で処理を止めます。誤りを呼び出し側に処理させても正しく直す方法は無く、止めて誤りを表に出すほうが早く直せるからです。

Result を関数の戻り値として外へ返すことは、この章では避けます。失敗の型が Throwable なので、呼び出し側はどんな失敗がありうるかを型から知ることができません。加えて、runCatching が捕まえる範囲 の節で見たとおり、runCatching で作った Result にはプログラムの誤りも入ります。runCatching は関数の中で使い、その関数から返す前に null か sealed に変えてください。

例外も、ここで捕まえずに放っておくわけではありません。第 2 部では、Spring Boot の上で捕まえなかった例外と sealed の失敗を、それぞれ HTTP のエラーレスポンスに変える場所を作ります。

まとめ​

  • Kotlin には検査例外が無く、例外を投げる関数を try なしで呼べます。投げる例外は KDoc の @throws で書き残します
  • catch は上から順に照らし合わされます。広い型の catch を下に置きます
  • 引数は require、状態は check で確かめます。起こらないはずの場合は error で止めます
  • runCatching は例外を Result に入れて返します。map と recover はラムダの中の例外を捕まえないので、捕まえるなら mapCatching と recoverCatching を使います
  • runCatching は Error やプログラムの誤りまで捕まえます。例外を投げる関数の呼び出しだけを囲みます
  • 理由が複数ある失敗は sealed interface で型にします。when に else を書かなければ、失敗の種類を足したときにコンパイラが処理の漏れを知らせます
  • 理由が 1 つの失敗は null で返します。誤りは例外で止めます

次に読む​

この章で第 1 部の Kotlin 入門は終わりです。前の章は コレクションとジェネリクス です。

次は第 2 部 (執筆中) です。第 2 部では Spring Boot を使い、注文 API を作り始めます。この章で決めた失敗の表し方は、入力の検証とエラーレスポンスの章で HTTP のステータスコードに対応付けます。

練習問題​

注文を取り消すメソッドに、前提条件の確かめ方の誤りがあります。どこをどう直しますか
enum class OrderStatus { DRAFT, CONFIRMED, SHIPPED, CANCELLED }

class Order(val id: String, status: OrderStatus) {
var status = status
private set

fun cancel() {
require(status != OrderStatus.SHIPPED) { "発送済みの注文は取り消せません: $id" }
status = OrderStatus.CANCELLED
}
}

解答例

enum class OrderStatus { DRAFT, CONFIRMED, SHIPPED, CANCELLED }

class Order(val id: String, status: OrderStatus) {
var status = status
private set

val canCancel: Boolean
get() = status == OrderStatus.DRAFT || status == OrderStatus.CONFIRMED

fun cancel() {
check(canCancel) { "取り消せない状態の注文です: $id は $status" }
status = OrderStatus.CANCELLED
}
}

cancel は引数を取らないので、確かめているのは注文の状態です。require を使うと IllegalArgumentException が投げられ、例外を見た人は渡した値を疑います。状態を確かめるので check にします。

もう 1 つの誤りは条件です。元のコードは「発送済みでない」ことだけを見ているので、すでに取り消した注文をもう一度取り消せてしまいます。解答例では、取り消せる状態を canCancel に書き出して、それ以外をすべて止めています。

canCancel を公開しておくと、呼び出し側は cancel を呼ぶ前に取り消せるかを確かめられます。確かめてから呼ぶのが前提なら、取り消せない状態で cancel が呼ばれるのは呼び出し側の誤りなので、check で止めるのが合っています。利用者の取り消しの依頼をそのまま cancel に渡す作りで、発送済みの注文への依頼が普通に起こるなら、例外でなく sealed の結果を返す形を選びます。

数量の文字列を読む関数があります。呼び出し側が「数字でない」と「範囲の外」で違うメッセージを返せるように、sealed interface で書き直してください
fun parseQuantity(text: String): Int? {
val quantity = text.toIntOrNull() ?: return null
if (quantity !in 1..99) return null
return quantity
}

解答例

sealed interface QuantityInput {
data class Valid(val quantity: Int) : QuantityInput
data class NotANumber(val text: String) : QuantityInput
data class OutOfRange(val quantity: Int) : QuantityInput
}

fun parseQuantity(text: String): QuantityInput {
val quantity = text.toIntOrNull() ?: return QuantityInput.NotANumber(text)
if (quantity !in 1..99) return QuantityInput.OutOfRange(quantity)
return QuantityInput.Valid(quantity)
}

fun main() {
for (text in listOf("3", "2個", "120")) {
println(parseQuantity(text))
}
}
実行結果
Valid(quantity=3)
NotANumber(text=2個)
OutOfRange(quantity=120)

元の関数は 2 つの失敗をどちらも null で返すので、呼び出し側は理由を区別できません。失敗ごとにクラスを分け、成功も Valid として同じ sealed interface に入れます。

失敗のクラスには、メッセージを組み立てるのに要る値を持たせます。NotANumber は受け取った文字列を、OutOfRange は読み取った数を持つので、呼び出し側は「120 は範囲の外です」のように値を入れたメッセージを作れます。data class にしてあるので、println で中身を確かめられます。実行結果のとおり、入れ子のクラスの toString には外側の QuantityInput の名前が付きません。

数を読む部分には toIntOrNull を使っていて、runCatching は要りません。例外を投げない関数があるなら、そちらを使うほうが捕まえる範囲を気にせずに済みます。