これは Kotlin Advent Calendar 2022 の19日目が空いていたので書いたものです。
Kotlin中心の仕事になるので少しずつお勉強中なのだが、Scalaでコードを書いているときに使っている便利なデータ型が意外とKotlinにはないなとかNull-Safetyの方針がScalaとは違うのだなと感じることがある。
これは、その感想をうけて関数型プログラミングライブラリであるArrowを使えばややScalaぽいコードが書けそうなので試してみるものである。ArrowにはCore, Fx, Optics等ライブラリがいくつかあるがそのうちArrow Coreの一部データ型について触っていく。
追記: 2022.12.21 Tuple4~22について追加した
Either<A, B>
Left<A>
または Right<B>
のいずれかとなる。
詳細は以下の記事が詳しい。
ScalaのEither同様にRight biasedなので map
は Rightな場合に値の変換等行える。同様に mapLeft
でLeftな場合の、 bimap
でRight,Leftな場合それぞれの変換を定義できる。
Scalaでいうfor内包表記のようなものがないので多段にflatMapする場合にネストが深くなってしまうが、以下のように書くことで可読性を高くできる。この例だとe3がLeftなので e == e3
である。
import arrow.core.computations.either val e1: Either<String, Int> = Either.Right(1) val e2: Either<String, Int> = Either.Right(2) val e3: Either<String, Int> = Either.Left("e3 error") val e: Either<String, Int> = either.eager<String, Int> { val _e1 = e1.bind() val _e2 = e2.bind() val _e3 = e3.bind() _e1 + _e2 + _e3 } e.tap { v -> println(v) } e.tapLeft { s -> println(s) } // e3 error が出力される
NonEmptyList<A>
要素数が1以上の配列。Type Aliasとして Nel
が定義されている。
このような 社員は少なくとも1つの会社に所属する
を実現したいときに、以下の例のようにEmployee.companiesがList
data class Company(val name: String) data class Employee(val name: String, val companies: List<Company>)
ここでNelを利用することで 少なくとも1つ
が型からわかるようになる。
data class Company(val name: String) data class Employee(val name: String, val companies: NonEmptyList<Company>)
初期化はこのように nonEmptyListOf
で行える
val companies = nonEmptyListOf(Company("AAA Company"), Company("BBB Company")) val employee = Employee("Taro", companies)
ListからNelを生成するには、 Nel.fromList
Listが空である場合を想定してOption(後述)でラップされている。
確実に空でないことがわかっているなら fromListUnsafe
してもよいがListの0番目の要素を取得しようとして IndexOutOfBoundsException
が発生する可能性がある。
val companiesList = listOf(Company("AAA Company"), Company("BBB Company")) val nullableCompaniesNel: Nel<Company>? = Nel.fromList(companiesList).orNull() val companiesNel: Nel<Company> = Nel.fromListUnsafe(companiesList)
NelからListへは toList
すればよい。
Validated<E, A>
Valid<A>
または Invalid<E>
のいずれかとなる。
ユースケースとしては以下のようにPhoneNumberの生成を行う際にバリデーションを実施することで事前条件違反の場合はインスタンスが作成できないようにするもの。
data class PhoneNumber(val value: String) { companion object { private fun isValidPhoneNumber(value: String): Boolean = TODO() fun withValidate(value: String): Validated<String, PhoneNumber> = if (isValidPhoneNumber(value)) Valid(PhoneNumber(value)) else Invalid("invalid phone number") } } fun savePhoneNumber(phoneNumber: PhoneNumber): Unit = TODO() fun main(args: Array<String>) { val validatedPhoneNumber = PhoneNumber.withValidate("invalid number") when { validatedPhoneNumber is Validated.Valid -> savePhoneNumber(validatedPhoneNumber.value) else -> TODO() } }
実際のケースとしては複数の値を一度にバリデーションして、すべてのエラーを返却したいことが多いと思われるが、そういったケースではNonEmptyListを使いつつ以下のようにバリデーションエラーを積み重ねていくことができる。
ここで、 ValidatedNel<E, A>
が Validated<Nel<E>, A>
のtypealiasとして定義されており、ValidatedNel<E,A>
の値を生成するために Validated.invalidNel()
や Validated.catchNel()
のようなメソッドも用意されているので使っていくと良い。
data class User(val id: Int, val name: String, val email: String) data class InvalidInput(val field: String) val validatedId: ValidatedNel<InvalidInput, Int> = Valid(1) val validatedName: ValidatedNel<InvalidInput, String> = Validated.invalidNel(InvalidInput("name")) val validatedEmail: ValidatedNel<InvalidInput, String> = Validated.invalidNel(InvalidInput("email")) val validatedUser: ValidatedNel<InvalidInput, User> = validatedId.zip(validatedName, validatedEmail) { id, name, email -> User(id, name, email) } validatedUser.tap { user -> println(user) } validatedUser.tapInvalid { errors -> println(errors) } // NonEmptyList(InvalidInput(reason=name), InvalidInput(reason=email)) が出力される
ただし、ValidatedはArrow 2.0で削除されるので注意が必要。
削除される理由はこのPull Requestに書いてあるので要約すると、Validated<E, A>とEither<E, A>どちらもE,Aの論理和であるがValidatedはbindできずzipで結合していくことは可能であるというのが違いであり、Eitherで代替可能であるのでValidatedは消しますということだった。 修正を見る限りEitherにValidatedのメソッドと同じものを移植したり、 typealias EitherNel<E, A> = Either<NonEmptyList<E>, A>
を定義するなど上記例で書いたようなものは大体Validatedの部分をEitherに書き直すだけで良さそうに見える。
Option<A>
JavaのOptional
nullableな複数の値をを扱うときにOption型にすることでnullチェックをいちいち行わずに済むので可読性が高くなる。かもしれない。
val a: Int? = 100 val b: Int? = 200 val c: Int? = 300 // このようには書けない val res1 = a? + b? + c? // letでunwrapする例 val res2 = a?.let { _a -> b?.let { _b -> c?.let { _c -> _a + _b + _c } } } // Option<A>を使った例 val res3 = option { val _a = a.toOption().bind() val _b = b.toOption().bind() val _c = b.toOption().bind() _a + _b + _c }.orNull()
Kotlinのお作法的にNull-SafetyであるためにNullableを使っていきましょうなので、基本的にOption型を使うことはほぼなさそう。一方で大量にnullableな値を扱わなければいけない状況でインデントがどんどん深くなることは想定されるため、引き出しとして持っておくのは良いかもしれない。
なお、ユースケースとしてあまり無いかもしれないがJavaのOptionとの相互運用をするにはこういうextensionを用意しておくと良さそう。
import arrow.core.Option import java.util.Optional fun <T> Optional<T>.toOption(): Option<T> = Option.catch { this.get() } fun <T> Option<T>.toJavaOptional() = Optional.ofNullable(this.findOrNull{ _ -> true})
Ior<A, B>
Left<A>
, Right<B>
, Both<A, B>
のいずれかとなる。 Inclusive or
の略。CatsのIorを移植したもので意味合いとしては Either<Either<A,B>, Pair<A,B>>
と同じである。
Eitherに似ており同じようにRight biasedであるが、EitherがLeftかRightのいずれかであるのに対して、IorはBothつまり両方というパターンが存在する。
実際のユースケースとしてはエラーは起きたが正常値もありますみたいなものだと思われるが、具体的な例がパッとは浮かばない。
Eitherと使い勝手が近いのでコードは割愛。
Tuple4<A,B,C,D>
~ Tuple22<A,B,C .. U,V>
Pair<A,B>
や Triple<A,B,C>
の更に件数が多いもの。Scalaエンジニアにはおなじみの。なぜ22までなのかはScalaで取りうる引数の上限が22だったから(Scala 3で変わる)
val t4 = Tuple4(first = 1, second = 2, third = 3, fourth = 4)
個人的には Tuple2<A,B>
と Tuple3<A,B,C>
だけないのは気持ち悪く、Pair, Tripleへのtype aliasを作ってくれても良いのではないかと思ったり。
その他
Monoidとかそのへんは関数型プログラミングの素人なので理解できなかった。