开始学习Scheme

时间:2022-05-15 05:09:37

开始学习Scheme

函数式编程(Functional Programming)是在MIT研究人工智能(Artificial Intelligence)时发明的,其编程语言为Lisp。确切地说,Lisp是一个语言家族,包括无数的方言如:Scheme、Common Lisp、Haskell……等等。
最后一次学习Scheme已经是去年7月份的事情了。本来只是出于兴趣,以及拓宽自己思路的目的来学习。未曾想,由于工作需要,Scheme编程已经成为一个必备的技能了。其实这里面也由办公室政治的原因,因为我本来是做驱动开发的。现在PO和Boss已经开始对立了,因此出现了PO想让我做驱动,而Boss更倾向于根据自己的亲信的兴趣爱好来决定是否要挤掉我的驱动开发岗位。扯得有点远了,生活就是如此,不能事事如意。还是那句话,做一天和尚就撞好一天钟。
出于兴趣或者因为工作需要而开始学习一项技能时,学习方法的差异相当的大。出于兴趣时,完全可以根据自己的喜好、时间、背景知识等情况来决定关注点,并可以充分研究自己所关心的地方。然而,为了工作而学习时,就需要综合考虑诸多因素,比如项目的计划、对技能熟悉程度的要求等来决定学习的重点。这种方式便是所谓"On job training",或者叫做通过实践来学习。这种方式的好处就是可以迅速的开始使用某项技能,缺点也很明显,那就是很难有时间让你去思考这项技能的本质。市场上充斥着"XXX天学会XXX"的书就不足为怪了。
说了这么多闲话,还是言归正传吧。先来看看Scheme的基本概念。
#!r6rs

(import (rnrs base (6))
(rnrs unicode (6))
(rnrs bytevectors (6))
(rnrs lists (6))
(rnrs sorting (6))
(rnrs control (6))
(rnrs records syntactic (6))
(rnrs records procedural (6))
(rnrs records inspection (6))
(rnrs exceptions (6))
(rnrs conditions (6))
(rnrs io ports (6))
(rnrs io simple (6))
(rnrs files (6))
(rnrs programs (6))
(rnrs arithmetic fixnums (6))
(rnrs arithmetic flonums (6))
(rnrs arithmetic bitwise (6))
(rnrs syntax-case (6))
(rnrs hashtables (6))
(rnrs enums (6))
(rnrs eval (6))
(rnrs mutable-pairs (6))
(rnrs mutable-strings (6))
(rnrs r5rs (6))) (display (find even? '(3 1 4 1 5 9)))
(newline)
(display "Hello\n") (guard (exn [(equal? exn 5) 'five])
(guard (exn [(equal? exn 6) 'six])
(dynamic-wind
(lambda () (display "in") (newline))
(lambda () (raise 5))
(lambda () (display "out") (newline)))))
第一个,也是最基本的概念:S-expression(Symbolic-expression,符号表达式),最初适用于表示数据的,后来被用作Lisp语法的基础。它是一个原子,或者一个(s-expr . s-expr)的表达式。后者为一个pair。所谓list,就是由pair组成的:(x . (y . z))就是一个list,它可以被简写为(x y z)。原子主要是指数字、字符串和名字。S-expression是Lisp求值器能处理的语法形式。
第二个,则是一等函数(first-class funciton)。它是first-class object的一种,是编程语言里的一等公民(first-class citizen)。first-class的含义是,当一个对象满足如下条件时:
1. 可以在运行时构造
2. 可以当做参数传递
3. 可以被当做返回值返回
4. 可以赋值给变量
便可以被成为first-class object。
例如:
  1. (define (my-cons x y)
  2. (lambda (f)
  3. (f x y)
  4. )
  5. )
  6. (define (my-car lst)
  7. (lst
  8. (lambda (x y) x)
  9. )
  10. )
  11. (define (my-cdr lst)
  12. (lst
  13. (lambda (x y) y)
  14. )
  15. )

对其的使用如下:

  1. > (define pair1 (my-cons 10 11))
  2. > pair1
  3. #<procedure>
  4. > (my-car pair1)
  5. 10
  6. > (my-cdr pair1)
  7. 11
根据上述规则,很显然,C/C++的函数就不是一等函数,因为他们不满足第一个条件。在函数式编程中,使用另一个函数作为参数,或者返回一个函数,或者二者兼有的函数称为高阶函数(High-order function)。既然说到高阶函数,就不能不说词法闭包(Lexical Closure,或者简称为闭包closure)。闭包指的是函数本身以及其*变量(或非本地变量)的引用环境一起构成的结构,其允许函数访问处于其词法作用域(lexical scope)之外的变量,例如:
  1. (define closure-demo
  2. (let ((y 5))
  3. (lambda (x)
  4. (set! y (+ y x))
  5. y)
  6. )
  7. )
这里需要注意闭包与匿名函数的区别。
第三个基础概念便是递归。其实对于递归没有太多可说的,但一定要注意的是尾递归(tail-recursion)。尾递归使得用递归的形式实现递推成为可能。
第四个是词法作用域。
第五个是lambda算子(lambda calculus)
第六个是块结构
第七个是一级续延(first-class continuation)
第八个是宏(卫生宏:展开时能够保证不使用意外的标示符)
其中,有些基本概念又能引申出一些新的概念。后面这些基本概念(4~8),留到以后讨论。
另外,在这里可以找到一些业界比较认可的Lisp应用。至于Common Lisp的应用,Paul Graham的Viaweb(后来被Yahoo!收购,成为Yahoo! Store)是个好例子。最著名的估计是这个,详情可以参考田春冰河的博客

线性递归以及循环不变式

例1:计算x^n(X的n次方)
可以采用如下算式来计算:
x^0 = 1
x^n = x*x^(n-1) = x*x*x^(n-2) = ……
那么,很容易得到该计算过程的递归表示:
    (define (exp x n)
  1. (if (= n 0)
  2. 1
  3. (* x (exp x (- n 1)))))

很容易看出来,这个计算的时间和空间复杂度均为O(n)。这便是一个线性递归。为了减少其空间复杂度,可以使用线性迭代来代替(使用递归实现):

      (define (exp-iter x n)

  1. (define (iter x n r)
  2. (if (= n 0)
  3. r
  4. (iter x (- n 1) (* r x))))
  5. (iter x n 1))

计算过程依然有改进空间,那便是可以降低时间复杂度。根据

x^n = x^(n/2)*x^(n/2)

可知,计算x^n的时间复杂度可以降低为O(logn)。此时,需要一个循环不变式来保证计算结果的正确性。设r初始值为1,则在计算过程中,从一个状态迁移到另一个状态(n为奇数迁移到n为偶数)时,r*x^n始终保持不变。而此时计算方法为:

n为奇数,则x^n = x*x^(n-1)

n为偶数,则x^n = x^(n/2)*x^(n/2) = (x^2)^(n/2)

因此,计算过程如下:

      (define (fast-exp-iter x n)

  1. (define (iter x n r)
  2. (cond ((= n 0) r)
  3. ((even? n) (iter (* x x) (/ n 2) r))
  4. (else (iter x (- n 1) (* r x)))))
  5. (iter x n 1))

例2:a*n可以写成a+a*(n-1)的形式。那么采用加法和递归来计算则是:

      (define (mul x n)

  1. (if (= n 0)
  2. 0
  3. (+ x (mul x (- n 1)))))

同样,可以采用迭代的方式来计算:

      (define (mul-iter x n)

  1. (define (iter x n r)
  2. (if (= n 0)
  3. 0
  4. (iter x (- n 1) (+ r x))))
  5. (iter x n 0))

与例1相似,也可以将迭代计算的时间复杂度降为O(logn):

  1. (define (fast-mul-iter x n)
  2. (define (iter x n r)
  3. (cond ((= n 0) r)
  4. ((even? n) (iter (+ x x) (/ n 2) r))
  5. (else (iter x (- n 1) (+ r x)))))
  6. (iter x n 0))

这个计算过程的循环不变式是什么呢?

在“学习Scheme”中提到了Scheme,或者说是函数式编程的一些基本概念,这些概念使得Scheme区别于其他的编程语言,也使得函数式编程FP区别于其他的编程范式。之前用了四篇博文详细讲述了递归以及尾递归,并给出了许多实际的范例。尤其是“[原]Scheme线性递归、线性迭代示例以及循环不变式”,详细讲述了如何设计并实现尾递归。下面,来看看第三个概念:闭包

“在计算机科学中,闭包(Closure)是词法闭包(Lexical Closure)的简称,是引用了*变量的函数。这些被引用的*变量将和这个函数一同存在,即使已经离开了创造它们的环境也不例外。所以,有另一种说法认为闭包是由函数和与其相关的引用环境组合而成的实体。”这是*给出的说明。
Paul Graham在On Lisp一书中对于闭包的定义则为:函数与一系列变量绑定的组合即是闭包。其实这里也隐含了一个计算环境的问题,那就是函数定义的计算环境。
Closure的示例如下:
  1. (define closure-demo
  2. (let ((y 5))
  3. (lambda (x)
  4. (set! y (+ y x))
  5. y)
  6. )
  7. )

这里使用了set!,因此其封装了一个状态,即*变量y:

  1. > (closure-demo 5)
  2. 10
  3. > (closure-demo 5)
  4. 15
  5. > (closure-demo 5)
  6. 20
“闭包可以用来在一个函数与一组“私有”变量之间建立关联关系。在给定函数被多次调用的过程中,这些私有变量能够保持其持久性。变量的作用域仅限于包含它们的函数,因此无法从其它程序代码部分进行访问。不过,变量的生存期是可以很长,在一次函数调用期间所建立所生成的值在下次函数调用时仍然存在。正因为这一特点,闭包可以用来完成信息隐藏,并进而应用于需要状态表达的某些编程范型中。”
看到这里,我们马上就能想到一个概念:面向对象。根据对“对象”的经典定义——对象有状态、行为以及标识;对象的行为和结构在通用的类中定义——可以得到,如果使用闭包,很轻松便可以定义一个类。另外,由于向对象发消息需要一个实例,一些参数,并得到发送消息之后的结果,因此,使用一个dispatcher便可以向对象发送消息。例如:
  1. (define (make-point-2D x y)
  2. (define (get-x) x)
  3. (define (get-y) y)
  4. (define (set-x! new-x) (set! x new-x))
  5. (define (set-y! new-y) (set! y new-y))
  6. (lambda (selector . args) ; a dispatcher
  7. (case selector
  8. ((get-x) (apply get-x args))
  9. ((get-y) (apply get-y args))
  10. ((set-x! (apply set-x! args))
  11. ((set-y! (apply set-x! args))
  12. (else (error "don't understand " selector)))))

在这里,make-point-2D是一个函数,它接受两个参数,并返回一个闭包——由lambda定义的一个匿名函数。这个闭包中,引用的*变量有:get-x,get-y,set-x!, set-y!。这些变量其实是函数,因为函数是一等公民,因此可以用变量将其进行传递。这就是一个基本的2D point类。该类的使用如下:

  1. > (define p1 (make-point-2D 10 20))
  2. > (p1 'get-x)
  3. 10
  4. > (p1 'get-y)
  5. 20
  6. > (p1 'set-x! 5)
  7. > (p1 'set- 10)
  8. > (list (p1 'get-x) (p1 'get-y))

注意,这些*变量自己本身又是函数,有自己的计算环境,而它们所访问的变量也是*变量,因此它们也是闭包,它们的计算环境由lambda定义的匿名函数提供——lambda定义的dispatcher是个大闭包,get-*和set-*都是这个闭包里的闭包。

利用闭包,还可以实现继承,如:
  1. (define (make-point-3D x y z) ; that is, point-3D _inherits_ from point-2D
  2. (let ((parent (make-point-2D x y)))
  3. (define (get-z) z)
  4. (define (set-z! new-z) (set! z new-z))
  5. (lambda (selector . args)
  6. (case selector
  7. ((get-z) (apply get-z args))
  8. ((set- (apply set- args)) ; delegate everything else to the parent
  9. (else (apply parent (cons selector args)))))))

这里面除了make-point-2D的闭包之外,还增加了get-z、set-z!以及lambda定义的匿名函数三个闭包。

在此基础上,利用宏对Scheme进行扩展,便可以得到一个通用的面向对象编程范式框架。当然,不能像在这里一样使用quote的串来确定应该调用哪个函数。
这里有个帖子讨论为什么Scheme不提供内置OO系统。我同意Abhijat的观点。OO主要目的是封装、模块化、大规模编程、状态,区分了数据和操作。Scheme不区分数据和函数,强调无状态,且函数为一等公民,因此并不需要OO。但实践中很难做到无状态,因此为了保持最小原则,OO由各实现自行添加。
难学却重要的Scheme特性 
这一篇将讲述"开始学习Scheme"里的first class continuation。关于continuation这个单词的翻译,有那么几种,有“延续”、“继续”、“续延”。个人认为,“续延”应当算是最好的了。这个翻译是由裘宗燕老师提出来的。
Continuation是由Scheme引入函数式编程中的。这个概念相当重要,但是理解起来却比较困难——也许对于我来说困难,但对于别人来说并不一定是这样。说它重要,是因为应用面比较广,比如协作例程、异常处理;还可以模拟别的编程语言里的return、实现无条件跳转等。其最大,也是最广泛的应用之一就是用于web服务器的开发。
在对任意一个Scheme表达式求值时,必须记录两件事:
  1. 对什么求值
  2. 对求出的值做什么
第二件事便是continuation。
这个定义相当简单。例如:
对于表达式(if (null? x) (quote ()) (cdr x))有六个continuations,分别等待如下值:
  1. (if (null? x) (quote ()) (cdr x))的值
  2. (null? x)的值
  3. null?的值
  4. x的值
  5. cdr的值
  6. x的值
其中,(cdr x)的值没有列出来是因为她就是第一个continuation等待的值。从这里可以看出,continuation是一个计算环境,它在等待一些计算,只有这些计算完成后,才能进行该计算环境相关的计算。也就是说,continuation是一个函数。
Scheme允许用call-with-current-continuation函数(间写为call/cc)对任意表达式的continuation进行捕捉。call/cc必须以一个函数p为参数,而函数p又必须只有一个参数k,这个参数k就是被捕获的continuation,也是一个函数。call/cc构造当前的continuation并传递给p。每当k被应用于一个值,它就返回call/cc的continuation的值,而这个值最终就是应用call/cc的值——或者说是(call/cc p)的值。例如:
  1. (call/cc
  2. (lambda (k)
  3. (* 5 4)))
  4. (call/cc
  5. (lambda (k)
  6. (* 5 (k 4))))
  7. (+ 2
  8. (call/cc
  9. (lambda (k)
  10. (* 5 (k 4)))))
第一个例子中,call/cc捕获的continuation就是返回一个值,并将其传递给匿名函数。这个匿名函数并没有使用k,因此,其结果为20。第二个例子中的continuation与第一个相同,只不过在匿名函数中将continuation应用于4,因此结果为4。第三个例子中的continuation是对其等待的值进行+2操作,因此,结果为6。
*上有个例子:
  1. (define the-continuation #f)
  2. (define (test)
  3. (let ((i 0))
  4. ; call/cc calls its first function argument, passing
  5. ; a continuation variable representing this point in
  6. ; the program as the argument to that function.
  7. ;
  8. ; In this case, the function argument assigns that
  9. ; continuation to the variable the-continuation.
  10. ;
  11. (call/cc (lambda (k) (set! the-continuation k)))
  12. ;
  13. ; The next time the-continuation is called, we start here.
  14. (set! i (+ i 1))
  15. i))

通过这个例子可以看出,continuation为什么又叫first class continuation——因为它和函数一样,是一等公民。这个例子里面用到的call/cc是call-with-current-continuation的一个别名。

下面再来看两个比较复杂一点的例子。
例1:
  1. (let ([x (call/cc
  2. (lambda (k) k))])
  3. (x (lambda (ignore) "hi")))

这个例子中,call/cc将捕获的continuation传递给(lambda (k) k),然后将该函数结果绑定到x,由于函数(lambda (k) k)返回k,因此,x被绑定为continuation自身。之后,将x的值应用于对表达式(lambda (ignore) "hi")求值的结果(函数f)。由于x是一个continuation,因此,此时continuation变为对表达式(lambda (ignore) "hi")的求值 ,此时continuation变为将该求值的结果应用到该结果自身。同时,因为x所绑定的continuation改变了,因此x所绑定的值也被修改为这个结果(函数f),并应用到f,即(f f),因此,结果为"hi"。

例2:
  1. (((call/cc
  2. (lambda (k) k))
  3. (lambda (x) x))
  4. "HEY!")

其实这个例子与例1是一样的,只不过写法不一样而已。但这个例子更具有迷惑性,你可能比较容易猜到结果,却不知道怎么得出这个结果的。我的理解也不一定正确,列在这里供大家参考:

call/cc捕获当前的continuation,因为(lambda (k) k),所以当前的continuation被返回并直接应用于(lambda (x) x),此时continuation就变为对(lambda (x) x)求值,然后应用到(lambda (x) x),最后将结果应用到"HEY!",即((f f) "HEY!")。
下面这个例子就比较有意思了:
  1. (define retry #f)
  2. (define factorial
  3. (lambda (x)
  4. (if (= x 0)
  5. (call/cc
  6. (lambda (k)
  7. (set! retry k)
  8. 1))
  9. (* x (factorial (- x 1))))))

这里保存了continuation到retry中。那么这个continuation是什么样呢?以(factorial 4)为例:

  1. (* 4 (factorial 3))
  2. (* 4 (* 3 (factorial 2)))
  3. (* 4 (* 3 (* 2 (factorial 1))))
  4. (* 4 (* 3 (* 2 (* 1 (factorial 0)))))
  5. (* 4 (* 3 (* 2 (* 1 (call/cc (lambda (k) (set! retry k) 1))))))

因为(lambda (k) (set! retry k) 1)中并没有应用k,因此,这个continuation应当是,返回当前值(此时为1)并乘以4!,或者说,将4!乘到当前值上。扩展到n的情况,则应当为:以参数为基数,做基数×n!操作。因此(factorial 4)然后(retry n)的结果应该是n*4!。

前面提到continuation的应用如协作例程、异常处理、模拟别的编程语言里的return、实现无条件跳转,下面就来看看。
return语句:
  1. (define (f return)
  2. (return 2)
  3. 3)
  4. (display (f (lambda (x) x))) ; displays 3
  5. (display (call-with-current-continuation f)) ; displays 2

无条件跳转:

  1. (define product
  2. (lambda (ls)
  3. (call/cc
  4. (lambda (break)
  5. (let f ([ls ls])
  6. (cond
  7. [(null? ls) 1]
  8. [(= (car ls) 0) (break 0)]
  9. [else (* (car ls) (f (cdr ls)))]))))))

计算过程中碰到第一个0,该函数便会立刻退出,相当于break的功能。

异常处理:可以参考这里。因为里面涉及到了其他的一些Scheme特性,而我还没有搞懂,因此暂时不做描述。
协作例程(coroutines):
  1. ;;; A naive queue for thread scheduling.
  2. ;;; It holds a list of continuations "waiting to run".
  3. (define *queue* '())
  4. (define (empty-queue?)
  5. (null? *queue*))
  6. (define (enqueue x)
  7. (set! *queue* (append *queue* (list x))))
  8. (define (dequeue)
  9. (let ((x (car *queue*)))
  10. (set! *queue* (cdr *queue*))
  11. x))
  12. ;;; This starts a new thread running (proc).
  13. (define (fork proc)
  14. (call/cc
  15. (lambda (k)
  16. (enqueue k)
  17. (proc))))
  18. ;;; This yields the processor to another thread, if there is one.
  19. (define (yield)
  20. (call/cc
  21. (lambda (k)
  22. (enqueue k)
  23. ((dequeue)))))
  24. ;;; This terminates the current thread, or the entire program
  25. ;;; if there are no other threads left.
  26. (define (thread-exit)
  27. (if (empty-queue?)
  28. (exit)
  29. ((dequeue))))
  30. (define (do-stuff-n-print str)
  31. (lambda ()
  32. (let loop ((n 0))
  33. (when (< n 10)
  34. (display (format "~A ~A\n" str n))
  35. (yield)
  36. (loop (+ 1 n))))))
  37. ;;; Create two threads, and start them running.
  38. (fork (do-stuff-n-print "This is AAA"))
  39. (fork (do-stuff-n-print "Hello from BBB"))
  40. (thread-exit)

这个例子稍显繁琐,下面会给出一个简化的实现。这里先说明一下这段代码完成何种功能:其中有一个全局变量queue,并定义了enqueue和dequeue函数来向queue中添加或取出一个元素。(fork proc)首先将当前的continuation插入队列,然后执行proc;而yield是首先将当前的continuation添加到队列尾部,然后将队首的continuation取出来并执行。do-stuff-n-print循环打印格式化后的str字串AAA(题外话:这里有个命名的let,名字为loop,其与内嵌的函数定义一样),每循环一次,就对yield进行求值,先保存当前的continuation(即开始下一次循环),然后取出队首的continuation(即对下当前fork之后的表达式求值),然后执行该求值过程。因此,程序执行第二个fork,即先打印BBB,然后取出队首的continuation,即第二次执行打印AAA的loop,然后是yield——保存AAA的执行——执行BBB的第二次loop——……

这个例子可以简化为:
  1. (define lwp-list '())
  2. (define lwp
  3. (lambda (thunk)
  4. (set! lwp-list (append lwp-list (list thunk)))))
  5. (define start
  6. (lambda ()
  7. (let ([p (car lwp-list)])
  8. (set! lwp-list (cdr lwp-list))
  9. (p))))
  10. (define pause
  11. (lambda ()
  12. (call/cc
  13. (lambda (k)
  14. (lwp (lambda () (k #f)))
  15. (start)))))
  16. (lwp (lambda () (let f () (pause) (display "h") (f))))
  17. (lwp (lambda () (let f () (pause) (display "e") (f))))
  18. (lwp (lambda () (let f () (pause) (display "y") (f))))
  19. (lwp (lambda () (let f () (pause) (display "!") (f))))
  20. (lwp (lambda () (let f () (pause) (newline) (f))))
  21. (start)

这段程序的执行永远不会自动停止。其中,lwp将函数放入列表尾部,start函数取出列表的第一个元素(函数)并执行它,pause函数捕获一个continuation,在这个continuation的处理函数中,把一个对该continuation求值的函数,通过lwp函数放入列表中,然后对start函数求值。然后分别将含有打印"h"、"e"、"y"、"!"语句的函数放入列表中。此后调用start函数,其过程如下:

  1. 取出函数(lambda () (let f () (pause) (display "h") (f)))
  2. 将f绑定为不接受参数的函数,该函数依次执行(pause)、(display "h")和(f)——无限循环
  3. 执行(pause),即将含有(display "h")和(f)作为continuation的函数放入列表尾部
  4. 执行(start)
  5. 取出函数(lambda () (let f () (pause) (display "e") (f)))
  6. 将f绑定为不接受参数的函数,该函数依次执行(pause)、(display "e")和(f)——无限循环
  7. 执行(pause),即将含有(display "e")和(f)作为continuation的函数放入列表尾部
  8. 执行(start)
  9. 取出函数(lambda () (let f () (pause) (display "y") (f)))
  10. 将f绑定为不接受参数的函数,该函数依次执行(pause)、(display "y")和(f)——无限循环
  11. 执行(pause),即将含有(display "y")和(f)作为continuation的函数放入列表尾部
  12. 执行(start)
  13. 取出函数(lambda () (let f () (pause) (display "!") (f)))
  14. 将f绑定为不接受参数的函数,该函数依次执行(pause)、(display "!")和(f)——无限循环
  15. 执行(pause),即将含有(display "!")和(f)作为continuation的函数放入列表尾部
  16. 执行(start)
  17. 取出函数(lambda () (let f () (pause) (newline) (f)))
  18. 将f绑定为不接受参数的函数,该函数依次执行(pause)、(newline)和(f)——无限循环
  19. 执行(pause),即将含有(newline)和(f)作为continuation的函数放入列表尾部
  20. 执行(start)
  21. 取出函数(lambda () (k #f)))
  22. 执行k,即输出"h",调用(f)
  23. 执行(pause),即将含有(display "h")和(f)作为continuation的函数放入列表尾部
  24. 执行(start)
  25. 取出函数(lambda () (k #f)))
  26. 执行k,即输出"e",调用(f)
  27. 执行(pause),即将含有(display "e")和(f)作为continuation的函数放入列表尾部
  28. 执行(start)
  29. 取出函数(lambda () (k #f)))
  30. 执行k,即输出"y",调用(f)
  31. 执行(pause),即将含有(display "y")和(f)作为continuation的函数放入列表尾部
  32. 执行(start)
  33. 取出函数(lambda () (k #f)))
  34. 执行k,即输出"!",调用(f)
  35. 执行(pause),即将含有(display "!")和(f)作为continuation的函数放入列表尾部
  36. 取出函数(lambda () (k #f)))
  37. 执行k,即输出换行符,调用(f)
  38. 执行(pause),即将含有(newline)和(f)作为continuation的函数放入列表尾部
  39. 回到20
最后,留下一个问题供大家讨论吧——我目前不知道怎么来解释这个问题。这就是著名的阴阳谜题:
  1. (let* ((yin
  2. ((lambda (cc)
  3. (display #\@) cc)
  4. (call/cc
  5. (lambda (c) c))))
  6. (yang
  7. ((lambda (cc)
  8. (display #\*) cc)
  9. (call/cc
  10. (lambda (c) c)))))
  11. (yin yang))

其结果看起来应当是这样:

  1. @*@**@***@****@*****@******@*******@********@*********@**********@***********@************@*************@**************@***************@****************@*****************@******************@*******************@********************@*********************@**********************@***********************@************************@*************************@**************************@***************************@****************************@*****************************@******************************@*******************************@********************************@*********************************@**********************************@***********************************@************************************@*************************************@**************************************@***************************************@****************************************@*****************************************@******************************************@*******************************************@********************************************@*********************************************@*********************

这段程序的执行永远不会自动停止。

PS. 由于我自己并没有太理解continuation,因此,这篇就更像一个信息的翻译和聚合,并没有太多自己的理解在里面。写完这篇,我对于continuation依然还是懵懵懂懂的,更谈不上运用它。也许随着时间的流逝,随着对Scheme的熟悉,会逐渐熟悉continuation吧。
参考
 

宏是扩展Scheme语言的手段。我们知道,Scheme的哲学就是最小化语言核心,因此,Scheme语言本身只提供非常有限的一些特性。为了让Scheme更加强大,宏承担起了扩展语言的任务。宏分为两种:卫生宏和不卫生宏。简言之,卫生宏就是不会导致副作用,不会意外地捕捉到错误的标识。C语言里面的宏则不是卫生宏,因为会导致副作用。

在Scheme中,对宏的处理与C语言类似,也分为两步:第一步是宏展开,第二步则是编译展开之后的代码。这样,通过宏和基本的语言构造,可以对Scheme语言进行扩展——C语言的宏则不具备扩展语言的能力。
Racket对宏的定义如下:
A macro is a syntactic form with an associated transformer that expands the original form into existing forms.
翻译过来就是说:宏是带有关联转换器的语法形式,该关联转换器将原先的形式展开成已有的形式(嫌我翻译得不好的尽管拍砖)。如果和Racket结合到一起说,应该是:宏是Racket编译器的一个扩展。
在许多Lisp方言中(当然包括Scheme),宏是基于模式的。这样,宏将匹配某个模式的代码展开为原先语法中所对应的模式。define-syntax和syntax-rules用于定义一个宏,例如,在Scheme中只提供if来执行分支:
(if pred expr1 expr2),对应的命题表达式为:(pred->expr1, true->expr2)。如果if分支中需要对多个表达式求值,那就需要使用begin,因此可以编写如下的宏when来满足需求:
  1. (define-syntax when
  2. (syntax-rules ()
  3. ((when pred exp exps ...)
  4. (if pred (begin exp exps ...)))))

其中,body里的when通常使用“_”代替。每次使用when时,就会被展开为对if的使用。

宏是Scheme的一个非常强大的功能,网上有很多专门针对Scheme宏编程的资源,有兴趣的可以搜索一下。
参考:
    1. *
    2. Racket文档
    3. Schemers.org
      图形界面的小应用 
      在学习了一些Scheme基础之后,我写了一个小程序,其功能如下:
      • 一个菜单栏上有两个菜单:File和Help
      • File菜单包含Start和Stop两个菜单项
      • Help包含About菜单项
      • 点击Start,程序将画出三个连在一起的空心小矩形,然后这三个小矩形同时向右移动
      • 点击Stop,停止移动
      好吧,我承认,这就是个贪食蛇的雏形。记得当年学习C#时也写了个最基本的贪食蛇游戏,现在算是二进宫了,轻车熟路。
      在开始之前,需要先大致说明一下Racket的对象系统。
      定义一个类:
      1. (class superclass-expr decl-or-expr ...)

      例如:

      1. (class object%
      2. (init size) ; initialization argument
      3. (define current-size size) ; field
      4. (super-new) ; superclass initialization
      5. (define/public (get-size)
      6. current-size)
      7. (define/public (grow amt)
      8. (set! current-size (+ amt current-size)))
      9. (define/public (eat other-fish)
      10. (grow (send other-fish get-size))))

      这是一个匿名类,其基类为object%,初始化参数为size——类似于C++的初始化列表,接下来current-size指的是一个私有成员,其初始值由初始化参数size所指定。再之后是通过(super-new)对父类即object%类调用“构造函数”。之后是三个公有的成员函数。

      为了能够创建这个类对象而不需要每次都把上面这一大段写到代码里,可以用define把这个匿名类绑定到一个变量上,比如叫做fish%。那么需要创建一个fish%的对象就很简单:
      1. (new fish% (size 10))
      需要注意的是,在Racket(也许其他的Scheme实现也一样)中,“{}”、“()”、“[]”是相同的,只不过必须匹配,如“{”必须匹配“}”。
      为了调用一个类的函数,需要用以下两种形式之一:
      1. (send obj-expr method-id arg ...)
      2. (send obj-expr method-id arg ... . arg-list-expr)
      如:
      1. (send (new fish% (size 10)) get-size)
      看到这里你也许会感到很奇怪:为什么没有析构函数?早在Lisp诞生初期,它就包含了垃圾收集功能,因此,根本不需要你释放new得到的对象。过了许多年之后,许多包含垃圾收集功能的语言诞生了。
      此外,结构体也是很有用的东西,它与类的区别,跟C++中类与结构体的区别差不多,但Racket结构体提供了很多辅助函数——当然是通过宏和闭包来提供这些函数。结构体是通过struct来定义的。——没猜错的话,struct应该也是一个宏——还没有细看Racket的代码。
      1. (struct node (x y) #:mutable)

      其使用如下所示:

      1. (node-x n) ; get x from a node n
      2. (set-node- n 10) ; set x to 10 of a node n
      3. (node? n) ; predicate, check if n is a node

      还有其他的辅助函数,在此不一一列举。

      这个应用的核心在于内嵌在canvas上的一个定时器:
      1. (define timer
      2. (new timer%
      3. [notify-callback
      4. (lambda ()
      5. (let ((dc (send this get-dc)))
      6. (send dc clear)
      7. (map (lambda (n)
      8. (send dc
      9. draw-rectangle (node-x n) (node-y n) 5 5))
      10. lst)
      11. (map (lambda (n)
      12. (set-node-x! n (+ (node-x n) 5)))
      13. lst)))
      14. ]
      15. [just-once? #f]))
      每当超时时间发生时,notify-callback所绑定的回调函数就会被调用,完成在canvas上画图的功能,同时更新图形所在的位置,这样便形成了移动。
      当然,现在这个程序还只是雏形而已,总代码量为101行。如果要完善成为一个贪食蛇游戏,还需要做很多工作,同时还需要进行一些设计,至少将Model、View和Controller分开吧。
      从这里也可以看出,用Scheme来进行面向对象的开发也十分容易,并不需要用到Scheme的高级功能例如宏和续延等等。当然,如果能运用好这些高级功能,相信代码会更加简单。
      续延的例子 
      先说一些题外话。
      关于函数式编程,这是Lisp 最常见的编程范式,它是构建在计算的基础上的。而另一个派别——命令式编程(包括面向过程范式、面向对象范式等等)则是构建在机器模型和汇编语言的基础上的。那么,函数式编程的特征有哪些呢?通常说来,具备下面特征的编程范式就可以说是函数式编程:
      • 声明式的。一个程序/过程更像是对问题的描述,或者对解决方法的描述,而不是如何进行一步步的计算。
      • 无(或者最小化)副作用的。所谓副作用(side-effect)指的是除了返回的计算结果之外,还修改了其他的对象。C库函数中strcat()函数就是典型具有副作用的函数。
      • 如果有副作用,这个副作用仅仅作用于唯一属主为该函数的对象。例如一个函数返回一个新构造的对象,该对象被修改了。
      • 计算结果只与输入有关,与执行次数无关。无论执行多少次,同样的定义域对应的值域完全一样
      • 返回值可被安全地修改
      至于闭包(closure),在Lisp中可以说无处不在,因此要特别注意。所谓闭包,就是一个计算过程以及其所使用的计算环境所构成的东西。

      在“开始学习Scheme :难学却重要的Scheme特性”中提到了续延(continuation)以及call/cc,然而里面有很多分析并不是可以形式化的,因此,很难作为普适的分析方法。这次让通过对“开始学习Scheme:难学却重要的Scheme特性 ”中的例子重新进行说明,来看看比较形式化的方法。

      首先回忆一下,continuation是指某个计算的未来路径。它是一个过程(procedure),而过程又是闭包(closure),因此continuation就是closure。
      来看看下面的表达式
      1. (if (null? x) (quote ()) (cdr x))

      假定求值的顺序为从右到左,那它所包含的continuation有:

      1. 等待x的continuation
      2. 等待cdr的continuation
      3. 等待 x的continuation
      4. 等待 null?的continuation
      5. 等待 (null? x)的continuation
      6. 等待 (if (null? x) (quote ()) (cdr x))的continuation
      根据定义,第一个continuation可以写成:
      1. (lambda (v1) (if (null? x) (quote ()) (cdr v1)))

      一旦x值确定之后,就可以将该continuation应用到x的值上:

      1. ((lambda (v1) (if (null? x) (quote ()) (cdr v1))) x)

      而第二个continuation在第一个continuation的基础上,等待cdr的值:

      1. ((lambda (v2) (if (null? x) (quote ()) (v2 v1))) cdr)

      以此类推,后面的continuation如下:

      1. ((lambda (v3) (if (null? v3) (quote ()) (v2 v1))) x)
      2. ((lambda (v4) (if (v4 v3) (quote ()) (v2 v1))) null?)
      3. ((lambda (v5) (if v5 (quote ()) (v2 v1))) (null? x))
      4. ((lambda (v6) v6) (if v5 (quote ()) (v2 v1)))

      也许这个例子比较复杂一些,那我们还是看看最简单的这个吧:

      1. (/ (- x 3) 10)

      在求出(- x 3)的值val之后,其后续计算为:

      1. (/ val 10)

      那么该continuation就可以写成:

      1. (lambda (val) (/ val 10))

      将(- x 2)的值作为该continuation的参数,便可以得到结果了。

      再说一个题外话:这里“-”操作被隐式地传递了一个continuation,即(lambda (val) (/ val 10))。如果将continuation显式传递的话,那就是另外一个大话题:CPS(Continuation Passing Style)。
      再回忆一下call-with-current-continuation,它捕获当前的continuation,并执行其函数参数。该函数参数是一个函数,接受continuation为参数并执行相应的操作。因此,call/cc的含义为:捕获当前的continuation,然后执行相应的操作。
      在上面的例子中,val就是当前continuation所期待的值,用call/cc来改写,则是如下形式:
      1. (/ (call/cc
      2. (lambda (cc)
      3. (cc (- x 3))))
      4. 10)

      因此,可以将对call/cc的调用视为后续计算所需要的val,然后将该continuation作为call/cc的函数参数的参数,执行(cc (- x 3))操作。

      因此,对于call/cc操作,可以分为两步来分析:
      1. 用val替代call/cc调用——确定continuation是什么
      2. 使用该continuation做什么
      此处需要特别注意的是:在第二步中,一旦将该continuation应用之后,该continuation后面的所有continuation都会被抛弃,表达式立即返回。因此捕获的continuation又被称为逃逸函数(escape procedure)。在上面的例子中,如果(cc (- x 3))之后还有任何后续操作,这些操作都将被忽略。
      根据这个规则,可以将上面的形式反推回去。当前的continuation是:
      1. (/ val 10)
      2. ;; 因为continuation是procedure,因此应当使用lambda来表示:
      3. (lambda (val) (/ val 10))

      对当前continuation做什么:

      1. (lambda (cc) (cc (- x 3)))
      2. ;;由于已经获得了当前的continuation,因此将该函数应用于当前的continuation,如下:
      3. ((lambda (cc) (cc (- x 3))) (lambda (val) (/ val 10)))

      有了这个分析的基础,现在来看看使用这两个步骤来分析使用call/cc的函数。

      1. (call/cc
      2. (lambda (k)
      3. (* 5 4)))
      4. ;; CC:
      5. (define cc1 (lambda (v) v))
      6. ;; What to do with CC:
      7. ((lambda (cc) (* 5 4)) cc1)
      8. (call/cc
      9. (lambda (k)
      10. (* 5 (k 4))))
      11. ;; CC:
      12. (define cc2 (lambda (v) v))
      13. ;; What to do with CC:
      14. ((lambda (cc) (cc 4)) cc2)
      15. (+ 2
      16. (call/cc
      17. (lambda (k)
      18. (* 5 (k 4)))))
      19. ;; CC:
      20. (define cc3 (lambda (v) (+ 2 v)))
      21. ;; What to do with cCC:
      22. (cc3 4)

      这三个相对简单些,因此不做说明。

      1. (define the-continuation #f)
      2. (define (test)
      3. (let ((i 0))
      4. (call/cc (lambda (k) (set! the-continuation k)))
      5. ( i (+ i 1))
      6. i))
      7. ;; CC:
      8. (define cc4
      9. (let ((i 0))
      10. (lambda (v)
      11. v
      12. ( i (+ i 1))
      13. i)))
      14. ;; What to do with CC:
      15. ((lambda (cc)
      16. (set! the-continuation cc)) cc4)
      17. ;; Testing
      18. (cc4 0)
      19. (cc4 0)
      20. (test)
      21. (the-continuation 0)
      22. (the-continuation 0)

      此处,cc4引用了一个*变量i。由于所有对test这个closure的调用(而不是每个closure的示例)都共享变量i,因此,i在cc4中也应当是一个共享变量。因此,cc4应该是这样:

      1. (define cc4
      2. (lambda (v)
      3. (let ((i 0))
      4. v
      5. (set! i (+ i 1))
      6. i)))

      下面这个例子就复杂一些:

      1. (let ([x (call/cc
      2. (lambda (k) k))])
      3. (x (lambda (ignore) "hi")))
      4. ;; CC:
      5. (define cc5
      6. (lambda (v)
      7. (let (( x v))
      8. (x (lambda (ignore) "hi")))))
      9. ;; What to do with cc:
      10. ((lambda (cc)
      11. (cc (lambda (ignore) "hi")))) cc5)
      12. ;; Testing
      13. (cc5 (lambda (i) "hI"))

      关键在于“what to do with cc”部分。因为(lambda (cc) cc)只是返回cc,而且是在let中,因此需要对cc做什么就变成了(cc (lambda (ignore) "hi"))。同样:

      1. (((call/cc
      2. (lambda (k) k))
      3. (lambda (x) x))
      4. "HEY!")
      5. ;; CC
      6. (define cc6
      7. (lambda (v)
      8. ((v (lambda (x) x))
      9. "HEY~!")))
      10. ;; What to do with cc:
      11. ((lambda (cc) (cc (lambda (x) x))) cc6)

      在下面这个例子中:

      1. (define retry #f)
      2. (define factorial
      3. (lambda (x)
      4. (if (= x 0)
      5. (call/cc (lambda (k) (set! retry k) 1))
      6. (* x (factorial (- x 1))))))
      7. ;; CC:
      8. (define f
      9. (lambda (x)
      10. (lambda (v)
      11. (if (= x 0)
      12. v
      13. (* x ((f (- x 1)) v))))))
      14. (define cc7 (f 4))
      15. ;; What to do with cc:
      16. ((lambda (cc) (set! retry cc) ((cc 1)) cc7)
      17. ;; Testing
      18. (retry 1)
      19. (retry 2)
      20. (retry 5)

      要注意当x不等于0的情况。此时由于 (f (- x 1)) 返回的是一个continuation,因此,需要将其应用到v上。另外就是call/cc捕获到当前的continuation是将f应用于某个数的结果。

      对于下面这个函数:
      1. (define product
      2. (lambda (ls)
      3. (call/cc
      4. (lambda (break)
      5. (let f ([ls ls])
      6. (cond
      7. [(null? ls) 1]
      8. [(= (car ls) 0) (break 0)]
      9. [else (* (car ls) (f (cdr ls)))]))))))

      由于用到了命名let,不是很直观,因此,首先将其改成如下形式:

      1. (define product-variant
      2. (lambda (ls)
      3. (call/cc
      4. (lambda (break)
      5. (cond
      6. [(null? ls) 1]
      7. [(= (car ls) 0) (break 0)]
      8. [else (* (car ls) (product (cdr ls)))])))))

      其continuation如下:

      1. ;; CC:
      2. (define cc8 (lambda (v) v))
      3. ;; What to do with cc:
      4. (define (p-1 ls)
      5. (lambda (cc)
      6. (cond
      7. [(null? ls) 1]
      8. [(= (car ls) 0) (cc 0)]
      9. [else (* (car ls)
      10. [(p-1 (cdr ls)) cc])])))
      11. ((p-1 '(1 2 3 0 5)) cc8)
      12. ((p-1 '(5 4 3 2)) cc8)

      由于p-1是一个以cc为参数的函数,因此,(product (cdr ls))就需要转换成[(p-1 (cdr ls)) cc]

      理解CPS  
       

      上一篇通过一些例子讲述了如何来理解continuation,这一篇讲主要讲述如何理解著名的Continuation Passing Style,即CPS。

      在TSPL的第三章“Continuation Passing Style”里,Kent Dybvig在对Continuation总结的基础上,引出了CPS的概念。因为Continuation是某个计算完成之后,要继续进行的计算,那么,对于每一个函数调用,都隐含了一个Continuation即:函数调用返回后,要继续进行的计算——或者是返回函数的返回值,或者是更进一步的计算。Kent在书中写道:
      “In particular, a continuation is associated with each procedure call. When one procedure invokes another via a nontail call, the called procedure receives an implicit continuation that is responsible for completing what is left of the calling procedure's body plus returning to the calling procedure's continuation. If the call is a tail call, the called procedure simply receives the continuation of the calling procedure.”

      也就是说,函数调用是都被隐式地传递了一个Continuation。如果函数调用不是尾部调用,那么该隐含的continuation将使用函数调用的结果来进行后续计算;如果是一个尾部调用,那么该隐含的continuation就是调用方调用该函数后的continuation。例如:

      1. (/ (- x 3) 10)

      对函数“-”的调用显然不是尾部调用,因此,该调用的continuation便是对该调用的返回值进行除以10的操作。

      那么,什么叫做CPS——Continuation Passing Style呢?CPS就是指将隐式传递给(关联于)某个函数调用的continuation显式地传递给这个函数。对于上面的例子,如果我们将“-”函数改写成现实传递continuation的版本,那就是:
      1. (define (my-minus x k) (k (- x 3)))

      其中,参数k就是显式传递给函数的continuation。为了完成上述除以10的计算,对my-minus的调用就应该写成(假设x值为15):

      1. (my-minus 10 (lambda (v) (/ v 10)))

      这里的匿名函数就是那个k。Kent还写道:

      “CPS allows a procedure to pass more than one result to its continuation, because the procedure that implements the continuation can take any number of arguments.”

      也就是说,CPS使得一个函数可以传递多个计算结果给其continuation,因为实现continuation的函数可以有任意数量的参数——当然,这也可以用values函数来实现。另外,CPS允许向一个函数传递多个continuation,这样就可以根据不同的情况来进行不同的后续计算。也就是说,通过CPS,我们可以对一个函数的执行过程进行控制(flow control)。

      为了加深一下印象,让我们来看看TSPL上的例子:将下面这段代码用CPS改写。
      1. (letrec ([f (lambda (x) (cons 'a x))]
      2. [g (lambda (x) (cons 'b (f x)))]
      3. [h (lambda (x) (g (cons 'c x)))])
      4. (cons 'd (h '())))

      (关于letrec,可以参考这里)。首先,我们来改写f。因为f使用尾部调用方式调用cons,其后续计算是基于cons的返回结果的, 因此, 对于f可以改写为:

      1. [f (lambda (x k) (k (cons 'a x)))]

      再来看g函数。由于g函数以非尾部调用的方式调用了f,因此,g传递给f的continuation就不是简单地返回一个值,而是需要进行一定的操作:

      1. [g (lambda (x k) (f x
      2. (lambda (v)
      3. (k (cons 'b v)))))]

      需要注意的是,这里g的含义是:以x和一个continuation调用f,将所得的结果进行continuation指定的计算,并在该计算的结果上应用k。

      最后,h函数通过尾部调用的方式调用g,因此,对h调用的continuation就是对g调用的continuation。那么,h可以改写为:
      1. [h (lambda (x k)
      2. (g (cons 'c x) k))]

      最后,将这些组合到一起:

      1. (letrec ([f (lambda (x k) (k (cons 'a x)))]
      2. [g (lambda (x k) (f x (lambda (v) (k (cons 'b v)))))]
      3. [h (lambda (x k) (g (cons 'c x) k))])
      4. (h '() (lambda (v) (cons 'd v))))
      通俗一点说来,continuation就像C语言里的long_jump()函数,而CPS则类似于UNIX里的管道:将一些值通过管道传递给下一个处理——只不过CPS的管道是函数级别而非进程级别的。这个观点大家让它烂在心里就好了,否则,如果某天你在宣扬这个观点的时候,不小心碰上一个(自诩的)Scheme高手,他一定会勃然大怒:Scheme为什么要跟C比较?Scheme和C的理念完全不一样!所以,低调,再低调。
      理论上,所有使用了call/cc的函数,都可以使用CPS来重写,但Kent也承认,这个难度很大,而且有时候要修改Scheme所提供的基础函数(primitives)。不过,还是让我们来看看几个将使用call/cc的函数用CPS改写的例子。
      1. (define product
      2. (lambda (ls)
      3. (call/cc
      4. (lambda (break)
      5. (let f ([ls ls])
      6. (cond
      7. [(null? ls) 1]
      8. [(= (car ls) 0) (break 0)]
      9. [else (* (car ls) (f (cdr ls)))]))))))

      首先,将call/cc的调用从函数体中除去,然后,为product函数加上一个参数k,该参数接受一个参数。另外,因为product增加了一个参数,因此对f这个命名let也需要增加一个参数。最后,在f的body里面调用f,也需要改写成CPS形式。因为对f的调用不是尾部调用,因此在f返回之前,需要进行计算,然后才是对该结果进行下一步的计算。此时需要的后续计算为:

      1. (lambda (v) (k (* (car ls) v)))

      对于cond的每个分支,都需要对其结果进行后续的k计算,这样,就得到了结果:

      1. (define product/k
      2. (lambda (ls k)
      3. (let f ([ls ls] [k k])
      4. (cond [(null? ls) (k 1)]
      5. [(= (car ls) 0) (k "error")]
      6. [else (f (cdr ls)
      7. (lambda (x)
      8. (k (* (car ls) x))))]))))
      需要注意的是,由于product/k是个递归过程,对于每个返回的值,都会有后续操作,因此需要对cond表达式的每个返回值应用continuation。
      习题3.4.1是要求用两个continuation来改写reciprocal函数,如下:
      1. (define reciprocal
      2. (lambda (x ok error)
      3. (if (= x 0)
      4. (error)
      5. (ok (/ 1 x)))))
      6. (define ok
      7. (lambda (x)
      8. (display "ok ")
      9. x))
      10. (define error
      11. (lambda ()
      12. (display "error")
      13. (newline)))
      14. (reciprocal 0 ok error)
      15. (reciprocal 10 ok error)

      习题3.4.2要求用CPS改写这里的retry。

      1. (define retry #f)
      2. (define factorial
      3. (lambda (x)
      4. (if (= x 0)
      5. (call/cc (lambda (k) (set! retry k) 1))
      6. (* x (factorial (- x 1))))))

      同样,需要将factorial改写成接受两个参数的函数,第二个参数为continuation。接下来,把对call/cc的调用去掉,改写成对k的使用。然后,根据对factorial递归调用的非尾部性,确定如何调用新的函数。结果如下:

      1. (define factorial/k
      2. (lambda (x k)
      3. (if (= x 0)
      4. (begin
      5. ( retry/k k)
      6. (k 1))
      7. (factorial/k
      8. (- x 1)
      9. (lambda (v)
      10. (k (* x v)))))))
      11. (factorial/k 4 (lambda (x) x))
      12. (retry/k 2)
      13. (retry/k 3)

      习题3.4.3要求用CPS改写下面的函数:

      1. (define reciprocals
      2. (lambda (ls)
      3. (call/cc
      4. (lambda (k)
      5. (map (lambda (x)
      6. (if (= x 0)
      7. (k "zero found")
      8. (/ 1 x)))
      9. ls)))))

      这道题难度很大,因此Kent给出了提示:需要修改map函数为接受continuation作为额外的参数的形式。——至于原因,我也说不清楚。

      首先,自己实现一个非CPS版本的map函数map1:
      1. (define map1
      2. (lambda (p ls)
      3. (if (null? ls)
      4. '()
      5. (cons (p (car ls))
      6. (map1 p (cdr ls))))))

      这里,当ls为空时,需要立刻对返回结果'()进行后续计算。而非空时,通过map1调用自身,并对结果进行后续计算。那这时就应该着重考虑这段代码:

      1. (cons (p (car ls))
      2. (map1 p (cdr ls)))
      根据对函数参数求值的顺序,有两种顺序来进行这段代码的计算。
      • 当求值顺序为从左向右时
      首先,它计算出(p (car ls))得到v1,其后续计算为(map1 p (cdr ls))得到v2,而后者的后续计算为(cons v1 v2)并返回该结果。那么,计算并得到v1及其后续计算如下:
      1. (p (car ls)
      2. (lambda (v1)
      3. (map2/k p (cdr ls) k1)))
      随即进行后续的k1计算,而k1为对v1和v2的后续计算:
      1. (lambda (v2)
      2. (k (cons v1 v2)))
      将这两个计算合并起来:
      1. (define (map2/k p ls k)
      2. (if (null? ls)
      3. (k '())
      4. (p (car ls)
      5. (lambda (v1)
      6. (map2/k p (cdr ls)
      7. (lambda (v2)
      8. (k (cons v1 v2))))))))
      • 当求值顺序为从右向左时
      首先,它计算出(map1 p (cdr ls))得到结果v2,其后续计算为(p (car ls))得到v1,而后者的后续计算为(cons v1 v2)并返回结果。那么,计算得到v2及其后续计算为:
      1. (map1/k p (cdr ls)
      2. (lambda (v2)
      3. (p (car ls) k1)))
      随后进行对v2和v1的计算,即:
      1. (lambda (v1)
      2. (k (cons v1 v2)))
      最后将这两个计算合并起来:
      1. (define map1/k
      2. (lambda (p ls k)
      3. (if (null? ls)
      4. (k '())
      5. (map1/k p (cdr ls)
      6. (lambda (v2)
      7. (p (car ls)
      8. (lambda (v1)
      9. (k (cons v1 v2)))))))))
      有了CPS的map函数之后,写出reciprocal的CPS形式就很简单了:
      1. (define reciprocal1/k
      2. (lambda (ls k)
      3. (map1/k (lambda (x c)
      4. (if (= x 0)
      5. (k "zero")
      6. (c (/ 1 x))))
      7. ls
      8. k)))

      其中,k是整个reciprocal1/k计算完成后的continuation,因此用于返回错误;而c则是计算完(/ 1 x)的continuation,只不过在这里也是k而已。另外,无论是用map1/k,还是map2/k,其结果应该是一样的。

      总结一下,当使用CPS来取代call/cc或者使用CPS时,如果函数中有对含有CPS的函数的调用,那么,传递进去的continuation或者作为函数,应用到传递来的参数上(非尾部调用);或者作为一个返回值(尾部调用);如果没有调用含有CPS的函数,则将其应用到返回值上。

杨辉(Pascal)三角

一个杨辉三角如下所示:
开始学习Scheme

为了计算某个位置上的值:
  1. (define pascal-triangle
  2. (lambda (row col)
  3. (cond ([or (= row 0) (= col 0)] 0)
  4. ([= row col] 1)
  5. (else (+ (pascal-triangle (- row 1) (- col 1))
  6. (pascal-triangle (- row 1) col))))))

没错,这是个树形递归,会占用较大的空间。那么,来考虑一下通用的情况:

f(0,0) = 0
f(0,1) = 0
f(1,1) = f(0,1)+f(0,0)
f(2,1) = f(1,1)+f(1,0)
f(2,2) = f(1,2)+f(1,1)
f(3,1) = f(2,1)+f(2,0)
f(3,2) = f(2,2)+f(2,1)
...
f(m-1,n-1) = f(m-2,n-1)+f(m-2,n-2)
f(m-1,n) = f(m-2,n)+f(m-2,n-1)
f(m,n) = f(m-1,n)+f(m-1,n-1)
f(m+1,n) = f(m,n)+f(m,n-1)
可以看出,每一次计算下一个值的时候,都无法完全使用上一步计算的结果,所以到目前为止我还没有找到一种使用尾递归的方式来改写这个函数。如果哪位同学能够用尾递归方式解出来,请及时通知我。
为了打印出杨辉三角,需要用两个循环变量来控制行和列的循环。每次增加一行,就需要对该行的每一列进行输出,知道行、列值相等。如下:
  1. (define p-t
  2. (lambda (n)
  3. (let iter ([i 1] [j 1])
  4. (when (< i (+ n 1))
  5. (display (pascal-triangle i j)) (display " ")
  6. (if (= i j)
  7. (begin (newline) (iter (+ i 1) 1))
  8. (iter i (+ 1 j)))))))

此处i为行号,j为列号。(p-t 8)结果如下:

  1. 1
  2. 1 1
  3. 1 2 1
  4. 1 3 3 1
  5. 1 4 6 4 1
  6. 1 5 10 10 5 1
  7. 1 6 15 20 15 6 1
  8. 1 7 21 35 35 21 7 1

再次考虑是否能使用尾递归:

由于Scheme提供do来完成循环,且可以利用尾递归——其实,使用do编写尾递归的关键因素是找到循环不变式,但目前我没有找到:使用do来考虑上面的结果,如果要计算出第7行第3列的15,需要保存上一步的两个计算结果5和10,而为了得到5,又需要保存其上一步的结果1和4,为了得到10,又需要保存其上一步的结果6和4,此时需保存的结果变为3个。考虑第8行第4列的35,最多的时候需要保存第5行的所有5个结果。由于每一步保存结果个数不一样,因此这种方式的尾递归行不通。

    1. 经过了三个月左右的集中学习(intensive learning),终于可以使用Scheme做一些简单的工作了,而且,也能够依葫芦画瓢做一些复杂点的工作了——然而,用Scheme语言编程,其重点是如何找到解决问题的方法,而不是如何去实现这个解决方法,因为Scheme提供了很强的表达能力,将程序员们从语言的细节以及语法糖蜜中解放出来,使得他们能够更专注于问题本身,而不是实现本身。
      • 起步
      回想起自己接触、学习函数式变成和Scheme的经过,其中充满了曲折和坎坷。Scheme语言本身的简单性导致了其灵活性,使得一个人可以在几天之内学完基本语法,但要使用好Scheme,需要长时间的训练。另外,对于初学者来说一个难点就是Lisp方言太多,而每个方言的各种实现也不尽相同,这就导致了在真正开始学习之前需要选择一个合适的Scheme实现。

      在2008年年底的时候,因为跟cuigang讨论一个C++的问题,开始知道函数式编程范式,于是买了本《计算机程序的构造与解释》(SICP)开始了Scheme之旅。然而,一方面函数式语言确实不符合自己一贯的编程习惯,另一方面这本书更注重数学方面,因此,开始的学习历程很艰苦,不但无法熟练使用尾递归,加之工作负载确实不小,于是便放弃了。

      • 重拾Scheme
      那是在将近三年以后了。在2011年年中,因为公司战略调整,手里基本上没有什么工作了。在某天整理书架时发现了SICP书,于是又开始学习了。这里必须承认,我看书的习惯确实不好,因为不能先浏览几遍,再开始精读。入门的艰苦导致了又产生了放弃的念头,此时无意之中发现了《Simply Scheme》,号称SICP的基础。这本书确实不算太难,话了很大的篇幅来讲述递归和尾递归,并提供了大量的基础练习。通过结合“循环不变式”的知识,并浏览了一些Scheme的语言构造之后,终于能够用尾递归来解决问题了。那段时间为了理解尾递归并解答相关的习题,经常在快睡着时突然有了思路,于是起来上机调试。在Simply Scheme系列的(2)、(3)、(4)中可以看到这些习题。在学习了do、loop和命名let之后,突然间好像醍醐灌顶,便有了这一篇:[原]重新开始学习Scheme(2):线性递归以及循环不变式。根据循环不变式,我们就可以很轻松地用尾递归来解决这两个问题:
      1. 当n<3时,f(n)=n;否则,f(n) = f(n-1)+2f(n-2)+3f(n-3)。代码如下:
      1. (define (f-i n)
      2. (let iter ((fn 4) (fn-1 2) (fn-2 1) (fn-3 0) (i n))
      3. (cond ((< n 3) n)
      4. ((= i 3) fn)
      5. (else
      6. (iter (+ fn (* 2 fn-1) (* 3 fn-2))
      7. fn
      8. fn-1
      9. fn-2
      10. (- i 1))))))
      2. 求解1!+2!+3!+...+n!。代码如下:
      1. (define (fac-sum n)
      2. (let iter ((fi-1 1) (c 1) (r 0))
      3. (if (= c (+ n 1)) r
      4. (iter (* fi-1 c) (+ c 1) (+ r (* fi-1 c))))))
      • 为了工作而学习Scheme
      快乐的日子总是短暂的。还没有完成Simply Scheme的一半,工作强度又大了起来,于是,Scheme的学习又放下了,直到工作中切切实实需要用到Scheme。正如我在“[原]重新开始学习Scheme(1)”中所说的,出于兴趣和工作需要来学习某项技能,其过程和结果都是不一样的,各有长短吧。如果不是项目需要,我也不可能在这么短的时间内如此高密度地学习一项技能;但正因为项目需要,不可能花大量的时间在自己的兴趣点上,这样就导致了许多问题遗留下来。因此,虽然在“重新开始学习Scheme”系列里涵盖了Scheme的几个重要特性,但好像除了尾递归,其他的特性我都只是摸到,甚至只是刚看到门槛而已。幸好工作中使用这些特性的机会不多,因此还是可以自诩为Scheme工程师。Scheme程序员?按照Lisp社区的说法,必须要能够写一个Lisp解释器,才能自称为Lisp程序员。这话同样适合于Scheme。
      在这样一家公司,由于战略调整和重组是非常频繁的事情,现在虽然开始做Scheme相关的工作,但恐怕过不了多久又会被安排去做其他的东西,那后续的Scheme学习就会成为镜花水月——但愿不要再在我身上发生这样的事情了。
      • 书目
      计算机程序的构造与解释》:函数式编程和Scheme的入门教材。正如王垠所说,这本书只有前三章对于初学者有价值。
      Simply Scheme》:虽然SICP的作者认为这本书的作者在恭维SICP,但确实难度要低一些。
      实用Common Lisp》:虽然是CL方言,但可以让读者对FP和Lisp有个大概的认识
      On Lisp》:高质量的一本书,里面一些重要章节同样适用于Scheme和CL,例如关于continuation的说明。
      The Scheme Programming Language》:不多说了,估计地位跟TCPL之类的书差不多。
      还有很多其他的书,这里不一一列举。
      • 参考资料
      Dr Racket:Scheme的一个实现,提供全面的文档和图形库
      Schemers.org:Scheme社区
      ReadScheme.org:论文聚合点
      "Recursive Functions of Symbolic Expressions and Their Computation by Machine, Part I":关于FP和Lisp的开山之作
      "Why Functional Programming"
      • 真的可以说学会了,不用再学习了吗?
      虽然只是刚刚具备了Scheme的基础,但系统学习这一阶段确实可以结束了,毕竟,项目就在那里,公司也不可能永远让员工处于学习状态,只向员工投入资金而不向员工要产出的公司只能出现在梦里。因此,以后基本不会有大量时间来集中学习Scheme了。我想,是时候总结一下了。——以后随用随学,一次一个小知识点。

=========================== End