cats effect IO.pureとdelayの違い

cats.effect.IOの#delayと#pureの違いについて1日で2回話したので書きました。
2020.06.26

はじめに

cats Effect IO#delay#pure使い方の誤りについて、たまたま同じ日に同じ指摘を2回したので記事にしてみることにしました。

何が違うのか

IO.delay(とそのエイリアスのIO.apply)は以下のように定義されています。

/**
    * Suspends a synchronous side effect in `IO`.
    *
    * Alias for `IO.delay(body)`.
    */
  def apply[A](body: => A): IO[A] =
    delay(body)

  /**
   * Suspends a synchronous side effect in `IO`.
   *
   * Any exceptions thrown by the effect will be caught and sequenced
   * into the `IO`.
   */
  def delay[A](body: => A): IO[A] =
    Delay(body _)

対してIO.pureは下記の通りです。

/**
   * Suspends a pure value in `IO`.
   *
   * This should ''only'' be used if the value in question has
   * "already" been computed!  In other words, something like
   * `IO.pure(readLine)` is most definitely not the right thing to do!
   * However, `IO.pure(42)` is correct and will be more efficient
   * (when evaluated) than `IO(42)`, due to avoiding the allocation of
   * extra thunks.
   */
  def pure[A](a: A): IO[A] = Pure(a)

どちらもAをとってIO[A]を返しますが評価戦略が異なります。delayは非正格(名前渡しパラメータ)ですがpureは正格です。以下のように実行してみると違いが明確です。

import cats.effect.IO
import cats.implicits._

@SuppressWarnings(Array("org.wartremover.warts.NonUnitStatements"))
object IOExample extends App {

  //IO.apply
  println("[IO.apply]")

  val ioWithApply = List(
    IO(println("hello")),
    IO(println("cats")),
    IO(println("world"))
  ).sequence

  ioWithApply.unsafeRunSync()
  ioWithApply.unsafeRunSync()

  //IO.pure
  println("[IO.pure]")

  val ioWithPure = List(
    IO(println("hello")),
    IO.pure(println("cats")),
    IO(println("world"))
  ).sequence

  ioWithPure.unsafeRunSync()
  ioWithPure.unsafeRunSync()
}

実行結果は下記のようになります。

[IO.apply]
hello
cats
world
hello
cats
world
[IO.pure]
cats
hello
world
hello
world

pureを使った2つ目のパターンでは以下の違いがあります。

  • 1回目のunsafeRunSyncではhelloとcatsの出力順序が逆になっている
  • 2回目のunsafeRunSyncではcatsは出力されない

これはIOが生成されるときに値が評価されているか、IOの実行時に評価されているか、の違いによるものです。

エラー処理

評価タイミングの違いはエラーハンドリングでは致命的な違いをもたらします。

以下のコードではIOの実行時発生したエラーをhandleErrorWithでハンドリングしようという意図で書かれています。

//エラーハンドリング
  def catsNameOrError: String = throw new RuntimeException("Cats Name Error !!")

  //IO.apply
  println("[IO.apply]")

  IO(catsNameOrError)
    .handleErrorWith {
      case e: Throwable =>
        println("Error handled: " + e.getMessage)
        IO.pure(e.getMessage)
    }.unsafeRunSync()

  //IO.pure
  println("[IO.pure]")
  IO.pure(catsNameOrError).handleErrorWith {
      case e: Throwable =>
        println("Error handled: " + e.getMessage)
        IO.pure(e.getMessage)
    }.unsafeRunSync()

しかし下記の実行例の通り実際にはIO.pureを使った方はIOが実行される前に例外が送出されるため補足されない例外はmainへ伝搬します。

[IO.apply]
Error handled: Cats Name Error !!
[IO.pure]
Exception in thread "main" java.lang.RuntimeException: Cats Name Error !!
	at IOExample$.catsNameOrError(IOExample.scala:30)
	at IOExample$.delayedEndpoint$IOExample$1(IOExample.scala:44)
	at IOExample$delayedInit$body.apply(IOExample.scala:5)
	at scala.Function0.apply$mcV$sp(Function0.scala:39)
	at scala.Function0.apply$mcV$sp$(Function0.scala:39)
	at scala.runtime.AbstractFunction0.apply$mcV$sp(AbstractFunction0.scala:17)
	at scala.App.$anonfun$main$1$adapted(App.scala:80)
	at scala.collection.immutable.List.foreach(List.scala:392)
	at scala.App.main(App.scala:80)
	at scala.App.main$(App.scala:78)
	at IOExample$.main(IOExample.scala:5)
	at IOExample.main(IOExample.scala)

まとめ

両者の違いを理解して副作用のある処理を安全に書きましょう。