使用Python模拟Lambda演算
目录 · Table of Contents
我们将从下面这段程序开始:
这段程序会打印出 1 到 19 之间能被 3 或 5 整除的数字:
如果我们定下一个极端的限制:不允许使用 Python 内置的数字、布尔值、列表,不允许使用 +、-、%、==、or 等运算符,也不允许使用 if、for、range 甚至 def 具名函数——唯一允许使用的只有单参数的匿名函数 lambda x: ... 以及函数调用 f(x),还能实现上面这段程序吗?
这正是阿隆佐·邱奇(Alonzo Church)在 20 世纪 30 年代提出 Lambda 演算(λ-calculus) 时所展示的核心思想:在只有“函数定义”与“函数调用”的世界里,函数本身就足以编码自然数、布尔值、算术运算、条件分支、递归乃至数据结构。这种仅用函数来构造数据与控制流的方法称为邱奇编码(Church Encoding)。
接下来,我们就以开头的 3 行代码为主线,每用 lambda 造出一个零件,就立刻替换掉原程序中的一部分,直到整段程序的计算过程完全由函数构成。
完整代码可以在文末查看,也可以直接下载 lambda_calculus.py。
1. 数字与基础算术
用“调用次数”表示自然数
数字的本质可以简单地描述为某一个动作的重复次数。比如“吃了 6 个苹果”,6 就代表重复执行了 6 次“吃”这个动作。同样地,在程序里,我们可以用某个函数 p 被调用了几次来表示数字。
比如数字 1 表示对初始值 x 调用 1 次 p:
依照同样的定义,数字 2 就是调用 2 次 p:
那么如何表示 0 呢?传入函数 p 但一次都不调用,直接返回 x,就表示 0 了:
在 Lambda 演算中,每个函数只接收一个参数。如果要接收多个参数(例如 p 和 x),就让外层函数先接收 p,并返回一个接收 x 的新函数(即柯里化,Currying)。用 Python 的 lambda 表达式写出来就是:
调用时依次传入两个参数即可,例如 THREE(p)(x) 会先算出 THREE(p) 得到一个新函数,再把 x 传进去,最终返回 p(p(p(x)))。
辅助函数 to_integer
由于 ZERO、ONE、TWO 本质上都是函数,直接打印它们只会看到 <function <lambda> at 0x...>。为了验证这些函数的确表示对应的数字,也为了让它们能暂时嵌回原 Python 程序中充当“脚手架”,我们可以写一个辅助函数 to_integer,把 lambda x: x + 1 当作 p、0 当作初始值 x 传进去:
一个数字 n 内部把 p 调用了多少次,0 就会被加一多少次:
后继、加法与乘法
如果继续按上面的方式手写原程序里用到的 5 和 20,代码会变成这样:
手写 20 层括号已经让人眼花缭乱了。与其数括号,不如直接用函数实现“加一”和算术运算:
-
后继函数
SUCC(加 1):既然n(p)(x)已经调用了n次p,那么在结果外面再套一层p(...),就等于调用了n + 1次:1SUCC = lambda n: lambda p: lambda x: p(n(p)(x)) -
加法
ADD(m + n):对数字m重复调用n次“加 1”函数SUCC,得到的就是m + n:1ADD = lambda m: lambda n: n(SUCC)(m) -
乘法
MULTIPLY(m * n):n(p)是一个“把p调用n次”的新函数;再把n(p)传给m,相当于将“调用n次p”这个动作重复执行m次,一共调用m * n次:1MULTIPLY = lambda m: lambda n: lambda p: m(n(p)) -
幂运算
POWER(m ** n):将m自身重复应用n次:1POWER = lambda m: lambda n: n(m)
有了 SUCC 和 MULTIPLY,我们就不需要手写长串嵌套括号了:
第一次替换:消灭数字字面量
现在,我们可以把原程序里的所有数字都替换成刚刚定义的 lambda 函数:
运行结果与原程序完全一致。
2. 布尔值、条件分支与逻辑运算
布尔值的本质是“二选一”
接下来我们要替换程序中的布尔值、if 和 or。
先思考一个问题:我们在程序里为什么需要 True 和 False?归根结底,布尔值的唯一用途就是在两个分支中做出选择——如果为真就选第一个分支,如果为假就选第二个分支。
因此,我们可以直接把布尔值定义成接收两个参数、并返回其中一个参数的选择函数:
TRUE接收两个参数x和y,永远返回第一个参数x;FALSE接收两个参数x和y,永远返回第二个参数y。
同样写一个辅助函数 to_boolean 来观察它们:
条件选择 IF
既然 TRUE 和 FALSE 自己就已经会做“二选一”了,那么 IF(b)(x)(y) 要做的事情其实非常简单——直接把真分支 x 和假分支 y 交给布尔值 b 去选:
仔细看 lambda x: lambda y: b(x)(y):它接收 x 和 y 并原封不动地传给 b,这和 b 本身的行为完全等价(在 Lambda 演算中称为 η-归约)。因此 IF 可以直接化简为恒等函数:
逻辑运算:NOT、AND、OR
理解了布尔值就是“选择器”之后,逻辑取反、逻辑与、逻辑或就完全不需要死记硬背,顺着“怎么选”就能写出来:
-
NOT(b):如果b为真,就选第 1 个参数FALSE;如果b为假,就选第 2 个参数TRUE:1NOT = lambda b: b(FALSE)(TRUE) -
AND(a)(b):如果a为真,最终结果取决于b(选第 1 个参数b);如果a为假,结果必定为假(选第 2 个参数FALSE):1AND = lambda a: lambda b: a(b)(FALSE) -
OR(a)(b):如果a为真,结果必定为真(选第 1 个参数TRUE);如果a为假,最终结果取决于b(选第 2 个参数b):1OR = lambda a: lambda b: a(TRUE)(b)
验证一下:
3. 判零、有序对与大小比较
判零函数 IS_ZERO
原程序里有 i % 3 == 0。怎样判断一个数字 n 是不是 ZERO?
回顾数字的定义:n(p)(x) 会从初始值 x 出发,把函数 p 重复调用 n 次。
如果我们把初始值 x 设为 TRUE,而把函数 p 设为 lambda _: FALSE(不管传入什么,永远返回 FALSE):
- 当
n是ZERO时,p一次都不会被调用,直接返回初始值TRUE; - 当
n大于 0 时,p至少被调用一次,结果就永远变成了FALSE。
用有序对(Pair)巧妙实现“减一”
除了判零,后面的取余 % 和区间 range 都需要比较两个数的大小(m <= n)。
由于我们只表示自然数(最小为 0,没有负数),如果定义减法 m - n 在不够减时返回 0,那么 m <= n 就等价于 m - n == 0!
而减法 SUB(m)(n) 就是对 m 重复调用 n 次“减一”函数(前驱函数 PRED)。
问题是:加一(SUCC)很容易,只要多调用一次 p;但“减一”(PRED)该怎么做? 一个数字 n 只知道把函数重复执行 n 次,一旦执行了,我们没法让时光倒流去“撤销最后一次调用”。
数学家斯蒂芬·克莱尼(Stephen Kleene)在看牙医时想出了一个极其漂亮的方法——用有序对(Pair)做滑动窗口。
我们先造出最基础的复合数据结构 PAIR。它把两个值 x 和 y 保存在闭包里,等待一个选择器函数 f(比如 TRUE 或 FALSE)来取出左边或右边的值:
现在,想象一个装有两个数字的滑动窗口 (左, 右),初始状态是 (0, 0)。
我们定义一个滑动函数 SLIDE:每次接收当前有序对 (a, b),返回一个新的有序对 (b, b + 1)(即把原来的右值移到左边,右边放上“右值 + 1”):
看看从 (0, 0) 开始,连续调用 SLIDE 会发生什么:
也就是说,让数字 n 对初始有序对 PAIR(ZERO)(ZERO) 重复执行 n 次 SLIDE,最后取左边的元素,就得到了 n - 1(且 0 的前驱依然是 0):
小于等于 LEQ 与相等 EQ
有了减法 SUB 和判零 IS_ZERO,大小比较就水到渠成了:
- 小于等于
LEQ(m <= n):检查m - n是否为0; - 相等
EQ(m == n):同时满足m <= n且n <= m。
4. 递归、Z 组合子与取余运算
接下来要替换原程序里的取余运算 i % 3 和 i % 5。
在没有内置除法和取余运算符的情况下,求 m % n 最直接的办法是重复相减:只要除数 n <= m,就从 m 中减去一个 n,然后对剩下的数继续取余;当 m < n 时,剩下的 m 就是余数。
如果用普通 Python 函数来写,它是一个非常简单的递归函数:
但如果我们直接把它改写成 lambda:
这段代码不仅违反了“不依赖变量名自引用”的规则,而且在 Python 里一运行就会立刻无限递归崩溃!原因有两个,我们逐一解决。
障碍一:Python 的及早求值与惰性分支
Python 采用严格求值(应用序,Applicative Order):在调用任何函数之前,Python 会先把括号里的实参全部算出来。
当我们写下 IF(条件)(真分支)(假分支) 时,还没等 IF 根据条件做出选择,Python 就已经把 真分支 和 假分支 统统执行了一遍!而在 MOD 中,真分支 包含递归调用 MOD(SUB(m)(n))(n),这会导致不管 LEQ(n)(m) 是真是假,递归都会永无止境地进行下去。
解决办法是延迟求值(Thunk):把两个分支都用一层 lambda _: ... 包裹成函数。函数定义本身不会立即执行内部代码;等 IF(条件) 选出正确的那个分支函数之后,我们在最末尾再传一个占位的参数(例如 (ZERO))去触发它:
障碍二:匿名函数如何递归(从自应用到 Z 组合子)
在纯粹的 Lambda 演算中,所有函数都是匿名的,函数体内部不能通过全局变量名 MOD 来调用自己。那没有名字的函数怎么递归?
第一步:把自己当作参数传给自己。
既然不能从外部作用域找名字,我们可以给函数多加一个参数 self,在调用时把函数自己传进去:
看,MOD_STEP 内部完全没有引用任何外部函数名,仅仅靠 self(self) 就实现了递归!
第二步:把 self(self) 抽离成通用工具——Z 组合子。
每次写递归函数都要手动传 self(self) 有些繁琐。在 Lambda 演算中,可以把这种“自我复制并传入”的模式提取成一个高阶函数,也就是著名的 Y 组合子(Y Combinator):
不过,和前面的 IF 一样,如果直接在严格求值的 Python 中写 f(x(x)),x(x) 会立刻无限展开。我们只需用同样的技巧,把 x(x) 用一层 lambda y: x(x)(y) 包起来延迟执行,就得到了适用于严格求值语言的 Z 组合子(Z Combinator):
有了 Z 组合子,任何需要递归的函数只需写成 Z(lambda recurse: ...),在函数体内直接调用 recurse 即可:
再顺手定义判断整除的函数 IS_DIVISIBLE_BY(余数是否为 0):
5. 列表与区间 RANGE
现在回头看最初的程序:数字、%、== 0、or、if 的替代品都已经齐备,只剩下 range(1, 20) 和 for 循环了。为了替换它们,我们需要用函数构造出列表(链表)。
用有序对构造链表
前面在第 3 节我们已经实现了有序对 PAIR、LEFT 和 RIGHT。链表可以自然地建立在 PAIR 之上。
我们可以把链表的每个节点表示成两层 PAIR:
- 空列表
EMPTY:第一个位置放TRUE表示列表为空(第二个位置放什么都可以,我们放TRUE); - 在头部插入元素
UNSHIFT:接收原列表l和新元素x,返回一个新节点——第一个位置放FALSE(表示非空),第二个位置放PAIR(x)(l); - 判空
IS_EMPTY:直接取外层PAIR的左值LEFT; - 取首元素
FIRST:先用RIGHT取出内层PAIR,再用LEFT取出首元素; - 取剩余列表
REST:先用RIGHT取出内层PAIR,再用RIGHT取出剩余列表。
写一个辅助函数 to_list,方便把我们的链表转换成 Python 列表来观察:
测试一下构造列表 [1, 2, 3]:
实现区间函数 RANGE
有了链表和 Z 组合子,我们可以递归地生成从 m 到 n(包含首尾)的数字列表:
- 如果
m <= n,就先递归生成m + 1到n的列表,再用UNSHIFT把m插到最前面; - 否则返回
EMPTY。
原程序中的 range(1, 20) 包含 1 到 19,用我们的函数表示就是 RANGE(ONE)(PRED(TWENTY))。
第二次替换:接管区间、取余与逻辑判断
现在,把 RANGE、MOD、IS_ZERO 和 OR 全部换进原程序中:
注意看这段代码:此时的循环变量 i 已经不再是 Python 的 int,而是纯粹的邱奇数!整个条件判断与取余运算完全在 lambda 的世界里完成,只剩下最外层的 for ... in to_list(...) 和 if to_boolean(...) 还在借用 Python 的语法。
6. 高阶遍历与最终组合
最后一步,我们把仅剩的 Python for 循环和 if 语句也替换成列表的高阶函数。
FILTER、MAP 与 EACH
在函数式编程中,“遍历列表并挑选出满足条件的元素”就是 FILTER:
- 如果列表
l为空,返回EMPTY; - 如果首元素
FIRST(l)满足条件f(FIRST(l)),就将它保留在结果列表头部,并继续过滤剩余列表REST(l); - 否则跳过首元素,直接过滤
REST(l)。
同样地,我们可以实现映射函数 MAP 和依次对每个元素执行动作的遍历函数 EACH:
最终形态
现在,最初的 3 行 Python 程序终于可以彻底摆脱 range、%、==、or、if 和 for 了:
运行它,依然会打印出熟悉的:
如果连变量名都不允许用呢?
你可能会问:上面的代码里不是还用了 ZERO = ...、SUCC = ...、MOD = ... 这些 Python 变量赋值语句吗?
其实,我们给函数起大写名字纯粹是为了人类阅读方便。因为有了 Z 组合子,上面定义的每一个函数都是非自引用的;而在 Lambda 演算中,先绑定变量 NAME = expr 再在 body 中使用 NAME,完全等价于一次匿名函数调用:
如果我们把所有大写名字全部按定义展开(内联替换),整段程序就会化为一个只包含 lambda、参数名和括号的单一表达式(仅在最外层用 print 和 to_integer 观察结果):
你可以把上面这整段表达式直接粘贴进 Python 解释器运行——除了最后一行用来把结果打印到屏幕上的 print(n(lambda x: x + 1)(0)),整段计算过程里没有任何数字、布尔值、运算符、条件语句或循环,却完整地算出了 1 到 19 之间所有能被 3 或 5 整除的数。
7. 完整代码
下面是整理后的完整 lambda_calculus.py: