续延传递(Continuation Passing Style, CPS)是一种编程手法,不要相信我能够将它讲清楚——在敲这些字的时候,我刚刚开始看《The Little Schemer》的第八章的 multirember&co
这个函数的定义。
下面是阶乘函数的定义:
(define (factorial n)
(cond ((= n 1) 1)
(else (* n (factorial (- n 1))))))
如果看不懂这个函数的定义,就没必要再看下去了,应该先阅读 SICP 的第一章。
下面是阶乘函数的另一种形式的定义:
(define (factorial-cps n k)
(cond ((= n 1) (k 1))
(else (factorial-cps (- n 1) (lambda (v) (* n (k v)))))))
看不懂这个函数的定义,是正常现象,因为它是续延传递风格的函数。
不要理睬 factorial-cps
的定义,来看一下它的用法。下面的代码可以计算 3!:
> (factorial-cps 3 (lambda (z) z))
6
下面是 factorial-cps
在接受实参 3
与 (lambda (z) z)
之后的执行过程:
第一步:
(factorial-cps 2 (lambda (v) (* 3 ((lambda (z) z) v))))
第二步:
(factorial-cps 1 (lambda (v') (* 2 ((lambda (v) (* 3 ((lambda (z) z) v))) v'))))
第三步:
((lambda (v') (* 2 ((lambda (v) (* 3 ((lambda (z) z) v))) v'))) 1)
最后得到的这个表达式虽然复杂,但它只不过是让一个函数——三个逐层嵌套的匿名函数构成的函数作用于实参 1
而已。如果将这个表达式复制到 Guile 交互解释器中,求值结果为 6
,恰好是 3!. 也就是说,这个表达式是真正的阶乘计算过程。factorial-cps
函数本身并未在阶乘方面进行任何计算,它所做的工作就是生成这个阶乘计算过程。
虽然可以将 factorial-cps
的过程完全展开,代码中的任何一个局部都能够理解,但是依然不理解代码的完整含义。因为我们的思维里还没有完全的接纳续延的概念。
下面的代码
(factorial-cps 3 (lambda (z) z))
它作出了一个承诺:算出 3! 的结果后,我会将它传递给匿名函数 (lambda (z) z)
。这个匿名函数只是简单的将参数作为返回值。
进入 factorial-cps
计算过程后,该函数会作出承诺:我要先计算 2!,然后将计算结果 v
交给一个匿名函数 (lambda (v) (* n (k v)))
,由这个函数负责算出 3! 的结果。
当 factorial-cps
接受了实参 2
与 (lambda (v) (* n (k v)))
时,它承诺:我要先计算 1!
,然后将计算结果 v'
交给匿名函数 (lambda (v') (* 2 ((lambda (v) (* 3 ((lambda (z) z) v))) v')))
,由这个函数负责算出 3! 的结果。
当 factorial-cps
传递接受实参 1
与 (lambda (v') (* 2 ((lambda (v) (* 3 ((lambda (z) z) v))) v')))
时,factorial-cps
的第一个谓词为真,于是,就得到了:
((lambda (v') (* 2 ((lambda (v) (* 3 ((lambda (z) z) v))) v'))) 1)
这是一条承诺链,随着递归层次的增加,这条承诺链会越来越长。当递归达到终点时,承诺链便建好了。接下来,以从新到旧的顺序完成每个承诺,便可得到 3!。
_SEC_(Fibonacci)
下面是 Fibonacci 函数的定义:
(define (fib n)
(cond ((= n 0) 0)
((= n 1) 1)
(else (+ (fib (- n 1)) (fib (- n 2))))))
如果看不懂这个函数的定义,就没必要再看下去了,应该先阅读 SICP 的第一章。
下面是 Fibonacci 函数的续延传递版本:
(define (fib-cps n k)
(cond ((= n 0) (k 0))
((= n 1) (k 1))
(else (fib-cps (- n 1)
(lambda (v)
(fib-cps (- n 2)
(lambda (v')
(k (+ v v')))))))))
假设使用 fib-cps
计算 (fib n)
:
(fib-cps n (lambda (z) z))
这行代码作出了第一个承诺:我要算出 (fib n)
,将结果传给 (lambda (z) z)
。
对于 (fib-cps n (lambda (z) z))
这个任务,fib-cps
函数作出承诺:我要先算出 (fib (- n 1))
的结果 v
,然后将 v
传递给下面这个匿名函数:
(lambda (v)
(fib-cps (- n 2)
(lambda (v')
(k (+ v v')))))
这个匿名函数也是在作承诺:我先算出 (fib (- n 2))
的值 v'
,然后将 v'
传给下面这个匿名函数:
(lambda (v')
(k (+ v v')))
这个匿名函数也是在作承诺:我先算出 (+ v v')
,然后将结果传给函数 k
,而这里的 k
恰好是 (lambda (z) z)
。
假设使用 fib-cps
计算 (fib n)
:
(fib-cps n (lambda (z) z))
(lambda (z) z))
是一个续延,表示 fib-cps
函数在算出 (fib n)
的结果之后要做的事,可将其表示为 (fib-cps n [])
。(lambda (z) z))
这个几乎什么也没做的非常普通的匿名函数之所以能成为续延,是因为它接受的参数 z
是 fib-cps
在应用这个匿名函数之前得到的计算结果——函数本来要作为结果返回的值变成了这个函数的续延的参数了。
将一个函数的续延作为参数传递给这个函数,这就是续延传递。
来看 (fib-cps (- n 1) [])
:
(lambda (v)
(fib-cps (- n 2)
(lambda (v')
(k (+ v v')))))
这是 fib-cps
在计算出 (fib (- n 1))
之后要做的事。这件事是什么呢?是 (k (+ (fib (- n 1)) (fib (- n 2))))
,而 k
就是 fib-cps
在计算出 (fib n)
之后要做的事,即 (lambda (z) z)
。
将原本很直白的
(define (fib n)
(cond ((= n 0) 0)
((= n 1) 1)
(else (+ (fib (- n 1)) (fib (- n 2))))))
写成
(define (fib-cps n k)
(cond ((= n 0) (k 0))
((= n 1) (k 1))
(else (fib-cps (- n 1)
(lambda (v)
(fib-cps (- n 2)
(lambda (v')
(k (+ v v')))))))))
这样做有什么好处?
注意 fib
与 fib-cps
的递归形式的不同,前者是符合人类思维模式的普通递归,后者是尾递归——函数的求值结果是其自身的应用。尾递归的好处是不会导致栈溢出。
非尾递归形式的递归函数,因为上层递归过程在下层递归过程返回计算结果之前不会退出,所以它们占据的栈空间不会被释放。这样,每执行一次递归都会消耗一部分栈空间——当递归深度过大时,栈空间会耗尽,导致运算过程中断。如果将这种递归形式改写为续延传递形式,那么它就变成尾递归了。由于尾递归函数,每次递归时,其上层递归过程已经完全执行完了,它们没必要再占据栈空间,因此编译器/解释器可以将它们所占用的栈空间释放——尾递归优化。
王垠写了 40 行我们看不懂的 Scheme 代码。据说这 40 行代码可以自动将非尾递归形式的递归函数『翻译』为续延传递风格的函数。如果真的是这样,这似乎是非常强大的技术。有了这种技术,再也不用担心递归会导致栈溢出。
据说续延传递还有更重要的应用。通过阶乘与 Fibonacci 函数的例子可以看出,续延传递风格的函数可以将特定的计算过程在递归函数的参数中积累起来。换句话说,基于续延传递,可以将特定的递归运算过程展开为一个层层嵌套的匿名函数构成的表达式,就像在做多项式展开运算一样。编译器/解释器有机会对展开结果进行优化。我不明白具体可以优化什么,大概是可以消除一些重复的运算吧。
fib-cps
的运算效率与 fib
是一样的慢!事实上,下面这个将 fib*
是更好的尾递归形式:
(define (fib* n)
(define (fib-iter a b count)
(cond ((= count 0) b)
(else (fib-iter (+ a b) a (- count 1)))))
(fib-iter 1 0 n))
这种尾递归形式可能不可能存在类似王垠 40 行代码的代码自动变换出来,它是基于动态规划方法构造出来的。基于动态规划方法所构造的运算过程通常与具体的问题密切相关。
写完此文后,我依然没看完《The Little Schemer》的第八章。