Prism: 型付きエフェクトを備えた非純粋関数型言語
(stephendiehl.com)- Prismは、可変変数・例外・ストリームのようなエフェクトを隠さず型に表しつつ、外部から観測できない局所的な変更は
Int -> Intのような純粋な型のまま保つ実験的コンパイラ - 中核は 代数的エフェクトハンドラ と row polymorphism で、エフェクトは関数型の row に統合され、ハンドラは必要なラベルだけを処理して残りを渡す
- 同じエフェクトシステムで例外、ジェネレータ/ストリーム、レンズ、
var状態、fail/guardフローを表現でき、一部の経路は中間リストやランタイム合成なしに lower される - 性能面では evidence passing と Perceus 参照カウントを組み合わせ、エフェクト呼び出しごとの割り当てを避け、固有所有値の関数型更新をインプレース変更に最適化する
- Prism は LLVM IR、MLIR、C ランタイム、Rust インタプリタ、Lean 4 モデル、WASM playground を提供し、型推論と lowering の結果を直接確認できる
外部から見えない変更と型付きエフェクト
- Prism の出発点は、内部で
varと代入を使うfib関数でも、外部の観測者には純粋関数のように見えるという点にある- 例の
fibは 2 つの変数をインプレース更新するが、関数の外へ状態を露出しない - 型は
Int -> Intで、エフェクトは型に現れない
- 例の
- Prism は過去 3 年にわたって開発された 概念実証の関数型コンパイラ で、OCaml 5、Haskell、Koka 系の現代的な型のアイデアを土台にエフェクトをモデル化している
- 中心的な方向性は、エフェクトを避けることではなく、エフェクトを型システムに入れ、コンパイラがそのコストを消すよう最適化することにある
エフェクトはインターフェースのように振る舞う
- Prism のエフェクトは演算を宣言し、ハンドラがその演算に意味を与える 代数的エフェクトハンドラ 方式である
effect Gen { ctl yield(Int) : Unit }はyield演算を宣言するfn produce(n) : !{Gen} Unitの!{Gen}は、関数がyieldを実行することを型に示す
- 同じ producer でも continuation である
kをどう扱うかによって異なる意味に解釈されるtotalはyieldの値を足し合わせるcountはyieldの回数を数えるkを捨てれば例外、1 回呼べば状態やジェネレータ、複数回呼べば探索の動作になる
Ambエフェクトの例では、chooseとrejectでピタゴラス数探索を表現するchoose(n)は0..n-1範囲の値を提供する- ハンドラは候補ごとに同じ continuation を再開し、探索木を作る
- OCaml 5 と異なり、Prism はエフェクトを型に含め、Haskell のモナドトランスフォーマースタックのように階層を手動で積み上げる必要がない
- エフェクト row は呼び出しをまたいで構造的に統合される
- スタックではなくラベルの集合のように動作する
1 つのメカニズムで表現される機能群
-
例外
- Prism では例外は continuation を捨てるハンドラ である
final ctl節はkを破棄し、その本体の値をハンドラ結果とするResultを伝播させたり、呼び出しスタックに?を散りばめたりする方式ではない- 例外はエフェクト row のラベルなので、拡張可能な例外 として合成される
- 各失敗は個別の演算であり、関数型の row は外へ出る可能性のある例外を示す
AbortとTimeoutの例では、fetchは!{Abort, Timeout}を持つwith_defaultはTimeoutだけを処理して fallback の"cached"を返し、処理後の型には!{Abort}だけが残る- Java の checked exception と違って、クラス階層に縛られず開いた構造的集合として動作する
-
ジェネレータとストリーム
-
レンズ
- Prism のレンズは別ライブラリではなく、レコード更新パス とメモリモデルの組み合わせである
- ネストしたレコードで、1 つのパス式により深いフィールドを複数更新できる
- 例:
{ g | player.pos.x = 30, player.hp = 95, score = 110 } - ネストしたレコードの spine を作り直しつつ、値が固有所有されている場合は分解したセルを再利用する
- この構造により、関数型更新をポインタ書き込みへコンパイルできる
- ランタイムで optic 型を割り当てたり合成したりはしない
deriving (Lens)はscore_of、with_scoreのような通常の関数アクセサを生成する
-
可変状態
varは private effect のget/set演算へ desugar される- ブロック末尾に設置されたハンドラがこのエフェクトを処理する
- クロージャ脱出解析は、状態が外へ逃げる場合を拒否する
- 外側の関数は空のエフェクト row を維持できる
-
失敗
- 失敗は匿名の
Failエフェクトで表され、「この式は値を生成できないかもしれない」ということを型システムが扱う fail()は失敗を実行し、guard(cond)は条件が偽のとき失敗する??は左辺が失敗したとき fallback を使う?.は option チェーンをたどりながら短絡する- comprehension guard は失敗した要素を crash させる代わりに枝刈りする
varもハンドラ sugar なので、transactブロックは live 変数をスナップショットし、失敗時にロールバックできる
- 失敗は匿名の
現代的な型機能
- Prism は大半のコードで型注釈を書かなくて済むよう、Dunfield-Krishnaswami 系の双方向 higher-rank 型推論を使う
- 高階多相性が必要な境界では
forallで binder を明示するpick(g : forall a. (a) -> a)はgをBoolとIntの両方に適用できる- Damas-Milner core なら最初の呼び出しで
aがBoolに単一化され、2 回目の呼び出しは拒否される状況である
- アドホック多相性は Lean スタイルの型クラスで表現される
- インスタンスは名前付きの値である
given Ord(a)は辞書を要求する- 複数のインスタンスがあるときは 1 つを
canonicalとして印を付け、他はusingで明示する
derivingはEq、Ord、Show、Lensのような反復的インスタンスとフィールドアクセサを生成する- パターンマッチもクラスと結び付けられている
pattern First(n) for Peek = view peekはクラスメソッドを view として使うパターンであるhead_orはPeekインスタンスを持つ複数の型に対して同じパターンを使える
showは別クラスなしで 型指向 に動作する- コンパイラが静的型から構造的 printer を合成する
- ランタイム型タグを読んで出力方法を決めるわけではない
- エフェクト row も多相的である
fn twice(f : (Unit) -> Int ! {| e})は、引数として受け取った関数のエフェクト roweが何であっても結果を足し合わせる- 純粋な thunk では
eは空 row{}と単一化される TickやSayのようなエフェクトを実行する thunk では、その row をそのまま伝播する
エフェクトコストを下げるコンパイル方式
- 教科書的な代数的エフェクト実装では、計算を free monad 形式の木に再構成し、演算ごとに小さなセルを割り当てることがある
- Prism の高速経路は Koka 系の evidence passing である
- 計算を再構成してハンドラを探す代わりに、アクティブなハンドラ節を通常の引数のように各演算地点へ渡す
do opは直接呼び出しへ lower される- ハンドラ設置時にクロージャを 1 つ割り当てるだけなので、コストは
O(handlers)であり、演算回数には比例しない
- free monad エンコーディングは fallback として残されている
- データ構造として外へ出る計算
- 真の multishot resumption
- masked handler のようなパターンが対象である
- ストリームパイプラインでは interprocedural flow analysis により、必要な effect evidence を関数境界の向こうまで計算する
- producer と transformer に evidence をスレッディングする
- 全体のチェーンは単一ループへ lower される
- 中間リストも演算ごとのセル割り当てもない
- この方式は、Haskell の stream fusion や Rust の iterator adapter の単一化に近い結果を、エフェクトコンパイラで得る構造である
メモリモデルとランタイム
- Prism は Perceus 参照カウント を使う
- ヒープセルは静的に既知の地点で決定的に解放される
- pause も tracing もない
- frame-limited reuse は、パターンマッチでたった今分解したセルをコンストラクタ側へ再び渡す
- 固有所有されたリストに対する
mapは、新しいリストを返す純粋関数のように見えるが、実際にはインプレース変更されうる - レンズ更新がポインタ書き込みへコンパイルされるのも同じメカニズムである
- 固有所有されたリストに対する
- ランタイム構造は Lean 4 のメモリ規律に似ているが、Prism は LLVM IR を出力する
- LLVM IR は
inkwellを通じて生成される - 同じプログラムに対してテキスト MLIR モジュールも生成する
- 結果は
clangで手書きの C ランタイムprism_rt.cとリンクされる
- LLVM IR は
prism_rt.cは約 2,000 行規模の小さなランタイムである- ヒープセルは
{ refcount, tag, arity, fields... }形式の 4 word 以上の構造を持つ - allocator、
rc_inc/rc_dec、インプレース再利用 allocator、bignum と文字列 primitive を含む - collector thread、card table、safepoint はない
- ヒープセルは
- 既定の allocator は libc の
mallocで、ベンチマーク用に opt-in のmimalloc設定がある - live-cell oracle は終了時に heap balance が 0 であることを assert し、テストスイートが garbage-free 特性を確認する
Lean モデルとバックエンド検証
- Prism インタプリタは CEK machine 系であり、この machine は Lean 4 モデル
models/Prism.leanに反映されている - Lean 4 モデルは、small-step relation が決定的であることを示す 機械検証済み定理 を持つ
- プログラムは常にただ 1 つの次状態へ進む
- Rust 実装のインタプリタは differential oracle としても使われる
- corpus 内のすべてのプログラムは、インタプリタと LLVM、MLIR、C-linked binary の各バックエンドをすべて通過する
- 4 つの結果の出力が byte-identical でなければビルドは通らない
- 決定性は replayable durable execution の基盤になる
- 入力を固定して実行を記録すれば、bit-for-bit の再生が可能になるという構想である
- 将来のバージョンでは、クラッシュ後の replay 安全性を型特性として検査する Prism が描かれている
WASM playground とソース
- 同じインタプリタは WASM にコンパイルされており、playground でインストールなしに Prism コードを実行できる
- playground は worker 上でプログラムを実行する
- ボタンで推論された型注釈、effect row、lower された core IR をダンプできる
varループやストリームパイプラインが実際にどのような形に lower されるかを見られる
- 本文の例は dropdown に含まれている
- 全ソース、Lean モデル、C ランタイムは prism repository on GitHub にある
実装の系譜とプロジェクトの性格
- Prism の core IR は、Levy の Call-by-Push-Value: A Functional/Imperative Synthesis に基づく call-by-push-value 系である
- 高速なエフェクト経路は、Xie と Leijen の Generalized Evidence Passing for Effect Handlers、Effect Handlers, Evidently に近い
- メモリモデルは Perceus: Garbage Free Reference Counting with Reuse、Reference Counting with Frame-Limited Reuse、FP^2: Fully in-Place Functional Programming に基づく
- エフェクト row は row polymorphism と scoped labels 系で、ハンドラは Plotkin と Pretnar の Handlers of Algebraic Effects を Eff と Koka を経て取り入れた形である
- パターンマッチは decision tree と usefulness matrix ベースで、
pattern形式は view pattern と GHC の Pattern Synonyms の組み合わせである - 失敗階層は The Verse Calculus を
final ctlハンドラで回収した形である - Prism の方向性は、「純粋関数型」よりも エフェクトを見える化し、型付けし、合成可能に追跡すること に近い
- プロジェクト自体は実用ツールというより、おもちゃであり芸術的作業に近く、関数型プログラミングのアイデアが持つ知的な美しさのために作られたコンパイラとしてまとめられている
1件のコメント
Lobste.rs のコメント
レンズがエフェクトと何の関係があるのか分からない。記事でレンズに触れるたびに、「1つのトリックを5通りに」の下にまとめられていること以外、互いに無関係に見える。
それに
tick_use()がそもそも何をしようとしているのかも分からない。読者が説明なしでこんな入り組んだ例を理解することを期待しているのだろうか? 型注釈があれば助けになったと思う。それでも、比喩的なレベルを超えてレンズがエフェクトと何の関係があるのかはよく見えない。著者はこう書いている:
もっと時間をかけて理解する必要があるが、本当に美しく見える。
本当に印象的だ。むしろそのために、Diehl が記事の最後でコンパイラをおもちゃと呼んでいる理由が気になる。新しいレベルの優雅さを示す、成功した概念実証のように見える。
call-by-push-value の中間表現が実際にどのような形なのか、もっと詳しく見てみたい。特に合流点(join point)をどう扱うのかが気になる。
CBPV にエフェクトを付ける理論を扱った論文はいくつかあった。計算にはエフェクト型があり、値にはないと言うのはかなり自然だが、Koka の証拠渡しにそのまま適用できるほど具体化されたものは見たことがないので興味深い。
全体として Koka と比べてどういう位置づけなのか知りたい。FBIP、Perceus、証拠渡しを見ると、Koka の研究から強く影響を受けているのは明らかで、同時に中間表現に CBPV を使っているので確かに異なってもいる。ただ、どのくらい違うのかはよく見えない。
自分がずっと時間を作って作ろうとしていたものにかなり似ている。いいね!
話題から少し外れるが、Stephen の「write you a haskell」プロジェクトが数年前に止まってしまったのは、いつも少し残念に思っていた。Prism についてはチュートリアル級の実装解説が出るといいな。
この言語で何が「不純」なのか気になる。その単語はタイトルにだけ出てきて、本文では二度と出てこない。
すべてのエフェクトが追跡されるように見えるので、エフェクトのない関数は依然として数学的な関数だ。何か見落としているのだろうか?
fib定義のように、関数定義内でローカルな可変変数を使うことに関係しているように見える。var xは不純な可変変数で、let xは純粋な不変変数だ。通常は言語の機能として扱われていたもの、たとえば X 言語の
yieldや Y 言語のthrowのようなものを、もう1つのインターフェースとして実装している点が本当に素晴らしい。副作用、例外、継続のような複数の制御フローを1つの抽象化の上に構築できるというのは、設計上の問い全体を新たに捉える興味深い方法であり、さらなる探求と革新を助けたり刺激したりしてくれることを期待している。今後何年も、インスピレーションを得るためにまた見返すことになりそうだ。