LoginSignup
7
2

More than 1 year has passed since last update.

【Kotlin】エラーを呼び出し元に伝えるさまざまな方法

Last updated at Posted at 2022-12-19
お詫び

「【Kotlin】よりよい値の受け渡し(関数の引数と返値の設計)」というタイトルを予告しておりましたが、
エラーの話だけで結構な規模になってしまったため、
今回はそこだけの記事といたしました。

「【Kotlin】よりよい値の受け渡し(関数の引数と返値の設計)」につきましては
また後日公開いたします。


エラーが起きたときにそれを呼び出し元に伝える方法はいくつもあります。

🔘例外をスローする

「エラー」といえばまず例外を思い付くでしょう。

@Throws(MyException::class)
fun myFun() {
    // ...
    
    if (isMyError) {
        throw MyException()
    }
    
    // ...
}

fun main() {
    try {
        myFun()
    } catch (e: MyException) {
        // エラー時の処理
        // ...
    }
}

⭕️長所:エラーの詳細を伝えられる

例外を使えば、例外の型やプロパティの値によって、
(単にエラーが起きたということだけでなく)
エラーの詳細な内容を呼び出し元に伝えることができます。

例えばローカルファイルへの書き込みに失敗したという場合でも、
原因は ディレクトリがない/書き込み権がない/ファイルがロックされている などさまざまです。
これを呼び出し元に伝えられるので、呼び出し元は原因に応じた対応を行うことができます。

❌短所:呼び出し元にエラー処理を強制できない

Kotlin では、関数の呼び出し元に例外処理を強制することができません1
そのため例外をスローする関数だということに気付かず、適切なエラー処理を行わなかった結果、実行時に例外がスローされてアプリがクラッシュするということが起こり得ます。

❌短所:エラー処理が型安全でない

関数に例外を追加した際に、呼び出し元の修正を忘れてもコンパイルエラーにはならず、修正忘れに気付けません。

try {
    myFun()
} catch (e: MyException1){
    // エラー時の処理
    // ...
} // MyException2 に対する例外処理を忘れている

❌短所:例外オブジェクトを生成する計算コストが大きい

例外オブジェクトを生成する際にはスタックトレースが生成されます。
これは計算コストが大きい処理です。
そのため短時間に高回数使用されエラーになることが多い関数では他の方法を採った方がよいことがあります。

💡使い所

呼び出し元にエラー処理を強制できないので、
エラー処理をしなくてよい場合、
つまり起こったら復帰のしようがないエラーを表すのに使うのがよいのではないでしょうか。

とはいえ Kotlin/JVM では Java の標準ライブラリーが、 IOException などの、
呼び出し元でエラー処理をすることを前提とした例外をスローしてくるのですが…。

🔘null を返す

成功時の返値が non-null であれば、
返値型を nullable にして
エラー時には null を返すという方法があります。

fun myFun(): MyResult? {
    // ...
    
    if (isMyError) {
        return null
    }
    
    // ...
    
    return MyResult()
}

⭕️長所:呼び出し元にエラー処理を強制できる

呼び出し元で成功時の返値(non-null)を取得するためには、
まず返り値が null でない(エラーでない)ことを確認する必要があります。
そのため null であった(エラーであった)場合に対する処理が必要であることにも気付くことができます。

fun main() {
    val myResult = myFun()
    if (myResult == null) {
        // エラー時の処理
        // ...
    } else {
        // 成功時の処理
        // ここでは myResult は non-null。
        // ...
    }
}

ただし、呼び出し元が成功時に返値を使用しない場合には強制力がありません。

Android の場合は、関数に @CheckResult アノテーションをつけることで、呼び出し元で返値を使用していなければ警告が出るようにできます。

❌短所:エラーの詳細を伝えられない

エラーの詳細を伝えられないので、
呼び出し元でエラーの原因によって処理を分岐したい場合には使えません。

❌短所:【Kotlin/JVM】nullable とパフォーマンス

JVM 上では、Kotlin の次の型は Java のプリミティブ型で表されます。

  • Double
  • Float
  • Long / ULong
  • Int / UInt
  • Short / UShort
  • Byte / UByte
  • Char
  • Boolean

しかしこれらを nullable にした型(Int? 型など)はボックス化されたラッパークラス(java.lang.Integer クラスなど)で表されます。

そのため non-null の場合と比べて、ボックス化/アンボックス化、ヒープ領域の確保、ガベッジコレクションなどの実行コストがかかります。
短時間に高回数使用される関数の場合にはパフォーマンスに有意な影響が出るかもしれません。

ただしこれは、プリミティブ型と比べるとコストが大きいというだけで、
non-null な参照型の場合と同じパフォーマンスです。

💡使い所

成功時の返値が non-null であり、
呼び出し元でエラーの種類を気にすることがない場合におすすめです。

🔘Result を返す

Result<T> クラスのインスタンスは、型パラメータで指定された型のオブジェクトか任意の例外オブジェクトを持ちます。
これを返値型とする2ことで、エラー時に例外を(スローするのではなく)返値として返す方法です。

fun myFun(): Result<MyResult> {
    //...
    
    if (isMyError) {
        return Result.failure(MyException())
    }
    
    // ...
    
    return Result.success(myResult)
}

fun main() {
  myFun()
      .onSuccess { myResult ->
          // 成功時の処理
          // ...
      }
      .onFailure { e ->
          // エラー時の処理
          // ...
      }
}

⭕️長所:呼び出し元にエラー処理を強制できる

呼び出し元で成功時の結果を取得するためには、成功したかどうかを判定する処理を書く必要があります。

myFun()
    .onSuccess { myResult -> 
        // 成功時の処理
        // ...
    }

そのため、エラー処理を失念するということがありません。

ただし「🔘null を返す」と同様、呼び出し元が成功時に返値を使用しない場合には強制力がありません。

❌短所:エラー処理が型安全でない

成功時の結果の型は型パラメータで指定しますが、
エラー時の例外の型は指定できません(Throwable です)。

そのため、例外の型に応じて呼び出し元の処理を分ける場合、
発生しないはずの例外については無視するか握りつぶすことになります。
関数が返す例外の種類を増やした際に、呼び出し元を修正するのをわすれていてもコンパイルエラーにならず、実行時エラーになったり動作が不正になったりすることがありえます。

myFun()
    .onFailure { e ->
        when (e) {
            is MyException -> {
                // ...
            }
            else -> {
                // ありえないはず
            }
        }
    }

❌短所:例外オブジェクトを生成する計算コストが大きい

例外をスローする場合と同じく、例外オブジェクトを生成する必要があるため、スタックトレースの生成コストが掛かります。

💡使い所

呼び出し元にエラー処理を強制し、かつエラーの詳細を伝えたい場合によいでしょう。

🔘sealed interface を返す

返値型とする型を sealed interface で定義し、
そのサブタイプとして、成功時に返すクラスとエラー時に返すクラスを定義します。
エラー時に返すクラスはエラーの種類に合わせて複数あってもかまいません。

通常、成功時の返値とエラー時の返値に共通のメンバはないはずなので sealed interface を使いますが、
必要であれば sealed class を使っても違いはありません。

sealed interface MyResult {
    class Success(
        val mySucceededResult: MySucceededResult
    ): MyResult
    
    class Failure1(
        val error: MyErrorInfo1
    ): MyResult
    
    class Failure2(
        val error: MyErrorInfo2
    ): MyResult
}

fun myFun(): MyResult {
    // ...
    
    if (myErrorInfo1 != null) {
        return MyResult.Failure1(myErrorInfo1)
    }
    
    if (myErrorInfo2 != null)
        return MyResult.Failure2(myErrorInfo2)
    }

    // ...
    
    return MyResult.Success(mySucceededResult)
}

fun main() {
    val myResult = myFun()
    when (myResult) {
        is MyResult.Success ->
            // 成功時の処理
            // ...
        is MyResult.Failure1 ->
            // エラー1の場合の処理
            // ...
        is MyResult.Failure2 ->
            // エラー2の場合の処理
            // ...
    }
}

⭕️長所:呼び出し元にエラー処理を強制できる

成功時の結果を取得するためには、まず返値が成功時の結果を表す型であることを確認する必要があります。
そのためエラー時の処理を失念することがありません。

ただし「🔘null を返す」と同様、呼び出し元が成功時に返値を使用しない場合には強制力がありません。

⭕️長所:エラー処理が型安全

エラーの種類ごとに sealed interface のサブクラスを実装すれば、
呼び出し元で when を使うことでそれらに対する処理を網羅できます。
漏れがあればコンパイルエラー3になりますので、返す例外が増えても修正を忘れることはありません。

fun main() {
    val myResult = myFun()
    when (myResult) {
        is MyResult.Success ->
            // 成功時の処理
            // ...
        is MyResult.Failure1 ->
            // エラー1の場合の処理
            // ...
        // エラー2の場合の処理を忘れている
    } // コンパイルエラー! エラー2の場合の処理がないため
}

❌短所:関数ごとにクラスを実装する必要がある

ほぼその関数でしか使用できないクラスを実装する必要があります。

💡使い所

呼び出し元でエラーの種類により処理を変える必要がある、重要な関数で使うとよいでしょう。

🔘コールバックする

エラー時に呼び出されるコールバック関数を引数で受け取る方法です。

fun myFun(onError: (MyErrorInfo) -> Unit) {
    // ...

    if (isMyError) {
        onError(MyErrorInfo())
        return
    }

    // ...
}

fun main() {
    myFun { e ->
        // エラー時の処理
        // ...
    }
}

⭕️長所:呼び出し元にエラー処理を強制できる

引数なので、(関数の定義でデフォルト引数を設定しない限りは)呼び出し元で必ず指定しなければなりません。

⭕️長所:エラー処理が型安全

エラーの種類ごとにコールバック関数を分けてそれぞれの引数の型を適切に実装すれば、エラーを型安全に処理することができます。
エラーの種類が増えた際には、呼び出し元を修正しないとコンパイルエラーになるので、修正忘れがありません。

ただし、種類が増えると引数も増え、呼び出し元の実装が面倒になります。

fun myFun(
    onError1: (MyErrorInfo1) -> Unit,
    onError2: (MyErrorInfo2) -> Unit,
) {
    // ...
    
    if (isMyError1) {
        onError1(MyErrorInfo1())
        return
    }
    
    if (isMyError2) {
        onError2(MyErrorInfo2())
        return
    }
    
    // ...
}

fun main() {
    myFun(
        onError1: { e ->
            // エラー1の場合の処理
            // ...
        },
        onError2: { e ->
            // エラー2の場合の処理
            // ...
        },
    )
}

❌短所:エラー処理で return できない

このような関数は通常はインライン関数ではないため、
エラーだったら return するということができません。

fun main() {
    myFunc { e -> 
        return // コンパイルエラー!
    }
}

❌短所:コールバック関数内での処理の結果を外に渡しにくい

コールバック関数は呼ばれるかどうかが分からないので、
その内で処理した結果を外に渡そうとすると、
受け取る変数を var にして、
デフォルト値(大抵は null)で初期化しておく必要があります。

fun main() {
    var callbackResult: MyCallbackResult = null // var かつ nullable などにするしかない。
    myFunc { e ->
        // エラー時の処理
        // ...
        callbackResult = // ...
    }
}

❌短所:エラー時の返値

成功時に値を返す必要がある場合、
エラー時に何を返すか考える必要があります。

次のような方法があります。

エラー時の返値を引数で指定させる

エラー時の返値を引数で指定させる方法です。

fun myFun(
    default: MyResult,
    onFailure: (MyErrorInfo) -> Unit,
): MyResult {
    // ...
    
    if (isMyError) {
        // エラーの場合は、コールバック関数を実行し、
        // 引数で指定されたデフォルト値を返す。
        onFailure(MyErrorInfo())
        return default
    }
    
    // ...
    
    return myResult
}

fun main() {
    val myResult = myFun(MyResult()) { e ->
        // エラー時の処理
        // ...
    }
}

コールバック関数の返値を返す

コールバック関数の返値型を成功時の返値と同じにして、
エラー時にはそれを元関数の返値にする方法です。

fun myFun(onFailure: (MyErrorInfo) -> MyResult): MyResult {
    // ...
    
    if (isMyError) {
        // エラーの場合は、コールバック関数を実行し、
        // その返値を自身の返値とする。
        return onFailure(MyErrorInfo())
    }
    
    // ...
    
    return myResult
}

fun main() {
    val myResult = myFun { e ->
        // エラー時の処理
        // ...
    }
}

成功時の結果もコールバックで返す

成功時の結果を、関数の返値ではなく、コールバックで返す方法です。

fun myFun(
    onSuccess: (MyResult) -> Unit,
    onFailure: (MyErrorInfo) -> Unit,
) { // 関数の返値型は Unit にする。
    // ...
    
    if (isMyError) {
        // エラーの場合も、コールバック関数を実行する。
        onFailure(MyErrorInfo())
    }
    
    // ...
    
    // 成功の場合も、コールバック関数を実行する。
    onSuccess(myResult)
}

fun main() {
    myFun(
        onSuccess: { result ->
            // 成功時の処理
            // ...
        },
        onFailure: { e ->
            // エラー時の処理
            // ...
        },
    )
}

💡使い所

絶対に呼び出し元にエラー処理をさせたい、という場合にはこの方法しかなさそうです。

🚫アンチパターン:成功時の返値の型の範囲内で、特定の値を返す

次のような方法は避けるべきです。

  • 成功時には負でない数値を返す関数で、
    エラー時には負の値を返す。
  • 成功時には空でない文字列やコレクションなどを返す関数で、
    エラー時には空を返す。

Kotlin 標準ライブラリーの indexOf 関数などでも使われていますが、
Java のコレクションフレームワークとの互換のためでしょう。

❌短所:呼び出し元にエラー処理を強制できない

言うまでもなく、呼び出し元にエラー処理を強制できません。

❌短所:ドキュメントを読まないと、どうなったらエラーなのかわからない

関数のドキュメントをちゃんと読まなければ、
返値がどうだったらエラーなのかが分かりません。
それ以前に、エラーを返値で表すということすら分かりません。
関数のドキュメントが長ければ、読んでも見落とすかもしれません。

➡代替手段

代わりに「🔘null を返す」を使いましょう。

🚫アンチパターン:成功時の結果と例外の Pair を返す

関数の返値型を Pair にして、
成功時には一方に値をセットし、
エラー時には他方に値をセットする、
というような方法は使わないでください。

🚫アンチパターン
fun myFunc(): Pair<MyResult?, MyException?> {
    // ...
    
    if (isMyError) {
        // エラー時
        return null to MyException()
    }

    // ...
    
    // 成功時
    return myResult to null
}

❌短所:両方が null の場合を考える必要がある

成功時の結果も例外も共に null になってしまった場合の処理を呼び出し元で書かないとコンパイルエラーになる場合があります。

fun main() {
    val (myResult, myException) = myFun()
    myResult?.also {
        // 成功時の処理
        // ...
        return
    }
    myException?.also {
        // エラー時の処理
        // ...
        return
    }
    
    // ありえないはずだが、書かないとコンパイルエラーになる。
    throw RuntimeException("Pair(null, null) が返されました)
}

➡代替手段

代わりに「🔘Result を返す」を使いましょう。

まとめ

まとめるとこうなります。

方法 エラー処理の強制 エラーの詳細 エラー処理の型安全 その他
例外をスローする × ×
null を返す ×
Result を返す ×
sealed interface を返す 関数ごとにクラスを実装する必要がある
コールバックする 呼び出し元は実装しにくい

△:呼び出し元が返値を使用する場合のみ強制できます。

万能の方法はなさそうです。

各方法の長所短所を考慮して、
実装する関数に適した方法を採用しましょう。

/以上

  1. Java には「検査例外」(checked exception)という仕組みがあり、 Exception クラス派生であり かつ RuntimeException クラス派生でない例外(バグでなくても普通に起こり得るエラーを表す)をスローする関数については、呼び出し元でその例外を処理するコードを書かないとコンパイルエラーになります。

  2. Result クラスは Kotlin 1.3 から stable になりましたが、Kotlin 1.3 および 1.4 では返値にすることができません。

  3. when の場合、Kotlin 1.7 以上ではコンパイルエラー、1.6 では警告、1.5 以下では警告もありません。when when の返値を使う)であれば、いずれのバージョンでもコンパイルエラーになります。

7
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
2