熟悉函数式编程的同学都了解lambda表达式,程序设计语言里的lambda表达式来源于1936年邱奇发明的lambda演算。Y-Combinator正是lambda演算里最富有神秘色彩的一种函数。它的作用是在只有匿名函数的lambda演算里实现递归函数调用。推导Y-Combinator很多人都做过了,比如这篇Javascript推导 Deriving the Y Combinator in 7 Easy Steps(中文) 还有这篇原文使用Scheme中文翻译为Javascript的推导 The Why of Y (中文)。读过我发现推导过程思维跳跃性有点大,且每个步骤没有给出足够的解释。本篇向读者揭示了如何反复重构一个普通的阶乘函数从而推导出Y-Combinator。文章的推导方法来源于Jim Weirich在ruby conference 2012上的一次分享(下载地址 优酷),Jim Weirich是Ruby社区的重要贡献者,开发了非常流行的 Rake —— 几乎被所有Ruby 开发者使用的开发工具。非常可惜的是Jim Weirich已经于今年2月19日离世。这篇推导非常棒,因为在推导前对重构方法进行了充分介绍,推导整个过程就变得非常平坦。于是我也顺便学习了下写了这篇文章。文章分三部分:1关于lambda演算和Y-Combinator的简单介绍,了解的人可以直接跳过;2重构的方法;3推导Y-Combinator过程。后两部分主要摘取自Jim Weirich的分享但是为了便于理解进行一些修改。
1 关于lambda演算
lambda演算
本文不详细介绍lambda演算和Y-Combinator了,那可能需要相当的篇幅。不熟悉的同学可以自己通过网上材料学习(参考英文wiki 中文wiki Programming Languages and Lambda Calculi G9的blog)。按照wiki的定义:lambda演算(lambda calculus,λ-calculus)是一套用于研究函数定义、函数应用和递归的形式系统。邱奇运用lambda演算在1936年给出判定性问题的一个否定的答案。这种演算可以用来清晰地定义什么是一个可计算函数。lambda演算是一套形式系统,在计算机被建造出之前就已经存在很久了。lambda演算和图灵机是从两个不同方向对计算能力的抽象,前者发源于理论而后者来自硬件设计。比较之下lambda演算更为简单优美并接近数学,一句话能很好的概括它:lambda演算可以被称为最小的通用程序设计语言。
lambda演算与Y-Combinator的关系
Y-Combinator是一个函数,他可以为匿名函数生成一个递归调用自己的函数。Y-Combinator使得lambda演算有能力表达递归逻辑。因为lambda演算和图灵机具有相同的计算能力,所以lambda演算当然可以表示任何常见的编程概念,比如用来表示自然数的邱奇数和当作谓词使用的邱奇布尔值(参考邱奇数 Church_encoding),或者是用单参数lambda表示多参数函数的Currying(参考 中文wiki 或者 谈谈Currying)。
了解Y-Combinator的意义
我们了解Y-Combinator有用吗?负能量的答案是:几乎没有任何用处。在具有命名能力的编程语言里完全不需要这种制造递归的函数,且编程语言的底层实现都是基于图灵机模型的,与lambda演算和Y-Combinator没有半点关系。正能量的答案是:有点用处可以帮助你更好的了解函数式编程。
lambda与Javascript
现代程序语言中的lambda表达式只是取名自lambda演算,已经与原始的lambda演算有很大差别了。而Javascript里没有任何语法专门代表lambda只写成这样的嵌套函数"function{ return function{...} }"
有趣的事
SICP封面画了lambda,MIT的计算机科学系徽就是Y-Combinator,创业教父Paul Graham的孵化器叫Y-Combinator。
2 重构的方法
下面5种方法都是Jim Weirich提到的,5种方法在后续的推导种都被使用了至少一次。第一种方法使用的最频繁。
1 Tennent Correspondence Principle
把任何表达式包装在一个lambda里并立刻调用这个lambda表达式,不会对原表达式的值产生影响。这个方法在推导Y-Combinator的过程中被使用了很多次。
// Tennent Correspondence Principle
mul3 = function(n) { return n * 3 }
mul3 = function(n) { return function() { return n * 3 }() }
make_adder = function(x) {
return function(n) { return n + x }
}
make_adder = function(x) {
return function() { return function(n) { return n + x } }()
}
2 Introduce Binding
可以为一个无参数的lambda表达式添加一个参数,当然这个参数是未被绑定到任何lambda的,然后就可以在调用的时候为这个新参数传入任何值,这样修改后的代码不会对原表达式产生影响。这个也容易理解,因为新参数是后添加的,lambda里根本没有被用到嘛,没有被用到的值当然不会对表达式产生任何影响了。
// Introduce Binding
make_adder = function(x) {
return function() { return function(n) { return n + x } }()
}
make_adder = function(x) {
return function(xyz) { return function(n) { return n + x } }(123456)
}
3 Rebind
当几个存在嵌套关系的lambda存在时,可以在中间层没有参数的lambda加一个参数,然后把外部的参数通过新加的参数传递给内部lambda,Rebind从名字看就是重新绑定的意思,下面代码里的n原来是绑定到最外层的n的,Rebind修改之后就绑定到中间层的n了,外层的n已经影响不到它,他是通过中间层调用的时候传入的n来获取值的。
// Rebind
mul3 = function(n) { return function() { return n * 3 }() }
mul3 = function(n) { return function(n) { return n * 3 }(n) }
4 Function Wrap
这和第一种方法Tennent Correspondence Principle有点相似但又不太一样,可以使用一个lambda来包装原有的lambda,只要调用一下被包装的lambda即可。
// Function Wrap
x = function(x) {
return function(n) { return n + x }
}
x = function(x) {
return function(z) { return function(n) { return n + x }(z) }
}
5 Inline Function
内联是最好理解的,把原来有变量名的地方直接用变量的内容替换掉,也就是说把命名变量变成了匿名。
//5 Inline Function
compose = function(f, g) {
return function(n) { return f(g(n)) }
}
mul3add1 = compose(mul3, add1)
compose = function(f, g) {
return function(n) { return f(g(n)) }
}
mul3add1 = function(f, g) {
return function(n) { return f(g(n)) }
} (mul3, add1)
3 推导Y-Combinator过程
说明一下,每次的结果都是用这样的输出形式,我用nodejs跑挺方便,直接浏览器里也没问题。同时为了便于理解,固定用途的变量从开始到结束尽量不修改命名(Jim Weirich推导时经常重命名变量,容易把人搞晕)。
console.log(function(){
//return xxx;
}())
先看看如果推导出了Javascript版的Y-Combinator,它大概是什么样子,后续就可以方便的进行对照。
第一个版本,这是一个很普通的递归求阶乘函数,没有任何特别之处。
console.log(function(){
function fact(n){
return n == 1 ? 1 : n * fact(n-1);
}
return fact(5)
}())
// method ==> parameter
console.log(function(){
function fact(g, n){
return n == 1 ? 1 : n * g(g, n-1);
}
return fact(fact, 5)
}())
// parameter ==> lambda
console.log(function(){
function fact(g) {
return function(n) {
return n == 1 ? 1 : n * g(g)(n-1);
}
}
return fact(fact)(5)
}())
// naming fact(fact)
console.log(function(){
function fact(g) {
return function(n) {
return n == 1 ? 1 : n * g(g)(n-1);
}
}
fx = fact(fact)
return fx(5)
}())
// Tennent Correspondence Principle
console.log(function(){
function fact(g) {
return function(n) {
return n == 1 ? 1 : n * g(g)(n-1);
}
}
fx = function() { return fact(fact) }()
return fx(5)
}())
// free variable ==> parameter
console.log(function(){
function fact(g) {
return function(n) {
return n == 1 ? 1 : n * g(g)(n-1);
}
}
fx = function(g) { return g(g) }(fact)
return fx(5)
}())
// Inline
console.log(function(){
fx = function(g) {
return g(g)
} (
function(g) {
return function(n) {
return n == 0 ? 1 : n * g(g)(n-1)
}
}
)
return fx(5)
}())
// Tennent Correspondence Principle
console.log(function(){
fx = function(g) {
return g(g)
} (
function(g) {
return function(n) {
return function() {
return n == 0 ? 1 : n * g(g)(n-1)
}()
}
}
)
return fx(5)
}())
// Rebind
console.log(function(){
fx = function(g) {
return g(g)
} (
function(g) {
return function(n) {
return function(n) {
return n == 0 ? 1 : n * g(g)(n-1)
}(n)
}
}
)
return fx(5)
}())
// Tennent Correspondence Principle
console.log(function(){
fx = function(g) {
return g(g)
} (
function(g) {
return function(n) {
return function() {
return function(n) {
return n == 0 ? 1 : n * g(g)(n-1)
}
}()(n)
}
}
)
return fx(5)
}())
// Introduce Binding
console.log(function(){
fx = function(g) {
return g(g)
} (
function(g) {
return function(n) {
return function(recursion_in_mind) {
return function(n) {
return n == 0 ? 1 : n * recursion_in_mind(n-1)
}
}(g(g))(n)
}
}
)
return fx(5)
}())
// Tennent Correspondence Principle
console.log(function(){
fx = function() {
return function(g) {
return g(g)
} (
function(g) {
return function(n) {
return function(recursion_in_mind) {
return function(n) {
return n==0 ? 1 : n * recursion_in_mind(n-1)
}
}(g(g))(n)
}
}
)
}()
return fx(5)
}())
// Introduce Binding
console.log(function(){
fx = function(f) {
return function(g) {
return g(g)
} (
function(g) {
return function(n) {
return f(g(g))(n)
}
}
)
}(
function(recursion_in_mind) {
return function(n) {
return n==0 ? 1 : n * recursion_in_mind(n-1)
}
}
)
return fx(5)
}())
// naming function(recursion_in_mind) {...}
// Y is form one of Y-Combinator
console.log(function(){
temp = function(recursion_in_mind) {
return function(n) {
return n == 0 ? 1 : n * recursion_in_mind(n-1)
}
}
Y = function(f) {
return function(g) { return g(g) } (
function(g) { return function(n) { return f(g(g))(n) } }
)
}
fact = Y(temp)
return fact(5)
}())
这一步推导稍微麻烦,因为之前的重构5种方法都不管用了,需要新引入了一个叫做”函数的不动点“的概念,函数f的不动点是一个值x使得f(x) = x。例如,0和1是函数f(x) = x^2 (计算平方的函数)的不动点,因为0^2 = 0而1^2 = 1。鉴于一阶函数(在简单值比如整数上的函数)的不动点是个一阶值,高阶函数f的不动点是另一个函数g使得f(g) = g。
回到推导中来,现在需要一个有关不动点的结论来完成后续的推导,那就是temp(fact) = fact(可以先不考虑为什么需要这样一个中间结论,现在的任务是要证明这个结论的正确性,等到结论被使用时就可以知道为什么需要它了),这代表什么意思呢?套用刚刚说过的定义,高阶函数temp的不动点是另一个函数fact,使得temp(fact) = fact,只要这一步相等能达成。后续推导就没问题。
回头看看19行的return fact(5)可以知道fact就是一个可以独立计算出5的阶乘的函数了。而temp是什么呢,temp是一个接收一个函数作为参数,并返回一个函数的函数。temp(fact)就是把fact传入temp喽,也就是把一个计算阶乘的函数传入进去了,而通过查看temp的函数实现发现,temp正好利用这个阶乘函数来计算n-1的阶乘了。最后再乘上n,返回的正好是一个计算n!的函数,而刚才我们根据return fact(5)已经得出过结论了fact()也是一个计算n!的函数,所以可以得出这个中间结论了,那就是temp(fact) = fact。中间结论成立!
按照原计划我们应该赶紧拿着这个得来不易的 temp(fact) = fact 结论继续上路开始推导了,上路之前再插播一个有趣的现象,因为这个中间结论,fact是函数temp的不动点了,而函数fact又是由Y函数对原料temp进行加工而成,所以我们可以从另一个角度为Y-Combinator下定义,Y-Combinator可以计算出一个函数(也就是temp函数)的不动点函数(也就是fact)。
继续上路,查看上面代码17行可以知道fact函数正是12行的这个g(g),因为return g(g) 就是赋值给fact了,而Y函数在17行被调用,传入的是temp,也就是Y函数里的f就是temp。拿出刚刚出炉的中间结论 temp(fact) = fact 一用,我们可以把第12行的return g(g) 替换为return f(g(g)),因为fact是temp的不动点,所以g(g)就是f的不动点,所以f(g(g)) == g(g)是成立的。所以这一步推导只修改了一行,也就是上面的12行。至此,这步重构算是完成了。
// fixed point refactor g(g) ==> f(g(g))
console.log(function(){
temp = function(recursion_in_mind) {
return function(n) {
return n == 0 ? 1 : n * recursion_in_mind(n-1)
}
}
Y = function(f) {
return function(g) { return f(g(g)) } (
function(g) { return function(n) { return f(g(g))(n) } }
)
}
fact = Y(temp)
// temp(fact) = fact
return fact(5)
}())
最后一步,需要为上面11行的return f(g(g))来一次5重构法之Function Wrap,传入的参数是n,通过这次修改,当前Javascript版本的Y函数与标准lambda演算里的Y完全相同了,非常好。再次对照我们在推导前给出的样子
// Function Wrap
// Y is form two of Y-Combinator
console.log(function(){
temp = function(recursion_in_mind) {
return function(n) {
return n == 0 ? 1 : n * recursion_in_mind(n-1)
}
}
// λg.(λx.g(x x))(λx.g(x x))
Y = function(f) {
return function(g) { return function(n) { return f(g(g))(n) } } (
function(g) { return function(n) { return f(g(g))(n) } }
)
}
fact = Y(temp)
return fact(5)
}())
按理说到此为止Y的推导过程已经结束了,但是我觉得这还不够完美,因为出现了一个命名变量temp,Y就是为实现匿名函数的递归调用而生的,我们这里放了temp是什么意思,男女授搜不亲啊,所以好人做到底,我们最后再使用一次Inline,直接把temp所代表的lambda丢给Y函数好了,这也就正好完美的演示了Y的真正用途:为匿名函数制造一个递归调用自己的函数(fact函数),全文完。
// Inline
console.log(function(){
// λg.(λx.g(x x))(λx.g(x x))
Y = function(f) {
return function(g) { return function(n) { return f(g(g))(n) } } (
function(g) { return function(n) { return f(g(g))(n) } }
)
}
fact = Y(
function(recursion_in_mind) {
return function(n) {
return n == 0 ? 1 : n * recursion_in_mind(n-1)
}
}
)
return fact(5)
}())