call/cc是怎样实现的

想象力比知识更重要。

——爱因斯坦


在大脑中第一个构想出来的人是天才,

后人只是一遍又一遍实现它最初的设想罢了。


call/cc如此,

lisp语言本身又何尝不是?


一堆括号字母和空格,

构筑了美妙的外观。


才有了后来者各种各样的实现。


这是一种自顶向下的设计思路,

用构想作为目的,

用实现来支撑。


与测试驱动开发,

有异曲同工之妙。


放飞自己的想象力

假如,我们有了一堆符号,

如何手动控制程序跳转?


这个跳转方式,既然可以手动触发,

那一定是可以调用的。(k)


k是哪来的?

一定是从什么地方创建的。


跳转到哪里?

一定是跳转到创建它的位置之后。


这个k是怎么过来的?

它一定当做参数传递过来的。


k需要传递参数过去吗?

最好是需要,我们不想纯粹依赖副作用编程。(k 1)


假如我们已经有k了,

并且在一个函数执行过程中调用了它,

(lambda (x)

    0

    (k 2)

    3)

执行完(k 2)后面的3还执行吗?

不执行了。

当前执行的这个函数还返回吗?

我们希望直接跳转到k定义的位置了。


那么函数的执行过程就要重新理解了,

先求值函数体,

然后跳转到给定的位置。


函数执行的结果,

并不一定是“返回”到调用的位置了。


这个跳转可否理解成调用了k呢?

在函数调用处定义k,

执行完以后,用函数体的值v,调用k,

(k v)。


嗯嗯,

就这么干,先从函数体执行后,

可以控制跳转位置开始。


把以后要做什么当做参数传过去

我们先看看,

旧观念中的“函数返回”是怎么绑架我们思维的。

为什么函数一定要“返回”?


实际上,从机器的角度来看,

并不存在自动的返回机制,

调用一个函数,会把调用前的代码位置,先存起来。

然后去执行函数体的中代码,这可能在代码段的其他位置,

执行完后,再把以前存起来的位置恢复,

就完成了“返回”操作。


现在我们不想这么干了,

我们不想让底层实现自动决定如何返回。


例如:

(define (fn x)

    (+ x 1))

    

(define (gn y)

    (fn y))

    

(gn 2)


我们调用了gn,gn又调用了fn,

fn执行完以后,返回gn,然后gn又返回,

像fn这样返回以后,调用者也返回的调用,称为尾调用


尾调用fn,本来没有必要返回gn内部,

直接返回gn该返回的位置就行了。


这就要求我们把函数执行完以后,

把“要做什么”当做参数传过去。


(define (final-cont v)

    (display v))


(define (fn x cont)

    (cont (+ x 1)))


(define (gn y cont)

    (fn y (lambda (v)

        (cont v))))


我们在REPL中,一个表达式求值以后,就是打印它。

所以,我们创建一个最终要做的事情,final-cont


先调用fn试试。

(fn 2 final-cont)

果然打印出了3。


因为我们把打印这件事当做函数传过去了,

随时都可以调用。


至于(cont (+ x 1))执行完后,fn不是还要返回的吗?

我们暂时可以认为是无用的数据,丢弃了,

后面再深入讨论。


然后再调用(gn 2)试试。

(gn 2 final-cont)

就会去调用

(fn y (lambda (v)

    (cont v)))

这里cont是final-cont


然后调用fn了,(cont (+ x 1))

fn中的cont就是

(lambda (v)

    (final-cont v))


结果也是打印了3,正确输出。


这是一个常函数,为什么要执行呢,

这是模拟fn执行完以后返回gn。

实际上,因为fn是尾调用,

我们只需要把gn中的cont传递给fn即可。


(define (gn y cont)

    (fn y cont))


gn中的cont本来是final-cont,

是gn执行完以后要做的事情,

现在不加改变的,传递给了fn,

是不是相当于fn直接返回到gn该返回的位置了呢?

非常巧妙。


其中,作为参数传递的cont,称为Continuation

这种把“要做什么”当做参数传递的手法,称为Continuation传递风格(CPS)


用call/cc设置跳转点

我们实际上不想每次都传递continuation,

只想在需要的时候调用它,

怎样产生我们需要的跳转点呢?


call/cc就是做这个的。


;before

(call/cc (lambda(k)

    (define (fn x)

        (k (+ x 1)))

        

    (define (gn y)

        (fn y))

        

    (gn 2))

;after


用call/cc产生了一个跳转点,

它把call/cc位置处“以后要做什么”,包装成了参数k。


对的,k虽然是函数的参数,

但是它也可以是一个函数。


其实k不是函数,是一个包装了continuation的对象,

它的调用机制,就是把包装的continuation提取出来调用一下。


反正k可以当做函数的参数传递,

像这样可以当做参数传递,可以作为函数的返回值的,k

称为first-class的,first-class continuation


我们看下执行流程,

先调用call/cc,设置了跳转点。


然后,就进入(lambda (k) ...)中了,

其中k是call/cc处的continuation,

可以表示为

k = (lambda (v)

    ;after

)


拿着call/cc的值,再执行after操作,

不就是“要做什么”的意思吗?


进入(lambda (k) ...)以后,

定义了两个函数,fn和gn,

然后调用gn。


gn调用了fn,fn又调用了k,

那么call/cc就直接返回了,程序跑到了k所示的跳转点了,

接着执行after操作。


一种实现方式

有了用例,

实现起来就简单多了。


call/cc有很多方式实现,

我们只看下简单的解释实现。


首先解释器的入口eval-exp要改,

(eval-exp '1 *env* *cont*)

需要传递一个最原始的“以后要做什么”,

(define *cont* (lambda (v)

    (display v)))


然后,遇到(call/cc ...),我们这样处理,


(define (eval-call/cc exp env cont)

    (display "eval-call/cc")

        (let ((fn (cadr exp))

            (continuation (make-continuation cont)))

            (eval-function-call-list `(,fn ,continuation) env cont)))


先拿到call/cc后面的那个lamabda,

然后用一个包装过的对象调用它,

k就是这个包装过的对象continuation了


我们再看看continuation对象调用的时候怎么处理,


(define (eval-continuation-call exp env cont)

    (display "eval-continuation-call")

    (eval-exp (car exp) env

        (lambda (continuation)

            (let ((wrapped-cont (continuation-cont continuation)))

                (eval-exp (cadr exp) env

                    (lambda (arg)

                        (wrapped-cont arg)))))))


嵌套很深嘛,

没关系,其实只是解开continuation对象的封装,

把原始的cont拿出来,

然后先求值(k (+ x 1))中的(+ x 1),

求值完了以后,

再调用包装中的cont。


这里比较新颖的地方是,因为整个解释器已经改成了CPS方式

所以,顺序结构都要改成回调方式,

(let ((arg (eval-exp (cadr exp) env)))

    (wrapped-cont arg))


要变成,

(eval-exp (cadr exp) env

    (lambda (arg)

        (wrapped-cont arg)))


然后呢,

还需要做什么呢?

没有了。

就完了。


偷偷借用的Scheme尾调用优化机制

我们前面埋了一个雷。


重新来看看,

(define (final-cont v)

    (display v))


(define (fn x cont)

    (cont (+ x 1)))


(define (gn y cont)

    (fn y cont))


(gn 2)


感觉上的执行过程是这样的,

gn调用了fn,fn调用cont,

cont返回,fn返回,gn返回,

回到了top-level。


并非我们想的,

gn调用fn,fn调用cont,

cont直接返回到top-level。


其实,在Scheme语言中,后者是对的。

确实直接返回到了top-level。


因为语言规范指定,

Scheme必须实现尾调用优化,

指的就是这个。


如果是尾调用,那么不用返回到调用处了,

只需要返回到调用者该返回的地方即可。


这样我们解释器里面实现的call/cc,

更理直气壮了。

哪怕我们把*cont*传的再远,

也会直接返回到top-level,

不会导致一系列的调用栈弹栈操作。


因为解释器实现中所有的函数调用都是尾调用。


结语

实际上,call/cc的编译实现还是比较麻烦的,


本来调用结构是栈型的,

函数调用时,新建一个frame,添加到环境顶端,

返回时,弹栈。


后来,为了实现闭包,

因为闭包具有无限生存期,

这个frame有可能以后还会用到,

所以,我们必须用链表来表示环境了,

函数返回后,并不会删除frame,只是暂时不链接到它了,

等待垃圾回收器来处理。


再以后,

我们的执行过程,可以往前跳转了,

跳转到设置好的点,再分叉执行,

结果,环境就是一个树型结构了。


每调用一个函数,

树增加了一个子节点,

函数返回,或者调用k,返回到以前的某个父节点,

因为还可能再回来,也可能重新执行一遍,

所以,再回来和重新执行必须同时保存下来,

成了两个分支。


然而,这种树型调用图

goto语句更容易控制,

这也是call/cc的巧妙之处。


当然call/cc用的时候,最好也封装一下,

免得k传递的到处都是。

不是吗,工具早就有了,

用的好不好,体现了工程师的水平。


参考:

源码

Essentials of Programming Languages

Concepts in Programming Languages

Lisp in small pieces

Compiling with Continuations