柯里化的前生今世(八):尾调用与CPS

关于

在上一篇中,我们介绍了continuation的概念,还介绍了Lisp中威力强大的call/cc,它提供了first-class continuation,最后我们用call/cc实现了python中的generator和yield。

call/cc赋予了我们很强的表达能力,Lisp中的异常处理机制也很人性化。
例如,Common Lisp: Condition_system
由于call/cc可以捕捉到异常处的continuation,
我们就可以手动调用这个continuation,
让程序从错误的位置以给定状态重新开始执行,
甚至结合REPL还可以询问用户,让用户输入这个状态。

其他语言的try/catch是无法做到这一点的,
我们拿到错误时,出现错误的那个环境已经被丢弃了,无法恢复,
那么除了提示用户程序崩溃了就没有别的办法了。

call/cc这么强大,更坚定了我们实现它的想法,
本文就从实现的角度来看call/cc

尾调用

In computer science, a tail call is a subroutine call performed as the final action of a procedure. 

如果在某个函数的末尾调用了另一个函数,这个调用就称为尾调用。
我们举个例子吧,

(define (f a)
  (display a)
  (g 2))

(define (g b)
  (display b))

(f 1)

我们看到,函数f的末尾调用了函数g(g 2)

尾调用有什么好处呢?
一个基本的事实是,如果gf的尾调用,g就可以不返回到f中,
而直接返回到f该返回的地方。

因为gf的尾调用,g后面没有其他调用了,
(g 2)调用结束后就可以不必返回到f的函数体中了,而是直接返回到(f 1)处。
因此,调用g的时候,调用栈可以不增加,而是直接废弃f的调用环境即可。

注意,我们上面提到的是『不必返回到f的函数体中』,
因为不是每个语言都可以做到这一点,
这个语言特性,称为尾调用优化(tail call optimization)。

调用栈和调用图

调用栈对我们来说是一个耳熟能详的名词,
可是我们有没有考虑过,为什么调用构成了一个『栈』呢?
有这么多的数据结构,为什么不是一个队列,不是一个树,不是一个图呢?

是因为函数的调用和返回机制,恰好可以用帧(frame)的压栈和弹栈来描述。
可是,尾调用优化,开始动摇了这一点,
为了能返回到调用者该返回的地方,调用栈有的时候可能会弹出两次,或者弹出更多次。

进一步,我们再来看call/cc的场景,它使得程序可以直接跳转到之前的某个状态,
根本上改变了压栈弹栈的规则,跳过去以后,以全新的状态重新开始执行。
然而,发生跳转时的状态还不能丢弃,因为有可能再跳回来。
因此,call/cc让调用不再构成一个栈,而是构成了一个调用图。

CPS

在这些复杂场景中,为了能显式的表示执行过程,
将程序转化为CPS(continuation passing style)是一种常用的办法,
CPS是一种程序的书写风格,经常作为编译器的一种中间表示。(IR

; 调用风格
(define (f x)
  (+ (g x) 1))

(define (g x)
  (* x 2))

(f 1)

; CPS
(define (f x cont)
  (g x (lambda (v)
         (cont (+ v 1)))))

(define (g x cont)
  (cont (* x 2)))

(f 1 display)

我们发现写成CPS之后,每个函数多了一个cont参数,
用来表示该函数调用表达式的continuation,
我们调用一个函数,就应该把它相应的continuation显式的传给它。
例如,我们在f中调用了g,那么我们就将(g x)的continuation传给了g,即(lambda (v) (cont (+ v 1)))

除此之外,我们还发现,CPS是一个尾调用形式,
因此程序的执行就变成了continuation的不断变换生长。

开始动手术

为了实现call/cc,首先我们要把解释器改造成CPS形式,
然后再将continuation拿出来包装一下,提供给用户使用。

我们先进行第一步改造,CPS,
回忆一下,为了实现词法作用域,我们给解释器中每个函数末尾加上了参数env,用于表示被求值表达式的环境。这次也相似,我们给每个函数加上了新的参数cont,用于表示被求值表达式的continuation,这样我们就可以将解释器改造成CPS形式了。

下一步改造我们要实现call/cc了,它直接使用了这些包含cont参数的函数,限于篇幅,CPS形式的解释器我们就略过了,这里我们只是先看一下handle-decision-tree的样子吧,

(define (handle-decision-tree tree exp env cont)
  (if (null? tree)
      (error 'handle-decision-tree "failed to make decision")
      (let* ((head (car tree))
             (predicator (car head))
             (decision (cadr head)))

        (predicator exp env
                    (lambda (predicate-result)
                      (if predicate-result
                          (if (not (list? decision))
                              (decision exp env cont)
                              (handle-decision-tree decision exp env cont))
                          (handle-decision-tree (cdr tree) exp env cont)))))))

实现call/cc

将解释器转换成CPS之后,我们就可以将cont进行包装了,
下面的实现中,我们将cont包装成了一个内部的数据结构continuation
(和闭包一样,continuation从实现的角度来看也是一个数据结构

然后,把这个数据结构提供给用户,就可以让用户代码实现自定义跳转了。
为了实现这一点,我们在解释器中判断是否调用了continuation,来做相应的处理。
handle-decision-tree增加了两个分支,is-continuation?is-continuation-call?

#lang racket

; tool

(struct closure
  (param body env))

(struct continuation
  (cont))

(define (create-frame)
  (make-hash))

(define (extend-frame frame key value)
  (hash-set! frame key value))

(define (extend-env env frame)
  (cons frame env))

(define (get-symbol-value env key)
  (let lookup-env
    ((env env))
    (if (null? env)
        (error 'get-symbol-value "failed to find symbol")
        (let ((head-frame (car env)))
          (if (hash-has-key? head-frame key)
              (hash-ref head-frame key '())
              (lookup-env (cdr env)))))))

(define (handle-decision-tree tree exp env cont)
  (if (null? tree)
      (error 'handle-decision-tree "failed to make decision")
      (let* ((head (car tree))
             (predicator (car head))
             (decision (cadr head)))

        (predicator exp env
                    (lambda (predicate-result)
                      (if predicate-result
                          (if (not (list? decision))
                              (decision exp env cont)
                              (handle-decision-tree decision exp env cont))
                          (handle-decision-tree (cdr tree) exp env cont)))))))

; env & cont

(define env `(,(create-frame)))

(define cont (lambda (v)
                 (display v)))

; main

(define (eval-exp exp env cont)
  (handle-decision-tree
   `((,is-symbol? ,eval-symbol)
     (,is-self-eval-exp? ,eval-self-eval-exp)
     (,is-continuation? ,eval-continuation)
     (,is-list?
      ((,is-lambda? ,eval-lambda)
       (,is-call/cc? ,eval-call/cc)
       (,is-continuation-call? ,eval-continuation-call)
       (,is-function-call-list? ,eval-function-call-list))))
   exp env cont))

(define (is-symbol? exp env cont)
  (display "is-symbol?\n")
  (cont (symbol? exp)))

(define (eval-symbol exp env cont)
  (display "eval-symbol\n")
  (cont (get-symbol-value env exp)))

(define (is-self-eval-exp? exp env cont)
  (display "is-self-eval-exp?\n")
  (cont (number? exp)))

(define (eval-self-eval-exp exp env cont)
  (display "eval-self-eval-exp\n")
  (cont exp))

(define (is-continuation? exp env cont)
  (display "is-continuation?\n")
  (cont (continuation? exp)))

(define (eval-continuation exp env cont)
  (display "eval-continuation\n")
  (cont exp))

(define (is-list? exp env cont)
  (display "is-list?\n")
  (cont (list? exp)))

(define (is-lambda? exp env cont)
  (display "is-lambda?\n")
  (cont (eq? (car exp) 'lambda)))

(define (eval-lambda exp env cont)
  (display "eval-lambda\n")
  (let ((param (caadr exp))
        (body (caddr exp)))
    (cont (closure param body env))))

(define (is-call/cc? exp env cont)
  (display "is-call/cc?\n")
  (cont (eq? (car exp) 'call/cc)))

(define (eval-call/cc exp env cont)
  (display "eval-call/cc\n")
  (let ((fn (cadr exp))
        (data-cont (continuation cont)))
    (eval-function-call-list `(,fn ,data-cont) env cont)))

(define (is-continuation-call? exp env cont)
  (display "is-continuation-call?\n")
  (eval-exp (car exp) env
            (lambda (value)
              (cont (continuation? value)))))

(define (eval-continuation-call exp env cont)
  (display "eval-continuation-call\n")
  (eval-exp (car exp) env
            (lambda (data-cont)
              (let ((wrapped-cont (continuation-cont data-cont)))
                (eval-exp (cadr exp) env
                          (lambda (arg)
                            (wrapped-cont arg)))))))

(define (is-function-call-list? exp env cont)
  (display "is-function-call-list?\n")
  (cont #t))

(define (eval-function-call-list exp env cont)
  (display "eval-function-call-list\n")
  (eval-exp (car exp) env
            (lambda (clos)
              (eval-exp (cadr exp) env
                        (lambda (arg)
                          (let ((body (closure-body clos))
                                (lexical-env (closure-env clos))
                                (param (closure-param clos))

                                (frame (create-frame)))

                            (extend-frame frame param arg)

                            (let ((executing-env (extend-env lexical-env frame)))
                              (eval-exp body executing-env cont))))))))

测试

(eval-exp '1 env cont)

(display "\n\n")
(eval-exp '(lambda (x) x)
          env cont)

(display "\n\n")
(eval-exp '((lambda (x) x)
            1)
          env cont)

(display "\n\n")
(eval-exp '((lambda (x)
              ((lambda (y) x)
               2))
            1)
          env cont)

(display "\n\n")
(eval-exp '((lambda (x)
              ((lambda (f)
                 ((lambda (x)
                    (f 3))
                  2))
               (lambda (z) x)))
            1)
          env cont)

(display "\n\n")
(eval-exp '(call/cc (lambda (k)
                      1))
          env cont)

(display "\n\n")
(eval-exp '(call/cc (lambda (k)
                      (k 2)))
          env cont)

要点分析

(1)eval-call/cc时会创建一个continuation
然后用这个continuation作为参数调用call/cc的参数。
call/cc的参数,就是后面的(lambda (k) 1),因此k就是这个continuation

; (call/cc (lambda (k) 1))

(define (eval-call/cc exp env cont)
  (display "eval-call/cc\n")
  (let ((fn (cadr exp))
        (data-cont (continuation cont)))
    (eval-function-call-list `(,fn ,data-cont) env cont)))

(2)eval-continuation-call会解开continuation的包装,得到内部包含的cont
然后用这个cont作为参数求值表达式,
这样就实现了,表达式求值完以后跳转到产生cont位置的效果。

(define (eval-continuation-call exp env cont)
  (display "eval-continuation-call\n")
  (eval-exp (car exp) env
            (lambda (data-cont)
              (let ((wrapped-cont (continuation-cont data-cont)))
                (eval-exp (cadr exp) env
                          (lambda (arg)
                            (wrapped-cont arg)))))))

(3)(call/cc ...)表达式中,如果k没有被调用,那么(call/cc ...)的值,就是call/cc参数函数的返回值,即(call/cc (lambda (k) 1)) = 1
这一点看起来很难实现,实则不然。

我们只需要巧妙的指定(lambda (k) 1)的continuation,
让它就是(call/cc (lambda (k) 1))的continuation即可。
这一点体现在eval-call/cc中,我们直接将cont原封不动的传给了eval-function-call-list

(define (eval-call/cc exp env cont)
   ...
    (eval-function-call-list `(,fn ,data-cont) env cont)))

下文

Lisp语言真是博大精深,写到这里我们甚至还没有提及它最重要的语言特性——宏,
Lisp宏提供了一种元编程的手段,同像性让Lisp元编程异常强大,
然而,把宏说清楚也颇费笔墨,因此,我打算在适当的时候单独讨论它。

本系列标题为『柯里化的前生今世』,意在通过柯里化引入种种有趣的概念,
目前为止,我们讨论了高阶函数,闭包,continuation,这些可以看做『柯里化的前生』,
我们不但理解了这些概念,还实现了它们,算是小有收获吧。

使用Racket也有一段日子了,对它也逐渐从陌生到熟悉,
可是偏执却容易让人误入歧途,错过其他风景,
下文我们将开启新的旅程了,Let's go !

参考

continuation passing style
Compiling with Continuations
An Introduction to Scheme and its Implementation

时间: 2024-08-04 11:55:00

柯里化的前生今世(八):尾调用与CPS的相关文章

柯里化的前生今世(一):函数面面观

关于 本文作为开篇,介绍了出场人物,并形象化的引入了高阶函数, 得到了柯里化的概念. 后续文章,会介绍高阶函数的实现方式,词法作用域和闭包,参数化类型,类型上的柯里化, 敬请期待. 如有不同的认识,或者感兴趣的点,请直接联系我,欢迎指教. 人物介绍 球星库里 库里,Stephen Curry,1988年3月14日出生于美国俄亥俄州阿克伦(Akron, Ohio), 美国职业篮球运动员,司职控球后卫,效力于NBA金州勇士队. 斯蒂芬·库里2009年通过选秀进入NBA后一直效力于勇士队,新秀赛季入选

柯里化的前生今世(九):For Great Good

关于 上文第二~八篇中,我们学习了Racket语言,它很有代表性,是一种Lisp方言. 很多概念用Racket描述会更加简便. 我们介绍了高阶函数,词法作用域,闭包以及continuation, 这些概念对理解函数式编程来说十分重要. 然而,偏见却经常起于片面. 只学习一种语言,会让我们对事物的同一个侧面产生习惯. 事实上,我们需要多样化的角度,也需要经常更换思维方式. 这对学习新知识很有帮助, 有些时候,我们理解不了某些概念,很有可能是因为这个概念被描述的不够全面, 我们经常走到深入思考这一特

柯里化的前生今世(四):编译器与解释器

关于 在上一篇中,我们提到了形式语言与文法,S表达式与M表达式,同像性. 本文将开始写一个简单的解释器, 通过具体实现,我们来理解求值环境,动态作用域和静态作用域,还有闭包等概念. 当然,一篇文章来写完这些肯定是不够的,我们可以慢慢来,循序渐进. 写完了这个解释器之后,我们会增加一些新的功能. 编译器与解释器 编译器会将源代码转换成另一种语言的代码,然后在支持后一种语言的机器上执行. 而解释器则不同,它会逐行分析源代码,直接执行分析结果. 值得一提的是,编译和解释是执行代码的两种手段, 具体的语

柯里化的前生今世(十二):多态性

关于 本文借用Haskell介绍了自定义类型,带参数的类型,Ad-hoc多态性,kind, 其中,带参数的类型在类型上可以做"柯里化". 1. 自定义类型 Haskell中使用data自定义类型. data Bool = True | False 其中,Bool是类型名,True和False是该类型的值. 一个值可以包含多种不同类型的字段(field),例如, data BookType = BookValue Int String 其中BookType是类型名,BookValue是值

柯里化的前生今世(十):类型和类型系统

形式化方法 在计算机科学中,尤其在软件工程和硬件工程领域, 形式化方法(Formal method),是一种数学方法,用于软件和硬件系统的描述(specification).开发(development)和验证(verification).旨在能像其它工程学科一样,通过用数学进行分析,来提高设计的可靠性(reliability)和健壮性(robustness). 为了让系统表现的和规范(specification)一致,现代软件工程采用了一系列的形式化方法.其中包括一些强有力的框架,例如,霍尔逻

柯里化的前生今世(十一):Pure and Lazy

语言的作用 语言可以用来交流想法,描述概念, 当前使用了什么语言,取决于我们有什么样的需要. 为了理解词法作用域,闭包,和continuation, 前文中,我们借助了Racket. 现在,为了理解代数数据类型(algebraic data type),多态(polymorphism),参数化类型(parameterized type),类型类(type class),我们要学习Haskell了. 编程也是如此,它是关于思想的, 编程语言只是描述这种思想的工具罢了. 非严格语义(non-stri

柯里化的前生今世(三):语言和同像性

按照故事情节的正常发展,我们这一篇该介绍Racket语言的语法了. 可是,在大局观上,我们还没有达成共识. 对于一个概念来说,我们不止要学会怎样描述它,还要学会理解它的内涵. 因此,这篇还是在打基础,俗称,引言.. 关于 在上一篇中,我们提到了Lisp语言家族,看到了关于Lisp最美丽的传说,我们提到了Racket,以及它的IDE,DrRacket. 本文将从目标语言和元语言,同像性(Homoiconicity),引用等这些角度来深入的讨论Lisp, 浅尝辄止,毕竟不是一个好习惯. 目标语言和元

柯里化的前生今世(十三):WHNF

1. 形式系统(Formal system) 在逻辑学与数学中,一个形式系统由两部分组成,一个形式语言加上一套推理规则. 一个形式系统也许是纯粹抽象地制定出来的,只是为了研究其自身. 也可能是为了描述真实现象或客观事实而设计的. 2. λ演算(λ-caculus) λ演算用于研究函数定义.函数应用和递归,它是一些形式系统的总称, 配备不同的推理规则集,就会得到不同的演算系统. λ演算由Alonzo Church和Stephen Cole Kleene在20世纪三十年代引入, Church在193

柯里化的前生今世(六):词法作用域和闭包

关于 在上一篇中,我们介绍了动态作用域,并进行了相关名词的解释. 我们解释了什么是环境,什么是帧,如何在一个环境中对表达式求值. 我们用一个内部结构表示了函数,实现了一个微型的支持动态作用域的解释器. 这一篇,我们将再往前一步,实现词法作用域(lexical scope). 动态作用域 vs 词法作用域 作用域(scope)的概念大家可能比较陌生, 但是闭包(closure)的概念在这几年非常流行,几乎已经没有什么主流语言不支持它了. 从实现角度,和函数一样我们将用另外一种内部结构表示闭包, ;