Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Python にも multi-shot な継続が欲しい! ~代数的エフェクトを添えて~

Avatar for hnmr hnmr
August 29, 2026

Python にも multi-shot な継続が欲しい! ~代数的エフェクトを添えて~

2026年8月29日(土)
オープンデベロッパーズカンファレンス(ODC)2026
ライトニングトーク資料
https://event.ospn.jp/odc2026/session/day1

Avatar for hnmr

hnmr

August 29, 2026

Other Decks in Programming

Transcript

  1. 今日話すこと aleff: Algebraic effects for Python こういうのができます https://github.com/hnmr293/aleff from aleff

    import effect, Effect, Resume, create_handler CPython に multi-shot な(限定)継続を実装しました # Define effects read: Effect[[], str] = effect("read") write: Effect[[str], int] = effect("write") それを使って、yield も await も無い、かつ継続を 複数回呼び出せる、本物の Algebraic Effects and Handlers を 実装しました CPython で継続をライブラリ実装する話をします 今日話さないこと ・継続とは何か ・Algebraic Effects and Handlers とは何か # Business logic using effects def run(): s = read() return write(s) # Handler implementations h = create_handler(read, write) @h.on(read) def _read(k: Resume[str, int]): return k("file contents") @h.on(write) def _write(k: Resume[int, int], contents: str): return k(len(contents)) result = h(run) print(result) # 13
  2. CPython のスタックフレームの中身 フレームの実体は _PyInterpreterFrame で、スレッドローカルの PyThreadState が所有している。 フレームは data stack(_PyStackChunk

    の連結リスト)上に連結リストとして積まれる。 _PyStackChunk (16 KiB) PyThreadState low _PyInterpreterFrame main datastack_chunk f datastack_limit datastack_top g current_frame 空き def g(): ... f_code コードオブジェクト previous 呼び出し元フレーム (Generator 等で必要) f_globals ほか 名前空間・関数オブジェクト ★ frame_obj PyFrameObject(遅延生成) ★ prev_instr 実行位置(バイトコード) ★ stacktop 評価スタックの先頭 return_offset 戻り先の相対位置 ★ owner このフレームの持ち主 ★ localsplus[] ローカル+セル+評価スタック previous ★ def f(): g() def main(): f() CPython 3.12 owner = THREAD / GENERATOR / FRAME_OBJECT / CSTACK ★ コピー時に書き換えるメンバ(後述) high
  3. スタックフレームの差し替え PyThreadState のリンク先を繋ぎ変えることでスタックフレームの入れ替えが可能 (greenlet)。 ただしあくまで python のフレーム。呼び出しが完了すると pop されて消えてしまい、使い回しできない。 PyThreadState

    datastack_chunk datastack_limit _PyStackChunk (16 KiB) datastack_top current_frame low before _PyStackChunk (16 KiB) low main h f 空き import greenlet as gl g gr = gl.greenlet(h) 空き def h(): ... def g(): gr.switch() def f(): g() greenlet が別に high 確保したスタック high ポインタを繋ぎ変える def main(): f() ※greenlet は python のフレームだけでなく C スタックも退避している
  4. 限定継続の実装 スタックフレームを別ヒープに保存しておいて、継続が起動されたら そのスタックフレームを新しい _PyStackChunk 用にコピーすれば何回でも使える! _PyStackChunk 新しい _PyStackChunk 別ヒープ low

    low f f f g 1. 保存 g 空き g 2. 復元 空き memcpy to malloc+memcpy datastack_top 何度でも使い回せる high 外側のフレームから順に積み直し、_PyInterpreterFrame のメンバを適当に修正 (previous を張り直したり) する。 eval は previous を entry_frame で上書きするため、1フレームずつ呼んで戻り値を外側へ渡していく。 ※ async や Generator は、現在の _PyInterpreterFrame を pop して別の構造体に保存する。フレームを別の場所に保存しておくという処理は 継続の実装に一部重複している。python の one-shot continuation やそれによる algebraic effects 実装でよく yield / yield from が使われるのは そういう事情がある。 を1フレームずつ呼ぶ (もろもろの事情により) 捕まえた時点のコピー high 3. 再開 _PyEval_EvalFrameDefaul t
  5. memcpy のあとに直すもの アドレスに依存するメンバと命令ポインタ。間違えるとSEGV。パッチバージョン単位で変わるので注意。 _PyInterpreterFrame previous コピー後に書き換え コピー先のアドレスで張り直す CALL の次の命令へ進める(右図) stacktop

    戻り値を push した位置へ frame_obj NULL(PyFrameObject は共有しない) localsplus[] f_globals ほか co_code CALL instr_ptr owner 命令ポインタをどこへ進めるか FRAME_OWNED_BY_THREAD 有効スロットを INCREF、残りは NULL borrowed を strong に昇格 3.12 は実行中フレームの stacktop が −1。有効スロットは co_nlocalsplus まで 3.14+ は stackpointer を持つので localsplus 基準で再計算 CACHE CACHE CACHE 保存時はここ next 復元後はここ 3.12 prev_instr は直前の命令 → += 4 - 1 3.13+ instr_ptr は次の命令 → += 4 CALL + CACHE ×3 = 4 codeunit。3.12 と 3.13 以降で指す位置が1命令ずれる 特殊化された CALL_PY_EXACT_ARGS などは deopt 表で素の CALL に戻して判定 CALL 以外(インライン dispatch 済み)なら命令ポインタは進めない 戻り値は stacktop を co_nlocalsplus に戻してから push する
  6. 使用例:Algebraic Effects and Handlers aleff: CPython 向け代数的エフェクトライブラリ https://github.com/hnmr293/aleff <特徴> yield,

    await などのキーワード不要(普通の関数として書ける) choose = effect("choose") h = create_handler(choose) 継続を複数回呼び出せる(今日の内容) ジェネリックに型付けされたエフェクト、継続、ハンドラ @h.on(choose) def _(k, *values): return sum((k(v) for v in values), []) async 対応 dynamic-wind あり CPython 3.12, 3.13, 3.14, 3.14t 対応 (OS: Ubuntu/macOS/Windows) h(lambda: [ choose("A","B") + choose("C","D") ]) # ['AC', 'AD', 'BC', 'BD'] 依存性注入 amb 確率分布 <制約> 間に未知の C フレームが挟まっていると何が起こるか分からない (python 標準の関数は気合で対応済み) CPython のパッチバージョン違いで動かないことがあるかも 継続手続きがエスケープすると適切に動作しないので、 エスケープした継続を呼ぶと例外を出すようにしている 逆モード自動微分 <他言語の事情> ※Scheme, Flix など first-class の継続がある言語では エスケープした継続も適切に動作する。