call/cc:把余下的计算抓在手里
这是整本书最难的一章 —— 如果你某一段要读三遍,就是这一章,这很正常。它也是最值的一章:读完之后,你会用二十行代码凭空造出 return、异常处理、和生成器,而这些东西在 Kotlin 和 JS 里都需要编译器的特殊支持。call/cc 做的事只有一件 —— 上一章那个「接下来要做的事」的洞函数,它直接把它交到你手上,当成一个普通的值。而一旦「未来」变成了你手里的一个值,你就可以对它为所欲为。
它的全名说明了一切
call/cc 是 call-with-current-continuation 的缩写:「用当前续延来调用(你给的函数)」。
它接受一个单参数函数,然后把「此时此刻的续延」作为参数喂给它:
(call/cc
(lambda (k) ; ← k 就是「call/cc 这个位置的续延」,一个普通函数
...)) ; 你在这里可以拿 k 干任何事
从最无聊的例子开始,建立信心:
> (+ 1 (call/cc (lambda (k) 10))) 11
如果你不碰 k,call/cc 就像不存在一样。它求值那个 lambda 的返回值(10),然后一切照旧:(+ 1 10) = 11。
那 k 是什么?它就是 (+ 1 □) 这个洞 —— 因为 call/cc 所在的位置,正是那个 □。
第一个把戏:调用 k 就是「跳出去」
> (+ 1 (call/cc (lambda (k) (k 10) 999))) 11
注意那个 999 —— 它被完全跳过了。
为什么?因为 (k 10) 的意思是:「把 10 填进那个洞,然后跳到续延里去执行。」续延是 (+ 1 □),填进 10 得到 (+ 1 10) = 11 —— 然后整个表达式就以 11 结束了。
(k 10) 之后的 999 永远没有机会执行,因为调用续延不会「返回」。它是一次跳转,不是一次调用。
普通函数:算完,返回到调用点,接着往下走。
续延:算完,不返回 —— 它取代了当前正在做的事,直接跳到「那个洞所在的地方」继续。
这就是为什么 (k 10) 后面的代码是死代码:一旦你跳走了,就再也回不到这里了(至少在这种「逃逸」用法里)。
造一个 return
Racket 的函数没有 return 关键字 —— 函数体的最后一个表达式的值就是返回值。那如果我想提前退出呢?
自己造一个。
(define (find-first pred lst)
(call/cc
(lambda (return) ; ← 把这个续延命名为 return
(for-each
(lambda (x)
(when (pred x)
(return x))) ; ← 找到了!跳出去,把 x 交出来
lst)
#f))) ; 循环走完都没找到 → 返回 #f
(find-first even? '(1 3 6 7 8)) ; ⇒ 6 (找到 6 就跳出,7 8 没看)
(find-first even? '(1 3 5)) ; ⇒ #f
return 从来不是「语言特性」,它是一个续延你在 Kotlin / Java / JS 里写的每一个 return,做的事正是这个:丢弃函数内部剩下的续延,把值交给「调用者的续延」。
区别只是:你的语言内置了它,而 Racket 让你用一个函数造出它。
把那个续延命名为 return 不是巧合 —— 它字面上就是 return 的语义。你甚至可以命名为 break、goto、escape,取决于你想让它扮演谁。
造一个异常系统
既然能提前退出,那异常也就是它 —— 只不过是跨越多层函数的提前退出:
(define (with-exception thunk handler)
(call/cc
(lambda (k) ; k = catch 之后要去的地方
(thunk (lambda (err) (k (handler err))))))) ; 把「throw」做成 (k (handler …))
; 用它:
(with-exception
(lambda (throw) (+ 1 (throw 'boom))) ; ← throw 一调,(+ 1 …) 就被丢弃
(lambda (err) (list 'caught err)))
; ⇒ '(caught boom)
看那个 (+ 1 (throw 'boom)):那个 (+ 1 …) 被彻底跳过了。就像真的异常一样 —— throw 之后,当前的计算(不管嵌套多深)全部作废,直接跳到 handler。
Java 的 try/catch、Kotlin 的异常、JS 的 throw —— 底层全是这个动作:捕获「catch 所在处的续延」,然后在 throw 时跳到它,丢弃中间的一切。
这也解释了两个你可能困惑过的现象:
- 为什么异常「穿透」中间的函数?因为它跳的是续延,不管中间隔着多少层调用 —— 它直接跳到 catch,中间那些函数的续延全被丢弃。
- 为什么
finally需要特殊机制?因为「丢弃续延」会跳过中间所有代码,包括那些本该清理资源的代码。finally是一个「即使续延被丢弃也要执行」的钩子 —— Racket 里对应的是dynamic-wind。
可重入续延:call/cc 真正疯狂的地方
到目前为止,我们只是「跳出去」—— 用一次续延就扔掉。但续延是一个普通的值。你可以存起来,过一会儿再调用。甚至调用好几次。
这里开始烧脑。做好准备。
(define saved #f)
(display (+ 1 (call/cc (lambda (k)
(set! saved k) ; ← 把续延存进 saved
10))))
(newline)
; 打印: 11 (正常走完,saved 现在 = 「(+ 1 □) 然后 display」)
(saved 100)
; 打印: 101 !!!
调用 (saved 100) 时,发生了什么?
saved 里存的续延不只是 (+ 1 □) —— 它是 「(+ 1 □),然后把结果 display 出来,然后换行」,也就是从 call/cc 那个点开始的整个剩余程序。
所以 (saved 100) 把 100 填进洞,得到 (+ 1 100) = 101,然后重新执行了那个 display —— 于是 101 被打印了出来。
你刚才「回到了过去」。你抓住了程序在某个时刻的「未来」,然后在之后的任意时刻,让那个未来重新发生了一遍,还塞了个不同的值进去。
这就是为什么 Racket 的 call/cc 被称为「时间旅行」。它捕获的是完整的、可以反复重放的续延。
用可重入续延造生成器
「暂停,交出一个值,之后从暂停处恢复」—— 这不就是生成器吗?有了可重入续延,就能造。
(define (make-gen lst)
(define saved-k #f)
(define (gen)
(call/cc (lambda (return)
(cond
[saved-k (saved-k return)] ; 有存档 → 回到上次暂停处
[else
(for-each
(lambda (x)
(call/cc (lambda (k)
(set! saved-k ; 存下「循环剩下的部分」
(lambda (r) (set! return r) (k #f)))
(return x)))) ; 交出 x,暂停
lst)
(return 'done)]))))
gen)
(define g (make-gen '(a b c)))
(g) ; ⇒ 'a
(g) ; ⇒ 'b
(g) ; ⇒ 'c
(g) ; ⇒ 'done
这段代码很绕(生成器天生就绕),你不需要完全看懂每一行。你需要看懂的是那两个 call/cc:
- 一个抓住「调用者的续延」(
return) —— 「交出值之后我该回到哪」 - 一个抓住「函数体剩下的部分」(
k) —— 「下次恢复时从哪继续」
生成器 = 这两个续延在互相跳。yield 就是它们的一次交换:把「函数剩下的」存起来,跳到「调用者的」。
function* 和 sequence { } 内部的样子你在 JS 里写 function* g() { yield 1; yield 2; },在 Kotlin 里写 sequence { yield(1); yield(2) } —— 编译器把它们展开成的东西,就是上面这段丑陋的双 call/cc 代码(或者它的等价物,一台状态机)。
区别只是:编译器把这坨复杂度藏起来了,还给你一个漂亮的关键字 yield。
而 Racket 让你看见了引擎盖下面。看过一次之后,yield 对你来说就永远不再是魔法了。
一个诚实的警告:别在生产代码里这么写
我教你 call/cc,不是让你去用它写业务代码。恰恰相反 —— 在真实项目里,你几乎永远不该直接用 call/cc。
- 它太强了。完整的可重入续延能表达一切控制流,也因此能写出没人看得懂的代码。「能回到过去」在调试时是噩梦。
- 它和资源管理打架。一个可以被重入的续延,遇上
try/finally、文件句柄、锁 —— 语义会变得极其微妙。 - 它有性能代价。捕获完整续延不便宜。
那学它干什么?
因为它是「控制流的公理」。就像你不会直接用 λ 演算写业务代码,但理解 λ 演算让你看懂了所有函数。call/cc 让你看懂了所有控制流:return、break、异常、生成器、协程、async/await —— 它们全都是 call/cc 能表达的东西的受限、安全、好用的特例。
而「受限」正是关键。下一章你会看到:语言设计者故意不给你完整的 call/cc,而是给你阉割版 —— 因为完整版太危险。这本身就是一堂重要的设计课。
为什么大多数语言不给你 call/cc
完整的、可重入的 call/cc 几乎是 Scheme 独有的。为什么?
| 语言 | 给你的续延能力 | 为什么是这个程度 |
|---|---|---|
| Scheme / Racket | 完整的可重入 call/cc | 把控制流的终极权力交给用户 —— 代价是危险 |
| Kotlin / JS | 只给成品:yield、await | 「一次性、单向」的续延。够用,而且好实现、好推理 |
| Java | 只有异常(单向逃逸) | 最保守。Loom 的虚拟线程内部才有真续延 |
Kotlin 的协程用的是 delimited(有界)续延 —— 它只捕获到 suspend 函数的边界,不是「整个未来」。它只能恢复一次(不能像 Racket 那样重放)。
这是故意的削弱。有界 + 一次性的续延:
- 更好实现(编译成状态机,不用捕获整个栈)
- 更好推理(不会「回到过去」)
- 和资源管理相处得更好
够用来做 async/await、生成器、协程 —— 覆盖了 99% 的真实需求,而砍掉了那 1% 的危险力量。
这是语言设计里反复出现的智慧:最强的原语不一定是最好的特性。知道完整版长什么样(这一章),你才能理解那些「阉割版」到底阉割了什么、为什么值得。
回流
这一章的回流不是「拿去用」,而是「重新理解你已经在用的」。
① Kotlin 的 Continuation<T> 现在对你透明了。下一章会正面拆它,但你现在已经知道:那个接口就是「被打包好的续延」,resumeWith 就是「调用这个续延」。协程库做的所有事,都是在管理这些续延对象。
② 你能判断一个控制流特性「强到什么程度」了。下次评估一个异步方案(RxJava vs 协程 vs 虚拟线程),你可以问:它给的是哪种续延?一次性还是可重入?有界还是无界?这个问题直接决定了它能做什么、不能做什么。
③ 你理解了 finally / use / Closeable 为什么是「特殊」的。因为普通代码依赖「续延会正常往下走」,而异常/协程取消会丢弃续延,跳过清理代码。所有「资源清理」机制,本质都是「即使续延被丢弃也要执行」的钩子。这解释了为什么协程取消时,你必须小心 finally 块里的挂起调用(它可能不会执行)。
④ 一个纯粹的收获:你再也不会觉得 async/await 是魔法了。它就是编译器替你写的、被约束过的 call/cc。祛魅本身就是价值 —— 不怕的东西,才驾驭得了。
下一章
这一章我们用 call/cc 手工造出了 return、异常、生成器。下一章,我们把这三样东西并排摆在一起,再配上你的语言里对应的成品代码 —— 你会亲眼看到「一个续延,三种面孔」。
然后,我们终于要正面拆开那个东西了:Kotlin 的 suspend 函数编译成了什么,以及那个 Continuation<T> 参数是从哪儿冒出来的。