Closeモナド

はじめに

Scalaで一番よく使うローンパターンでは、ローンパターンの典型的なコードとして次があげられている。

import java.io.Writer

import scala.io.Source

object Using {
  def apply[A, B](resource: A)(process: A => B)(implicit closer: Closer[A]): B =
    try {
      process(resource)
    } finally {
      closer.close(resource)
    }
}

case class Closer[-A](close: A => Unit)

object Closer {
  implicit val sourceCloser: Closer[Source] = Closer(_.close())
  implicit val writerCloser: Closer[Writer] = Closer(_.close())
}

このコードは、リソース(resource)を使った関数processを受け取ってそれを実行する。もしprocessが成功したとしても、あるいは失敗して例外を送出したとしても、リソースを閉じるためにcloser.close(resource)を呼び出すようになっている。ただ、このコードは著者が主張するようにモナドではないため、for式の中で使うことができない。よって、たとえば次のようにいくつものリソースを取り扱う場合はネストする。

Using(new FileInputStream(getClass.getResource("/source.txt").getPath)) { in =>
  Using(new InputStreamReader(in, "UTF-8")) { reader =>
    Using(new BufferedReader(reader)) { buff =>
      Using(new FileOutputStream("dest.txt")) { out =>
        Using(new OutputStreamWriter(out, "UTF-8")) { writer =>
          var line = buff.readLine()
          while (line != null) {
            writer.write(line + "\n")
            line = buff.readLine()
          }
        }
      }
    }
  }
}

また、Loanパターンをモナドfor式で使えるようにしてみたよでは次のようにしてfor式の中で使えるようにしている。

class Loan[T <: {def close()}] private (value: T) {

  def foreach[U](f: T => U): U = try {
    f(value)
  } finally {
    value.close()
  }  

}
object Loan {
  
  def apply[T <: {def close()}](value: T) = new Loan(value)
  
}

これを用いると先ほどのネストした例を次のように書ける。

for {
  in     <- Loan(new FileInputStream("source.txt"))
  reader <- Loan(new InputStreamReader(in, "UTF-8"))
  buff   <- Loan(new BufferedReader(reader))
  out    <- Loan(new FileOutputStream("dest.txt"))
  writer <- Loan(new OutputStreamWriter(out, "UTF-8"))
} {
  var line = buff.readLine()
  while (line != null) {
    writer.write(line)
    line = buff.readLine()
  }
}

ただ、この例では著者が主張するようにモナドにはなっていない。本記事ではこのようなIOのリソースを適切にクローズするようなCloseモナド1の作成を行う。また作成したモナドに対してscalapropsでテストを作成する。なお、全体のソースコードは次のリポジトリにある。

追記

@jwhaco さんが継続モナドを利用してよりよい実装を公開されていましたので、紹介させていただきます。

Closeモナド

このモナドの作成はリーダーモナドとFujiTaskを参考にした。

abstract class Close[+R, +A](res: R) { self =>
  protected def process()(implicit closer: Closer[R]): A

  def run()(implicit closer: Closer[R]): A =
    try {
      process()
    } finally {
      closer.close(res)
    }

  def flatMap[AR >: R, B](f: A => Close[AR, B]): Close[AR, B] = new Close[AR, B](res) {
    def process()(implicit closer: Closer[AR]): B =
      try {
        f(self.process()).process()
      } finally {
        closer.close(res)
      }

    override def run()(implicit closer: Closer[AR]): B =
      process()
  }

  def map[B](f: A => B): Close[R, B] = flatMap(x => Close(res, f(x)))
}

object Close {
  def apply[R, A](res: R, a: => A) = new Close[R, A](res) {
    def process()(implicit closer: Closer[R]): A = a
  }

  def apply[R](res: R): Close[R, R] = apply(res, res)
}
trait Closer[-A] {
  def close(a: A): Unit
}

object Closer {
  def apply[A](f: A => Unit): Closer[A] = new Closer[A] {
    def close(a: A): Unit = f(a)
  }
}

まずCloseモナドは2つの型パラメータを受け取る。型パラメータRはリソースの型を表し、型パラメータAは結果の型を表すようになっている。 また、flatMapの内部では新しいCloseモナドを作成している。processメソッドを積んでいく構造になっており、まず自分(self)のprocessメソッドを実行し、その結果をfに投入してさらにprocessメソッドを呼ぶようになっている。この一連の実行はtryの中に入れることで、成功したとしても失敗して例外が送出されたとしてもfinallycloser.close()が実行されリソースがクローズされるようにしている。 closerはリソースの型Rに対応するリソースをクローズする方法を提供する型クラスのインスタンスである。型クラスCloser[A]はリソースAをクローズするためのメソッドcloseを持つ。

また、がくぞさんから指摘を参考に次の2つの変更を与えた。

  1. res: Rres: => Rにしてリソースの掴みっぱなしを無くした
  1. runメソッドは最初だけリソースを解放するようにセットしておき、一度でもmap/flatMapが発生するとprocessを呼び出すだけになるようにした。こうすることで指摘にあった未合成のCloseモナドのrunでリソースリークする問題に対応した

Closeモナドの実行例

さきほどの例をCloseモナドで書くと次のようになる。

implicit def closer[R <: Closeable]: Closer[R] = Closer { x =>
  println(s"close: ${x.toString}")
  x.close()
}

(for {
  in     <- Close(new FileInputStream(getClass.getResource("/source.txt").getPath))
  reader <- Close(new InputStreamReader(in, "UTF-8"))
  buff   <- Close(new BufferedReader(reader))
  out    <- Close(new FileOutputStream("dest.txt"))
  writer <- Close(new OutputStreamWriter(out, "UTF-8"))
} yield {
  println("[begin]")

  var line = buff.readLine()
  while (line != null) {
    println(line)
    writer.write(line + "\n")
    line = buff.readLine()
  }

  println("[end]")
}).run()

実行すると次のような結果が得られる2

[begin]
This
is
a
pen
[end]
close: java.io.OutputStreamWriter@46cd1743
close: java.io.FileOutputStream@513460bd
close: java.io.BufferedReader@35f61ec8
close: java.io.InputStreamReader@56d5bf00
close: java.io.FileInputStream@788ccd96

scalapropsによるテスト

やや複雑になったのでGitHubにあるコードのリンクを貼ることにする。

まずClose[R, A]Rを何か適当に固定してscalazのモナドインスタンスを作成する。そして後はひたすらGenEqualのインスタンスを作ればよい。ただ、Gen[Close[R, A]]の定義において、特定の割合でrunメソッドが例外を送出するような工夫を行った。 また、モナドの性質とは別に次のようなテストも追加した。

  • 合成する前のリソースをrunした場合にきちんとクローズされるか
  • res1res2の順で合成した場合にres2res1の順でクローズされるか

まとめ

この記事ではIOのリソースをクローズするCloseモナドを実装した。よい悪いの議論は別として、Closeモナドに限らず世の中にあるローンパターンはモナドで書き換え可能であるのではないかと考えている。Closeモナド以外にも、何かモナドで書き換えると便利になるような例があるかもしれない。


  1. Closeモナドは一般的な名前ではなく、筆者が勝手につけた名前である。

  2. source.txtの内容により出力が異なる。

コメント