说来惭愧,学习 Scheme 多年,也是最近才把经典书籍 The Little Schemer 给读完了。

作为一本入门书籍,本书旨在教会大家如何“用递归的思维方式解决问题”。如果读者具有一定的 Scheme/Lisp 基础,那么完全可以在一天内读完。本书的前几个章节比较 trivial,在引入 Scheme 的基本运算与表结构后,Dan Friedman 向大家介绍了什么是自然递归,以及如何通过对表或自然数的自然递归解决问题,同时也讲解了线性递归、树形递归等不同“形状”的递归。

老书新读,温故知新,这本书的珠玑全在细微之处。例如,第8章介绍了Continuation-Passing-Style 的程序设计方式,第9章引导大家推导 Y 组合子,第10章介绍了求值器的原理,其中任何一个章节都能够引入一个宏大的主题。由于我购买的是中文版,在阅读过程中,发现某些地方译者似乎都没能完全明白 Dan 的本意,因此想在这里同大家分享一下我的思考。

Conitnuation 与 CPS 风格初感受

首先请让我们思考一下 Scheme 的求值规则,为了求值表达式:

(display (+ 1 2))

Scheme 需要先求值子表达式 (+ 1 2) ,程序控制权转移到子表达式内部,对于 display 语句来说:

(display <retval>)

它期待着子表达式返回一个值 <retval>,以完成后续的过程调用。这个动作隐式地存在于解释器的求值规则中,现在,“显式”地把它变成程序自身的行为:

(lambda (retval) (display retval))

这里,我们通过一个 lambda 抽象创建了一个只接收单个值的继续(continuation)。如果我们有一个 CPS 风格的 +cps 函数,那么就可以向下面这样调用:

(+cps 1 2 (lambda (retval) (display retval)))

+cps 内部求值完 1+2 后,会将得到的值作为参数去调用该继续,所以有时在其它程序语言社区,继续也被叫做回调函数(callback function)。由于 cps+ 的实现要依赖一个由解释器提供的非 CPS 风格的加法,因此代码也要依赖于“函数返回”的模型。我们的实现通过显式地使用 (k sum) ,指出 CPS 风格的程序都是通过函数调用实现“返回”的 :

(define (+cps a b k)
  (let ((sum (+ a b)))
    (k sum)))

在 CPS 风格中,“返回”这个行为只是函数调用的语法糖衣,调用者(caller)与被调用者(callee)的角色发生了,或者说,消除了传统意义上的“调用者”。

简单总结一下:

  1. 继续是一个有单个或多个参数的过程;
  2. 继续传递风格中的函数一般带有一个额外参数 k 来接收传递而来的继续,并通过在尾表达式处调用该函数,实现控制权转移。

如何运行 CPS 风格的函数?

为了在常见的解释器中运行 CPS 风格的函数,我们通常会定义一个 id 函数:

(define id (lambda x x))

这样,在运行 CPS 风格的函数 fn-cps 时,在顶层将 id 函数作为继续传递即可。

如何将普通函数改写为 CPS 风格?

我们以原书中的 evens-only* 函数为例。给定一张表,该函数会构造一张只保留了偶数元素的新表。函数名的 * 尾缀暗示了这是一个树形递归,即,如果我们处理的某元素也是一张表,那么就以该元素为参数,递归地调用 evens-only* 函数。为了方便阅读,本文采用的代码都以 R^6RS 报告允许的方式改写了括号,读者可以发现 cond 形式的子句都以 [] 包裹。为了在只支持 R^5RS 的实现上运行代码,读者需要自行对代码做出修改。

(define evens-only*
  (lambda (l)
    (cond
      [(null? l) '()]
      [(atom? (car l))
       (cond
         [(even? (car l))
          (cons (car l) (evens-only* (cdr l)))]
         [else
          (evens-only* (cdr l))])]
      [else
       (cons (evens-only* (car l))
             (evens-only* (cdr l)))])))

;;; (evens-only* '((9 1 2 8) 3 10 ((9 9) 7 6) 2))
;;; => ((2 8) 10 (() 6) 2)

那么,如何将 evens-only* 改写为 CPS 风格的函数呢?首先我们知道,CPS 风格的函数会多一个额外的参数 co ,书上把它叫做 collector ,但更常用的名字是 k ,来强调他是一个 kontinuation。因此,首先改写 evens-only*&co 函数的签名如下:

(define evens-only*
  (lambda (l)
    (cond
(define evens-only*&co
  (lambda (l col)
    (cond
Direct Style Continuation-Passing-Style

此时,evens-only*&co 函数的语义会发生细微的变化:给定一张表 l,该函数会构造一张只保留了偶数元素的新表 l^,并调用 (co l^),没有对函数本身的返回值做出任何承诺。我们需要注意这其中的细微变化。

先考虑其中的原子情况:作为递归的边界条件,当 l 为空的时候,我们应该做什么呢?这需要我们考察原函数的语义。(evens-only* '()) 返回空表,因此,evens-only*&co 也应该“返回”空表。在这一层,我们没有需要再进行的计算了,我们以空表为参数调用继续:

      [(null? l) '()]
      [(null? l) (co '())]
Direct Style Continuation-Passing-Style

继续考虑其他情况:当 (car l) 为原子时,我么需要根据其奇偶性来决定后续操作。我们先考虑较为简单的 else 子句。如果 (car l) 是奇数,那么 (evens-only* l)(evens-only* (cdr l)) 的结果应该一致,也就是说,把求解关于 l 的子问题,归约为了规模较小一点的,关于 (cdr l) 的问题。

;;; evens-only*
[(atom? (car l))
  (cond
    [(even? (car l))
     (cons (car l) (evens-only* (cdr l)))]
    [else
     (evens-only* (cdr l))])]  ;;; <== watch here

改写只是改变过程“返回结果的方式”,并不改变语义。因此不难想到,改写的过程,也需要递归地调用 (evens-only*&co <s> <k>) 过程。

对于 <s> ,很容易想到就是 (cdr l)。而第二个参数 <k>,也就是我们要传递的继续,又应该是什么呢?

子问题 (cdr l) 的解,就是关于 l 的解,因此对于解的结果。我们不需要任何处理,直接送给“外层”继续:

(evens-only*&co (cdr l) (lambda (l^) (co l^)))

;;; 注意到这里构造的继续只是辅助性地传递了“返回值”
;;; 因此可以直接将上层继续传递给子问题
(evens-only*&co (cdr l) co)

我们似乎找到了点感觉,大概是什么样的呢:

  1. (递归调用部分)将本层的问题,转换为一个或多个子问题求解;
  2. (构造继续部分)将如何关于处理得到的解,形成本层问题的解的过程,封装在继续里,传递给子问题;

因此,请考虑 (car l) 为偶数时的情况:

;;; evens-only*
[(atom? (car l))
  (cond
    [(even? (car l))  ;;; <== watch here
     (cons (car l) (evens-only* (cdr l)))]
    [else
     (evens-only* (cdr l))])]

关于 l 的解,是把 (car l) 关于子问题的解给 cons 起来,因此我们认识到:① 子问题可以调用 (evens-only*&co (cdr l)) 求解;② 如果子问题求解的结果是 l^,那么 (cons (car l) l^) 就是最终的答案;③ 可以通过 (co ...) 来返回最终答案。因此,我们可以整合得到下面的代码:

[(even? (car l))
 (evens-only*&co (cdr l)
  (lambda (l^)                      ; l^ 是子问题的解
    (let [(l^^ (cons (car l) l^))]
      (col l^^))))]

掌握窍门后,读者可以轻易地将原过程外层 cond 形式的 else 子句变换为 CPS 风格,这里不再做赘述。注意到该 else 子句的值依赖于两个子问题,如何组织他们的继续是一个值得思考的问题。

有答案了么?下面是原过程与改写为CPS风格后的过程对比。其中,蓝色橙色绿色表示相应子问题的求解,而红色则代表最终解是如何通过子问题构造而来的。读者可以观察到,在 evens-only*&co 中,子问题的求解与最终解的构造被清晰地分离开了。

(define evens-only*
  (lambda (l)
    (cond
      [(null? l) '()]
      [(atom? (car l))
       (cond
         [(even? (car l))
           (cons (car l) (evens-only* (cdr l)))]


          [else
           (evens-only* (cdr l))])]
 
 
       [else
        (cons (evens-only* (car l))
              (evens-only* (cdr l)))])))



(define evens-only*&co
  (lambda (l co)
    (cond
      [(null? l) (co '())]
      [(atom? (car l)
       (cond
         [(even? (car l))
          (evens-only*&co (cdr l)
            (lambda (l^)
              (co (cons (car l) l^)))))]
         [else
          (evens-only*&co (cdr l)
            (lambda (l^)
              (co l^)))])]
     [else
       (evens-only*&co (car l)
         (lambda (l^)
           (evens-only*&co (cdr l)
             (lambda (l^^)
               (co (cons l^ l^^)))))))])))
Direct Style Continuation-Passing-Style

有任何好处么?

仔细观察 CPS 风格的函数,请注意它的尾表达式(参见R5RS关于尾表达式的描述):

01. (define evens-only*&co
02.   (lambda (l co)
03.     (cond
04.       [(null? l) (co '())]
...
07.          [(even? (car l))
08.           (evens-only*&co (cdr l)
...
11.          [else
12.           (evens-only*&co (cdr l)
...
15.      [else
16.        (evens-only*&co (car l)

所有的尾表达式都是一个过程调用,并且除了充当边界条件的第 4 行的调用外,其余的都是自调用,因此这是一个尾递归(tail recursion)。这样,由于 CPS 风格的函数都不需要“返回”,因此在尾递归调用中,没有必要保留调用栈,理论上来说,可以在常量空间的栈上,实现任意数量的尾递归调用。可以参见 SICP 《Lec1b:计算过程》 中解释为什么递归过程也可以产生迭代计算的描述。

通过改写为 CPS 风格的程序,我们真的就能消除掉像 evens-only* 那样的函数的树形递归结构吗?当然,没有什么是免费的,我们通过 CPS 变换将原函数变成了尾递归,但跟随尾递归调用而构造出的继续却不断地变得复杂!这些继续通常都是一个闭包,一方面它们通常在堆上分配,另一方面它们还需要捕获定义它们时的环境。对栈的节省,又得靠对堆的消耗来弥补!

也难怪不得有人说:

CPS stands for Continuation is Poor man’s Stack.

后记

The Little Schemer 的中译版制作非常精良,译者翻译也十分用心。在阅读时,我注意到第 142 页的一条脚注:

译者注:原文为 “…and that multiinsertLR will return () when lat is empty.” 有误。正确的应该是 “multiinsertLR&co will return ()”。

我能理解译者的意图。但事实上,Dan Friedman 的原文是正确的,同时也是理解 CPS 变换的关键点。也就是说,对于 CPS 变换,我们并不改变函数的“语义”,只是改变了函数的“返回”方式。multiinsertLR&co 的定义当然依赖于原函数。

本文仅做为抛砖引玉之介绍,关于 continuation 和 CPS 还有很多有意思的高级主题,希望之后我们能涵盖到。

参考资料

关于 CPS 有大量高质量的论文与文章,这里列出其中一小部分:

  1. Matt Might, By example: Continuation-passing style in JavaScript, https://matt.might.net/articles/by-example-continuation-passing-style/
  2. Tao He, Continuation Passing Style, https://sighingnow.github.io/%E7%BC%96%E7%A8%8B%E8%AF%AD%E8%A8%80/continuation_passing_style.html