Let's build our mathematics by using lambda calculus && church encoding!
致谢
Gemini 2.5 Pro 0325 && 0506:文本润色、事实正确性审查,以及帮助我学习、理解和回忆这些内容。
myster1ous: 给予我巨大的启发,帮助我构建出 prev 函数。
前言
本文的目的是带领大家踏上一段奇妙的旅程:我们将使用 λ 演算(Lambda Calculus)这一极简的计算模型,并通过丘奇编码(Church Encoding)这一巧妙的技术,一步步构建出我们日常编程中熟悉和依赖的计算体系,例如数字、布尔逻辑乃至更复杂的数据结构。
为了方便演示和实践,本文中的所有代码示例都将使用 Scheme 语言。Scheme 是 Lisp 家族的一员,它的语法是 S-表达式 (Symbolic Expressions)。S-表达式不仅简洁优雅,而且其结构与 λ 演算的表达方式惊人地契合。
在我们开始之前,让我们先快速了解一下 S-表达式:
- 核心是列表 (List): S-表达式的基本形式是由圆括号
()包围起来的列表。 - 结构:
(操作符 参数1 参数2 ...): 列表中的第一个元素通常是操作符(即要应用的函数),其余元素则是传递给该操作符的参数。 - 原子 (Atom) 与嵌套: 列表中的元素可以是原子(如数字
123、符号x、+),也可以是另一个 S-表达式。这种嵌套能力使得 S-表达式可以表示复杂的树状结构。
例如,表达式 (+ 1 (* 2 3)) 在 Scheme 中表示数学上的 1 + (2 * 3)。
这里:
- 最外层的
(+ 1 (* 2 3))是一个 S-表达式。+是操作符,1和(* 2 3)是它的参数。 (* 2 3)本身也是一个 S-表达式。*是操作符,2和3是它的参数。执行它会得到6。- 所以整个表达式
(+ 1 6)最终会计算得到7。
理解了 S-表达式,我们就可以更顺畅地进入 λ 演算的世界了。
λ 演算:计算的本质
λ 演算(Lambda Calculus)由阿隆佐·丘奇在 20 世纪 30 年代提出,它是一个极其简洁却具有图灵完备计算能力的数学形式系统。这意味着任何可计算的函数都可以用 λ 演算来表达和计算。
λ 演算的核心要素出奇地少,主要包括:
- 变量 (Variables): 如
x,y,f等,用作参数名或函数本身的占位符。 - 函数抽象 (Function Abstraction): 这是定义一个新(通常是匿名的)函数的过程。
- 函数应用 (Function Application): 这是将一个函数作用于其参数(即调用函数)的过程。
让我们逐一来看。
1. 函数抽象 (定义函数)
在传统的数学或 λ 演算表示法中,一个接受参数 x 并返回表达式 E 的函数可以写为
(lambda (x) E)
这里的 lambda 是一个特殊的关键字,表示我们正在定义一个匿名函数。(x) 是参数列表(这里只有一个参数 x),而 E 是函数体,代表函数要执行的计算并返回的结果。
例如,一个接受参数 x 并简单返回 x 本身的函数 (称为恒等函数):
(lambda (x) x)
这是一个非常基础但重要的函数。
再举一个例子,一个接受参数 x 并期望返回 x+1 的函数:
(lambda (x) (+ x 1))
请注意: 在这个例子中,我们暂时借用了 Scheme 中预定义的 + 操作符和数字 1。这有助于我们直观地理解函数定义。然而,在纯粹的 λ 演算中,并不存在预定义的数字或算术运算符。一切——包括数字、布尔值和运算——都需要从最基本的函数抽象和应用来构建。这正是我们稍后将通过“丘奇编码”来实现的迷人之处!现在,请将注意力集中在 lambda 如何定义一个接受输入、进行处理、然后输出结果的“黑盒子”。
这些用 lambda 定义的函数都是匿名函数,它们没有名字。
2. 函数应用 (调用函数)
定义了函数,我们自然需要调用它。在传统表示法中,将函数
(f a)
其中 f 是要应用的函数,a 是传递给函数的参数。
如果我们想直接应用一个匿名函数,可以这样做:
((lambda (x) (+ x 1)) 5)
这个表达式的含义是:将匿名函数 (lambda (x) (+ x 1)) 应用于参数 5。计算过程(我们稍后会详细讨论其规则)会得到 6。
虽然纯粹的 λ 演算主要处理匿名函数,但在像 Scheme 这样的编程语言中,为了代码的清晰和可重用性,我们通常会给函数命名。这可以通过 define 实现:
(define inc (lambda (x) (+ x 1)))
这里,我们将前面定义的“加一”函数命名为 inc。现在,我们可以通过名字来调用它:
(inc 5) ; 这将返回 6
(inc 10) ; 这将返回 11
需要强调的是,define 并不是 λ 演算本身的一部分,它是 Scheme 提供的用于绑定名称到值的便利工具。在 λ 演算的理论探讨中,我们更多关注匿名函数及其组合。
3. 万物皆函数:λ 演算的核心思想
λ 演算最令人着迷的一点或许是它的极简主义哲学:在纯粹的 λ 演算中,一切皆是函数。 我们通常认为理所当然存在的数字 (0, 1, 2...)、布尔值 (真/假)、条件判断 (if-then-else)、数据结构 (如列表) 等等,在 λ 演算的宇宙中,都是通过巧妙地组合和嵌套函数来表达的。
这意味着,理论上我们只需要“定义函数”和“应用函数”这两个基本操作,就能构建出整个可计算的世界。这听起来可能有些抽象和不可思议,但别担心,本文的后半部分将通过丘奇编码来具体展示如何用函数来表示数字和布尔值,并在此基础上进行运算。
λ 演算的运算法则
λ 演算有几条基本的变换规则,它们定义了表达式如何被“计算”或“简化”。最重要的规则有:
a. α-变换 (Alpha Conversion / α-renaming)
α-变换指的是,在不改变函数行为的前提下,可以对函数定义中的绑定变量(即参数名)进行重命名。
例如,以下两个函数定义是完全等价的:
(lambda (x) (+ x 1))
和
(lambda (y) (+ y 1))
(用传统符号表示即
只要新的参数名不与函数体内的自由变量(即那些不是由当前 lambda 定义的参数,而是来自外部作用域的变量)冲突,这种重命名就是有效的。α-变换告诉我们,参数的具体名字是什么并不重要,重要的是函数的结构和行为。
b. β-规约 (Beta Reduction)
β-规约是 λ 演算的“引擎”,它描述了函数应用(调用)是如何执行的,其本质就是代入 (substitution)。
当一个形如 ((lambda (v) E_body) E_arg) 的表达式出现时(即一个 lambda 函数紧接着被一个参数应用),β-规约允许我们将函数体 E_body 中所有出现的绑定变量 v 都替换成实际参数 E_arg 的(经过求值的)副本。
例如,对于表达式:
((lambda (x) (+ x 1)) 5)
根据 β-规约:
- 找到
lambda的参数x和函数体(+ x 1)。 - 找到实际传入的参数
5。 - 将函数体
(+ x 1)中的所有(自由出现的)x替换为5。 - 得到新的表达式:
(+ 5 1)。
在 Scheme 环境下,这个表达式 (+ 5 1) 会被进一步求值为 6。β-规约是实现计算的核心步骤。
c. η-变换 (Eta Conversion / η-reduction)
η-变换表达了函数外延性的概念:如果两个函数对于所有可能的输入都产生相同的结果,那么它们就是等价的。
形式上,如果 F 是一个函数,且变量 x 在 F 中不是自由变量 (not free in F),那么:
(lambda (x) (F x)) 等价于 F。
(用传统符号表示即
这意味着,如果一个函数的唯一作用就是将其参数传递给另一个函数 F 并返回结果,那么这个外层包裹的函数实际上就是 F 本身。
例如,如果我们有函数 inc (定义为 (lambda (z) (+ z 1))),那么:
(lambda (y) (inc y)) 就 η-等价于 inc 本身。
α-变换、β-规约和(可选的)η-变换共同构成了 λ 演算的计算基础。对于构建我们的计算体系而言,β-规约是最直接相关的“执行”规则。
柯里化 (Currying):用一元函数模拟多元函数
你可能已经注意到,在纯粹的 λ 演算定义中,函数抽象(lambda)一次只绑定一个参数。例如我们前面看到的 (lambda (x) x)((lambda (x) (+ x 1))((lambda (x y) (* x y))(或
在纯粹的 λ 演算中,函数严格来说只接受一个参数。但是,通过一种称为柯里化 (Currying) 的巧妙技巧(以逻辑学家哈斯凯尔·柯里命名),我们可以用这些一元函数来优雅地实现多元函数的效果。
这里的关键在于 λ 演算中的一个核心特性:函数是“一等公民”。这意味着函数可以作为参数传递给其他函数,也可以作为其他函数的返回值。能够接受函数作为参数或返回函数的函数,我们称之为高阶函数 (Higher-Order Functions)。
柯里化的思想很简单:
要模拟一个接受多个参数(比如 n 个)的函数,我们可以:
- 定义一个一元函数,它接受第一个参数。
- 这个函数返回一个新的一元函数,这个新函数接受第二个参数。
- 这个新函数又返回一个更新的一元函数,接受第三个参数...
- 如此往复,直到所有
n个参数都被逐个接受。最后一个返回的函数才会最终计算并返回结果。
让我们看一个例子。假设我们想实现一个两数相乘的函数 multiply(x, y)。通过柯里化,它可以表示为:
在 Scheme 中,这对应于:
(lambda (x)
(lambda (y)
(* x y)))
这个表达式做了什么?
- 它首先定义了一个匿名函数,接受参数
x。 - 当这个函数被调用(比如传入
3)时,它并不会立即计算乘积,而是返回另一个新的匿名函数。这个新的函数“记住”了x的值(比如3),并且它接受一个参数y。 - 当这个返回的新函数被调用(比如传入
4)并传入y的值时,它才会执行(* x y)(即(* 3 4)),返回最终结果12。
我们如何使用这个柯里化的乘法函数呢?
; 定义柯里化的乘法函数
(define curried-mult
(lambda (x)
(lambda (y)
(* x y))))
; 应用第一个参数
(define times-three (curried-mult 3))
; (curried-mult 3) 返回的是 (lambda (y) (* 3 y))
; times-three 现在就是这个新函数
; 应用第二个参数
(display (times-three 5)) ; 输出 15
; 或者一步到位地调用
(display ((curried-mult 3) 5)) ; 输出 15
通过柯里化,我们成功地用一系列只接受单个参数的函数模拟了多参数函数的功能。这在 λ 演算的理论框架中至关重要,因为它表明仅用一元函数就足以表达所有计算。在很多函数式编程语言中,虽然它们可能提供了直接定义多参数函数的语法,但其底层有时就是通过柯里化来实现或解释的,或者柯里化本身就是一种推荐的编程范式。
一层又一层抽象:构建复杂性的阶梯
计算机科学与工程的伟大之处,很大程度上在于其精妙的抽象分层 (Layers of Abstraction)。我们从最底层的物理现象开始:
- 我们抽象晶体管的行为,得到逻辑门(与门、或门、非门)。
- 我们抽象逻辑门的组合,得到算术逻辑单元 (ALU)、存储单元等。
- 我们抽象这些硬件组件的协同工作,得到完整的计算机体系结构。
- 然后,我们抽象硬件的细节,得到机器语言。
- 我们进一步抽象机器语言,得到汇编语言和指令集架构 (ISA)。
- 再往上,我们抽象汇编指令,得到C、Java、Python等高级编程语言,它们提供了更接近人类思维的表达方式。
- 甚至在高级语言内部,我们也会构建库、框架,提供更高层次的抽象来解决特定领域的问题。
每一层都依赖于其下一层提供的功能,同时向上一层隐藏了不必要的复杂细节。这使得我们能够专注于当前层次的问题,而不必每次都从最原始的元素(比如电子的运动)开始思考。
在本文探索 λ 演算和丘奇编码的旅程中,我们也将采用类似的方法。
一旦我们成功地用 λ 演算定义了一个概念(比如布尔值、数字或某个运算),在后续的构造中,我们就会把它当作一个已知的、可以直接使用的“基本构建块”或“新定义的原语 (primitive)”。 我们不会在每次使用时都将其完全展开回最原始的 lambda 表达式形态(除非为了阐释特定原理)。
例如,一旦我们用 λ 表达式定义了数字 ZERO、ONE 和加法运算 ADD:
- 在讨论乘法时,我们就可以直接写
(ADD ONE ONE)来表示1+1,而不需要把ADD,ONE都展开成它们底层的lambda定义。
这样做可以让我们保持思路清晰,逐步构建更复杂的系统,而不至于迷失在无尽的 lambda 嵌套中。
现在,我们已经了解了 λ 演算的基本规则和柯里化这一重要技巧(它让我们能方便地处理多“参数”的函数),是时候开始用这些积木搭建我们计算世界的第一块基石了!
构建数学与计算体系的开始:基本元素
正如我们之前讨论的,纯粹的 λ 演算只拥有函数抽象(定义函数)和函数应用(调用函数)这两个基本构建块。我们日常编程中习以为常的“数字”、“自然数”、“加法运算”,乃至“字符”、“布尔值”(真/假)等等,在 λ 演算的原始宇宙中并不直接存在。
显然,如果缺少这些基本的数据类型和运算,我们很难用常规的思路来编写程序。
然而,λ 演算的强大之处在于,我们可以完全利用其自身的体系来构造出这些常用的对象。换句话说,我们可以为这些概念找到它们在 λ 演算世界中的函数式对应物 (functional counterparts)。
这个将数据类型和运算用纯函数来表达和实现的过程,正是丘奇编码 (Church Encoding) 的核心思想,由阿隆佐·丘奇本人开创。
让我们从最基本、也可能是最简单的概念开始:布尔值 (Booleans)。
布尔值 (Booleans) 与逻辑运算
布尔逻辑是计算的基石,它处理的是“真”与“假”这两个基本值。在丘奇编码中,这两个值被巧妙地定义为两个不同的选择函数:
-
true被定义为一个函数,它接受两个参数,并总是选择并返回第一个参数。true := \lambda t . \lambda f . t 或者,使用我们之前约定的 Scheme 多参数函数抽象(内部通过柯里化实现):
(define true (lambda (t f) t)) -
false被定义为一个函数,它接受两个参数,并总是选择并返回第二个参数。false := \lambda t . \lambda f . f (define false (lambda (t f) f))
这两个定义的神奇之处在于,它们内在地编码了条件选择的行为,这正是 if-then-else 语句的核心!
如果我们有一个丘奇布尔值 condition,以及两个表达式 then-expr 和 else-expr,那么:
(condition then-expr else-expr)
这个表达式本身就会根据 condition 是 true 还是 false 来求值:
- 如果
condition是true,例如(true "苹果" "香蕉"),它会返回"苹果"(第一个参数)。 - 如果
condition是false,例如(false "苹果" "香蕉"),它会返回"香蕉"(第二个参数)。
有了 true 和 false 这两个基本构建块,我们就可以定义出标准的逻辑运算符:
-
逻辑非 (NOT):
(not x)not应该将true变为false,将false变为true。 我们可以这样定义not:(define (not x) ; x 是一个选择函数,它会从后面两个参数中选一个 ; 如果 x 是 true, 它选择第一个参数 (false)。 ; 如果 x 是 false, 它选择第二个参数 (true)。 (x false true))验证:
(not true)会展开为(true false true),根据true的定义,返回false。(not false)会展开为(false false true),根据false的定义,返回true。
-
逻辑与 (AND):
(and p q)p AND q为真,当且仅当p和q都为真。(define (and p q) ; 如果 p 是 true,则 AND 的结果取决于 q (实际上是 q 本身) ; 如果 p 是 false,则 AND 的结果一定是 false (p q false)) ; 注:这里 q 必须也是一个丘奇布尔值解释:
- 如果
p是true:表达式变为(true q false)。true选择第一个参数,即q。所以(and true q)的结果就是q。- 若
q是true,结果是true。 - 若
q是false,结果是false。
- 若
- 如果
p是false:表达式变为(false q false)。false选择第二个参数,即false。所以(and false q)的结果总是false。 这完全符合AND的逻辑。 (你原文中的(p (q true false) false)也是完全正确的,它更明确地将q的结果约束在true或false上,如果q本身已经是丘奇布尔值,(q true false)就等价于q。)
- 如果
-
逻辑或 (OR):
(or p q)p OR q为真,只要p或q中至少一个为真。(define (or p q) ; 如果 p 是 true,则 OR 的结果一定是 true ; 如果 p 是 false,则 OR 的结果取决于 q (实际上是 q 本身) (p true q)) ; 注:这里 q 必须也是一个丘奇布尔值解释:
- 如果
p是true:表达式变为(true true q)。true选择第一个参数,即true。所以(or true q)的结果总是true。 - 如果
p是false:表达式变为(false true q)。false选择第二个参数,即q。所以(or false q)的结果就是q。- 若
q是true,结果是true。 - 若
q是false,结果是false。 这完美符合OR的逻辑。 (同样,你原文的(p true (q true false))也是正确的。)
- 若
- 如果
阿隆佐·丘奇选择用“选择函数”来代表布尔值,这无疑是一个天才般的洞察。“选择”这一行为完美地概括了我们通常使用布尔值所做的核心操作——基于条件做出决策——这也使得后续逻辑运算的定义变得异常简洁和优雅。我们仅仅通过函数定义和应用,就凭空创造出了逻辑系统!
小扩展:更直观的 if 结构
既然我们的丘奇布尔值 true 和 false 本身就是选择函数,我们自然可以定义一个更符合我们日常编程直觉的 if 控制结构。这个 if 函数将接受三个参数:一个条件 (丘奇布尔值),一个“then”分支的表达式,以及一个“else”分支的表达式。
(define (if-then-else condition then-branch else-branch)
(condition then-branch else-branch))
你可能会问,这和直接写 (condition then-branch else-branch) 有什么本质区别呢?确实,通过我们前面讨论过的 η-变换 (Eta Conversion),(lambda (c t e) (c t e)) 其实就等价于一个直接接受 c, t, e 并应用的结构(在Scheme中,多参数函数可以看作是柯里化的一种语法糖)。
然而,定义这样一个 if-then-else 函数的好处在于:
- 可读性:
(if-then-else hungry? (eat food) (sleep))比(hungry? (eat food) (sleep))更明确地表达了条件判断的意图,尤其是当hungry?本身也是一个复杂表达式时。 - 抽象的体现: 它再次强调了我们可以通过函数封装来构建我们熟悉的编程构造。这更像是一种“语法糖”,它向我们展示了如何将底层的函数式构造包装成更易读、更符合特定编程范式的形式。
这再次提醒我们,λ 演算的表达能力是灵活的,我们可以基于核心规则构建出我们需要的抽象层次。
自然数 (Natural Numbers):函数的重复应用
继布尔值之后,下一个重要的基石是自然数 (Natural Numbers),如 0, 1, 2, ... 与布尔值通过“选择”来定义不同,自然数的丘奇编码抓住的是“重复”或“迭代”这一核心概念。
阿隆佐·丘奇的天才之处在于他意识到,数字可以被表示为高阶函数,其含义是“一个函数被应用的次数”。
具体来说,一个丘奇数 n 是一个函数,它接受两个参数:
- 一个函数
f(代表要被重复应用的操作)。 - 一个初始值
x(代表该操作的起点,或者说被操作的对象)。
这个丘奇数 n 会将 f 应用到 x 上恰好 n 次。
“重复应用 n 次”如何用纯函数定义呢? 我们没有循环语句,也没有内置的计数器。 答案在于巧妙地利用函数本身的结构,类似于数学中从 0 和后继操作构建自然数的皮亚诺公理 (Peano Axioms)。
数字 零 (ZERO)
数字 零 (ZERO) 就代表“将函数 f 应用零次”。这意味着无论 f 是什么,初始值 x 都原封不动地返回。
其定义如下:
在 Scheme 中:
(define ZERO
(lambda (f) ; ZERO 接受一个函数 f
(lambda (x) ; 并返回一个新的函数,这个新函数接受 x
x))) ; 然后直接返回 x (f 应用了0次)
这里的 ZERO 是一个柯里化的函数。它首先接受一个函数 f,然后返回另一个函数。这个内部函数接受初始值 x 并直接返回 x,完美体现了 f 被应用了零次。
例如,如果我们有一个函数 inc (加一) 和一个值 10:
((ZERO inc) 10)
(ZERO inc)会返回(lambda (x) x)(因为f是inc,但没有被使用)。- 然后
((lambda (x) x) 10)会返回10。 这正是我们期望的“对 10 加一 0 次”的结果。
后继函数 (Successor Function, S)
有了 ZERO,我们如何得到其他数字呢?我们需要一个后继函数 (Successor Function),通常表示为 S。S 接受一个丘奇数 n 作为输入,并返回代表 n+1 的丘奇数。
如果丘奇数 n 代表“将 f 应用 n 次”,那么 (S n)(即 n+1)就应该代表“将 f 应用 n+1 次”。
这可以这样理解:要将 f 应用 n+1 次到 x 上,我们可以先将 f 应用 n 次到 x 上得到一个中间结果,然后再对这个中间结果额外应用一次 f。
形式化地,后继函数 S 定义为:
在 Scheme 中:
(define S
(lambda (n) ; S 接受一个丘奇数 n
(lambda (f) ; 返回一个新函数 (代表 n+1),它接受 f
(lambda (x) ; 再返回一个新函数,它接受 x
(f ; 将 f 应用于...
((n f) x)))))) ; ...n 将 f 应用于 x 的结果
让我们仔细解读一下 S 的定义:
S接受一个丘奇数n(例如ZERO)。- 它返回一个新的函数,这个函数就是代表
n+1的丘奇数。这个新函数也遵循丘奇数的规范,所以它也期望接受一个函数f和一个值x。 - 当这个代表
n+1的函数被f和x调用时,它的行为是(f ((n f) x)):(n f): 首先,我们将输入的丘奇数n应用于f。回忆一下,丘奇数n本身是(lambda (f_inner) (lambda (x_inner) ...))的形式。所以(n f)会返回一个“准备好对某个x_inner应用f_inner(这里是f)n次”的函数。((n f) x): 接着,我们将上面返回的函数应用于x。这就完成了f在x上的n次应用。(f ((n f) x)): 最后,我们将f再额外应用一次到((n f) x)的结果上。这就构成了f的n+1次应用。
这种从 ZERO 和后继函数 S 出发来构造所有自然数的方法,其思想与数学中的皮亚诺公理 (Peano Axioms) 定义自然数的方式高度相似(皮亚诺公理:① 0 是自然数;② 每个自然数 a 都有一个后继数 S(a);...)。
现在,我们可以定义其他数字了:
ONE就是(S ZERO)TWO就是(S ONE),也就是(S (S ZERO))THREE就是(S TWO),也就是(S (S (S ZERO)))\ 等等。
例如,ONE 的展开会是:
(define ONE (S ZERO))
;; ONE 展开为:
;; (lambda (f)
;; (lambda (x)
;; (f ((ZERO f) x))))
;; 由于 ((ZERO f) x) 会返回 x,所以 ONE 进一步简化为:
;; (lambda (f)
;; (lambda (x)
;; (f x)))
这完美符合“将 f 应用一次到 x”的定义。
同样,TWO 会展开为 (lambda (f) (lambda (x) (f (f x)))),即 f 应用两次。
通过 ZERO 和 S,我们已经用纯粹的函数构建了整个自然数体系的“骨架”!下一步,我们将探索如何在这些丘奇数上进行算术运算,比如加法和乘法。
算术运算 (Arithmetic Operations): 加法与乘法
有了代表自然数的丘奇编码,我们自然想在它们之上定义算术运算,比如加法和乘法。
熟悉皮亚诺公理 (Peano Axioms) 的朋友可能还记得它们是如何递归定义加法和乘法的:
- 加法:
- 对于任意自然数
m ,m + 0 = m - 对于任意自然数
m 和n ,m + S(n) = S(m+n) (其中S(n) 是n 的后继,即n+1 )
- 对于任意自然数
- 乘法:
- 对于任意自然数
m ,m \times 0 = 0 - 对于任意自然数
m 和n ,m \times S(n) = (m \times n) + m
- 对于任意自然数
直接将这些递归定义转换为纯 λ 演算(尤其是没有显式递归操作符如 Y 组合子的情况下)会有些棘手,特别是处理像
幸运的是,丘奇编码的本质——“将一个函数应用特定次数”——为我们提供了一条更直接、更优雅的路径。
加法 (Addition): m + n
思考一下加法 m + n 的含义:它可以被看作是从数字 m 开始,连续应用“加一”(即后继函数 S)操作 n 次。
这完美契合了丘奇数 n 的定义!回忆一下,丘奇数 n 是一个函数 (lambda (f) (lambda (x) ...)),它会将 f 应用到 x 上 n 次。
- 我们想要应用的操作是“后继”函数
S。 - 我们想要应用这个操作的起点是丘奇数
m。 - 我们想要应用它的次数是
n次。
所以,m + n 可以通过将丘奇数 n 应用于后继函数 S 和初始值 m 来实现:
((n S) m)
因此,我们可以定义一个柯里化的加法函数 PLUS (或者叫 ADD):
在 Scheme 中:
(define PLUS
(lambda (m) ; PLUS 接受第一个加数 m
(lambda (n) ; 返回一个函数,该函数接受第二个加数 n
((n S) m)))) ; 将 S 应用到 m 上 n 次
让我们来验证一下 (PLUS ONE TWO) 是否等于 THREE:
PLUS应用于ONE:(PLUS ONE)返回(lambda (n) ((n S) ONE))。- 将此结果应用于
TWO:(((lambda (n) ((n S) ONE)) TWO))这会进行 β-规约,得到((TWO S) ONE)。 - 现在我们展开
TWO。TWO是(lambda (f) (lambda (x) (f (f x))))。 - 所以
(TWO S)返回(lambda (x) (S (S x)))。 - 最后,
((lambda (x) (S (S x))) ONE)应用于ONE: 这得到(S (S ONE))。 - 根据后继函数的定义,这正是
THREE!
加法的定义就是如此简洁!它直接利用了丘奇数 n 作为“迭代器”的特性。
乘法 (Multiplication): m × n
乘法 m × n 又如何用类似的思想表达呢?
它可以被看作是将“加 m”这个操作,应用 n 次,并且从 ZERO 开始累加。
也就是说:
- 我们想要重复应用的操作是 “将
m加到某个数上”。这个操作本身可以用我们刚定义的PLUS来表示:(PLUS m)。回忆一下,(PLUS m)是一个函数,它接受一个参数k并返回m+k(即((k S) m))。 - 我们想要应用这个操作的起点是
ZERO。 - 我们想要应用它的次数是
n次。
所以,m × n 可以通过将丘奇数 n 应用于“加 m”这个函数 (PLUS m) 和初始值 ZERO 来实现:
((n (PLUS m)) ZERO)
因此,柯里化的乘法函数 TIMES (或者叫 MULTIPLY) 定义为:
在 Scheme 中:
(define TIMES
(lambda (m) ; TIMES 接受第一个乘数 m
(lambda (n) ; 返回一个函数,该函数接受第二个乘数 n
((n (PLUS m)) ; 将 (PLUS m) 这个“加m”的函数...
ZERO)))) ; ...应用 n 次到 ZERO 上
这里的 (PLUS m) 是一个函数,它等待另一个参数 k 来计算 m+k。丘奇数 n 正好可以将这个“加 m”的函数重复作用。
例如,思考 (TIMES TWO THREE):
(TIMES TWO)返回(lambda (n) ((n (PLUS TWO)) ZERO))。- 将其应用于
THREE:((THREE (PLUS TWO)) ZERO)。 THREE会将(PLUS TWO)这个函数应用 3 次到ZERO上:- 第一次:
((PLUS TWO) ZERO)结果是TWO(0 + 2 = 2) - 第二次:
((PLUS TWO) TWO)结果是FOUR(2 + 2 = 4) (假设我们已经定义了FOUR) - 第三次:
((PLUS TWO) FOUR)结果是SIX(4 + 2 = 6) (假设我们已经定义了SIX) 这正是2 \times 3 = 6 。
- 第一次:
通过这些定义,我们看到丘奇编码不仅能表示数字,还能以一种非常优雅和本质的方式来定义它们之间的运算,这一切都仅仅建立在函数抽象和应用之上!
进入更深层次的构造:比较与减法
我们已经成功地用纯函数定义了布尔逻辑、自然数以及基本的加法和乘法运算。这本身已经是一个了不起的成就,展示了
正如你可能预料到的,这些操作的定义会比加法和乘法稍微复杂一些,它们需要我们更深入地挖掘丘奇编码的潜力,并引入一些新的辅助函数。
减法 · 引入
按照四则运算的顺序,接下来我们要构造的就是减法,它是加法的逆运算。
面对这个问题,你第一时间的想法是什么?很有可能你会想到“前驱函数”(即找到
这听起来似乎直接,例如通过求反函数。然而,并非所有函数都拥有反函数,且求取反函数本身也相当复杂。我们需要另辟蹊径。
从“加法的逆运算”这个角度思考
然而,这又引入了一个新问题:我们如何判断两个丘奇数是否“相等”?这是一个我们尚未定义的关键谓词。
有人可能会说:“如果
别担心,我们将逐步解决这些问题。
判断是否为零 (ZERO?):一个重要的边界条件
要判断任意两个自然数是否相等,或者进行更复杂的算术运算,我们至少需要能够判断一个数是否为零。这是许多递归定义和条件判断的基础。
我们可以通过以下方式巧妙地定义 ZERO? 谓词:
在 Scheme 中:
(define ZERO?
(lambda (n)
((n (lambda (x) FALSE)) TRUE)))
这个定义的巧妙之处在于它利用了丘奇数自身的特性:
-
回忆一下,丘奇数
ZERO定义为\lambda f . \lambda x . x 。如果参数
n是ZERO,那么(ZERO (lambda (x) FALSE))会忽略掉(lambda (x) FALSE)这个函数,直接返回一个恒等函数\lambda y . y 。于是整个表达式
(ZERO? ZERO)变为((lambda (y) y) TRUE),其结果自然是TRUE。 -
如果参数
n是一个非零的丘奇数(比如ONE,TWO, ...),它代表将第一个参数(一个函数)应用若干(非零)次。那么
(n (lambda (x) FALSE))意味着将(lambda (x) FALSE)这个“无论输入是什么都返回FALSE”的函数至少应用一次到某个初始值上。其最终效果是得到一个“总是返回FALSE”的函数(可以认为是\lambda y . \text{FALSE} )。于是整个表达式
(ZERO? n)(当n非零时)变为((\lambda y . FALSE) TRUE),其结果便是FALSE。
这样,我们就得到了一个可靠的 ZERO? 测试。
构造数据结构的基础——丘奇对 (Church Pairs)
在定义更复杂的操作(如前驱函数)之前,我们需要一种方式来将两个值组合成一个单一的复合数据单元。丘奇对 (Church Pairs) 提供了这样的机制,它类似于许多编程语言中的 pair 或二元元组。
一个丘奇对通过一个高阶函数来表示,这个高阶函数接受一个“选择器”函数作为参数。当我们用两个值 a 和 b 构造一个序对时,这个序对函数会“包裹”住 a 和 b。当它被一个选择器函数 s 调用时,它会将 a 和 b 传递给 s,即执行 (s a b)。
构造序对的函数 CONS 定义如下:
在 Scheme 中:
(define CONS
(lambda (first-element)
(lambda (second-element)
(lambda (selector-function)
(selector-function first-element second-element)))))
要从序对中提取元素,我们需要合适的选择器。丘奇布尔值 TRUE (FALSE (
CAR(获取第一个元素):\text{CAR} := \lambda p . (p \ \text{TRUE}) (define CAR (lambda (a-church-pair) (a-church-pair TRUE)))CDR(获取第二个元素):\text{CDR} := \lambda p . (p \ \text{FALSE}) (define CDR (lambda (a-church-pair) (a-church-pair FALSE)))丘奇对是构建更复杂数据结构(如列表)和实现某些算法(如下文的前驱函数)的关键。
前驱函数 (PRED):寻找 n-1
前驱函数 PRED 的目标是计算 ZERO,其前驱仍然是 ZERO (即
直接从丘奇数“减去”一次函数应用是困难的。但我们有一个巧妙的思路:考虑一个序列,例如
我们不必实际构造一个无穷列表。关键在于定义一个状态转换函数,该函数接受当前状态(一个序对),并返回序列中的下一个状态。丘奇数
我们将状态表示为一个序对 (current-predecessor-value, flag):
current-predecessor-value:表示当前计算到的前驱值。flag:一个状态标记。ZERO表示这是迭代的初始步骤(对应输入数字 0 或 1 的情况),ONE表示是后续的常规步骤。
初始状态为 (CONS ZERO ZERO)。
状态转换函数 PHI_PRED_STEP 定义为:
在 Scheme 中:
(define PHI_PRED_STEP
(lambda (p) ; p 是形如 (current-predecessor-value, flag) 的序对
((ZERO? (CDR p)) ; 检查 flag 是否为 ZERO
(CONS ZERO ONE) ; 如果是,下一个状态是 (ZERO, ONE)
(CONS (S (CAR p)) ONE)))) ; 否则,下一个状态是 (S current-predecessor-value, ONE)
这个转换逻辑是:
- 如果
flag是ZERO(初始状态,意味着我们正在处理输入为ZERO或ONE的情况的“第一步迭代”):下一个状态的current-predecessor-value保持ZERO,并将flag置为ONE。 - 如果
flag是ONE(后续状态):下一个状态的current-predecessor-value是前一个值的后继(S (CAR p)),flag保持ONE。
有了初始状态和转换函数,PRED n 就是将 PHI_PRED_STEP 应用 (CONS ZERO ZERO) 上,然后取结果序对的第一个元素:
在 Scheme 中:
(define PRED
(lambda (n)
(CAR ((n PHI_PRED_STEP) (CONS ZERO ZERO)))))
让我们验证一下:
(PRED ZERO):PHI_PRED_STEP应用 0 次,结果是(CAR (CONS ZERO ZERO))即ZERO。(PRED ONE):- 初始状态
p0 = (CONS ZERO ZERO)。 - 应用一次
PHI_PRED_STEP:(PHI_PRED_STEP p0)。因为(CDR p0)是ZERO,所以结果是p1 = (CONS ZERO ONE)。 (CAR p1)是ZERO。所以(PRED ONE)是ZERO。
- 初始状态
(PRED TWO):p0 = (CONS ZERO ZERO)。- 第一次应用得到
p1 = (CONS ZERO ONE)。 - 第二次应用
(PHI_PRED_STEP p1)。因为(CDR p1)是ONE(非ZERO),所以结果是p2 = (CONS (S (CAR p1)) ONE) = (CONS (S ZERO) ONE) = (CONS ONE ONE)。 (CAR p2)是ONE。所以(PRED TWO)是ONE。
这完美地实现了前驱函数的功能。
减法 · 截断减法 (SUB)
有了前驱函数 PRED,定义减法就变得直接了。我们要实现的是截断减法 (Truncating Subtraction),即