副作用の影響範囲を型で示したい — Scala 3 が模索する「Capture Checking」 - エムスリーテックブログ

エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

副作用の影響範囲を型で示したい — Scala 3 が模索する「Capture Checking」

本記事は、コンシューマーチームブログリレー 1日目です。

はじめに

関数型まつりで触れてから、再び自分の中の Scala 熱が高まっています。エンジニアリンググループ、コンシューマーチーム 兼 WHITEJACK チームの松本です。

Scalaでは従来、Effのようなライブラリを使うことでEffect Systemのようなものを実現できました。しかし学習コストが高く、実行時のインタープリタのコストなどの課題もあります。もっと軽い方法はないのでしょうか。

「OCaml 5 の algebraic effects(effect handlers)に似たことが Scala でできないか」という出発点からはじまり、調べていく過程でわかったのは、実は OCaml 5 自身も effect を型で追跡してはいない(untyped で、未処理の effect は実行時例外になる)ということでした。つまり「どの副作用がどこまで及ぶかを型で示す」という課題は、様々な言語や処理系が、現在挑戦中の課題ということになります。

では Scala はこの課題にどう答えようとしているのか。その候補が、本記事の主役である experimental 機能 Capture Checking です。

本記事で扱う機能はすべて experimental です。バージョン間で仕様が大きく変わる可能性があり、確定した機能の紹介ではなく「現在進行形の模索」の紹介であることをあらかじめお断りしておきます。

記事中のコードとエラーメッセージは、執筆時点の最新安定版 Scala 3.8.3 で実際にコンパイルして確認したものです。

課題: 影響範囲が型に現れない

まず、解決したい問題を具体的なコードで見てみます。DB・Redis・ロガーをまとめてセットアップし、処理を実行して、確実にクローズする、よくあるローンパターンです。

class DbConnection:
  def query(sql: String): List[String] = ???
  def close(): Unit = ???

class RedisClient:
  def set(key: String, value: String): Unit = ???
  def close(): Unit = ???

class Logger:
  def info(msg: String): Unit = ???

def withContext[T](op: (DbConnection, RedisClient, Logger) => T): T =
  val db     = DbConnection()
  val redis  = RedisClient()
  val logger = Logger()
  try op(db, redis, logger)
  finally
    redis.close()
    db.close()

一見安全に見えますが、このコードには穴があります。

val refreshCache = withContext { (db, redis, logger) =>
  logger.info("キャッシュ更新処理を準備しました")
  () => // 「あとで実行する」つもりのクロージャ
    val users = db.query("SELECT name FROM users")
    redis.set("users", users.mkString(","))
}

refreshCache() // 💥 クローズ済みの DB / Redis へアクセス → 実行時エラー

op の戻り値としてクロージャを返すと、その中に db と redis への参照が閉じ込められたまま withContext の外へ持ち出せてしまいます。refreshCache() を呼ぶ頃には両方ともクローズ済みです。

問題の本質は、シグネチャを見ても各リソースの影響範囲がわからないことにあります。op: (DbConnection, RedisClient, Logger) => T という型は「op は3つのリソースを受け取る」としか言っておらず、「受け取ったものを戻り値の中に隠して持ち出してはいけない」という制約はどこにも現れていません。さらに言えば、戻ってきた refreshCache の型はただの () => Unit で、3つのうちどれを抱え込んでいるのかすら読み取れません。コンパイラは何も指摘できず、規約とレビューだけが頼りになります。

DB や Redis に限らず、ファイルハンドル、トランザクション、例外を投げる権利、ネットワークアクセス——「使ってよい範囲が決まっているもの」すべてに同じ構造の問題があります。

Scala が模索する方向: capability + Capture Checking

この課題に対して Scala が試しているアプローチは、大きく2つの部品からなります。

発想: 副作用は「capability を受け取る」ことで表現する

1つ目は設計スタイルの話です。副作用を IO モナドのように「値を包む」のではなく、その副作用を起こす権利(capability)をふつうの引数として受け取ることで表現します。DB に触れるのは DbConnection を持っている関数だけ、Redis に書けるのは RedisClient を持っている関数だけ、という具合です。

これ自体は昔からあるスタイルで、シグネチャに「何を使うか」が現れる点は優れています。しかし冒頭の例が示すとおり、capability がクロージャに捕まって外へ逃げることは防げません。逃げてしまえば「持っている人しか使えない」という前提が崩れます。

Capture Checking: capability の「逃亡」を型で防ぐ

そこで2つ目の部品、Capture Checking の出番です。次の方法で有効化できます。

import language.experimental.captureChecking

Capture Checking を有効にすると、型に捕捉集合(capture set)を付けられるようになります。T^{c} は「c という capability を捕捉しているかもしれない T」を意味し、T^ は「何でも捕捉しうる T」(ルート capability caps.any の捕捉の略記)です。

関数型にも純粋・不純の区別が入ります。

記法 意味
A -> B 純粋な関数。capability を何も捕捉しない
A ->{c} B c だけを捕捉する関数
A => B 不純な関数。何でも捕捉しうる(ルート capability 付き -> の略記)

これだけの道具で、冒頭の穴が塞がります。withContext の各パラメータに ^ を1文字ずつ足すだけです。

def withContext[T](op: (DbConnection^, RedisClient^, Logger^) => T): T =
  // 本体は先ほどと同じ

val refreshCache = withContext { (db, redis, logger) => // ✗ コンパイルエラー!
  logger.info("キャッシュ更新処理を準備しました")
  () =>
    val users = db.query("SELECT name FROM users")
    redis.set("users", users.mkString(","))
}
[error] |Capability `db` outlives its scope: it leaks into outer capture set 's1
[error] |which is owned by value refreshCache.
[error] |The leakage occurred when trying to match the following types:
[error] |
[error] |Found:    (db: DbConnection^, redis: RedisClient^, logger: Logger^) ->'s2
[error] |  () ->{db, redis} Unit
[error] |Required: (DbConnection^, RedisClient^, Logger^) => () ->'s1 Unit

DbConnection^ と宣言したことで、db たちは「このスコープ内でだけ有効な capability」として扱われます。それを捕捉したクロージャを外へ持ち出そうとすると、「db がスコープより長生きしようとしている(outlives its scope)」としてコンパイラが拒否します。影響範囲の逸脱が、実行時エラーではなくコンパイルエラーになりました。

エラーメッセージの Found: の行にも注目してください。クロージャの型が () ->{db, redis} Unit と表示されています。コンパイラは「このクロージャが3つのうち db と redis だけを抱えている」ことまで正確に突き止めているのです。この捕捉集合の追跡が何の役に立つのかを、次の節でもう少し見てみます。

複数の影響範囲が、クロージャごとに区別される

捕捉集合の面白いところは、capability が複数あるときどのクロージャがどれに依存しているかを個別に追跡してくれる点です。ローカルなクロージャの捕捉集合は推論されるので、自分で書くのは公開 API の境界に置く ^ くらいで済みます。

withContext { (db, redis, logger) =>
  val audit  = (msg: String) => logger.info(msg)   // String ->{logger} Unit
  val warmup = () => redis.set("greeting", "hi")   // ()     ->{redis}  Unit
  val load   = () => db.query("SELECT 1")          // ()     ->{db}     List[String]
  // ...
}

3つのクロージャは Scala 3 のこれまでの型ではすべて区別のつかない「ただの関数」でしたが、Capture Checking のもとでは ->{logger}、->{redis}、->{db} と触るものの違いが型の違いとして現れます。「この処理は結局どこまで影響するのか」をシグネチャから読み取れます。便利ですね。

逃亡チェックもこの粒度で働きます。たとえば logger だけは長生きしてよい設計にしたければ、Logger パラメータから ^ を外せば「logger を捕捉したクロージャは持ち出せるが、db や redis を捕捉したものはコンパイルエラー」という区別が付きます。影響範囲ごとにルールを変えられるのは、まさに「複数の capability にまたがるコード」で効いてきます。

なお、今回はローン関数側が ^ を付けて capability 化しましたが、caps.SharedCapability などのトレイトを継承したクラスを定義すれば、そのインスタンスは常に capability として追跡されます。

使ってみる

安全なリソース管理

前章の withContext がそのまま実用パターンです。ポイントを整理すると:

  • リソースを貸し出すローン関数は、パラメータ型に ^ を付けて「貸すだけで、あげてはいない」ことを宣言する
  • 正しい使い方(スコープ内で使い切る)は今までどおり何の追加記述もなくコンパイルが通る
  • 間違った使い方(クロージャやコレクションに入れて持ち出す)だけがコンパイルエラーになる

「正しいコードには何も要求せず、間違ったコードだけを弾く」という感触になります。

検査例外の再発明: CanThrow

capability という考え方の応用範囲を示す好例が、例外の追跡です。saferExceptions を有効にすると、「例外 E を投げる権利」が CanThrow[E] という capability になります。

import language.experimental.saferExceptions

class LimitExceeded extends Exception

val limit = 10e9

def f(x: Double): Double throws LimitExceeded =
  if x < limit then x * x else throw LimitExceeded()

throws LimitExceeded は CanThrow[LimitExceeded] ?=> Double の糖衣構文、つまり「CanThrow capability を暗黙に要求する関数」です。try/catch がこの capability を導入するので、ハンドラの内側でしか f を呼べません。

try
  println(f(2e9))   // OK: try が CanThrow[LimitExceeded] を提供している
catch
  case _: LimitExceeded => println("too large")

f(2e9)              // ✗ コンパイルエラー: CanThrow[LimitExceeded] がない
[error] |The capability to throw exception LimitExceeded is missing.
[error] |The capability can be provided by one of the following:
[error] | - Adding a using clause `(using CanThrow[LimitExceeded])` to the definition of the enclosing method
[error] | - Adding `throws LimitExceeded` clause after the result type of the enclosing method
[error] | - Wrapping this piece of code with a `try` block that catches LimitExceeded

エラーメッセージが3通りの直し方まで提示してくれるのが印象的です。この「原因と修正手順がセットになったエラー」は人間に親切なだけでなく、AI コーディングエージェントに読ませて自己修正させるフィードバックとしてもそのまま機能します。実際 Scala チームは、capture checking を AI が生成したコードの安全性検証に応用する方向にも動いています(後述の safe mode)。

Java の検査例外は「シグネチャを汚す割に高階関数と相性が悪い」と嫌われてきましたが、こちらは capability がふつうの(暗黙の)値なので高階関数とも自然に合成できます。そして Capture Checking と組み合わせると、try ブロックから CanThrow を捕捉したクロージャを持ち出す——つまりハンドラの外で例外を投げる抜け道——もコンパイルエラーにできます。「例外の影響範囲が型に現れ、かつ逃げられない」わけです。

Eff と比べてみると

冒頭の動機に戻りましょう。誤解のないように書いておくと、Eff で書いたコードの見た目は綺麗です。作用は for 内包表記で自然に合成できますし、シグネチャには使う作用の一覧がきちんと現れます。capability スタイルとの違いは見た目ではなく、構造とコストにあります。

  • 世界の分かれ方: Eff では作用を使う処理をすべて Eff[R, A] の世界に持ち上げる必要があり、ふつうのコードとの間に境界ができる。capability はただの引数なので、モナドに持ち上げず値を直接受け渡すふつうの書き方(いわゆる direct style)のまま混ぜて書ける
  • 実行時: Eff はインタープリタがプログラムを解釈しながら走る。capability には実行時の仕掛けが何もなく、コストはほぼゼロ
  • 型に現れる情報: Eff が示すのは「どの種類の作用を使うか」まで。capability + Capture Checking は「何を使うか」に加えて「どこまで持ち出せるか(影響範囲)」も示す
  • 学習コスト: Eff はコードを読み書きするだけでも Freer モナド・インタープリタ・作用スタックの理解が前提になり、チーム全員に求めるにはハードルが高い。capability は「引数で受け取って使う」だけなので、ふつうの Scala の延長で読める(ただし Capture Checking 側にも ^ や捕捉集合という新しい概念はある)

一方で、Eff が持つ「計算を中断して継続ごとハンドラに渡す」という表現力は capability にはありません。あくまで守備範囲が違う道具であって、どちらかがより優れているといったものではありません。ただ「副作用の影響範囲を型で示す」という当初の課題に対しては、この軽さは魅力的です。

現状と今後(2026年8月時点)

期待が高まったところで、現実的な話をします。

  • あくまで experimental です。 公式ドキュメント自身が「highly experimental and unstable」と明言しており、次期 LTS の Scala 3.9 でも安定化の予定はありません。本番コードへの投入はまだ早い、というのが正直なところです
  • 仕様は現在進行形で大きく動いています。 scala/scala3 リポジトリを追うとその様子がよくわかります。3.8.0 で capability の階層が再編されて Capability が sealed になり、3.8.3 でルート capability が cap から any に改名され、2026年6月には reach capability(x*)という機構が丸ごと削除されました。半年前の解説記事のコードがもう動かない、というペースで変わり続けています
  • とはいえ、実用化への投資は本物です。Scala 3.8 ではキャプチャチェック済みの標準ライブラリが同梱されました(opt-in なのでデフォルトでは影響なし)。3.8.3 では capture checking を土台に、AI エージェントが生成したコードを安全に実行するための safe mode(language.experimental.safe)まで登場しています
  • 排他的アクセスを追跡してデータ競合を静的に防ぐ Separation Checking も別フラグ(language.experimental.separationChecking)で開発中です。ただしこちらは capture checking 本体よりさらに若く、未解決課題を集約したトラッキング issue が積まれている段階です
  • この一連の研究は EPFL の Caprese プロジェクトとして進められており、direct-style の非同期ライブラリ Gears も将来的な capture checking との統合を見据えて開発されています。Capture Checking 単体ではなく、Scala の今後の方向性そのものに関わる布石と見るのがよさそうです

まとめ

「副作用・リソースの影響範囲を型で示したい」という課題に対して、Scala が模索している方向を見てきました。

  • 副作用をモナドで包むのではなく、capability を引数で受け取るスタイルで表現する
  • その capability がスコープの外へ逃亡することを Capture Checking が型で防ぐ
  • 結果として、direct style のまま、リソース安全性や例外の影響範囲がシグネチャに現れる

ただし繰り返しになりますが、これは experimental であり、まだ「答え」ではありません。仕様は動き続けていますし、エコシステムの対応もこれからです。それでも、「正しいコードには何も要求せず、間違ったコードだけを弾く」という設計思想は試してみる価値があります。興味を持った方は、ぜひ最新版の Scala で import language.experimental.captureChecking を書いてみてください。

参考リンク

We are hiring!

エムスリーでは、言語や型システムの進化を追いかけながら、プロダクト開発に活かしていくエンジニアを募集しています。カジュアル面談からでも大歓迎です!