读取数量、计算价格、输出结果,可以写成三层回调。把回调包成 flatMap 后,缩进少了,控制流却没有消失:谁调用下一步、下一步返回后还会发生什么、异常经过哪一层,仍然决定程序行为。尤其是资源关闭和异步异常,不能靠一个名字叫 Continuation 的容器自动保证。

本篇从同步计算开始,手写最小 CPS 组合,打印进入与退出顺序,再用泄漏、提前关闭和重复回调作为反例。它补充Cats 与 IO 的执行和取消中的运行时边界,不替代成熟效果系统的资源管理。系列入口见导读与能力自测。

返回结果与传递剩余计算

普通函数 price(qty) 返回价格。CPS 版本额外接收 k,把价格交给 k,并返回 k 的结果:priceCps(qty,k)=k(qty*10)。k 表示当前步骤完成后剩余的计算。它不是必然异步的回调,也不意味着独立线程;本例所有调用都在同一个同步调用栈上发生。

固定最终结果类型 R,可以把产生 A 的计算写成 Cont[R,A]=(A=>R)=>R。A 是当前阶段交出的值,R 是整个后续计算的最终结果。给它传一个 A=>R,才能完成执行。若 R 选成字符串,最后一步可以格式化;若 R 选成整数,最后一步可以再计算。

这解释了为什么不能把 Cont 当作普通盒子直接 get:若只知道 (A=>R)=>R,通常没有办法凭空制造一个 A。运行它需要明确选择后续操作,而这个操作可以忽略、变换甚至多次使用收到的值。语言中是否允许这些行为取决于额外约束,普通 JavaScript 函数类型并不限制调用次数。

实验实现只需两条组合公式:

1
2
const pure = a => k => k(a);
const flatMap = (m, f) => k => m(a => f(a)(k));

pure(a) 不计算下一步,只把 a 交给传入的 k。flatMap 则给 m 安装一个新的后续函数:m 产生 a 后,调用 f(a) 得到下一段 CPS 计算,再把最终 k 传给它。类型上 f 是 A=>Cont[R,B],k 是 B=>R,所以中间函数 a=>f(a)(k) 恰好是 A=>R。

Cats 的ContT 文档用用户更新与持久化展示同类组合,并解释底层 Defer 与栈安全的联系。本篇没有调用 ContT 库,手写实现用于暴露同步调用结构;不能因为数学形状相近就假定它拥有 Cats 实现的所有运行保证。

一次完整的调用与返回顺序

实验每个阶段在调用 k 之前记录 before,在 k 返回之后记录 after。先构造 read 阶段,交出二;接着 price 阶段,交出二十;最后的 k 记录 done 并返回二十一。

1
2
3
4
5
6
7
const stage = (name, value) => k => {
trace.push(`${name}:before`);
const r = k(value);
trace.push(`${name}:after`);
return r;
};
const p = flatMap(stage('read', 2), n => stage('price', n * 10));

构造 p 时 trace 为空,因为这里只创建闭包。运行 p 时,进入 read,read 调用后续,从而进入 price,price 又调用最终 k。done 返回之后,控制流先回到 price 的 after,再回到 read 的 after。实际轨迹是:

1
read:before,price:before,done,price:after,read:after

因此“链从左到右执行”只描述了进入顺序,没有描述退出顺序。资源释放、耗时统计与 finally 常发生在退出阶段;如果只按 before 的顺序思考,很容易把关闭时点理解错。这个嵌套结构也解释了为何同步回调中抛出的异常会沿当前调用栈反向传播。

这里的 flatMap 没有向线程池提交任务,没有排队,也没有把递归自动变成循环。代码看起来像异步库,并不能作为异步执行的证据。实验选择同步行为,正是为了先把控制转移本身与调度机制分开。

组合定律只在声明的观察下比较

对纯、全、普通函数,代入公式可以检查三个定律。左单位将 flatMap(pure(a),f)(k) 展开成 f(a)(k);右单位把 flatMap(m,pure)(k) 展开成 m(a=>k(a));结合律的两侧都变成 m(a=>f(a)(b=>g(b)(k)))。

这些等式的依据是函数应用,不是因为方法恰好命名为 flatMap。JavaScript 里若把计数、异常或者执行时间加入观察,比较时就必须明确这些因素是否被等式保持。尤其不能先把 k 的效果忽略,再在结果中宣称所有效果轨迹等价。

实验在三个整数上,以身份函数作为最终 continuation,对 pure、加一与乘二检查九个值相等断言。这是对实现公式的有限回归测试,不是函数外延相等的穷举证明。手算结合律时必须保留最终 k,不能只计算中间数值后省略真正的运行入口。

四种回调结构没有保证的事情

最直接的泄漏是把关闭语句放在 k 后面:k(resource); close()。若 k 抛异常,close 不执行。实验让 k 抛出 use,确认错误被观察到,同时确认 closed 仍为 false。把 close 移到 finally 后,同一个同步异常场景中 closed 才变成 true。

但 finally 保护的是当前调用的动态范围。如果 k 被保存起来,等原函数返回后才调用,那么 finally 已经关闭资源。实验故意保存一个延迟函数,先退出原作用域,再触发它;continuation 观察到资源已关闭。显式的“保存、退出、触发”顺序使该测试不依赖定时睡眠。

同样的区别影响异常。try { registerCallback(...) } catch {...} 只能捕获注册过程以及当前栈上的同步调用。若 callback 后来才抛出,外层 try 已经结束。实验先保存一个会抛 later 的函数,再离开 try,最后调用它,确认外层 catch 没有运行。真实异步 API 需要把错误显式送入约定通道,或在真正执行边界处理。

第四个问题是重复完成。普通 k 可以被调用零次、一次或多次。实验的 twice 连续调用 k 两次,计数器得到二;没有任何类型或组合器阻止它。如果 k 扣款,重复调用就可能造成重复效果。给回调增加一次性状态门能防某一类重复完成,但还需要考虑取消与完成竞争、失败回调竞争和资源释放协议。

因此回调接口的契约至少要回答:完成是否恰好一次、错误如何报告、谁拥有资源、取消之后是否还可能到达回调。CPS 只是把剩余计算显式化,这些协议没有从函数箭头里自动推导出来。对于实际 I/O,复用已经验证的效果运行时通常比继续扩展教学实现更合适。

CPS与栈安全之间还差一个执行器

同步 m 调用 f,f 再调用 k,长链仍可能形成深调用栈。尾调用形状也不能在任意 JavaScript 引擎上被当作可靠的尾调用优化承诺。本实验选择显式 trampoline:每一步返回下一步的无参函数,最外层循环反复执行,直到得到普通值。

1
2
3
4
5
6
7
const bounce = (n, total = 0) =>
n === 0 ? total : () => bounce(n - 1, total + 1);
const run = initial => {
let current = initial;
while (typeof current === 'function') current = current();
return current;
};

十万次计数由 while 驱动,返回十万。递归调用位于下一次 thunk 执行中,每次 thunk 返回后,上一层执行器调用已经结束。这控制了调用栈增长,但仍要分配函数,也仍然会持续占用当前线程;它不是公平调度器,更不是自动可取消的异步运行时。

这个小执行器还把函数值用作“继续”标记,所以无法无歧义地把函数本身作为最终结果。若要支持任意结果类型,应该用明确的 Done(value) 与 More(thunk) 标签区分,而非继续堆叠 typeof 判断。协议越一般,对数据表示和执行状态的要求越明确。

从嵌套回调逐层改写

把三个步骤直接写成 read(q => price(q, p => finish(p))),可以先给 read 与 price 各加一层返回函数:readC=k=>read(k),priceC=q=>k=>price(q,k)。随后 flatMap(readC,priceC)(finish) 展开后恰好回到原来的嵌套式。这个代入过程说明组合器抽取了哪一部分重复结构,而不是凭命名让回调突然拥有新的执行语义。

如果原始 read 同时接受成功与错误回调,单一 continuation 版本还不完整。可以把错误放进 Either 再统一传递,也可以显式保存成功/失败两个 continuation。两者都需要规定遇到失败是否调用后续业务步骤、错误处理器抛错如何传播。不能只包装成功回调,忽略错误分支后宣布完成了 CPS 转换。

同样,原回调 API 的返回值可能表示注册句柄,而非计算结果。同步示例能直接 return k(value),异步注册函数却可能在 k 执行之前已经返回。此时最外层返回类型要表达延迟完成,例如 Promise 或专门的效果值;继续沿用同步 R 会造成“回调里 return 了值,外部为什么仍是 undefined”的问题。

回调可能保存资源引用,也可能保存大量输入数据。即使控制栈被 trampoline 降低,闭包仍会延长被捕获对象的存活时间。取消时若没有注销保存的 continuation,内存和外部监听器仍可能保留。栈安全、堆占用和资源生命周期是三个不同维度,不能用一次十万步计数成功替代另外两项检查。

还要区分提前退出与重复进入。一个 CPS 函数完全可以不调用 k 而直接返回某个 R,形成短路;另一个可以像 twice 一样多次调用。它们在普通函数类型下都可表达,但调用者若要求单次完成,就必须在协议中排除或封装这些行为。把所有函数都称为 continuation,并不会自动赋予线性使用规则。

对于调试,显式 continuation 可以让某些状态转换更容易记录,却也可能让栈信息变短或更间接。trampoline 的异常栈通常只反映当前 thunk,而不是所有历史逻辑步骤。需要业务诊断时,可以显式携带阶段标识或结构化上下文,但这又是独立的可观察信息,应该进入测试,而不是假设异常栈总能恢复完整因果链。

如果把完成回调当作通知而不使用其返回值,最好在接口里明确约定返回 Unit 或 undefined。同步 Cont 的 R 却有实际组合意义,随意丢掉 return r 会改变外层计算结果。日志轨迹可能依然完全相同,因此返回值二十一的断言与顺序断言都需要保留。

错误处理器也可能再次调用成功 continuation,形成难以看出的重复完成。测试不能只统计成功分支内部次数,还应记录终止状态是否只能从 pending 转移一次。这个状态机不包含在当前最小实现里,因而文章不会把九个纯值定律通过当作一次性回调协议已经得到验证。

每个反例都由独立状态变量隔离,避免前一个关闭结果污染后一个泄漏判断。

实验与验收

运行 node examples/functional-programming/run.mjs E05。Node 22.22.2 的断言覆盖进入/退出顺序、九个纯值定律、同步泄漏、finally 关闭、逃逸后访问、延迟异常、双回调与十万步 trampoline,结果见Main.mjs和result.json。输出中的 unsafe-leak=true、deferred-use-after-close=true 是成功捕获负例,不是宣称这些实现可用于生产。

自测时先把最终 k 改成返回字符串 "total=20",写出 R 与 A 各是什么,再预测 before/after 是否变化。答案是返回类型变化不会改变这里的嵌套调用次序;若 k 抛异常,则两个普通 after 都不会执行,只有位于 finally 中的清理才能沿异常路径运行。

修改题是给 trampoline 增加 Done/More 标签,让最终结果能够是一项函数,并增加一个输入预算:每次执行 More 扣减一次,预算用尽返回显式状态而不继续运行。保持现有十万步测试,再检查零预算、返回函数值和中途异常。不要把“预算耗尽”混成业务失败,也不要把这项练习表述为已经实现异步取消。