Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

CatsのKleisliについて勉強したメモ

こんな記事を読んだ。

zenn.dev

この記事の中にKleisliという概念が登場する。自分は圏論の専門家ではないのでKieisli圏の話ではなく、Catsを使ってKleisliを扱う方法、どう便利なのかについて勉強したことをまとめてみる。

flatMapの中身

Kleisliとは以下のような定義である:

Kleisli[F[_], A, B] = A => F[B]

そう、ただのラッパーである。この等式の右辺をよく見ると、モナドをflatMapするときに渡すアレのことだとわかるはずだ。

val f = (n: Int) => Some(n * 2)
Some(42).flatMap(f) // ここのfの型がKleisli[Option[_], Int, Int]と等価なものになっている
// => Some(84)

Catsでは、Kleisliを作るにはKleisliコンストラクタを使って関数を包む必要がある(たぶん、モナドの情報を型パラメータとして引っ張り上げてくるのに必要なのだ):

import cats._, cats.data._, cats.syntax.all._

val k = Kleisli(f)
k(42) // => Some(84)

// runで中身の関数を取り出せる
Some(42).flatMap(k.run) // => Some(84)

これは当てずっぽうだけれど、おそらくflatMapするときに渡すあの関数自体を独立して扱えたら便利そうという発想で導入された概念だと思っておけばよさそう(実際は多分そんなことはなくて複雑な理論があるのだろう・・・)。

Kleisliは合成できる

Kleisli自体にはいくつかの望ましい特性がある。その一つが「F[_]がモナドであればKleisli[F[_], A, B]は合成できる」ということだ((より厳密には、F[_]FlatMapである必要があるが、初心者はMonadだと考えておけばよい))。

val k = (s: String) => try {
        Some(s.toInt)
    } catch {
        case e: NumberFormatException => None
    }

val l = (n: Int) => if (n % 2 == 0) Some(n*2) else None

このような2つのKleisliがあるとき・・・

import cats.implicits._
import cats._, cats.data._, cats.syntax.all._

val lk = Kleisli(l) compose Kleisli(k)
lk("42") // => Some(84)

ちゃんと合成できている。この操作は、最初からSomeで包んだ値に対して2回flatMapするのと等価な操作だ:

Some("42").flatMap(k).flatMap(l) // => Some(84)

2回flatMapするようなとき、klを合成したくなるのは自然な欲求だと思う。

ちなみにcomposeの順序を替えたandThenも用意されている。

特殊な合成いろいろ

localメソッド

Kleisli[F[_], A, B]に対してAA => Aを渡すことで、Kleisli[F[_], AA, B]に変形する。つまりKleisliの入力だけ変形するメソッド。

import cats.data.Kleisli, cats.implicits._
type ParseResult[A] = Either[Throwable, A]
val parseInt = Kleisli[ParseResult, String, Int](s => Either.catchNonFatal(s.toInt))
parseInt.local[List[String]](_.combineAll).run(List("1", "2"))
// => ParseResult[Int] = Right(12)

github.com

lower メソッド

用途不明。結果をさらにpureしてくれる。

val k = (s: String) => try {
        Some(s.toInt)
    } catch {
        case e: NumberFormatException => None
    }
val lower = Kleisli(k).lower
lower("42") // => Some(Some(42))
lower("abc") // => Some(None)

liftメソッド

別のApplicativeの文脈に押し上げてくれるメソッド。例えばkListに対して使えるようにしてくれる:

val listK = Kleisli(k).lift[List]
List("42", "43", "abc", "def", "44").flatMap(li.run)
// => List(Some(42), Some(43), None, None, Some(44))

便利なときには便利だと思うけどパッと用途は思いつかない。

Kleisliの型クラス

KleisliF[_]がどの型クラスのインスタンスであるかによって様々な型クラスのインスタンスになることができる。

typelevel.org

また、このドキュメントによると、Monoid[F[B]]のインスタンスがあれば、Monoid[Kleisli[F, A, B]]のインスタンスも得られるといっている。

例えば、数字に点数を付ける2つの関数があるとする:

val score1 = Kleisli { (n: Int) => if (n % 2 == 0) Some(1) else None }
val score2 = Kleisli { (n: Int) => if (n >= 10) Some(1) else None }

それぞれ、偶数なら1点、10以上なら1点を与えるような関数だ。

Option[Int]Monoidなので、Kleisli[Option, Int, Int]同士をcombineできるようになる:

val score = score1 |+| score2

score(0) // => Some(1)
score(5) // => None
score(42) // => Some(2)

いずれの点数も得られなかった数字にはNoneが返された。そして両方に合致している場合は加算の結果2が返されている。これはルールエンジンのようなものを作るときに役立ちそうだ。

まとめると

  • Kleisliとはm.flatMap(f)するときのfの部分の型を抜き出したものである
  • Kleisliは型が合っていれば関数のように合成できる
  • F[_]によっては、Kleisli自体も様々な型クラスのインスタンスとして振る舞う

Scalaを書いているとflatMapはどこにでも現われる概念なので、Kleisliもおそらくどこにでも登場する概念だと考えておくとよさそう。ありがたいことに、便利な特性が用意されている。

★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?