タイトルの通り. 「 Either ←→ Validated 間で行けるんだから EitherT でも行けるだろう…​」 と思っていたところ見事にハマった.

検索してもあまり説明している人がいなそうなので、とりあえずメモしておく.

TL;DR

自分の使っているエフェクトが F[_] であるならば、以下の 1 行をスコープ中に含めれば良い.

given Parallel[F] = EitherT.accumulatingParallel

おわり.

以下は詳細.

Cats では Parallel 型クラスを介して Either と Validated の自動的な変換が提供されている

まずは前置き.

例えば、 Scala には「エラーの可能性」を表現するデータ型として Either[E, A] 型が用意されている. Either はエラーを表すデータ型 E を固定することで Either[E, *] でモナドを成すため、 flatMap あるいは for 式を用いて複数の Either 値を合成することができる. 例えば、 val ea: Either[E, A]val eb: Either[E, B]val ec: Either[E, C] であるとき、以下のような for 式を書くことができる.

for {
  a <- ea
  b <- eb
  c <- ec
} yield {
  /* a, b, c を使って何か計算 */
}

しかし、 eaebec の中の 1 つ以上がエラー値である場合、それらのエラーを集めようとすると困ってしまうこととなる. 上の for 式は flatMap を使った以下のようなコードと等価であり、 flatMap は最初に Left 値を見つけた段階で残りの計算を行わずにすぐに計算を終了 (fail-fast) してしまうためである.

ea.flatMap { a =>
  eb.flatMap { b =>
    ec.flatMap { c =>
      /* a, b, c を使って何か計算 */
    }
  }
}

これは Either がモナドでありモナド則を満たさなければならない以上、自然な振る舞いである. 例えば関数 fA ⇒ Either[E, B] 型であるとき、左単位元則 return a >>= f == f a を満たすかを考えてみると、

Right(a).flatMap(f) == f(a)    // a は A 型なので OK.

Left(e).flatMap(f)  == f(e)    // e は E 型なので型エラー.

となるため、 flatMap (および Haskell における >>=) は Left 値に対する変換は行えないことがわかる.

しかし、独立した複数の Either 値が生じる場合、それらの中に Left 値が含まれているならばそれらのエラーを集めたいと思うときもあるだろう. Scala の関数型プログラミング用のライブラリである Cats ではそのようなニーズに答えるために Validated[E, A] というデータ型が用意されている. ValidatedEither と同様に 2 つの型パラメータを取ってエラーの可能性を表現できるデータ型であり、 「アプリカティブファンクターではある」「モナドではない」 という特徴を持つ. モナドではないため flatMap メソッドおよび for 式を使うことはできないが、代わりに Applicative 型クラスが提供する mapN を使うことでエラーを集積することができるようになっている. 例えば、 val va: Validated[E, A]val vb: Validated[E, B]val vc: Validated[E, C] であるとき、以下のように書くことができる.

import cats.syntax.apply.*

(va, vb, vc).mapN { case (a, b, c) => /* a, b, c を使って何か計算 */ }

これにより、 vavbvc のうち 1 つ以上にエラーが含まれている場合には、それらのエラーを 1 つの Invalid 値中に集めることができる. なお、エラーを「集める」という都合上、エラー型 ESemigroup のような制約を満たしていることが前提となる.

これにより、 Either 値が複数ある場合にはそれらを Validated 値に変換することができればエラーを mapN で収集することができそうにみえる. 実は Cats ではこの Either <→ Validated 間の変換を Parallel 型クラスを活用することで自動で行うことができるようになっている. Parallel はモナド M とアプリカティブファンクター F の間の相互変換を与えてくれるものであり、 Cats にはデフォルトで Either および Validated 間の相互変換を行うインスタンスが定義されている. 通常の Validated 値のタプルに対しては mapN を使用するが、 Parallel 型クラスを使用することで Either のタプルには parMapN という par が付いたバージョンのメソッドが生える. このメソッドを使用することで、内部的に各 Either 値が Validated 値に変換された上で mapN 相当の処理が行われ、最後に 1 つに集約された Validated 値が Either 値に変換されて結果として返される.

(ea, eb, ec).parMapN { case (a, b, c) => /* a, b, c を使って何か計算 */ }
// 戻り値は Either 値となる.

これにより、 Either 値を使って「依存関係がある処理は flatMap あるいは for 式で順次実行」し、「依存関係のない独立した処理は parMapN で結果を集計しつつ並列実行」することが可能となる.

モナドトランスフォーマー

単なる Either 値の場合はエラー値 or 成功値が返されるという「エラーが含まれる可能性」しか表現することができないが、 実際に開発するアプリケーションでは「エラーの可能性」以外に I/O やネットワーク通信のような「副作用」だとか、「ログの書き出しが可能」などの様々な文脈を表現したいのが普通である. しかし、文脈が F[G[H[A]]] のようにネストしてしまうと途端に扱いづらくなり、単に内側の A 型の値にアクセスするのに .map(.map(.map(…​))) だとか .flatMap(.flatMap(.flatMap(…​))) といったコードが コードベース中に大量にバラ撒かれてしまうことになって著しく可読性が落ちてしまう.

このような問題を解決するものがモナドトランスフォーマーであり、複数の文脈を合成することで、様々なモナドの機能を持った 1 つの新しいモナドを作ることができる. 例えば、

type F[A] = EitherT[[V] =>> WriterT[IO, S, V], E, A]

のようにしてモナドトランスフォーマー EitherTWriterT を使用して F[A] 型を定義することで、 F はエラーの可能性 + ログ書き出し機能 + 副作用という異なる能力を持った新たなモナドとして使用することが できるようになる. F[A] 型の値があったとき、その中の A 型の値にアクセスするには単に 1 回の map 呼び出しあるいは flatMap 呼び出しだけで済むようになり、コードの可読性の問題も解消する.

F[_]: Parallel と制約を付与したのに、EitherT 値を parMapN してもエラーが集積されない

さて、そろそろ本題に近づいてきた.

モナドトランスフォーマーを使うと複数の文脈を合成した新しいモナドを定義することができるのであった. ここで気になるのは、「ではエラーの可能性を表現するのに EitherT モナドトランスフォーマーを使って新しいモナドを定義したが、このモナドの値中に含まれるエラー値を集積することはできるのか」ということである.

幸い、 Cats では型クラスインスタンスは自動導出が可能となっているため、 EitherT を使用して作ったモナド F[A] にも自動的に Parallel 型クラスのインスタンスが導出できるようになっている.

「おっよかった. じゃあ、 EitherT でも parMapN を使えばエラーを集積できるんだな…​」

…​と思っていたら、タイトルに書いた通り 「デフォルトで導出される EitherTParallel インスタンスはエラーを集積してくれず、 (Validated ではない) 通常の Either と同様の fail-fast な振る舞いとなる」 という問題にぶつかったわけである.

実は昔の Cats では EitherT でも問題なく parMapN でエラーを集積できていた

どうやら、この問題については過去に GitHub の issue (https://github.com/typelevel/cats-effect/issues/1645https://github.com/typelevel/cats/issues/3776https://github.com/typelevel/cats/pull/3777) で 議論されていたようであり、過去のバージョンの Cats では EitherT でもデフォルトで parMapN でエラーを集積できていたようだ. Cats 2.4 系が出るタイミングでこの振る舞いは変更されてしまった模様.

幸い、以前のような振る舞いを復元する方法も残されている. 冒頭に書いたように given を使って EitherT.accumulatingParallel を明示的にスコープに入れることにより、 EitherT を使用して作ったモナドに対しても parMapN でエラーを集積することが可能となる.

given Parallel[F] = EitherT.accumulatingParallel

これ、 Cats の EitherT.accumulatingParallel についての API ドキュメントには少しだけ説明 (および GitHub issue へのリンク) が書いてあるけど、他に書いてあるドキュメントあるのかな…​

初見だとだいぶ混乱するので、どこか目立つところに明記しておいてほしい…​…​