尾递归和Continuation这两个概念,我在不同阶段接触过好几次。第一次是在读《计算机程序的构造和解释》时,看到用CPS变换把递归程序改成迭代形式,当时只觉得绕,没太当回事。后来在写一个前端状态机的时候,遇到深层递归爆栈的问题,才回头认真研究尾递归。再后来做异步流程控制,发现Continuation的思想无处不在,很多看似无解的逻辑,一旦你接受“把下一步要做的动作当成一个值传来传去”,就豁然开朗了。
这篇博文,我会把这两个概念放在一起讲,因为它们本来就是同一枚硬币的两面:尾递归是对递归调用位置的一种约束,Continuation则是对“调用之后做什么”的显式化。理解了尾递归,你更容易理解Continuation的意义;理解了Continuation,你才能真正理解为什么函数式语言里很多东西看起来那么“不接地气”,却能优雅地解决并发、状态机、异常处理等问题。
这篇内容没有依赖特定框架,所有示例我都用JavaScript来写(偶尔用一点Scheme风格的伪代码做对比),因为JS里Promise、生成器、回调这些机制和Continuation的关系非常直观。不管你是写后端还是前端,只要写递归、写异步逻辑,这篇内容都有参考价值。
1. 先理清两个概念:尾递归到底是什么,Continuation又是什么
很多人把尾递归简单理解成“递归调用放在函数最后一行”,这个说法不够准确。尾递归更严格的定义是:递归调用是函数返回前执行的最后一个动作,而且这个调用的结果不需要再参与任何计算,直接作为当前函数的返回值。
js复制// 这不是尾递归,因为递归返回后还要加1
function f(n) {
if (n === 0) return 0;
return f(n - 1) + 1;
}
// 这是尾递归,递归调用的结果直接返回
function fTail(n, acc = 0) {
if (n === 0) return acc;
return fTail(n - 1, acc + 1);
}
区别在哪?第一版递归返回后还要执行一次加法,所以每一层调用都必须保留自己的栈帧,等待内层返回后继续计算。第二版当前栈帧已经没有任何后续动作要做,完全可以把栈帧直接复用给下一层调用。如果编译器或运行时支持“尾调用优化”,整个递归过程无论深度多大,栈空间始终是一个固定的常量。
Continuation这个概念,可以理解成“程序在某一个时刻剩下还没执行完的所有计算”。举个例子,你在函数A里调用函数B,当B返回时,A里“接着要做的事”就是一个continuation。大部分语言里,这个continuation被隐式地放在调用栈里,你碰不到它。而Continuation-Passing Style,也就是CPS,核心做法就是把这个隐式的continuation变成显式的参数,函数不“返回”结果,而是把结果传给continuation函数。
js复制// 普通写法
function add(a, b) {
return a + b;
}
const result = add(1, 2);
console.log(result);
// CPS写法
function addCps(a, b, cont) {
cont(a + b);
}
addCps(1, 2, result => {
console.log(result);
});
看起来就是把return换成了回调,好像没什么了不起。但请先留个印象:当所有函数都变成“不返回值,只调用continuation”的风格时,你会得到两个很有用的特性——调用顺序完全显式化,以及非局部跳转变得极其容易。这两个特性,后文我会详细展开。
尾递归和Continuation在技术上是两样东西,一个关注栈帧复用,一个关注控制流的显式化。但CPS变换有个很酷的结果:经过变换后的函数,所有的调用都变成了尾调用。也就是说,只要你用CPS风格写代码,需要递归的地方全部天然是尾递归,编译器或运行时再配合尾调用优化,就可以在固定栈空间里跑无限深度的递归。这也是很多函数式语言能把复杂的控制流做得如此干净的原因。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 尾递归的实操细节:优化原理、语言支持情况与写法要领
2.1 为什么尾递归能省栈空间,中间发生了什么
要理解尾递归优化(Tail Call Optimization,简称TCO),我们得先看普通函数调用时栈上发生了什么。每次调用函数,运行时都会创建一个栈帧,里面保存了局部变量、参数、返回地址。返回地址就是“调用完之后接着执行哪条指令”的位置。
普通递归里,内层调用返回后还有代码要执行,所以当前栈帧不能销毁,必须等到内层递归全部返回后一层一层地销毁。深度一大,栈就满了,JavaScript里会直接抛出RangeError: Maximum call stack size exceeded。
尾递归的调用点已经是当前函数的最后一步,当前栈帧里所有局部变量都不再需要了,返回地址也不需要保留(因为内层调用直接返回给当前函数的调用者)。既然当前栈帧已经是“死”的,运行时就可以直接丢弃它,把栈帧让给下一层调用。这就是TCO的本质——复用栈帧,把递归降级成循环。
注意:尾递归优化是运行时/编译器层面的优化策略,不是你写了尾递归就一定生效。JS引擎、Python解释器、Java虚拟机对TCO的支持程度各不相同,后面会细说。
2.2 各语言对尾递归优化的支持情况,别踩没优化就爆栈的坑
这是实操中最容易踩坑的地方。很多人把尾递归代码写得漂亮工整,结果一跑还是爆栈,就是没搞清楚目标运行时的支持情况。
我直接给出一张我实际测试过的对照表(基于目前主流版本):
| 语言/运行时 | 是否支持TCO | 备注 |
|---|---|---|
| JavaScript(Node.js 6+,开启严格模式) | 支持 | 只限于严格模式下的尾调用,且需要满足一定条件 |
| JavaScript(V8目前默认开启TCO) | 大部分支持 | Chrome、Node已实现,但Web兼容性取决于浏览器版本 |
| Python(CPython) | 不支持 | 解释器层面不回收栈帧,写尾递归依然会RecursionError |
| Python(PyPy) | 支持 | JIT版解释器对尾调用做了优化 |
| Java(HotSpot) | 不支持 | JVM没有通用的尾调用优化,但有-XX:CompileCommand等特定技巧 |
C/C++(GCC/Clang开启-O2) |
支持 | 优化器会把尾调用转成跳转指令 |
| Go | 不支持 | Go语言规范没有承诺TCO,实战中递归深度依然受限 |
| Rust | 支持(LLVM层) | Rust编译器基于LLVM,开启优化后可复现TCO效果 |
| Scheme(各主流实现) | 强制支持 | R6RS标准里明确要求必须支持 |
这张表要说明的问题很明确:如果你用CPython写深递归,哪怕写了尾递归也救不了你,正确做法是改成循环或借助sys.setrecursionlimit临时调高(但这只是治标不治本)。如果你写JavaScript,要意识到TCO在严格模式下才保证可用,而且尾调用优化对调用位置有严格限定。
2.3 怎么写才算是合格的尾递归:三个常见误区和正确姿势
我见过不少把“函数最后一行的调用”当成尾递归的代码。判断标准其实就一条:这个调用的返回值是否被直接返回给当前函数的调用者,中间没有经过任何运算、赋值、包装。如果被赋值给变量再返回,那不是尾调用。
js复制// 误区1:把结果赋值后再返回,不是尾调用
function wrongTail(n, acc = 0) {
const r = helper(n - 1, acc + 1);
return r;
}
// 误区2:return后面跟一元或二元运算,不是尾调用
function wrongTail2(n, acc = 0) {
return helper(n - 1, acc) + 1;
}
// 误区3:return后跟逻辑表达式,不是尾调用
function wrongTail3(n) {
return n > 0 && helper(n - 1);
}
误区1虽然代码上看起来“只返回一个变量”,但赋值操作引入了额外的计算指令,编译器无法确定当前栈帧在调用前是否可以安全释放,所以不能优化。误区2和3就更直接了,返回值都参与了计算。正确姿势是return后面直接跟调用表达式,不做任何包装。
js复制// 正确:直接返回调用
function goodTail(n, acc = 0) {
if (n === 0) return acc;
return goodTail(n - 1, acc + n);
}
还有一点容易被忽视:尾调用的位置必须是函数的尾部,但函数尾部不一定只出现一次调用。条件分支的每个分支最后都可能是不同的尾调用,这在语法上是允许的。比如上面这个例子,if的两个分支,一个return acc,一个return goodTail(...),后者就是尾调用。
2.4 如何把普通递归改写成尾递归:一个通用的“累加器”套路
普通递归改尾递归,最常用的技巧就是引入累加器参数。累加器负责保存“到目前为止已经计算好的结果”,递归调用的时候把当前结果带下去,等递归到底时直接返回累加器。
拿计算斐波那契数列来演示。普通递归,指数级复杂度,而且别想用尾递归优化跑太深:
js复制function fib(n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
}
// fib(50) 基本就跑不动了,不是栈的问题,是指数爆炸
改成尾递归,关键是把“当前值a”和“下一个值b”都作为参数传下去:
js复制function fibTail(n, a = 0, b = 1) {
if (n === 0) return a;
if (n === 1) return b;
return fibTail(n - 1, b, a + b);
}
这个写法的思路就是:第n项的结果不再往回倒推,而是从第0项开始一路累加过去。初始时a=0(第0项),b=1(第1项),每递归一层就向后推一格。这样写的好处不仅是尾递归,而且时间复杂度从指数级降到了线性级。
类似地,反转链表、遍历树的深度计算等,都可以用累加器套路改写。核心心法就一步:找出“已经到目前的结果”和“下一步要处理的数据”分别是什么,然后把它们都塞进参数里。
3. Continuation的引入:什么是CPS,如何手写CPS变换
3.1 从回调地狱说起:为什么需要显式的Continuation
聊到Continuation,很多前端开发者第一反应是“这玩意儿不就是Promise、async/await的底层原理吗?”——这话不算错,但没说到点子上。
我们可以从回调地狱开始理解CPS。下面这段代码,把三个异步操作按顺序串起来:
js复制// 回调地狱
getUser(id, user => {
getPosts(user.id, posts => {
getComments(posts[0].id, comments => {
render(comments);
});
});
});
这段代码里,getUser执行完之后“要做的事”(获取posts)被作为回调传进了getUser。这个回调,本质上就是一个continuation——它代表了程序未来的执行路径。回调地狱之所以难维护,是因为每个层级的continuation被散落在不同的回调里,代码的阅读顺序和执行顺序完全脱节,缩进越来越深,排查起来非常痛苦。
Promise和async/await的核心贡献,就是把这种”传递下一步动作“的模式从语法层面做了封装,让你能用同步代码的书写方式表达异步逻辑。但你要知道,async函数里每一个await后面的代码,本质上也都会被编译成continuation传递给Promise。
3.2 CPS的完整手写过程:普通函数如何一步步变成CPS形式
我不会只停留在理论层面。这里我直接取一个简单的计算表达式,演示从普通写法到CPS写法的完整变换过程。
原函数:计算三个数之和的三倍再减一。
js复制function calc(a, b, c) {
return 3 * (a + b + c) - 1;
}
第一步,把每个中间计算都抽成一个显式的步骤。不抽还叫CPS吗?
js复制function calc(a, b, c) {
const sum = a + b + c;
const tripled = 3 * sum;
return tripled - 1;
}
第二步,给每个显式子步骤都分配一个continuation。add之后,把结果传给下一个continuation;multiply之后,传给再下一个;最后的结果传给最终的continuation:
js复制function calcCps(a, b, c, cont) {
const sum = a + b + c;
const tripled = 3 * sum;
cont(tripled - 1);
}
这样写还是不够纯粹,因为我们没有把加法、乘法本身也改成CPS风格。完全CPS化的版本如下:
js复制function addCps(a, b, cont) {
cont(a + b);
}
function mulCps(a, b, cont) {
cont(a * b);
}
function subCps(a, b, cont) {
cont(a - b);
}
function calcCpsFull(a, b, c, cont) {
addCps(a, b, sum => {
addCps(sum, c, total => {
mulCps(3, total, tripled => {
subCps(tripled, 1, result => {
cont(result);
});
});
});
});
}
看起来复杂了不少,但观察一下就会发现:calcCpsFull内部所有调用,每一个都处在它所在回调的尾部——没有返回值参与后续计算。这就是前面说的CPS变换的副产品:所有调用都变成了尾调用。
那这个复杂有什么用?一个实际且常见的应用场景是异步流程控制。如果addCps、mulCps这三个操作是异步的(比如从数据库取值、调用外部API),上面的CPS代码和回调地狱没有本质区别。但如果配合支持尾递归优化的运行时,它就有了一个巨大的优势:无论continuation嵌套多深,调用栈都不会增长。这意味着一万个步骤的连续异步操作,在内存上不会被栈深度卡死。
3.3 从CPS到Control Flow:非局部跳转、return的替代、异常处理的统一
CPS不止能解决栈深度问题,它还能统一地表达几种普通函数做不到的控制流:非局部跳转、提前返回、异常处理、协程切换。
先看一个提前返回的例子。假设我们在一个多层嵌套的搜索逻辑里,找到目标后就立刻结束所有后续步骤(相当于break或return):
js复制function findAndProcess(data) {
for (let i = 0; i < data.length; i++) {
const item = data[i];
if (item.condition) {
return process(item); // 找到就提前返回,不再继续遍历
}
processStep(i);
}
return null;
}
改成CPS后,“提前返回”就变成了“直接调用最终continuation,跳过中间所有continuation”:
js复制function findAndProcessCps(data, done, fail) {
function loop(i) {
if (i >= data.length) {
fail(null);
return;
}
const item = data[i];
if (item.condition) {
done(process(item)); // 直接调用done,loop之后的逻辑全部不再执行
return;
}
processStep(i);
loop(i + 1);
}
loop(0);
}
这里的done和fail就是两种continuation。一旦调用done,整个loop链条就终止了,不需要额外的break标志位或异常机制。
异常处理也可以统一。普通写法里,异常靠try/catch在调用栈中逐层向上传播,而CPS写法里,你可以给函数额外传一个errCont(错误continuation),遇到异常就调用它。这样错误处理就变成了普通参数传递,错误路径的跳转逻辑是显式的,不再依赖隐式的栈回溯。
js复制function riskyOperationCps(input, succ, err) {
try {
const result = doSomething(input);
succ(result);
} catch (e) {
err(e);
}
}
这个模式在Node.js早期的“错误优先回调”风格里非常常见——第一个参数是错误对象,这就是一种显式的错误continuation。
3.4 手写生成器、协程与Continuation的关系
如果你接触过JavaScript生成器,可能会觉得生成器的行为和continuation有点像。事实上,生成器函数在执行到yield时,会保存整个执行上下文(包括局部变量和指令位置),然后暂停。下一次调用next(),就从暂停的位置继续。这个“暂停时保存的上下文”本质上就是continuation——只是被打包成了运行时对象,而不是代码里的回调函数。
为什么不直接用thunk-like的continuation?因为生成器给了你一个更友好的语法外壳:
js复制function* counter() {
let i = 0;
while (true) {
i++;
const cmd = yield i;
if (cmd === 'reset') i = 0;
}
}
const gen = counter();
console.log(gen.next().value); // 1
console.log(gen.next().value); // 2
console.log(gen.next('reset').value); // 1,注意这里从yield处恢复
这个循环之所以能在yield处暂停并恢复,就是因为运行时保存了一个额外的continuation。在协程模型里(比如Kotlin的协程、Lua的coroutine),这个机制更加明确——协程切换就是保存当前协程的continuation,然后恢复另一个协程的continuation。
理解了这层关系,你再回头看不带生成器、只用CPS手写的异步代码,就会觉得它其实是“把协程手动展开成了显式回调”。两者能力等价,只是语法糖不同。
4. 实战:用CPS驱动一个有限状态机,根治回调嵌套问题
4.1 业务场景:为什么状态机会用到CPS
我实际做过的场景是,一个图表加载流程包含多个阶段:拉取配置、校验配置、加载数据、处理数据、渲染、监听用户交互。流程中任何一步都可能失败,失败后要跳到错误处理阶段;用户还可能在任何时刻取消,取消后要安全终止整个流程。
我当时的做法是用Promise链。Promise链的问题是:取消逻辑和错误处理得塞进链的各个环节,代码一长就变成“链式回调地狱”,而且一旦某个环节需要根据条件跳转到若干步骤之后,Promise链的线性结构就得靠标志位或外部状态来帮忙,非常别扭。
后来换成了CPS风格的状态机,每个状态就是一个函数,函数执行完之后把控制权交给下一个状态的continuation。状态之间的切换用函数调用表达,状态内部不用管其他状态的细节,状态机引擎只需要负责在每条状态转移时调用对应的continuation。
4.2 实现:一个极简但完整的状态机
这个状态机不需要引入任何库,核心就是把“当前状态”抽象成一个函数state,并把输入的“下一步动作”作为参数传下去:
js复制class TinyStateMachine {
constructor(states, initial) {
this.states = states;
this.currentState = initial;
this.cancelToken = false;
}
// 进入某个状态,并传入该状态的continuation
enter(stateName, cont) {
if (this.cancelToken) {
console.log('已被取消,流程终止');
return;
}
const stateFn = this.states[stateName];
if (!stateFn) return cont('unknownState');
this.currentState = stateName;
// 状态函数执行完毕后,通过cont跳转到下一状态
stateFn.call(this, nextState => {
if (typeof nextState === 'string') {
this.enter(nextState, cont);
} else {
// 也可以是最终结果对象
cont(nextState);
}
});
}
cancel() {
this.cancelToken = true;
}
}
状态的定义方式:
js复制const states = {
init({ input }) {
console.log('进入init状态');
const configOk = input && input.configUrl;
if (!configOk) return this.enter('error', err => {
console.log('配置缺失,进入error状态');
});
this.enter('fetchConfig', err => err);
},
fetchConfig({ cont }) {
console.log('拉取配置');
fetch('/config').then(res => res.json()).then(config => {
this.enter('loadData', () => cont(config));
}).catch(err => {
this.enter('error', errC => errC(err));
});
},
loadData({ config, cont }) {
console.log('加载数据');
fetch('/data').then(res => res.json()).then(data => {
this.enter('render', () => cont({ config, data }));
}).catch(err => {
this.enter('error', errC => errC(err));
});
},
render({ data, cont }) {
console.log('渲染');
renderChart(data);
this.enter('listenUser', () => cont(null));
},
listenUser({ cont }) {
console.log('监听用户交互');
button.onclick = () => {
console.log('用户点击取消');
this.cancel();
};
cont('done');
},
error(err) {
console.error('流程异常终止:', err);
}
};
可以看到,每个状态函数都通过this.enter(nextState, cont)把控制权交给下一个状态。enter方法里,如果没有取消,就会继续执行下一个状态。cont这个continuation,在嵌套调用中始终是“整个流程最终结束后要执行的动作”(比如记录日志、通知UI层),它在初始时被传入,所有状态共享同一个最终continuation。这样设计的好处是:任何一个状态要终止整个流程,只需要调用cont,不需要拆掉一层层回调。
这个状态机有两个很实用的特性:
- 每个状态的执行栈都是浅的,因为状态切换本质上是一个尾调用链。
- 取消逻辑变得极其简单,一个
cancelToken就够,所有状态执行前都会检查。
4.3 与Promise/async方案的对比,什么时候用CPS什么时候用async
很多朋友会问,现在都有async/await了,为什么还要用CPS状态机?我的答案是:看你的需求是否复杂到线性流程表达不了。
async/await适合的流程是“按顺序执行、有明确的错误路径、不需要随时取消”,它的写法是最舒服的。但如果你遇到下面这些情况,CPS或显式状态机反而更合适:
- 状态之间互相跳转:比如从渲染状态可以直接跳回加载状态(用户点击刷新),也可以跳到错误状态,还可以跳到取消状态。用async/await表达这种非线性的跳转逻辑,你得靠大量状态标志,代码会变得又长又乱。
- 外部事件随时中断流程:async/await的取消要靠AbortController,侵入性也很大。
- 你对调用栈深度有苛刻要求:比如某些极端环境下递归调用深度受限,CPS配合尾递归优化在固定栈空间运行,更安全。
这并不意味着CPS是银弹。CPS风格的代码阅读门槛较高,调试时看到的调用栈全是callback嵌套,排查问题不如直接写await舒服。所以我实际项目里的策略是:简单线性流程用async/await,复杂非线性流程(多状态跳转、可取消)用状态机+CPS,不会滥用。
5. 常见问题与排查技巧实录
5.1 为什么我的尾递归代码还是爆栈了
这是出现频率最高的一个问题。我排查过的案例里,九成都是因为以下原因之一:
- 目标运行时根本没有TCO。Python的CPython就是典型,哪怕你写了完美尾递归,依然RecursionError。解决方案只能是改写循环,或用PyPy这种带JIT优化的解释器。
- 你写的不是真尾调用。之前说过,只要有赋值、运算、包装,哪怕在return关键字后面,也不构成尾调用。建议打印一下AST或人工检查一下有没有隐藏的包装操作。
- 运行时虽然支持TCO,但你的代码没进入优化路径。JavaScript严格模式下才能保证TCO,如果你没写
'use strict';,某些引擎就不会做优化。另外,Safari的JavaScriptCore和大部分现代引擎对TCO的支持情况不完全一致,生产环境建议别盲目依赖TCO,写循环更稳妥。 - 递归不是“直接递归”,而是“非尾调用的互递归”。比如A调用B、B调用A,虽然每个调用点都可能处于尾部,但运行时不一定能识别这种间接调用并优化。尽量避免这种写法。
5.2 CPS代码里回调嵌套太深,代码可读性太差怎么办
CPS风格写多了确实容易变回调地狱。我的经验是,不要把所有细节都CPS化,只把需要跳转或需要暂停的点CPS化,其余内部计算用普通函数封装。另外,给每个continuation取一个有语义的名字(比如onConfigLoaded,onDataLoaded),比写匿名回调要好读得多。
如果项目规模大,我更推荐用生成器或Promise封一层语法糖,把CPS的显示跳转封装成库内部细节,对外暴露简洁的API。我上面那个状态机示例,其实也相当于在半自动地做这件事。
5.3 尾递归和Continuation在算法题里的典型应用
在刷算法题的时候,CPS技术也很有用。比如树的后序遍历,普通递归写法在极端情况下会栈溢出;但树的遍历改成尾递归并不直观,因为确实有两步递归(左子树和右子树)。这个时候CPS能帮上忙:把“递归后要做的事”变成continuation传下去,这样每个递归调用点都变成了尾调用。
js复制function postOrderCps(root, cont) {
if (!root) return cont([]);
postOrderCps(root.left, leftNodes => {
postOrderCps(root.right, rightNodes => {
cont([...leftNodes, ...rightNodes, root.val]);
});
});
}
这段代码的妙处在于:内部的两个postOrderCps调用,都处于外层回调的尾部。如果运行时不支持TCO,它本质上还是回调嵌套,栈一样会涨。但在支持TCO的语言里(比如Scheme、某些优化过的运行时),这段代码就能以常量栈深度运行。而更重要的价值是,这种写法把“访问左子树之后要做什么”“访问右子树之后要做什么”显式化了,非常适合在解释器、编译器的AST遍历中做定制化处理。
5.4 调试CPS风格代码的三点建议
CPS代码的调试体验确实不如普通代码。第一次跑CPS程序出错的时候,那调用栈看起来就像一团乱麻,全是callback。我后来总结出几个土办法:
- 给continuation命名,不要全用箭头函数匿名写。命名的continuation在栈轨迹里清晰得多,直接显示函数名。用V8引擎调试时,能少花很多时间。
- 在关键continuation入口打console.log或断点。因为CPS把流程打散了,你不再能靠普通的单步看所有中间变量,需要主动在所有关键步骤入口输出。
- 先把CPS版本跑通,再考虑优化。CPS版本哪怕丑,只要有日志可追踪,调试就比没有日志的神秘崩溃好处理。
5.5 与内存泄漏有关的坑:Continuation持有栈环境导致对象回收不了
最后提醒一个容易被忽视的坑:Continuation本质上是闭包,闭包会捕获外部变量。如果一个continuation被放入事件队列、缓存或全局容器里,它就会一直持有闭包引用的对象,造成内存泄漏。尤其是在异步场景里,如果一个用户请求的continuation被存到全局Map后忘记手动清理,哪怕请求已经结束,这个Map里的continuation仍会持有整个请求上下文,导致大对象一直无法被垃圾回收。
解决思路很简单:凡是长期存活的容器,存放continuation之前务必想清楚生命周期,用完立即移除。比如用Map存请求级continuation时,在finally里保证删除。这个坑我在用CPS写状态机时踩过,排查了整整一天,最后发现是缓存里残留的一堆旧continuation把大对象全包住了。
6. 再补充一个扩展思路:用CPS实现极简的协作式多任务调度
前面讲了很多,最后分享一个我近期做的小实验。我用CPS思想写了一个极简的协作式任务调度器。在普通写法里,两个任务如果想交错执行(比如一个任务做一步,另一个任务做一步),你需要线程、生成器或async支持。但用CPS,我直接手工保存continuation就能实现:
js复制function taskA(resume) {
console.log('A: step 1');
setTimeout(() => {
console.log('A: step 2');
resume('A结束');
}, 100);
}
function taskB(resume) {
console.log('B: step 1');
setTimeout(() => {
console.log('B: step 2');
resume('B结束');
}, 50);
}
// 简易调度器:轮流执行两个任务,每个任务执行到暂停点后切换
function scheduler(tasks) {
const resumes = tasks.map(task => () => {
// 每个任务执行到暂停/完成,然后切到下一个任务
task(() => {
// 任务完成时,执行下一个任务
const idx = tasks.indexOf(task);
if (idx < tasks.length - 1) {
resumes[idx + 1]();
} else {
console.log('所有任务完成');
}
});
});
resumes[0]();
}
scheduler([taskA, taskB]);
这个实验让我彻底理解了“continuation就是可以被保存和恢复的执行状态”。虽然实际开发中你不会真的用手写continuation做任务调度(有更好的并发原语),但它能让你对所有建立在“继续执行”之上的机制——Promise、生成器、协程、async/await——有更本质的感知。
我在实际项目里的体会是,尾递归和Continuation不是考试知识点,而是工具箱里的两把钥匙。尾递归帮你解决深度递归的栈空间,Continuation帮你把控制流拆成可保存、可恢复、可跳转的状态。遇到复杂递归或流程跳转问题,不妨先想一想,能不能用这两个概念简化设计。
最后再分享一个小技巧:如果你在写递归时不确定能不能做尾递归优化,最快的验证方法是写一个需要递归到一万层的小样例跑一下。爆栈,说明没优化;不爆,说明你的代码在优化路径上。这个方法不严谨,但足够实用。希望这篇文章能帮你少走弯路。
