LINEヤフー Tech Blog

LINEヤフー株式会社のサービスを支える、技術・開発文化を発信しています。

This post is also available in the following languages. English

コード品質向上のテクニック: 第 1 回(覆<error>盆に返らず)

こんにちは。コミュニケーションアプリ「LINE」のモバイルクライアントを開発している石川です。

私達は、高い開発生産性を維持するために、コード品質と開発文化の改善に注力しています。 そのために様々な取り組みを行っているのですが、その 1 つとして Review Committee の活動があります。 Review Committee では、マージ済みのコードを再度レビューし、レビューアとオーサーにフィードバックしたり、レビューで集めた知見を Weekly Report と称して毎週共有したりしています。

この Weekly Report で共有される話題は、Android や iOS といったプラットフォームや、Kotlin や Swift 言語固有の注意点も含まれるのですが、多くの場合はプログラミング一般に適用できるものになるように配慮しています。(ただし、説明のために使うコードは Kotlin を使うことが多いです。)

今後、毎週木曜の定期連載として、Weekly Report をこのブログ上で公開していこうと思います。今回共有するレポートのタイトルは「覆<error>盆に返らず」です。

なお、コード品質と開発文化の改善のために私達がどのような取り組みをしているかについては、DroidKaigi 2021 で発表した長く生きるコードベースの「品質」問題に向き合うをご参照ください。

覆<error>盆に返らず

下記の parseFooValue という関数は、"foo:" から始まる文字列から 1 ~ 6 桁の整数を取得する関数です。

/**
 * Parses the given "FOO" data string then returns the parsed integer by wrapping with
 * [FooParseResult].
 *
 * The expected format is: "foo:(1-6 digit non-negative integer)".
 * For example, "foo:00" and "foo:123456"
 are valid while "foo : 1", "foo,2", "foo:-1" are not.
 * 
 * This throws [IllegalArgumentException] if the given text format is invalid.
 */
 @Throws(IllegalArgumentException::class)
 fun parseFooValue(inputText: String): FooParseResult {
    val matchResult = FOO_FORMAT_REGEX.matchEntire(inputText)
    requireNotNull(matchResult) // Throws if `inputText` format is invalid

    val fooIntValue = matchResult.groupValues.getOrNull(1)?.toIntOrNull()
    return if (fooIntValue != null) {
        FooParseResult.Success(fooIntValue)
    } else {
        FooParseResult.InvalidRegexError
    }
 }

 /** Result model of parsing "FOO" integer from string value. */
 sealed class FooParseResult {
    /** A result representing "FOO" data is correctly parsed as an integer. */
    class Success(val value: Int) : FooParseResult()

    /** An error result representing the parsing regex implementation is incorrect. */
    object InvalidRegexError : FooParseResult()
 }

 private val FOO_FORMAT_REGEX: Regex = """foo:(\d{1,6})""".toRegex()

このコードの問題点は何でしょうか?

覆水返して お釣り返さず

エラーを通知する手段は、呼び出し元がどのようにそのエラーを処理するかに依存します。

上記のコードでは、2 種類のエラーが存在します。

  • 引数として与えられる文字列のフォーマットが不正
  • 正規表現の実装のミス

一般的には、「呼び出し元から与えられた引数は信頼できない」としたほうが、より頑健なコードになります。その引数は、ユーザによる入力や外部のシステムから与えられたものかもしれないからです。「不正な引数が与えられることは普通に起こる」ことを前提に、エラーの処理を実装する必要があります。

一方で、正規表現の実装ミスは parseFooValue 関数に閉じたエラーです。呼び出し元はこのエラーについて気にする必要はなく、「回復不可能」なエラーであるべきです。

これを踏まえて、それぞれのエラーの表現方法を確認してみましょう。引数のフォーマットのエラーは IllegalArgumentException で表現され、正規表現の実装ミスは sealed class  object で表現されます。IllegalArgumentException 等のロジックエラーは、多くの場合 catch すべきではありません。その代わりに、ロジックそのものを修正するべきです。一方で、正規表現のミスは、実際にはロジックエラーなのにもかかわらず、呼び出し元で処理をすることを sealed class によって強制してます。そのため、エラーをどう処理するべきかとエラーの表現の間でミスマッチが起きています。

回復(不)可能のレベル

プログラミング言語や処理系にも依存しますが、エラーの表現方法は多岐にわたります。どのエラー表現を使うかを決めるには、そのエラーがどの程度回復可能かを参考にすると良いです。

回復可能
↑
┃ 0 デフォルト値
┃ 1 シンプルドメインエラー
┃ 2 直和 + エラー値, nullable なエラー値 (, or 多値リターン)
┃ 3 検査例外
┃ 4 非検査例外
┃ 5 キャッチ不能エラー
↓
回復不可能

0. デフォルト値

例: "", 0 (加算値), 1 (係数), Int.MIN_VALUE, [], null オブジェクトパターン

デフォルト値は、エラーが起きたかどうかを呼び出し元が区別しなくて良い場合に使われます。

// If some error happens, `getUsers` returns empty
val userList = userProvider.getUsers()
userList.forEach { messageSender.send("You are ${it.name}.") }

1. シンプルドメインエラー

例: null/nil/undefined, Optional/Maybe, false

シンプルドメインエラーは、エラーが起きたことは知る必要があるものの、エラーの内容までは知る必要がない場合に使われます。典型的な例としては、早期リターンを適用する場合です。

// If some error happens `getUser` returns null.
val user = userProvider.getUser(id)
    ?: return
dialog.showWithMessage("You are ${user.name}.")

もし、正常系での戻り値がない場合は、エラーが起きたかどうかを示す真偽値がよく使われます。多くの場合は false がエラーが起きたことを示します。(ただし、C言語においては、0 が正常系を示すことも多い点に注意してください。)

可能な限り、型安全なシンプルドメインエラーを使うべきです。(Kotlin の null や Scala の Option.empty など)。型安全なシンプルドメインエラーが使える場合は、-1  0xDEADBEEF などの特殊なエラー値を使うべきではありません。

2. エラー値を含む直和, nullable なエラー値 (, or 多値リターン)

例: Either, Result, sealed class, associated value, nullable error value

直和型は、正常系とエラーそれぞれに異なる値が必要なときに使われます。これは、呼び出し元がエラーの種類に応じて異なる処理を必要とする場合に有効です。

sealed interface Response {
    class Success(val value: Value): Response
    class Error(val errorType: ErrorType): Response
}
enum class ErrorType { ... }

もし、正常系で値を必要としない場合は、nullable なエラー値を返すという方法もあります。この場合は、null が正常系を意味します。ただし、誤解を招きかねない場合は nullable なエラー値を使うよりも、直和型を定義したほうが良いでしょう。

これに近い概念として、多値リターンを採用している言語もあります (例: Go, Python)

3. 検査例外

例: Java の Exception, Swift の Error

検査例外は理論上、戻り値として直和型を使っているのと同じとみなせます。ただし、現実の実装 (Java と Swift の場合) としては、直和型よりも型について厳密な取り扱いがしにくいです。また、Java のException は特に、スタック情報といった多くの情報を持ちます。そのため、現実としては直和型よりもより回復不可能に近いものに対して使うべきでしょう。

4. 非検査例外

例: Java の RuntimeException

非検査例外は、処理系のエラーやロジックエラーといった「多くの場合に回復不可能」というエラーについて使うべきです。上記 4 つと比較して、エラーが起きる可能性そのものを見落とす可能性があるためです。

ただし、直和型や検査例外を持たない言語については、回復可能なエラーとして非検査例外が使われることもあります。

5. キャッチ不能エラー

例: Swift の fatalError()

キャッチ不能エラーは、ほぼ非検査例外と同じ使われ方をしますが、より厳格に「フェイルファスト」を実現します。

覆水は盆に返すな

例題のコードは以下のように改善することができます。

  • 不正な入力に対しては null を返す
  • 正規表現の実装ミスには非検査例外を使う
    • 文字列グループの取得は getOrNull ではなく get ([])を使う
    • toIntOrNull の代わりに toInt を使う
/**
 * Returns an integer parsed from the given "FOO" data string.
 * 
 * The expected format is: "foo:(1-6 digit non-negative integer)".
 * For example, "foo:00" and "foo:123456"
 are valid while "foo : 1", "foo,2", "foo:-1" are not.
 * 
 * This returns null if the given text format is invalid.
 */
fun parseFooValue(inputText: String): Int? {
    val matchResult = FOO_FORMAT_REGEX.matchEntire(inputText)
        ?: return null
    return matchResult.groupValues[1].toInt()
}

一般にエラーが回復可能であるかどうかは、呼び出し元のコードや、エラーを取り扱うスコープにも依存します。 例えば、クエリの処理中に発生したエラーの場合、「クエリ処理のロジックの視点では回復不能だが、サーバプロセスの視点では回復可能」という取り扱いになることも多いでしょう。 呼び出し元のコードが決まらないと回復可能かどうか判断がつかない場合は、一旦は取り扱い易い方法でエラーを返し、呼び出し元で別のエラーに変換することも考慮に入れる必要があります。


一言まとめ: エラーがどの程度回復可能かに応じて、適切なエラーの表現方法を使う。

キーワード: recoverable error, logic error, exception