缘起
在做计算的本质指称语义的时候,遇到了需要在Python匿名递归调用。Python的lambda
表达式本身不支持,需要借助 Y-Combinator
技术实现。于是研究了下Y-Combinator
。中文世界了很多Blog介绍和推导Y-Combinator
的文章。然而大部分的文章都省略了推导的关键步骤和推导的依据。仿佛读者都默认已经懂得Y-Combinator
了。最后我在Youtube上
找到了Ruby大神Jim Weirich
在Ruby Conf 12
上的topicY Not- Adventures in Functional Programming。大神就是大神,step by step 介绍了lambda calculus
及Y-Combinator
。本文即对Jim Weirich
推导过程的一个梳理。
Jim Weirich
使用了 Ruby 进行演示。Y-Combinator
推导使用动态语言很方便。然而本文使用Golang
语言进行描述。主要原因是作为静态语言,函数的入参和出参都需要声明类型。这样对于不熟悉匿名函数和Y-Combinator
的人而言,可以直观的了解嵌套的匿名函数都是什么。不然直观的看 ruby,python或者js的Y-Combinator的推导过程,很容易被括号搞晕。
Y-Combinator
Y-Combinator又称之为Y组合算子。由Haskell B. Curry发现的。其定义为
Y = λf.(λx.(f (x x)) λx.(f (x x)))
作用就是可以成为匿名函数的容器
,让匿名函数进行递归调用。计算理论有图灵机Tuling machine, 递归函数[Recursive function] ,λ演算lambda calculus。lambda 演算里没有命名函数,因此想要在lambda演算里使用递归,就需要借助Y-Combinator
。
现在的编程语言多数都能命名,并且底层都是基于图灵机模型实现。与Y-Combinator
和lambda calculus 没有关系。换言之,Y-Combinator
有如屠龙术,对于日常的编程工作几乎无用。那么为什么还要介绍Y-Combinator
。
为了思维锻炼。
匿名函数(Anonymous function)
lambda calculus 是基于lambda表达式进行演示。其语法规约如下
语法 Syntax | 命名 | 含义 |
---|---|---|
x | 变量 Variable | 表示值 |
(λx. M) | 抽象 Abstraction | 定义抽象(函数, M 是 lambda 表达式 |
(M N) | 应用 Application | 函数调用 M N 都是lambda 表达式 |
lambda calculus中有三条重要的规则,alpha 变换 , beta 规约 和eta 变换 这些规则的应用在后面的Function Refactor
使用中具体介绍。
在Golang中,lambda表达式可以用匿名函数表示。Golang 中匿名函数很简单,使用func
声明。与正常函数不一样的就是没有名字。可以声明即调用,也可以声明后赋值给一个变量。
package main
func main() {
func(a int) int {
return a + 1
}(2)
add1 := func(a int) int {
return a + 1
}
add1(2)
}
高阶函数(High Order)
普通函数的参数是变量或者表达式。高阶函数的定义是至少满足下面一个条件的函数:
- 接受一个或多个函数作为输入
- 输出一个函数
package main
type Fn func(int)int
func main() {
addN := func(n int) Fn{
return func(a int) int {
return a + n
}
}
makeAdd := func(f func(n int) Fn, n int) Fn {
return f(n)
}
add1 := makeAdd(addN, 1)
add1(2) // 3
}
makeAdd
是一个高阶函数,它的入参有函数,同时也返回一个函数。
运算技巧 Fucntion Reactor
匿名函数有下面几个技巧,推导Y-Combinator即需要用到。
Tennent Correspondence Principle
Tennent Correspondence Principle
指任何lambda 表达式都可以使用一个无参的lambda表达式包裹并立即调用。其形式是a + 1 <=> (lambda : a + 1)()
a <=> b
表示 a 等价 b
a := 1
expr := 1 + 1
expr := func() int {
return a + 1
}()
lambda表达式即可以是求值表达式,可以是匿名函数,本文这两个词等价
这个技巧也可以作用匿名函数表达式,下面两种写法的add1
是等价的。
第二个add1
在外层包裹了一个匿名函数,但是这个匿名函数立即调用了,返回了一个函数,返回的函数正式第一个add1
的声明
type Fn func(int)int
add1 := func(a int) int{
return a + 1
}
add1 := func() Fn {
return func(a int) int{
return a + 1
}
}()
Introduce Binding
与Tennent Correspondence Principle
类似,可以给表达式包裹的时候指定一个参数,这个参数在原表达式中不存在。其形式是lambda a: a + 1 <=> (lambda n: lambda a: a + 1)("any value")
相当于传入了一个无效参数。因为参数对原表达式无影响。
type Fn func(int)int
add1 := func(a int) int{
return a + 1
}
add1 := func(n interface{}) Fn {
return func(a int) int{
return a + 1
}
}("任意值")
这个法则看似无用,但是是很多推导过程的中间环节。此外还有一种Rebinding绑定方法。其形式: lambda n: (lambda : lambda a + n)() <=> lambda x: (lambda n: lambda a + n)(x)
在多层嵌套的lambda表达式中。可以在中间嵌入一个匿名函数传递最外层的参数。第一个add1
,中间有个立调用无参匿名函数。最内层使用了最外层的参数n
,如果重命名最外层的n
,那么最内层的也需要修改。
使用 Rebinding的方法就是将最外层的n
通过中间无参立即调用的匿名函数传递进去。即第二个add1
。这个转换的作用在于,如果将来需要提取最内层的匿名函数,其参数命名不会与最外层的耦合。
type Fn func(int)int
add1 := func(n int) Fn {
return func() Fn {
return func(a int) int{
return a + n
}
}()
}
add1 := func(x int) Fn {
return func(n int) Fn {
return func(a int) int{
return a + n
}
}(x)
}
// fmt.Println(add1(1)(2))
Function Wrap
Function Wrap
也和Tennent Correspondence Principle
相似。只不过它作用于它包裹的函数,其形式:// lambda a: a + 1 <=> lambda x: (lambda a: a + 1)(x)
add1 := func(x int) int{
return (func(a int) int {
return a + 1
})(x)
}
add1 := func(x int) int{
return (func(a int) int {
return a + 1
})(x)
}
// fmt.Println(add1(2))
这里需要注意与Introduce Binding的区分, 一个是对包裹的函数本身立即调用,一个是对被包裹的函数进行调用
- lambda a: a + 1 <=> (lambda x: lambda a: a + 1)(x)
- lambda a: a + 1 <=> lambda x: (lambda a: a + 1)(x)
Inline Function
Inline Function顾名思义,就是把原来使用变量的地方直接用表达式替换。对于第一个 add := addN(1, add1)
。使用 inline function的就是把 add1 变量消除,使用其函数体代入即可。即第二个add的形式。
type Fn func(int)int
add1 := func(n int) int {
return 1 + n
}
addN := func(n int, add1 Fn) Fn{
return add1
}
add := addN(1, add1)
//
addN := func(n int, add1 Fn) Fn{
return add1
}
add := addN(1, func(n int) int {
return 1 + n
})
fmt.Println(add(2))
推导
掌握了上面四条Function Refactor 规则,就可以开始推导 Y-Combinator。推导过程大致分为三个过程。首先构造匿名函数,其次是匿名调用,最后是提取匿名函数。就像把大象放进冰箱的步骤一样简单。
构造匿名函数
Y-Combinator 的经典推导就是实现一个求阶乘函数的递归函数。求阶乘的递归函数如下:
import "fmt"
func fact(n int) int{
if n <= 1{
return 1
}
return n * fact(n - 1)
}
func main() {
ans := fact(5)
fmt.Println(ans)
}
下面改写成匿名函数实现:
type G func(int) int
func main() {
var fact G // 声明 fact 变量,以便匿名函数里的 fact(n-1)引用
fact = func(n int) int {
if n <= 1 {
return 1
}
return n * fact(n-1)
}
ans := fact(5)
fmt.Println(ans)
}
上面的代码看起来也像实现了匿名函数的递归调用。其实不然,声明fact的时候,匿名函数通过闭包可以访问,真正的匿名函数调用应该如下:
(func(n int) int {
if n <= 1 {
return 1
}
return n * fact(n-1)
})(10)
很不幸,编译器拒绝编译,因为找不到一个叫fact
的函数。在现代编程语言中,将一个变量(标志符)与函数绑定,一般有三种方法:
- 直接命名函数 如
func fact(){}
- 赋值操作,即 var fact = func(){}
- 参数绑定
前面两种我们都很熟悉,后面一种也不陌生。后者将一个函数的参数指定为函数,通过传递一个函数变量,在函数体内进行调用。即将参数和函数绑定。当然绑定既可以是入参,也可以是返回值的出参。
匿名函数递归调用
上面的匿名函数想要运行可以改下如下:
type G func(G, int) int
func main() {
ans := (func(fact G, n int) int {
if n <= 1 {
return 1
}
return n * fact(fact, n-1)
})(func(fact G, n int) int {
if n <= 1 {
return 1
}
return n * fact(fact, n-1)
}, 5)
fmt.Println(ans)
}
上面的代码可以正常运行。其形式是 (lambda f, n: f(f, n))(lambda f, n: f(f, n))
。匿名函数有两个参数,一个是类型为G的函数,第二个是 n。函数体的fact 来自参数。当匿名函数运行的时候,它的第一个参数就是它自己本身。一个函数调用自己本身,这就实现了递归调用。
匿名函数的提取
单参数匿名函数调用
将匿名函数作为参数传入到自身的参数之中以实现递归。这就是lambda calculus中的思想。通常lambda 表达式是单参数。通过一种叫柯里化 的技术可以实现。
下面进行推导。首先将观察单参数的匿名函数
func(n int) int {
if n <= 1 {
return 1
}
return n * fact(n-1)
}
利用技巧一,使用一个新的无参的匿名函数包裹上面的匿名函数
type G func(int) int
func() G {
return func(n int) int {
if n <= 1 {
return 1
}
return n * fact(n-1)
}
}()
然后依据上面的思路,我们将匿名函数以外层的参数传递进去,即利用了技巧二
type G func(int) int
func(fact G) G {
return func(n int) int {
if n <= 1 {
return 1
}
return n * fact(n-1)
}
}(fact)
此时再次回想前面匿名函数递归调用的方法,fact 就是其本身改下如下
func(fact G) G {
return func(n int) int {
if n <= 1 {
return 1
}
return n * fact(n-1)
}
}(func(n int) int {
if n <= 1 {
return 1
}
return n * fact(n-1)
})
函数体内的问题解决了,但是参数里又出现了fact。因此不能直接传这个匿名函数,而是需要把外层的包裹函数开始当成参数,再次改下如下:
type G func(int) int
func(fact G) G {
return func(n int) int {
if n <= 1 {
return 1
}
return n * fact(n-1)
}
}(func(fact G) G {
return func(n int) int {
if n <= 1 {
return 1
}
return n * fact(n-1)
}
})
很不幸,依然无法编译。报错信息也很明显,最外层的函数的参数是一个 G
,而自身是 func (G) G
,两者类型不一致。以为要自身调用自身,因此最外层的函数定义为
type F func(F) G
再次改下 最外层的匿名函
type G func(int) int
type X func(X) G
func main() {
func(fact X) G {
return func(n int) int {
if n <= 1 {
return 1
}
return n * fact(n-1)
}
}(func(fact X) G {
return func(n int) int {
if n <= 1 {
return 1
}
return n * fact(n-1)
}
})
}
依然很不幸,匿名函数调用不再报错。报错的是 n * fact(n-1)
错误原因是:
./main.go:13:13: invalid operation: n * fact(n - 1) (mismatched types int and G)
./main.go:13:21: cannot use n - 1 (type int) as type F in argument to fact
其实很好理解了,现在入参 fact
的类型 type X func(X) G
, 而与 n
相乘的应该是类型G
。解决方法也很简单,既然 入参是X
,而 X
以自身为入参调用返回的结果就是G
。那么上面的代码改下如下
type G func(int) int
type X func(X) G
fn := func(fact X) G {
return func(n int) int {
if n <= 1 {
return 1
}
return n * fact(fact)(n-1)
}
}(func(fact X) G {
return func(n int) int {
if n <= 1 {
return 1
}
return n * fact(fact)(n-1)
}
})
fn(5)
nice~ 现在可以正常运行了。我们实现了真正的匿名函数递归调用。代码没有声明fact
, 但是我们却在函数调用中使用 fact。
实际上此时的 fact
是 X
, 而不是 G
。因此重命名为x。如下
type G func(int) int
type X func(X) G
fact := func(x X) G {
return func(n int) int {
if n <= 1 {
return 1
}
return n * x(x)(n-1)
}
}
fx := fact(fact)
fx(5)
单参数匿名函数提取
- 经过上面的步骤。距离目标完成了一半。下面的目标就是把上面的参数提取出来。观察
fx := fact(fact)
。使用技巧一可以改成通用的函数:
fact := func(x X) G {
return func(n int) int {
if n <= 1 {
return 1
}
return n * x(x)(n-1)
}
}
fn := func() G{
return fact(fact)
}()
fn(5)
- 使用技巧二,将 fact 用外面的参数替代
fact := func(x X) G {
return func(n int) int {
if n <= 1 {
return 1
}
return n * x(x)(n-1)
}
}
fn := func(x X) G{
return x(x)
}(fact)
fn(5)
- 使用技巧四,将fact函数代入到 fn 表达式中,参数fact被消除。
fn := func(x X) G{
return x(x)
}(func(x X) G {
return func(n int) int {
if n <= 1 {
return 1
}
return n * x(x)(n-1)
}
})
fn(5)
- 下面的目标就是将 最内层的函数提取。使用技巧一包裹最内层的表达式。
fn := func(x X) G {
return x(x)
}(func(x X) G {
return func(n int) int {
return (func() int {
if n <= 1 {
return 1
}
return n * x(x)(n-1)
})()
}
})
fn(5)
- 使用技巧二,将最内层的n由刚才包裹的无参匿名函数传递进去。这一步的作用是逐渐将n由外层传入。便于外层修改。
fn := func(x X) G {
return x(x)
}(func(x X) G {
return func(n int) int {
return (func(n int) int {
if n <= 1 {
return 1
}
return n * x(x)(n-1)
})(n)
}
})
fn(5)
- 接下来使用技巧一再对内层的匿名函数进行包裹。注意是最内层的匿名函数进行包裹,而不是对匿名汉调用后进行包裹,即
( ()() )(n)
而不是(()n)()
fn := func(x X) G {
return x(x)
}(func(x X) G {
return func(n int) int {
return (func() G {
return func(n int) int {
if n <= 1 {
return 1
}
return n * x(x)(n-1)
}
}())(n)
}
})
fn(5)
- 现在可以使用技巧二,传递一个参数,这个参数可以假想为我们需要的fact函数
fn := func(x X) G {
return x(x)
}(func(x X) G {
return func(n int) int {
return (func(recursion_fact G) G {
return func(n int) int {
if n <= 1 {
return 1
}
return n * recursion_fact(n-1)
}
}(recursion_fact))(n)
}
})
fn(5)
此时是无法编译,由于 recursion_fact
就是目标分离函数,即 x(x)
, 并且x是最上层传递下来的,因此他们可以使用技巧二重新绑定,即替换。
fn := func(x X) G {
return x(x)
}(func(x X) G {
return func(n int) int {
return (func(recursion_fact G) G {
return func(n int) int {
if n <= 1 {
return 1
}
return n * recursion_fact(n-1)
}
}( x(x)))(n)
}
})
fn(5)
现在可以编译成功。同时注意观察,最内层的匿名函数,和我们最初写的匿名函数一模一样了。接下来就只要把他作为整体再提取出去。
- 想要把最内层的匿名提取出去,根据上面的经验,还是依靠参数,而依靠参数,就需要往外层包裹。因此再次使用技巧一,将整个 fn 的最外层匿名函数进行包裹
fn := func () G{
return func(x X) G {
return x(x)
}(func(x X) G {
return func(n int) int {
return (func(recursion_fact G) G {
return func(n int) int {
if n <= 1 {
return 1
}
return n * recursion_fact(n-1)
}
}(x(x)))(n)
}
})
}()
- 技巧一就是为技巧二服务的,很自然使用技巧二
fn := func (f interface{}) G{
return func(x X) G {
return x(x)
}(func(x X) G {
return func(n int) int {
return (func(recursion_fact G) G {
return func(n int) int {
if n <= 1 {
return 1
}
return n * recursion_fact(n-1)
}
}( x(x)))(n)
}
})
}("任意值")
- 既然任意值都可以,那么就把我们最想提取出来的 匿名函数 recursion_fact 传递进去吧
type G func(int) int
type X func(X) G
type F func(G) G
func main() {
fn := func (f F) G{
return func(x X) G {
return x(x)
}(func(x X) G {
return func(n int) int {
return (func(recursion_fact G) G {
return func(n int) int {
if n <= 1 {
return 1
}
return n * recursion_fact(n-1)
}
}( x(x)))(n)
}
})
}(func(recursion_fact G) G {
return func(n int) int {
if n <= 1 {
return 1
}
return n * recursion_fact(n-1)
}
})
fn(5)
}
- 既然 f 是从外面传递,同时也是内层的阶乘函数,那么直接用 f 替换 recursion_fact 匿名函数即可。
type G func(int) int
type X func(X) G
type F func(G) G
func main() {
fn := func(f F) G {
return func(x X) G {
return x(x)
}(func(x X) G {
return func(n int) int {
return (f(x(x)))(n)
}
})
}(func(recursion_fact G) G {
return func(n int) int {
if n <= 1 {
return 1
}
return n * recursion_fact(n-1)
}
})
fn(5)
}
- 目前为止,基本上已经推导完成。fn 中分为两部分。匿名函数定义部分,与匿名阶乘函数没有直接关系。匿名函数的参数,恰好是阶乘函数。重命名如下:
package main
type G func(int) int
type X func(X) G
type F func(G) G
func main() {
g := func(recursion_fact G) G {
return func(n int) int {
if n <= 1 {
return 1
}
return n * recursion_fact(n-1)
}
}
Y := func(f F) G {
return func(x X) G {
return x(x)
}(func(x X) G {
return func(n int) int {
return f(x(x))(n)
}
})
}
fn := Y(g)
fn(5)
}
至此,Y-Combinator 已经推导完毕。下面是使用不动点的概念,可以把 x(x) 等价于 f(x(x))。
g := func(recursion_fact G) G {
return func(n int) int {
if n <= 1 {
return 1
}
return n * recursion_fact(n-1)
}
}
Y := func(f F) G {
return func(x X) G {
return f(x(x))
}(func(x X) G {
return func(n int) int {
return f(x(x))(n)
}
})
}
fn := Y(g)
fn(5)
惰性求值
考虑与Y-Combinator的递归定义
Y = λf.(λx.(f (x x)) λx.(f (x x)))
与我们上面的推导有一点点出入。可以直接写出golang的实现
type G func(int) int
type X func(X) G
type F func(G) G
func main() {
g := func(g G) G {
return func(n int) int {
if n <= 1 {
return 1
}
return n * g(n-1)
}
}
Y := func(f F) G {
return func(x X) G {
return f(x(x))
}(func(x X) G {
return f(x(x))
})
}
fn := Y(g)
fn(5)
}
编译运行,程序会爆栈。fatal error: stack overflow
。 因为在 Y 表达式的定义时候,f内的函数体是一个立即调用的定义。像Golang这样的语义不是惰性求值,而是严格求值,即会马上求值。当代入到函数体的时候,就会出现无穷的递归。因此会爆栈、解决办法就是再包裹一个匿名函数以惰性求值。
type G func(int) int
type X func(X) G
type F func(G) G
func main() {
g := func(g G) G {
return func(n int) int {
if n <= 1 {
return 1
}
return n * g(n-1)
}
}
Y := func (f F) G {
return func(x X) G {
return f(x(x))
}(func(x X) G {
return func(n int) int {
return f(x(x))(n)
}
})
}
fn := Y(g)
fn(5)
}
延迟参数惰性求值的转换,再次对比Y-Combinator的定义和我们的推导。他们的形式上也几乎一样了。不同在于golang因为需要惰性求值,在 Y-Combinator的第二部分进行了一个匿名函数包裹而已。这种转换后的形式通常称之为 Z-Combinator。
总结
lambda calculus是一种计算模式,其与Turing machine ,Recursive function 三者是等价的。
lambda calculus中如果对匿名函数进行递归调用,需要用到 Y-Combinator组合算子。
Y-Combinator 的推导依赖支持高阶函数的语言,例如 python,golang,JavaScript等。推导过程需要掌握4个function refactoring的技巧。
golang python这些语言是严格求值(Eager evaluation)。而 Lisp,Haskell 这些是惰性求值(Lazy evaluation)。因此直接使用Y-Combinator的定义实现的代码,python和golang都需要处理后面作为参数调用的匿名函数体。这个转换后的通常称之为 Z-Combinator。
Y-Combinator 在日常编码工作中几乎没什么用,但是lambda calculus的精妙演算可以大开眼界,推导过程也是一项很好的思维训练。