组合子是什么
组合子逻辑可以被看作是lambda演算的变体,它把lambda表达式(用来允许函数抽象)替代为组合子的有限集合,它们是不包含自由变量的原始函数。很容易把lambda表达式变换成组合子表达式,并且因为组合子归约比lambda归约要简单,它已经被用做用软件中的某些非严格的函数式编程语言和硬件的图归约机的实现基础。 --wikipedia
Y组合子有什么用
我的理解是学习他可以更好的理解lambda推演,并能在任意支持函数式风格编程语言中实现纯匿名函数的递归。
实现过程
匿名的递归函数
首先我们定义一个需要使用到递归的函数,这里展示了一个阶乘函数的实现
const factorial = (num) => num == 0 ? 1 : factorial(num-1)*num;
console.log(factorial(5)); // 120
可见,函数递归时需要使用到自己的函数名字,那么我们如何使用匿名函数定义一个阶乘函数呢?
我们可以假装将这个函数放到某个环境中执行,这个外部环境会将函数本身作为参数传回给这个函数,具体实现就是这样
((self) =>(num) => num == 0 ? 1 : self(num-1)*num)
他只是多了一个self参数而已,并没有改变什么,假设这个self参数是函数本身。我们要如何调用他呢
((self) =>(num) => num == 0 ? 1 : self(num-1)*num)(/* OuterEnv */)(5);
这个函数先被其他调用一次,然后才是传入我们需要计算的参数。
注意到这个函数还不能运行,因为这个函数现在是有两个参数的,所以我们不能self(num-1)。而是self(self)(num-1)
((self) =>(num) => num == 0 ? 1 : self(self)(num-1)*num)(/* OuterEnv */)(5);
到现在,我们总算是设计好了一个匿名的递归函数。但是我们还没有办法运行,因为我们没有给他传入self变量。
注意到,我们的这个函数是没有状态的,也就是他是一个纯函数,那么我们复制一份这个函数,他和原函数是一模一样的。我们可以尝试这样做:
((self) =>(num) => num == 0 ? 1 : self(self)(num-1)*num)
((self) =>(num) => num == 0 ? 1 : self(self)(num-1)*num)
(5);
现在打开控制台执行这个函数,你会发现他成功的输出了5的阶乘120,你也可以更改最后的参数让他计算其他值的阶乘。
抽象
在上一节中,我们知道实现一个这样的函数非常简单,只需要先假设有一个self参数会传入,我们可以作为自身的引用去使用他。并且在实际调用前,我们复制一份相同的函数在后面即可。 不过现在这个函数还有两个问题:
- 调用时必须先
self(self)(实际参数)而不能self(实际参数)。我们不能无感知的使用他。 - 两份一模一样的代码,这不优雅,特别是对于一些比较长的函数。我们应该对其进行抽象。 解决第二个问题非常简单,我们可以这样设计一个函数
(g=>g(g))
这个函数很简单,只是将传入的函数将自己作为参数执行了一次。我们看一下他可不可用。
(g=>g(g))
((self) =>(num) => num == 0 ? 1 : self(self)(num-1)*num)
(5);
答案是可用的,我们非常轻松的解决了这个问题,但是对于第一个问题,我们还要再讨论。
实现
假设存在一个这样的函数
let Y = (recuiseFunction) => {/* Some Work */}
然后他就会自身无限递归 => F(F(F(F(F(F(...Y(F)...))))))
其中:
- 参数 F 是我们的原函数 ,他的签名是
F = (self) => (realparams) => SomeWork - F 有两个参数 , 所以只是传入第一个参数 Y(F) , 函数并不会产生一次递归
- 当传入第二个参数, F会真正的执行逻辑 ,并产生下一个Y(F), 并且会自动传入第一个参数(也就是Y(F))... 接下来我们开始实现
let Y = (origin) => ( (params1) => (params2) => {/*SomeWork*/} );
我们可以根据语义确定上面的各位参数分别是什么。
- origin是我们要处理的源函数,当我们传入的时候,Y将处理这个函数
- 接下来传入的应该是他自己,然后传入真实参数,然后执行这个函数
let Y = (origin) => ( (self) => (realParams) => origin(self)(realParams) );
目前为止他还没有解决任何问题,因为origin不应该传入两个函数后执行。
下面的做法让Origin调用时只传入一个参数,那么用户拿到的self必然不是真实的self,只是将提前闭包在里面的origin调用的过程。
let Y = (origin) => ( (self) => origin(realParams => self(self)(realParams)) );
- 当self传入 ,立刻执行origin, 按道理origin应该直接执行,
- 但是我们给origin传入的是另一个函数,他需要接受一个参数才能真正的执行,这样便保证了用户可以使用
self(params)直接使用self函数
这样一来,我们将复制一份这样的函数,让前面的self持有本身,此时执行origin,函数变为一个等待realParams的状态
let Y =
(origin) => ( (self) => origin(realParams => self(self)(realParams)) )
( (self) => origin(realParams => self(self)(realParams)) );
当realParams传入,origin的真实执行结果将返回,并且self仍然是self。
你可以使用上面的函数了!我们还可以用g=>g(g)优化一下不是吗?
let Y = (origin) => (g=>g(g))
( (self) => origin(realParams => self(self)(realParams)) );
使用案例
console.log(
(g=>g(g))
(self => num => num == 0 ? 1 : self(self)(num-1)*num)
(5)
); // 不优雅的阶乘
console.log(
(g=>g(g))
(self => num => (num == 1 || num == 2) ? 1 : self(self)(num-1)+self(self)(num-2))
(7)
); // 不优雅的斐波那契
// use Y
console.log(
((f) => (g=>g(g))((x) => f((y) => x(x)(y))))
(self => num => num == 0 ? 1 : self(num-1)*num)
(5)
); // 优雅的阶乘
console.log(
((f) => (g=>g(g))((x) => f((y) => x(x)(y))))
(self => num => (num == 1 || num == 2) ? 1 : self(num-1)+self(num-2))
(7)
); // 优雅的阶乘
他们都正常的运行了!
所以Y组合子到底是什么
在上面的实现中,我们注意到Y函数他有这样的性质 Y(F) == F(Y(F))
其中 Y(F) 是 F 的函数, 设为 x
x == F(x)
这是个漂亮的等式,在数学上我们称为不动点。
Y-Combinator 可以找到函数的不动点,他对所有(数学意义上的)函数都成立。