Racket 编程入门

第 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”。

调用一个延续,就是丢弃当前计算,跳回它被捕获的那一刻。这不是函数调用,是时间旅行。

racket-continuation-control-flow

它不是 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 时,把它封装成一句注释能讲清的小模式,别让它裸露在业务代码里。 延续是把流程变成数据的力量,但好钢用在刀刃上。