こんにちは。コミュニケーションアプリ「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
を返す - 正規表現の実装ミスには非検査例外を使う