こんにちは河内です。
この記事は10周年記念として133日間ブログを書き続けるチャレンジの120日目の記事となります。 そして本日2024年1月6日がFLINTERS創立10周年当日です! 10年を無事に迎えられたのは、取引先のお客様、応援してくださる皆様、そして社員の皆のおかげです。ありがとうございます!
さて、最近は ZIO を使ってプログラムを書くことが多いのですが、先日会社の人と ZIO のいいところ、いろいろあるよね、という話をしていまして、今回は Scope について書こうと思います。 (Scala 2.13.4, ZIO 2.0.13 です。)
Scope はリソース管理のための機能です。 リソース管理というのは Java でいうところの try-with-resources文がやっているようなものです。確保したものを使い終わったら解放するための仕組みです。
ZIO は Scala のライブラリなので、Scala で例を書きます。
Scala では try-with-resources の代わりに scala.util.Using
が使えます。
Using.resource(new FileInputStream("foo.txt")) { is => ... // is を使う } // is はここで close される
そんなに話すことのない機能のように思えますが、実行タイミングが異なる要素が入ると少し難しくなります。
例えば前述のコードの中で is
を使う部分が ZIO.attempt()
になったとします。 ZIO.attempt()
はその場で実行されるのではなく、ワークフロー(ZIO型のインスタンス)の構築のみを行い、実行は後で行われます。
val io = Using.resource(new FileInputStream("foo.txt")) { is => // is を使う ZIO.attempt { // is を使う } } // is はここで close される // io の実行
この場合、io の実行時には is
は close されてしまっているので、実行時にエラーになってしまいます。
この例ですと Using.resource()
を ZIO.attempt()
の中に入れてしまえばいいのですが、複数の ZIO.attempt()
から is
を使いたい場合などはそうもいきません。
これが ZIO のやり方ではこうなります。
ZIO.scoped { for { is <- ZIO.fromAutoCloseable(ZIO.attempt(new FileInputStream("foo"))) _ <- ZIO.attempt { ... // is を使う } } yield () } // is はここで close される
ZIO.fromAutoCloseable()
は ZIO[R with Scope, E, A]
型の値を返します。
Scope
はリソースの解放方法を知っている値です。
また ZIO.scoped
はスコープを閉じる関数で、 ZIO[R with Scope, E, A]
を ZIO[R, E, A]
に変換します。
ZIO.fromAutoCloseable(ZIO.attempt(new FileInputStream("foo")))
を説明のために分解すると、次のようになります。
// "foo" を開く方法を記述したワークフロー val howToOpenFoo: ZIO[Any, Throwable, FileInputStream] = ZIO.attempt(new FileInputStream("foo")) // "foo" を開き、 Scope が閉じられる際に close する方法を記述したワークフロー val howToOpenAndCloseFoo: ZIO[Scope, Throwable, FileInputStream] = ZIO.fromAutoCloseable(howToOpenFoo)
Java の try-with-resources や Using.resource()
がコードブロックでリソースの利用範囲を表現するのに対して、ZIO では Scope
が R
に含まれていることによってリソースの利用範囲を表現しています。
FileInputStream
のように java.lang.AutoCloseable
を継承している値には ZIO.fromAutoCloseable()
を使えます。
それ以外には ZIO.acquireRelease()()
が使えるので、AutoCloseable でないリソースも扱えます。
// ZIO.fromAutoCloseable(ZIO.attempt(new FileInputStream("foo"))) を書き換えたもの ZIO.acquireRelease(ZIO.attempt(new FileInputStream("foo")))(is => ZIO.succeed(is.close()))
Scope は複数のリソースを扱える
Scope
は複数のリソースを扱うことができます。
例を示します。
ZIO.scoped { for { is1 <- ZIO.fromAutoCloseable(ZIO.attempt(new FileInputStream("foo"))) is2 <- ZIO.fromAutoCloseable(ZIO.attempt(new FileInputStream("bar"))) _ <- ZIO.attempt(???) } yield () } // is2 → is1 の順で、ここで close される
リソースは確保した順と逆の順でシリアルに解放されます。
bracket と比べて何が嬉しいの?
確保、利用、解放を表現するときに使われるイディオムとして、それぞれを関数として引数に取る方法があります。
bracket pattern と呼ばれるものです。
該当するものとして ZIO 2.x には ZIO.acquireReleaseWith()()()
という関数があるので、比べてみましょう。
一時ファイルを作ってそれを利用する例を考えます。
まずは Scope を使って書いたものです。
import zio.{Scope, ZIO, ZIOAppArgs, ZIOAppDefault} import java.nio.file.{Files, Path} object ScopeExample extends ZIOAppDefault { def createTmpFile: ZIO[Scope, Nothing, Path] = ZIO.acquireRelease( ZIO.succeed(Files.createTempFile("foo", ".tmp")) )(f => ZIO.succeed(Files.deleteIfExists(f))) override def run: ZIO[ZIOAppArgs with Scope, Any, Any] = for { tmp1 <- createTmpFile tmp2 <- createTmpFile _ <- ZIO.logInfo(s"tmp1: $tmp1, tmp2: $tmp2") } yield () }
次に bracket を使って書いたものです。
import zio.{Scope, ZIO, ZIOAppArgs, ZIOAppDefault} import java.nio.file.{Files, Path} object BracketExample extends ZIOAppDefault { def createTmpFile[R, E, A](use: Path => ZIO[R, E, A]): ZIO[R, E, A] = ZIO.acquireReleaseWith( ZIO.succeed(Files.createTempFile("foo", ".tmp")) )(f => ZIO.succeed(Files.deleteIfExists(f)))(use) override def run: ZIO[ZIOAppArgs with Scope, Any, Any] = for { _ <- createTmpFile { tmp1 => createTmpFile { tmp2 => ZIO.logInfo(s"tmp1: $tmp1, tmp2: $tmp2") } } } yield () }
ポイントは for 式の中です。 Scope は flatMap で合成できるので、平坦に for 式がかけますが、 bracket の場合は利用部を関数として渡すことになるため、ネストが深くなります。
一部の Scope を延長する
Scope はリソースの閉じ方を知っている値で、複数のリソースを扱えることは説明しました。
ZIO.scoped
はそれらすべてを閉じる関数です。
一方で、一部のリソースを延長したい場合もあります。
そんなときは ZIO.scoped
の外で ZIO.service[Scope]
で Scope を取得し、 extend()
でスコープを延長します。
// tmp1 はワークフロー内で close され、tmp2 は Scope が閉じられるときに close される def workflowWithHowToCloseTmp2: ZIO[Scope, Nothing, Unit] = for { s <- ZIO.service[Scope] _ <- ZIO.scoped { for { tmp1 <- createTmpFile tmp2 <- s.extend(createTmpFile) _ <- ZIO.logInfo(s"tmp1: $tmp1, tmp2: $tmp2") } yield () } } yield ()
まれにあるケース
Scope はリソースの管理を確実にするためのものですが、たまにはわざと管理をすり抜けたいこともあります。
そんなときは Scope.global
で extend します。
def workflow: ZIO[Any, Nothing, Unit] = ZIO.scoped { for { tmp1 <- createTmpFile tmp2 <- Scope.global.extend(createTmpFile) // 注意! close されない! _ <- ZIO.logInfo(s"tmp1: $tmp1, tmp2: $tmp2") } yield () }
危ないのでよく考えて使いましょう。
まとめ
ZIO の Scope はリソースの管理を確実にするための機能です。 for 式で平坦にかけ、複数のリソースを扱えるのが特徴です。 いざというときの抜け道もありますが、危険なのでよく考えて使いましょう。