1

从零开始的 λ 演算

 2 years ago
source link: https://blog.ruo-chen.wang/2021/04/lambda-calculus-from-the-ground-up.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

从零开始的 λ 演算

15 minute read

这是我在看完 PyCon 2019 演讲「Lambda Calculus from the Ground Up」之后做的一个文字版,原视频在 https://youtu.be/pkCLMl0e_0k。另见官网中的 介绍,这里提供一个翻译。

最近关于编程风格的指南层出不穷。但是如果我们把风格限制为只允许出现单参数的函数会发生什么呢?没有模组,没有类,没有控制流,没有数据结构,甚至连整数、正则表达式等内建类型都没有,只有函数。用这种风格能写出程序吗?令人惊讶的是,答案是肯定的。在这个教程中,你将学到如何从零开始在 Python 中推导 λ 演算。

你不会在这个教程中学到有实际用处的东西。没有打包,没有工具,没有库,没有部署,也没有神奇的 Python 编程技术。你也不会学到会被你用在实际项目上的东西。但是你将获得很多乐趣,将感到惊叹,并学习一些基础的计算机科学,这是你进一步探索函数式编程、类型理论、编程语言等话题的起点。

规则Permalink

如上介绍所说,我们只允许函数调用,或者参数替换这一种操作。以下列举一些不被允许的操作:

def f(x):
    return x + 1  # 不允许使用数字和运算符 +

def f(x, y):
    ...  # 只允许单个参数

以下是一些合法的操作,但是他们好像没什么意义:

def f(x):
    return x

def f(x):
    return x(x)

def f(x):
    def g(y):
        return x(y)
    return g

所以在这样的一个如此抽象的系统中,我们可以做些什么?

布尔值(18:27Permalink

首先来构造布尔值和布尔运算。可以借用选择器的概念来构造布尔值。TRUE 选择两个值中的第一个,而 FALSE 选择第二个。注意这里的 TRUEFALSE 并不是一个具体的值,而是一种行为。

def TRUE(x):
    return lambda y: x

def FALSE(x):
    return lambda y: y
>>> TRUE('5v')('gnd')
'5v'
>>> FALSE('5v')('gnd')
'gnd'

接下来是布尔运算 NOT, ANDOR。注意到 NOT 的参数应该是 TRUE 或者 FALSE,它们都是需要两个输入的函数(柯里化的)。然后根据 TRUEFALSE 在两个值中的选择情况便可构造出 NOT

def NOT(x):
    return x(FALSE)(TRUE)

对于 AND,在它的第一个参数为 TRUE 的时候,值就等于第二个参数;在第一个参数为 FALSE 的时候,值为 FALSE(也就是它的第一个参数)。OR 可以根据类似的思想写出:

def AND(x):
    return lambda y: x(y)(x)

def OR(x):
    return lambda y: x(x)(y)

布尔运算演示

 
<function FALSE(x)>
 
<function TRUE(x)>
 
<function TRUE(x)>
 
<function FALSE(x)>
 
<function FALSE(x)>
 
<function FALSE(x)>
 
<function TRUE(x)>
 
<function TRUE(x)>
 
<function TRUE(x)>
 
<function FALSE(x)>

数字(34:45Permalink

因为我们能够做的只有函数调用,所以可以尝试用调用函数的次数表示数字,比如 TWO(f)(x) 的含义是以 x 为初始值,调用 f 两次:

ONE = lambda f: lambda x: f(x)
TWO = lambda f: lambda x: f(f(x))
THREE = lambda f: lambda x: f(f(f(x)))
FOUR = lambda f: lambda x: f(f(f(f(x))))

零表示为调用函数零次,即不调用函数:

ZERO = lambda f: lambda x: x

为了能够方便地展示我们的系统中的数字,在此写一个函数用来将系统中的数字转为 Python 中的一个 int。这个函数并不在 λ 演算系统中,只是为了方便地查看系统中的数字。

def toint(n):
    return n(lambda x: x + 1)(0)
>>> toint(THREE)
3

加法和乘法(50:02Permalink

使用 Peano 公理 的思想,先实现一个数字的后继。

SUCC = lambda n: lambda f: lambda x: f(n(f)(x))

在这个实现中,SUCC 的返回值是一个数(因为拥有 lambda f: lambda x: xxx 这样的「接口」),而这个返回的数是以 x 为初始值调用了 n 次函数 f(用 n(f)(x) 表示)后又多调用了 f 一次的结果,所以表示 n + 1。

>>> toint(SUCC(THREE))
4

有了 SUCC 就可以实现加法了。x + y 就是在 x 的基础上调用 ySUCC。而数字的行为正好是调用某个函数多少次。

ADD = lambda x: lambda y: y(SUCC)(x)

乘法就是将「调用函数 f x 次」(即 x(f) 这个行为)重复 y 次:

MUL = lambda x: lambda y: lambda f: y(x(f))

从上面的定义可以看出乘法就是用函数的复合,即 MUL x y = y ∘ x

加法和乘法的演示:

>>> toint(ADD(FOUR)(THREE))
7
>>> toint(MUL(FOUR)(THREE))
12

二元组(2:03:49Permalink

为了实现减法,我们需要先实现二元组。

CONS(a)(b) 将会返回一个「选择器」,这个选择器根据给它的参数选择 ab 中的一个。这里的函数名借用了 Lisp 中的名字。

CONS = lambda a: lambda b: (lambda s: s(a)(b))
CAR = lambda p: p(TRUE)
CDR = lambda p: p(FALSE)
>>> p = CONS(2)(3)
>>> CAR(p)
2
>>> CDR(p)
3

这里的 CONS 接受两个参数并构造了一个二元组,CARCDR 分别取二元组中的第一个和第二个值。

减法(2:12:15Permalink

下面借助二元组来实现一个数的前驱。我们可以从 (0, 0) 开始增加,(1, 0), (2, 1) …… 二元组的第二个数是第一个数的前驱,而第一个数就是增加的次数。

T = lambda p: CONS(SUCC(CAR(p)))(CAR(p))
>>> a = FOUR(T)(CONS(ZERO)(ZERO))
>>> toint(CAR(a))
4
>>> toint(CDR(a))
3

上面的 a 是从 (0, 0) 开始执行了函数 T 4 次后的结果,它本身是一个二元组,第二个值就是 4 的前驱 3。于是实现前驱的方式为从 CONS(ZERO)(ZERO) 开始执行函数 T n 次,再取二元组的第二个值:

PRED = lambda n: CDR(n(T)(CONS(ZERO)(ZERO)))
>>> a = FOUR(THREE)
>>> toint(a)
81
>>> b = PRED(a)
>>> toint(b)
80

然后仿造加法构造减法:

SUB = lambda x: lambda y: y(PRED)(x)
>>> a = SUB(FOUR)(TWO)
>>> toint(a)
2

判断一个数是否为零(2:24:12Permalink

由于 ZERO 的行为是直接返回第二个输入,所以下面的第二个括号中为 TRUE。而其它的数都会调用第一个输入,所以直接让这个输入返回 FALSE

ISZERO = lambda n: n(lambda _: FALSE)(TRUE)
>>> ISZERO(ZERO)
<function TRUE(x)>
>>> ISZERO(ONE)
<function FALSE(x)>

递归(2:30:13Permalink

我们已经有了 AND, SUCC, ADD, CONS, CARISZERO 等等函数,现在我们要来挑战写出阶乘函数。阶乘在普通 Python 中的一种写法是

fact = lambda n: 1 if n == 0 else n * fact(n-1)

它只用了判断是否为零,乘法和减法三种操作。把它转化为 λ 演算中的形式,得到

FACT = lambda n: ISZERO(n) \
                 (ONE) \
                 (MUL(n)(FACT(PRED(n))))

但是这个实现有问题:

>>> FACT(THREE)
RecursionError: maximum recursion depth exceeded

惰性求值(2:34:40Permalink

原因是 Python 函数不是惰性求值(lazy)的,递归发生在 ISZERO 判断之前。为了规避这个限制,我们实现以下的 lazy 版本

L_TRUE = lambda x: lambda y: x()
L_FALSE = lambda x: lambda y: y()
L_ISZERO = lambda n: n(lambda _: L_FALSE)(L_TRUE)

FACT = lambda n: L_ISZERO(n) \
                 (lambda: ONE) \
                 (lambda: MUL(n)(FACT(PRED(n))))
>>> a = FACT(THREE)
>>> toint(a)
6

避免引用自己(2:47:30Permalink

目前还有一个问题,在 λ 演算中没有全局变量,没有把值「存储」在一个变量中的概念。而我们在 FACT 的定义中使用了 FACT 这个名字。

我们也用了 ISZEROONE 等等名字啊?

事实上这些名字不需要存储的概念。可以把所有出现了 ONE 的地方换成它的定义(相当于 :%s/\<ONE\>/lambda f:lambda x:f(x)/g)而程序还是会正确运行。而不能换 FACT 的原因是它的定义本身就使用了 FACT 这个名字。

如何在实现 FACT 的时候不引用它自己的名字?为了方便先用 fact 函数来试验,我们不希望在下面的实现中出现 fact

fact = lambda n: 1 if n == 0 else n * fact(n-1)
#                                     ^^^^ bad

一种方式是把 fact 作为参数传进去,但是这样还是用了 fact 这个名字,而且这个写法在 fact 还没定义的时候就使用了它。

fact = (lambda f: lambda n: 1 if n == 0 else n * f(n-1)) \
       (fact)
#       ^^^^ ?

解决方案是直接复制粘贴,而不是把自己的名字传进参数:

fact = (lambda f: lambda n: 1 if n == 0 else n * f(n-1)) \
       (lambda f: lambda n: 1 if n == 0 else n * f(n-1))

我们还需要修正一下 f 的调用方法(因为需要先传入 f 再传入 n)。下面是最终的结果:

fact = (lambda f: lambda n: 1 if n == 0 else n * f(f)(n-1)) \
       (lambda f: lambda n: 1 if n == 0 else n * f(f)(n-1))
#                                                ^^^^
>>> fact(4)
24

可以把 fact 直接换成它的定义:

>>> (lambda f: lambda n: 1 if n == 0 else n * f(f)(n-1)) \
... (lambda f: lambda n: 1 if n == 0 else n * f(f)(n-1))(4)
24

不动点(3:00:38Permalink

以上的实现有一点不好,它把一个很长的括号重复了两次。我们也可以借助不动点实现 fact

回到之前尝试实现 fact 的时候,

fact = (lambda f: lambda n: 1 if n == 0 else n * f(n-1))(fact)

如果把中间的部分抽出来

R = lambda f: lambda n: 1 if n == 0 else n * f(n-1)

那么 fact = R(fact),即 factR 的一个不动点。如果有一个可以计算不动点的函数,就可以得到 fact 了。

假设这个函数存在,设它为 Y。那么有 Y(R) = R(Y(R))。变形:

Y(R) = (lambda x: R(x))(Y(R))

仿照上一节中的做法,把括号中的表达式复制一遍,再修正一下 x 的调用方法:

Y(R) = (lambda x: R(x))(lambda x: R(x))
Y(R) = (lambda x: R(x(x)))(lambda x: R(x(x)))

那么我们就得到了 Y

Y = lambda f: (lambda x: f(x(x)))(lambda x: f(x(x)))

Y 组合子(3:13:20Permalink

这里的 Y 就是著名的 Y 组合子(Y combinator)。但是

>>> R = lambda f: lambda n: 1 if n == 0 else n * f(n-1)
>>> fact = Y(R)
RecursionError: maximum recursion depth exceeded

要解决这个问题,可以把 x(x) 换成 lambda z: x(x)(z),它可以延后 x 的求值。

Y = lambda f: (lambda x: f(lambda z: x(x)(z)))(lambda x: f(lambda z: x(x)(z)))
>>> R = lambda f: lambda n: 1 if n == 0 else n * f(n-1)
>>> fact = Y(R)
>>> fact(4)
24

Y 用在其它函数上:

>>> fib = Y(lambda f: lambda n: 1 if n <= 2 else f(n-1) + f(n-2))
>>> fib(10)
55

实现 FACT

>>> FACT = Y(
...     lambda f: lambda n: L_ISZERO(n) \
...                         (lambda: ONE) \
...                         (lambda: MUL(n)(f(PRED(n))))
... )
>>> a = FACT(FOUR)
>>> toint(a)
24

本文中的代码已整理到 Gist 上。

补充Permalink

惰性求值 一节中也可以使用另一种方法。参考 Y 组合子 一节,用 lambda x: f(x) 代替 f,这样不需要新定义 lazy 版本的函数。因为万物皆是函数,所以不需要担心 f(x) 因为 f 不是函数而出错。

FACT = lambda n: ISZERO(n) \
                 (lambda x: ONE(x)) \
                 (lambda x: MUL(n)(FACT(PRED(n)))(x))
FACT = Y(
    lambda f: lambda n: ISZERO(n) \
                        (lambda x: ONE(x)) \
                        (lambda x: MUL(n)(f(PRED(n)))(x))
)

总结Permalink

  • 这里推导的 λ 演算没有什么实际用途,没有谁会用这里的 λ 演算写一个现实中使用的程 序;
  • 但是 λ 演算的思想在函数式语言中随处可见,非函数式语言也都在慢慢吸收函数式语言 中的一些的思想和概念;
  • λ 演算就像机器语言一样。现在绝大多数人都在写高级语言,但是高级语言在某种程度上 是机器语言的抽象,了解一些机器语言或者汇编的知识有助于写出更好,效率更高的代 码。了解 λ 演算对理解和运用函数式语言和其它语言中的函数式元素是很有益的。

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK