/var/log/jsoizo

メモ帳 技術とか趣味とか

Arrow Coreのデータ型探訪

これは 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> のいずれかとなる。

詳細は以下の記事が詳しい。

hack.nikkei.com

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 が定義されている。

画像引用元: https://atmarkit.itmedia.co.jp/ait/articles/0105/02/news002.html

このような 社員は少なくとも1つの会社に所属する を実現したいときに、以下の例のようにEmployee.companiesがListだと空配列である可能性があるために、制約条件が型として表現できていないことになり、Employeeを扱う際につど空であることをチェックする必要がある。

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で削除されるので注意が必要。

github.com

削除される理由はこの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のOptionalScalaのOption[T]と同様で、Nullableをデータ型で表現したもの。

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とかそのへんは関数型プログラミングの素人なので理解できなかった。