Scala 3のCapture Checkingでローンを確実に返済!

わざと怪しいタイトルにしてみましたが、こんにちは。

雑にググった限り、これを書いてる2024年5月現在、日本語でのScala 3のCapture Checkingの解説記事を見たことがない気がするので、 自分も1%くらいしか理解してない気がしますが、書いてみることにしました。

公式のサンプルを別の視点というか書き方で説明しなおしたり、もう少し色々なパターン試した程度の情報でしかないので、おそらく公式のドキュメントをしっかり読んだ人にとっては、特に新しい情報はありません。

さて、公式には以下のようなサンプルが載っています。

docs.scala-lang.org

def usingLogFile[T](op: FileOutputStream => T): T =
  val logFile = FileOutputStream("log")
  val result = op(logFile)
  logFile.close()
  result
val later = usingLogFile { file => () => file.write(0) }
later() // crash

いわゆる「ローンパターン」と呼ばれるものだと思います。

説明例:

xerial.org

ファイル関連の何かかコネクションなのかセッションなのか、場合によりますが、それを「借りて」「返す」のが、ScalaのFunctionの中で行われるので、見た目がわかりやすいというか、返し忘れが発生しにくくなる、ということですね。

この「ローンパターン」という呼び方が、どこ発祥なのか、他の言語の界隈で使われてる呼び方なのか?などの詳細は知らないですが、そこについては考えないことにします。誰か詳細知ってたらコメントとかに書いておいてください。

Scala 2.13からは scala.util.Using という名前で、それっぽいものが標準ライブラリにも入っています。

https://github.com/scala/scala/blob/ab41073758319f2c3fb37519745c9f0485017297/src/library/scala/util/Using.scala

理解力が速い人は、ここまで書いただけで完全理解したと思いますが、これは「返し忘れが発生しにくくなる」だけで、よく考えると、かなりガバガバですよね??? ローン返さずに逃げる、借りっぱなしにすることが出来てしまいます。

いや、その言い方は若干語弊があって、ある意味返してるのだけど、返却済のものを参照してしまうことが防げない、と言った方がより正確かもしれません。

さて、返さないと、返したはずのものを参照出来てしまうと、上記のScala 3公式の例のように、すでに存在しない、close済みのものを参照するなどしてしまって、実行時エラーになり、プログラムがバグります。

使おうとすると確実にエラーになるなら、まだバグがすぐわかっていいですが、非同期に終了処理をするパターンだったりすると「割と動いてしまうこともあるけど、たまにエラーになる」という、すごく最悪なパターンにもなりかねません。つらい・・・

さて

「俺はScalaでそういうパターンに絶対遭遇しないぞ(気を付けて書けるぞ!)(テストで防げるだろ!)」

「そういうパターンを防ぐ素晴らしい方法を既存の型システム使ってライブラリ作れるぞ!(例えばFP in Scalaにあるようなテクニック使えばある意味では?)」

という人にとっては、(少なくとも今回のような例の場合は)あまり必要ないのかもしれません。

しかし、このガバガバなローンパターンが、手軽に勝手にcompilerがチェックしてくれたら嬉しいですよね?

Capture Checking自体の本来の力や他の使用例がどのくらいあるのは?は、よく知りませんが、少なくともモチベーションの1つは、このような説明で合っているはずです。

さて、本体の例を真似して、以下のような例を書いてみました。

impor追加と PathPath^ と変えた以外は、見た目は普通のScalaプログラムです。

error と書いてるのは、実行時エラーではなく、compile errorになります。

scalaVersion := "3.4.1"
package example

import java.nio.file.Files
import java.nio.file.Path
import scala.language.experimental.captureChecking

object Main {
  def withTempDir[A](f: Path^ => A): A = {
    val dir = Files.createTempDirectory("")
    try {
      f(dir)
    } finally {
      Files.delete(dir)
    }
  }

  def main(args: Array[String]): Unit = {
    // ok
    withTempDir { dir => println(dir) }

    // ok
    withTempDir { dir => dir.toFile.getName }

    // ok
    withTempDir { dir => List(dir).map(_.toFile.getName) }

    // error
    withTempDir { dir => dir }

    // error
    withTempDir { dir => Option(dir) }

    // error
    withTempDir { dir => List(dir) }

    // error
    withTempDir { dir => java.util.List.of(dir) }

    // error
    withTempDir { dir =>
      new java.util.function.Function[Int, String] {
        def apply(n: Int) = dir.toFile.getName
      }
    }
  }
}
[error] -- Error: capture-check-loan-pattern-example/Main.scala:28:4 -----------------
[error] 28 |    withTempDir { dir => dir }
[error]    |    ^^^^^^^^^^^
[error]    |local reference dir leaks into outer capture set of type parameter A of method withTempDir
[error] -- Error: capture-check-loan-pattern-example/Main.scala:31:4 -----------------
[error] 31 |    withTempDir { dir => Option(dir) }
[error]    |    ^^^^^^^^^^^
[error]    |local reference dir leaks into outer capture set of type parameter A of method withTempDir
[error] -- Error: capture-check-loan-pattern-example/Main.scala:34:4 -----------------
[error] 34 |    withTempDir { dir => List(dir) }
[error]    |    ^^^^^^^^^^^
[error]    |local reference dir leaks into outer capture set of type parameter A of method withTempDir
[error] -- Error: capture-check-loan-pattern-example/Main.scala:37:4 -----------------
[error] 37 |    withTempDir { dir => java.util.List.of(dir) }
[error]    |    ^^^^^^^^^^^
[error]    |local reference dir leaks into outer capture set of type parameter A of method withTempDir
[error] -- Error: capture-check-loan-pattern-example/Main.scala:37:40 ----------------
[error] 37 |    withTempDir { dir => java.util.List.of(dir) }
[error]    |                         ^^^^^^^^^^^^^^^^^
[error]    |local reference dir leaks into outer capture set of type parameter E of method of
[error] -- Error: capture-check-loan-pattern-example/Main.scala:40:4 -----------------
[error] 40 |    withTempDir { dir =>
[error]    |    ^^^^^^^^^^^
[error]    |local reference dir leaks into outer capture set of type parameter A of method withTempDir
[error] 6 errors found

とりあえずこの程度のパターンなら、しっかり勝手にチェックしてくれます、ありがたいですね。

PathPath^ と変えた」と書きましたが、これを普通の Path に戻すと、これは当たり前ですが(ローン返済してないのに)全部コンパイルが通ってしまいます。

まだまだ実験的機能でバグも多そうだし、いつ正式な機能になるのか?そもそも正式な機能になるのか?などは、かなり未定だと思われます。

ちなみに、Scala標準ライブラリで、このCapture Checkingをより正確にするために、以下の部分でCapture Checkingの型をつけた形で再実装してるみたいですね。

https://github.com/scala/scala3/tree/362b0752e6a54edfea3909d32e6e43f740c13e92/scala2-library-cc/src/scala

見た目が味わい深い・・・

https://github.com/scala/scala3/blob/c251f36f155b81e039b3b5d72a99edebffcae182/scala2-library-cc/src/scala/collection/SeqView.scala#L86-L91

関連して思い出したシリーズだと、scalikejdbcでは、Futureなどに対応するため (単に確実に返すだけではなく、成功と失敗で、コミットか?ロールバックか?の機能もありますが)

github.com

TxBoundaryという型クラス用意してたりしますが、こういうの用意したところで、際限がないというか、普通のこういう形式の型クラスでは、どうやっても漏れさせることはやろうと思えば簡単なので、それ以上に強力にチェックしてくれることはありがたいですね。 (深く使ってないので、面倒な点や、Capture Checkingの限界を理解してない適当発言)

Rustとの比較や、もっと理論的な詳細や、他の例、今後の展望や他の機能との関連など、他にも知ってたら書きたいというか、書いた方がいいと思われることは山ほどあるのですが、

冒頭に書いたように、あくまで「自分も1%くらいしか理解してない」ので、今回はこれで終わりにします。

みんなもっと試して、理解して、面白い使い方見つけたり、詳しい解説書いてみてください。