はじめに
前回は Validated についての記事を投稿しました。
今回は 前回のつづきとして Cats に定義されている Ior というエラーを累積するタイプのデータ型を Either や Validated との比較を交えつつ見ていきます。
まず cats の公式ページを読む
typelevel の cats の公式ページの Ior に関するページは以下になります。
めちゃくちゃざっくり要約すると
- Either, Validated との違い・類似点・相互変換について
- 警告を累積して、やばいエラーの時だけ計算を停止するという使い方
みたいなことが書かれていました。
Ior の性質
- エラー、正常値、またはその両方、という 3 つの状態を持つことができる
- 複数のエラーを累積できる
- 処理を継続できないような致命的なエラーというよりは、警告レベルのエラーをまとめて処理を続行したりする用途に合う。
- 累積するタイプなので、left 側は Semigroup を要求する関数も結構あって、left 側を NonEmptyList, NonEmptyChain として使うためのヘルパー関数もたくさん用意されている。
エラーを累積するという部分については、Validated と同じ様な気がしてきますね。
違うのは、Validated が Applicative なのに対し、Ior は Monad(MonadError)であるという点でしょうか。
また 両方の状態を併せ持つ事ができるという性質が Either とも Validated とも大きく違いそうです。
3 つの状態について
Ior は 3 つの状態を持つことができるという性質があります。
object Ior extends IorInstances with IorFunctions with IorFunctions2 {
final case class Left[+A](a: A) extends (A Ior Nothing)
final case class Right[+B](b: B) extends (Nothing Ior B)
final case class Both[+A, +B](a: A, b: B) extends (A Ior B)
}
こちらを見ていただくとわかりますが、Either のように Left, Right を持っているのとは別に Both という右左両方を併せ持つような状態を表現することができます。
Align::align
この性質を使っているものの一つのが、Align です。Align:align は異なる長さの配列やなんかを zippping できるような関数になっています。
println(List(1, 2).align(List(10, 11, 12)))
// List(Both(1,10), Both(2,11), Right(12))
println(List(1, 2, 3).align(List(10, 11)))
// List(Both(1,10), Both(2,11), Left(3))
このように、同じ長さの分だけ、両方の要素をもつ Both としてまとめられて、長さの異なる分だけ Left, Right として要素をもつような挙動になります。
このときの結果の型として、Ior が使われているんですね。(Option の Tupple2 で表現する version もあります。)
情報が消えていなくて元の形に戻すことができそうなのも、良い点かなと思います。
Either, Validate との Semigroup としての違い
3 つの状態をもつという特徴の Either の比較としては、Semigroup としての挙動の違いが挙げられます。
Semigroup としての Either
Either は右側が Semigroup だった場合に Either 自体も Semigroup のインスタンスになるのですが、
一つでも left が混じっていると、最初の left が結果として出力されます。
println {
NonEmptyList
.of(
1.rightNel[Throwable],
2.rightNel[Throwable]
)
.reduce
}
// Right(3)
println {
NonEmptyList
.of(
1.rightNel[Throwable],
new Throwable("a").leftNel[Int],
new Throwable("b").leftNel[Int],
2.rightNel[Throwable]
)
.reduce
}
// Left(NonEmptyList(java.lang.Throwable: a))
Semigroup としての Validated
Validated の場合は、left, right の両方が Semigroup だった場合に Validated 自身も Semigroup になるのですが、挙動としては
println(
NonEmptyList
.of(
1.validNel[Throwable],
2.validNel[Throwable]
)
.reduce
)
// Valid(3)
println(
NonEmptyList
.of(
1.validNel[Throwable],
new Throwable("a").invalidNel[Int],
new Throwable("b").invalidNel[Int],
2.validNel[Throwable]
)
.reduce
)
// Invalid(NonEmptyList(java.lang.Throwable: a, java.lang.Throwable: b))
このように、すべて valid だった場合は valid の結果が combine されたものになり、一つ以上の invalid があれば、invalid が combine されたものになるという挙動になります。
Semigroup としての Ior
implicit def catsDataSemigroupForIor[A: Semigroup, B: Semigroup]: Semigroup[Ior[A, B]]
Ior の場合は Validated と同じように、left, right の両方が Semigroup だった場合に Ior 自身も Semigroup になるのですが、
挙動に違いがあります。
println {
NonEmptyList
.of(
1.rightNel[Throwable].toIor,
new Throwable("a").leftNel[Int].toIor,
new Throwable("b").leftNel[Int].toIor,
2.rightNel[Throwable].toIor,
Ior.bothNel(new Throwable("c"), 4)
)
.reduce
}
// Both(NonEmptyList(java.lang.Throwable: a, java.lang.Throwable: b, java.lang.Throwable: c),7)
Ior の場合は、left, right, both が混じっていた場合でも both としてまとめ上げてくれるんですね。
right biased な型クラスのインスタンス
Ior の left, right 両方が Semigroup だった場合は、Ior は、どちらの結果も累積することができる性質をもっていることがわかりました。
Ior のインスタンス定義を眺めていると、left 側だけ Semigroup だった場合に
- MonadError
- Parallel
こちらの2つのインスタンスになることがわかります。何かしらのエラーの累積が行われることが予想できますね。
MonadError としての Ior
Ior の MonadError のインスタンスの定義をみると、flatMap の実装が目に留まります。
final def flatMap[AA >: A, D](f: B => AA Ior D)(implicit AA: Semigroup[AA]): AA Ior D =
this match {
case l @ Ior.Left(_) => l
case Ior.Right(b) => f(b)
case Ior.Both(a1, b) =>
f(b) match {
case Ior.Left(a2) => Ior.Left(AA.combine(a1, a2))
case Ior.Right(b) => Ior.Both(a1, b)
case Ior.Both(a2, d) => Ior.Both(AA.combine(a1, a2), d)
}
}
right biased な処理に加えて、Both のときの処理が追加されているイメージでしょうか。
元が Both だった場合は、left の累積を伴うような、
それでいて right biased というか、left があったら以降は left として扱われるような、
そんな実装になっていますね。
println(for {
i <- Ior.bothNel(new Throwable("a"), 1)
j <- Ior.bothNel(new Throwable("b"), 2)
} yield i + j)
// Both(NonEmptyList(java.lang.Throwable: a, java.lang.Throwable: b),3)
println(for {
i <- Ior.bothNel(new Throwable("a"), 1)
k <- Ior.leftNel[Throwable, Int](new Throwable("c"))
j <- Ior.bothNel(new Throwable("b"), 2)
} yield i + j + k)
// Left(NonEmptyList(java.lang.Throwable: a, java.lang.Throwable: c))
Both が連続する限りは、left が累積されつつ、計算が行われますが、一度 Left になってしまうとそこで累積は止まって Left が出力されます。
面白いですね。
Parallel としての Ior
Ior の Parallel のインスタンス定義をみてみると、applicative, monad どちら側も Ior を使って定義されていることがわかります。
implicit def catsDataParallelForIor[E](implicit E: Semigroup[E]): Parallel.Aux[Ior[E, *], Ior[E, *]]
どちらの型も Ior なのですが、実装を見ると applicative が Ior を使った独自定義になっていることがわかります。
val applicative: Applicative[Ior[E, *]] = new Applicative[Ior[E, *]] {
def pure[A](a: A): Ior[E, A] = Ior.right(a)
def ap[A, B](ff: Ior[E, A => B])(fa: Ior[E, A]): Ior[E, B] =
fa match {
case Ior.Right(a) =>
ff match {
case Ior.Right(f) => Ior.Right(f(a))
case Ior.Both(e1, f) => Ior.Both(e1, f(a))
case Ior.Left(e1) => Ior.Left(e1)
}
case Ior.Both(e1, a) =>
ff match {
case Ior.Right(f) => Ior.Both(e1, f(a))
case Ior.Both(e2, f) => Ior.Both(E.combine(e2, e1), f(a))
case Ior.Left(e2) => Ior.Left(E.combine(e2, e1))
}
case Ior.Left(e1) =>
ff match {
case Ior.Right(f) => Ior.Left(e1)
case Ior.Both(e2, f) => Ior.Left(E.combine(e2, e1))
case Ior.Left(e2) => Ior.Left(E.combine(e2, e1))
}
}
}
flatMap の実装と同じように right biased な処理、Both のときの処理が追加されているような実装になっていますね。
どのような場合も left と合成するときに left になるような処理になっています。
この applicative 実装により、par** 関数を使うと left 側の累積が可能になります。
println {
List(
1.rightIor[Throwable].toIorNel,
new Throwable("a").leftIor[Int].toIorNel,
new Throwable("b").leftIor[Int].toIorNel,
2.rightIor[Throwable].toIorNel
).sequence
}
// Left(NonEmptyList(java.lang.Throwable: a))
このように通常の Traverse な関数(sequence)を使うと、エラーの累積はなされません。
エラーの累積を行いたい場合は、Parallel version(今回でいうと parSequence)を使うと良いということになります。
println {
List(
1.rightIor[Throwable].toIorNel,
new Throwable("a").leftIor[Int].toIorNel,
new Throwable("b").leftIor[Int].toIorNel,
2.rightIor[Throwable].toIorNel
).parSequence
}
// Left(NonEmptyList(java.lang.Throwable: a, java.lang.Throwable: b))
left 側の累積のみが行われているのがわかると思います。
まとめると、
- Semigroup を使うと、left, right 両方を累積することができる
- FlatMap, Parallel を使うと left のみを累積することができる
ということになります。
関数について
ここからは、Ior に実装されている関数を見ていこうと思います。
「3つの状態を保持できる」という特性を活かすためのユニークな関数がたくさん定義されています。
fromOptions
まずは、ファクトリメソッドである、forOptions です。
def fromOptions[A, B](oa: Option[A], ob: Option[B]): Option[A Ior B] =
oa match {
case Some(a) =>
ob match {
case Some(b) => Some(Ior.Both(a, b))
case None => Some(Ior.Left(a))
}
case None =>
ob match {
case Some(b) => Some(Ior.Right(b))
case None => None
}
}
こちらは、2つの Option を受け取って、Ior に変換するための関数のようですね。見たまんまですが、
- どちらもあった場合は Both
- 左側だけあった場合は Left
- 右側だけあった場合は Right
という風に Ior にまとめ上げてくれるようです。
fold
続いては fold です。
final def fold[C](fa: A => C, fb: B => C, fab: (A, B) => C): C =
this match {
case Ior.Left(a) => fa(a)
case Ior.Right(b) => fb(b)
case Ior.Both(a, b) => fab(a, b)
}
Either などの fold と違い、3つ目の Both のための引数が追加されているのがわかりますね。
putLeft
続いては putLeft です。
final def putLeft[C](left: C): C Ior B =
fold(_ => Ior.left(left), Ior.both(left, _), (_, b) => Ior.both(left, b))
Functor::as のように、left を入れ替えるような処理になっています。putRight という処理が right 用に逆さまになったような関数も存在します。
addLeft
続いては addLeft です。
final def addLeft[AA >: A](left: AA)(implicit AA: Semigroup[AA]): AA Ior B =
fold(l => Ior.left(AA.combine(l, left)), Ior.both(left, _), (l, r) => Ior.both(AA.combine(l, left), r))
putLeft と似ていますが、left の型が Semigroup であることを要求していますね。
処理としては、putLeft のように入れ替えるのではなく、left があったら combine してあげるような処理になっています。
putRight と同様に addRight という right 用に逆さまになったような関数も存在します。
pad
続いては pad です。
final def pad: (Option[A], Option[B]) = fold(a => (Some(a), None), b => (None, Some(b)), (a, b) => (Some(a), Some(b)))
pad というと穴を埋めて出力する様な語感がありますよね。Ior の pad 関数は、left, right を Option の Tuple で返してくれる関数のようですね。
Left, Right の場合は、それに沿ったほうの値が Some, 反対側が None になり、 Both の場合はどちらも Some で返してくれるんですね。
unwrap
続いては unwrap です。
final def unwrap: Either[Either[A, B], (A, B)] =
fold(a => Left(Left(a)), b => Left(Right(b)), (a, b) => Right((a, b)))
Ior[A, B]
をEither[Either[A, B], (A, B)]
として返してくれるんですね。Either が入れ子になっている。。
Ior(a, b) を
- Left(Left(a))
- Left(Right(b))
- Right((a, b))
という風に表現することになるのですね。 なんだか面倒くさいですが、Either で Both を表現するとこういう風になるのでしょうか。
pad は Option の Tuple として表現されていましたが、そちらのほうが直感に近い感じがします。
valueOr
続いては valueOr です。
final def valueOr[BB >: B](f: A => BB)(implicit BB: Semigroup[BB]): BB =
fold(f, identity, (a, b) => BB.combine(f(a), b))
ちょっとなにやってるんだろう・・・という感じがすると思いますので、まずは Either に定義されている valueOr を見てみましょう。
def valueOr[BB >: B](f: A => BB): BB =
eab match {
case Left(a) => f(a)
case Right(b) => b
}
こちらのほうがわかりやすいですね。Right の場合は中身をそのまま返してで Left だった場合は関数を当てた結果を返しています。
制約としては、BB >: B
である BB 型に変換する関数を渡す必要があるというものがあります。
もう一度、Ior::valueOr を見てみましょう。
final def valueOr[BB >: B](f: A => BB)(implicit BB: Semigroup[BB]): BB =
fold(f, identity, (a, b) => BB.combine(f(a), b))
Ior の方の valueOr は BB 型が Semigroup であることを要求するのと、Both の概念があるので少し複雑ですが、Left, Right の場合の処理は Either と同じ処理になっていると思います。
Both の場合だけ、関数適用済みの Left と Right を combine させる挙動になるんですね。面白いですね。
merge
続いては merge です。こちらは traverse <-> sequence の関係に似ていると思いますが、
merge を一般化したような処理が valueOr になっていると捉えて良いのではないかと思います。
final def merge[AA >: A](implicit ev: B <:< AA, AA: Semigroup[AA]): AA =
fold(identity, ev, (a, b) => AA.combine(a, b))
もともと A, B が同じ先祖を持っていた場合でその先祖が Semigroup である場合に、Ior を AA としてまとめることが出来るんですね。
mergeLeft, mergeRight, mergeWith という Both に対するまとめ方の扱いが違う version の関数も定義されています。
おわりに
Ior は right, left, both という3つの状態を保持できるデータ型でした。
また、Ior はエラーハンドリングに関する処理に使われるものであることがわかりました。
直近で書いていた以下の三記事と合わせて、Cats ではエラーをどのように扱うことができるのか、それを実現している裏側ではどんな考え方が存在しているのか、という部分をみていきました。
ものすごくざっくり言うと、
- エラーを累積したくなったら、Either の Parallel 使う
- エラーとそうでないものを分けて累積したい場合は Ior の Semigroup を使う
というような感じにすればいいかなと思いました。エラーハンドリングの幅が fail fast だけではなくなるのはとても良いですね。
次は、Cats Effect 周りの型クラスを勉強して記事として残せればと思っています。また機会があればご覧いただけると幸いです。