[CE2] cats.EvalとIO.evalで正格評価した結果をキャッシュする

今回はcats.Evalを使って副作用のある処理の結果をキャッシュするコード
2021.05.30

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

CE3が出てもまだまだ2.x系を使っている皆さんこんばんは。最近はCE3のワークスティーリングスケジューラーの実装がどんな感じなのか気になっている佐々木です(挨拶) 今回はcats.Evalを使って副作用のある処理の結果をキャッシュするコードを試してみました。

ライブラリのバージョン

今回使用しているCEのバージョンは2.5.1です。

やりたいこと

やや特殊ですが以下のようなことをしたいです。

  • 少し重い処理を正格評価して結果をキャッシュしたい
  • 重たい処理は既にIOモナドで実装されている
  • 処理は失敗するかもしれない
  • 失敗した場合はエラーも含めてキャッシュする

cats.Eval

Evalは評価タイミングを制御できるデータ構造です。評価戦略は以下の3つです。

  • Eval.now 正格評価 + キャッシュあり
  • Eval.defer 非正格評価 + キャッシュあり
  • Eval.always 非正格評価 + キャッシュなし

これと以下のIO.evalを組み合わせることでEvalの評価戦略を保ったままリフトできます。

  /**
   * Lifts an `Eval` into `IO`.
   *
   * This function will preserve the evaluation semantics of any
   * actions that are lifted into the pure `IO`.  Eager `Eval`
   * instances will be converted into thunk-less `IO` (i.e. eager
   * `IO`), while lazy eval and memoized will be executed as such.
   */
  def eval[A](fa: Eval[A]): IO[A] = fa match {
    case Now(a) => pure(a)
    case notNow => apply(notNow.value)
  }

コード例

上記を踏まえてやりたかったことを実装すると以下のようになります。

package example

import cats.Eval
import cats.data.Validated
import cats.effect.{ExitCode, IO, IOApp}
import cats.implicits._

object EvalExample extends IOApp {

  type ValidationResult = Validated[String, Int]

	//既存の重たい(!)処理
  def evenNumber(n: Int): IO[ValidationResult] =
    IO(println(s"validating number $n")) *> IO(if ((n % 2) === 0) n.valid else s"$n is a odd number".invalid)

	//重たい処理の結果
  val threeIsNotEven = IO.eval(Eval.now(evenNumber(3).attempt.unsafeRunSync()))

	//重たい処理の結果を使ったロジック
  override def run(args: List[String]): IO[ExitCode] = for {
    _ <- threeIsNotEven.flatMap(IO.fromEither) //IO[ValidationResult]
    _ <- threeIsNotEven.flatMap(IO.fromEither)
    _ <- threeIsNotEven.flatMap(IO.fromEither)
  } yield ExitCode.Success
}

上の例を実行するとrunが実行される前に重たい処理は実行されますが、その結果はIOとして透過的に合成したり実行することができます。evenNumberの中のコンソール出力処理も最初に1回実行されるだけです。またevenNumberがエラーになる場合もattemptによってエラー(Left)がそのままキャッシュされます。IO.evalの定義を見るとIO.pureでも代替できそうですがEvalを挟んでおくことであとで非正格評価にも変えやすいかと思います。

注意点

上記の例では正格評価された値をキャッシュしてIOモナドとして扱っていますが、このような値を他のIOと合成すると評価戦略が異なるため意図しない挙動を生み出す可能性があります。

まとめ

やや強引ですが、EvalとIOを使って正格評価した値をキャッシュするコードを実装してみました。