
本記事は、コンシューマーチームブログリレー 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 で実際にコンパイルして確認したものです。
- はじめに
- 課題: 影響範囲が型に現れない
- Scala が模索する方向: capability + Capture Checking
- 使ってみる
- 現状と今後(2026年8月時点)
- まとめ
- 参考リンク
- We are hiring!
課題: 影響範囲が型に現れない
まず、解決したい問題を具体的なコードで見てみます。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 を書いてみてください。
参考リンク
- 公式リファレンス: Capture Checking
- Scala 3.8 リリースノート
- tanishiking blog: Introduction to Scala 3's Capture Checking and Separation Checking
We are hiring!
エムスリーでは、言語や型システムの進化を追いかけながら、プロダクト開発に活かしていくエンジニアを募集しています。カジュアル面談からでも大歓迎です!