Javascript推导Y-Combinator (来自Jim Weirich)

来源:互联网 发布:游戏源码怎么用 编辑:程序博客网 时间:2024/05/16 06:12

        熟悉函数式编程的同学都了解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 Weirichruby 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 Principlemul3 = 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 Bindingmake_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来获取值的。

// Rebindmul3 = 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 Wrapx = 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 Functioncompose = 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)}())


        我们把fact放到参数中去,这样函数体里就少了一个自由变量,把命名变量从自由变量改成绑定变量是一个很重要的重构手段。
// method ==> parameterconsole.log(function(){  function fact(g, n){    return n == 1 ? 1 : n * g(g, n-1);  }  return fact(fact, 5)}())


        进一步,我们把fact的返回值从数字改成函数,也就是说fact从“计算一个数的阶乘的函数”,变成了“返回一个可以计算阶乘的函数的函数”,这次重构后,n和fact被分割开了,这样逻辑更清晰,计算阶乘这个动作被分解成了两步:第一步是制造一个能计算阶乘的函数,这由fact(fact)来完成;第二步是让这个函数计算5的阶乘。调用方式发生了细微的变化,从fact(fact, 5)变成了fact(fact)(5)。
// parameter ==> lambdaconsole.log(function(){  function fact(g) {    return function(n) {      return n == 1 ? 1 : n * g(g)(n-1);    }  }  return fact(fact)(5)}())


        休息一下,来一个简单的重构,为fact(fact)命个名就叫fx,后面再调用fx。
// 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)}())


        这一步第一次使用了前面学习的重构5方法之Tennent Correspondence Principle,简称TCP了,把return fact(fact)包装起来调用一下。
// Tennent Correspondence Principleconsole.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)}())


        这一步把自由变量fact换成绑定变量g,之前用过了。
// free variable ==> parameterconsole.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)}())


        重构5方法之Inline,Inline的结果是fact这个命名变量消失了,在需要fact的地方,用一个lambda表达式代替了。
// Inlineconsole.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)}())


        再来一次TCP,把最内层的lambda包裹一下并执行,执行的时候参数是空的。
// Tennent Correspondence Principleconsole.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,把外层的n传递给内层lambda。
// Rebindconsole.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)}())


        再来一次TCP,注意,这次被包裹的lambda是return function(n) { return n == 0 ? 1 : n*g(g)(n-1) },所以第13行写成}()(n)而不是}(n)()。
// Tennent Correspondence Principleconsole.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为无参的函数添加一个参数recursion_in_mind,并将其代替g(g),当然了需要把g(g)传递进去。因为recursion_in_mind这个函数是有特殊含义的,所以我们使用了一个特殊的名字,这个变量将代表一个假想中的可以递归计算阶乘的函数。它的用途在后面的步骤中介绍。
// 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)}())


        再来一次TCP,注意这次被包裹的是整个fx所代表的lambda。所以调用的括号在18行的末尾。
// Tennent Correspondence Principleconsole.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,为上一步18行添加的那个调用传入一个参数,同时这个参数没有使用自由变量,而是直接使用了一个lambda,代码有点拖沓,仔细看就会明白。
// Introduce Bindingconsole.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)}())


        用一个变量temp来指代上一步直接传入的lambda,同时把fx改名为Y,是的这个Y就是Y-Combinator的一种形式了。同时为了代码便于阅读,多行的代码被整理到一行里。推导到这一步,代码结构突然变的和之前不太一样了,可以看到现在有两个命名函数存在,一个是temp,另一个是Y。计算过程是清晰的两个步骤,第一步是fact = Y(temp),这是以temp为原料,以Y为制作工艺,产出了一个fact函数,这一步是在制造函数。第二步使用5来调用这个刚制作好的函数。temp函数也非常特别,它接收一个递归函数recursion_in_mind,然后调用这个函数来计算n-1的阶乘。推导到这一步,可以认为Y-Combinator已经推导出来了,但是对照lambda演算里Y-Combinator的标准形式会发现,现在的Y的样子明显跟它不符,所以要进一步推导。
// naming function(recursion_in_mind) {...}// Y is form one of Y-Combinatorconsole.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-Combinatorconsole.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函数),全文完。

// Inlineconsole.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)}())
5 2
原创粉丝点击