如果你写过递归,多半见过这种报错:RangeError: Maximum call stack size exceeded,或者更朴素的“段错误”。很多人第一反应是“把递归改成尾递归不就好了”,但真正改完之后,在不少语言里依旧崩。这时候就该停下来问一句:尾递归到底救的是什么?它背后站着的那个更底层的概念——Continuation(续延),才是真正值得吃透的东西。
这篇文章围绕“尾递归与Continuation”这两个词展开,我会先用一段会崩的递归讲清楚调用栈和尾递归优化(TCO)的底层原理,再引出 Continuation 和 CPS(Continuation-Passing Style)这套“把剩余计算显式化”的思维方式,最后用 JavaScript 和 Scheme 的实操代码演示手动 CPS 变换、call/cc 如何实现非局部退出和生成器,并附上我在实际环境中踩过的坑。
内容密度不低,但我会尽量用生活化类比去解释。适合三类读者:被爆栈问题折磨过的普通开发者、准备面试时想彻底搞懂函数式控制流的进阶选手、以及做语言实现或框架设计时需要自定义控制流的人。
1. 先从一段会崩的递归说起
1.1 递归为什么爆栈:调用栈不是无限的
先看一段最普通的阶乘递归:
javascript复制function fact(n) {
if (n <= 1) return 1;
return n * fact(n - 1);
}
fact(100000);
在大多数 JS 引擎里,跑到 n 几千上万时就崩了。问题不在你的函数逻辑,而在于“递归调用返回后还有活要干”。
以 fact(5) 为例,执行过程是这样:
fact(5)等fact(4)的结果,然后乘 5fact(4)等fact(3)的结果,然后乘 4- ...
fact(1)返回 1,然后一层层往回乘
每一层调用在返回之前,都必须记住“我是谁、我的 n 是多少、返回地址在哪”。这些信息被压进一个叫调用栈(Call Stack)的内存结构里。栈不是无限的,每一层调用就算只占几十字节,几万层下来也会把栈挤满。
一个特别容易踩的误区:很多人觉得“栈溢出=递归太深”,其实更准确的表述是“调用返回前还有额外计算要做=当前栈帧无法释放”。同样是递归,写法不同,栈的行为完全不同,这就是下面要说的尾递归。
1.2 尾递归与TCO:优化到底优化了什么
把上面的阶乘改写成带累加参数的版本:
javascript复制function factTail(n, acc) {
if (n <= 1) return acc;
return factTail(n - 1, n * acc);
}
factTail(5, 1); // 120
现在递归调用 factTail(n - 1, n * acc) 位于函数的尾位置,意思是:这个调用的返回值会直接作为当前函数调用的返回值返回,调用结束后没有任何剩余操作。数学上可以证明,这种情况下当前栈帧不再是必需的——反正 factTail(n, acc) 能返回的结果就是 factTail(n - 1, n * acc) 能返回的结果,直接把控制权转交给新调用就行。
于是支持尾调用优化(Tail Call Optimization,简称 TCO)的语言会把这种调用编译成一条跳转指令,复用当前栈帧,而不是新压一帧。递归调用变成了“原地打转的循环”,栈空间从 O(n) 降到 O(1)。
注意,“尾递归”只是“尾调用”的一种特例。TCO 优化的是所有尾调用,不仅仅是自己调用自己。这个区别很关键,因为在 CPS 风格下,所有调用都要想办法变成尾调用。
实践中有个快速判断尾位置的口诀:
return f(x):f 的调用在尾位置return n * f(x):f 调用后面还有乘法,不是尾位置return cond ? f(x) : g(x):f 和 g 的调用都在尾位置var r = f(x); return r;:f 调用不在尾位置(只要赋值后再返回,就不是)
提示:所谓“尾位置”,是指这个表达式的结果会直接成为整个函数的结果。只要中间隔了一层赋值、运算、try-finally,引擎就不敢随便复用栈帧。
1.3 各语言与运行时对TCO的真实态度
很多人在 JavaScript 里写了尾递归后发现照样爆栈,原因很简单:规范写了,但引擎没做。这不是个别现象,各语言对 TCO 的态度差异很大。
| 语言 / 运行时 | TCO 支持情况 | 说明 |
|---|---|---|
| Scheme / Racket | 规范强制支持 | 函数式语言标准里明确要求,学习这两个概念的最佳环境 |
| Lua | 支持 | 官方保证 proper tail calls |
| JavaScript | 规范要求(严格模式),但实现不统一 | ES2015 起规范要求,但 V8 / Node 默认未完全落地,Safari 的 JavaScriptCore 支持较好 |
| Python | 不支持 | 官方为保留完整堆栈回溯信息,明确拒绝 |
| Java | 不支持 | JVM 方法调用模型没这个保证,JIT 也基本不做 |
| Kotlin / Scala | 通过编译器支持 | tailrec 修饰符和 @tailrec 注解,编译器展开成循环 |
| C / C++ | 不保证 | 编译优化开启时简单尾调用常被优化掉,但语言层面没有承诺 |
| Rust / Go | 不保证 | 依赖 LLVM 优化,Rust 在实验 become 关键字 |
| C# (.NET) | 部分场景 | JIT 会做一些尾调用优化,程序员难以精确控制 |
这张表的信息可能在版本迭代中变化,但它给我们的启示是稳定:TCO 是编译器和运行时的恩赐,不是语言语法自带的属性。 你在 Scheme 里能放心写一万层尾递归,换到 Python 和 Java 里就得老老实实换循环。遇到“尾递归还是爆栈”的问题,第一步不是改代码,而是确认你的目标运行时到底有没有做 TCO。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 真正的主角:Continuation(续延)
2.1 每个表达式背后都站着一个“剩余计算”
递归和尾递归只回答了“调用怎么返回”,但它没有触及一个更根本的问题:一个子表达式算完之后,接下来要做什么?
举个例子。表达式 (1 + 2) * (3 + 4) 在计算 1 + 2 时,程序知道“算出结果后要乘以 7,然后把这个值作为整个表达式的值返回”。这个“接下来要做的事”就是 1 + 2 的 Continuation。同理,3 + 4 的 Continuation 是“把 7 和这个结果相乘,然后返回”。
在普通直接风格(Direct Style)的代码里,Continuation 是隐式的,藏在系统调用栈里。每一层栈帧本质上就是一个 Continuation 的实现:它记录了“我算完之后该去哪个地址、该用哪些局部变量”。所以调用栈可以说是 Continuation 的一种具体实现方式。
但 Continuation 这个概念比栈帧更抽象、更通用。把它想明白之后,很多控制流问题会豁然开朗。我比较喜欢的一个类比是“存档点”:表达式求值到一半,当前这一刻剩下的所有计算被做成了存档;你可以随时读档,回到这一刻继续走。
2.2 CPS风格:把后续计算变成显式参数
如果把“接下来要做的事”显式地变成一个函数参数,一路往下传,这种写法就叫 CPS(Continuation-Passing Style,续延传递风格)。核心就一句话:函数不再返回值,而是把结果交给传入的 continuation 函数。
看最经典的 CPS 化阶乘:
javascript复制function factCps(n, k) {
if (n <= 1) return k(1);
return factCps(n - 1, function (res) {
return k(n * res);
});
}
factCps(5, function (v) {
console.log(v); // 120
});
在 factCps(n - 1, function (res) {...}) 这一行里,递归调用后面的那个匿名函数捕获了“n 是多少、k 是谁”,它就是这个递归调用的 Continuation。调用 factCps 不再“返回”结果,而是它后续的一切被包装成参数传递进去。
CPS 有个很漂亮的推论:在 CPS 代码中,每个函数调用都位于尾位置。因为 factCps 调用完之后,确实不需要再做任何事——它要么把结果交给 k,要么发起下一次调用,所有的“剩余计算”都被装进参数里了。所以 CPS 是让程序满足 TCO 条件的一种通用手段,这就是尾递归和 Continuation 的深层关系。
但这不代表在任意语言里写 CPS 就能获得栈安全。JS 引擎不做 TCO 的话,CPS 版本照样爆栈,这个坑我后面会详细说。
2.3 call/cc:当“未来”成为一等公民
CPS 是把 Continuation 当作参数显式传递,但有些语言走得更远,直接把“当前这一刻的 Continuation”包装成函数值,随时可以拿出来调用。最典型的原语是 Scheme 的 call/cc,全称 call-with-current-continuation。
scheme复制(call/cc
(lambda (k)
...))
call/cc 接受一个函数作为参数,这个函数会收到一个 k。k 是什么?就是“调用 call/cc 那一刻,整个程序剩余的全部计算”。它被封装成一个函数:
- 如果你在 lambda 体内调用
(k v),程序不会返回 lambda 的返回值,而是直接回到call/cc被调用的地方,并把v作为call/cc这个表达式的值。 - 如果你不调用 k,而是让 lambda 正常返回一个值,那这个值就是
call/cc表达式的值。
对第一次接触的人来说,k 看起来像函数,但它一旦被调用就“穿越”了,不会像普通函数那样回到调用点继续执行。这就是为什么 call/cc 被称为“把未来变成一等公民”——你握住 k,就握住了当前执行点的读档权限。
CPS 和 call/cc 的联系在于:带 call/cc 的代码可以被编译器转换成 CPS 形式,因为 call/cc 本质上是把隐式 Continuation 抓出来交给程序员。反过来,在纯 CPS 风格下写代码,你根本不需要 call/cc,因为 Continuation 本来就在参数里。
3. 实操:亲手把尾递归改成CPS,再用call/cc造控制流
3.1 JavaScript:尾递归版阶乘为什么还是爆栈
前面写过 factTail,这里在真实环境里验证一下:
javascript复制'use strict';
function factTail(n, acc) {
if (n <= 1) return acc;
return factTail(n - 1, n * acc);
}
console.log(factTail(100000, 1));
在 Safari 里可能能跑出结果,但在 Chrome 和 Node(V8)里大概率直接 RangeError: Maximum call stack size exceeded。我实测过,Node 18 跑这个用例是崩的。原因前面说过:V8 没有默认实现 ES2015 规范里的 Proper Tail Calls。
这不是你写错了,而是环境不支持。识别办法:看递归调用是否真的在尾位置,然后确认运行时是否支持 TCO。如果一个都不满足,那尾递归写法再漂亮也白搭。
3.2 手动CPS变换:让后续计算无处可藏
再说回 CPS。factCps 在 Node 里跑 factCps(100000, ...) 同样会爆栈,因为它本质上还是“函数调用函数”,没有 TCO 的引擎每一层都会压栈。那手动 CPS 的意义在哪?
意义在于:它把“接下来要做的事”从隐式的系统栈里搬到了显式的闭包参数里。虽然没有解决栈问题,但控制流变得完全可见,你可以在这个基础上做各种之前做不了的事——比如把闭包存进数组、传给另一个线程、或者在合适的时机调用它。CPS 是很多编译器中间表示的基础,也是理解回调风格异步的钥匙。
想在 JS 里真正避免栈溢出,常见做法是“蹦床”(trampoline):让递归函数每次返回一个 thunk(匿名函数),然后外层用循环驱动 thunk。
javascript复制function factTramp(n, acc) {
if (n <= 1) return acc;
return function () {
return factTramp(n - 1, n * acc);
};
}
function trampoline(f) {
let result = f;
while (typeof result === 'function') {
result = result();
}
return result;
}
trampoline(() => factTramp(100000, 1));
这种方法绕过了引擎不实现 TCO 的限制,代价是手动控制每一次“下一步”。这个思路和 CPS 一脉相承:你不再依赖语言运行时帮你管理 Continuation,而是自己在堆上管理。
3.3 Scheme:用call/cc实现非局部退出
想在真实代码里体验 Continuation 的力量,最好换到 Scheme。下面用 call/cc 实现“在列表里找第一个满足条件的元素,找到立刻返回,不再继续遍历”:
scheme复制(define (find-first p lst)
(call/cc
(lambda (exit)
(for-each
(lambda (x)
(when (p x)
(exit x))) ; 找到后直接退出整个 for-each
lst)
#f)))
(find-first even? '(1 3 5 6 7 9))
; => 6
这段代码让 exit 成为了一个可以从 lambda 内部“远距离传送”的 Continuation。exit x 一执行,不管 for-each 嵌套了多少层,控制流直接回到 call/cc 被调用的地方,整个表达式的值是 x。
不用 call/cc 的话,你得写循环、维护标志位、检查返回值、预先 break,代码会被条件判断塞满。call/cc 把“提前终止”抽象成了语言原语,这也是它最清楚的应用:非局部退出。
3.4 用call/cc实现生成器:保存与恢复“下一个瞬间”
非局部退出只是开胃菜。call/cc 更惊人的能力是实现生成器(generator)——每次调用取一个值,yield 后挂起,下次从挂起点继续。
核心思路是用两个 Continuation 互相切换:
kor:外部调用方(next)的 Continuation,yield 跳过去kgen:生成器内部的 Continuation,next 跳回来
scheme复制(define (make-generator producer)
(define kor #f) ; 外部调用方的 continuation
(define kgen #f) ; 生成器内部的 continuation
(define running #t)
(define (yield v)
(call/cc
(lambda (k)
(set! kgen k) ; 保存生成器的恢复点
(kor v)))) ; 跳回外部,交付 v
(define (producer-wrapper)
(producer yield)
(set! running #f)
(kor 'done))
(define (next)
(call/cc
(lambda (k)
(set! kor k) ; 保存调用者的恢复点
(if kgen
(let ((g kgen))
(set! kgen #f)
(g #f)) ; 恢复生成器执行
(producer-wrapper)))))
(lambda ()
(if (not running)
(error "generator done")
(next))))
(define gen
(make-generator
(lambda (yield)
(yield 1)
(yield 2)
(yield 3))))
(gen) ; 1
(gen) ; 2
(gen) ; 3
我第一次认真读这种代码时头皮发麻,因为它彻底违背了“函数调用必须返回”的直觉。拆开看其实很机械:yield 里 call/cc 捕获了生成器的“未来”,存到 kgen,然后跳到外部;下一次 next 调用时,外部捕获自己的“未来”,存到 kor,再跳回 kgen。两把钥匙来回交接,就实现了“挂起-恢复”。
多数现代语言里的 yield / await / 协程,底层都干着类似的事:把当前执行状态打包成 Continuation,等条件满足后再恢复。区别只是运行时帮你管理,还是你手动用 call/cc 拼装。
3.5 CPS在异步流程里的现身
很多用 Node.js 写过东西的人其实早就接触过 CPS 了,只是不知道这个名字。异步回调就是典型:你让 IO 操作结束后调用一个回调函数,而不是返回结果。
javascript复制const fs = require('fs');
function readJsonCps(file, k) {
fs.readFile(file, 'utf8', function (err, data) {
if (err) return k(err);
try {
k(null, JSON.parse(data));
} catch (e) {
k(e);
}
});
}
readJsonCps('./config.json', function (err, data) {
if (err) {
console.error('读文件或解析失败:', err.message);
return;
}
console.log(data);
});
回调风格就是 CPS:每个函数多一个 continuation 参数,结果往 continuation 里传。Promise 和 async/await 更像是一种“运行时帮你管理 Continuation”的封装——await 会暂停当前函数,把后续代码保存成一个 Continuation,等异步结果返回后恢复执行。
所以别看尾递归、Continuation、CPS、call/cc、生成器、async/await 这些名词天差地别,底层逻辑是相通的:你把“接下来要做的事”看作一个可以传递、保存、调用的值,控制流就掌握在你手里了。
4. 常见问题与排查技巧实录
4.1 尾递归优化不生效,可以怎么绕过
我在实际环境里遇到最多的场景是:代码从函数式语言迁移到 JS/Java/Python,尾递归写法保留下来了,结果一压测就爆栈。排查顺序建议是先确认三点:
- 运行时是否支持 TCO:JS 看引擎是否实现 proper tail calls,Java 默认没有,Python 没有
- 是否为严格模式:即便在 Safari 里,ES2015 的 TCO 也只对严格模式代码生效
- 递归调用是否真的在尾位置:检查有没有 finally、debugger、赋值后再返回等干扰
如果运行时不支持,常见处理办法除了前面说的蹦床,还可以用编译器自动展开。Kotlin 的 tailrec 和 Scala 的 @tailrec 就是编译器帮你把尾递归翻译成迭代循环:
kotlin复制tailrec fun fact(n: Int, acc: Int = 1): Int =
if (n <= 1) acc else fact(n - 1, n * acc)
在 Kotlin 里加 tailrec 之后,编译器会报错提示“该函数不是尾递归”,这反而是个检查尾位置的好用工具。
4.2 CPS的栈换到堆上:不是免费午餐
网上有些文章给人“CPS 可以解决爆栈”的错觉。准确说法是:CPS 把对系统调用栈的依赖转成了对堆内存里闭包对象的依赖。没有 TCO 的运行时里,CPS 照样爆栈;有 TCO 的运行时里,CPS 确实能跑深,但代价是大量闭包的创建和 GC 压力。
一个百万次递归的 CPS 版本可能生成上百万个匿名函数对象,每个闭包都捕获 n 和 k。这些对象在跑完之前无法被回收,内存占用从“栈上几个字节”变成“堆上几十上百字节”。所以实际工程中,能用循环解决的问题,没必要为了秀 CPS 硬上。CPS 的价值主要在语言实现、编译器、复杂控制流框架这种场景。
4.3 调试CPS与call/cc代码的实用技巧
CPS 代码的堆栈信息很难看,因为堆栈里全是匿名函数,看不出业务语义。几个我用下来有效的技巧:
- 给匿名函数命名。
factCps(n - 1, function step(n, res) { ... }),至少在报错时能看到函数名。 - 加阶段日志。在关键 Continuation 入口加
console.log('stage=xxx, value=yyy'),把“推进到哪一步”打出来。 - Scheme 里用 trace 调试 call/cc 时,先在关键分支打断点或 display 当前值。因为 call/cc 会穿越函数边界,单步调试往往直接飘到意想不到的地方,不如老老实实打印。
- 把 CPS 代码切分成多个有名字的小函数,不要全写成嵌套匿名闭包。可读性会好很多。
4.4 工程化选型:尾递归、循环、CPS、生成器怎么挑
最后说点我在团队里做方案选型的个人倾向:
- 语言支持 TCO(Scheme、Lua、Kotlin tailrec)时,尾递归是优雅且栈安全的选择;不支持时,优先用循环或迭代器。
- 写库或框架,需要让用户可以“提前退出复杂遍历”时,用生成器或显式信号量,
