续体(Continuation)
续体(Continuation)
什么是续体?
定义上,续体,在没有前缀的情况下默认指全续体,它的标准含义是,程序接下来待执行的所有剩余计算——从当前执行点直到程序结束。
如何理解?
理解上,我更喜欢将其看作被保存的线程执行入口,或者当前线程的存档点,就像按下了暂停键。它表现为一个接收参数的函数,这个函数就对应着被保存的执行入口。
一般来说,程序的执行总是不断地期望结果(如赋值和往函数/表达式传值),并且不断地获取结果(表达式求值,如字面量求值或函数返回)以继续运行。于是我们可以从中切开,分离为值的入口(接收值)和出口(返回值),将入口及其后续所有计算看作一个函数,于是可以任意地暂停和恢复控制流,包括函数和表达式求值的进入和返回,续体将所有跳转平等地看待——调用函数是传值,函数返回怎么就不是传值呢?
于是调用后的返回不再被视为理所应当,“程序下一步要做什么”被显式封装起来,去任何地方都需要一个地址——如果想要把值返回到原来的上下文,就得在调用前捕获当前等待函数返回的这个续体,并传递给函数,以便在未来的不知道什么时候被函数调用以重新恢复控制流,或者彻底消失。
行为上,调用全续体就像return
或exit
一样,调用它会抛弃当前上下文,带着值立即跳转到捕获点,并从那里继续执行到线程结束,永远不会回来——这是一个有去无回的函数调用,只有传入的值被送到了捕获点,从而继续留存在线程中。
相关概念
与它非常类似的是,我们可以将后续的操作全部塞进一个回调函数并作为最后一个参数传入,那么这个回调函数就代表了某种意义上的续体,承载了继续运行的控制权,所有函数都这么写的编程风格就是CPS风格(Continuation-Passing Style)
此时调用者和被调用者的关系就被颠覆了,不是调用者接收函数返回值(后续内容是主动执行的),而是函数调用调用者的后续操作(变成了被动执行),这种暂停依靠函数封装进行隔离,是产生嵌套的根源。
它同样能起到暂停执行的作用,因此常用在简单的异步处理当中,而由于其嵌套特性,当异步流程复杂起来时就导致了臭名昭著的回调地狱。
值得强调的是,现代语言通过提供Promises、Async/Await、协程(Coroutines)等高级抽象,巧妙地隐藏了显式的续体传递,从而避免了回调地狱,让异步代码可以以更接近同步的线性方式书写,这也是为什么续体能实现以上的这些现代特性。
实际上,作为一种划分程序控制流的视角,所有控制流,包括函数调用、顺序执行、条件、循环、异常、协程、乃至代数效应,都能用续体描述,它有goto
的自由度,却携带了整个上下文,更为可控和强大。
实现与应用
实现上,它对应着程序继续执行下去所需要的全部数据和代码,是当前线程的完整执行上下文快照——具体来说是捕获时的整个调用栈和每个栈帧上的程序计数器/指令指针(指向代码区域,标记当前执行到了哪里),把这些内容给到任意一个空的线程,线程就能继续执行到结束。需要注意的是,一般它不会复制堆上的内容,因此数据结构中的值不会被重置,这一般也是期望的行为。
原生概念上的续体是一个可重复调用的捕获状态,实现上则各有不同(如Lisp的call/cc
(call-with-current-continuation)表现为一个可调用的函数)。它是一种极其强大的控制流机制。它提供了对程序执行上下文的精细控制,但也带来了极高的概念复杂性和潜在的性能开销(由于需要复制整个栈,尽管可以通过写时复制 Copy-on-Write等策略优化为按需复制),因此,全续体在大多数日常编程中并不常用,更多出现在高级编程语言理论、元编程或实现特殊控制结构(如协程、非确定性计算)的场景中。
需要注意的是,由于一旦调用了续体,当前上下文的后续代码将不再执行。 因此,如果需要多次调用同一个续体,需要将其保存到数据结构中/传给自己(这两个都不会被重置),或者在捕获后不同分支/逻辑中分别调用,而不是在续体调用后紧接着的代码中再次调用,因为在第一次调用时后面的代码就被抛弃了。
一次性续体(One-shot Continuation)
在绝大多数需要显式使用续体的情况下,我们只是要暂停当前执行,可能跳出当前上下文,并至多恢复一次,我们不需要知道它什么时候返回当前上下文,此时的续体可以被优化为有去无回的一次性全续体,甚至更极端点,只需要单纯挂起栈而不需要做任何额外的事情。
定界续体(Delimited Continuation)
与之相对,像函数一样有去有回的就是定界续体。当我们明确需要回溯等重复返回到某一点的功能,但是只限定在有限范围时,我们并不需要也不希望它复制整个栈。
通过在特定位置放置标记,定界续体只保存从标记到捕获点的这段栈,相当于保存调用树上从某一点“递”出去的分支,而不是从根开始;调用时,定界续体将保存的这段栈拼回去,并执行到保存的这段内容返回,将这个返回值作为调用定界续体的返回值,跳转到调用定界续体的地方,而不是一直执行到程序结尾。
它的有效范围也只有标记开始往后的位置,如果标记被返回,那么这个分支就断了。
假设标记点是一个函数调用,捕获点是这个函数运行到一半的地方,那么定界续体就保存了这个函数运行完前半段的数据(这个点可以是任意深度的!),这意味着只要这个函数没有返回,它就可以恢复数据,走一遍这个函数的后半段并获得返回值,此时调用它就代表重走函数的后半段,代表从中间“继续”运行,于是它是这个函数在这一点的续体。