タプルのSeqの片側にflatMapしたいことがあって、arrowのsecond
を使ったらうまくいったのでメモ。
flatMap(知ってる人は飛ばしていい)
皆さんflatMap
って知ってますか?(YouTuber風) 例えば以下のようなことができて便利。
val lis = Seq(1, 2, 3, 4, 5) val f: Int => Seq[Int] = n => Seq(n, n+1, n+2) lis.flatMap(f) // => List(1, 2, 3, 2, 3, 4, 3, 4, 5, 4, 5, 6, 5, 6, 7): scala.collection.immutable.Seq[scala.Int]
普通にmap
するだけだと二重のSeq
が作られてしまうのだが、flatMap
すると1つのSeq
に潰して平らにしてくれる。
flatMap
する操作ができるのはSeq
だけではなく、Future
やOption
といったF[_]
といった形になるような多くの型でも使うことができる。より正確に関数型的な言い方で言うと、モナドのインスタンスになるようなF[_]
ではflatMap
を呼ぶことができる。
おおまかに言い換えると、f: A => F[B]
になるような関数を受け取って、F[A]
からF[B]
に変換できるような操作が可能な型のことをモナドと言う*1。Seq
もFuture
もOption
もモナドなので、flatMap
を呼び出せるようになっているというわけ。
タプルのSeqに対してflatMapしたい
さきほど自作プロダクトをいじっていて、こういう操作をしたいということがあった。
Seq
に対してflatMap
したいのだが、flatMap
したい対象がペアに包まれてしまっている。つまりSeq[(A, B)]
のB
に対してflatMap
がしたい。- 渡す関数の型は
B => Seq[C]
、つまりペアの右側だけに作用する関数。 - 最終的に
Seq[(A, C)]
になっていてほしい。Cが増えた分は、単純にAを流用してほしい。
コードにするとこんな感じだ:
val lis: Seq[(String, Int)] = Seq( "a" -> 42, "b" -> 100, "c" -> 256, ) val f: Int => Seq[Int] = n => Seq(n, n+1, n+2) // 最終的にSeq((a,42), (a,43), (a,44), (b,100), (b,101), (b,102), (c,256), (c,257), (c,258))が欲しい
ナイーブな解法1: fをタプルに拡張する
素朴にやると、f: (String, Int) => Seq[(String, Int)]
になるようにf
を書き直せばやれるのだが、f
とは全く関係のない事情がf
の実装に入り込むことになるので、メンテナンス困難になるし、なにより分かりにくい。
val f2: (String, Int) => Seq[(String, Int)] = { case (a, b) => Seq(a -> b, a -> b+1, a -> b+2) }
ナイーブな解法2: lisの片側にだけfを適用する
lis
をmap
して、片側だけ剥がした状態でf
を適用することもできるが、これだとどれだけ増えたのかが不明になってしまう。
val result = lis.map(_._2).flatMap(f) lis.map(_._1) zip result // 個数が増えているので処理できない!
Arrow.secondを使った解法
このような操作は、CatsのArrow
を使った関数の変形を使って簡単に実現できる。操作は以下のように行う:
f
をKleisli
として定義するcats.arrow.Arrow
の拡張メソッドを使ってflatMap(f.second.run)
する
これからそれぞれのステップについて解説する。
f
をKleisli
として定義する
Catsが提供するArrow
とは、矢印のような性質を持つ、合成が可能な概念をまとめてArrow
という概念に統合し、合成や変換を行えるようにする仕組みである。例えばA => B
といった1引数関数もArrowとして合成や変換が可能である。
A => F[B]
といった関数もArrowにすることができるのだが、型推論の関係でF
の情報を型引数として取り出す必要があるので、一旦Kleisli
というラッパーを挟む必要がある。Kleisliとは、A => F[B]
の形になる関数のことで、要するにflatMap
に入れるあの部分のことである。
といっても、書く上ではあまり難しいことはない。Kleisli
の型引数は、F, 引数, 返り値
の順に入れるだけだ。
import cats.data.Kleisli val kf: Kleisli[Seq, Int, Int] = Kleisli { n => Seq(n, n+1, n+2) }
cats.arrow.Arrow
の拡張メソッドを使ってflatMap(f.second.run)
する
CatsのArrowにはfirst
とsecond
という、A => B
の形になっているArrowを(A, X) => (B, X)
や(X, A) => (X, B)
の形に変形してくれるメソッドがある。これを使うことで、タプルの左側か右側かのどちらかにだけArrowを適用できるようになる。
そしてKleisli
もArrow
として扱えるので、kf.second
と書くとKleisli[Seq, (X, Int), (X, Int)]
の形になる。
最後に、run
を呼び出すことでKleisil[Seq, (X, Int), (X, Int)]
を元々の形である(X, Int) => Seq[(X, Int)]
の形に戻し、flatMap
に渡すことで完了する。
import cats.implicits._ lis.flatMap(kf.second.run) // => List((a,42), (a,43), (a,44), (b,100), (b,101), (b,102), (c,256), (c,257), (c,258))
全体のコード
import cats.implicits._ import cats.data.Kleisli val lis: Seq[(String, Int)] = Seq( "a" -> 42, "b" -> 100, "c" -> 256, ) val kf: Kleisli[Seq, Int, Int] = Kleisli { n => Seq(n, n+1, n+2) } lis.flatMap(kf.second.run)
まとめ
- ペアのSeqに対して、そのペアのどちらかだけにflatMapを行い、適切にもう片側を補完するような処理が可能である。
- この処理は、CatsのArrowが持つ
first
/second
という能力と、KleisliもまたArrowであるという特性によって実現できる。 A => F[B]
をKleisli
に包むことで型がKleisli[F, A, B]
になり、Arrowとして扱えるようになる。run
を呼び出すことでKleisli
のラッパーが取れて元のA => F[B]
の形になる。
関連記事
*1:正確には、これに加えて他の操作や満たすべき法則がある