この記事は MicroAd Advent Calendar 2021 の 8 日目の記事です。
記事としては Cats の関数覚え書き(Functor, Apply, Applicative, FlatMap) の続きになります。
はじめに
現在、弊チームでは Cats 製の WEB アプリを実装(既存のアプリのリプレイス)を進めています。
Cats にはたくさんの型クラスにたくさんの関数が定義されているのですが、それを知った上で実装を行うのと、知らないで実装を行うのではコーディングの難易度に天と地ほどの差があるように感じています。
今回はチームメンバーへの共有も兼ねて、よく使うであろう型クラスの関数について覚書程度ですが使い道などを書いていこうと思います。
- flatMap とそれに連なる型クラス
- Functor
- Bifunctor
- FunctorFilter
- Apply
- Applicative
- FlatMap
- エラー処理に関する型クラス
- ApplicativeError
- MonadError
- cats.kernel 系
- Eq
- Order
- Semigroup
- (SemigroupK)
- Monoid
- (MonoidK)
- traverse とそれに連なる型クラス
- Foldable
- Bifoldable
- Reducible
- Traverse
- Bitraverse
- TraverseFilter
上記の型クラスに定義されている関数を紹介しようと思ったのですが、めちゃくちゃ長くなってしまったので記事を分割して、1カテゴリーずつ毎週水曜日に紹介していこうと思います。
本記事では、
- ApplicativeError
- MonadError
をそれぞれ見ていこうとおもいます。
今回使用する cats, scala のバージョン
- scala 2.13.1
- cats 2.6.1
を使って今回のコードを書いています。
使用するクラスについて
本記事では、関数の使い方の具体例を書いていこうと思うのですが、以下の様なクラスを使って例をあげていこうと思っています。
// 値クラス
case class AccountId(private val toInt: Int) extends AnyVal
case class AccountName(override val toString: String) extends AnyVal
// 値クラスを使ったクラス
case class Account(private val id: AccountId, private val name: AccountName)
// ファーストクラスコレクション
case class AccountIds(private val toList: List[AccountId]) extends AnyVal
case class Accounts(private val toNel: NonEmptyList[Account]) extends AnyVal
よくある感じですね。これらを使って実装例を示していきますので、覚えておいていただければと思います。
F として抽象化する際のコード例について
関数の説明をする中で、具体的な List, Either などのクラスとしてではなく、F として抽象化した状態での関数の使い方を説明する場合があります。
class FunctorContext[F[_]: Functor] {
def f: F[Int => String] = ???
def apply(i: Int): F[String] = ???
}
その時は、上記のようなクラスを作り、その中で対象の型クラスの関数を使う様にして説明していこうと思います。
関数の使い方の説明について
関数の使い方を説明する際、なるべく
〇〇を使わないと・・・
〇〇を使うと・・・
というように、関数を使わなかった場合の実装と、使った場合の実装を比較して説明してしようと思っています。
関数を使う例が List, Either などの scala 標準で用意されているデータ型の場合は、〇〇を使わないと・・・
の方ではなるべく cats 自体を使わないように努め、cats を使う場合とそうでない場合の比較が出来るようにしようと思っています。
関数を使う例がF[_]: Functor
などとして抽象化してある場合は、cats を使わないのは無理なので、他の実装方法をとる場合はどういう風になるのか、例えばその関数の存在を知らない場合は、どういう実装が必要になってしまうのかの様な比較が出来る様に書いていこうと思います。
syntax をなるべく使う
cats では関数の実装(定義)とは別に、それをスマートに使うための syntax が用意されています。例えば、Foldable::combineAll という関数を使う場合、syntax を使わないで書くと、
Foldable[List].combineAll(List(1, 2, 3))
// 6
この様に書く必要がありますが、syntax を使って書くと
List(1, 2, 3).combineAll
// 6
この様に書くことが出来ます。こちらのほうが自然ですよね。
実際にコーディングをする場合はこちらを使うことになると思いますので、実践的な意味で syntax を使える場合は使ってコード例を示していこうと思います。
型クラスの定義について
この二つの型クラスは以下のような定義になっています。
trait ApplicativeError[F[_], E] extends Applicative[F]
trait MonadError[F[_], E] extends ApplicativeError[F, E] with Monad[F]
これらの型クラスは、これまで見てきたものと違い、F[_]
とE
を受け取る形になっています。E は error を表す方の型だと思っていればよさそうです。例えば、ApplicativeError::recover 関数のインターフェイスをみてみると、
def recover[A](fa: F[A])(pf: PartialFunction[E, A]): F[A]
E を A にする PartialFunction を受け取ってF[A]
を返す関数になっていますよね、これは、特定の Error を A としてリカバリする関数なんだろうなというのが予想できると思います。
これらの型クラスをF[_]
に context bound して使う場合、E の方を固定した type を定義してそれを使う方法があります。大体の場合、E は Throwable 的な何かになると思います。
type ApplicativeErrorOr[F[_]] = ApplicativeError[F, Throwable]
class ApplicativeErrorContext[F[_] : ApplicativeErrorOr]
type MonadErrorOr[F[_]] = MonadError[F, Throwable]
class MonadErrorContext[F[_] : MonadErrorOr : Logger]
なお、cats の package object にも
type ApplicativeThrow[F[_]] = ApplicativeError[F, Throwable]
object ApplicativeThrow {
def apply[F[_]](implicit ev: ApplicativeThrow[F]): ApplicativeThrow[F] = ev
}
type MonadThrow[F[_]] = MonadError[F, Throwable]
object MonadThrow {
def apply[F[_]](implicit ev: MonadThrow[F]): MonadThrow[F] = ev
}
この様な定義があるので、左側を Throwable として扱う場合はこちらを使うといいかと思います。
理解が深まる注目ポイント
これらの型クラスのインスタンスは、例えば Either, Try のような失敗と成功の二つの状態を併せ持ったようなクラスの他に、IO も含まれる点に注目してください。例えば、IO[A]
をF[A]
として扱った場合、
- エラーであるということを表す E
- エラー以外の副作用が含まれているという意味での F
- 正常な値であることを表す A
という3つの状態を併せ持った表現が可能になります。
Either 型が抽象化されたやつだなとだけ思っていると、AllicativeError::handleError の定義である以下をみて、
def handleError[A](fa: F[A])(f: E => A): F[A]
「全てのエラーをリカバリするのであれば、返り値はF[A]
ではなくて、ただの A でよくない?」と感じると思うんですが
IO などの副作用を含めたクラスに対する抽象化として考えると、この関数の返り値がF[A]
になっている理由が見えてくると思います。
それを踏まえて ApplicativeError から見ていきましょう。
ApplicativeError
trait ApplicativeError[F[_], E] extends Applicative[F]
まずは ApplicativeError です。先ほど紹介したように Applicative を継承しているので、map, ap, pure などを使ったエラー処理用の関数がたくさん用意されています。
raiseError
def raiseError[A](e: E): F[A]
raiseError は error を ApplicativeError に lift させる関数で、E をエラー側に持つ F を作ることが出来ます。Either を使うとすると、
val error: Throwable = ???
def apply: Either[Throwable, String] = Left(error)
や
def apply: Either[Throwable, String] = error.asLeft[String]
のように、Throwable を Either に lift させると思います、raiseError はそれらの処理を抽象化したような関数で、以下のように使うことが出来ます。
class ApplicativeThrowContext[F[_] : ApplicativeThrow] {
val error: Throwable = ???
def apply: F[String] = error.raiseError[F, String]
}
error 版 pure という感じの関数ですね。
recover
def recover[A](fa: F[A])(pf: PartialFunction[E, A]): F[A]
recover は特定のエラー(E 型)を拾って、エラーではなく別の正常な値(A 型)にする関数です。
sealed trait MyError extends Throwable
object MyError {
case object Unrecoverable extends MyError
case class Recoverable(id: Int) extends MyError
}
def f: Either[MyError, String] = ???
def apply: Either[MyError, String] = ???
エラーだった場合、MyError という型を、エラーではなかった場合 String を返す関数 f があり、f を使って、MyError.Recoverable だった場合だけ、s"$id is recovered!"
という String を返す様にするという関数 apply を実装したいとします。
recover を使わないと・・・
recover を使わない場合、パターンマッチで実装すると思います。
def apply: Either[MyError, String] = f match {
case Left(MyError.Recoverable(id)) => s"$id is recovered!".asRight
case others => others
}
case others => others
がなんだか冗長ですよね。
recover を使うと・・・
その悩みをまさに解決できるのが、recover です。recover を使うと以下の様に実装できます。
def apply: Either[MyError, String] =
f.recover { case MyError.Recoverable(id) => s"$id is recovered!" }
シンプルですね。
recoverWith
def recoverWith[A](fa: F[A])(pf: PartialFunction[E, F[A]]): F[A]
recover に with がついてる名前のこの関数ですが with がついてるものは他にもあります。これらは全て with がついていないものと比べて与える関数の返り値がコンテキストに包まれているという違いがあります。
sealed trait MyError extends Throwable
object MyError {
case object Unrecoverable extends MyError
case class Recoverable(id: Int) extends MyError
}
def f: Either[MyError, String] = ???
def g(id: Int): Either[MyError, String] = ???
def apply: Either[MyError, String] = ???
先程の例と少し違うケースで、f を使って、MyError.Recoverable だった場合だけ g を実行したいとします、g もまた Either を返すので recover を使っての実装は出来ないですね。
recoverWith を使わないと・・・
その場合に、recoverWith を使わずに apply を実装しようとすると
def apply: Either[MyError, String] = f match {
case Left(MyError.Recoverable(id)) => g(id)
case others => others
}
recover のときと同じ様にパターンマッチで分岐して g を使うことになると思います。
recoverWith を使うと・・・
def apply: Either[MyError, String] =
f.recoverWith { case MyError.Recoverable(id) => g(id) }
こちらもシンプルですね。
adaptError
def adaptError[A](fa: F[A])(pf: PartialFunction[E, E]): F[A]
ちょっとややこしい話ですが、ApplicativeError の syntax として用意されている名前は adaptErr になります、MonadError という型クラスもあるんですが、そちらが adaptError の syntax として用意している名前が adaptError なんですね。
あまりないと思いますが、明示的に ApplicativeError のほうを使いたい場合、adaptErr と書く必要があります。さて、adaptError は、特定の Error を別の Error に変換するための関数です。
sealed trait MyError extends Throwable
object MyError {
case object Unrecoverable extends MyError
case class Recoverable(id: Int) extends MyError
case class Advanced(something: String) extends MyError
}
def f: Either[MyError, String] = ???
def apply: Either[MyError, String] = ???
上記の様な実装があったとして、apply 関数は f が返す結果がLeft[Unrecoverable]
だった場合にLeft[Advanced]
に変換する様な実装をしたいとします。
adaptError を使わないと・・・
adaptError を使わない実装の場合、パターンマッチで Left の中身だけを変える様な処理を書くか、
def apply: Either[MyError, String] = f match {
case Left(_: MyError.Unrecoverable.type) => MyError.Advanced("なにか付加情報").asLeft
case others => others
}
leftMap を使って同じようなことをするか、などの実装が考えられると思います。
def apply: Either[MyError, String] = f.leftMap {
case _: MyError.Unrecoverable.type => MyError.Advanced("なにか付加情報")
case others => others
}
これらはやはり、case others => others
が冗長な気がしますね。。
adaptError を使うと・・・
この様なケースも adaptError を使うことで、
def apply: Either[MyError, String] =
f.adaptErr { case _: MyError.Unrecoverable.type => MyError.Advanced("なにか付加情報") }
シンプルに書くことが出来ます。
onError
def onError[A](fa: F[A])(pf: PartialFunction[E, F[Unit]]): F[A]
こちらの関数は、エラーだった場合にログを挟み込む専用の関数といってしまっていいと思います。これまでは Either で例を挙げていましたが今回は ApplicativeError として抽象化されているコード例にしようと思います。
class ApplicativeThrowContext[F[_] : ApplicativeThrow : Logger] {
def f: F[String] = ???
def apply: F[String] = ???
}
今回は関数 f を使って Recoverable だった場合だけs"$id is recoverable"
というログを出す様な関数 apply を実装してみたいと思います。
onError を使わないと・・・
Either の場合はまたパターンマッチか leftMap でなんとかする感じになると思うんですが、F に抽象化されている場合、onError を使わないとどういう実装が考えられるでしょうか。
def apply: F[String] =
f.recoverWith {
case error@MyError.Recoverable(id) =>
Logger[F].info(s"$id is recoverable") *> error.raiseError
}
こんな感じで、recoverWith + raiseError を使えば実現できそうですが、なにもリカバリーしていないし処理的にも気持ち悪いですね・・・。
onError を使うと・・・
onError を使って実装すると、
def apply: F[String] =
f.onError { case MyError.Recoverable(id) => Logger[F].info(s"$id is recoverable") }
この様にシンプルに記述することが出来ます。
handleError
def handleError[A](fa: F[A])(f: E => A): F[A]
handleError は recover と似ていますが、比較すると受け取る関数が PartialFunction ではなくE => A
の関数になっています。
def recover[A](fa: F[A])(pf: PartialFunction[E, A]): F[A]
こちらが recover のインターフェイスです、PartialFunction ではないということは、エラーの場合すべてを補足したい場合にこちらを使うということだと思います。今回は F として抽象化された処理に対しての実装例を示します。
class ApplicativeThrowContext[F[_] : ApplicativeThrow : Logger] {
def f: F[String] =
new Error().raiseError[F, String] <* Logger[F].info("error")
def apply: F[String] =
f.handleError(e => s"${e.getClass.getName} is recovered")
}
redeem
def redeem[A, B](fa: F[A])(recover: E => B, f: A => B): F[B]
修復するなどの意味である、redeem ですが、Either で考えると Either::fold と同じ様な意味を持つ関数であると言えます。
def fold[C](fa: A => C, fb: B => C): C
上記が Either::fold のインターフェイスですが、結果がコンテキストに包まれたままかどうかの違いがありますね、redeem は map + handleError を同時に行うと考えるとイメージしやすいかもしれません。
class ApplicativeThrowContext[F[_] : ApplicativeThrow : Logger] {
def f: F[Int] = ???
def apply: F[String] = f.redeem(
e => s"${e.getClass.getName} is recovered",
i => s"f returns $i"
)
}
F[Int]
を返す関数 f を使って、失敗していたらリカバリをして、そうでなかったらメッセージに変換するというような処理を この様にシンプルに書くことが出来ました。
handleErrorWith
def handleErrorWith[A](fa: F[A])(f: E => F[A]): F[A]
こちらは、recover に対する、recoverWith と同じ様に handleError と比較すると、受け取る関数の結果がコンテキストで包まれているという違いがあることがわかります。
def handleError[A](fa: F[A])(f: E => A): F[A]
今回は、例えば関数 f が何らかの抽選処理で、エラーになった場合も error ログを出したあとで再抽選したい、というような処理を書きたい場合、先程の handleError では実装できなそうなので、この handleErrorWith を使うことになると思います。
class ApplicativeThrowContext[F[_] : ApplicativeThrow : Logger] {
def f: F[String] = ??? // なんかの抽選処理とか
def apply: F[String] = f.recoverWith(e => Logger[F].error(e.getMessage) *> f)
}
この様なシーンはよくあるので使い勝手が良さそうですね。
orElse
def orElse(other: => F[A])(implicit F: ApplicativeError[F, E]): F[A]
Either::orElse を抽象化したようなこちらの関数ですが、使い所としては先程の handleErrorWith を使うときに、
def handleErrorWith[A](fa: F[A])(f: E => F[A]): F[A]
E を使わない処理を書きたい場合、先程でいうとロギングしない場合などは、
def apply: F[String] = f.handleErrorWith(_ => f) // ロガーを仕込まないで f をもう一回呼ぶ。
と実装すると思います、この時の_ =>
って冗長ですよね、こういう場合は handleError ではなく orElse を使うといいと思います。
def apply: F[String] = f.orElse(f)
この様に Error 自体に関心がないときは orElse を使ってリカバリすると良さそうです。
catchNonFatal
def catchNonFatal[A](a: => A)(implicit ev: Throwable <:< E): F[A]
こちらは名前の通り、NonFatal のときにキャッチして Left にしてくれるファクトリメソッドになります、NonFatal というのは、scala.util.control.NonFatal で定義されている VirtualMachineError など致命的なエラー以外のエラーを表すものになります。
実装自体は別物ですが Either の場合でも同じ関数を使うことが出来るので、例を Either でも示すことにします。
catchNonFatal を使わないと・・・
例えば、外部のライブラリなどを使う際に、例外が throw されてくることを考えると、Try に包んでから Either に変換する様な処理を書いていたと思います。
def libMethods: String = ???
def apply: Either[Throwable, String] = Try(libMethods).toEither
このとき NonFatal かどうかとかを気にして書くともう少し複雑になりますが、そこの差異はおいておきます。
catchNonFatal を使うと・・・
def libMethods: String = ???
def apply: Either[Throwable, String] = Either.catchNonFatal(libMethods)
Try で包んだあと toEither をする必要がなくシンプルですね。
class ApplicativeThrowContext[F[_] : ApplicativeThrow : Logger] {
def libMethods: String = ???
def apply: F[String] = ApplicativeError[F, Throwable].catchNonFatal(libMethods)
}
F で使う場合はこの様になると思います。
catchOnly
def catchOnly[T >: Null <: Throwable]: CatchOnlyPartiallyApplied[T, F, E]
こちらは、指定した型のエラーだけを拾ってくれる関数になります。特定の型しか throw されないことがわかっている場合などは、こちらを使った方がより目的に沿っている感じがしますね。使用例は省略します。
attempt
def attempt[A](fa: F[A]): F[Either[E, A]]
こちらは、インターフェイスを見ると、F[A]
をF[Either[E, A]]
に変換していますね、ApplicativeError の冒頭で書いた
- エラーであるということを表す E
- エラー以外の副作用が含まれているという意味での F
- 正常な値であることを表す A
のうち、E と A をEither[E, A]
にして副作用である F はそのままにする、つまり副作用とエラーを分離する処理を行うことが出来ます。
class ApplicativeThrowContext[F[_] : ApplicativeThrow : Logger] {
def f: F[String] = new Error("error").raiseError[F, String] <* Logger[F].info("logging!")
def apply: F[Either[Throwable, String]] = ???
}
今回は、関数 f を使ったあと、Error を Left にして、String は Right にする様な関数 apply を実装してみましょう。
attempt を使わないと・・・
まず attempt を使わなかった場合の実装を考えてみると、
def apply: F[Either[Throwable, String]] = f.redeem(_.asLeft, _.asRight)
redeem を使って、Left と Right に振り分けるような処理を書くでしょうか。_.asLeft, _.asRight
ってもっとなんとかなるでしょ・・・って思ってしまうようなコードですよね。
attempt を使うと・・・
attempt を使うと下記のようになります。
def apply: F[Either[Throwable, String]] = f.attempt
シンプルでいいですね。
直接 EitherT にしてくれる attemptT という関数もあります。
def attemptT(implicit F: ApplicativeError[F, E]): EitherT[F, E, A]
EitherT が直接欲しい場合こちらを使うと良いかもしれないですね。
蛇足ですが・・・
逆にF[Either[Throwable, String]]
をF[String]
にしたい場合はどう実装するのが良いだろうと疑問に思う方もいると思います、そんなときは、MonadError::rethrow を使えばいいかなと思います。
def rethrow[A, EE <: E](fa: F[Either[EE, A]]): F[A]
MonadError の章でも紹介しますが、今回の apply を使って、F[String]
を返す関数 g を実装したい場合は以下のように書きます。
def g: F[String] = apply.rethrow
シンプルで良いですね。
attemptNarrow
def attemptNarrow[EE, A](fa: F[A])(implicit tag: ClassTag[EE], ev: EE <:< E): F[Either[EE, A]]
attempt を使っていると「特定の型だけ拾って Either に出来たらいいのにな」と思うシーンがあると思います。そんな思いを叶えてくれるのが、この attemptNarrow です。
def apply: F[Either[MyError, String]] = f.attemptNarrow[MyError]
この実装では Throwable の中で、MyError だけを拾って Either にしてくれています。面白いですね。
fromEither
def fromEither[A](x: E Either A): F[A]
こちらは、Either からF[A]
に変換したい場合に使うことができる関数になっています、直接使うことはおそらくあまりなく、これを使ってEither に実装された関数 liftTo を使うのがほとんどではないかと思います。
def liftTo[F[_]](implicit F: ApplicativeError[F, _ >: A]): F[B] = F.fromEither(eab)
以下のように使うことが出来ます。
class ApplicativeThrowContext[F[_] : ApplicativeThrow : Logger] {
def f: Either[Throwable, String] = ???
def apply: F[String] = f.liftTo[F]
}
他にも、fromTry, fromOption, fromValidated が用意されており、Try, Option, Validated 側にも liftTo 関数が用意されています、Option だけはどの様なエラーにするか、という値も合わせて引数で渡してあげる必要があるので注意が必要です。
def fromOption[A](oa: Option[A], ifEmpty: => E): F[A]
Option::raiseTo
def raiseTo[F[_]](implicit F: ApplicativeError[F, A]): F[Unit]
こちらは、直接 ApplicativeError の関数ではないですが Option から ApplicativeError への変換する関数ですので紹介します。
class ApplicativeThrowContext[F[_] : ApplicativeThrow : Logger] {
def f: Option[Throwable] = ???
def apply: F[Unit] = f.raiseTo[F]
}
Option が Some だった場合、中身を raiseError してF[Unit]
として扱える様にしてくれるんですね、いまのところ、使うケースは浮かんでいないですが、あると便利な面白い関数ですね。
ApplicativeError の関数についての感想
F として抽象化された場合に使い勝手の良い関数、Either など具体的な型の処理に対してかゆいところに手が届くような関数、どちらも発見することが出来ました。個人的にはエラーの処理に関してはログ出力が絡みがちなのもあり、どう実装するべきか迷うことが多かったので便利なたくさん関数を知ることが出来て、良かったです。
MonadError
Applicative な Error があるとすれば Monad な Error もありますよね、こちらは ApplicativeError としては実現できなかった、flatMap を使って実装された関数がいくつか用意されています。
class MonadThrowContext[F[_] : MonadThrow : Logger]
ApplicativeError と同じ様に、エラー側を Throwable に固定した type を F に context bound させる形で今回はコード例を書いていこうと思います。
redeemWith
def redeemWith[A, B](fa: F[A])(recover: E => F[B], bind: A => F[B]): F[B]
ApplicativeError には redeem がありましたが、それに with をつけた関数ですね、こちらもほかの**With
と同じ様に、with が付いていないバージョンの関数と比べると、引数として渡す関数の返り値がコンテキストに包まれているという違いがあります。
def redeem[A, B](fa: F[A])(recover: E => B, f: A => B): F[B]
redeem が map + handleError だとしたら、redeemWith は flatMap + handleErrorWith といったところでしょうか。
class MonadThrowContext[F[_] : MonadThrow : Logger] {
def f: F[Int] = ???
def g(i: Int): F[String] = ???
def h(t: Throwable): F[String] = ???
def apply: F[String] = ???
}
関数 f と、その結果を受け取ってF[String]
に変換する関数 g、エラーを受け取ってF[String]
にリカバリする関数 h があるとします、それらを使って関数 apply を実装する場合、どういうコードになるでしょうか。
redeemWith を使わないと・・・
冒頭で書いてしまったように flatMap + handleErrorWith を使う感じになると思います。
def apply: F[String] = f.flatMap(g).handleErrorWith(h)
そのままですね。
redeemWith を使うと・・・
def apply: F[String] = f.redeemWith(h, g)
この様に書くことが出来ます。
rethrow
def rethrow[A, EE <: E](fa: F[Either[EE, A]]): F[A]
ApplicativeError の attempt でも軽く触れましたが、attempt とは逆にF[Either[EE, A]]
をF[A]
にする関数ですね。
class MonadThrowContext[F[_] : MonadThrow : Logger] {
def f: F[Either[Throwable, String]] = ???
def apply: F[String] = ???
}
F[Either[Throwable, String]]
を返す関数 f を使ってF[String]
にする関数 apply の実装を考えてみましょう。
rethrow を使わないと・・・
rethrow を使わない場合、liftTo[F]
と flatMap を使って F を潰す感じで実装すると思います。
def apply: F[String] = f.flatMap(_.liftTo[F])
rethrow を使うと・・・
def apply: F[String] = f.rethrow
シンプルですね。
reject
def reject(pf: PartialFunction[A, E])(implicit F: MonadError[F, E]): F[A]
こちらは syntax のみに実装されている関数になります。名前の通り特定の A をエラーに変換する関数です、recover の逆バージョンの様な感じでしょうか。
class MonadThrowContext[F[_] : MonadThrow : Logger] {
def f: F[String] = ???
def apply: F[String] = ???
}
F[String]
を返す関数 f を使った結果が"is not correct"
という文字列だった場合だけそれをエラーにする関数 apply を実装したいとします。
reject を使わないと・・・
特定の値だけエラーに変換する処理を、reject を使わないで書いた場合どういう方法があるでしょうか。
def apply: F[String] =
f.flatMap {
case error@"is not correct" => new Error(error).raiseError
case others => others.pure[F]
}
flatMap + パターンマッチを使って、raiseError を行うと実装できそうですね、この場合もcase others => others.pure[F]
が野暮ったく感じます。
reject を使うと・・・
def apply: F[String] =
f.reject { case error@"is not correct" => new Error(error) }
この様にシンプルに書くことが出来ました。
ensure
def ensure[A](fa: F[A])(error: => E)(predicate: A => Boolean): F[A]
さてこちらは、reject と似ているのですが、値が特定の条件を満たしていない場合エラーにするというものです。
class MonadThrowContext[F[_] : MonadThrow : Logger] {
def f: F[Int] = ???
def apply: F[Int] = ???
}
今回はF[Int]
を返す関数 f を使った結果の数値が、負数だった場合だけエラーにするという関数 apply を実装してみるとします。
ensure を使わないと・・・
reject の時の実装とほとんど同じですが、case if で絞ったあと raiseError する形になると思います。
def apply: F[Int] = f.flatMap {
case i if i < 0 => new Error("is negative").raiseError
case others => others.pure[F]
}
あとは、なんなら reject を使って
def apply: F[Int] =
f.reject { case i if i < 0 => new Error("is negative") }
などと書けるかなと思います。
ensure を使うと・・・
def apply: F[Int] = f.ensure(new Error("is negative"))(0 <= _)
シンプルですね。ただこの例だと reject を使ってもどっちでもいいかなぁと思ってしまいますよね。使い分けはどうすればいいでしょうか。ensure は条件分岐が boolean 一つで収まる場合にしか使えませんが、reject は PartialFunction を受け取るので複雑な条件分岐を実装することができそうです。
ですので、条件分岐が一つの場合は ensure を使い、それ以外は reject を使うというのはどうでしょうか。
ensureOr
def ensureOr[A](fa: F[A])(error: A => E)(predicate: A => Boolean): F[A]
先程の ensure に or をつけたこの関数ですが、比較するとエラー生成用の引数の型が違いますね。
def ensure[A](fa: F[A])(error: => E)(predicate: A => Boolean): F[A]
ensure に比べて、E を生成するのに A を受け取れるようになっています。
class MonadThrowContext[F[_] : MonadThrow : Logger] {
def f: F[Int] = ???
def apply: F[Int] = ???
}
さきほどと同じ様な関数ですが、今度は"is negative"
ではなくs"$i is negative"
という様にエラーにする値をメッセージに含めたい場合を考えます。
ensureOr を使わないと・・・
def apply: F[Int] =
f.reject { case i if i < 0 => new Error(s"$i is negative") }
さきほどとほぼ同じですが、reject を使って表現できると思います。
ensureOr を使うと
def apply: F[Int] = f.ensureOr(i => new Error(s"$i is negative"))(0 <= _)
こういう感じになるでしょうか。こちらも reject を使うのと大差ないですね。、reject, ensure, ensureOr の使う訳をまとめて考えると、
- 条件がシンプルで、かつエラー処理に A を使いたい場合 -> ensureOr
- 条件がシンプルで、かつエラー処理に A は使わない場合 -> ensure
- 条件が複雑な場合 -> reject
という風になるでしょうか。
MonadError の関数についての感想
ApplicativeError にはなかった **With シリーズの redeemWith が実装されていたり、エラーをリカバリする関数がたくさん定義してあった ApplicativeError とは反対に MonadError では、正常な値をエラー扱いにする関数がいくつも定義されていました。そのような実装をするために、flatMap の力が必要だというのはとても興味深いですね。
おわりに
副作用を F として扱うときに、エラー関連の処理を行うと型合わせを難しく感じるケースが比較的多いなと思っていました。
今回 ApplicativeError と MonadError に定義された関数の使い方を理解したことで、それらに立ち向かっていくことができるようになったと思います。型クラスの性質として、ApplicativeError はリカバリ系、MonadError はリジェクト系というように比較的棲み分けされて定義されていることがわかったのが面白いポイントでした。
明日のアドベントカレンダーは
@apollo_programさんによる記事です。こうご期待!