foldM
についてちょっと勉強したのでメモ。
文脈
このへんの会話が流れてきたけど、そういえばfoldM
使ったことなかったな、と思った。
やはり traverse … traverse は全てを解決する…https://t.co/NSJUZFV1yi
— えびちゃんを探しています (@Kory__3) 2023年9月2日
やはり traverse
— がくぞ (@gakuzzzz) 2023年9月3日
そういえば foldLeft で途中脱出したい時は foldLeftM にして Option に包めばいいというのなかなか直ぐに繋がらなかったなー
— がくぞ (@gakuzzzz) 2023年9月4日
みんな大好き Either[A, A].merge: A
— えびちゃんを探しています (@Kory__3) 2023年9月4日
foldMapA 便利すぎる
— がくぞ (@gakuzzzz) 2023年9月4日
とりいそぎ、foldM
について勉強してみるか。
おさらい: foldLeft
foldM
は正確?にはfoldLeftM
なのだけれど、いったんfoldLeft
についても復習しておこう。
def foldLeft[B](z: B)(op: (B, A) ⇒ B): B
あるSeq[A]
に対して、初期値B
と(B, A) => B
となる関数を渡すと、Seq[A]
は畳み込まれてB
になる。
例えばsumを以下のようにして実装できる:
val xs: Seq[Int] = Seq(1, 2, 3, 4, 5, 6, 7, 8, 9) xs.foldLeft(0)(_ + _) xs.reduce(_ + _) // same
しかしこれだと型はSeq[Int]
からInt
になるだけなのであまり変わり映えしない。同じことがreduce
で可能だ。
より面白い例として、リストを逆転させることができる:
val xs: List[Int] = List(1, 2, 3, 4, 5, 6, 7, 8, 9) xs.foldLeft[List[Int]](Nil)((lis, x) => x * 2 :: lis) // => List(18, 16, 14, 12, 10, 8, 6, 4, 2)
畳み込みというと語弊があるが、左からリストを走査していくのでそれを元に値を組み立てているのだ。reduce
は(A, A) => A
の関数しか扱えないので、こういった処理はreduce
ではできない。
foldLeft
自体の解説は色々あるので参考にしてほしい。
foldM
さて、おなじみの関数型ライブラリCatsはコレクションにfoldM
を導入する:
https://typelevel.org/cats/api/cats/Foldable.html
型シグネチャを見てみよう:
def foldM[G[_], A, B](fa: F[A], z: B)(f: (B, A) ⇒ G[B])(implicit G: Monad[G]): G[B]
実際にこのコードを使うときはコレクションの拡張メソッドとして呼び出すので、実際は以下のような形になる:
def foldM[G[_], B](z: B)(f: (B, A) ⇒ G[B])(implicit G: Monad[G]): G[B] // 参考 def foldLeft[B](z: B)(op: (B, A) ⇒ B): B
すると、差分となるのは関数の部分がB
の代わりにG[B]
を返すこと、そしてG[_]
はモナドでなければならないという2点のみだ。
ちなみにfoldM
のM
はモナドのMだ。同じく、語尾にA
がついているメソッドがあるとしたら、そいつはアプリカティブ版だ。
foldM
の振る舞い
foldM
は、fold
と同様に畳み込みを行うが、その過程でモナディックな合成を行ってくれる。まずは何もしないモナドであるId
をfoldM
に渡してみよう:
import cats.implicits.{*, given} import cats.Id Seq(1, 2, 3).foldM[Id, Int](0)((a, x) => a + x) // => 6: Int
Id
はMonad
のインスタンスだが、実際はId[A]
は単なるA
のラッパーとして振る舞い、何も特殊な振る舞いをしない。したがってこれは普通のfoldLeft
を実行しているのと変わりない。実行結果は全ての値を足した6になった。
さて、今度は本物のモナドを使ってみよう。こういうときはOption
を使うと良さそうだ:
Seq(1, 2, 3).foldM[Option, Int](0)((a, x) => Some(a + x)) // => Some(6)
実行結果はSome(6)
だ。foldM
にOption
を適用すると、Some(_)
である間だけ畳み込みを続行する。常時Some
を返すようにしているので、先程のバージョンとあまり変わりはない。
もし計算結果が10を超えたらNone
を返すようにするとどうだろう:
def mySum(xs: Seq[Int]) = xs.foldM[Option, Int](0)((a, x) => (a + x) match { case n if n > 10 => None case n => Some(n) } ) mySum(Seq(1, 2, 3)) // => Some(6) mySum(Seq(1, 2, 3, 4)) // => Some(10) mySum(Seq(1, 2, 3, 4, 5)) // => None
Option
の特性が発揮され、10を超えたタイミングで計算が中断した。これは畳み込み処理を強制脱出させるのに便利だ。
発展例: ルールエンジン
以下のようなPerson
データがあるとする。
import cats.implicits.{*, given} case class Person(name: String, age: Short, employed: Boolean, living: String) val x = Person("windymelt", 30, employed = true, "Kyoto") val y = Person("foo", 24, employed = false, "Kyoto") val z = Person("bar", 32, employed = true, "Hokkaido") val a = Person("buzz", 15, employed = false, "Kyoto")
Person
をあらかじめ定められたルールに適合するかどうか判定できるだろうか。ルールは以下のように定義する:
type Rule = Person => Either[String, Person] val adult: Rule = p => Right(p).filterOrElse(_.age >= 20, "Should be an adult") val free: Rule = p => Right(p).filterOrElse(!_.employed, "Should not be employed") val kyoto: Rule = p => Right(p).filterOrElse(_.living == "Kyoto", "Should be living in Kyoto")
例えば、アルコールを購入するためには大人である必要がある:
// rules may be added def validateAlcohol0( p: Person, ): Either[String, Person] = for { rp <- Right(p) ad <- adult(rp) } yield ad
ルールが1つだけならばadult
に渡すだけで良いが、今後ルールが追加されることを考えると、for
を使っておいたほうが無難だ。
数年後、法律が厳しくなって身分証の提示が必須になったとする。これをコードに反映させよう:
def checkId(p: Person): Option[String] = Some("123456") // stub val shouldHaveId: Rule = p => Right(p).filterOrElse(checkId(_).isDefined, "Should have ID card") def validateAlcohol1( p: Person, ): Either[String, Person] = for { rp <- Right(p) ad <- adult(rp) id <- shouldHaveId(ad) } yield id validateAlcohol1(x) // => Right(x)
検証を実行すると2つのルールが検証されることがわかる。この調子で今度は雇用のためのルールを定義しよう。成人で現在就職しておらず、京都に住んでいる人が欲しいとする:
def validateHiring0(p: Person): Either[String, Person] = for { rp <- Right(p) ad <- adult(rp) fr <- free(ad) ky <- kyoto(fr) } yield ky
for
を使うとこのような実装になるはずだ。
ここで1つ問題がある。ルールを増減させた別のルールを定義するには、コードをほぼ複製してコンパイルし直さなければならないのだ。また、どう見てもボイラープレートにしかなっていない変数があり、冗長だ。コレクションにルールを集めて、うまく使えないだろうか?
foldM
の出番
さて、ここからがfoldM
の使い所になる。foldM
はfor
文によるモナドの積み重ねをコレクションに押し込めると考えることができる。
// ルールのコレクションを定義する val alcoholRules = Seq(adult) // foldMでそれを全て適用する def validateAlcohol( p: Person, ): Either[String, Person] = alcoholRules.foldLeftM(p)((p, f) => f(p)) val hiringRules = Seq(adult, free, kyoto) def validateHiring( p: Person, ): Either[String, Person] = hiringRules.foldLeftM(p)((p, f) => f(p)) // for comprehensionをfoldの形にできる
これにより、動的にルールの数を増減できるようになった。例えば、夜間に限って身分証を出さないとアルコールを買うことができない、といった動的な定義も可能になる。
def isNight: Boolean = ??? val alcoholRules = Seq(adult) val alcoholRulesNight = Seq(adult, shouldHaveId) def validateAlcohol( p: Person, ): Either[String, Person] = { val rs = if (isNight) alcoholRulesNight else alcoholRules rs.foldLeftM(p)((p, f) => f(p)) }
まとめ
foldM
はfoldLeft
のモナディック版だ。- モナディック版であるということは、畳み込む過程でモナドの合成が同時に行なわれるということを意味する。
foldM
を使うことで、for
式を使ったルールの積み重ねを動的に書けるようになる可能性がある。