续延:「接下来要做的事」
这一卷只讲一个概念,但它是整本书概念上的最高峰。你每天都在用它 —— 你写的每一个 return、每一个 throw、每一个 break、每一个 await,都是在操作它 —— 但你从来没有见过它的样子,也不知道它的名字。这一章我们先不碰任何高级把戏,只做一件事:把这个你天天在用的东西,指给你看。一旦你看见了它,Kotlin 那个 Continuation<T> 接口会突然变得透明。
一个简单到你会怀疑的问题
看这个表达式:
(+ 1 (* 2 3))
机器求值它的时候,会在某个瞬间算 (* 2 3),得到 6。
现在问你一个问题:在算出 6 的那一刻,「程序剩下要做的事」是什么?
答案是:「拿这个 6,加上 1。」
就这么简单。而这个「剩下要做的事」,就是 (* 2 3) 这个子表达式的续延(continuation)。
对于程序里的任意一个点,「续延」= 从这一点开始、直到程序结束、剩下要做的全部计算。
换个说法:如果你把这个子表达式挖成一个洞 □,那么「把值填进 □ 之后,接下来发生的一切」就是它的续延。
(* 2 3) 的续延,就是 (+ 1 □) —— 「等着一个值填进来,然后加 1」。
把它写成一个函数
「等一个值填进 □,然后加 1」—— 这句话,其实就是一个函数:
(lambda (□) (+ 1 □))
一个子表达式的续延,就是「把它挖空、然后把整个剩下的程序包成一个以那个洞为参数的函数」。
这个函数接受子表达式的值,返回整个程序的最终结果。
这就是全部。续延不是一个玄学概念,它就是一个函数 —— 一个代表「未来」的函数。
亲手点一下
光说不够。下面这台机器,让你点任意一个子表达式,它就把那个子表达式的续延画出来给你看(可以点嵌套的):
玩几分钟。你会建立起一个直觉:程序里的每一个点,都活在某个续延里。越靠外的表达式,续延越简单(剩下的事越少);最外层表达式的续延就是「打印出来,然后结束」—— 那叫顶层续延。
为什么这件事很重要:因为你能给它命名
这个概念之所以强大,是因为一旦「接下来要做的事」变成了一个普通的函数值,你就可以:
- 不调用它 —— 就是「提前退出 / 抛异常」(丢弃续延)
- 存起来待会儿再调用 —— 就是「暂停 / await」(保存续延)
- 调用它两次 —— 就是「回溯 / 生成器多次 yield」(重用续延)
你已经在用这三种操作了,只是它们藏在关键字后面。让我们把它们一个个揪出来。
CPS:把续延显式地传来传去
先看一种编程风格,它把「续延」这个抽象概念变成了你能亲眼看见的代码。
普通的阶乘(直接风格):
(define (fact n)
(if (= n 0)
1
(* n (fact (- n 1)))))
; ↑ fact 返回之后,还要乘以 n —— 这个「乘以 n」就是续延,但它是隐式的
现在把那个隐式的续延拽出来,变成一个显式的参数 k:
(define (fact-cps n k)
(if (= n 0)
(k 1) ; 到底了,把 1 交给续延
(fact-cps (- n 1)
(lambda (v) (k (* n v)))))) ; 「拿到 v 之后,乘 n,再交给 k」
; ↑ 这个 lambda 就是续延,现在它是一个看得见的值
(fact-cps 5 identity) ; ⇒ 120
; ↑ 顶层续延:「拿到结果,原样返回」
规则:每个函数多接一个参数 k(它的续延)。函数永不返回 —— 它算完之后,把结果交给 k。
看那个 (lambda (v) (k (* n v))):它字面上就是你在上面那台机器里点出来的「洞函数」。CPS 只是把每一步的洞函数都写了出来。
而这里有一个惊人的副作用:在 CPS 代码里,每一个函数调用都恰好处在尾位置(你看 (fact-cps …) 和 (k …) 都是各自分支里的最后一件事)。
「每个调用都在尾位置」意味着什么?意味着 CPS 版本不需要栈(第 8 章)。验证一下 —— 直接风格的 fact 在深递归时会吃栈,而 CPS 版本不会:
> (fact-cps 100000 (lambda (v) (string-length (number->string v)))) 456574 ; ← 十万层递归,算出一个 45 万位的数,栈纹丝不动
栈变成了那条闭包链。
你看 (lambda (v) (k (* n v))) 里面套着 k,而 k 又是上一层的 (lambda (v) (k' (* n' v)))…… 一层套一层,十万层深。
「调用栈」和「续延」是同一个东西的两种实现。栈是把它放在一块连续内存里(快,但会满),闭包链是把它放在堆上(慢,但能一直长)。
这就是第 8 章那个「非尾递归一百万层也不崩」的谜底:Racket 的续延本来就在堆上。它从一开始就是数据,不是那块会满的内存。
你其实见过 CPS —— 它就是回调地狱
看这段 CPS 风格的勾股定理:
(define (pyth x y k)
(square x (lambda (x2)
(square y (lambda (y2)
(add x2 y2 (lambda (sum)
(sqrt-k sum k))))))))
这个「一层套一层的 lambda」的形状,你一定见过:
// JavaScript:Promise 之前的世界
getUser(id, (user) => {
getPosts(user, (posts) => {
getComments(posts[0], (comments) => {
render(comments); // ← 一直缩进下去……
});
});
});
那个越缩越深的金字塔,就是续延被显式地、手动地传来传去。每个回调,都是「拿到这一步的结果之后,接下来要做的事」—— 也就是那一步的续延。
Promise / async-await 做的事,就是把这个金字塔压平。它们没有消灭续延 —— 续延还在,只是被语言藏了起来。await 那一行之后的所有代码,就是那个 await 的续延,只不过编译器替你把它打包了(下一章的主题)。
所以:你从「回调地狱」升级到「async/await」的那次进化,本质是「从手写续延,升级到让编译器管理续延」。
三种操作,一张表
现在把「你能对续延做什么」和「你天天在用的关键字」对上号。这张表是整个卷 IV 的地图:
| 对续延的操作 | 你熟悉的名字 | 发生了什么 | 哪一章 |
|---|---|---|---|
| 丢弃当前续延 | return / break / throw | 不走「接下来」了,跳到别处 | 第 14、15 章 |
| 保存续延,待会儿恢复 | yield / await / suspend | 暂停,把「接下来」存起来 | 第 15 章 |
| 捕获续延成一个值 | call/cc | 把「接下来」当普通函数交到你手上 | 第 14 章 |
| 重用续延(调用多次) | 生成器、回溯、amb | 让「接下来」重新发生几次 | 第 14、15 章 |
Racket 的特殊之处在于最后那两行:它把「捕获续延」这个能力直接交给你了,通过一个叫 call/cc 的函数。其他语言只给你打包好的成品(return、yield、await),Racket 给你原材料。下一章我们就去用它。
回流
从今天起,每当你看到这些关键字,在心里翻译成「对续延的操作」:
| 你看到 | 心里想 |
|---|---|
return x | 「丢弃函数内剩下的续延,把 x 交给调用者的续延」 |
throw e | 「丢弃续延,一路往上,直到撞见一个 catch」 |
break | 「丢弃循环剩下的续延」 |
val x = await f() | 「把这一行之后的所有代码打包成续延存起来;f 的结果到了,再拿出来喂给它」 |
yield x | 「把函数剩下的部分存成续延,先把 x 交出去,等下次 next 再恢复」 |
这个视角的实际价值:它让「异步代码为什么这么难」有了一个精确的答案。
难,是因为 在同步代码里,续延是隐式的(就是「下一行」),你不用想它;而异步代码把续延显式化了 —— 它变成了一个可以被存起来、被延迟、被丢到另一个线程上的东西。
- 你为什么会遇到「
await之后this变了 / 上下文丢了」?因为续延被恢复时,环境可能已经不是当初的了。 - Android 里为什么
viewModelScope.launch { }里更新 UI 有时会崩?因为那个续延可能在 Activity 已经销毁之后才被恢复。 - 为什么协程要有「结构化并发」「作用域」这些概念?因为一旦续延能被存起来延迟执行,你就需要一套机制来管理「哪些续延还该被恢复、哪些该被取消」。
「续延」这个词不会出现在你的日常对话里。但它是你每天都在搏斗的那个东西的真名。知道真名,是掌控它的第一步。
下一章
我们已经知道续延是什么了。下一章,我们拿起 Racket 递给我们的那把螺丝刀 —— call/cc —— 把「接下来要做的事」真的抓在手里,当成一个值传来传去。
这是整本书最难的一章。也是最值的一章。读完它,你会用二十行代码凭空造出 return、异常、和生成器 —— 而它们在你的语言里,是需要编译器特殊支持的关键字。