Y组合子的一个启发式推导

Y Combinator是Lambda演算理论中的一个关键性概念,通过它我们可以实现匿名的递归调用函数。关于它的解释,一般是所谓的“懂的都懂”,换句话说,不懂的人看了之后,大概还是不懂。在本文中,我希望提供一个启发式的推导,尽量使得Y组合子的构造显得直观一些。为了方便对lambda演算不熟悉的同学,在附录一节中我也增加了一段对lambda演算规则的简短介绍。

一. Y Combinator

首先,我们来看一下递归函数的基本形式:

let f = x => 函数体中用f指代自身,实现递归调用
// 例如阶乘函数
let fact = n => n < 2 ? 1 : n * fact(n-1)

上述递归函数是所谓的一阶递归函数,即定义中只引用自身导致递归的函数

我们看到,递归函数的实现体中通过函数名f引用了自身。如果我们希望消除这个对自身的引用,就必须把它转换为一个参数。从而得到

let g = f => x => 函数体中用f表示原递归函数

函数g相当于是在f的基础上加盖了一层,使它成为了一个高阶函数。因为f是任意的某个递归函数,关于函数g,我们唯一知道的就是它能作用到函数f上。

g(f) = x => 函数体中的f通过闭包变量引用了参数f

显然g(f)的返回值就是我们所需要的目标递归函数,由此我们得到了所谓的不动点方程

g(f) = f

函数g作用于参数f上,返回的结果也等于f,在这种情况下,f称作是函数g的不动点

现在我们可以制定一个构造匿名的递归函数的标准程序:

  1. 根据命名函数f定义辅助函数g

  2. 求解函数g的不动点

假设存在一个标准解法可以得到g的不动点,我们把这个解法记作Y,

f = Y g  ==>  Y g = g (Y g)

Y这个解法如果存在,它到底长什么样?为了求解不动点方程,一个常用的方法是迭代法:我们反复应用原方程,然后考察系统演进的结果。

f = g(f) = g(g(g...))

如果完全展开,则f对应于一个无限长的序列。假设这个无限长的序列可以开平方

f = g(g(g...)) = G(G) = g(f) = g(G(G))

如果存在这样的函数G,它的定义是什么?幸运的是G(G) = g(G(G))本身就可以被看作是函数G的定义

G(G) = g(G(G)) ==> G = λG. g(G(G)) = λx. g(x x)

上式中的最后一个等号对应于函数的参数名重命名,即λ演算中的所谓alpha-变换。

在G已知的情况下,Y的定义就很显然了

Y g = f = G(G) = (λx.g (x x)) (λx.g (x x))   (1)
Y = λg. (λx.g (x x)) (λx.g (x x))            (2)

上式中(1)是直接代入G的定义。而(2)是把 Y g看作是对Y的定义

 Y g = expr ==> Y = λg. expr

我们可以继续执行alpha-变换,改变参数名,从而使得Y组合子的定义成为一般文献中常见的样子。

Y = λf. (λx.f (x x)) (λx.f (x x))

可以验证,Y确实满足不动点方程

Y-combinator.png

上图中的Y 实际上是 Y f,即 Y作用到f上的结果,因此它验证了 Y f = f(Y f)。

二. 递归的本质 G(G)

上一节的推导中,最关键的步骤是 f = G(G),即我们把递归函数开平方分解为G函数作用到自身上的结果。下面我们通过一个具体的例子来加深对这个结构的必然性的理解。

所谓的递归函数,其内部必然存在着两种结构:递归结构和(无递归的)计算结构。例如

let fact = n => n < 2 ? 1 : n * fact(n-1)
// 或者写成函数声明的形式
function fact(n){
    return n < 2 ? 1: n*fact(n-1)
}

fact函数既完成了本步骤的计算,又通过fact名称引用自身实现了递归。

如果我们试图把递归结构和计算结构分离,可以定义如下纯计算结构(所有计算中用到的变量都是参数传递过来的,不存在自引用而产生的递归)

function fact0(fact, n){
    return n < 2 ? 1 : n * fact(n-1)
}
// 或者使用lambda表达式形式
const fact0 = fact => n => n < 2 ? 1 : n * fact(n-1)

如果递归结构可以被抽象到一个算子Y中,则我们可以期望通过如下调用形式为fact0增加递归结构。

Y(fact0)(n) = fact(n), fact = Y(fact0)

即算子Y可以看作是一个加工器,它吃入无递归的fact0,吐出带有递归功能的fact。fact0是一个两参数函数,而生成的fact是一个单参数函数。

接下来,我们注意到,fact0是我们认为已经存在的东西,而fact是某种需要我们去构造的、未知的东西,fact0的定义中同时包含已知和未知的量,因此我们有必要将fact0的定义改写为完全基于已知量来构造

const fact1 = fact1 => n => n < 2 ? 1: n * fact1(fact1)(n-1)

上式中,函数体内的fact1实际指向的是参数名fact1,而不是函数名。如果觉得命名容易混淆,我们可以再次改写为

const fact1 = self => n => n < 2 ? 1 : n * self(self)(n-1)

我们可以验证,fact(n)等价于fact1(fact1)(n)

Y(fact0)(n) = fact(n) = fact1(fact1)(n)

对比等式两边,可以得知

Y(fact0) = fact1(fact1), 也就是上一节中的 Y g = G(G)

通过以上的分析,我们注意到递归函数中必然出现的一个基本结构x(x)

如果我们定义如下的函数

function w(x){
   return x(x)
}
// 或者写成lambda表达式形式
const w = x => x(x)

显然w(G)=G(G),w(w)提供了一个最简单也是最本质的无限循环形式。在lambda演算理论中,上述w函数被称为是Omega组合子,

ω : = λ x . x   x \omega := \lambda x. x\ x \\ ω:=λx.x x

Ω : = ω ω = ( λ x . x   x ) ( λ x . x   x ) \Omega := \omega \omega = (\lambda x. x \ x)(\lambda x. x \ x)\\ Ω:=ωω=(λx.x x)(λx.x x)

$$
(\lambda x.x x)(\lambda x.x x) \mapsto (\lambda x.x x)[x\mapsto (\lambda x.x x)]
\mapsto (\lambda x.x x)(\lambda x.x x)\

$$

也就是说

ω ω = ω ω \omega \omega = \omega \omega\\ ωω=ωω

w作用于自身,可以不断的产生自身,因此x(x)可以编码无限循环,那么为任意函数F引入无限循环的能力,可以采用如下形式

const wF = x => F(x(x))

注意, w函数是直接返回x(x),而wF是把x(x)送入函数F作为参数。参考上文中对fact0函数的分析,wF相当于是将fact0中的fact函数调用替换为fact1(fact1)

wF函数对应于

ω F : = λ x . F ( x   x ) \omega_F := \lambda x. F(x\ x)\\ ωF:=λx.F(x x)

Y组合子的定义对应于

Y F : = ω F ω F = ( λ x . F ( x   x ) ) ( λ x . F ( x   x ) ) Y_F := \omega_F\omega_F = (\lambda x. F(x\ x))(\lambda x. F(x\ x))\\ YF:=ωFωF=(λx.F(x x))(λx.F(x x))

写成JavaScript函数,形式为

const Y = f => {
    const g = x => f(x(x));
    return g(g);
}

三. Z Combinator

如果我们真的按照上一节的形式来实现Y组合子,我们会发现Y(fact0)(3)抛出了堆栈溢出的错误,并没有得到计算结果fact(3)

这是因为JavaScript采用的是Call-by-value调用约定(CBV),它的参数的值在传入函数之前就必须被计算出来,而f(x(x))调用会产生死循环。

为了解决这个问题,在JavaScript中实现Y组合子,我们需要将参数改造成延迟求值的,简单的说,就是把直接的参数改造成一个延迟加载的函数,这一步对应于λ 演算中所谓的eta-规约。

- Y = λf.(λx.f (x x)) (λx.f (x x))
+ Z = λf.(λx.f (λy. (x x) y)) (λx. f (λy. (x x) y))

通过这种方式我们得到的就是所谓的Z组合子。对应到JavaScript中

function Z(f) {
    const g = x => {
-       return f(x(x));
+       return f(y => x(x)(y));
    };
    return g(g);
}

fact(n) == Z(fact0)(n)

四. Turing Combinator

函数的不动点组合子并不是只有Y Combinator,实际上它是无限多的。我们重新回顾一下不动点的定义

f ∗ = f ( f ∗ ) f ∗ = Θ f Θ f = f ( Θ f ) f_* = f(f_*)\\ f_* = \Theta f\\ \Theta f = f(\Theta f) \\ f=f(f)f=ΘfΘf=f(Θf)

仿照第一节中的做法递归展开,我们可以得到一个新的递归方程

Θ f = f ( f ( Θ f ) ) = f ( f ( . . . f ) ) = Θ ′ Θ ′ f = f ( Θ ′ Θ ′ f ) \Theta f = f(f(\Theta f)) = f(f(... f)) = \Theta' \Theta' f = f(\Theta' \Theta' f) Θf=f(f(Θf))=f(f(...f))=ΘΘf=f(ΘΘf)

Θ ≡ Θ ′ Θ ′ \Theta \equiv \Theta'\Theta' \\ ΘΘΘ

Theta’的定义可以从上式中直接读出来

Θ ′ Θ ′ f = Θ ′ ( Θ ′ ) ( f ) = ( λ Θ ′ λ f . f ( Θ ′ Θ ′ f ) ) ( Θ ′ ) ( f ) \begin{aligned} \Theta' \Theta' f &= \Theta'(\Theta')(f)\\ &= (\lambda \Theta'\lambda f.f(\Theta'\Theta'f))(\Theta')(f)\\ \end{aligned} ΘΘf=Θ(Θ)(f)=(λΘλf.f(ΘΘf))(Θ)(f)

Θ ′ : = λ t . λ f . f ( t   t   f ) \Theta' := \lambda t.\lambda f. f( t\ t \ f) Θ:=λt.λf.f(t t f)

通过这种分解方式得到的Theta就是所谓的图灵组合子。

五. 更多的不动点组合子

上述启发式构造方法是我很早之前提出的,今天仔细想了一下,发现它相当通用,可以用来发现其他的不动点组合子。例如

如果我们不是对无穷序列开平方,而是保留中间的一个g,我们可以得到

f = G g G = g (G g G)  对比Y组合子对应  G G = g(G G)

从上述不动点方程我们可以直接读出 G的定义

G := λg. λG. g (G g G) = λy.λx. y(xyx)

而不动点组合子可以从 如下方程直接读出

f = Y g = G g G  ==>  Y := λg. G g G = λy. G y G 

把G的定义代入,我们得到最终完全由匿名的lambda函数所构成的不动点组合子

Y := (λx.λy. xyx)(λy.λx. y(xyx))

这是由John Tromp发现的一个组合子。

类似的,如果我们选择开立方而不是开平方,可以得到

G G G = f (G G G) ==> G := λxy. f(x y x)
Y g = G G G       ==> Y := λg. G G G = λf.(λx. xxx)(λxy. f(x y x))

在javascript中的实现对应于

const Y3 = f =>{
    const g = (x,y) => {
       return f(u => x(y,x)(u));
    };
    return g(g,g)
}

可以验证Y3(fact0) == fact。

Y组合子的形态在很多程序语言中看起来都有点吓人,所以可能很多人一直都在疑惑这么复杂的东西是怎么想出来的,为什么要选择 f = G(G)这种分解形式?事实的真相是,压根没有什么为什么。选择开平方完全是一个很随意的选择(或者说最简单的选择)。如果不选择开平方,还可以选择开立方f=G G G,或者选择做个夹心饼干f=G g G,如果你乐意,你甚至可以做个千层饼。同样的套路你可以反复套用,产生无限多的不动点组合子。

附录:lambda演算 (Lambda calculus)

λ 演算号称是最简单、最小化的一个形式系统,它包含三条语法规则

x | λx.expr | expr expr

其中 x 表示变量定义, λx.expr 表示函数定义。expr expr 表示函数调用,

以上三种情况分别对应JS中的

x
x => expr
expr(expr)
例如
expr0 = 2
expr1 = x => x + 1
expr1(expr0) = 2 + 1 

λ 演算的惊人之处在于仅仅依赖上述三条规则,我们就可以完成所有通用计算,它就是最简形式的通用编程语言!

约定:

  1. 函数的作用是左结合的. 这意味着 f x y ≡ (f(x)y)
  2. λ操作符被绑定到它后面的整个表达式
    这样可以省略很多括号, 例如 ((λx.(x x))(λy.y))可以简写为 (λx.x x)λy.y

三条公理:

  1. alpha-变换:函数参数名是随意取的
    λx.(λx.x)x与λy.(λx.x)y是等价的。

  2. beta-归约:函数应用(运算过程)就是符号模板的替换过程。

    (λx. M) N 表示将函数体M中所有名称为x的符号都替换为N。例如
    (λf.f 3)(λx.x+2) = (λx.x+2)3 = 3 + 2 = 5

这里存在一个隐含的内在复杂性:没有一个通用的方法来判定两个λ表达式是否等价。Church提出lambda演算就是为了证明这个定理,从而否证希尔伯特判定性问题。

  1. eta-归约:如果运算结果总相等,则λ表达式等价
    f ≡ λx.f x

Church-Rosser定理:当把归约规则施用于λ项和λ演算时,所选择的归约顺序将不会影响最终的结果

参考

Y 组合子详解 (The Y Combinator)

认知科学家写给小白的Lambda演算

推导Y组合子

重新发明 Y 组合子 JavaScript(ES6) 版

Fixed-Point Combinators in JavaScript

Lambda calculus encodings; Recursion

On Recursive Functions

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值