[译] The Why of Y - 理解Y Combinator

原文:(The Why of Y)

作者:

Richard P. Gabriel

Lucid, Inc. and StanfordUniversity


你是否好奇Y(Y combinator,下文简称Y)的工作原理、前人是怎么发明出这玩意的?我将在这篇文章中告诉你。我将使用Scheme语言描述,因为用这种语言表达“作为参数传入另一个函数的函数被调用”更容易理解。

Y存在的意义是,在不使用(某种语言提供的)特殊的内置方法的情况下写出自引用的(self-referential)(译注:也就是递归)程序。Scheme提供了几种写出自引用程序的方法,包括全局变量定义和 letrec 等。以下是阶乘函数的一种写法:

(define fact
  (lambda (n)
    (if (< n 2)
1
  (* n (fact (- n 1))))))

这段程序能达到效果,因为全局变量fact被设为上述函数,每当计算函数体内的变量fact以确定要调用哪一个函数时,都能在全局变量中找到它的值。从某种意义上说使用全局变量是一种不愉快的体验,因为全局变量依赖于全局变量空间(the global variable space)——一种因为全局而显得脆弱(vulnerable)的资源。

Scheme中的自引用形式(form) letrec 通常是用副作用(side effect)实现的。然而推导出(reason about)不使用副作用的编程语言和程序更容易,因此学会不使用副作用编写递归程序是有理论意义的。

以下是阶乘的 letrec 写法:

(letrec
  ((f (lambda (n)
        (if (< n 2)
1
(* n (f (- n 1))))))) (f 10))

这段程序计算$10!$的值。上面的lambda表达式中的变量f引用的是 letrec 定义的变量f。

我们可以用 let 和 set! 实现 letrec :

(letrec ((f (lambda ...)))
...)

等价于

(let ((f <undefined>))
  (set! f (lambda ...))
...)

函数中所有变量f引用的都是这个函数本身。

 

Y是一个函数,传入一个可被视为递归函数的函数,返回实现了这个递归函数的函数。以下是使用Y计算$10!$的程序:

(let ((f (y (lambda (h)
              (lambda (n)
                (if (< n 2)
1
(* n (h (- n 1))))))))) (f 10))

注意传入Y的函数,这个函数接受一个函数作为参数并返回另一个函数,返回的这个函数看起来像我们想定义的递归函数。也就是说,传入Y的函数是 (lambda (h) ...) ,这个函数的函数体看起来像阶乘函数,但是其中需要递归调用阶乘函数本身的地方用对h的调用代替了。Y为h设置了合适的值,以使递归函数能够正确运行。

人们把Y称为函数(functional)的应用序(applicative-order)不动点算子(fixed point operator)。让我们看看这在我们的阶乘例子是什么意思。设$\mathcal{F}$是真正的数学意义上的阶乘函数,令$F$表示以下函数:

$F$ =  (lambda (h) (lambda (n) (if (< n 2) 1 (* n (h (- n 1)))))) 

则$((F \; \mathcal{F}) \; n) = (\mathcal{F} \; n)$。即,$\mathcal{F}$是$F$的一个不动点:$F$(在某种意义上)将$\mathcal{F}$映射到$\mathcal{F}$。Y满足该属性:$((F \; (Y \; F)) \; x) = ((Y \; F) \; x)$。这是Y的一个很重要的属性。另一个重要属性是:函数的最小定义的不动点是唯一的,因此$(Y \; F)$和$\mathcal{F}$在某种意义上是相同的。

应用序Y和经典的(classical)Y是不一样的。我们所说的Y,在有些文献里被称为Z。

 

我将使用阶乘函数作为例子来推导出Y。我在以下的推导中将使用三种技术:

(1) 传递一个额外的参数以避免使用Scheme的自引用机制。

(2) 将多个参数的函数转换为嵌套的单参数函数,以分离自引用参数(译注:上一点所说的额外参数)的操作和常规参数的操作。

(3) 通过抽象引入函数(introduce functions through abstraction)。

以下的代码中都使用n和m表示整数变量,x表示未知的普通变量,f、g、h、q和r表示函数。

阶乘函数的基本形式如下:

(lambda (n)
(if (< n 2)
1
(* n (h (- n 1)))))

当发生递归调用时,变量h引用的应该是正确的函数,在这里是引用阶乘函数本身。但我们没办法让h直接引用正确的函数,所以先把h作为参数吧:

(lambda (h n)
(if (< n 2)
1
(* n (h h (- n 1)))))

在对h的递归调用中,第一个参数仍然是h,因为我们要把正确的函数传递给后续的递归调用。

因此,计算$10!$的写法是:

(let ((g (lambda (h n)
           (if (< n 2)
1
(* n (h h (- n 1))))))) (g g 10))

 在执行g的函数体时,变量h的值和 let 所定义的变量g的值一样。即,当执行g时,h引用的是正在执行的函数(译注:也就是g本身)。当执行到 (h h (- n 1)) 时,同样的值被传递给h作为参数:h将自己传递给自己。

接下来要做的是把函数的自引用参数和其他参数的操作分离开来。在这个例子中我们要分离h和n。这通常用一种称为柯里化(currying)的技术实现。在柯里化上面的例子之前,让我们先看另一个柯里化的例子。以下是一段用更机智的方法计算$10!$的程序:

(letrec ((f (lambda (n m)
              (if (< n 2)
m
(f (- n 1) (* m n)))))) (f 10 1))

这里使用了一个累加器(accumulator)m来计算最终结果。这个函数在Scheme中是迭代的,但这不是重点。让我们把f柯里化:

(letrec ((f (lambda (n)
              (lambda (m)
                (if (< n 2)
m
((f (- n 1)) (* m n))))))) ((f 10) 1))

柯里化的主要思想是,每个函数都只有一个参数,通过嵌套的函数调用实现传递多个参数:最内层的函数调用接收第一个参数并返回一个函数,这个函数接收第二个参数,并返回接收第三个参数的函数,以此类推,当接收完所有参数时,最外层的函数调用计算出返回值。上面的程序中 ((f (- n 1)) (* m n)) 分为两步:(1) 计算出正确的函数以供调用; (2) 将正确的参数传入这个函数。

我们可以用这种思想柯里化之前的阶乘例子:

(let ((g (lambda (h)
           (lambda (n)
             (if (< n 2)
1
(* n ((h h) (- n 1)))))))) ((g g) 10))

在这段程序中,递归调用也分为两步,第一步也是计算出正确的函数。但这个正确的函数是通过将一个函数作为参数传入那个函数本身得到的。

我们通过将函数传入函数自身实现了基本的自引用。上面程序的最后一行的自调用(self-application) (g g) 将g作为参数调用g自身。这个调用返回一个闭包,闭包中变量h引用了外部的g。这个闭包将接收一个数字并做阶乘运算。如果运算过程中需要递归调用,将被闭包捕获(closed-over)的变量h作为参数调用h本身。被捕获的变量h引用的是 let 定义的变量g。

总结一下这个技巧。假设我们有一个使用 letrec 实现的自引用函数:

(letrec ((f (lambda (x)
... f ...)))
... f ...)

这可以转换成使用 let 的自引用函数:

(let ((f (lambda (r)
(lambda (x)
... (r r) ...))))
... (f f) ...))

其中r是一个新的变量。

 

 让我们进一步分离阶乘函数中对h和n的操作。回忆一下之前的阶乘函数:

(let ((g (lambda (h)
           (lambda (n)
             (if (< n 2)
1
(* n ((h h) (- n 1)))))))) ((g g) 10))

我们要抽象出 if 表达式中的 (h h) 。抽象之后的函数将独立于它外围的变量,并且对控制参数的操作和数值参数分离了。以下是抽象之后的结果:

(let ((g (lambda (h)
           (lambda (n)
             (let ((f (lambda (q n)
                        (if (< n 2)
1
(* n (q (- n 1))))))) (f (h h) n)))))) ((g g) 10))

继续,柯里化f函数:

(let ((g (lambda (h)
           (lambda (n)
             (let ((f (lambda (q)
                        (lambda (n)
                          (if (< n 2)
1
(* n (q (- n 1)))))))) ((f (h h)) n)))))) ((g g) 10))

注意到函数f没必要被嵌套在函数g的内部。因此我们可以把函数的主要部分——计算阶乘的部分——提取出来:

(let ((f (lambda (q)
(lambda (n)
(if (< n 2)
1
(* n (q (- n 1)))))))) (let ((g (lambda (h)
(lambda (n)
((f (h h)) n))))) ((g g)
10)))

函数f又回到了一开始的柯里化阶乘函数的形式。我们可以把f剥离出去,从而得到Y的定义:

(define Y (lambda (f)
            (let ((g (lambda (h)
                       (lambda (x)
((f (h h)) x))))) (g g))))

这是推导出Y的一种方法。


译注:

参考书目

[1] Daniel P. Friedman and Matthias Felleisen. The Little Schemer. MIT Press, 1987.

[2] Daniel P. Friedman and Matthias Felleisen. The Seasoned Schemer. MIT Press, 1996.

 

posted @ 2017-06-13 10:38  plodsoft  阅读(596)  评论(0编辑  收藏  举报