🏹

Kotlin x Arrowで見る関数型プログラミングのパラダイム

2023/05/03に公開

積読していたJavaScript関数型プログラミングを読み終わったのと、先日開催されたKotlinConf'23のレポートを見ていてArrowという関数型プログラミングの影響を強く受けたKotlin製のライブラリの存在を知ったため学習内容の確認を踏まえて触ってみたのでその備忘録です。

対象読者

  • Kotlinに関数型プログラミングのエッセンスを取り入れたい方
  • 関数型プログラミングをこれから学習しようとしている方
  • Kotlinを普段から書いている方

モチベーション

もともと関数型プログラミングを学習しようと思ったのが普段業務でコードを書いている時にテストコードをもっとメンテナンスしやすく簡単に書きたいという気持ちがあり、なんでテストが辛いのかを考えた時に

  • 一つの関数がいろいろやっていてでかい。(200-300行くらいあると既に辛い気がする)
  • 副作用バリバリなのでテスト書くのが辛い。
  • ある入力値を渡したときに予測可能な出力が戻ってこない。(戻り値なしとか外部の何かに依存していたりとか)

といった理由だよなーと考えていて、いわゆる純粋関数を徹底するように心がけてコードを書いていけばテスト書きやすくなるのかなという浅はかな考えです。

本記事で説明しないこと

  • モナドなどの関数型プログラミング特有の概念の詳細な説明

setup

適当にKotlinプロジェクトを用意します。今回は特にフレームワークなどは利用せずgradle initしました。プロジェクトが作成できたら以下の依存関係を追加します。

build.gradle.kts
    // arrow
    implementation("io.arrow-kt:arrow-core:1.2.0-RC")
    implementation("io.arrow-kt:arrow-fx-coroutines:1.2.0-RC")

Either

Eitherはモナドの1種であり、関数の処理の成功・失敗をExceptionをスローすることなく扱うことができます。KotlinではJavaとは違い検査例外が存在しないのでtry-catchで関数実行を囲う必要はないですが、投げられる可能性のある例外を補足したいときもあると思います。ArrowではEitherを使用することでExceptionをスローすることなくエラー処理を書くことができます。

object UserNotFound

data class User(val id: Long)

fun findUserById(id: Int): Either<UserNotFound, User> {
   return if (id == 1) {
       User(1).right()
   } else {
       UserNotFound.left()
   }
}

EitherはEither<E, A>のように型パラメーターを2つ取り、Eに失敗した時の値、Aに成功したときの値を取ります。Eは一般的にExceptionなどを指定します。関数型プログラミングの世界においてこの時のEをLeft、AをRightと呼びます。

Arrowではfun <A> A.right() Either<Nothing, A>fun <A> A.left() Either<A, Nothing>のように拡張関数が用意されているため、成功した時の値をright()で返し、失敗した時にExceptionなどをleft()で返すようにすることでExceptionをスローすることなく関数を作成することができる。

また、eitherブロックを使用して以下のように書くこともできる。

fun findUserById(id: Int) = either {
   ensure(id == 1) { UserNotFound }
   User(1)
}

eitherは

public inline fun <Error, A> either(
    @BuilderInference block: Raise<Error>.() -> A
): Either<Error, A>

このような定義になっているのでブロック内でRaise<Error>.() -> A型の関数を実行することができる。
ensureは第一引数にとった条件がtrueの場合はそのまま処理が進み値を返すが、falseだった場合内部的にExceptionが発生し、第二引数で指定した値をLeftに取るEitherインスタンスを返す。

また、Raise<Error>.() -> Aの関数を自分で定義して使用することもできる。

fun findUserById(id: Int) = either {
//    ensure(id == 1) { UserNotFound }
//    User(1)
    user(id)
}

fun Raise<UserNotFound>.user(id: Int): User {
    return if(id == 1) {
        User(1)
    } else {
        raise(UserNotFound)
    }
}

呼び出し方法は以下のようにwhenを使用すると良い感じに書ける

fun main() {
    val user = when(val result = findUserById(1)) {
        is Either.Left -> {
            println("User not found")
            return
        }
        is Either.Right -> result.value
    }
    println("User found: $user")
}

Option

KotlinではJavaと違いnull安全な言語として設計されておりnull許容型が用意されているため、関数型で言うOptionMaybeといったものはKotlinの世界には必要ないかもしれない。ただ、以下のようなときにArrowが提供するOptionが使うことができる。

  • JavaライブラリなどをKotlinから呼ぶ時にnull安全が保証されていないとき。(RxJavaなど)
  • ネストしたnullability問題。

前者は単純にKotlinを使っていてもnull安全が完全に保証されていない時があるのでそういった時にOptionが使えるよという話です。

    val a = 1.some()
    val b = none<Int>()

    println("a: ${a.getOrNull()}")
    println("b: ${b.getOrElse { "default" }}")

    val c = Option.fromNullable(1)
    val d = Option.fromNullable(null)

    println("c: ${c.getOrNull()}")
    println("d: ${d.getOrElse { "default" }}")

Optionを作成するときはArrowで用意されているpublic fun <A> A.some(): Option<A>public fun <A> none(): Option<A>を使用することで作成することができます。また、Optionのファクトリ関数を使用することでも作成することができます。

後者のネストしたnullability問題とは例えば以下のような時です。

fun <A> List<A>.firstOrElse(default: () -> A): A = firstOrNull() ?: default()

fun example() {
    val a = emptyList<Int?>().firstOrElse { -1 } // -1
    val b = listOf(1, null, 3).firstOrElse { -1 } // 1
    val c = listOf(null, 2, 3).firstOrElse { -1 } // expect null but -1

    println("a: $a")
    println("b: $b")
    println("c: $c")
}

aとbは意図通りでaの時はリストが空のためデフォルト値の-1となり、bの時はリストの最初の要素が存在するので1になります。では、cの時はどうかというとリスト自体は空ではないので最初の要素を取得してほしいです。上記の例で言うと最初の要素がnullのためnullが取得されることを意図していますが、実際はデフォルト値の-1となってしまいます。

このような問題をネストしたnullability問題と言い、これはOptionを使用することで解決することができます。firstOrElse関数を以下のように修正してみます。

fun <A> List<A>.firstOrElse(default: () -> A): A =
    when(val option = firstOrNone()) {
        is Some -> option.value
        None -> default()
    }

Arrowで用意されているfirstOrNone()を使用することでOptionを得られるので、要素が取得できた時はその要素を返し、要素が取得できなかったときはデフォルト値を返すようにしています。これで実行してみると意図した通りcの値がnullとなります。

a: -1
b: 1
c: null

Lens

Lensは、関数型プログラミングにおけるイミュータブルなデータを扱う際に、特定のプロパティに対する操作を容易にするための手法です。Kotlinにおいてビジネスロジックを表現するために多くのdata classが作成されることがあると思いますがこれらdata classを不変に安全に取り扱う機能は備わっていません。Arrowではopticsという機能でこれを解決することができます。

data class Person(val name: String, val age: Int, val address: Address)
data class Address(val street: Street, val city: City)
data class Street(val name: String, val number: Int?)
data class City(val name: String, val country: String)

例えば、上記のようなdata classがあったときにPersonクラスを組み込みのcopyメソッドで複製しようとすると以下のように各フィールドごとにcopyメソッドを使用しなければなりません。

fun Person.capitalizeCountry(): Person =
  this.copy(
    address = address.copy(
      city = address.city.copy(
        country = address.city.country.capitalize()
      )
    )
  )

Arrowでは以下のようにdata classに@opticsを付与するだけです。とりあえず、依存関係を追加します。

build.gradle.kts
plugins {
  id("com.google.devtools.ksp") version "1.8.10-1.0.9"
}

dependencies {
  implementation("io.arrow-kt:arrow-optics:1.2.0-RC")
  ksp("io.arrow-kt:arrow-optics-ksp-plugin:1.2.0-RC")
}

追加できたら以下のようにdata classに@opticsを付けます。

@optics data class Person(val name: String, val age: Int, val address: Address) {
    companion object
}
@optics data class Address(val street: Street, val city: City) {
    companion object
}
@optics data class Street(val name: String, val number: Int?) {
    companion object
}
@optics data class City(val name: String, val country: String) {
    companion object
}

@opticsが付与されたdata classはcompanion object内に自動で各フィールドへアクセスできるLensが自動生成されます。

fun Person.capitalizeCountryModify(): Person =
    Person.address.city.country.modify(this) {country ->
        country.replaceFirstChar {
            if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString()
        }
    }
fun lensesExample() {
    val person = Person("John", 30, Address(Street("Main", 42), City("London", "uk")))
    val modifiedPerson = person.capitalizeCountryModify()
    println("person: $person") // country=uk
    println("modifiedPerson: $modifiedPerson") // country=Uk
}

このようにArrow Opticsプラグインを使用することでKotlinでLensという関数型プログラミングの概念を使用することができ、immutableにdata classを扱えたり、Lensの合成を使用することでネストの深いプロパティにも簡潔にアクセスできるようになります。

関数合成

関数型プログラミングにおいて小さくモジュール化された関数は再利用製が高く、テストがしやすいといったメリットがあります。この小さく作成された関数を組み合わせることでさまざまな処理をメソッドチェーンで実行することができこれを関数合成などと呼ぶ。

Kotlinで関数型プログラミングをしたいという場合にはArrowで用意されているandThencomposeといった関数を使用したり、curriedを使用して関数をカリー化したりすることで実現することができる。

data class Student(val group: String, val name: String, val age: Int)

fun findStudent(group: String) =
    if(group == "A")
        Student(group, "Alice", 20).right()
    else
        Either.Left("Student not found")

fun csv(useQuotes: Boolean, student: Student) =
    if(useQuotes)
        "\"${student.group}\",\"${student.name}\",\"${student.age}\""
    else
        "${student.group},${student.name},${student.age}"

val curryCsv = ::csv.curried()

fun getGroup(id: String) = id.first().toString()

// 上記の関数たちを合成
fun printStudent(id: String) = ::getGroup
    .andThen(::findStudent)
    .andThen { it.map(curryCsv(true)) }
    .andThen {either ->
        either.fold(
            { println("Error: $it") },
            { println(it) }
        )
    }
    .invoke(id)

fun main() {
    val id = "A-001-00001"
    printStudent(id) // "A","Alice","20"
}

上記の例ではprintStudent関数は上から順に以下の処理が実行される。

  • 引数のidから先頭のグループIDを取得する。
  • グループidでStudentインスタンスを取得する。
  • StudentインスタンスをCSV形式の文字列に変換する。
  • 値をコンソールに出力する。

composeでも同じように関数合成をすることができるが、composeの場合は下から順番に処理が進むため記述が逆になる。カリー化に関しては詳細な説明は控えますが2つ以上の引数をとる関数を引数が1つの扱いやすい単項関数にするための処理のようなものです。

プロパティテスト

最後にKotestを使ったプロパティテストについて触れてみたいと思います。プロパティテストとはある関数において数千、数万のあらゆるパターンの入力を試し、出力が期待通りであることをテストする手法です。もともとはHaskellのQuickcheckのようなフレームワークからきているようで関数型プログラミングの世界だけでなく広く使われている。

Kotestではこのプロパティテストを書くためのモジュールも用意されているので以下の依存関係を追加する。

build.gradle.kts
testImplementation("io.kotest:kotest-property:$kotestVersion")

ざっくり説明するとKotestにおけるプロパティテストはForAllCheckAllを使った2種類の方法があります。ForAllは以下のようにラムダ式が真偽値を返すように記述します。

class PropertyExample: StringSpec({
   "String size" {
      forAll<String, String> { a, b ->
         (a + b).length == a.length + b.length
      }
   }
})

CheckAllは以下のようにアサーションをラムダ式の中に記述します。

class PropertyExample: StringSpec({
   "String size" {
      checkAll<String, String> { a, b ->
         a + b shouldHaveLength a.length + b.length
      }
   }
})

どちらも入力値のパターン型を型パラメーターで指定します。Kotestのプロパティテストはデフォルトで1000回実行され、さまざまなエッジケースもカバーすることができます。例えば、Int型なら正の最大値や負の最大値などはカバーされます。

また、入力パターンを生成する機構をGeneratorと呼び、KotestではArbクラスを使用し指定することができる。Arrow用のGeneratorとアサーション用のmatcherは拡張モジュールとして用意されているので使用する場合は下記の依存関係を追加する。

build.gradle.kts
    testImplementation("io.kotest.extensions:kotest-property-arrow:$kotestArrowExtensionVersion")
    testImplementation("io.kotest.extensions:kotest-assertions-arrow:$kotestArrowExtensionVersion")

試しに前述したStudentクラスのテストをプロパティテストで書いてみると以下のように書ける。

StudentTest.kt
package demo2

import io.kotest.assertions.arrow.core.shouldBeLeft
import io.kotest.assertions.arrow.core.shouldBeRight
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.property.Arb
import io.kotest.property.arbitrary.stringPattern
import io.kotest.property.checkAll

internal class Demo2Test : FunSpec({
    context("findStudent") {
        test("グループがA以外の指定のときはStudent not foundが返る") {
            // 引数にStringのジェネレーターを指定してA以外の文字列でテスト
            checkAll(Arb.stringPattern("[^A]")) { group ->
                println(group)
                val student = findStudent(group)
                student shouldBeLeft "Student not found"
            }
        }
        test("グループがAのときStudentが返る") {
            val student = findStudent("A")
            student shouldBeRight Student("A", "Alice", 20)
        }
    }
    context("csv") {
        test("useQuotesがtrueのときはダブルクォーテーションで囲まれた文字列が返る") {
            checkAll<String, String, Int> {group, name, age ->
                val student = Student(group, name, age)
                val csv = csv(true, student)
                csv shouldBe "\"$group\",\"$name\",\"$age\""
            }
        }
        test("useQuotesがfalseのときはダブルクォーテーションで囲まれない文字列が返る") {
            checkAll<String, String, Int> {group, name, age ->
                val student = Student(group, name, age)
                val csv = csv(false, student)
                csv shouldBe "$group,$name,$age"
            }
        }
    }
    context("getGroup") {
        test("idの先頭の文字が返る") {
            checkAll<String> { id ->
                val group = getGroup(id)
                group shouldBe if (id.isEmpty()) "" else id.first().toString()
            }
        }
    }
})

ちなみに、getGroupのテストを実行したところ空文字が指定された時にエラーが発生することがわかったので以下のように修正しました。

- fun getGroup(id: String) = id.first().toString()
+fun getGroup(id: String): String {
+    if(id.isEmpty()) {
+        return ""
+    }
+    return id.first().toString()
+}

こういったよくやりがちなケースを全て網羅できるのがプロパティテストの良いところですね!!

まとめ

当初の目的である関数型を学ぶことでテストの書きやすい関数を作るが達成できたかと言うと微妙ですが関数型の学習をしてよかったとは思っています。理由としては以下のような感じです。

  • 今まで未知の概念であったモナドといった関数型の概念を知ることができたのでこわくなくなった。
  • JavaやKotlinで普段使っているmapやfilter, Optionalがたぶん関数型からきてるんだと知ってプログラミング言語に対しての解像度がなんか上がった気がした。
  • 関数型を学習したことでArrowやKotestのプロパティテストなどを理解することができた。

Arrowを実際のプロジェクトで採用するのはよほど統制がとれていないと難しい気がしますがArrowのコードはジェネリクスやラムダの使い方としてかなり勉強になったので興味がある方はぜひ使ってみてください。

あとは、Kotestのプロパティテストは年末のアドベントカレンダーの時に記事にしようとしたんですがいまいち理解できなくて断念したんですが今回改めて挑戦してみたらすんなり理解できたので今回の取り組みとしてはよかったです。プロパティテストの導入に関しては特に問題は発生しないかと思うので使えそうなときにはどんどん使っていこうと思います。

関数型に関してもArrowに関してもまだキャッチアップしていないことも多いですし、理解もいまいちなところが多いのでまた関数型の学習をする際にはHaskellかElmを触ってみようかと思います。

今回は以上です🐼

本記事のサンプルコードなどはこちら

https://github.com/JY8752/functional-programing-kotlin-demo

GitHubで編集を提案

Discussion