継続とコルーチンの理論(call/ccと一級継続)
ジェネレータも例外も非同期も「実行の続き」を捕まえて呼び直す同じ仕組みだと見通せる。call/ccと限定継続を原理から押さえ、コルーチンの正体まで一本で理解できます。
- 1.継続とは「ある時点でプログラムが終わった後にやる残りの計算全体」。これを第一級の値として捕捉・保存・再呼び出しできると、制御フローを言語の外から自前で構成できる。
- 2.call/cc は現時点の継続を関数として手渡す演算子。捕まえた継続を呼ぶと、その捕捉地点へ「続きごと」処理が飛び戻る。非局所脱出・例外・再開はすべてこの一般化。
- 3.限定継続(shift/reset)は継続を区切りまでに限定して切り出す。区切られた継続は値として合成・再利用でき、ジェネレータやコルーチンの yield/resume を直接表現できる。
継続とは「実行の残り全体」を指す値
プログラムのある実行点に立ったとき、そこから先にやり残している計算のすべてを一つの関数とみなしたものが**継続(continuation)**です。1 + f(x) * 2 を評価していて、いま f(x) を呼ぶ直前だとします。このとき f(x) の継続は「返ってきた値を 2 倍し、1 を足し、その結果をプログラム全体の戻り先へ渡す」という残り処理の総体です。
通常、この「続き」はコールスタックに暗黙のうちに積まれています。関数から return するとは、現在の継続を呼び出すことの言い換えにすぎません。継続の理論が面白いのは、この暗黙の続きを第一級の値として取り出せると仮定したとき、何が表現できるようになるかという点にあります。捕まえて変数に入れ、保存し、好きなタイミングで呼べる継続を**一級継続(first-class continuation)**と呼びます。
継続をコールバックと混同しがちですが、規模が違います。コールバックは「この処理が終わったら次にこれ」という局所的な一手です。対して一級継続は、捕捉地点から見たプログラム末尾までの残り計算すべてを丸ごと包みます。だから継続を呼ぶと、その地点の文脈ごと実行が再構成される。これが非局所脱出や再開を可能にする源泉です。
call/cc:現在の継続を関数として手に入れる
一級継続を言語機能として与える代表が、Scheme の call/cc(call-with-current-continuation の略称)です。call/cc は引数として関数 f を一つ取り、いまこの瞬間の継続を関数 k に包んで f に渡します。
(+ 1 (call/cc
(lambda (k)
(k 10) ; k を呼ぶと call/cc 全体が 10 を返したことになる
20))) ; k を呼んだので、ここには到達しない
;; 結果は 11
k は「call/cc の式が値を返した後の残り計算」、すなわち「その値に 1 を足す」を包んだ継続です。(k 10) を呼ぶと、call/cc 全体がその場で 10 を返したかのように振る舞い、続きが走って 11 になります。k を呼ばなければ call/cc は本体の最後の式 20 を返し、結果は 21 になります。
決定的なのは、k を呼ぶと制御が call/cc の捕捉地点へ飛び戻る点です。普通の関数呼び出しが「呼んで、戻ってくる」のに対し、継続の呼び出しは「呼んで、二度と戻らない」。k を呼んだ後の 20 に到達しないのはこのためです。継続を呼ぶ行為は return でも例外でもなく、実行コンテキストそのものの差し替えだと捉えると正確です。
call/cc で取り出した k は、call/cc を抜けた後でも値として保持できます。後で k を呼べば、すでに抜けたはずの捕捉地点へ実行が「巻き戻って」再開します。これは捕捉時点の文脈をクロージャのように閉じ込めているからで、スタックが暗黙に担っていた「中断地点と局所変数」を、ヒープ上の値へ持ち出していることを意味します。
脱出・例外・再開を一つの原理で説明する
一級継続が強力なのは、言語に組み込みの制御構文として現れる制御の飛びを、すべて「捕まえた継続を呼ぶ/呼ばない」で説明できるからです。
| 制御構造 | 継続による解釈 | 継続を呼ぶ回数 |
|---|---|---|
| 早期 return / break | 脱出先の継続を一度だけ呼ぶ | 1回(呼んだら戻らない) |
| 例外 throw/catch | catch 地点の継続へ飛び、残り計算を捨てる | 1回 |
| ジェネレータの yield | 呼び出し側の継続を呼び、自分の継続を保存 | 交互に複数回 |
| コルーチンの resume | 保存しておいた相手の継続を呼ぶ | 複数回 |
| バックトラック探索 | 分岐点の継続を後で何度も呼び直す | 同じ継続を複数回 |
下に向かう一方向の飛び(脱出・例外)は、捕まえた継続を一度だけ呼ぶ使い方に対応します。これは脱出先より外側へ飛ぶだけなので、多くの言語が return・break・例外として直接備えています。継続の真価が出るのは、同じ継続を何度も呼ぶ使い方です。探索木の分岐点で取った継続を保存し、行き止まりに当たるたびに呼び直せば、バックトラックが言語機能なしで書けます。捕まえた継続を「再開可能なチェックポイント」として何度でも復元できる点が、return や例外処理との根本的な違いです。
;; 成功継続 k で「分岐の選択肢」を後から呼び直す擬似コード
choose(options, k):
for opt in options:
save k together with opt as a resumable point ; 後で復元できるよう退避
k(first option) ; まず先頭で続きを走らせる
;; 行き止まりに来たら、退避した次の選択肢の地点へ k を呼び直す
なぜ非限定の call/cc は扱いにくいのか
call/cc が捕まえる継続は「プログラム末尾までの残り全部」です。これを**非限定継続(undelimited continuation)**と呼びます。範囲が無制限であることは、理論上は最も一般的ですが、実用上は二つの難点を生みます。
第一に、継続が値を返さないことです。捕まえた k を呼ぶとプログラム末尾まで一気に走ってしまうため、k を「途中まで計算して値を返す部品」として合成できません。第二に、副作用との相互作用が直感に反することです。同じ継続を複数回呼べば、その間の入出力や状態変更が再実行されかねません。
継続を任意の時点で復元できるようにするには、捕捉時点のスタック状態を保存しておく必要があります。素朴な実装では、捕捉のたびにスタック全体をヒープへコピーすることになり、コストが大きい。だからフル機能の call/cc を備える言語は Scheme など一部に限られ、多くの言語は必要な制御構造(例外・ジェネレータ・async)だけを直接提供する道を選んでいます。継続は「すべてを表現できるが、すべてに使うには重い」道具です。
限定継続:区切りまでの「続き」を値として切り出す
そこで実用上の主役となるのが**限定継続(delimited continuation / 部分継続)です。プログラム末尾までではなく、あらかじめ置いた区切り(prompt)**までの残り計算だけを切り出します。代表的な演算子が reset(区切りを置く)と shift(区切りまでの継続を捕まえる)の対です。
;; reset が区切り、shift が「reset までの継続 k」を捕まえる
reset:
1 + shift(k => k(k(5))) ; k は「受け取った値に 1 を足す」だけ(reset まで)
;; k(5) = 6、k(6) = 7 → reset 全体は 7
ここで k は「与えられた値に 1 を足して reset まで返す」関数です。継続が reset で区切られているため、k は普通の関数のように値を返し、k(k(5)) のように合成できます。これが非限定継続との決定的な差です。限定継続は「途中まで計算し、値を返す再利用可能な部品」になるため、return 相当の使い勝手で制御を組み立てられます。
| 観点 | 非限定継続 (call/cc) | 限定継続 (shift/reset) |
|---|---|---|
| 捕まえる範囲 | プログラム末尾までの残り全部 | 直近の区切り(reset)まで |
| 継続の戻り値 | 返らない(呼ぶと末尾まで走る) | 区切りまでで値を返す |
| 合成・再利用 | 難しい | 関数として合成できる |
| 典型用途 | 大域脱出・バックトラック | ジェネレータ・コルーチン・モナド的効果 |
限定継続とコルーチン/ジェネレータの等価性
限定継続がコルーチンやジェネレータと結びつくのは、yield が本質的に限定継続の捕捉と保存だからです。ジェネレータが yield v を実行する瞬間に起きることを継続で書き下すと、こうなります。値 v を呼び出し側へ渡しつつ、yield 以降の「自分の残り計算」を限定継続として保存し、いったん呼び出し側へ制御を返す。次に呼び出し側が next() で再開すると、保存しておいた継続が呼び戻され、yield の直後から実行が続く。
| ジェネレータの操作 | 限定継続での意味 |
|---|---|
| yield v | 区切りまでの残り計算を継続 k に保存し、v を相手へ渡して中断 |
| next(x) | 保存した k を x で呼び戻し、yield の直後から再開 |
| 関数本体の return | 区切り内の継続を終え、列の終端を通知 |
| 双方向の値受け渡し | k に渡す引数が、次の yield 式の評価値になる |
この対応は理論にとどまりません。限定継続を備える言語では、ジェネレータやコルーチンをライブラリとして実装できます。reset でコルーチン本体を囲み、yield を「shift で残りの継続を捕まえてスケジューラへ渡す」操作として定義すれば、言語コアに yield 構文が無くても協調的な中断・再開が成立します。逆に言えば、多くの言語が組み込みで持つジェネレータは、限定継続という一般理論の特殊化です。
ジェネレータは「呼び出し側へだけ戻る」非対称コルーチンで、これは単一の区切りで限定継続を捕まえる形に対応します。一方、任意の相手へ制御を渡せる対称コルーチンは、互いの継続を保存し合って呼び交わす形です。「どこまでの継続を、誰に渡すか」を区切りで制御するのが限定継続だ、と押さえると、両者の差を一つの軸で説明できます。
直接スタイルの裏で動く同じ機械
ここまでを整理すると、継続は「実行の残り全体を値として捕まえ、好きなときに呼ぶ」という単一の発想で、脱出・例外・コルーチン・バックトラックを貫いて説明します。非限定継続(call/cc)は最も一般的だが重く戻り値も持たない。限定継続(shift/reset)は区切りまでに絞ることで、継続を合成・再利用できる実用的な部品に変え、ジェネレータやコルーチンの yield/resume を直接表現します。
実務で call/cc を直に書く機会はまれですが、私たちが日々使う return・例外・yield・async/await の裏には、例外なくこの「継続を捕まえて呼び直す」機械が動いています。実際、コンパイラが継続渡しスタイルへ変換してから制御フローを実装する設計は珍しくありません。継続の理論は、CPS変換を経て、async/awaitとコルーチンの内部実装が状態機械で中断・再開を実現する仕組みと地続きです。直接スタイルで書くコードを「暗黙の継続が走っている」と読み替えられるようになると、非同期もコルーチンも一本の線で見通せます。
プログラミング Article
継続とコルーチンの理論(call/ccと一級継続)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
継続
比較で見る軸
難易度: advanced / カテゴリ: プログラミング / タグ数: 5
導入後に効く点
call/cc は現時点の継続を関数として手渡す演算子。捕まえた継続を呼ぶと、その捕捉地点へ「続きごと」処理が飛び戻る。非局所脱出・例外・再開はすべてこの一般化。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- プログラミング
- タグ数
- 5
判断チェックリスト
- 自社の用途が「継続 / call/cc」に近いか確認する。
- 強みである「継続とは「ある時点でプログラムが終わった後にやる残りの計算全体」。これを第一級の値として捕捉・保存・再呼び出しできると、制御フローを言語の外から自前で構成できる。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。