Church编码、参数化与Yoneda引理
摘要
深入探讨Church编码的理论基础,并将其与System F和多态lambda演算背景下的参数化及Yoneda引理联系起来。
<p><a href="https://lobste.rs/s/ulkriy/church_encoding_parametricity_yoneda">评论</a></p>
查看缓存全文
缓存时间: 2026/05/21 16:16
# 丘奇编码、参数性与米田引理
来源:https://blog.wybxc.cc/blog/parametricity/
我依然记得多年前初次接触函数式编程时的震撼。那一刻我得知,自然数可以仅在语言内部构造出来:
``
data Nat = Zero | Succ Nat
``
接着我了解到,所有计算都可以通过函数来表达(Lambda演算),而递归本身可以被编码为令人着迷的Y组合子:
``
Y = λf. (λx. f (x x)) (λx. f (x x))
``
还有丘奇数,每个数都变成一个函数:
``
0 = λs. λz. z
1 = λs. λz. s z
2 = λs. λz. s (s z)
``
丘奇编码将自然数表示为函数;每个数 `n` 接受一个后继函数和一个初始点,然后将后继应用 `n` 次。
但这背后的逻辑是什么?为什么偏偏是这些Lambda项?
列表也有丘奇编码:
``
nil = λc. λn. n
cons = λx. λxs. λc. λn. c x (xs c n)
``
模式看似相似,但细节不同。很长一段时间里,我认为事情就是这样:一个聪明的技巧,每遇到新情况就重新发现一次。
经过多年学习,我终于理解了更深层的故事。丘奇编码展现了数据类型、多态性和范畴论之间的深层联系。一旦你看到这些联系,编码的形式就不可避免了,其结果比技巧本身更优雅。
在这篇文章中,我想追溯这些联系。我们将从类型化环境中最简单的数据类型开始,观察一种模式的浮现,然后逐步构建起一系列工具(参数性、代数数据类型、F-代数和米田引理),直到丘奇编码呈现出它是由数学所要求的。
### System F
为了捕捉丘奇编码背后的模式,我们需要类型。无类型Lambda演算给出了项,但没有词汇来描述这些“项”是什么。这就是简单类型Lambda演算(STLC)出现的原因——每个项都有一个类型。纯STLC中唯一的类型构造子是函数箭头 `A → B`¹。但STLC几乎立刻碰壁。一个丘奇数必须接受“任何”类型作为其载体:关键在于 `2` 无论是用来数苹果还是函数都应该表现相同。STLC无法表达这一点。STLC中的每个函数都有固定的、单态的类型。
²但STLC几乎立刻碰壁。一个丘奇数必须接受“任何”类型作为其载体:关键在于 `2` 无论是用来数苹果还是函数都应该表现相同。STLC无法表达这一点。STLC中的每个函数都有固定的、单态的类型。
这就是说,有经验的程序员会警觉:我们需要“泛型”。我们需要对类型而言是多态的函数。给予我们这一点的演算是“System F”,即多态Lambda演算。
思路是这样的:在System F中,函数可以将类型作为参数。类型抽象写作 `ΛX.t`,类型应用写作 `f[X]`。System F的类型包括类型变量 `X`、函数类型 `A → B` 和全称量化 `∀X.A`²。如你所见,我们的System F始于一个空宇宙。在STLC中,我们会声明像 `Unit` 和 `Bool` 这样的基类型来起步。但在System F中,有趣的事发生了:我们可以仅用函数和多态性从零开始构造这些类型!我们可以将 `Unit` 和 `Bool` 编码为多态函数,而不需要任何数据类型专用语法。
让我陈述一个原则:一个数据类型可以通过它的消费方式来等价地表示。我暂时无法解释这个说法背后的逻辑,但让我们先把它放在一边,看看它对 `Unit` 是如何起作用的³。
如果你持有一个 `Unit` 类型的值,你能用它做什么?消费 `Unit` 值的唯一方式就是忽略它,然后返回某个与它无关的东西。
假设你想写一个通用的消费者:对于任何目标类型 `X`,它将一个 `Unit` 转化为一个 `X`。因为我们不知道 `X` 的任何信息,我们无法凭空捏造出一个 `X` 类型的新值。我们唯一能做的就是返回一个我们已经拥有的 `X`。但我们从哪里获得一个 `X` 呢?我们没有!我们应该将它作为一个参数接收进来。因此,`Unit` 的通用消费者的类型签名是:
`∀X. X → X`,读作:“对于任何类型 `X`,给我一个 `X`,我还你一个 `X`。”
这个类型有什么居民?可以轻松想到一个:恒等函数⁴。它接受一个 `X` 并原样返回:
``
id = ΛX. λx. x
``
这个函数就是 `Unit` 的丘奇编码。
让我们对 `Bool` 尝试同样的推理。`Bool` 有两个构造子:`True` 和 `False`。
消费一个 `Bool` 需要什么?你需要一个目标类型 `X`,并且需要 `True` 和 `False` 各自的解释:两个 `X` 类型的值。然后,根据实际的 `Bool` 值代表哪个构造子,你返回对应的解释。
作为类型签名:
``
∀X. X → X → X
``
读作:“对于任何类型 `X`,给我两个 `X`,我还你一个 `X`。”
我们可以找到这个类型的两个居民,对应消费 `Bool` 的两种方式:
``
true = ΛX. λx. λy. x
false = ΛX. λx. λy. y
``
一个选择第一个参数,另一个选择第二个参数。它们恰好对应 `Bool` 的两个值。
观察这两个例子,一种模式浮现出来:
总结一下目前的模式:System F中数据类型的丘奇编码是一个多态函数,其参数与构造子匹配。它是该类型的一个通用消费接口。这个调查对 `Unit` 和 `Bool` 非常完美,因为它们的构造子不携带数据。但是像 `Nat` 这样的类型呢?它的 `Succ` 构造子有一个递归参数。我们会在后续章节讨论如何处理,但在此之前,我们需要理解使这个模式起作用的机制。
### 参数性
真正令人瞩目的是,对于 `∀X. X → X` 我们只能找到一个实现,即恒等函数。同样,对于 `∀X. X → X → X`,我们只能找到两个实现:`true` 和 `false`。System F的类型系统不允许其他实现。
一旦你接受了这个事实,我们继续。
要理解“为什么”类型系统如此精确地限制居民,我们需要审视System F多态性背后的机制。System F中的多态性是“参数化”的:一个多态函数得到一个类型参数,但它不能检查它。它不能根据“我处理的是 `Int` 还是 `Bool`”来分支。它必须将类型视为黑箱。这个限制恰恰是巨大力量的来源。
John Reynolds 在1983年证明了“参数性定理”(也称“抽象定理”)。这里给出一个紧凑的陈述:
> 令 `f: ∀X. τ(X)` 为System F的封闭项,其中 `τ(X)` 是一个类型表达式。对于任意类型 `A, B` 以及任意关系 `R ⊆ A × B`,令 `τ(R) ⊆ τ(A) × τ(B)` 为从 `τ` 的结构递归定义的关系提升。如果 `(a, b) ∈ τ(R)`,则 `(f[A] a, f[B] b) ∈ τ(R)`。
这个表述可能看起来很抽象,但其核心信息很简单:一个多态函数必须“保持”你与类型参数相关联的任意关系。它无法检测它所实例化的具体类型结构。一个推论是,你可以从类型签名本身推导出函数必须满足的方程,而无需参考实现。Philip Wadler 后来称这些为“免费定理”。
让我们从一个你可以亲手验证的特例开始。
**主张**:`∀X. X → X` 只有恒等函数,即任何 `f: ∀X. X → X` 必须满足对所有 `A` 和 `a` 有 `f[A] a = a`。
**证明**:我们来看看参数性对 `f: ∀X. X → X` 说了什么。取两个任意类型 `A` 和 `B`,并在它们之间选择任意关系 `R ⊆ A × B`。定理要求我们将 `R` 提升通过类型表达式 `τ(X) = X → X`。提升是这样工作的:两个函数 `hA: A → A` 和 `hB: B → B` 被 `τ(R)` 关联起来,恰好当它们把 `R` 相关的输入映射到 `R` 相关输出时,即如果 `(x, y) ∈ R`,则 `(hA(x), hB(y)) ∈ R`。直观上说,`hA` 和 `hB` 同步地保持 `R`。
现在,参数性说:因为 `f` 是多态的,它在 `A` 和 `B` 处的实例化必须被这个提升后的关系关联。用符号表示:`(f[A], f[B]) ∈ τ(R)`。展开来说:对任意 `(x, y) ∈ R`,有 `(f[A] x, f[B] y) ∈ R`。
关键步骤来了。固定一个类型 `A` 和一个值 `a: A`。我们的目标是证明 `f[A] a = a`。定义一个非常具体的关系:
`R = {(a, a)} ⊆ A × A`
这个关系只将 `a` 与自身配对。取 `B = A`。由于 `(a, a) ∈ R`,参数性条件迫使:
`(f[A] a, f[A] a) ∈ R`
但 `R` 恰好只包含一个对:`(a, a)`。因此 `f[A] a` 必须是 `a`。证毕。
注意刚才发生了什么:参数性迫使 `f` 成为恒等函数,即使 `f` 对它被应用的类型一无所知。仅凭类型签名就没有给它其他选择。
类似的论证对 `∀X. X → X → X` 也有效。这个类型的函数接收两个未知类型 `X` 的值,必须返回一个 `X`。由于对 `X` 没有任何可用操作,唯一可能的行为是“总是返回第一个参数”或“总是返回第二个”。这就是 `true` 和 `false`⁵。
现在是关键部分。参数性对一般形式 `f: ∀X. (F X → X) → X` 说了什么?这里 `F` 是任何类型算子;可以把它看作是描述数据类型的结构。我们得到的免费定理是:对于任何 F-代数同态⁶ `h: (A, fA) → (B, fB)`,
`h (f[A] fA) = f[B] fB`
这个方程在后文中扮演核心角色,我们将用它证明丘奇编码与其编码的数据类型等价。
### 代数数据类型与函子
手握参数性工具,我们现在回到主线。回忆一下我们停在哪里:`Unit` 和 `Bool` 揭示了一种模式(丘奇编码是一个多态函数,其参数与构造子匹配),但我们还没有办法处理像 `Nat` 这样的递归类型。为了扩展这个模式,我们需要一种系统性的语言来描述数据类型的结构,使构造子及其参数的构成显式化。这就引入了“代数数据类型”(ADT)。
我们知道元组是“积类型”,而带标签的联合是“和类型”。一个代数数据类型从根本上说就是由这两种运算构建的,即“积的和”。每个构造子是其字段的积;整个类型是构造子之间的和。如果一个构造子不携带数据,那么它就是空积,也就是 `Unit` 类型。
我们可以用一个简单的代数记号来精确表达:
- 1(单元类型 `Unit`):有恰好一个值的类型。它是积的幺元:`1 × A ≅ A`。
- `A + B`(和类型):一个值要么是 `A` 要么是 `B`,并带有标签。
- `A × B`(积类型):一个 `A` 和一个 `B` 的对。
在这个记号下:
```
Unit = 1
Bool = 1 + 1
Nat = 1 + Nat
```
最后一行读作:自然数要么是 `Zero`(`1` 分支,不携带信息),要么是 `Succ`(包装另一个 `Nat`)。这个方程是递归的,我们用 `μ` 绑定器来理解:
```
Nat = μX. 1 + X
```
同样的方法适用于列表:
```
List(a) = μX. 1 + a × X
```
这里 `Nil` 是 `1` 分支,`Cons` 是 `a × X` 分支。
但代数不仅限于记号。类型惊人地遵循熟悉的代数定律。例如,分配律:
```
a × (b + c) ≅ a × b + a × c
```
这里 `≅` 表示类型同构:存在双向见证等价性的函数。
将这条定律读作类型:`(a, Either b c)` 同构于 `Either (a, b) (a, c)`。证据是我们可以编写以下函数:
``
f :: (a, Either b c) -> Either (a, b) (a, c)
f (a, Left b) = Left (a, b)
f (a, Right c) = Right (a, c)
g :: Either (a, b) (a, c) -> (a, Either b c)
g (Left (a, b)) = (a, Left b)
g (Right (a, c)) = (a, Right c)
``
那函数类型呢?我们能将其表示为代数运算吗?
让我宣布:“函数就是指数。”考虑两个有限类型 `A` 和 `B`,分别有 `|A|` 和 `|B|` 个居民。有多少个函数 `A → B` 存在?对于每个输入 `a ∈ A`,我们可以独立地从 `B` 中选择任意一个输出。那就是 `|B|` 种选择重复 `|A|` 次:`|B|^|A|` 种可能性。这提示我们将函数类型写为指数:
```
A → B ≅ B^A
```
Currying 于是就变成了熟悉的指数定律:
```
C^{A × B} ≅ (C^B)^A
```
而走出和类型的函数遵循自己的分配律:
```
C^{A + B} ≅ C^A × C^B
```
现在让我们回到自然数的丘奇编码。我们在无类型 Lambda 演算中见过丘奇数:
``
0 = λs. λz. z
1 = λs. λz. s z
2 = λs. λz. s (s z)
``
我们想给它们在 System F 中赋予类型。假设 `z` 的类型是 `X`。我们期望 `0` 和 `1` 的类型相同,因此 `s z` 应该与 `z` 具有相同的类型。所以 `s` 必须具有类型 `X → X`。这就给出了自然数丘奇编码的类型:
```
ChurchNat = ∀X. X → (X → X) → X
```
它看起来与 `Unit` 和 `Bool` 的丘奇编码类型有些许不同。第二个参数是一个函数而不是一个值。是否存在一个单一的形式可以涵盖所有这些?
让我们更仔细地检查编码的内部部分。
Currying 告诉我们,一个函数 `X → (X → X) → X` 等价于一个函数 `(X, X → X) → X`。而我们已经知道元组 `(A, B)` 可以写作 `A × B`,函数类型 `A → B` 可以写作 `B^A`。然后我们可以做一些代数操作:
```
X → (X → X) → X
≅ X × X^X → X
≅ X^{1+X} → X // 因为 X^1 = X, X^(1+X) ≅ X^1 × X^X
≅ ((1+X) → X) → X // 反过来
```
因此完整的丘奇编码变成:
```
ChurchNat ≅ ∀X. ((1+X) → X) → X
```
现在看看表达式 `1+X`。这正是 `Nat = μX. 1+X` 递归类型定义的主体。换句话说,丘奇编码的形式是:
```
∀X. (F X → X) → X
```
其中 `F X = 1+X`。这里的 `F` 是一个“函子”,它捕获了数据类型形状的轮廓。让我们验证一下这对我们之前的例子是否成立:
- 对于 `Unit`:函子为 `F X = 1`。那么 `(F X → X) → X = (1 → X) → X ≅ X → X`。匹配 `∀X. X → X`。
- 对于 `Bool`:函子为 `F X = 1+1`。那么 `(F X → X) → X = (1+1 → X) → X ≅ (X × X) → X ≅ X → X → X`。匹配 `∀X. X → X → X`。
这里我们得到了一个通用模式:对于代数数据类型 `T = μX. F X`,其丘奇编码为 `∀X. (F X → X) → X`。
在下一节中,我们将看到丘奇编码 `∀X. (F X → X) → X` 是 **F-代数** 通用消费者的类型,正是这个视角,通过参数性和米田引理,将引领我们对丘奇编码的真实含义达成最深刻的理解。
### F-代数
我们已经来到了一个转折点。上一节以 `∀X. (F X → X) → X` 这个统一了 `Unit`、`Bool` 和 `Nat` 丘奇编码的形式收尾。但这个形式仍然像是凭空而来。为什么偏偏是这个符号排列?为了回答这个问题,我们需要给各个部分命名。这就是范畴论登场的时刻。
我们从小的开始。一个“范畴”是一族对象和它们之间的箭头,并满足组合和恒等的合理规则。对程序员而言,最自然的例子是 `Set` 范畴:对象是类型,箭头是函数,组合是函数组合,恒等箭头是 `id`。
一个“函子” `F` 是范畴之间的映射。在我们的世界中,`F` 将每个类型 `A` 映射到类型 `F A`,将每个函数 `f: A → B` 映射到函数 `F f: F A → F B`,并且保持组合和恒等:
```
F (g ∘ f) = F g ∘ F f
F id = id
```
这正是 `F X = 1+X` 一直以来在做的事:它从一个旧类型构建新类型,“并且”提升函数来作用于该结构内部⁷。
一个“自然变换” `η` 介于两个函子 `F` 和 `G` 之间,是将 `F` 的形状转换为 `G` 的形状的一种方式,且不依赖于具体的值。
相似文章
非凡序数
在λ演算中对序数的各种编码进行学术探讨,比较包括Mackie和Parigot编码在内的线性、仿射和非线性系统。
无依赖类型的条件表达式
本文展示了一种技巧,使用Haskell中的Church编码和可重绑定语法来实现无依赖类型的条件表达式,并通过简单类型推断证明其可行性。
"Five-Point Haskell": Unconditional Election via Parametricity
A blog post explaining the principle of 'Unconditional Election' in Haskell, using parametric polymorphism and free theorems to enforce behavior via type signatures.
无参数编码器对RDB基础模型仍然可行
本文认为无参数编码器对关系数据库基础模型仍然可行,提供了对可训练编码器参数的理论限制以及跨基准测试任务的实证验证。
揭秘类型(及一些悖论的解惑)
类型理论为编程语言基础增加了不必要的复杂性,并提出基于关系成员的更简单观点。