无类型λ-演算入门:语法、Church编码、Y组合子与Python实现
一篇关于无类型 λ-演算的学习笔记,往往比一本正经的教科书更实用。这篇文章不会从“什么是 λ-演算”这种百科式定义讲起,而是直接把我自己从入门到能动手实现一个求值器过程中的理解、踩坑和验证沉淀下来。内容覆盖语法直觉、三条归约规则、Church 编码、Y 不动点,以及一个用 Python 手写最小实现的可复现方案。无论你是刚翻开 TAPL 被第一章劝退,还是写了很多年代码想搞明白函数式语言的底层直觉,这篇笔记都能用得上。
1. 无类型 λ-演算到底在讲什么
1.1 一个极小的语法,却能装下整个可计算世界
我当年第一次看到 λ-演算的语法定义时,心里冒出来的想法是:就这么点东西,怎么就能和图灵机等价了?
翻译成人类语言就三条规则:一个变量 x 是一个项;一个以 x 为参数的“函数定义” λx.M 是一个项;一个函数作用在一个参数上,也就是 M M,是一个项。没了。没有数字、没有加法、没有 if、没有循环,整个系统连“类型”都没有,所有东西都是项,函数就是值,值就是函数。
无类型指的是这个系统里不区分“数据”和“函数”——两者都是表达式,一个函数可以作用于任何表达式,包括作用于它自己。这不像你在 Java 或 C++ 里写代码,编译器会拦住“把一个整数当成函数调用”的写法。在无类型 λ-演算里,(λx.x)(λy.y) 合法,(λx.x)(λx.x)(λx.x) 也合法,甚至可以构造一个项去“调用”它自己。这一方面带来了极大的自由,另一方面也意味着不是每个项都能在有限步骤内算出结果。
实际书写时有一堆省略括号的惯例,初学容易懵。我建议你直接按“函数体尽量往右延伸”和“应用左结合”这两条规则来读:
λx.λy.x等价于λx.(λy.x),不是(λx.λy).x;M N P等价于(M N) P,应用从左边开始结合;λx.M N等价于λx.(M N),不是(λx.M) N。
记住这两条,绝大部分表达式的结构就不会读错。这和你读函数式编程代码时辨识柯里化函数是一样的:多个参数本质上是“返回函数的函数”,而不是一个接受多参数的原生函数。
一个“函数没有多参数,只有单参数 + 柯里化”的直觉很重要。比如一个二元加法函数,你写成 λx.λy.(+ x y),先吃进 x 返回一个新函数 λy.(+ x y),这个新函数再吃 y,最后才得到结果。这和 Python 里写 lambda x: lambda y: x + y 完全对应。
1.2 为什么“无类型”反而是理解计算的捷径
有类型系统的时候,编译器会通过类型约束去掉许多非法程序,比如不能把字符串当函数调用。但类型本身也在掩盖“计算”的本来面目。无类型 λ-演算把一切抽象到只剩“函数定义”和“函数调用”两个动作,相当于删掉了所有装饰,让你直接面对计算的骨架结构。
我一开始总忍不住问:那真实世界的 1、2、3 在哪?布尔值在哪?if-else 在哪?循环在哪?答案是:全部可以通过函数“编码”出来,而不是作为内置语法。这个发现对我来说是理解 λ-演算最大的一道坎,也是最大的突破口。
把无类型 λ-演算当作“计算的汇编语言”来学,而不是当作一门“可以编程的语言”来用,心态会顺很多。它真正的价值不在于写出业务代码,而在于让你从中看到“一个可计算系统最少需要哪些机制”。你会发现:函数定义、函数调用、变量替换,这三个机制就足够表达一切可计算函数。这就是它和图灵机等价,甚至和现代 CPU 指令集在表达能力上等价的原因。
在学习方法上,我强烈建议不要只读不练。纸笔推导是第一步,写一个小求值器是第二步。前者帮助你建立归约手感,后者逼你把“替换”“作用域”“求值顺序”这些概念精确到机器可以执行的程度。这两步做完,后面再去看类型系统、程序语言语义,都会轻松很多。
2. 三条归约规则:α、β、η,以及一个关键定理
2.1 α-等价:换名字不换灵魂
λx.x 和 λy.y 表达的是同一个函数:不管参数叫什么名字,它都原样返回你给它的东西。这种“换一个变量名但含义不变”的关系叫 α-等价。
规则很简单:只要不改变函数的绑定结构,你可以把绑定的变量名和函数体里对应的自由出现一起替换为新名字。需要注意的一点是,换名时不能把其他自由变量“捕获”进来。比如 λx.λy.x 中,x 是外层抽象的参数,y 是内层抽象的参数。如果把内层变量 y 换成 x,就得到 λx.λx.x,这就改变了语义——原本外层 x 被内层遮蔽,现在内层 x 遮蔽了外层 x。这种错误是“变量捕获”,在实现求值器时是最容易出 bug 的地方。
实操时,处理捕获问题的通用方案是:每次替换前,先给重名变量换一个“全新名字”。比如实现替换 M[x := N] 时,遇到 λx.M1 这样的情况,先给 x 换成 x1、x2 这种绝对不会在 N 中出现的名字,再做替换。这是“捕获避免替换”(capture-avoiding substitution)的标准做法,后面第五部分我会给出完整代码。
2.2 β-归约:一次函数调用的本质
β-归约描述了“函数调用”本身:
意思是把函数体 M 中所有自由出现的 x 都替换成实参 N。这个替换必须是捕获避免的。例如:
这里的 y 没有被替换,因为被 λy 绑定了。如果写成 (λx.λy.x) y,把外层 x 替换成 y,得到 λy.y,这就是一个典型的“变量捕获”例子——实参 y 本来是一个自由变量,替换进去后却被 λy 绑架成了绑定变量。所以写求值器时,这个边界情况必须处理。
β-归约里有个地方容易绕晕:为什么 (λx.M) N 被称为可归约表达式(redex)?因为“它还能继续算”。一个表达式如果没有可归约的 redex,就称它处于“规范形式”(normal form,或叫范式)。对无类型 λ-演算里的项来说,范式就是“答案”。比如 λx.x 是范式,但 (λx.x) (λy.y) 不是,它可以归约一步变成 λy.y。
2.3 η-变换:外延性的体现
η-变换讨论的问题是:什么样的两个函数可以看成同一个函数?
直觉是:一个函数返回“把 M 作用在 x 上”,和你直接把 M 当函数用,对于任何输入 x 的效果都一样。只要 x 是自由的,λx.M x 和 M 就是外延等价的。反过来从 M 变成 λx.M x 叫 η-展开,有些语言编译器会用它做优化。
这条规则在实际函数式编程中有一个直接对应:在 Haskell 或 JavaScript 里,x => f(x) 和直接写 f 基本是一回事,除非你想借这层包装延迟求值或者改变 this 绑定。理解 η-变换不只为理论完备,更为了以后看你手写代码时能识别出很多“多余的那层 wrapper”。
2.4 Church-Rosser 定理,以及归约的“确定性错觉”
这儿有一个初学者很容易问的问题:如果一项里有多个 redex,我先归约哪一个,最后结果一样吗?
Church-Rosser 定理回答:若 A 能归约到 B,也能归约到 C,那么一定存在一个 D,使得 B 和 C 都能继续归约到 D(模 α-等价)。换句话说,只要表达式最终能收敛到范式,那么不管中间走哪条路径,最终得到的范式在 α-等价意义下是唯一的。这个定理也叫“菱形性质”,因为归约路径图形上像一个可以不断收拢的菱形。
但这里有两个坑:
第一,不是所有项都能归约到范式。最常见的死循环项是 Ω = (λx.x x)(λx.x x)。它既不含有任何 β-redex 之外的规则,而又一步都停不下来,因为归约后还是自己。第二,归约顺序会影响是否终止,这是下一节的主题。
Church-Rosser 定理给 λ-演算提供了计算的“确定性”:结果不依赖于你怎么算。这一点在现代编程语言里等价于“纯函数的运算结果不依赖求值顺序”。但既然归约顺序会影响是否停得下来,那实现一个解释器时选哪种求值策略就非常关键。
3. 求值策略:正规序、应用序与惰性求值
3.1 正规序:最左最外的 redex 先归约
正规序求值规则是:每一步都选择当前表达式里“最左、最外”的 redex 进行归约。
“最左”指按语法树从左到右找,“最外”指不进入任何 λ 函数体内部去找 redex。你看一个项 (λx.M) N1 N2,它会先处理 (λx.M) N1,因为它在最左边。
正规序有个很漂亮的定理:如果这个 λ 项存在范式,正规序求值一定能找到它。这是纯函数式语言的“按名调用”(call-by-name)的理论基础。
举例验证一下:
用正规序,先归约最左最外的 redex,也就是 (λx.λy.y) (Ω),代入后直接得 λy.y,结束,没有进入 Ω 的死循环。但如果你先对参数 Ω 求值,那永远等不到结果。
3.2 应用序:先把参数算完再说
应用序求值规则是:先求值所有参数,再应用函数。大多数编程语言(Python、Java、C)的实参求值都接近应用序,也就是严格的按值调用。
应用序有一个实用优点:不会反复计算同一个参数。在按名调用中,如果一个参数在函数体里使用了 10 次,原始表达式会被复制 10 份、计算 10 遍;应用序只算一遍,再把结果传进去。
但你得注意:应用序可能错过范式。看这个例子:
应用序先去算 Ω,Ω 不会终止,于是整个表达式也永远得不到结果。虽然这个表达式明明有范式 λy.y。
这正是“求值策略影响是否停机”的活生生的例子。假如把逻辑反过来,比如在 JavaScript 里,函数参数不够时会报错,或参数永远急匆匆先求值。如果有一个参数是死循环,函数根本不执行,程序就永远挂住了。Haskell 避开这个问题靠的是惰性求值。
3.3 从正规序到 Haskell 的惰性求值
惰性求值可以理解为:按名调用 + 结果缓存(memoization)。表达式不是立刻求值,而是以“未求值的承诺”(thunk)的形式传进函数;如果函数体里多次用到这个参数,第一次真正使用时才求值,并将结果保存下来,后续直接复用。
很多人第一次接触 Haskell 会被它的惰性吓到:take 3 (repeat 'a') 为什么不会死循环?repeat 'a' 生成无限长的列表,但 take 3 只需要前三个元素。惰性求值使得无限数据结构成为可能,因为 repeat 'a' 在未用完前根本不会被完全展开。
在实现层面,这和正规序的联系是:两者都在“不必要时不求值参数”,区别是正规序每次用参数都重新替换,惰性求值用指针/缓存共享结果。理解这一点,对阅读 Haskell 代码的性能行为非常有帮助。
实操中,应用序语言(如 Python、JavaScript、Rust)学 λ-演算,最容易混淆的就是“函数参数到底什么时候求值”。我建议你做题时先默认使用正规序推导,等懂了 Church-Rosser 定理之后,再刻意用应用序去推一遍同一道题,观察二者的路径差异。这比自己抄十遍定义有用得多。
4. 用 λ-演算编码数据与控制流
4.1 Church 布尔值与 if-then-else
无类型 λ-演算里没有内置的布尔值,但你可以把布尔值编码为“选择两个分支之一”的函数:
这个设计的精妙在于:true 接受两个参数返回第一个,false 返回第二个。顺理成章,if-then-else 就变成一个函数应用:
例如 if true M N:
最后返回了 M,符合 true 走“then”分支的语义。false 同理会返回 N。
这里面最有价值的不是记住 true/false 的定义,而是体会“数据即是行为”的思路:布尔值不是一个存储 True/False 的状态,而是“在两种可能性里做选择”的能力本身。这个思路后续在 Church 编码数字、序对、列表里都会反复出现。
4.2 Church 自然数与算术
自然数可以编码成“把函数重复应用 n 次”:
也就是说数字 n 被翻译成:给定一个函数 f 和初始值 x,把 f 应用 n 次到 x。这是“迭代”结构的抽象,而不是“数”的模仿。自然数在 λ-演算里本质上就是循环次数。
后继函数 succ 接受一个数字 n,返回 n+1:
验证:对 2 这个数字执行 succ,得到 λf.λx.f ((λf.λx.f(f x)) f x),化简之得 λf.λx.f(f(f x)),即 3。核心逻辑:先让 n 对 f 和 x 应用 n 次,得到“已经迭代 n 次的结果”,再额外套一层 f。
加法是一个数字 n 对另一个数字 m 做“迭代 m 次 f”:
乘法稍微绕一点:
意思是:m 对“把 f 应用 n 次”这个操作应用 m 次。如果你手工推导 mult 2 3,最后应该得到 6 的编码。我建议新手至少把 plus 2 3 完整用纸笔推一遍。推导过程不长,但能让你彻底记住“数字就是迭代次数”这句话。
4.3 序对、列表与复合数据结构
没有元组也没有链表,但用函数依然可以编码。序对的定义如下:
你可以验证:
所以 first (pair M N) 返回 M。second 同理,利用 false 取第二个参数。
列表可以定义为嵌套的序对:列表 [1,2,3] 编码为 pair 1 (pair 2 (pair 3 nil)),其中 nil 你可以定义为 λx.true 或者任何识别空表的方式。处理列表的函数通过反复取 first/second 遍历。
这里的通用技巧是编码一个复合数据时,把数据的所有“访问方式”都作为参数传给一个函数。序对的关键不是“容器”,而是“如何从容器里取得两个元素”。这种观点在理解 Haskell 的 fold 或 Lisp 的 cons 时很有用。
4.4 递归与 Y 不动点组合子
最难理解的部分来了。λ-演算里没有命名函数,怎么实现递归?
fact(n) = if (n == 0) then 1 else n * fact(n-1) 里出现了 fact 这个名字。但 λ-演算没有全局名字表。解决方案是使用不动点组合子。
最著名的 Y 组合子定义如下:
它的性质是:对任意函数 F,有 Y F = F (Y F)。也就是说 Y F 是 F 的不动点。反复展开,得到:
这就把“看起来没名字的递归”转化成了“无限展开的表达式”。
实际使用时,先把递归函数写成“一次迭代”的形式。例如计算阶乘,先定义:
这里 f 是“本轮要递归调用的函数”,不是名字。然后 fact := Y F。
展开看几个步骤:
你会看到 Y F 又被递归调用。这个形式确实能做递归计算,但在无类型设置下,实际实现时所有项都可以无限展开,每次调用都会重新生成一份 Y F 的副本。所以使用 Y 组合子时计算代价很高,实践中还需要图归约技术来共享展开后的相同子树。
业务开发里你不会真的写 Y 组合子。但理解它有两个巨大收益:一是明白“递归不需要语言单独提供机制,只需要某个能自复制的表达式”;二是看编译器或解释器实现递归时,能认出来它们本质上是在做类似不动点的事情。
5. 把笔记变成动手练习:用 Python 写一个最小求值器
5.1 用 Python 表示 λ 项与捕获避免替换
只做纸笔推导总觉得空。我建议你亲手写一个几十行的极简求值器。整体目标不是性能,而是能对任意 λ 项执行正规序归约,打印每一步,看到范式。
第一条替换规则必须处理好。我写一个用 Python 类表示语法树的版本,重点实现 capture-avoiding substitution。
然后实现 free variables 收集和替换。替换函数 subst(term, x, n) 表示把 term 中自由出现的 x 替换为 n:
关键点是 Abs 分支:如果 term.param 出现在 n 的自由变量中,就必须先给 term.param 换一个全新名字,否则会把 n 中的自由变量捕获。这个 bug 非常隐蔽,没有换名时大部分例子能跑通,偶尔出现结果错误,排查起来费时。
fresh 函数我通常这样写:
5.2 实现正规序归约与每一步推导打印
下一步实现“寻找最左最外 redex”。规范做法是递归遍历语法树:
这个顺序很关键:先查 App 本身是不是 redex,不是就递归查 fn,再查 arg。这正是“最左最外”定义。Abs 分支只进入函数体找 redex,不会把函数体外面包的 redex 漏掉。
归约主逻辑:
最后是重复归约直到范式,打印每一步:
用 Y F 这类高阶表达式测试时,你大概率会在几步之后看到重复模式,然后计数器把它截停。对学习而言,截停很合理,因为你实际想看的是归约路径而不是无限展开。
5.3 常见坑与调试技巧
我写这个求值器时踩了几个坑,列出来供参考,以免你重走弯路。
第一,变量捕获。这是隐藏最深的坑。当 subst 里的 Abs 分支没有做换名,普通例子看不出问题,一旦参数里出现与函数体绑定变量同名的自由变量,结果就会错。调试方法:准备一个测试用例 (λx.λy.x) y,正确结果应该是 λy'.y 之类,而不是 λy.y。每次修改替换逻辑后先跑这个用例。
第二,打印格式混乱。嵌套的 App 和 Abs 如果不加括号,人工阅读很容易看错。我干脆在 __repr__ 里把所有子表达式都加括号,看起来冗余,但正确性优先。调试完成后再精简打印格式。
第三,正规序的 find_redex 遍历方向写错。有人先查 arg 再查 fn,得到的是“最左最内” redex,那是另一套归约策略。想验证正规序是否正确,可以拿 (λx.λy.y) (Ω) 测试,正规序应立即得到 λy.y,如果程序卡住就去检查遍历方向。
6. 无类型 λ-演算的现代回响与学习路线
6.1 表达能力与不可判定性
无类型 λ-演算表达了所有可计算函数,这一点由 Church-Turing 论题支撑。但也正因为它没有类型约束,表达式中蕴含了不可判定性:两个项是否 β-等价,没有通用算法能够在有限步骤内判定。直观原因是归约可能不终止,你无法区分“还在跑”和“永远跑不完”。
这个结论对程序员的意义是:计算的性质里,停机问题不是某个特殊模型的缺陷,而是所有足够强的计算系统共同的内禀属性。哪怕你写的只是一个十几行的小解释器,当你允许它接受任意 λ 项作为输入时,你已经在处理不可判定问题了,所以循环次数截断就成了工程上的权宜之计。
6.2 从 λ-演算看现代语言
现代语言里的匿名函数、闭包,本质上是 λ-抽象加上词法环境记录。JavaScript 里的 x => x + 1,Python 里的 lambda x: x + 1,Lisp 家族里的 (lambda (x) ...),都是早期 λ-演算的直系后代。
闭包的实现思路可以理解为:“词法环境 + 函数体代码”的组合。函数被创建时记录当前变量环境,被调用时恢复这个环境再执行函数体。这个机制与 λ-演算里“抽象 + 代入规则”的语义几乎是一一对应的。理解了 λ-演算的替换,再理解闭包的 capture 行为就有一种顺手拈来的感觉。
编译器和程序分析领域里,“de Bruijn 索引”“α-等价”“作用域规则”这些概念都源自 λ-演算。你把无类型 λ-演算装进脑子,后续读编译原理中变量绑定的章节,会感觉像在复习老朋友而不是学新知识。
6.3 从无类型走向类型,以及我的学习建议
要我说,无类型 λ-演算最好的归宿就是成为走向类型系统的一级台阶。TAPL(《Types and Programming Languages》)前三章就是这个路径。读完无类型部分,再引入 “→” 类型、简单类型 λ-演算,你会发现类型系统本质上是在“表达什么程序不会跑飞”这件事上做静态判断。类型是限制,但限制带来保证。
如果你要系统性深入学习,我给出一个个人经验式的顺序:
- 用纸笔推完 Church 编码的布尔、自然数、加法、乘法、序对、if、Y 组合子。这一阶段的目标不是“会算”,而是“能看到函数组合的模式”。
- 写一个至少能处理捕获避免替换和正规序归约的小求值器。语言不限,Python、OCaml、Haskell 都行。写完你才会真正理解“替换”为什么是 λ-演算里最核心也最麻烦的部分。
- 再回来读教材,你会发现自己能跟上 TAPL 第一至第三章几乎每一行。这时可以放心进阶简单类型 λ-演算。
我现在依然每隔一段时间翻一翻当年的笔记。每次重看,都能从同一个表达式里看出些新的东西——比如从 Church 数字里看到 fold 的本质,从 Y 组合子里看到递归与自指的关系。这种“少即是多”的理论,越是简化到极限,越能在你写真实代码时产生影响。如果这篇笔记能帮你少走一点我第一次学它时走的弯路,就算很有价值了。