末尾呼び出し最適化と継続(CPS)
末尾再帰がなぜスタックを食わずに済むのか、その原理を一段深く理解できる。継続(CPS)まで知れば、例外も非同期も同じ仕組みの応用だと見通せます。
- 1.末尾呼び出しとは、関数の最後の動作が別の呼び出しそのものである形。戻った後にやることが無いので、処理系は現在のスタックフレームを再利用でき、深さがO(1)に保たれる。
- 2.末尾位置の判定は構文的に決まる。return f(x)は末尾だが、return f(x)+1やtry内の呼び出しは末尾ではない。
- 3.CPS(継続渡しスタイル)は「次にやること」を関数として明示的に引数で渡す変換。全呼び出しが末尾呼び出しになり、例外・ジェネレータ・非同期といった制御の飛びを一般化できる。
末尾呼び出しとは「戻り先で何もしない」呼び出し
再帰で触れたとおり、関数を呼ぶたびにコールスタックには戻り先や局所変数を保持するスタックフレームが積まれます。普通はこのフレームが呼び出し連鎖のあいだ生き続けるため、深い再帰はスタックを食いつぶします。
ところが、ある呼び出しが関数の末尾位置(tail position)にある場合、事情が変わります。末尾位置とは「その呼び出しが返した値を、呼び出し元がそのまま自分の戻り値として返すだけ」の位置です。つまり呼び出しから戻ってきた後に、加工も後処理も一切残っていない。
function f(n) {
return g(n); // 末尾呼び出し:g の結果をそのまま返すだけ
}
function h(n) {
return g(n) + 1; // 末尾ではない:戻った後に +1 が残る
}
fではg(n)が戻った瞬間、fにはもう仕事がありません。であればfのスタックフレームはもはや不要です。gが最終的に返す値は、fを飛び越してfの呼び出し元へ直接返ってよいのです。
スタックフレーム再利用の原理
末尾呼び出し最適化(TCO / 末尾呼び出し除去)は、この「戻り先で何もしない」性質を使い、新しいフレームを積まずに現在のフレームを上書き再利用します。通常の呼び出しとの違いはコード生成のレベルで見ると明快です。
| 観点 | 通常の呼び出し (call) | 末尾呼び出し最適化 (jump) |
|---|---|---|
| スタック操作 | 戻りアドレスを積み、新フレームを確保 | 現フレームを引数で上書きし再利用 |
| 戻りアドレス | 呼び出し元に戻るため保存する | 呼び出し元の戻り先をそのまま引き継ぐ |
| スタック深さ | 1段深くなる | 増えない(O(1)を維持) |
| 実体 | サブルーチン呼び出し | 実質ジャンプ(goto相当) |
要は、末尾呼び出しは機械語レベルではcallではなくジャンプへ落ちます。戻りアドレスを積まないので、呼び出された関数がreturnするときは「今の関数の呼び出し元」へ直接戻る。結果として、何段呼び出しを連ねてもスタックは深くならず、末尾再帰はループと同じO(1)空間で回ります。
// 末尾再帰版の階乗:累算器 acc に途中結果を持ち回る
function fact(n, acc = 1) {
if (n === 0) return acc; // ベースケース
return fact(n - 1, acc * n); // 末尾位置:戻った後に掛け算が残らない
}
最初の素朴なreturn n * fact(n - 1)は、戻った後にn *が残るので末尾ではありません。累算器accに途中結果を退避することで、掛け算を呼び出しの前に済ませ、末尾位置を作り出しているのがポイントです。
評価順序がカギです。fact(n - 1, acc * n)では、引数acc * nが呼び出しより先に計算され、確定した値だけが次のフレームへ渡されます。だから戻った後に保留中の計算が無く、現フレームを捨ててよい。一方n * fact(...)は、factの結果が出てから掛ける必要があるため、nを保持するフレームを残さざるを得ません。この違いが空間計算量をO(n)とO(1)に分けます。
末尾位置の判定は構文で決まる
「末尾かどうか」は実行時ではなく、構文的に静的に決まります。判定基準は「その式の値が、関数の戻り値として何も足されずに使われるか」です。代表的なパターンを整理します。
| 文脈 | 末尾位置か | 理由 |
|---|---|---|
| return f(x) | 末尾 | 結果をそのまま返す |
| return f(x) + 1 | 末尾でない | 戻った後に加算が残る |
| if 条件 return f(x) else return g(x) | 両方とも末尾 | 各分岐の結果がそのまま戻る |
| return cond ? f(x) : g(x) | f,g とも末尾 | 三項の各枝が戻り値になる |
| return a && f(x) | f は末尾 | 短絡の最終値が戻る |
| try { return f(x) } ... | 末尾でない | 例外捕捉のため枠を残す必要がある |
| x = f(); return x | 末尾でない | 代入を挟むと最後の動作が呼び出しでない |
重要なのは、try/catchの内側にある呼び出しは末尾になり得ないことです。万一例外が飛んだら捕まえねばならず、そのためのハンドラ枠をスタックに残す義務があるからです。同様に、finallyを持つtry、ループ内で結果を集計する処理なども末尾位置を壊します。制御構文が末尾性に直結している好例です。
試験・面接では「呼び出しの結果に対して、それ以上の演算・代入・捕捉が無いか」で判定すると速いです。具体的には(1)算術や文字列連結を足していない、(2)結果を変数に束縛して後で使っていない、(3)例外ハンドラやfinallyに囲まれていない、の3点を満たせば末尾呼び出しです。
TCOは「言語仕様の保証」かどうかが分かれる
末尾呼び出しを最適化するかどうかは、言語と処理系の方針に強く依存します。ここを誤解すると本番でスタックオーバーフローを踏みます。
| 言語 | TCOの扱い |
|---|---|
| Scheme | 言語仕様で「適切な末尾再帰」を保証(必須) |
| Lua | 仕様で proper tail call を保証 |
| JavaScript | ES2015仕様にはあるが、主要エンジンは実質未実装(Safariのみ一部) |
| Python | 設計方針として採用しない(スタックトレース保持を優先) |
| Scala / Kotlin | 明示注釈(@tailrec / tailrec)で末尾再帰のみコンパイラが保証 |
| C/C++ | 規格の保証は無いが、最適化が効くと sibling call として除去されることが多い |
末尾再帰の形に書けても、その処理系がTCOを保証しなければ普通にスタックを積み、深ければクラッシュします。JavaScriptやPythonでは末尾再帰でも安全になりません。深さが入力サイズに比例して青天井になるなら、ループや明示的スタック(配列)への書き換えが確実です。SchemeやScalaの@tailrecのように「保証される」と明記された環境でのみ、末尾再帰を反復の代替として信頼できます。
Pythonが採用しない理由は技術的不能ではなく設計判断です。フレームを使い回すと、エラー時のスタックトレースから途中の呼び出し履歴が消え、デバッグ性が落ちる。トレースの可読性を空間効率より優先した、という背景があります。
継続(CPS):すべてを末尾呼び出しに変える
ここから一段抽象を上げます。継続(continuation)とは「今の計算が終わった後にやるべき残りの計算全体」を指す概念です。return g(x) + 1なら、g(x)の継続は「その結果に1を足して、関数から返す」という処理です。普通この継続はスタックに暗黙に積まれていますが、これを関数として明示的に引数で持ち回る書き方が**継続渡しスタイル(CPS / Continuation-Passing Style)**です。
// 直接スタイル(結果を return で返す)
function add(a, b) { return a + b; }
function square(x) { return x * x; }
const result = square(add(1, 2)); // 9
// CPS:戻り値を返さず、「次にやること」k を呼ぶ
function addK(a, b, k) { k(a + b); }
function squareK(x, k) { k(x * x); }
addK(1, 2, sum => // sum = 3
squareK(sum, sq => // sq = 9
console.log(sq))); // ここが最終の継続
CPSでは関数は決して値を返しません。代わりに、計算結果を継続kに渡して呼ぶ。注目すべきは、addKの本体k(a + b)も、squareKの本体k(x * x)も、すべてが末尾呼び出しになっている点です。CPS変換を施すと、プログラム中のあらゆる呼び出しが末尾位置に移るため、TCOが効く処理系ではスタックを一切伸ばさずに任意の計算を実行できます。これがコンパイラ内部でCPSが重宝される理由の一つです。
明示化した継続kは、囲む変数(上の例のsumなど)を閉じ込めたクロージャそのものです。直接スタイルではスタックフレームが担っていた「中断地点と局所変数」を、CPSはヒープ上のクロージャへ移します。この「実行の続きをヒープに退避する」発想は、async/awaitの状態機械化とまったく同じ構図です。
CPSが一般化する制御フロー
継続を第一級の値として手に持てると、通常は言語機能として組み込まれている制御の飛びを、自前で構成できるようになります。CPSが「制御フローの一般化」と呼ばれるゆえんです。
- 早期脱出・例外:継続を呼ばずに別の継続を呼ぶことは、途中の残り計算を捨てて飛ぶこと、すなわち例外の送出や非局所脱出に相当します。成功継続と失敗継続の2本を渡せば、
try/catchを関数だけで表現できます。 - 非同期:I/O完了後の処理を継続
kとして渡し、完了時に呼んでもらえば、それはコールバックそのものです。awaitは本質的に「中断点で継続を登録し、完了したら再開する」ので、CPSの構文糖と見なせます。 - ジェネレータ・コルーチン:継続を保存しておき後で再呼び出しすれば、中断と再開(yield/resume)が実現します。
// 成功 ok と失敗 err の2継続で、例外なしに分岐を表現する
divK(a, b, ok, err):
if b == 0:
err("division by zero") // 残りの計算(ok)を捨てて err へ飛ぶ
else:
ok(a / b) // 通常の継続へ
原理上、CPSはあらゆる制御構造を呼び出しだけで表現できる極めて一般的な道具です。しかし全コードを手でCPS変換すると、入れ子のコールバックが深くなり可読性は崩壊します(いわゆるコールバック地獄)。実務でCPSが姿を見せるのは、コンパイラの中間表現や、async/await・例外・ジェネレータといった言語側が裏で行う変換としてです。私たちは直接スタイルで書き、処理系がCPS相当へ落としてくれる、という分業になっています。
まとめ
末尾呼び出し最適化の核心は「呼び出しが末尾位置にあるなら、戻った後にやることが無いので現在のスタックフレームを再利用でき、深さがO(1)に保たれる」という一点です。末尾位置は構文的に静的判定でき、算術の残り・代入・例外ハンドラがあると壊れます。ただしTCOが保証されるかは言語仕様しだいで、JavaScriptやPythonでは末尾再帰でも安全にはなりません。
そして継続(CPS)は、この「戻った後にやること」を関数として明示化し、すべての呼び出しを末尾化する変換です。例外・非同期・ジェネレータといった制御の飛びを単一の枠組みで説明でき、ラムダ計算を土台に処理系の内部で広く使われています。直接スタイルで書くコードの裏に、こうした継続の機械が動いていると見えると、非同期やコルーチンの挙動まで一本の線で理解できます。
プログラミング Article
末尾呼び出し最適化と継続(CPS)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
末尾呼び出し最適化
比較で見る軸
難易度: advanced / カテゴリ: プログラミング / タグ数: 5
導入後に効く点
末尾位置の判定は構文的に決まる。return f(x)は末尾だが、return f(x)+1やtry内の呼び出しは末尾ではない。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- プログラミング
- タグ数
- 5
判断チェックリスト
- 自社の用途が「末尾呼び出し最適化 / 継続」に近いか確認する。
- 強みである「末尾呼び出しとは、関数の最後の動作が別の呼び出しそのものである形。戻った後にやることが無いので、処理系は現在のスタックフレームを再利用でき、深さがO(1)に保たれる。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。