柯里化的前生今世(七):first-class continuation

关于

在上一篇中,我们对比了动态作用域和词法作用域,并实现了一个支持词法作用域的Lisp方言。

我们看到,动态作用域和词法作用域的区别就在于“函数体在什么环境中被求值”。
动态作用域中的函数,在函数调用的环境中被求值。
而词法作用域中的函数,在被定义时的环境中求值。

从实现的角度来看,闭包就是一个数据结构,
它保存了函数的参数列表,函数体和被定义时的环境,并没有什么神秘的地方。

上一篇文章中,我们还对比了闭包与对象,它们都是有内部状态的实体。
引出了我们let over lambda的优雅断言。

Continuation

这一篇文章,我们将学习新的概念了,它不过是一个“坑”。
之所以这么说,一方面,这个概念极难理解,普通人被坑数月也不为过。
另一方面,它的表现形式好像是一个“坑”,它可以看成一个“有去无回”的单参函数。

1. 什么是后续的计算

我们先看一个小例子吧。

(+ 1 (* 2 3))

求值结果是7,它是怎么得来的呢?
是先求值(* 2 3)得到6,然后求值(+ 1 6)得到了7

值得注意的是,乘法那一步得到了运算结果,而加法那一步,使用了这个运算结果。

如果乘法得到的不是6,而是10
加法运算使用10这个运算结果,那么最终结果是多少?
(+ 1 10),即11了。

更进一步,如果乘法得到的不是6,而是x,最终结果是多少?
我们知道了,将会是(+ 1 x)

因此,一旦我们有了乘法的值,整个计算结果就知道了。
而当乘法值尚未确定的时候,我们只能用一个单参函数来表示“后续的计算”,

(define cont
  (lambda (x)
    (+ 1 x)))

计算完乘法的值后,只要调用这个单参函数,就能拿到整个程序的计算结果了。
因此,我们称这个单参函数为乘法表达式的continuation。

2. 未来要做的事情是一个单参函数

如图,我们不但关心表达式的值是怎样得出来了,
还关心表达式的值是怎样被使用的,
于是,每个表达式都对应了一个“它的”continuation。

一旦我们用表达式的值,填充了它的continuation,整个程序就算跑完了。
程序的未来,就是这个单参函数。

call-with-current-continuation

在所有语言中,我们都可以引入continuation这个概念,
但是Lisp更强大,它们的continuation是first-class的。

1. first-class

什么叫first-class的呢?
某个东西是first-class的,就是说这个东西可以当做函数的参数,也可以当做函数的返回值。

有了高阶函数之后,函数就是first-class的了。
现在我们来看continuation变成first-class会有什么好玩的事情发生。

2. 例子

Racket提供了call/cc,来拿到(call/cc ...)表达式本身的continuation,
即,current continuation,看一个例子吧。

(+ 1 (call/cc
      (lambda (cont)
        (cont 2))))

结果是3,其中cont就是(call/cc ...)这个表达式的continuation,
不就是这个函数吗?

(define cont
  (lambda (x)
    (+ 1 x)))

是的,因此,(cont 2)的结果就是整个计算结果,(cont 2)得到3

3. 语法规则

(1)call/cc接受一个单参函数fn作为参数。
这里fn指的就是上面的(lambda (cont) ...)
(2)求值(call/cc ...)表达式,会使用(call/cc ...)表达式的continuation调用fn
因此,fn的形参cont就是(call/cc ...)表达式的continuation。
(3)另外,我们规定,fn的continuation就是call/cc的continuation。
即,如果fn中没有调用cont,则(call/cc ...)的值就是函数的返回值。

(+ 1 (call/cc
      (lambda (cont)
        2)))

结果也是3

4. 这有何难?

可能看了上面的例子,会对continuation付之一笑,这还要坑几个月?怎么可能?
那好吧,我们看一个稍微复杂点的例子。

((call/cc (lambda (cont) cont)) (lambda (x) "hi"))

谁能告诉我,结果为什么是"hi"?

实际上,执行过程是这样的:
(1)这是一个函数调用表达式,形如(f a),因此先求值f,再求值a
(2)求值(call/cc (lambda (cont) cont)),因为cont没有被调用,
所以,(call/cc ...)表达式的值就是cont了,f的值是cont了。
(3)(lambda (x) "hi")求值为一个函数对象#<procedure>
a的值是#<procedure>了。
(4)开始用cont调用这个函数对象,
注意了啊。。因为cont(call/cc ...)表达式的continuation,
会导致程序从(call/cc ...)求完值后的位置再次执行,
即,看起来好像(call/cc ...)又返回了。
并且,(call/cc ...)的值就是这个函数对象。
因为用谁调用cont(call/cc ...)的值就是谁嘛。
(5)(call/cc ...)求完值后要做什么呢?那就是第(3)步了啊,
(lambda (x) "hi")又求值为一个函数对象#<procedure>
a的值是#<procedure>了。
(6)然后进行函数调用了,((lambda (x) "hi") (lambda (x) "hi"))
结果为"hi"。

5. 我彻底凌乱了

是不是有些凌乱啊,下面这个阴阳谜题难度更大。

(let* [(yin ((lambda (foo) (newline) foo)
             (call/cc (lambda (bar) bar))))
       (yang ((lambda (foo) (display "*") foo)
              (call/cc (lambda (bar) bar))))]
  (yin yang))

结果会无限输出,
先输出一个星号,换行输出两个星号,换行输出三个星号,等等。

这里把值得注意的几个点,重点表述一下,
(1)什么时候cont被调用,就相当于对应于这个cont(call/cc ...)表达式返回了,
并且该(call/cc ...)表达式的值,就是用来调用cont的值,
即,(cont v)(call/cc ...)的值就是v
(2)不同位置,或者相同位置不同时间调用的(call/cc ...)产生的continuation是不同的,不同的cont被调用,会返回到“历史上”的某个位置。

call/cc的威力

说了这么多call/cc,有什么卵用?

我们知道很多动态语言有generator这个概念,
伴随generator有一个yield,很诡异。
它居然可以让一个函数返回多次,没错,它无非就是continuation嘛。

1. python中的generator

def gen(x):
    yield x
    yield x+1
    yield x+2

iter=gen(1)
print(iter.next())  #1
print(iter.next())  #2
print(iter.next())  #3

我们看到gen()返回一个迭代器iterator
不断的调用next()会导致genyield位置继续向下执行。

相似的例子,可参考ES6的generatorRuby的Fiber

2. 用call/cc实现yield

(define (gen x)
  (define k-body #f)
  (define k-yield #f)

  (define (yield x)
    (call/cc (lambda (k2)
               (set! k-yield k2)
               (k-body x))))

  (lambda ()
    (call/cc (lambda (k1)
               (if (eq? k-body #f)
                   (begin
                     (set! k-body k1)
                     (yield x)
                     (yield (+ x 1))
                     (+ x 2))
                   (k-yield))))))

(define iter (gen 1))
(iter)  ;1
(iter)  ;2
(iter)  ;3

后续文章我们还会发现,我们可以定义宏(macro),让上述表达方式更简练。
宏,才是Lisp语言的精髓。

3. 用宏做语法糖

(make-generator (gen x y)
                (yield x)
                (yield y)
                (+ x y))

(define iter (gen 1))
(iter)  ;1
(iter)  ;2
(iter)  ;3

其中,make-generator是一个宏,按下面的方法定义,感兴趣的可以一试。

(define-syntax make-generator
  (lambda (form)
    (syntax-case form ()
      [(keyword (name ...) . body)
       (syntax-case (datum->syntax #'keyword 'yield) ()
         [yield
          #'(define (name ...)
              (define k-body #f)
              (define k-yield #f)
              (define (yield . args)
                (call/cc (lambda (k2)
                           (set! k-yield k2)
                           (apply k-body args))))
              (lambda ()
                (call/cc (lambda (k1)
                           (if (eq? k-body #f)
                               (begin
                                 (set! k-body k1) . body)
                               (k-yield))))))])])))

下文

本文介绍了continuation,还介绍了Lisp中的first-class continuation,
最后,用call/cc实现了yield
然而,只停留在使用层面的话,神秘感是无法抹除的,
下一篇文章,我们来实现call/cc,你准备好了吗?

参考

Python: Iterators & Generators
Continuation
The Scheme Programming Language, 4th Edition
Practical Common Lisp

时间: 2024-10-02 15:27:15

柯里化的前生今世(七):first-class continuation的相关文章

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

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

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

关于 在上一篇中,我们介绍了continuation的概念,还介绍了Lisp中威力强大的call/cc,它提供了first-class continuation,最后我们用call/cc实现了python中的generator和yield. call/cc赋予了我们很强的表达能力,Lisp中的异常处理机制也很人性化. 例如,Common Lisp: Condition_system, 由于call/cc可以捕捉到异常处的continuation, 我们就可以手动调用这个continuation,

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

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

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

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

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

关于 本文借用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