一步一步讲解Y组合子 (Y-Combinator Explained Step by Step)
你也许听说过Y组合子(又叫Y Combinator),也查过一些资料看过一些示例代码,但就是不明白什么意思,可能是因为自己平常使用的开发语言先入为主阻碍了对函数式独特的运算规则和一些细节没想清楚。
一:Lambda演算(Lambda Calculus)
Lambda又写作希腊字母λ,Lambda演算由Alonzo Church引入以定义“可计算函数”。该演算影响了一系列所谓函数式编程语言,如Lisp、ML系列。
一个Lambda表达式用以下格式定义:
λ变量.表达式体
Scheme里要这么写:
(lambda (变量)
表达式体)
表达式体)
比如,传入一个数后返回加1的结果,Lambda表达式写作:
λa.a+1
Scheme可以写作:
(lambda (a)
(+ a 1))
(+ a 1))
当然,变量可以是多个,比如求两个数之和的Lambda表达式可以写作:
λa b.a+b
用Scheme语言可以写作:
(lambda (a b)
(+ a b))
(+ a b))
原始的Lambda演算甚至连逻辑和算术运算和数字都没有,所有的一切都是可以用Lambda演算定义出来的,当然在现代的编程语言中没必要做到那么“纯粹”,包括各种数据类型和运算该用都能用。
比如判断一个数字是否大于3,Scheme里写作:
(lambda (a)
( if (> a 3)
"Yes"
"No"))
( if (> a 3)
"Yes"
"No"))
具体Lambda函数是怎么工作的呢,答案是归约。
归约有三种规则:
α-转换(α-conversion)
α读作alpha。alpha转换的意思是变量名不影响函数含义的意思。比如:
λa b.a+b
把变量名的a和b换为x和y:
λx y.x+y
该函数的功能并没有发生改变,不管传哪两个数字进去,都会得到一样的结果即两者的和。
β归约(β-reduction)
β读作beta。beta归约的规则是把函数“应用”到传入的参数上。
比如这么个Lambda函数:
λa b.a+b
应用在传入的两个参数1和2,那么a和b分别就替换成1和2,表达式体中的a和b的位置也分别被替换成1和2:
1+2
结果为3.
Scheme该这么写:
((lambda (a) (+ a b)) 1 2)
η变换(η-conversion)
β读作eta,eta转换表达的意思是,如果两个函数对于所有相同的传入的参数都能得到一样的结果,则两个函数相等。
二:循环与递归
现在看一个稍微复杂一点的问题,假设要求n!,也就是1*2*3*4*5*...*n该如何编写程序?
一种想法用所谓循环,比如用最常见的C语言,可以这么写:
int i;
int s = 1;
for (i = 1; i != n; i++)
{
s *= i;
}
int s = 1;
for (i = 1; i != n; i++)
{
s *= i;
}
但是普通的函数式语言是不提倡甚至不允许这么写的,原因就在于上面的写法有一个保存状态的行为,也就是给一些变量赋值,保存了中间结果,而函数式语言则基于Lambda演算,Lambda演算可没有“保存状态”这种行为。
你可能会想到使用递归来实现类似循环的效果,比如Scheme里:
(define factorial
(lambda (n)
( if (> n 0)
(* n (factorial (- n 1)))
1)))
(lambda (n)
( if (> n 0)
(* n (factorial (- n 1)))
1)))
这样做运行起来是没有问题的,可是我们给这个函数绑定了一个名字Lambda运算本身不支持这种做法,也就是说第一行得擦掉
(lambda (n)
( if (> n 0)
(* n (factorial (- n 1)))
1))
( if (> n 0)
(* n (factorial (- n 1)))
1))
但是这样factorial则不再存在了,调用它是不会有结果的,我们只能调用点别的东西:
(lambda (n)
( if (> n 0)
(* n (??? (- n 1)))
1))
( if (> n 0)
(* n (??? (- n 1)))
1))
此时对于常用普通的非函数式语言的程序员比较费解的一点来了,就是函数也是一种“值”或者“对象”,它不但可以绑定到一个变量上,而且还能调用某函数时作为实际参数传入、并且在被调用的函数内部通过参数列表绑定的名字把传入的函数取出来。这么听上去似乎和函数指针之类的东西比较也没什么了不起,但是流行的函数式语言或者常被用于举例解释Y-Combinator的函数式语言往往是动态类型的或者有一定类型推导能力的,写起来十分简洁,看上去就似乎特别神奇。
以Scheme为例,定义一个返回两数和函数是:
(define (add a b)
(+ a b))
(+ a b))
但是这只是一种缩略的写法,Scheme里所有函数都是Lambda函数,本质上它等同于:
(define add
(lambda (a b)
(+ a b)))
(lambda (a b)
(+ a b)))
含义是,有那么个Lambda函数,功能是返回两数的和,然后把这个函数绑定在add这个变量上,或者说“赋值”给add变量。
我们可以直接调用这个add函数:
(add 1 2)
但是实际上add不是这个函数,只是这个函数绑定的名字,实际执行时会根据绑定的名字取出原来的Lambda函数:
((lambda (a b)
(+ a b)) 1 2)
(+ a b)) 1 2)
然后把函数应用在1和2上,得到结果3
还可以把一个函数作为参数传给另一个函数:
(define (foo f)
(f 1 2))
(define add
(lambda (a b)
(+ a b)))
(foo add)
(f 1 2))
(define add
(lambda (a b)
(+ a b)))
(foo add)
这个程序就是把add函数作为参数传给foo函数,foo函数内部则取出绑定在f变量的传入的add函数,将该函数应用在1和2上,执行得到3后返回。
当然我们传入的函数不一定要绑定add这个名字,直接传入lambda函数也是一样的效果:
(define (foo f)
(f 1 2))
(foo (lambda (a b)
(+ a b)))
(f 1 2))
(foo (lambda (a b)
(+ a b)))
既然能把Lambda传入一个绑定名字的函数,那能不能不要绑定名字而是直接把Lambda函数传递到另一个Lambda函数中呢?当然可以。我们可以看到上面的foo函数本质上就是Lambda函数,也就是:
(define (foo f)
(f 1 2))
(f 1 2))
和:
(define foo
(lambda (f)
(f 1 2)))
(lambda (f)
(f 1 2)))
是等价的。我们继续做上面做过的类似C语言的inline操作,也就是手工把函数“展开”,可以得到
((lambda (f)
(f 1 2)) (lambda (a b)
(+ a b)))
(f 1 2)) (lambda (a b)
(+ a b)))
此时原本清晰的程序已经非常难看了,但是运行的结果也是一样的。经过上面这一系列示例,你对Lambda演算有初步的概念了吗?
三:传入自己
回到之前的求阶乘的问题上:
(lambda (n)
( if (> n 0)
(* n (??? (- n 1)))
1))
( if (> n 0)
(* n (??? (- n 1)))
1))
lambda变量里不能调用一个外部的绑定名字的函数,当然Lambda函数本身也不能有名字(所以在某些编程语言里Lambda函数这个概念又叫做“匿名函数”),既然自己没有名字那如何调用自己呢?通过上一节的讨论结论很明显了,就是把“自己”或者说跟自己功能一样的函数作为参数传给自己,然后自己就可以从参数列表中取出“自己”或者说跟自己功能一样的函数进行调用。
程序修改为:
(lambda (f)
(lambda (n)
( if (= n 0)
1
(* n (f (- n 1))))))
(lambda (n)
( if (= n 0)
1
(* n (f (- n 1))))))
其中f为“跟‘自己’功能一样的函数”,上面写的函数是阶乘函数吗?不是,他本身是一个Lambda函数,接受了一个f参数,并且返回了一个使用f参数的Lambda函数。有人觉得很奇怪为什么返回的另一个函数居然能使用f这个参数,这就涉及函数式编程语言中流行的另一个概念,叫“闭包”。关于闭包的更多信息请参考**这篇文章**。可见,以上的函数不是阶乘函数而是“阶乘函数生成器”,为了方便下面解释暂时绑定一个名字。
(define factorial-maker
(lambda (f)
(lambda (n)
( if (= n 0)
1
(* n (f (- n 1)))))))
(lambda (f)
(lambda (n)
( if (= n 0)
1
(* n (f (- n 1)))))))
那么传入的“跟自己功能一样的函数”是一个什么样的函数呢?是一个阶乘函数,阶乘函数的写法是:
(define factorial
(lambda (n)
( if (= n 0)
1
(* n (factorial (- n 1))))))
(lambda (n)
( if (= n 0)
1
(* n (factorial (- n 1))))))
但是慢着,factorial函数可不能调用自己啊,那不能调用自己调用谁呢?可以调用之前写的“阶乘生成器”,于是阶乘函数改为:
(define factorial
(lambda (n)
( if (= n 0)
1
(* n ((factorial-maker factorial) (- n 1))))))
(lambda (n)
( if (= n 0)
1
(* n ((factorial-maker factorial) (- n 1))))))
这里用到的参数factorial还是不存在的,那么如何得到factorial呢?还是得调用factorial-maker产生:
(define factorial
(lambda (n)
( if (= n 0)
1
(* n ((factorial-maker (factorial-maker factorial)) (- n 1))))))
(lambda (n)
( if (= n 0)
1
(* n ((factorial-maker (factorial-maker factorial)) (- n 1))))))
这似乎要无数次调用factorial-maker,没完没了……
四:不动点
从上一节引出的问题是,虽然factorial-maker能生成factorial,但是还是需要以factorial作为参数传入,而这与转而使用factorial-maker的目的相违背,所以我们得引入一个概念,叫“不动点”。
不动点的概念以前大家都应该接触过,维基百科里解释“在数学中,函数的不动点或定点是指被这个函数映射到其自身一个点。”
举例解释,比如函数:
f(x) = x * x
当x分别为0或1时,函数的值也分别为0或1即原来的数,则0和1为函数f的不动点,也就是:
x = f(x) = f(f(x)) = f(f(f(x)))
在编程语言里,这个概念又要进一步扩展,因为函数也可以作为输入。
假设有函数f(fn),fn为函数f的输入参数,并且
fn = f(fn)
很容易发现f应用在fn上不管多少次,结果都一样:
fn = f(fn) = f(f(fn)) = f(f(f(fn)))
函数可以作为参数传递、返回值也可以是个函数,说起来容易但是对于平常不使用“函数式语言”的程序员理解起来总是不太顺畅。
五:Y函数
有了不动点的概念,再考虑上面的问题,我们有了factorial-maker,也就是可以生成factorial的函数,但是需要的是factorial本身作为参数传入,那么如何获得factorial本身?假设我们有一个函数叫Y,这个函数的作用是输入一个函数的生成器也就是factorial-maker输出该函数本身如factorial:
factorial = Y(factorial-maker) (1)
用Scheme的语法写作(Y factorial-maker)可以得到我们想要的factorial。而且:
factorial = factorial-maker(factorial) = factorial-maker(factorial-maker(factorial)) =
(2)
对于函数factorial应用任意次factorial-maker函数,都得到factorial本身,说明factorial本身是函数factorial-maker的不动点。
结合(1)和(2)可以得到:
Y(factorial-maker) = factorial-maker(factorial) (3)
(1)代入(3)的右边得到:
Y(factorial-maker) = factorial-maker(Y(factorial-maker))
于是我们需要的Y就出来了,用Scheme语言写出来就是:
(define Y
(lambda (f)
(f (Y f))))
(lambda (f)
(f (Y f))))
整个程序则为:
(define Y
(lambda (f)
(f (Y f))))
(define factorial-maker
(lambda (f)
(lambda (n)
( if (= n 0)
1
(* n (f (- n 1)))))))
(display ((Y factorial-maker) 5))
(lambda (f)
(f (Y f))))
(define factorial-maker
(lambda (f)
(lambda (n)
( if (= n 0)
1
(* n (f (- n 1)))))))
(display ((Y factorial-maker) 5))
这个程序从字面上是正确的,但一旦运行则会根据运行环境的不同有不同的运行结果,卡死直至消耗完计算机资源、提示栈溢出、运行得到正确的120,都是有可能的。原因在于Y函数内部的Lambda函数可能会调用Y不断增加新的栈帧,还来不及执行函数f。
当然一个更明显的问题是,这个程序仍然没有仅仅借用Lambda运算完成重复的操作。
六:Y函数(改进)
接下来的推导就比较困难了,我现在还没能完全弄清楚怎么到我们常见的最终形式。
Y组合子常见的最终形式是:
Y = λf.(λx.f (x x)) (λx.f (x x))
用Scheme写出来则是:
(define y-combinator
(lambda (f)
((lambda (x) (f (lambda (y) ((x x) y))))
(lambda (x) (f (lambda (y) ((x x) y)))))))
(lambda (f)
((lambda (x) (f (lambda (y) ((x x) y))))
(lambda (x) (f (lambda (y) ((x x) y)))))))
这个最终形式的Y组合子可以工作在非Lazy的正确实现的Scheme里。
七:用途
在“大多数”普通的命令式编程语言里、甚至某些支持函数式编程的不标榜函数式的编程语言里,的确很难想象到为什么表达一个重复的过程,非得求助于Y,正如前面的例子所描述,绑定一个名字往往就可以直接解决问题。不过大部分函数式编程语言既然构建在Lambda演算的基础上,底层通常也是把我们看到的高级语言的假象展开为Lambda演算,对于这些编程语言的实现者来说Y组合子是实现一些特殊语法的必要设施。
参考资料:
Lambda calculus: http://en.wikipedia.org/wiki/Lambda_calculus
不动点: http://zh.wikipedia.org/wiki/%E4%B8%8D%E5%8A%A8%E7%82%B9