用类型看透 Y Combinator
当 f(x) = x ,称此时的 x 为函数 f 的固定点(Fixed Point)。如果 x 是一个函数呢?
“不就是 Y Combinator 嘛, 我知道” 1。
嗯,介绍它的文章确实不少,但容易看懂的应该不多。这次,我另辟蹊径,尝试从类型的视角来剖析 Y Combinator ,看能否让人豁然开朗。
回顾一下 Y Combinator 的无类型 Lambda 演算表达式,如下(此后不再有更多公式):
不卖关子,直接上翻译后的 Scala 代码:
type F[A] = A => A
trait X[A] extends (X[A] => F[A])
def Y[A](f: F[A] => F[A]): F[A] =
val g: X[A] = x => a => f(x(x))(a)
g(g)
秒懂的朋友,可略过后文了,若感兴趣验证一下代码的请参阅脚注 2。留下的朋友,请继续跟我来。
破除阅读障碍
看不懂 Y Combinator 的原因,大概率是因为看不懂(无类型的) Lambda 演算语法。为方便后文理解,以下语法翻译小抄请记牢:
λx.x
,即x => x
,定义匿名函数;f x
,即f(x)
,应用函数;
那么,Y Combinator 若翻译成 Javascript 就是 3:
const Y = f => (x => f(x(x)))(x => f(x(x)))
自应用函数 4
Y
是什么类型,很难判断。不妨从 f
和
x
这两个相对简单的函数入手先。假定,f
的类型为
F
,参数和返回值类型不详,暂且用 Any
表示:
> type F = Any => Any
scala// defined alias type F = Any => Any
x
的类型 X
就有点让人挠头了,它
自己 依赖 自身 作为参数。硬着头皮可以这么声明:
> type X = X => Any
scala-- [E140] Cyclic Error: ------------
1 |type X = X => Any
| ^
|illegal cyclic type reference:
| alias X => Any of type X refers
| back to the type itself
1 error found
意思是这么个意思,可惜编译器不接受这样的类型别名(alias)声明。编译器可接受的等价形式是这样的:
> trait X extends (X => Any)
scala// defined trait X
由此,我们可以尝试将 Y Combinator 翻译成 Scala,来看看 Y
的类型:
> def Y(f: F) =
scala| val g: X = x => f(x(x))
| g(g)
|
def Y(f: F): Any
其中的 g
相当于给后面 X
的表达式起了个别名,这样代码看着更简洁。最后一行,编译器已经推断出
Y
的完整类型表达了。
注意,根据 f(x(x))
,可知 x
的返回值是要作为
f
的参数的,这二者的类型必然是 一致
的。显然,Any
是不足以采信的。因此,我需要将这个类型参数化,以做到严谨的表达。
> type F[A] = A => A
scala| trait X[A] extends (X[A] => A)
|
| def Y[A](f: F[A]) =
| val g: X[A] = x => f(x(x))
| g(g)
|
// defined alias type F[A] = A => A
// defined trait X
def Y[A](f: F[A]): A
至此,是不是有点感觉了?Y Combinator
的奥义在于,对「自应用函数」类型 X
的自应用。这样做的价值,从编程的角度来看,就是实现了递归(循环)。
没想通的话,我们就以阶乘函数为例验证一下:
> def fact: Int => Int =
scala| case 1 => 1
| case n => n * fact(n - 1)
|
def fact: Int => Int
> fact(4)
scalaval res1: Int = 24
将其改写为对 Y
的应用:
val fact = Y[Int => Int]: f =>
case 1 => 1
case n => n * f(n - 1)
二者区别在于,前者是直接引用自身,后者的参数是自身的代理。
I’m my own granddad…
The Bartender, Predestination (2014) 5
不幸的是,这么改写编译器是扛不住的:
> val fact = Y[Int => Int]: f =>
scala| case 1 => 1
| case n => n * f(n - 1)
|
.lang.StackOverflowError
java...
惰性求值
为什么会栈溢出呢?这个问题确实不好解释,不如从解决方案中,我们去逆向理解。
> class Lazy[A](f: () => A):
scala| def eval: A = f()
|
// defined class Lazy
首先,需要引入 Lazy
来模拟 惰性求值 6。其次,对应地调整 F
X
Y
:
> type F[A] = Lazy[A] => A
scala| trait X[A] extends (X[A] => Lazy[A])
|
| def Y[A](f: F[A]) =
| val g: X[A] = x => Lazy:
| () => f(x(x))
| g(g)
|
// defined alias type F[A] = Lazy[A] => A
// defined trait X
def Y[A](f: F[A]): Lazy[A]
最后,调整改写的 fact
,回车!
> val fact = Y[Int => Int]: f =>
scala| case 1 => 1
| case n => n * f.eval(n - 1)
|
val fact: Lazy[Int => Int] = Lazy@1c23e369
> fact.eval(4)
scalaval res2: Int = 24
从结果来看,栈溢出是没有了。其中的细节就在 g
:
// stack overflow
val g: X[A] = x => f(x(x))
// ok
val g: X[A] = x => Lazy:
() => f(x(x))
主流编程语言几乎都是 严格求值 的,这意味着
x(x)
会在 自应用 中无限循环直到栈溢出。而
Lazy
类型的引入,就让 自应用
可按需(延迟)调用。这个按需,就体现在 f.eval(n - 1)
。
眼尖的你,可能已经发现了,这一步步剖析得到的代码和最初给出的答案不一样啊。嗯,是不一样,但效果是一样的,不信就自己去试试吧。
写在最后
由此可见,Y Combinator 的实质是,在 向未来借一个「代理」 啊。
不懂,没关系。墙裂推荐这部电影,看过之后再来体会 Y Combinator,必定更生动。
https://scastie.scala-lang.org/zhongl/inj6N2dpQ6eSB5xSAKwVlQ/28↩︎
出自我的个人命名,可能不严谨,懂我意思就行。↩︎
https://www.imdb.com/title/tt2397535/quotes/?item=qt4296558&ref_=ext_shr_lnk↩︎
https://en.wikipedia.org/wiki/Lazy_evaluation#Simulating_laziness_in_eager_languages↩︎
https://www.imdb.com/title/tt2397535/mediaviewer/rm2485654785/?ref_=tt_ov_i↩︎