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

续延:「接下来要做的事」

这一卷只讲一个概念,但它是整本书概念上的最高峰。你每天都在用它 —— 你写的每一个 return、每一个 throw、每一个 break、每一个 await,都是在操作它 —— 但你从来没有见过它的样子,也不知道它的名字。这一章我们先不碰任何高级把戏,只做一件事:把这个你天天在用的东西,指给你看。一旦你看见了它,Kotlin 那个 Continuation<T> 接口会突然变得透明。

续延continuation求值上下文CPS

一个简单到你会怀疑的问题

看这个表达式:

(+ 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
;           ↑ 顶层续延:「拿到结果,原样返回」
◆ CPS = Continuation-Passing Style(续延传递风格)

规则:每个函数多接一个参数 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);           // ← 一直缩进下去……
    });
  });
});
◆ 「回调地狱」就是手写的 CPS

那个越缩越深的金字塔,就是续延被显式地、手动地传来传去。每个回调,都是「拿到这一步的结果之后,接下来要做的事」—— 也就是那一步的续延。

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 的函数。其他语言只给你打包好的成品(returnyieldawait),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、异常、和生成器 —— 而它们在你的语言里,是需要编译器特殊支持的关键字。