卷 IV · 续延CH 14深度 14/25

call/cc:把余下的计算抓在手里

这是整本书最难的一章 —— 如果你某一段要读三遍,就是这一章,这很正常。它也是最值的一章:读完之后,你会用二十行代码凭空造出 return、异常处理、和生成器,而这些东西在 Kotlin 和 JS 里都需要编译器的特殊支持。call/cc 做的事只有一件 —— 上一章那个「接下来要做的事」的洞函数,它直接把它交到你手上,当成一个普通的值。而一旦「未来」变成了你手里的一个值,你就可以对它为所欲为。

call/cc逃逸续延可重入续延throwyield

它的全名说明了一切

call/cccall-with-current-continuation 的缩写:「用当前续延来调用(你给的函数)」。

它接受一个单参数函数,然后把「此时此刻的续延」作为参数喂给它

(call/cc
  (lambda (k)      ; ← k 就是「call/cc 这个位置的续延」,一个普通函数
    ...))          ;   你在这里可以拿 k 干任何事

从最无聊的例子开始,建立信心:

> (+ 1 (call/cc (lambda (k) 10)))
11

如果你不碰 kcall/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 永远没有机会执行,因为调用续延不会「返回」。它是一次跳转,不是一次调用。

◆ 调用续延 vs 调用普通函数

普通函数:算完,返回到调用点,接着往下走。

续延:算完,不返回 —— 它取代了当前正在做的事,直接跳到「那个洞所在的地方」继续。

这就是为什么 (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 的语义。你甚至可以命名为 breakgotoescape,取决于你想让它扮演谁。

造一个异常系统

既然能提前退出,那异常也就是它 —— 只不过是跨越多层函数的提前退出:

(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,不是让你去用它写业务代码。恰恰相反 —— 在真实项目里,你几乎永远不该直接用 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只给成品:yieldawait「一次性、单向」的续延。够用,而且好实现、好推理
Java只有异常(单向逃逸)最保守。Loom 的虚拟线程内部才有真续延
◆ 一次典型的「用力量换可控」

Kotlin 的协程用的是 delimited(有界)续延 —— 它只捕获到 suspend 函数的边界,不是「整个未来」。它只能恢复一次(不能像 Racket 那样重放)。

这是故意的削弱。有界 + 一次性的续延:

  • 更好实现(编译成状态机,不用捕获整个栈)
  • 更好推理(不会「回到过去」)
  • 和资源管理相处得更好

够用来做 async/await、生成器、协程 —— 覆盖了 99% 的真实需求,而砍掉了那 1% 的危险力量。

这是语言设计里反复出现的智慧:最强的原语不一定是最好的特性。知道完整版长什么样(这一章),你才能理解那些「阉割版」到底阉割了什么、为什么值得。

回流

⟲ 回流 · 你不会用 call/cc,但你会更懂你的工具

这一章的回流不是「拿去用」,而是「重新理解你已经在用的」。

① Kotlin 的 Continuation<T> 现在对你透明了。下一章会正面拆它,但你现在已经知道:那个接口就是「被打包好的续延」,resumeWith 就是「调用这个续延」。协程库做的所有事,都是在管理这些续延对象。

② 你能判断一个控制流特性「强到什么程度」了。下次评估一个异步方案(RxJava vs 协程 vs 虚拟线程),你可以问:它给的是哪种续延?一次性还是可重入?有界还是无界?这个问题直接决定了它能做什么、不能做什么。

③ 你理解了 finally / use / Closeable 为什么是「特殊」的。因为普通代码依赖「续延会正常往下走」,而异常/协程取消会丢弃续延,跳过清理代码。所有「资源清理」机制,本质都是「即使续延被丢弃也要执行」的钩子。这解释了为什么协程取消时,你必须小心 finally 块里的挂起调用(它可能不会执行)。

④ 一个纯粹的收获:你再也不会觉得 async/await 是魔法了。它就是编译器替你写的、被约束过的 call/cc。祛魅本身就是价值 —— 不怕的东西,才驾驭得了。

下一章

这一章我们用 call/cc 手工造出了 return、异常、生成器。下一章,我们把这三样东西并排摆在一起,再配上你的语言里对应的成品代码 —— 你会亲眼看到「一个续延,三种面孔」

然后,我们终于要正面拆开那个东西了:Kotlin 的 suspend 函数编译成了什么,以及那个 Continuation<T> 参数是从哪儿冒出来的。