最短的咒
首发于最短的咒
用continuation实现非确定计算

用continuation实现非确定计算

原文地址(我的博客):

http://notebook.xyli.me/SICP/continuations-for-nondeterministic-evaluator/notebook.xyli.me

刚拿到Structure and Interpretation of Computer Programs 的时候我扫了一眼没看见全书有提到过call/cc,所以天真的以为continuation相关的内容不会出现,就暂时放心的把书扔在了一边等以后有空慢慢看,毕竟continuation真是让我头疼不已的内容。现在发现在4.3.3 Implementing the Amb Evaluator 实现非确定求值器(nondeterministic evaluator)时,continuation作为参数过程,成功时可以推进进一步求值,失败时可以回滚到上一个选择分支。所以到这个时候了continuation还是绕不开的话题,没办法也只能硬着头皮强行理解了,再加上整个4.3 Variations on a Scheme -- Nondeterministic Computing 的内容编排顺序也很容易让人(其实只有我)困惑,所以写这篇笔记按照自己的理解写一下如何实现Scheme的非确定求值系统。

有能力的人我建议还是先看原书,虽然个人感觉原书内容排的极其不友好,但硬啃下来还是没有问题的,只是文字材料太多很容易精力分散不知所云,而且代码内容距离比较远,不能通过实例体会到文字叙述的重点,所以如果看书最好先从代码密集的地方开始看实现,看不懂的地方再看上下文的文字解释,差不多感觉有点意思了再去看长篇的文字描述。另外,这小节的内容与数字电路模拟那篇相反,一开始太专注于描绘底层实现,会让人产生为什么底层要怎么写的疑问,实际上都是为了top-level的特性有意写成这些奇怪的形式的,先去了解Evaluating amb expressions和top-level的Driver loop部分会好很多。如果还是觉得看书很辛苦,可以试试来读我这篇消化后的笔记,或许可以有所帮助。


amb表达式和非确定返回值

在确定计算(deterministic computing)中,当输入一定时,输出的结果也是确定的。在纯函数式语言(不允许变量的变值操作)中体现为用相同的参数调用同一个函数,一定会返回相同的结果。这个返回结果不一定是标量的(scalar)值,可以是一个tuple,可以是一个list,可以是规模很大的矩阵,甚至可以是长度无限的流……当然也可以是这些复杂数据结构之间的多层复合,但我们仍然视之为单个的确定值。与此相反,如果每次返回的结果即使是并不复杂的小规模的数据,但相同的调用返回不同的结果,就是非确定的计算。

一个不怎么函数式的例子就是随机数生成函数rand,每次使用(rand)都会返回一个不同的随机数。当然,众所周知,这只是伪随机数生成函数rand-update的包装[^1],隐藏了用于生成下一个随机数的种子参数。


(define random-init 1)

(define (rand-update x)
  (remainder (+ (* 23 x) 19) 101))

(define rand (let ((x random-init))
                (lambda ()
                  (set! x (rand-update x))
                  x)))

这里实现非确定性的方法是通过每次计算后再更新rand的局部变量x的赋值,即通过调用函数的副作用(side effect)来改变每一次的返回结果。

非确定的计算可以用于得到满足一组约束的任一可行结果。当然在计算资源充足的前提下,计算出所有可行结果把它们复合成一个数据对象(如list)返回,显然是一种更充分的做法,但很多情况下我们未必需要得到所有可行的结果(比如伪随机数的生成),很可能每次只需要一个结果;而且可行结果可能有无数个,或许可以把它们作为流(stream)返回,每次取用只计算一个需要的结果,后面可以看到,在确定求值的解释器上实现非确定求值器确实也使用了类似延迟计算的思想,但表现为每次只返回一个干净的结果。

amb是实现非确定求值需要用到的重要的特殊表达式,名字来源于ambiguously

(amb <e1> <e2> ... <en>)

表示可以取任<e1><en>的任一表达式作为返回值。比如

(list (amb 1 2 3) (amb 'a 'b))

可以产生6种可能的返回结果(1 a) (1 b) (2 a) (2 b) (3 a) (3 b),每次调用返回的只能是6个结果之一。当amb只有一个参数时,只有一种确定的返回结果;当amb没有参数时,即调用(amb)时,我们的程序“走投无路”,不得不中止返回。因此我们可以把约束p写为这样的形式:

(define (require p)
  (if (not p) (amb)))

在其他程序中就可以指令式的使用(require p)检查当前情况是否满足约束,如果不满足就直接跳回。从一个list中取任一元素的过程an-element-of可以写成这样

(define (an-element-of items)
  (require (not (null? items)))
  (amb (car items) (an-element-of (cdr items))))

初见这个写法的时候可能有人会和我一样困惑,如果amb是等概率随机选择那么items靠前的元素被选到的概率岂不是远大于后面的?按照应用序(applicative order)模型计算函数调用,会先计算函数的所有参数再把它们代入函数体求值,那么接下来会不断的计算最后一行的第二个参数,直至不满足require导致求值失败为止,但amb本身却一次也没被计算,这样不是没有达到预期效果吗?

首先解释第二个问题,amb表达式不是普通过程(ordinary procedure),不遵循一般函数的求值规则,它和if表达式,cons表达式等都是一种特殊形式(special form),不需要等所有参数都完成求值后才传入进行求值,这一点可以在后文解释器对amb语句的分析和求值中看到具体是如何实现的,现在只需要知道,它每次只计算被选中的参数的值。

回到第一个问题,等于触及本质的提问,amb到底是怎么做的?

我们可以把确定程序(实质就是某个函数的调用)想象成一个顺序执行的路径,哪怕存在ifcond的分支,因为某次调用的参数的固定的,所以接下来进行哪个分支也是确定的,而amb语句则被视为开始不同分支的选择结点,每个参数都会延伸出一条独立的路径。当遇到没有参数的(amb)或者其他错误时表示“此路不通”。amb执行的其实是我们熟悉的深度优先搜索(depth-first search, DFS),并当遇到失败时回溯(backtrack)到最近的选择结点选择另一条路径,直至找到第一个成功的结果返回。至于选择是依靠什么做出的,可以在后面的实现中看到,就是简单的按照参数的顺序依次去尝试,这也是BFS通常的做法,这样的做法确实会倾向于先返回参数顺序靠前的可行结果,需要下一个可行结果的时候也可以通过调用try-again得到,概率上的“公平”对于这个问题又有什么现实意义呢?

一个用于找和为质数的整数对的例子:

;;; Amb-Eval input:
(prime-sum-pair '(1 3 5 8) '(20 35 110))
;;; Starting a new problem
;;; Amb-Eval value:
(3 20)
;;; Amb-Eval input:
try-again
;;; Amb-Eval value:
(3 110)
;;; Amb-Eval input:
try-again
;;; Amb-Eval value:
(8 35)
;;; Amb-Eval input:
try-again
;;; There are no more values of
(prime-sum-pair (quote (1 3 5 8)) (quote (20 35 110)))
;;; Amb-Eval input:
(prime-sum-pair '(19 27 30) '(11 36 58))
;;; Starting a new problem
;;; Amb-Eval value:
(30 11)

语法分析

非确定求值器是在4.1.7 Separating Syntactic Analysis from Execution实现的,预先完成语法分析再代入环境执行计算的求值器的基础上改造完成的,因为两种求值器的框架十分相似。

为了提高求值的效率,对表达式的求值可以被分为用analyze分析表达式,返回一个只有环境作为参数的过程。

(define (eval exp env)
  ((analyze exp) env))

analyze是结构与eval相似的dispatch函数,对不同表达式分析出不同的结果,实现细节可以直接去看源码,这里仅观察对于lambda表达式的分析

(define (analyze-lambda exp)
  (let ((vars (lambda-parameters exp))
        (bproc (analyze-sequence (lambda-body exp))))
    (lambda (env) (make-procedure vars bproc env))))

如果这个lambda表达式被绑定到了某个函数名,以函数名反复多次调用这个函数时,lambda表达式的函数体bproc只需要被分析一次,可以极大提高求值效率。

在非确定求值器中,也是这样先对表达式进行语法,再把环境env作为参数之一传入分析结果得到最终结果,不同的是增加了另外两个参数,也就是两个续延(continuation)过程,成功续延(success continuation)succeed表示如果对这个表达式求值成功,接下来应该做什么,失败续延(failure continuation)fail表示如果当对这个表达式的求值陷入死路或者其他失败情况(如再次调用try-again)时需要被调用的过程。总之,求值写为

(define (ambeval exp env succeed fail)
  ((analyze exp) env succeed fail))

续延的结构

上文提到的success continuation更接近于我们在call/cc接触到的current continuation,不过这话说的很没意义,这里实现的continuation无论哪种和实践中的call/cc使用起来差别都不小。

success continuation负责接收表达式的求值结果并开始进一步的计算(这一点可以在后文对简单表达式的分析中看到典型的例子),除了这个值,还需要另一个failure continuation过程作为参数,来处理接下来在success continuation中如果遇到求值失败的情况。

而failure continuation是一个没有参数的过程,当对当前分支的求值失败时会被调用,从当前对这个错误的分支跳出,从而开始对另一个分支的求值。

所以对一个表达式的语法分析结果是这样的一个lambda表达式:

(lambda (env succeed fail)
  ;; succeed is (lambda (value fail) ...)
  ;; fail is (lambda () ...)
  ...)

当对某个表达式求值时,如果用如下的实参

(ambeval <exp>
         the-global-environment
         (lambda (value fail) value)
         (lambda () 'failed))

如果计算<exp>顺利得到了结果,将会执行success continuation,直接返回求值结果;如果求值失败,将执行failure continuation返回'failed

在这个非确定求值器的设计中,success continuation并不怎么需要费心去构造,在driver-loop中已经规定过对一个表达式最终求值成功只需要返回求值结果并开始下一轮输入,所以在大部分场景中succeed只是用来调用或者传递,少数需要重新构造的难点也不在逻辑本身。

比较麻烦的是failure continuation,首先要注意在每个新的success continuation的构造中都会使用一个新的failure continuation作为形参;其次是它的构造和调用场景的限制。

它的构造由只由以下操作完成:

  • amb表达式: 具体来说是在对amb表达式的分析和求值时,会构造新的failure continuation,这个failure continuation的内容是再去尝试当前amb中的其他选择。
  • top-level的driver loop:声明了如果对输入表达式的求值失败,即没有剩下的可行解,应该做什么,failure continuation的内容可以是打印提示信息然后再开始一个loop
  • 赋值: 当选择了一个错误的分支,并进行了这个分支内对变量的变值操作时,如果想放弃对这个分支的继续计算,跳到其他分支,那么必须消除赋值等副作用。

它的调用在以下情况发生:

  • 执行(amb)时:除了像require那样特意为了失败而失败以外,还有可能只是因为耗尽了amb表达式中所有的选择
  • 用户在top-level输入了try-again迫使程序返回下一个可行结果,那么必须使用failure continuation跳到最近选择点的另一个分支。



实现细节

接下来从driver-loop和对amb表达式的分析开始,来看我们的分析器和求值器怎样处理各种语句。

amb表达式

首先来看对amb表达式的分析

(define (amb? exp) (tagged-list? exp 'amb))
(define (amb-choices exp) (cdr exp))

(define (analyze-amb exp)
  (let ((cprocs (map analyze (amb-choices exp))))
    (lambda (env succeed fail)
      (define (try-next choices)
        (if (null? choices)
            (fail)
            ((car choices) env
                           succeed
                           (lambda ()
                             (try-next (cdr choices))))))
      (try-next cprocs))))

当没有选择((amb)语句或者已经耗尽了所有选项)时,直接执行fail,否则执行当前第一个选择(car choices),并把尝试剩下的选择(try-next (cdr choices)作为当前这项选择失败后需要进入的failure continuation。这就是非确定选择实现的核心部分,由分析amb语句构造出来的failure continuation会一直传递下去,直至当前选择遭遇失败时才会执行它跳到最近选择点的下一个选择。

简单总结一下,当还有可选项时,执行可选项并把尝试剩下的选项作为failure continuation,当没有可选项时直接调用这个语句本身的failure continuation即跳回上一个选择点。这种做法是符合DFS的逻辑的。

Driver loop

driver-loop直接接受用户的输入并输出求值结果,维持着一个循环接受新的表达式或者try-again命令。

(define input-prompt ";;; Amb-Eval input:")
(define output-prompt ";;; Amb-Eval value:")
(define (driver-loop)
  (define (internal-loop try-again)
    (prompt-for-input input-prompt)
    (let ((input (read)))
      (if (eq? input 'try-again)
          (try-again)
          (begin
            (newline)
            (display ";;; Starting a new problem ")
            (ambeval input
                     the-global-environment
                     ;; ambeval success
                     (lambda (val next-alternative)
                       (announce-output output-prompt)
                       (user-print val)
                       (internal-loop next-alternative))
                     ;; ambeval failure
                     (lambda ()
                       (announce-output
                        ";;; There are no more values of")
                       (user-print input)
                       (driver-loop)))))))
  (internal-loop
   (lambda ()
     (newline)
     (display ";;; There is no current problem")
     (driver-loop))))

这里的内部过程internal-loop会接受一个参数过程,作为收到try-again命令时需要执行的内容。在internal-loop初始化时,这个参数过程被设置成了提示";;; There is no current problem"并再次发起循环;当计算新的表达式时,整个ambeval的success continuation被初始化为

(lambda (val next-alternative)
  (announce-output output-prompt)
  (user-print val)
  (internal-loop next-alternative))

全局的计算得到结果以后,把值作为val参数传递给这个success continuation,继而打印显示给用户;而next-alternative参数对照上文一般的success continuation的结构,应该是另一个用于处理后续求值失败的failure continuation,到达调用这个全局success continuation时,除了已经计算出的表达式结果,还有从最近的选择点构造出的failure continuation(内容是尝试剩下的选择)一直传递下直到被当成next-alternative传入,那么当打印完求值结果后,还会把next-alternative作为参数再调用一次internal-loop,当用户输入try-again时便可以直接调用next-alternative过程,即由上次求值附带的failure continuation进入最近选择点的另一个分支开始寻找另一个可行的求值结果,另外,即使是调用的next-alternative的success continuation也还是这个全局的success continuation,只是再调用时参数发生了变化,所以返回另一个结果后又开始开始一轮循环,这是很微妙的设计。

以上全是我已经假设了最后的failure continuation是由最近的amb选择点构造的情况,现实情况会更复杂一些,比如一直没有遇到amb语句,那么就会使用最初传入的failure continuation,提示";;; There are no more values of"并重置循环。

更多情况需要参见求值器对语言中其他常见表达式的处理。

简单表达式

对简单表达式的求值在执行过程只需要把求值结果和failure continuation都传递给success continuation执行即可,相比普通的求值器,只需要增加continuation的维护。

(define (analyze-self-evaluating exp)
  (lambda (env succeed fail)
    (succeed exp fail)))
(define (analyze-quoted exp)
  (let ((qval (text-of-quotation exp)))
    (lambda (env succeed fail)
      (succeed qval fail))))

self-evaluating对象(如数值常量)和quotation的求值结果与env无关,也不会发生求值失败,直接计算出交给succeed进行后续操作即可。

(define (analyze-variable exp)
  (lambda (env succeed fail)
    (succeed (lookup-variable-value exp env)
             fail)))

变量绑定的值需要在env查找,如果无法找到也是因为用户的程序编写问题而不是因为非确定计算的问题,因此可以直接把结果和fail传入succeed继续求值。

(define (analyze-lambda exp)
  (let ((vars (lambda-parameters exp))
        (bproc (analyze-sequence (lambda-body exp))))
    (lambda (env succeed fail)
      (succeed (make-procedure vars bproc env)
               fail))))

对lambda表达式的求值结果也是一个封装了形参,函数体和定义环境的过程对象,直接传入succeed

条件和顺序语句

现在开始接触到一些有执行顺序的语句,这时,仅当对表达式的某一部分求值成功后,才可以开始下一部分的求值。

(define (analyze-if exp)
  (let ((pproc (analyze (if-predicate exp)))
        (cproc (analyze (if-consequent exp)))
        (aproc (analyze (if-alternative exp))))
    (lambda (env succeed fail)
      (pproc env
             ;; success continuation for evaluating the predicate
             ;; to obtain pred-value
             (lambda (pred-value fail2)
               (if (true? pred-value)
                   (cproc env succeed fail2)
                   (aproc env succeed fail2)))
             ;; failure continuation for evaluating the predicate
             fail))))

if条件语句需要先执行它的predicate部分的求值,如果求值成功才可以根据predicate的值决定执行接下来哪个语句。因此整个表达式的分析结果是这样的函数,执行predicate的分析结果pproc,即对predicate求值,构造了一个新的success continuation,它的意思是:如果predicate求值成功,接下来根据它的值选择执行cprocaproc,求值成功后再进入原来的succeed

顺序语句,即cond或lambda表达式内可能出现多个连续语句,或者begin表达式,执行其中的每个表达式,但只返回最后一个表达式的求值结果作为最终结果。

(define (analyze-sequence exps)
  (define (sequentially a b)
    (lambda (env succeed fail)
      (a env
         ;; success continuation for calling a
         (lambda (a-value fail2)
           (b env succeed fail2))
         ;; failure continuation for calling a
         fail)))

  (define (loop first-proc rest-procs)
    (if (null? rest-procs)
        first-proc
        (loop (sequentially first-proc (car rest-procs))
              (cdr rest-procs))))

  (let ((procs (map analyze exps)))
    (if (null? procs)
        (error "Empty sequence -- ANALYZE"))
    (loop (car procs) (cdr procs))))

这里的内部过程(sequentially a b)很好的解释了顺序语句的执行,和if语句类似,先对分析结果a进行求值,构造新的success continuation表示如果成功继续对b求值。

定义和赋值

定义时需要先计算变量被定义的表达式,那就同样要考虑如果这个表达式求值不成功应该怎么办。

(define (analyze-definition exp)
  (let ((var (definition-variable exp))
        (vproc (analyze (definition-value exp))))
    (lambda (env succeed fail)
      (vproc env                        
             (lambda (val fail2)
               (define-variable! var val env)
               (succeed 'ok fail2))
             fail))))

这也是与上面的做法类似的过程,先对被表达式求值,构造新的success continuation调用它的分析结果,使成功的求值结果再进入success continuation完成绑定并把绑定操作的结果'ok传入原来的succeed继续接下来的操作。

这里不用担心如果完成定义后陷入思路重新开始时是否需要撤回定义,为了方便,我们假设这些内部定义语句是转化成let表达式的scan out操作完成的(可以见前篇局部绑定和内部定义)。

但赋值操作就必须考虑在失败后消除它的副作用。

(define (analyze-assignment exp)
  (let ((var (assignment-variable exp))
        (vproc (analyze (assignment-value exp))))
    (lambda (env succeed fail)
      (vproc env
             (lambda (val fail2)        ; *1*
               (let ((old-value
                      (lookup-variable-value var env)))
                 (set-variable-value! var val env)
                 (succeed 'ok
                          (lambda ()    ; *2*
                            (set-variable-value! var
                                                 old-value
                                                 env)
                            (fail2)))))
             fail))))

首先还是像定义语句那样,对被绑定的表达式求值,如果求值成功,进入新的success continuation(即代码中*1*开始的位置),在这里先保存变量原来的值old-value,再对变量进行变值操作,赋值操作的结果'ok传递给succeed进行后续计算,注意succeed的第二个实参也是重新构造的failure continuation(*2*开始的位置),在赋值操作完成之后的后续计算中,如果遇到了求值失败会调用这个failure continuation,在执行原计划的(fail2)之前会先把变量恢复成改变之前的值。

过程调用

函数调用更为复杂一点。首先需要考虑函数被调用时还需要对所有实参求值,对每个实参求值时都可能遭遇求值失败,导致中断,因此不能用原来的map直接算出实参列表,需要写一个新的get-args对实参的分析结果列表aprocs进行依次求值:

(define (get-args aprocs env succeed fail)
  (if (null? aprocs)
      (succeed '() fail)
      ((car aprocs) env
                    ;; success continuation for this aproc
                    (lambda (arg fail2)
                      (get-args (cdr aprocs)
                                env
                                ;; success continuation for recursive
                                ;; call to get-args
                                (lambda (args fail3)
                                  (succeed (cons arg args)
                                           fail3))
                                fail2))
                    fail)))

有些像顺序语句的求值,但不同的是需要返回列表中所有实参的求值结果。在对第一个参数的分析结果求值成功后,这个值arg会进入line 6开始的success continuation,对剩下的参数分析结果用get-args递归求值得到参数列表args,完成后进入下一个从line 11开始的success continuation,再把它们cons起来返回给最终的succeed。除了维护continuation有些麻烦,把aprocs一层层cdr下来求值再通过构造新的succeed把结果一层层cons起来,这种做法和map的递归逻辑别无二致。

(define (analyze-application exp)
  (let ((fproc (analyze (operator exp)))
        (aprocs (map analyze (operands exp))))
    (lambda (env succeed fail)
      (fproc env
             (lambda (proc fail2)
               (get-args aprocs
                         env
                         (lambda (args fail3)
                           (execute-application
                            proc args succeed fail3))
                         fail2))
             fail))))

对调用语句分析也是需要小心的按照顺序来,先对函数名(操作符)的分析结果fproc执行求值,成功后再开始用get-args对实参列表求值,最后用execute-application去计算调用结果。

(define (execute-application proc args succeed fail)
  (cond ((primitive-procedure? proc)
         (succeed (apply-primitive-procedure proc args)
                  fail))
        ((compound-procedure? proc)
         ((procedure-body proc)
          (extend-environment (procedure-parameters proc)
                              args
                              (procedure-environment proc))
          succeed
          fail))
        (else
         (error
          "Unknown procedure type -- EXECUTE-APPLICATION"
          proc))))

execute-application的定义和在普通求值器中除了管理continuation以外没有什么大的区别,调用是一步操作,因此没有前文那样反复构造新的success continuation的需求。对于primitive来说,不存在调用过程本身引起的求值失败,直接把调用结果作为参数传递给succeed就可以了;对于用户自己编写的复合过程,在当前两个continuation和添加实参绑定的定义环境下对函数体的分析结果进行调用计算,求值完成后,无论成功或失败,对于的continuation会作为实参被传入函数体的分析结果,用以执行下一步操作。


[1]: 使用种子 x 生成伪随机的一种常见做法是: 挑选合适的大整数 a , b , N ,返回 ax+b \bmod N ,我所给出的实现里面三个整数的挑选实际上都并不是很合适,仅为了演示,这里不再详细讨论怎样去生成统计性质良好的随机序列。

发布于 2018-11-10

文章被以下专栏收录