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

        熟悉函数式编程的同学都了解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{...} }"

Javascript推导Y-Combinator (来自Jim Weirich)_第1张图片

有趣的事

        SICP封面画了lambda,MIT的计算机科学系徽就是Y-Combinator,创业教父Paul Graham的孵化器叫Y-Combinator。

Javascript推导Y-Combinator (来自Jim Weirich)_第2张图片  Javascript推导Y-Combinator (来自Jim Weirich)_第3张图片 Javascript推导Y-Combinator (来自Jim Weirich)_第4张图片

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,它大概是什么样子,后续就可以方便的进行对照。

Javascript推导Y-Combinator (来自Jim Weirich)_第5张图片

        第一个版本,这是一个很普通的递归求阶乘函数,没有任何特别之处。

console.log(function(){

  function fact(n){
    return n == 1 ? 1 : n * fact(n-1);
  }
  return fact(5)

}())


        我们把fact放到参数中去,这样函数体里就少了一个自由变量,把命名变量从自由变量改成绑定变量是一个很重要的重构手段。
// method ==> parameter
console.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 ==> lambda
console.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 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)

}())


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

}())


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


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


        再来一次TCP,注意,这次被包裹的lambda是return function(n) { return n == 0 ? 1 : n*g(g)(n-1) },所以第13行写成}()(n)而不是}(n)()。
// 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为无参的函数添加一个参数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 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,为上一步18行添加的那个调用传入一个参数,同时这个参数没有使用自由变量,而是直接使用了一个lambda,代码有点拖沓,仔细看就会明白。
// 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)
}())


        用一个变量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-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完全相同了,非常好。再次对照我们在推导前给出的样子

Javascript推导Y-Combinator (来自Jim Weirich)_第6张图片

// 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)

}())

你可能感兴趣的:(JavaScript,函数式编程,lambda,lisp,Y-Combinator)