热门问题
时间线
聊天
视角

续体

在计算机科学中,续体(Continuation)是一种计算过程中控制状态的抽象表示 来自维基百科,自由的百科全书

Remove ads

计算机科学中,续体(英语:continuation,也译作计算续体、续延、延续性),是实化计算机程序控制状态的一种抽象表示。程式在运算过程中给定一个点,其“剩馀将要执行的部分”,即为“续体”。借助程式语言中的续体特性,程式设计师不但能模组化地自订回圈、break陈述句、continue陈述句等基本控制结构;此外还能以更加简练的方式实现更加复杂的控制结构或计算模式,例如:异常处理 生成器协程回溯非确定性计算概率编程[1]反向传播算法可微分编程[2]

概述

对一段程式 1 * (2 + 3) - 4 而言,若当前正在计算 2 + 3,这部份则称为可约表达式(英语:redex; reducible expression[3];而其馀的部分 1 * - 4 即是续体。这里的符号 被称为“洞”,表示可约表达式之计算结果将要回填的地方,可视为占位符[4]

续体的种类

续体的概念在任何程式语言中都会出现,而不同的程式语言之间的差别在于是否提供构造或是算子,以供显示地捕捉跟恢复续体。不同种类的算子,亦有相异的抽象程度与表达能力。

头等续体

若能将捕捉到的续体实化作为一般的值储存与传递,便会称其为头等续体(英语:first-class continuations)。而当被捕捉的续体被呼叫时,程式的流程控制将会恢复到续体被捕捉时所在的地方。这赋予程式语言强大的执行顺序控制能力。它可以跨越多个呼叫堆叠来返回结果,或者跳转到此前已经退出了的函数。可以认为其保存了程序的控制流程上下文,而需要注意的是这与进程映像英语System image不同,它不保存程序数据,只保存控制流程上下文。这经常采用“续体三明治”譬喻来说明:

你正站在冰箱前,想要做一个三明治。
你就把这个情况变成一个续体,放入口袋里。
接着,从冰箱里拿出火鸡肉和面包做一个三明治,并坐到桌案前。
你启用了口袋里的续体
这时发现自己再次站到了冰箱之前,想要一个三明治。
幸运的是,在桌案上就有一个三明治,而用于做三明治的材料都没有了。你可以吃三明治了。[5]

在这个譬喻中,三明治是一部份程序数据,比如在分配堆上一个对象,并非去调用“制做三明治”例程并等它返回,这里调用“以当前续体制做三明治”例程,它创建一个三明治并在已脱离执行的续体所保存的地方继续执行。

多发及单发恢复

能够恢复多次的多发续体(英语:multi-shot continuations),其表达能力比仅能恢复一次的单发续体(英语:one-shot continuations)还要强。单发续体能够直接表达回圈异常处理迭代器生成器协程等控制结构,而多发续体不仅仅是具备单发续体的能力,此外还能用在非确定性回溯法布林可满足性问题反向传播这些单发续体无法直接表达的问题。 [6] [7]

然而,多发恢复的续体在编译器执行期系统的实现面临著工程上的挑战。为了实现多发恢复的语意,通常需要以复制呼叫堆叠实现,这也为多发续延带来了一些性能负担。考虑到这个原因,部分程式语言仅提供单发恢复,例如OCaml的Effect[8];也有程式语言实现提供了单发化版本的控制算子,例如Chez Scheme提供了call/1cc[9]而如何更高效地实现多发恢复,在近年来(2025)仍然是学者积极探讨的问题。[10][11][12]

学界也探讨了单发有界续体与协程的关系。有堆叠的非对称式协程,例如Lua所提供的协程,可以对应到单发有界续体。[13][14][15][16]

Remove ads

历史

Gerald J. SussmanGuy L. Steele Jr.在1976年论文《Lambda之终极指令式》中,提及了John C. Reynolds英语John C. Reynolds在1972年的论文《高阶编程语言的定义性解释器》中采用了术语“续体”[17],并认定Alonzo Church在1941年著作《Lambda转换的演算》里[18],已经清晰的理解了“续体”的使用,这是用纯λ演算彻底完成所有事情即邱奇编码的唯一方式,并举出其中有序对定义所对应的Scheme表示为例[19]

(define (cons m n)
  (lambda (a) (a m n)))
(define (car a)
  (a (lambda (b c) b)))
(define (cdr a)
  (a (lambda (b c) c)))

John C. Reynolds英语John C. Reynolds在1993年的论文《续体的发现》中给出了发现续体的完整历史[20]。Reynolds认为续体的最早描述由Adriaan van Wijngaarden在1964年9月作出。van Wijngaarden在奥地利维也纳附近巴登召开的关于“形式语言描述语言”的IFIP英语International Federation for Information Processing工作会议上,在关于ALGOL 60预处理器公式化英语Formulation的论文《语法和语义的递归定义》中[21],倡导通过将真正(proper)过程变换成续体传递风格而消除标签goto语句[22],但是他没有使用“续体”这个名字。

Christopher Strachey、Christopher P. Wadsworth和John C. Reynolds英语John C. Reynolds,在指称语义领域的工作中突出了术语“续体”,此间广泛利用续体来允许使用函数式编程语义来分析顺序程序。

Steve Russell为他给IBM 704LISP实现的一个用户,发明了续体来解决双重递归英语Double recursion问题[23],但是他没有为其命名。

Scheme是支持续体的第一个完全的生产系统,它最初提供了源自Maclisp的用于例外处理的命名catch/throw[24],在1985年,Daniel P. FriedmanMitchell Wand英语Mitchell WandWilliam Clinger英语William Clinger (computer scientist)提供了头等续体算子call-with-current-continuation英语call-with-current-continuation(简写为call/cc[25]

在1991年,Bruce Duba等人将callcc介入到了SML/NJval callcc : ('a cont -> 'a) -> 'a,这里的'a cont是接受类型为'a的参数的续体的类型;callcc f应用f到当前续体,如果f以参数x调用这个续体,则如同(callcc f)返回x作为结果[26]

Remove ads

续体传递风格

续体的概念主要起源于计算模型研究,比如λ演算[27]指称语义[17]演员模型[28]。这些模型仰仗于编程者或语义工程师书写数学函数时采用“续体传递风格”(continuation-passing style,简写为CPS)[29]。续体传递风格(CPS)意味着编程中用到的每个函数,都接纳并于结束时应用一个表示有关于这个函数调用的余下计算的函数。表达式英语Expression (computer science)的最内部份必须首先求值,将表达式转换为续体传递风格等价者,比如Michael J. Fischer英语Michael J. Fischer在1972年所举的例子[27]:将变换成,具有将表达式“从内至外”翻转的效果,从而使求值次序以及控制流程变得更加明确。

下面是Standard ML中的高阶函数foldrmap的续体传递风格实现,和达成一个整数列表的合计函数的简单用例[30]

fun foldr' f b l = let
    fun cf ([], k) = k b
      | cf (a :: r, k) = cf (r, fn x => k (f (a, x)))
    in
        cf (l, fn x => x)
    end

fun map' f l = foldr' (fn (x, y) => (f x) :: y) [] l 

fun sum l = foldr' (fn (x, y) => (x + y)) 0 l

所有CPS函数比如这里的cf,都接受一个额外的实际参数比如这里的k,它被称为续体。在CPS函数调用中的所有实际参数,必须要么是一个λ表达式比如这里fn x => k (f (a, x)),它将续体k应用于函数f部份应用,要么是一个变量比如这里的lr,而不能是更复杂的表达式。当CPS函数已经计算出来其结果值的时候,它通过以结果值作为实际参数调用续体函数来返回它,比如这里的cf ([], k) = k b,在计算的任何步骤中只要直接返回结果值就会终止整个计算。一个函数比如这里的foldr',要调用CPS函数比如这里的cf,就必须提供一个函数比如这里的fn x => x,用来接受它所调用的CPS函数的结果值。

对于输入[e1, e2, …, en],这里的sum函数等价于将函数复合(fn x => x)∘(fn x => e1 + x)∘(fn x => e2 + x)∘…∘(fn x => en + x),应用于0之上,它得到(e1 + (e2 + (… + (en + 0)…)))。此外,建立在惰性求值柯里化之上的Haskell提供了函数复合算子

下面是在Scheme语言中仅使用其基本形式的几个例子,对比了直接风格代码和对应的CPS代码,这里约定了将续体函数表示为名为k的形式参数:

更多信息 直接风格, 续体传递风格 ...

对于函数式编程者而言,以续体传递风格表达代码使得在直接风格中隐含的一些事物显露出来,这包括了:过程返回变成了对续体的调用,中间值都有了给定名字,实际参数求值的次序变为显见,而尾调用变成将传递给这个调用者的续体不加修改的传递给被调用过程。

注意在CPS版本的代码中,使用的函数原语(functional primitive)[31],比如这里的*&+&-&=&sqrt&,自身也是CPS而非直接风格,所以要使得上述例子在Scheme系统中运行,就必须写出这些函数原语的CPS版本,例如=&定义为:

(define (=& x y k)
  (k (= x y)))

一般编程在写CPS函数之时,经常不采用CPS函数原语[32],比如将前面的阶乘函数写为:

(define (factorial& n k)
  (if (= n 0)
    (k 1)
    (factorial& 
      (- n 1) 
      (lambda (acc) (k (* n acc))))))

要在REPL或直接风格函数中调用CPS函数,必须提供接受CPS函数计算结果的一个续体,比如恒等函数

(pythag& 3 4 (lambda (x) x))
(factorial& 4 (lambda (x) x))

采用续体传递风格使函数式编程者能获得以任意方式操纵控制流程的表达能力,代价是手工维护控制续体通常是高度复杂的任务。终极解决方案是用Scheme写一组转换例程,将Scheme的直接风格表达式英语Expression (computer science)转换成CPS表达式[33],下面的例子仅将直接风格函数原语转换成CPS函数原语:

(define (cps-prim f)
  (lambda args
    (let ((r (reverse args)))
      ((car r) (apply f (reverse (cdr r)))))))

(define =& (cps-prim =))
(define *& (cps-prim *))
(define +& (cps-prim +))
(define -& (cps-prim -))
(define sqrt& (cps-prim sqrt))

这里的reverse函数反转LISP列表所用的单向链表,其首次应用将原来尾部元素反转到了首部,接着取出这个元素应用并再次应用reverse函数将余下诸元素反转成原来次序。apply英语apply函数的应用(apply f args),以args列表的诸元素作为实际参数调用f函数[34]eval函数和apply函数是元循环求值器的两大中心构件[35]

Remove ads

以当前续体调用

小步语意

Remove ads

非形式化概述

Scheme语言包括了实化控制流程算子call-with-current-continuation英语call-with-current-continuation(简写为call/cc)。call/cc所建立的续体是头等对象,具有函数应用英语Function application作为其唯一的运算,这种续体有着无限制的时效范围英语Extent,可以被存储在变量或数据结构之中,它可以按需要多次调用。

call/cc的调用(call/cc f)中的函数f,接受当前续体作为其唯一实际参数。在后续执行中将这个续体应用于一个实际参数之时,此刻生效的续体被舍弃,转而使用所应用的这个续体,控制流程将在这个续体被捕获的那一点上继续,而传递给这个续体的实际参数则变成这个call/cc调用的返回值。

Scheme编程语言示例

立即返回

下面的例子中,使用call/cc来模拟C风格语言中的return语句

(define (f return)                                                                                                                               
  (return 2)                                                                                                                                     
  3)

(f (lambda (x) x))
=> 3

(call/cc f)
=> 2

第一个演示,以恒等函数(lambda (x) x)作为实际参数调用函数f,函数f将绑定到形式参数return上的恒等函数应用于2,接着执行最后一个表达式3,从而函数f返回3。第二个演示,将call/cc应用于函数f,函数f将绑定到形式参数return上的续体应用于2,这在控制流程上等价于非局部跳转回到调用(call/cc f)的那一点上,将其代换英语Substitution (logic)为返回值2

Remove ads

生成器

下面是生成器的实现代码:

(define (generator lst)
  (define (iterator yield)
    (for-each (lambda (item)
      (set! yield (call/cc (lambda (resume)
        (set! iterator resume)
        (yield item)))))
      lst)
    (yield 'stop-iteration))
  (lambda () (call/cc iterator)))

在这里call/cc被用到了两处:一处是(call/cc iterator),它以当前取用遍历返回值续体调用生成器所生成的迭代器;另一处是(call/cc (lambda (resume) ……)),它在迭代器遍历目标列表之时捕获当前位置,用于下次重入迭代器之时于此处恢复执行。下面是上述代码的简单用例:

(define generate-digit
  (generator '(0 1 2)))

(define (display-two-digits)
  (display (generate-digit)) (newline)
  (display (generate-digit)) (newline))

(display-two-digits) ;; 分两行打印 0 和 1
(display-two-digits) ;; 分两行打印 2 和 stop-iteration

在定义变量generate-digit之时,将其定义为函数generator应用于聚集(aggregate)即这里的列表'(0 1 2)之上,而得到的一个闭包lambda () (call/cc iterator))

在第一次执行(generate-digit)之时,执行(call/cc iterator),进而执行(iterator yield),它将取用遍历返回值续体绑定到形式参数yield之上;然后开始遍历列表的元素进行迭代的(for-each (lambda (item) ……) lst),在求值(set! yield (……))的第二个实际参数之时,进行每一次迭代步骤(call/cc (lambda (resume) ……)),其中的(set! iterator resume),将绑定到变量resume上的当前续体,重新绑定到函数名字iterator之上用于以后恢复执行,最后执行暂停表达式(yield item)返回当前列表项目。

在下一次执行(generate-digit)之时,(call/cc iterator)iterator所绑定的续体应用于当前取用遍历返回值续体之上,从而在上次迭代的(set! yield (call/cc ……))之处恢复执行,这个函数的实际参数求值完毕,它的执行将新传入的取用遍历返回值续体绑定到变量yield上,此后继续这一次的迭代步骤。在遍历了列表的元素之后迭代结束,最终执行(yield 'stop-iteration),返回一个约定的文字英语Literal (computer programming)常量。

协程

call/cc还可以表达其他复杂的原始运算。下面的代码使用续体达成协作式多任务用户级线程,亦称为协程

(define *ready-list* '())

(define (yield)
  (call/cc (lambda (return)
    (let ((cont (car *ready-list*)))
      (set! *ready-list* (append (cdr *ready-list*) (list return)))
      (cont #f)))))

(define (fork fn . args)
  (call/cc (lambda (return)
    (set! *ready-list* (cons return *ready-list*))
    (yield)
    (apply fn args)
    (let ((cont (car *ready-list*)))
      (set! *ready-list* (cdr *ready-list*)) 
      (cont #f)))))

(define (schedule)
  (let loop ()
    (if (not (null? *ready-list*)) (begin
      (yield)
      (loop)))))

这里的全局变量*ready-list*是就绪线程列表,它存储处于脱离(detached)状态的就绪线程的重入续体;在选择重入线程的时候,将就绪线程列表头部的续体从列表中取出,并应用它以重入对应线程。退让过程yield,捕获对应于调用者的当前续体,将其追加到就绪线程列表的尾部,取出并应用就绪线程列表头部的续体而使本线程脱离运行。

分叉过程fork,接受一个函数fn和相应的参数列表args,它首先捕获自身的当前续体并将其添加到就绪线程列表的头部;然后调用退让过程yield,从而创建了一个用来创建工人(worker)线程的线程,接着取出并应用就绪线程列表头部的续体而使本线程脱离运行;由于分叉过程自身的续体已经添加到了就绪线程列表头部,重入它而随即结束分叉过程的这次调用。当重入这个创建工人线程的线程之时,调用函数fn,预期这个函数在自身之中调用退让过程yield,从而创建重入函数fn内部的工人线程。当工人线程最终并未调用退让过程而从函数fn直接退出回到分叉过程之时,必须接着以取出并应用就绪线程列表头部的续体的方式来结束这个工人线程,因为对分叉过程的调用早已结束。

调度过程schedule,持续检测就绪线程列表,只要有任何其他线程等待就调用退让过程yield,取出并应用就绪线程列表头部的续体而使本线程脱离运行,最终在无就绪者等待之时结束。调度过程在每一轮并发运行循环中只运行一次。调度过程在所有分叉过程之后调用,它起到的根本作用是充当会合点,即并发运行的多个线程中的最终剩下的唯一线程,防止出现不介入调度过程转而完全由工人线程自行调度,会有可能最终会合于某个不处在最后的分叉过程的非预期情况。

上述代码的简单用例:

(import (srfi 28))

(define (echo-string-n-times str n)
  (let loop ((i 0))
    (if (< i n) (begin
      (display (format "~a ~a~%" str i))
      (yield)
      (loop (+ i 1))))))

(define (main)
  (fork echo-string-n-times "This is AAA" 3)
  (fork echo-string-n-times "Hello from BBB" 2)
  (schedule))

(main)

其输出为:

This is AAA 0
Hello from BBB 0
This is AAA 1
Hello from BBB 1
This is AAA 2

最后演示对上述协程代码的一种变化:将退让过程yield改成具有返回值,调用了退让过程的分叉过程fork也相应更改;将调度过程schedule改为采用弹跳床英语trampoline (computing)策略,它与前面代码的区别在于将调度过程自身的续体添加到就绪线程列表的头部;并为调度过程增加了一个函数参数fn,用这个函数来处理工人线程的返回值。这种调度过程也可以改名为分派过程dispatch

(define *ready-list* '())

(define (yield x)
  (call/cc (lambda (return)
    (let ((cont (car *ready-list*)))
      (set! *ready-list* (append (cdr *ready-list*) (list return)))
      (cont x)))))

(define (fork fn . args)
  (call/cc (lambda (return)
    (set! *ready-list* (cons return *ready-list*))
    (yield #f)
    (apply fn args)
    (let ((cont (car *ready-list*)))
      (set! *ready-list* (cdr *ready-list*)) 
      (cont #f)))))

(define (schedule fn)
  (let loop ()
    (if (not (null? *ready-list*)) (begin
      (fn (call/cc (lambda (return)
        (let ((cont (car *ready-list*)))
          (set! *ready-list* (cons return (cdr *ready-list*)))
          (cont #f)))))
      (loop)))))

下面是其用例:

(import (srfi 28))

(define (echo-string-n-times str n)
  (let loop ((i 0))
    (if (< i n) (begin
      (yield (format "~a ~a~%" str i))
      (loop (+ i 1))))))

(define (main)
  (fork echo-string-n-times "This is AAA" 3)
  (fork echo-string-n-times "Hello from BBB" 2)
  (schedule display))

(main)

编程语言的直接支持

很多编程语言以各种名义体现出头等续体,特别是:

参见

引用与注释

延伸阅读

外部链接

Loading related searches...

Wikiwand - on

Seamless Wikipedia browsing. On steroids.

Remove ads