热门问题
时间线
聊天
视角

续体

在计算机科学中,续体(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的论文《语法和语义的递归定义》中,倡导通过将真正(proper)过程变换成续体传递风格而消除标签goto语句[21],但是他没有使用“续体”这个名字。

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

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

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

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

Remove ads

续体传递风格

续体的概念主要起源于计算模型研究,比如λ演算[26]指称语义[17]演员模型[27]。这些模型仰仗于编程者或语义工程师书写数学函数时采用“续体传递风格”(continuation-passing style,简写为CPS)[28]。续体传递风格(CPS)意味着编程中用到的每个函数都消费一个表示有关于这个函数调用的余下计算的函数。

所有CPS函数都接受显式的被称为续体的一个额外的实际参数,并且在函数调用中的所有实际参数必须要么是一个变量,要么是一个λ表达式而非更复杂的表达式。这具有将表达式“从内至外”翻转的效果,由于表达式最内部分必须首先求值,因此求值次序以及控制流程变得更明确了。当CPS函数已经计算出来其结果值的时候,它通过以这个值作为实际参数调用续体函数来返回它,而要终止这个计算则简单的返回这个值。这意味着在调用CPS函数的时候,调用者函数需要提供一个过程接受这个子例程的返回值。

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

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

对于函数式编程者而言,以续体传递风格表达代码使得在直接风格中隐含的一些事物显露出来,这包括了:过程返回变成了对续体的调用,中间值都有了给定名字,实际参数求值的次序变为显见,而尾调用变成将传递给这个调用者的续体不加修改的传递给被调用过程。采用续体传递风格使其能获得以任意方式操纵控制流程的表达能力,代价是他们必须手工维护控制和续体的不变条件,这通常是高度复杂的任务。

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

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

更通用的方式是写一个转换例程:

(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))

要在REPL或直接风格函数中调用CPS函数,必须提供接收CPS函数计算结果的一个续体。上述例子在所需CPS函数原语均已提供的条件下,可以指定其续体为恒等函数

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

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

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

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

fun foldr' f b l = let
    fun f2 ([], k) = k b
      | f2 (a :: r, k) = f2 (r, fn x => k (f (a, x)))
    in
        f2 (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

对于输入[e1, e2, …, en]sum函数等价于将函数复合(fn x => x)∘(fn x => e1 + x)∘(fn x => e2 + x)∘…∘(fn x => en + x),应用于0之上,它得到(e1 + (e2 + (… + (en + 0)…)))[30]。此外,Haskell提供了函数复合算子

Remove ads

以当前续体调用

小步语义

Remove ads

非形式化概述

Scheme编程语言包括了控制流程算子call-with-current-continuation英语call-with-current-continuation(简写为call/cc),Scheme程序可以通过它操纵控制流程。在计算机科学中,使这种类型的隐含程序状态可见为对象,术语上叫做实化。在Scheme中,续体对象是头等值并被表示为函数,具有函数应用英语Function application作为其唯一的运算。Scheme在应用续体和函数二者之间不做语法上的区分。

在Scheme中,出现在一个表达式中的(call/cc f),接受一个函数f作为它的唯一实际参数,并把它应用到这个表达式的当前续体上。当一个续体对象被应用于一个实际参数的时候,现存的续体被消除,而应用的续体在其所在位置上被恢复,所以程序流程将在续体被捕获的那一点上继续,而“续体的实际参数”则变成call/cc调用的“返回值”。call/cc建立的续体可以被多于一次调用,甚至可以从这个call/cc应用的动态时效范围(extent)的外部来调用。

例如在表达式(g (call/cc f))中,在概念上给出f所应用到的当前续体的方式,是通过将(call/cc f)替代为以lambda抽象绑定的一个变量比如叫做cc,故而它的当前续体是(lambda (cc) (g cc))。对它应用函数f,得到最终结果(f (lambda (cc) (g cc)))。而在表达式((call/cc f) g)中,子表达式的(call/cc f)的续体是(lambda (cc) (cc g)),故而整个表达式等价于(f (lambda (cc) (cc g)))

Scheme编程语言示例

立即返回

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

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

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

(call/cc f)
=> 2

首先以正规函数实际参数(lambda (x) x)调用f,它将绑定到形式参数return上的这个函数应用于值2,接着执行下一个表达式返回3。但是,在f被传递给call/cc的时候,将绑定了续体的形式参数应用于2,将程序执行强制为跳转到调用call/cc那一点上,并导致call/cc返回值2。这个结果接着被顶层续体用display函数打印出来。

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应用于列表'(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*是就绪线程列表,它存储处于脱离状态的就绪线程的重入续体;在选择重入线程的时候,将就绪线程列表头部的续体从列表中取出,并应用它以重入对应线程。退让过程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)策略,它与前面代码的区别在于将调度过程自身的续体添加到就绪线程列表的头部,并增加了对线程返回值的处理,这里是显示返回值:

(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)
  (let loop ()
    (if (not (null? *ready-list*)) (begin
      (let ((x (call/cc (lambda (return)
        (let ((cont (car *ready-list*)))
          (set! *ready-list* (cons return (cdr *ready-list*)))
          (cont #f))))))
        (display x))
      (loop)))))

用例也相应作出部分更改,未列出者不变:

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

编程语言的直接支持

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

在支持闭包和适当尾调用的任何编程语言中,都可能以续体传递风格书写程序并手工实现call/cc。在续体传递风格下,call/cc成为了可以用lambda表达式书写的一个简单函数。需要支持适当尾调用,因为在续体传递风格下没有函数会返回,所有调用都是尾调用。

参见

引用与注释

延伸阅读

外部链接

Loading related searches...

Wikiwand - on

Seamless Wikipedia browsing. On steroids.

Remove ads