第 6 部分 · 语言设计与计算模型
延续——Racket 里的时间旅行
延续(continuation)是“计算剩下要做的事”。
call/cc把它打包成一个可调用的值,你可以存起来、传出去、以后再跳回去。return只能回一层,延续能回到任意一个被保存的未来——但这力量该怎么用,才不会反噬?
你写过函数,return 用过无数次。它只能往上一层,回到调用者那里。但你有没有想过:要是能保存住“此刻之后程序要做的全部事”,以后随时跳回这个点,甚至跳好几次呢?
Racket 把“计算的剩余部分”做成了一等公民,可以存进变量、传来传去、随时调用。这就是延续。它和 return 不在一个量级:return 只回一个地方,延续能回到任意一个被保存的未来。
延续,就是“接下来要做的事”
一行代码正在运行时,它之后还要做的事,就是它此刻的延续。
(+ 1 (* 2 3))
求值 (* 2 3) 那一刻,它的延续是“把结果乘出来之后,加 1”。平时这个“剩余部分”是隐式的——求值器替你记着,做完一步自动做下一步。Racket 的 call/cc 把它显式地交到你手里。
call/cc:时间旅行的开关
call/cc 是 call-with-current-continuation 的简写。它接受一个函数,把“当前的延续”作为参数传给它:
#lang racket
(define saved-k #f)
(define result
(+ 1 (call/cc
(λ (k)
(set! saved-k k) ; 把延续存起来
10))))
result
;; 11 (= 1 + 10)
这次 (call/cc ...) 返回 10,于是 result 是 11。但 saved-k 里存着的,是“把 call/cc 的返回值加 1、再绑定给 result”这一整段未来。现在调用它:
(saved-k 100)
result 会突然变成 101——因为 saved-k 跳回了 call/cc 当时的延续,用 100 重跑了一遍“加 1、赋给 result”。
调用一个延续,就是丢弃当前计算,跳回它被捕获的那一刻。这不是函数调用,是时间旅行。
它不是 return,是跳回任意时刻
普通函数只能 call → execute → return,沿调用栈往回走一层。延续没有这个限制:它捕获的是“从那一点往外的整条路”,可以跨过任意多层调用,也可以反复跳回。
这种能力让 Racket 能直接表达很多别处要专门造机制才能做的事:非本地退出、生成器、协程、异常恢复,甚至 Web 表单的“等用户提交后接着跑”。底层都是同一件事——把延续存起来,以后跳回去。
用得最多的形态:非本地退出
call/cc 的全部威力,在大多数场景里用不上。八成的情况,你只是想从深层嵌套里提前跳出来。这时该用的是 escape continuation——更轻、更安全:
#lang racket
(define (find-first pred lst)
(let/ec exit ; let/ec 是 call/ec 的简写
(for ([x (in-list lst)])
(when (pred x)
(exit x)))
#f))
(find-first even? '(1 3 5 8 9))
;; 8
let/ec(背后是 call/ec,全名 call-with-escape-continuation)捕获的延续只能往回跳一次,只能跳到定义它的那一点,也只能在其作用期内调用。
escape continuation 是单向、一次性的,这正是它比 call/cc 安全的地方。 需要提前退出、跳出循环、中断递归,都用它;把全功能的 call/cc 留给真正要“跳回去好几次”的场景。
真·生成器:用 call/cc 造一个 yield
Python 的 yield 能把一个函数变成可以暂停、恢复的迭代器。Racket 标准库的 racket/generator 提供同样的东西,但它底层正是用 call/cc 拼出来的。几十行就能写一个最小版本,看清这个魔法:
#lang racket
(define (make-gen lst)
(define caller #f) ; 调用者的接收点
(define resume #f) ; 生成器内部的恢复点
(define (next)
(call/cc
(λ (k)
(set! caller k) ; 记下"把值还给谁"
(cond
[(eq? resume 'done) (caller 'done)] ; 已耗尽
[resume (resume #f)] ; 已启动:跳回上次暂停处
[else ; 首次:开始遍历
(for ([x (in-list lst)])
(let/cc here ; 记下"下次从这里继续"
(set! resume here)
(caller x))) ; 把当前值交还调用者
(set! resume 'done)
(caller 'done)]))))
next)
(define g (make-gen '(a b c)))
(g) ;; 'a
(g) ;; 'b
(g) ;; 'c
(g) ;; 'done
每当 next 想交出一个值,它用 let/cc 把“生成器内部接下来该跑的代码”存进 resume,再用 caller 把值送回调用者,整个生成器的执行就此冻结。下一次 next 被调用时,caller 换成新的接收点,(resume #f) 解冻上次冻结的地方。两次 call/cc,一进一出,就是 yield。
Web 服务器里的会话魔法
延续最经典的应用,是 Racket Web 服务器。写一个多页表单流程,传统框架要你手动管 session、存中间状态。在 Racket 里:
#lang racket
(require web-server/servlet
web-server/servlet-env
web-server/http/bindings)
(define (start req)
(define next-req
(send/suspend
(λ (k-url)
`(html
(body
(form ([action ,k-url])
(input ([name "name"]))
(input ([type "submit"]))))))))
;; 用户提交表单后,代码从这行继续——next-req 就是这次提交的请求
(define name (extract-binding/single 'name (request-bindings next-req)))
(response/xexpr
`(html (body (p ,(string-append "你好," name))))))
send/suspend 把“拿到表单之后要继续执行的整段代码”——也就是那个 define 之后的所有内容——打包成延续存进服务器,并把一个绑定了这段延续的 URL 交给浏览器。用户提交时访问这个 URL,服务器就跳回那个延续,send/suspend 返回新的请求,代码继续往下跑。
它把“等用户提交”这件本该异步、要拆成回调的事,写成了从上到下的同步流程。 多页表单、带分支的会话,都能像普通函数一样顺序写下来。
dynamic-wind:跳走之前,打扫干净
延续能任意跳转,引出一个问题:如果一个 call/cc 跳出了“已经打开文件、拿到锁”的代码块,那些清理代码还跑吗?默认是不跑——你跳走就跳走了。dynamic-wind 就是来解决这个的:
#lang racket
(define saved-k #f)
(dynamic-wind
(λ () (displayln "[pre] 拿到锁")) ; 进入 body 时执行
(λ () ; body
(displayln "[body] 干活")
(let/cc k (set! saved-k k)) ; 捕获延续
(displayln "[body] 干完了"))
(λ () (displayln "[post] 释放锁"))) ; 离开 body 时执行
(saved-k 'replay)
第一次运行会打印 [pre] / [body] 干活 / [body] 干完了 / [post]。(saved-k 'replay) 跳回 body 里 let/cc 的位置——但因为是跳“进” dynamic-wind 的 body,[pre] 会再跑一遍;body 跑完后跳“出”去,[post] 又跑一遍。
只要控制流进入或离开 dynamic-wind 的 body,对应的 pre 或 post 就会执行一次。 这让延续在带资源管理的代码里也能安全使用:拿到什么,跳走时一定会放下。
延续也有跨不过的边界
延续不是无所不能。Racket 在很多边界自动安装 continuation barrier:加载模块、运行宏展开、调用异常处理器、命令行顶层求值,这些地方都会挡住 full continuation 的跳转。捕获的延续一旦跨越了 barrier,调用时就会抛 exn:fail:contract:continuation 异常。
这是有意为之——它把“乱跳”限制在了一个可控的范围里。日常应用代码几乎不会撞上这道墙,但要知道它在那儿。
强力药,点到为止
延续是 Lisp 家族最强大的控制结构之一,能表达协程、生成器、异常、Web 会话——一切“非线性流程”。但它也是典型的“读起来费劲”特性:一个 call/cc 让执行流能飞到任何地方,调试时这是噩梦。
实用的态度是分层:先用 let/ec / call/ec 解决提前退出,用 racket/generator 解决迭代器,用 send/suspend 解决 Web 会话。这些都是在延续之上包好的安全壳。真要自己手写 call/cc 时,把它封装成一句注释能讲清的小模式,别让它裸露在业务代码里。 延续是把流程变成数据的力量,但好钢用在刀刃上。