Share
Embed
KotlinでミニマルなResult実装による関数型エラーハンドリング
Kotlinでの(サードパーティライブラリに依存しない)必要最小限のResult実装による関数型エラーハンドリングについてご紹介します。
Kent OHASHI
November 25, 2025
Transcript
31 slides
-
のシニアエンジニア スタートアップと投資家のやり取りを効率化する データ管理プラットフォームを開発している 技術スタック: Kotlin/Ktor & TypeScript/Vue.js の運営にも協力 , などの関数型言語の愛好者
の運営スタッフ( 座長のひとり) Java, , Clojure, Kotlin とJVM 言語での開発経験 Kotlin の実務利用は1 年半ほど🐣 lagénorhynque 🐬カマイルカ 株式会社スマートラウンド Server-Side Kotlin Meetup Clojure Haskell 関数型まつり Scala 2 -
取り組んでいたタスク 取引( 入出金) 情報のCSV ファイルについて形式/ 内容 を検証してからDB 保存するインポート機能 想定する処理の流れ 1.
CSV ファイルのパース 2. 入力形式のチェック 3. DB の既存情報との照合/ 補完 4. DB への保存 6 -
設計時に重視したこと CSV ファイルを取り込む複数の段階で様々な理由で 失敗( エラー終了) する可能性がある → どこでどのように失敗したか把握したい エラーハンドリングを愚直に実装すると扱いづらく 読みづらくなりそう
→ コードをなるべく宣言的で高レベルな表現に 保ちたい ⇒ 関数型言語で定番の Either/Result 型がほしい 7 -
A. 例外のthrow/catch によるエラーハンドリング 主流言語でたいてい基本的な手法 組み合わせづらい(composability が低い) 例外のthrow とは大域脱出(readability も低い) 良くも悪くも命令型の仕組みといえる
エラー情報を取り回すのにはあまり適していない エラーが回復不能/ 不要で暗黙的に扱えば十分な場 合には使いやすい 9 -
B. エラーを表す型によるエラーハンドリング ( 静的型付き) 関数型言語でよくある設計パターン Either: Haskell, Scala, etc. Result:
OCaml, Elm, Rust, etc. ただの( ふさわしい型が付いた) 値なので: 自由に組み合わせられる 便利な関数などを用意して操作を抽象化できる エラーを明示的に扱いたい場合にほしくなる 10 -
成功値 Success or 失敗値 Failure (Throwable) 関数型言語でいう Either/Result ほど汎用的 ではない
Kotlin 標準ライブラリの Result fun mean(xs: List<Double>): Result<Double> = if (xs.isEmpty()) Result.failure( IllegalArgumentException("mean of empty list!") ) else Result.success(xs.sum() / xs.size) val x1 = mean(listOf(1.0, 2.0, 3.0)).getOrThrow() // 2.0 val y1 = mean(listOf(4.0, 5.0)).getOrThrow() // 4.5 val result1 = x1 + y1 // => 6.5 val x2 = mean(listOf(1.0, 2.0, 3.0)).getOrThrow() // 2.0 val y2 = mean(emptyList()).getOrThrow() // 例外発生 val result2 = x2 + y2 // 到達しない 12 -
成功値 Right or 失敗値 Left によってフラットに命令的に記述 できる Arrow の Either
fun mean(xs: List<Double>): Either<String, Double> = if (xs.isEmpty()) Either.Left("mean of empty list!") else Either.Right(xs.sum() / xs.size) either { val x = mean(listOf(1.0, 2.0, 3.0)).bind() // Right(2.0) val y = mean(listOf(4.0, 5.0)).bind() // Right(4.5) x + y } // => Right(6.5) either { val x = mean(listOf(1.0, 2.0, 3.0)).bind() // Right(2.0) val y = mean(emptyList()).bind() // Left(...) x + y } // => Left("mean of empty list!") either メソッド 13 -
成功値 Ok or 失敗値 Err によってフラットに命令的に記 述できる kotlin-result の Result
fun mean(xs: List<Double>): Result<Double, String> = if (xs.isEmpty()) Err("mean of empty list!") else Ok(xs.sum() / xs.size) binding { val x = mean(listOf(1.0, 2.0, 3.0)).bind() // Ok(2.0) val y = mean(listOf(4.0, 5.0)).bind() // Ok(4.5) x + y } // => Ok(6.5) binding { val x = mean(listOf(1.0, 2.0, 3.0)).bind() // Ok(2.0) val y = mean(emptyList()).bind() // Err(...) x + y } // => Err("mean of empty list!") binding メソッド 14 -
[ 参考] 成功値 Right or 失敗値 Left (flatMap, map などのメソッドの連鎖に対する
汎用的なシンタックスシュガー) が便利 Scala 標準ライブラリの Either def mean(xs: Seq[Double]): Either[String, Double] = if xs.isEmpty then Left("mean of empty list!") else Right(xs.sum / xs.length) for x <- mean(Seq(1, 2, 3)) // Right(2) y <- mean(Seq(4, 5)) // Right(4.5) yield x + y // => Right(6.5) for x <- mean(Seq(1, 2, 3)) // Right(2) y <- mean(Seq(4, 5)) // Left("mean of empty list!") yield x + y // => Left("mean of empty list!") for 式 15 -
ImportResult 型: インポート処理のためのResult 型 // 代数的データ型(Success or Failure の直和型) sealed
interface ImportResult<out T> { data class Success<out T>(val value: T) : ImportResult<T> data class Failure(val errors: List<ImportError>) : ImportResult<Nothing> { init { // 代わりにnon-empty list を用意してもよい require(errors.isNotEmpty()) } } val isSuccess: Boolean get() = this is Success val isFailure: Boolean get() = this is Failure // 以下、便利な関数を定義( 後述) } 17 -
ImportError 型: インポートに関するエラー情報 ImportResult.Success<T>: T 型の成功値 ImportResult.Failure: List<ImportError> の失敗値 内容は
ImportError リストに固定した( 汎用的 な実装ではなく用途特化で扱いやすくするため) data class ImportError( val line: Int, val message: String, ) 18 -
ImportResult インターフェースの便利な関数 fold 関数: 成功値、失敗値それぞれに関数適用して 同じ型の値に畳み込む cf. List に対するfold 関数
概念的には、空(nil) もしくは要素を持つ(cons) 再帰的な代数的データ型 典型的な操作でwhen による場合分けが不要に fun <U> fold(valueFn: (T) -> U, errorsFn: (List<ImportError>) -> U): U = when (this) { is Success -> valueFn(value) is Failure -> errorsFn(errors) } 19 -
get 関数: 成功値の中身を取り出す 失敗値に対しては動作しない fun getValueOrThrow(): T = this.fold( {
it }, { throw IllegalStateException( "ImportResult is Failure: $it" ) }, ) 20 -
flatMap 関数: 成功値に ImportResult を返す関数 を適用する 失敗値はそのまま cf. List に対するflatMap
関数 ℹ️ Haskell では (bind) fun <U> flatMap(f: (T) -> ImportResult<U>): ImportResult<U> = fold( { f(it) }, { Failure(it) }, ) Monad 型クラスの >>= 21 -
map 関数: 成功値に関数を適用する 失敗値はそのまま cf. List に対するmap 関数 ℹ️ Haskell
では flatMap があればmap は実装できる ℹ️ なぜなら、Monad ⇒ Functor fun <U> map(f: (T) -> U): ImportResult<U> = flatMap { Success(f(it)) } Functor 型クラスの fmap 22 -
sequence 関数: List<ImportResult<A>> を ImportResult<List<A>> に変換する 失敗値があれば ImportError リストに集約 fun
<A> List<ImportResult<A>>.sequence(): ImportResult<List<A>> = this.fold(Success(emptyList<A>()) as ImportResult<List<A>>) { acc, result -> acc.fold( { accValues -> // 累積値が成功の場合 result.fold( { Success(accValues + it) }, { Failure(it) }, ) }, { accErrors -> // 累積値が失敗の場合 result.fold( { Failure(accErrors) }, { Failure(accErrors + it) }, ) }, ) } 23 -
利用例: 取引のCSV ファイルのインポート処理 import: メインの関数 成功値: インポートした取引データのリスト 失敗値: パース、チェック、DB データ補完、保存
のいずれかの段階で発生したエラー情報リスト fun import( csv: InputStream, targetId: TargetId, ctx: AppContext, ): ImportResult<List<TransactionInput>> = parse(csv) .flatMap { validate(it) } .flatMap { fillWithStoredData(it, targetId, ctx) } .flatMap { save(it, ctx) } 24 -
parse: CSV ファイルのパース処理を行う関数 成功値: パース済みのCSV 行リスト 失敗値: パース過程でのエラー情報リスト private fun
parse(csv: InputStream): ImportResult<List<ParsedCsvRow>> = csvReader().open(csv) { readAllWithHeaderAsSequence() .mapWithLineNumber { line, row -> val ctx = RowContext(line, row) ParsedCsvRow.from( executionDate = extractRequiredColumn(" 取引日", ctx) .flatMap { coerceAsLocalDate(it, " 取引日", ctx) }, amount = extractRequiredColumn(" 金額", ctx) .flatMap { coerceAsBigDecimal(it, " 金額", ctx) }, // 以下、その他の列の抽出が続く ) }.toList().sequence() } 25 -
extractRequiredColumn: 必須の列を抽出する 関数 成功値: 抽出された文字列 失敗値: 抽出過程のエラー情報リスト private fun extractRequiredColumn(
columnName: String, rowContext: RowContext, ): ImportResult<String> = extractColumn(columnName, rowContext) .flatMap { value -> value?.let { ImportResult.Success(it) } ?: ImportResult.singleError( line = rowContext.line, message = "${columnName} は必須です", ) } 26 -
extractColumn: 列を抽出する関数 成功値: 抽出された文字列 失敗値: なし private fun extractColumn( columnName:
String, rowContext: RowContext, ): ImportResult<String?> = rowContext.row[columnName]?.trim() .let { ImportResult.Success(it) } 27 -
validate: 入力形式のチェックを行う関数 成功値: チェック済みのCSV 行リスト 失敗値: チェック過程のエラー情報リスト private fun validate(rows:
List<ParsedCsvRow>): ImportResult<List<ValidatedCsvRow>> = rows.mapWithLineNumber { line, row -> ValidatedCsvRow.from( row, listOf( validateSingleFields(row, line), validateFieldRelations(row, line), ) ) } .sequence() 28 -
validateSingleFields: CSV 行の各フィールド をチェックする関数 成功値: Unit ( 意味のある結果がないため) 失敗値: バリデーションエラー情報リスト
private fun validateSingleFields( row: ParsedCsvRow, line: Int, ): ImportResult<Unit> = Validator.validate(row).let { errorMap -> if (errorMap.isEmpty()) ImportResult.Success(Unit) else ImportResult.Failure(errorMap.values.map { ImportError(line = line, message = it) }) } 29 -
Further Reading Kotlin 標準ライブラリの の Scala 標準ライブラリの サンプルコード: サンプルコード: Result
Arrow Either kotlin-result Either 『Scala 関数型デザイン&プログラミング』 fpinscala/fpinscala 『なっとく!関数型プログラミング』 miciek/grokkingfp-examples 『関数型ドメインモデリング』 31