- 株式会社スマートラウンドのシニアエンジニア
- スタートアップと投資家のやり取りを効率化するデータ管理プラットフォームを開発している
- 技術スタック: Kotlin/Ktor & TypeScript/Vue.js
- Server-Side Kotlin Meetupの運営にも協力
- スタートアップと投資家のやり取りを効率化するデータ管理プラットフォームを開発している
- Clojure, Haskellなどの関数型言語の愛好者
- 関数型まつりの運営スタッフ(座長のひとり)
- Java, Scala, Clojure, KotlinとJVM言語での開発経験
- Kotlinの実務利用は1年半ほど🐣
Kotlin Fest 2025で登壇しました!
Functional Calisthenics in Kotlin: Kotlinで「関数型エクササイズ」を実践しよう
-
きっかけ
-
エラーハンドリングのパターン
-
Kotlinの
Result/Either -
実際に採用したResult実装
-
取引(入出金)情報のCSVファイルについて形式/内容を検証してからDB保存するインポート機能
-
想定する処理の流れ
- CSVファイルのパース
- 入力形式のチェック
- DBの既存情報との照合/補完
- DBへの保存
-
CSVファイルを取り込む複数の段階で様々な理由で失敗(エラー終了)する可能性がある
- → どこでどのように失敗したか把握したい
-
エラーハンドリングを愚直に実装すると扱いづらく読みづらくなりそう
- → コードをなるべく宣言的で高レベルな表現に保ちたい
⇒ 関数型言語で定番の Either/Result 型がほしい
-
主流言語でたいてい基本的な手法
-
組み合わせづらい(composabilityが低い)
-
例外のthrowとは大域脱出(readabilityも低い)
- 良くも悪くも命令型の仕組みといえる
-
エラー情報を取り回すのにはあまり適していない
-
エラーが回復不能/不要で暗黙的に扱えば十分な場合には使いやすい
-
(静的型付き)関数型言語でよくある設計パターン
-
Either: Haskell, Scala, etc. -
Result: OCaml, Elm, Rust, etc.
-
-
ただの(ふさわしい型が付いた)値なので:
-
自由に組み合わせられる
-
便利な関数などを用意して操作を抽象化できる
-
-
エラーを明示的に扱いたい場合にほしくなる
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 // 到達しない- 成功値
Successor 失敗値Failure(Throwable)- 関数型言語でいう
Either/Resultほど汎用的ではない
- 関数型言語でいう
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!")- 成功値
Rightor 失敗値Left eitherメソッドによってフラットに命令的に記述できる
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!")- 成功値
Okor 失敗値Err bindingメソッドによってフラットに命令的に記述できる
[参考] 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!")- 成功値
Rightor 失敗値Left - for式(flatMap, mapなどのメソッドの連鎖に対する汎用的なシンタックスシュガー)が便利
// 代数的データ型(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
// 以下、便利な関数を定義(後述)
}data class ImportError(
val line: Int,
val message: String,
)-
ImportResult.Success<T>:T型の成功値 -
ImportResult.Failure:List<ImportError>の失敗値- 内容は
ImportErrorリストに固定した(汎用的な実装ではなく用途特化で扱いやすくするため)
- 内容は
fun <U> fold(valueFn: (T) -> U, errorsFn: (List<ImportError>)
-> U): U =
when (this) {
is Success -> valueFn(value)
is Failure -> errorsFn(errors)
}- fold関数: 成功値、失敗値それぞれに関数適用して同じ型の値に畳み込む
-
cf. Listに対するfold関数
- 概念的には、空(nil)もしくは要素を持つ(cons)再帰的な代数的データ型
-
典型的な操作でwhenによる場合分けが不要に
-
fun getValueOrThrow(): T =
this.fold(
{ it },
{
throw IllegalStateException(
"ImportResult is Failure: $it"
)
},
)- get関数: 成功値の中身を取り出す
- 失敗値に対しては動作しない
fun <U> flatMap(f: (T) -> ImportResult<U>): ImportResult<U> =
fold(
{ f(it) },
{ Failure(it) },
)- flatMap関数: 成功値に
ImportResultを返す関数を適用する-
失敗値はそのまま
-
cf. Listに対するflatMap関数
-
ℹ️ HaskellではMonad型クラスの
>>=(bind)
-
fun <U> map(f: (T) -> U): ImportResult<U> =
flatMap { Success(f(it)) }- map関数: 成功値に関数を適用する
-
失敗値はそのまま
-
cf. Listに対するmap関数
-
ℹ️ HaskellではFunctor型クラスの
fmap -
flatMapがあればmapは実装できる
- ℹ️ なぜなら、Monad ⇒ Functor
-
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) },
) },
)
}- sequence関数:
List<ImportResult<A>>をImportResult<List<A>>に変換する- 失敗値があれば
ImportErrorリストに集約
- 失敗値があれば
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) }import: メインの関数- 成功値: インポートした取引データのリスト
- 失敗値: パース、チェック、DBデータ補完、保存のいずれかの段階で発生したエラー情報リスト
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()
}parse: CSVファイルのパース処理を行う関数- 成功値: パース済みのCSV行リスト
- 失敗値: パース過程でのエラー情報リスト
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}は必須です",
)
}extractRequiredColumn: 必須の列を抽出する関数- 成功値: 抽出された文字列
- 失敗値: 抽出過程のエラー情報リスト
private fun extractColumn(
columnName: String,
rowContext: RowContext,
): ImportResult<String?> =
rowContext.row[columnName]?.trim()
.let { ImportResult.Success(it) }extractColumn: 列を抽出する関数- 成功値: 抽出された文字列
- 失敗値: なし
private fun validate(rows: List<ParsedCsvRow>):
ImportResult<List<ValidatedCsvRow>> =
rows.mapWithLineNumber { line, row ->
ValidatedCsvRow.from(
row,
listOf(
validateSingleFields(row, line),
validateFieldRelations(row, line),
)
)
}
.sequence()validate: 入力形式のチェックを行う関数- 成功値: チェック済みのCSV行リスト
- 失敗値: チェック過程のエラー情報リスト
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)
})
}validateSingleFields: CSV行の各フィールドをチェックする関数- 成功値:
Unit(意味のある結果がないため) - 失敗値: バリデーションエラー情報リスト
- 成功値:
-
標準ライブラリのResultは使いづらいがサードパーティライブラリを導入するほどではないとき、必要最小限のResult/Eitherを実装するのも選択肢になる
-
関数型言語で培われた設計パターンを参考にデータ型や関数を整備することで、コードを宣言的な表現に保ちやすくなる💪
- Kotlin標準ライブラリの
Result - Arrowの
Either - kotlin-result
- Scala標準ライブラリの
Either - 『Scala関数型デザイン&プログラミング』
- サンプルコード: fpinscala/fpinscala
- 『なっとく!関数型プログラミング』
- サンプルコード: miciek/grokkingfp-examples
- 『関数型ドメインモデリング』
