はじめに
去年の 12 月に cats の基本的な型クラスの関数を、使用しなかった場合との比較を交えて紹介しました。
今回はその続きとして、Parallel とその関数について覚え書き程度ですが、書いていこうと思います。
par** みたいな関数が、サジェストされて出てきたときに見ないふりをしていたあなた(僕のことです)もこれを機に、使えるようにしておきましょう。
これまで見てきたのは、
- kernel に属する、Eq, Order, Semigroup, Monoid
- core に属する Traverse, Monad とそれらの関連型クラス
- Bitraverse (は一応別枠ですね・・・)
という大きく分けて 2 系統なのですが、今回は外様な感じのポジションの Parallel とそれと関連のある NonEmptyParallel について見ていきます。
まだまだ知らない型クラスがいっぱいありますね。。
Parallel, NonEmptyParallel とは
trait NonEmptyParallel[M[_]] extends Serializable {
type F[_]
def apply: Apply[F]
def flatMap: FlatMap[M]
def sequential: F ~> M
def parallel: M ~> F
まず NonEmptyParallel は、2つの型を相互に自然変換(言い方あってるかな違うかな)するやり方を知っている場合かつそれらが、Apply と FlatMap の型クラスのインスタンスである場合に、
インスタンスにすることができるというふうな定義になっているのがわかると思います。
trait Parallel[M[_]] extends NonEmptyParallel[M] {
def applicative: Applicative[F]
def monad: Monad[M]
Parallel は NonEmptyParallel を継承しており、M に要求する型が FlatMap ではなく Monad と一段階強い制約になっているのがわかると思います。
cats では、
implicit def catsParallelForEitherValidated[E: Semigroup]: Parallel.Aux[Either[E, *], Validated[E, *]]
implicit def catsStdNonEmptyParallelForZipList: NonEmptyParallel.Aux[List, ZipList]
implicit def catsStdNonEmptyParallelForZipVector: NonEmptyParallel.Aux[Vector, ZipVector]
という風に型クラスのインスタンスが定義されています。(ParallelInstances をみるともっとたくさんのインスタンスを見ることができます。)
似ているけど、少し違う(Either と Validated はエラー処理だけど、fail fast と累積可能なデータ型という点が違ったりする)ような、ものが対になっている事がわかると思います。
さて、関数を見ていきましょう。
parProduct
まずは、parProduct です。シンプルな関数なので、Parallel の特性が理解しやすいかもしれません。
def parProduct[B](mb: M[B])(implicit P: Parallel[M]): M[(A, B)]
関数の In / Out だけ見ると、普通の product を使うのと何が違うのかあまり想像できないと思いますので、実行結果を比較してみましょう。
val rightA: EitherNel[Throwable, Int] = 1.rightNel[Throwable]
val rightB: EitherNel[Throwable, Int] = 2.rightNel[Throwable]
val leftA: EitherNel[Throwable, Int] = new Throwable("error A").leftNel[Int]
val leftB: EitherNel[Throwable, Int] = new Throwable("error B").leftNel[Int]
例としては、EitherNel の場合を考えてみましょう。
println(rightA.product(rightB))
// Right((1,2))
println(rightA.parProduct(rightB))
// Right((1,2))
println(rightA.product(leftB))
// Left(NonEmptyList(java.lang.Throwable: error B))
println(rightA.parProduct(leftB))
// Left(NonEmptyList(java.lang.Throwable: error B))
println(leftA.product(rightB))
// Left(NonEmptyList(java.lang.Throwable: error A))
println(leftA.parProduct(rightB))
// Left(NonEmptyList(java.lang.Throwable: error A))
どちらも right の場合と、どちらかが left の場合は、product と parProduct で挙動の差がないことがわかると思います。
どちらも left だった場合はどうなるでしょうか。
println(leftA.product(leftB))
// Left(NonEmptyList(java.lang.Throwable: error A))
println(leftA.parProduct(leftB))
// Left(NonEmptyList(java.lang.Throwable: error A, java.lang.Throwable: error B))
parProduct を使った場合は、エラーが累積されました!これはすごい。
この挙動はなぜかと言うと、Either にとっての Parallel の相棒である Validated の product を間接的に呼び出しているから、こういうことが実現できるんですね。
Validated の product を確認すると、
def product[EE >: E, B](fb: Validated[EE, B])(implicit EE: Semigroup[EE]): Validated[EE, (A, B)] =
(this, fb) match {
case (Valid(a), Valid(b)) => Valid((a, b))
case (Invalid(e1), Invalid(e2)) => Invalid(EE.combine(e1, e2))
case (e @ Invalid(_), _) => e
case (_, e @ Invalid(_)) => e
}
どちらもエラーだった場合に、累積するような実装になっているのがわかると思います。
これを中間的に呼び出して、fail fast なモナドとしては書きづらい処理をシンプルに実現できるようにするのが Parallel のすごいところですね。
- parProductR(&>)
- parProductL(<&)
- parAp(<&>)
のように、Apply の関数が Parallel バージョンとして存在します。
parMapN
続いては parMapN です。こちらも mapN と同じように 2 ~ 22 までの map* が用意されており、parMapN として使えるようになっています。
def parMap2[M[_], A0, A1, Z](m0:M[A0], m1:M[A1])(f: (A0, A1) => Z)(implicit p: NonEmptyParallel[M]): M[Z]
使い勝手としては mapN と同じですが、parProduct と同様に Parallel の相棒の mapN を中間的に呼び出してくれるので、EitherNel の場合はエラーの累積が可能になります。
println((leftA, leftB).mapN(_ + _))
// Left(NonEmptyList(java.lang.Throwable: error A))
println((leftA, leftB).parMapN(_ + _))
// Left(NonEmptyList(java.lang.Throwable: error A, java.lang.Throwable: error B))
Either のに特に Validated への変換を意識しないでもエラーの累積が可能になるのは、どの関数を見ても感心してしまいますね。
・・・他の関数
2 つの関数を見てきましたが、他の関数をみてみると、
- Foldable
- Traverse
- Bitraverse
- Reducible
- TraverseFilter
などの型クラスで実装されている関数が Parallel バージョンがたくさん実装されていることがわかりますが、使い方としてはお気付きの通り、元の関数と同じですので省略しようと思います。
Ior の Parallel インスタンス
Either と Validated と同じように Ior もエラーに関するクラスですよね。
Ior の map, flatMap は Either 似た感じの right biased な処理になっているので、
List[Ior]
に対する sequence は fail fast な処理になりますが、parSequence を使うと、以下のようにエラーを累積することができました。
val invalidA: IorNel[Throwable, Int] = new Throwable("error A").leftIor[Int].toIorNel
val invalidB: IorNel[Throwable, Int] = new Throwable("error B").leftIor[Int].toIorNel
println(List(invalidA, invalidB).sequence)
// Left(NonEmptyList(java.lang.Throwable: error A))
println(List(invalidA, invalidB).parSequence)
// Left(NonEmptyList(java.lang.Throwable: error A, java.lang.Throwable: error B))
なぜでしょうか。
実装を覗いてみると、面白いことに、Parallel の要素が 2 つとも Ior になっています。
implicit def catsDataParallelForIor[E](implicit E: Semigroup[E]): Parallel.Aux[Ior[E, *], Ior[E, *]]
一見すると、意味のないインスタンスなのではとおもうのですが、よく見てみると
この中で使われる Applicative が独自実装されており、それがエラーを累積するような実装になっていることで、
par** を使うと、エラーを累積できるように作られているのですね。
とても興味深い Parallel の使い方ですね。
Ior については、Either や Validated と使いみちの比較などを詳しく見ていこうと思っています。
終わりに
個人的には全然馴染みのなかった Parallel という型クラスについて、どのようなものかをみていきました。サジェストで par** が出てきたときに「並列実行できるなにかかな?」とか思っていたのですが、今後は EitherNel, IorNel などと合わせて使っていくことができそうです。