开始之前:如何使用本教程
本教程面向这样一类读者:你已经写过一些程序,知道变量、函数、条件分支这些基本概念,但还没有系统学习过编程语言理论和类型系统。如果你读过一些函数式编程资料,或者听说过 Lambda 演算(Lambda Calculus)、类型推断(type inference)、子类型(subtyping)、线性类型(linear type)这些词,却总觉得概念零散、彼此之间缺少一条主线,那么这份教程就是为你准备的。
本教程的目标不是把所有高级主题一次讲完,而是建立一条适合自学的入门路径:
- 先学会用形式化的方式描述语言;
- 再理解 Lambda 演算这个最小计算模型;
- 然后在这个基础上引入类型系统;
- 最后逐步扩展到多态、子类型、类型推断和子结构类型等主题。
主要参考材料是:
- Benjamin C. Pierce,《Types and Programming Languages》(TAPL)
- Luca Cardelli,《Type Systems》
- Pierce 编,《Advanced Topics in Types and Programming Languages》(ATAPL)的相关章节
不过,这份教程并不是这些材料的逐章翻译或摘抄,而是按“先建立直觉,再进入形式化”的思路重新组织的学习材料。
本教程与参考材料的关系
为了帮助你建立稳定主线,本教程对参考材料做了有意识的重组,而不是按原书目录逐章平移:
- 第 0–6 章主要围绕 TAPL 前半部分的核心路线展开,并吸收 Cardelli 对“类型系统是什么、它在防什么错误”的经典表述;
- 第 7–10 章继续引入多态、子类型、类型推断和子结构类型系统,但目标仍然是建立可自学的整体框架,而不是完整覆盖 TAPL、ATAPL 或 PFPL 的全部技术细节;
- 附录部分则承担三类任务:
- 统一术语与记号;
- 提供自检与学习建议;
- 展示若干值得了解、但不必作为正文前置条件的进阶主题。
因此,更准确地说,本教程是:
以经典教材为基础、按“语法 → 语义 → 类型 → 元理论”主线重新组织的一份入门教程。
你需要具备哪些基础?
编程基础
最低要求并不高。你只需要:
- 用过任意一门编程语言;
- 能看懂简单的函数定义和函数调用;
- 知道什么是变量、返回值、条件分支。
如果你完全没有编程经验,建议先补一个非常基础的入门教程。Python、JavaScript、TypeScript、Java、C、OCaml 都可以,语言本身并不重要,重要的是你对“函数接受输入并产生输出”这件事已经不陌生。
数学基础
本教程主要依赖离散数学里最基础的部分:
- 集合
- 函数
- 命题逻辑中的“且 / 或 / 蕴含”
- 简单的归纳法
不要求你先学完高等数学,也不要求你有逻辑学背景。第一章会把后续真正要用到的数学工具重新梳理一遍。
建议怎样阅读本教程?
本教程大致分成四个层次。
第一层:形式化工具
- 第零章:为什么需要类型系统
- 第一章:数学基础
- 第二章:形式文法与 BNF
这一部分的任务,是让你先接受一种思维方式:
讨论语言和类型系统时,我们不能只靠“差不多能懂”的自然语言描述,而要学会用明确的语法、规则和证明来表达。
第二层:计算模型
- 第三章:Lambda 演算基础
- 第四章:Lambda 演算中的计算
这一部分建立“程序如何计算”的最小模型。后面的大多数类型系统,都是在某种 Lambda 演算之上扩展出来的。
第三层:核心类型系统
- 第五章:类型系统核心概念
- 第六章:一阶类型系统
这一部分会回答最关键的问题:
- 什么叫“一个项具有某个类型”?
- 类型规则究竟在说什么?
- 为什么类型系统可以排除某些运行时错误?
第四层:重要扩展
- 第七章:二阶类型系统
- 第八章:子类型
- 第九章:类型推断
- 第十章:子结构类型系统
这里的主题彼此有关,但并不要求你一次完全掌握。第一次阅读时,建议把注意力放在每章的核心动机和最基本规则上;形式细节可以在第二遍阅读时慢慢回看。
阅读时应该重点抓什么?
每一章里通常会同时出现三类内容:
- 直觉:为什么要引入这个概念?它解决了什么问题?
- 形式化定义:它的语法、规则、定理是什么?
- 语言对照:在真实语言里,大概有什么相似做法?
如果你是第一次接触这类内容,建议优先确保自己能回答下面三个问题:
- 这个概念想解决什么问题?
- 它的最小形式定义是什么?
- 我能否手动做一个最基本的例子?
比如:
- 学 Lambda 演算时,能否自己算自由变量和替换?
- 学类型规则时,能否自己画一个简单的推导树?
- 学类型推断时,能否跟着一个例子生成约束并做合一?
和“看懂了”相比,能亲手做一个最小例子往往更重要。
本教程常用的记号与约定
完整的术语说明见附录A,完整的符号表见附录B。建议把这两个附录当作阅读时的常驻参考:
- 正文中首次引入的重要术语,通常会在括号中给出英文原文;
- 如果你一时忘了某个术语的边界,优先查附录A;
- 如果你一时忘了某个记号的读法和语境,优先查附录B;
- 如果你一时分不清“值 / 范式 / 卡住”“类型检查 / 类型推断”“Church 风格 / Curry 风格”这类术语差别,优先查附录A;
- 如果你一时忘了
$\Gamma \vdash t : T$、$\longrightarrow$、$\forall$、$S <: T$这些记号怎么读、怎么用,优先查附录B。
这里先列出最常见、最值得提前熟悉的几个。
| 记号 | 读法 | 直觉含义 |
|---|---|---|
| “lambda x 点 t” | 一个以 x 为参数、函数体为 t 的函数 | |
| “把 应用于 ” | 函数调用 | |
“把 中的 x 替换成 s” | 替换 | |
| “在环境 下, 的类型是 ” | 类型判断 | |
| “一步归约到” | 单步计算 | |
| “对所有” | 全称量化 | |
| “存在” | 存在量化 |
其中要特别注意两点:
$\mapsto$的字面意思是“映射到”;在不同上下文里,它可能表示替换、环境中的绑定,或更一般的映射关系。$\vdash$常读作“推出”或“可判断为”;它通常不是普通的逻辑蕴含,而是在某套规则系统内部的可推导关系。
最后一点建议
第一次读这类材料时,遇到公式多、符号多是正常的。不要把目标设成“第一次就完全掌握所有形式细节”,而是先建立下面这条主线:
语法定义了程序长什么样;语义定义了程序怎么计算;类型系统定义了哪些程序被允许;元理论则说明这些规则为什么可靠。
如果你能沿着这条主线把各章串起来,后面的细节就有落脚点了。
接下来,从第零章开始,我们先回答一个最自然的问题:
为什么程序语言需要类型系统?
第零章 导论:为什么需要类型系统
第零章 导论:为什么需要类型系统
阅读提示
本章是全书主线的起点。阅读时如果你想快速确认“卡住”“类型安全”“类型判断”“静态 / 动态”“安全 / 不安全”等术语,建议配合:
- 附录A《术语表》
- 附录B《符号速查表》
这一章不急着进入公式和推导规则,而是先回答一个更根本的问题:
为什么我们需要类型系统?
如果不先回答这个问题,后面学到的类型判断、类型安全、子类型、类型推断,很容易变成一堆彼此孤立的技术名词。导论的任务,就是先把整本教程的主线建立起来。
0.1 从一个简单错误开始
考虑下面这个 JavaScript 函数:
function add(a, b) { return a + b; }
add(3, "hello")
这段程序在语法上完全合法,但它的行为未必符合你的意图:
- 如果你想做数值加法,那么这里出了问题;
- 问题并不是在写代码时立刻暴露的,而是在运行时才表现出来。
类型系统最直观的价值,就是把一部分这类问题提前到运行之前发现。
function add(a: number, b: number): number { return a + b; }
add(3, "hello")
在这个版本里,编译器会直接报告:第二个参数不是 number。程序甚至不必真正运行,错误就已经被指出来了。
这就是类型系统最常见的第一印象:
它试图在程序运行之前,排除某些本来会在运行时才出现的错误。
当然,这个说法还是比较粗糙的。下面我们把它说得更精确一点。
0.2 类型系统是什么?
Cardelli 对类型系统有一个经典定义:
类型系统是一种可判定的语法方法,通过对程序短语按其计算值的种类进行分类,来证明程序的某些行为不会发生。
这个定义里的每个词都很重要。
| 关键词 | 含义 | 为什么重要 |
|---|---|---|
| 可判定 | 存在一个总能在有限时间内结束的检查过程 | 否则编译器可能永远检查不完 |
| 语法方法 | 主要根据程序的结构来分析,而不是先运行程序 | 类型检查通常发生在执行之前 |
| 分类 | 把程序片段分到不同“类型”之下 | 例如布尔值、函数、积类型等 |
| 某些行为不会发生 | 类型系统不是万能的,它只能排除特定的一类错误 | 类型系统总是带着边界工作 |
这里最值得强调的是最后一点:类型系统保证的从来不是“程序绝对正确”,而是“程序不会发生我们选定的那类错误”。
例如,一个程序即使类型完全正确,也仍然可能:
- 返回了错误的业务结果;
- 进入死循环;
- 因为算法太慢而不可用;
- 因为逻辑漏洞而做出不合预期的事。
所以学习类型系统时,最重要的心态之一是:
类型系统很重要,但它解决的是程序可靠性中的一部分问题,而不是全部问题。
0.3 为什么类型系统必须“保守”?
你可能会问:
既然目标是排除运行时错误,为什么类型系统不直接精确分析程序的所有行为?
直观上的答案是:那通常做不到,或者代价太高。
更深层的背景来自可判定性理论。Rice 定理告诉我们:关于程序语义行为的很多非平凡性质,一般来说是不可判定的。你可以把它理解为一种“坏消息”:
- 如果我们要求类型系统精确预测程序所有有意义的行为,
- 那它往往就不再是一个总能结束的检查过程。
因此,类型系统通常采取一种折中策略:
- 不去精确模拟程序所有运行行为;
- 而是只根据程序结构做一种保守近似。
“保守”意味着两件事:
- 它宁可拒绝一些其实运行时没有问题的程序;
- 也要尽量避免放过那些会落入禁止错误的程序。
例如,下面这段代码从运行角度看其实没有问题:
if (true) { 42 } else { "hello" }
但如果某个极其简单的类型系统只允许 if 的两个分支具有相同类型,那么它就会拒绝这段程序。原因不是程序真的会出错,而是这个类型系统太粗糙,只能用“两个分支类型一致”这种简单规则来换取可判定性和实现简洁性。
所以“被类型系统拒绝”并不一定意味着“运行时一定出错”;它也可能意味着:
这个类型系统选择了一种更简单、更安全、也更保守的近似方式。
0.4 类型系统到底在防什么错误?
在本教程里,我们会频繁使用一个词:卡住(stuck)。
先给一个工作直觉:
一个程序如果既不是一个被当前求值规则视为“计算完成”的结果,又无法按照语言的求值规则继续走下去,我们就说它“卡住”了。
这里先故意说得比较直觉。更严格地说,后面我们会区分:
- 值(value):相对于某个求值策略,被视为“计算完成”的项;
- 范式(normal form):相对于某个归约关系,已经无法继续归约的项;
- 卡住(stuck):既不是值,又不能继续前进的项。
更稳妥地说,本章这里只给出“卡住”的工作定义;第 4 章会把它放回具体求值关系中说明,第 5 章再把它和“类型安全”正式连起来。若你想先快速确认这些术语,也可以配合附录A“术语表”和附录B“符号速查表”一起看。
例如,把布尔值当函数调用,就是一种典型的卡住情形:
true 0
这个式子不是一个正常结果,也没有任何合理的“下一步计算规则”。后面几章我们会把这种现象形式化,并把它和类型安全联系起来。
Cardelli 还区分了不同种类的执行错误。用最粗略的方式说,可以把错误分成两类:
- 程序显式报错并终止:例如除零异常、断言失败;
- 程序进入未预期状态:例如类型混淆、非法内存访问、协议顺序错误。
不同语言、不同语义模型,会把“什么算错误”画在不同边界上。因此更稳妥的说法不是“类型系统保证绝不出错”,而是:
相对于我们选定的语义和错误模型,良类型程序不会发生那类被禁止的错误。
这就是后面“类型安全”要表达的核心思想。
0.5 静态类型、动态类型与安全性
在日常讨论里,人们常把“静态 / 动态”和“安全 / 不安全”混在一起,但它们其实是两个不同维度。
静态 vs 动态
- 静态类型:主要在运行之前检查程序;
- 动态类型:主要在运行过程中检查程序。
安全 vs 不安全
- 安全:语言设计会阻止某类未定义或不可接受的错误行为;
- 不安全:某些错误行为可能直接暴露给程序员,语言不保证彻底拦住它们。
这两个维度并不完全重合。
- 一个语言可以是静态而安全的;
- 也可以是动态而安全的;
- 还可以是静态但仍允许某些不安全操作的;
- 更可以是把很多责任交给程序员自己承担的。
因此,下面这种说法就太粗糙了:
“静态类型语言一定更安全。”
更准确的说法是:
- 静态类型系统常常能在运行前排除更多错误;
- 但完整的安全性往往还依赖运行时检查、内存模型、异常机制、边界检查、模块系统等其他设计;
- 而动态语言也完全可以通过运行时检查来维持某种安全保证。
这也是为什么本教程后面会区分:
- 类型规则本身在保证什么;
- 整个语言运行时系统在保证什么。
本教程后文主要关心的是:静态类型系统如何与操作语义配合,建立可证明的安全保证;至于完整语言实现中的所有安全机制,则只在需要时点到为止。
0.6 本教程接下来会怎样推进?
既然类型系统的目标是“排除某些不良运行行为”,那么我们就需要按顺序解决四个问题:
-
程序长什么样?
- 需要语法工具
- 对应第1章和第2章的一部分准备工作
-
程序怎么计算?
- 需要求值规则、操作语义
- 对应第3章和第4章的 Lambda 演算
-
程序为什么算是“有类型”?
- 需要类型判断和类型规则
- 对应第5章
-
这些规则为什么可靠?
- 需要先在第4章明确“程序怎样前进”,再在第5章讨论类型安全、进展性与保持性,并在第6–10章继续扩展
- 对应第4章、第5章后半以及第6–10章;其中第 9 章还会回到一个新问题:如果项里不显式写出类型,系统如何自动恢复这些类型信息?
换句话说,本教程的大主线其实很简单:
先定义语言,再定义计算,再定义类型,最后证明这些定义彼此匹配。
如果你读到后面一时忘了术语或符号,可以随时回查:
- 附录A:术语表
- 附录B:符号速查表
它们并不替代正文,但很适合在阅读第 4 章“计算”与第 5 章“类型安全”时来回对照。
这条主线会在后面的章节里反复出现。
0.7 本章小结
这一章先建立了三个最关键的认识:
- 类型系统的任务不是证明程序“绝对正确”,而是排除某类被禁止的错误行为。
- 类型系统之所以保守,是因为程序语义的精确分析通常不可判定或代价过高。
- 后续所有形式化工作——语法、语义、类型规则和安全性证明——都是为了把“良类型程序不会卡住”这类直觉命题说清楚。
如果你读完这一章,已经能清楚地区分下面几件事,那就达成目标了:
- 静态类型和动态类型不是一回事;
- 安全和不安全也不是同一个维度;
- 类型系统是“程序可靠性的一部分”,不是“程序正确性的全部”。
回看导航
如果你读完本章后还觉得有些概念只是“听懂了大意”,但还没形成稳定主线,建议按下面顺序回看:
- 先回看本章的 0.2、0.4、0.5 三节,重新确认:
- 类型系统到底在防什么错误;
- 什么叫“卡住”;
- 为什么“静态 / 动态”和“安全 / 不安全”不是同一个维度。
- 若你对术语边界仍然模糊,回查附录A《术语表》。
- 若你读到后面章节时忘了本章埋下的主线,可以把本章和第 4 章“计算”、第 5 章“类型系统核心概念”连起来重看。
思考题
- 为什么“程序能运行”并不等于“程序没有类型问题”?
- 如果一个类型系统很保守,它可能拒绝哪类实际上运行正常的程序?
- “静态类型”和“安全”为什么不能简单画等号?
第一章 数学基础
第一章 数学基础
阅读提示
本章会频繁使用“集合”“关系”“归纳定义”“结构归纳法(structural induction)”“推导规则(inference rule)”“推导树(derivation tree)”等概念。若你在阅读时想快速回查术语与记号,建议配合:
- 附录A《术语表》中的 A.9“逻辑与元理论相关术语”
- 附录B《符号速查表》中的“逻辑与判断相关记号”
后面的章节会频繁出现集合、关系、归纳定义、推导规则这些概念。它们不是为了“让材料看起来更数学”,而是因为:
- 语法需要精确定义;
- 类型判断需要规则化表达;
- 类型安全需要证明。
这一章的目标不是系统讲授全部离散数学,而是把后续真正会用到的最小工具建立起来。
1.1 为什么类型系统需要数学工具?
学习类型系统时,你会不断遇到三类问题:
-
如何定义一个语言?
- 这需要归纳定义和形式文法。
-
如何说明一个判断是成立的?
- 这需要推导规则和推导树。
-
如何证明一个性质对所有程序成立?
- 这需要归纳法。
所以这一章的核心不是“数学知识本身”,而是下面这套工作流:
定义对象 → 写出规则 → 对规则与结构做证明。
1.2 集合:最基本的直觉工具
集合是类型理论里最常见的背景语言之一。
在很多入门场景下,我们会用“值的集合”来帮助理解类型。例如:
Bool可以直觉地理解成集合 ;- 一个积类型可以直觉地理解成两个集合的笛卡尔积;
- 一个和类型可以直觉地理解成两个集合的带标签并集。
要注意,这是一种有帮助的语义直觉,但它并不是所有类型理论中的统一定义。随着系统变复杂,类型未必总能简单等同于某个朴素集合。
1.2.1 常见集合操作
| 操作 | 记号 | 含义 |
|---|---|---|
| 并集 | 属于 或属于 | |
| 交集 | 同时属于 和 | |
| 差集 | 属于 但不属于 | |
| 子集 | 的每个元素都在 中 | |
| 幂集 | 的所有子集组成的集合 | |
| 笛卡尔积 | 所有有序对 的集合 |
例如,若 ,,则:
在后面的章节里:
- 笛卡尔积会帮助我们理解积类型;
- 集合包含会帮助我们直觉化地理解某些子类型关系;
- 幂集会在更高级的语义讨论里再次出现。
在某些语义模型中,子类型常可以用集合包含来直觉理解;但这是一种语义视角,不应直接当作所有类型系统中的定义。
1.3 关系与函数
仅仅有集合还不够。类型理论更关心的是:
对象之间怎样相互关联?
例如:
- “这个类型是不是那个类型的子类型?”
- “这个项能不能一步归约到那个项?”
- “这两个项是否等价?”
这些都属于关系。
1.3.1 关系
集合 与 之间的一个二元关系 ,就是笛卡尔积 的一个子集:
若 ,我们常记作:
1.3.2 常见性质
| 性质 | 定义 |
|---|---|
| 自反性 | |
| 对称性 | |
| 反对称性 | |
| 传递性 |
不同性质的组合,会得到不同种类的关系:
| 关系类型 | 典型性质 |
|---|---|
| 前序 | 自反 + 传递 |
| 偏序 | 自反 + 反对称 + 传递 |
| 等价关系 | 自反 + 对称 + 传递 |
这些名字在后文都不是摆设:
- α-等价(alpha-equivalence)是一种等价关系;
- 子类型关系(subtyping relation)通常至少希望具备前序性质;
- 归约关系(reduction relation)则是另一类重要关系。
1.3.3 一个例子:模 3 等价
在整数集合上定义关系:
这个关系是一个等价关系:
- 自反:,而
- 对称:若 ,则也有
- 传递:若 且 ,则
因此它会把整数划分成三个等价类:
这个例子的重要性不在于模运算本身,而在于它帮助你建立“等价关系把对象按某种意义上的相同划分成类”的直觉。后面讲 α-等价(alpha-equivalence)时,这种直觉会再次出现(见第 3 章;若你只想先查术语,可配合附录A“术语表”中的“α-等价”条目)。
1.3.4 函数
函数可以看作一种特殊关系:
- 每个输入都有输出;
- 每个输入至多对应一个输出。
写作:
根据是否“对所有输入都有定义”,可以区分:
- 全函数:对每个输入都给出结果;
- 部分函数:只对某些输入有定义。
这里要特别小心一个常见混淆。
- 类型判断本身首先是一个关系:;
- 在某些语法制导(syntax-directed)的系统中,这个关系可以实现成一个检查算法;
- 这个算法通常是一个总函数,返回“类型”或“错误”。
所以,更准确的说法不是“类型判断就是部分函数”,而是:
类型关系在某些系统里可以被实现为一个总会终止的检查过程。
后面几章里,这种“关系先于算法”的视角会不断出现:
- 第 4 章会把“程序如何计算”写成归约关系与求值关系;
- 第 5 章会把“项具有某类型”写成类型判断关系;
- 第 8 章会再引入子类型关系
<:,讨论什么叫安全替换。
1.4 归纳定义:如何定义无限对象族?
编程语言的语法、类型、推导树,往往都是无限的对象族。我们不可能把它们一个个列出来,所以需要一种“有限地定义无限”的方法:归纳定义。
归纳定义通常由两部分构成:
- 基础情况:先给出最简单的对象;
- 构造规则:说明如何从已知对象构造新对象。
1.4.1 例子:自然数
自然数可以归纳地定义为:
- 是自然数;
- 如果 是自然数,那么 也是自然数。
通过不断使用第二条规则,我们得到:
- ……
这个定义告诉我们两件事:
- 哪些对象属于自然数;
- 除了按这些规则构造出来的对象,别的都不算自然数。
这类“最小闭包”思想,在后面定义项、类型、推导树时都会出现。
1.4.2 例子:算术表达式
可以把只含加法和乘法的表达式定义为:
- 数字 是表达式;
- 如果 和 是表达式,那么 是表达式;
- 如果 和 是表达式,那么 是表达式。
这也是后面定义语法的基本方式。
对本教程中的语法对象来说,第二章的 BNF 记法可以看作归纳定义的一种常见写法;而第 3 章对 Lambda 项的定义,就是这一点最直接的实例。
1.5 归纳法:如何证明“对所有对象都成立”?
归纳定义告诉我们对象是怎样长出来的;归纳法则告诉我们,怎样对它们做普遍证明。
1.5.1 自然数归纳法
要证明性质 对所有自然数成立,通常分两步:
- 证明 ;
- 假设 成立,证明 成立。
这种证明方法之所以有效,是因为自然数正是按“从 0 出发、不断取后继”归纳生成的。
1.5.2 结构归纳法
对于语法树、项、类型等归纳定义出来的对象,更常见的是结构归纳法(structural induction):
- 对每个基础构造分别证明;
- 对每个复合构造,假设性质对其子结构成立,再证明对整体成立。
1.5.3 一个简单示例
假设算术表达式的深度定义如下:
我们想证明:每个算术表达式的深度都是有限的。
证明方法就是对表达式结构归纳:
- 若表达式是数字,深度显然是 1,因此有限;
- 若表达式是加法或乘法,则由归纳假设,其两个子表达式深度有限,因此最大值再加 1 仍然有限。
这类证明在后面的类型安全证明中会反复出现。
1.5.4 推导归纳法
除了对“项的结构”做归纳,我们还常对“推导树的结构”做归纳,这叫推导归纳法(induction on derivations)。
它的模式是:
- 看一个判断是通过哪条规则最后推出的;
- 对该规则的前提对应的子推导使用归纳假设;
- 再证明结论成立。
第五章证明保持性时,就会用到这种方法;而替换引理、进展性、保持性这些术语,也都可以在附录A“术语表”中快速回查。
1.6 推导规则:怎样把判断写成统一格式?
类型系统里最常见的书写形式是推导规则:
意思是:
如果上面的前提都成立,那么下面的结论成立。
如果一个规则没有前提,就叫公理。
1.6.1 一个小例子:自然数上的“小于”
我们可以用下面两条规则定义“”:
用它们可以推出 :
这里最重要的不是这个例子本身,而是要熟悉这样一种表达方式:
- 一个判断是否成立,
- 取决于它能否被某些规则一步步推出。
后面的类型规则、子类型规则、操作语义规则,都会以同样的形式出现。
1.6.2 推导树的意义
当多条规则叠加使用时,就会形成一棵推导树:
- 叶子通常是公理或无需再展开的事实;
- 中间节点对应规则的应用;
- 根节点是我们最终想得到的判断。
这也是为什么“一个判断成立”常常等价于“存在一棵以它为根的合法推导树”。
这条视角在后续几章中会不断复现:
- 第 3 章里,我们会先定义 Lambda 项及其变量结构;
- 第 4 章里,我们会用规则描述归约与求值;
- 第 5 章里,我们会正式写出类型判断与类型推导树;
- 第 8 章里,我们还会把同样的规则化思路用于子类型关系。
1.7 本章小结
这一章建立了后续最常用的四种工具:
- 集合与关系:帮助我们表达“元素属于什么”“对象之间如何关联”。
- 归纳定义:帮助我们有限地定义无限对象族。
- 归纳法:帮助我们证明某个性质对所有语法对象或所有推导都成立。
- 推导规则:帮助我们把“何时能推出一个判断”写成精确统一的形式。
如果把本章压缩成一句话,那就是:
后面所有形式化内容,都会围绕“定义、规则、归纳证明”这三件事展开。
1.8 回看导航
如果你读到后面章节时,发现自己开始对形式化规则“看得懂但推不动”,最值得优先回看的通常就是本章。建议按下面顺序复习:
- 回看本章的“关系”“归纳定义”“结构归纳”“推导规则”。
- 配合附录A确认术语:尤其是“推导规则”“推导树”“结构归纳法”“推导归纳法”。
- 配合附录B确认记号:尤其是
\vdash、\forall、\Rightarrow等。 - 再回到第 3–5 章看它们怎样把这些数学工具真正用在语法、归约和类型判断上。
下一章里,这些工具会立刻派上用场:我们将用形式文法和 BNF 来精确定义语言语法。
本章练习
- 设 ,,计算 、、、。
- 用归纳定义的方式定义“合法的括号字符串”。
- 用推导规则定义自然数上的“小于等于”关系,并尝试推出 。
- 用结构归纳法证明:每个算术表达式的大小(节点数)至少为 1。
第二章 形式文法与 BNF
第二章 形式文法与 BNF
阅读提示
本章会频繁使用“终结符(terminal)/ 非终结符(nonterminal)”“具体语法(concrete syntax)/ 抽象语法(abstract syntax)”“抽象语法树(abstract syntax tree, AST)”“BNF(Backus–Naur Form)”等概念。若你在阅读时想快速回查术语与记号,建议配合:
- 附录A《术语表》中的相关条目;
- 附录B《符号速查表》中的元变量与常见记号说明。
第一章介绍了推导规则和归纳定义,现在我们把这些工具真正用在“语言本身”上:
怎样精确地说明:什么样的程序是合法的?
这件事如果只靠自然语言描述,往往不够稳定,也不够适合后续的数学推理。于是我们需要形式文法(formal grammar)。
在阅读本章时,建议同时记住两条线索:
- 本章主要处理“程序在表面上如何写出来”与“它在结构上如何被分析”;
- 下一章开始,我们会把这里的语法工具真正用于 Lambda 演算,并更多地站在抽象语法而不是源代码表面写法的层面讨论问题。
如果你在术语或记号上感到不确定,可以配合附录A“术语表”和附录B“符号速查表”一起阅读。
2.1 为什么自然语言不够?
假设我们这样描述一个简单表达式语言:
算术表达式由数字、加号、乘号和括号组成,乘法优先级高于加法。
这段话对人类读者来说已经“差不多能懂”,但对于形式化讨论来说还不够。
例如:
1 + 2 * 3
它到底应该理解成:
- ,还是
- ?
人类会说:“当然是后者,因为乘法优先级更高。”
但如果你要把这件事交给编译器、解释器,或者要在这个语言上定义类型规则,就不能只说“当然”。你必须把优先级和结合性直接体现在语法规则中。
这就是形式文法的作用:
- 明确什么字符串是合法程序;
- 明确它们应该如何解析;
- 为后续的抽象语法和类型规则打基础。
2.2 BNF:最常见的语法表示法
类型系统文献里最常见的语法记法是 BNF(Backus–Naur Form)。
它的基本形式是:
<非终结符> ::= 定义
其中:
::=表示“定义为”;|表示“或”;- 尖括号里的名字表示一个语法类别,称为非终结符(nonterminal);
- 真正出现在程序里的字面符号,称为终结符(terminal)。
这里先提醒一个后文会反复用到的区分:
- 当我们用 BNF 讨论源代码表面写法时,重点通常是具体语法(concrete syntax);
- 当我们在后续章节里写
t ::= ...、T ::= ...这类生成式时,重点往往已经转向抽象语法(abstract syntax)的归纳定义。
例如:
<expr> ::= <expr> + <term> | <term>
<term> ::= <term> * <factor> | <factor>
<factor> ::= ( <expr> ) | <number>
这里:
<expr>、<term>、<factor>、<number>是非终结符;+、*、(、)以及数字字符属于终结符。
BNF 的价值不在于记号本身,而在于它把“合法程序长什么样”变成了一组可以机械检查的规则。
2.3 优先级与结合性如何写进文法?
回到前面的算术表达式例子。为什么要分成 expr / term / factor 三层?
因为这恰好把优先级写进了语法:
expr对应较低优先级的加法层;term对应较高优先级的乘法层;factor对应最基本的原子层。
于是 1 + 2 * 3 只能被解析成:
- 一个
expr - 其左边是
1 - 其右边是一个
term - 而那个
term再展开为2 * 3
这就自然得到:
一个简单推导示例
<expr>
→ <expr> + <term>
→ <term> + <term>
→ <factor> + <term>
→ <number> + <term>
→ 1 + <term>
→ 1 + <term> * <factor>
→ 1 + <factor> * <factor>
→ 1 + 2 * 3
这个推导过程说明:
2 * 3先在term层形成;- 然后整个式子才回到
expr层; - 因而乘法天然绑定得更紧。
左结合与右结合
除了优先级,文法也常用来表达结合性。
例如:
<expr> ::= <expr> + <term> | <term>
这是一种典型的左递归写法,它对应左结合:
1 + 2 + 3 读作 (1 + 2) + 3
后面第三章讲 Lambda 项时,我们同样会用“左结合”和“向右延伸”这样的书写约定来消歧义。到那时你会看到:很多看起来像“BNF 规则”的写法,真正服务的对象其实已经是抽象语法层面的结构表示。
2.4 形式文法的四元组表示
从更抽象的角度看,一个形式文法通常写成四元组:
其中:
| 记号 | 含义 |
|---|---|
| 非终结符集合 | |
| 终结符集合 | |
| 产生式规则集合 | |
| 起始符号 |
对于前面的算术表达式文法,可以理解为:
- 是所有产生式规则
四元组写法的好处是:
- 你可以精确讨论“这是哪一类文法”;
- 也可以把“语法”当成一个标准数学对象来研究。
一个简短的 Chomsky 层级说明
本教程主要用的是上下文无关文法。它的特征是:
每条产生式左侧都是一个单独的非终结符。
这正好就是 BNF 最常见的写法。
对本书来说,你不需要深入掌握整个 Chomsky 层级。只要记住:
- 编程语言的核心语法,通常适合用上下文无关文法描述;
- 而“变量必须先声明后使用”这类约束,往往不属于纯语法层,而要交给后续的语义分析或类型系统处理。
2.5 具体语法与抽象语法
真正编程时,你写下的是具体语法(concrete syntax);
而类型系统和解释器更关心的是:
去掉无关书写细节之后,程序的核心结构是什么?
这就是抽象语法(abstract syntax)。
这一点对后文非常重要。因为从第三章开始,本教程虽然仍会沿用类似 BNF 的紧凑写法,但默认更关心的是:
- 哪些对象属于语言的抽象语法;
- 它们如何按归纳方式生成;
- 后续的自由变量、替换、归约和类型规则如何定义在这些结构上。
也就是说,后文很多看起来像“文法”的写法,主要服务于抽象语法对象的定义,而不是完整源代码语法的刻画。
例如,下面这些写法在具体语法上不同:
1 + 2 * 3
1+2*3
(1) + ((2) * 3)
但它们都对应同一个抽象语法树(AST):
Add
/ \
Num Mul
1 / \
Num Num
2 3
这就是为什么后面定义语义和类型规则时,我们通常直接面向 AST,而不是面向源代码表面的括号和空格。
第三章的 Lambda 项语法就是第一个完整例子:虽然表面上仍会写成类似 BNF 的形式,但那时我们真正关心的已经是“变量、抽象、应用”这三类抽象语法构造,而不是某门具体语言里的排版细节。
一个重要提醒
很多入门材料会说:“BNF 的每个分支都对应一个 AST 节点。”
这句话作为直觉有帮助,但不能说得太绝对。更准确的说法是:
- 抽象语法构造子通常来自我们对文法的整理;
- 具体语法里的某些产生式只是为了优先级、结合性或书写方便服务;
- 它们未必都会在 AST 中保留下来。
比如括号就是典型例子:
- 它在具体语法里非常重要;
- 但在 AST 里通常不会单独形成一个“括号节点”。
因此,后面如果你看到教程把某个语法写成
t ::= ...
这样的形式,最稳妥的理解通常不是“这里在讨论完整源代码语法”,而是“这里在用一种紧凑记法描述抽象语法对象的生成方式”。
这也是为什么本章虽然从 BNF 讲起,但它真正要为后文准备的,不只是“怎么写文法”,更是:
怎样把语言对象写成可归纳定义、可做推理、可接上语义与类型规则的抽象结构。
2.6 词法层与语法层
还有一个常见细节值得提早说明:
- 词法层负责把字符流切成记号(token),如标识符、数字、关键字;
- 语法层负责把这些记号组织成树状结构。
因此,如果你在 BNF 里看到类似:
<number> ::= <digit> | <digit> <number>
<digit> ::= 0 | 1 | 2 | ... | 9
这只是为了教学清楚,把数字的内部结构也显式写出来。
但到了 AST 层,解释器或类型检查器往往只关心:
Num(123)
也就是说:
词法细节在 AST 中常被折叠。
这点后面非常重要。因为类型规则通常对应的是 AST 构造,而不是字符级别的文法细节。
2.7 本书中的常见元变量
类型系统文献喜欢使用固定的元变量约定。熟悉这些记号,会让后面的公式更容易读。
| 记号 | 常见含义 |
|---|---|
| 项(term) | |
| 变量 | |
| 值(value) | |
| 类型 | |
| 类型环境 |
这里的“项”可以理解成“形式化语言中的表达式对象”。例如:
- 在第三章,Lambda 项就是用语法规则生成出来的项;
- 在第五章,类型规则判断的对象也是项。
2.8 本章小结
这一章最重要的不是记住某种特定文法,而是理解下面几件事:
- 形式文法的任务是精确定义“什么样的程序合法”。
- BNF 是最常见的语法书写方式,尤其适合描述上下文无关语法。
- 优先级与结合性并不是语法之外的注释,而是可以直接写进文法结构里的。
- 类型系统真正操作的对象通常是抽象语法,而不是源代码的表面写法。
- 从第三章开始,本教程中的许多生成式写法都应优先理解为抽象语法的归纳定义,而不是完整 concrete syntax 的逐字描述。
下一章开始,我们就会把这些工具用在一个真正重要的对象上:
Lambda 演算。
届时你会看到,本章区分的几件事会立刻变得具体起来:
- 什么是语法对象;
- 什么是抽象语法层面的构造;
- 为什么后续的自由变量、替换、β-归约和类型规则,都更自然地定义在 AST 结构上。
如果你想提前做准备,建议特别回顾本章中的“优先级 / 结合性”“具体语法 vs 抽象语法”“元变量约定”这三部分。
回看导航
如果你读完本章后仍觉得“文法看着都懂,但不知道它和后面章节有什么关系”,建议这样回看:
- 回看本章中的“优先级 / 结合性”和“具体语法 vs 抽象语法”;
- 配合附录A确认“终结符、非终结符、抽象语法、AST”这些术语;
- 配合附录B确认本章使用的元变量记法;
- 然后再进入第 3 章,看这些语法工具如何真正落到 Lambda 项上。
本章练习
- 用 BNF 定义一个简单的布尔表达式语言,包含
true、false、not、and、or,并体现not的优先级高于and,and高于or。 - 用你定义的文法推导字符串
true and (false or true)。 - 为表达式
1 + 2 * 3画出对应的 AST。 - 解释“具体语法”和“抽象语法”的区别,并说明类型系统主要工作在哪一层。
第三章 Lambda 演算基础
第三章 Lambda 演算基础
阅读提示
本章会频繁使用“项”“自由变量 / 绑定变量”“α-等价”“替换”“新鲜变量”“变量捕获”等术语。若你在阅读时想快速回查定义与记号,建议配合:
- 附录A《术语表》中的 A.1“Lambda 演算与语法相关术语”
- 附录B《符号速查表》中的“Lambda 演算相关记号”“正文中常见的元变量约定”
我们已经学会了如何用 BNF 和抽象语法来定义一门语言。现在可以把这些工具真正用起来:定义一个极简、却足以支撑整个类型系统理论的核心语言——Lambda 演算(Lambda Calculus)。
阅读本章时,建议把附录A“术语表”和附录B“符号速查表”放在手边。
如果你一时分不清 FV(t)、[x \mapsto s]t、\equiv_\alpha 这些记号,它们都可以在附录中快速查到。
Lambda 演算(Lambda Calculus)之所以重要,不是因为它像真实编程语言那样“功能丰富”,恰恰相反,是因为它极简。整个语言只有三种基本构造:
- 变量(variable)
- 函数抽象(abstraction)
- 函数应用(application)
它没有数字、没有字符串、没有布尔值、没有 if、没有循环。但令人惊讶的是,在适当的编码方式下,未类型化 Lambda 演算具有与图灵机等价的计算表达能力。
这也是为什么它会成为类型系统理论的出发点:
真实语言太复杂,不适合直接做数学分析;Lambda 演算足够小,我们可以先在这个最小核心上定义语法、变量作用域、替换和计算规则,再把这些思想推广到更丰富的语言中。
Lambda 演算之于类型系统,犹如集合论之于数学:它是一个小而稳固的形式化基础。
3.1 为什么学习 Lambda 演算?
学习 Lambda 演算,主要有四个原因:
- 它是函数式编程的理论基础
- 它提供了极简但完整的计算模型
- 类型系统通常建立在某种 Lambda 演算之上
- 现代语言(如 Haskell、ML、Scala)的许多核心概念都可以追溯到这里
如果你后续要读《Types and Programming Languages》(TAPL),那几乎一定会反复遇到 Lambda 演算。
可以把它理解为:类型系统研究中的“牛顿力学模型”——现实世界远比它复杂,但许多基础原理都要先在这里建立。
3.2 语法:只有三种构造
先用一句话概括 Lambda 演算:
Lambda 演算是一门只有“变量、函数定义、函数调用”三种构造的极简语言。
你可以先把它和熟悉的语言对应起来。比如在 Python 里:
f = lambda x: x
f(3)
第一行定义了一个匿名函数,第二行调用它。
Lambda 演算做的事情完全一样,只不过它更纯粹——连数字和 + 都还没有:
λx.x
这个项读作:“一个函数,接收参数 x,返回 x 本身。”
它通常叫做恒等函数(identity function)。
3.2.1 抽象语法的定义
按照第 2 章的区分,这里更准确地说是在给出 Lambda 项的抽象语法;它常用 BNF 风格记号写成:
这表示一个项 只能是以下三种情况之一:
- 一个变量(variable)
- 一个抽象(abstraction)
- 一个应用(application)
其中:
- 表示变量名
- 表示“参数为 、函数体为 的函数”
- 表示“把函数 应用于参数 ”
通常我们默认变量名来自一个可数无限集合,例如 。
这样做的好处是:当后面需要“换一个没用过的新变量名”时,总能找到一个。
3.2.2 三种构造的直观含义
| 构造 | 语法 | 含义 | 编程语言中的类比 |
|---|---|---|---|
| 变量 | 一个名字,表示某个值 | 变量引用 | |
| 抽象 | 函数定义 | fun x -> t、lambda x: t | |
| 应用 | 函数调用 | f a、f(a) |
例如:
- :恒等函数
- :接收两个参数,返回第一个
- :接收函数
f和参数x,把f应用于x
3.2.3 括号与书写约定
上面的核心语法里没有把括号写进 BNF,但在实际书写时我们会大量使用括号来消除歧义。这里的括号只是元语言中的辅助记法,不是在给 Lambda 演算增加新的核心构造。
有两个非常重要的约定:
- 应用左结合:
读作 - 抽象体尽量向右延伸:
读作
因此:
- 表示
((a b) c) - 表示
λx.((x y) z) - 表示 “一个函数,其函数体是
x (λy.y)”
如果没有这些约定,很多式子都会变得难以阅读。
3.2.4 一些基本例子
λx.x
λx.λy.x
λx.λy.y
λf.λx.f (f x)
它们分别表示:
λx.x:恒等函数Iλx.λy.x:返回第一个参数的函数Kλx.λy.y:返回第二个参数λf.λx.f (f x):把函数f连用两次
对应到熟悉的语言中,大致可以写成:
fun x -> x
fun x -> fun y -> x
lambda x: x
lambda x: (lambda y: x)
3.2.5 与抽象语法树(AST)的关系
按照第 2 章的思路,这里的三种抽象语法构造分别对应三类 AST 节点。因此 Lambda 演算的 AST 只有三种节点:
- 变量节点
- 抽象节点
- 应用节点
例如项 的抽象语法结构可以画成:
λx.
└── @
├── x
└── y
这里的 @ 只是图示中的一个记号,表示“应用节点”,不是 Lambda 演算本身的语法符号。
3.3 变量、作用域与自由变量
变量是 Lambda 演算里最微妙的部分。
真正困难的地方不是“函数定义”本身,而是:一个变量出现时,到底是被哪个 λ 绑定的?
3.3.1 绑定变量与自由变量
在项 中:
λx引入一个绑定- 函数体 中由这个
λ管辖的那些x,叫做绑定变量出现(bound occurrence) - 若一个变量出现不受任何
λ绑定,它就是自由变量(free variable) - 这个绑定生效的语法范围,叫做作用域(scope)
例如:
- 在 中,
x是绑定的 - 在 中,
x是绑定的,y是自由的 - 在 中,
x和y都是自由的
可以把自由变量理解成“依赖外部环境的名字”。
3.3.2 自由变量集合的递归定义
我们用 表示项 的自由变量集合。这个记号也收录在附录A与附录B中。定义如下:
这三条规则非常自然:
- 一个单独的变量
x,它的自由变量就是自己 - 在
λx.t里,x被绑定了,所以要从FV(t)中删掉 - 在应用
t1 t2中,自由变量来自左右两个子项的并集
3.3.3 例子
| 项 | 绑定变量 | 自由变量 |
|---|---|---|
我们来逐步计算最后一个:
先根据抽象规则:
再计算内层:
由于应用左结合, 就是 ,所以它的自由变量是:
因此:
最后:
3.3.4 封闭项
如果一个项没有任何自由变量,就称它是封闭项(closed term)。
例如:
这些都是封闭项。
有些文献也会把没有自由变量的 Lambda 项叫做组合子(combinator),尤其在讨论 S、K、I 等经典例子时更常见。不过在初学阶段,记住“封闭项 = 没有自由变量的项”就足够了;后面若无特别说明,本教程优先使用“封闭项”这一说法。
3.4 α-等价:绑定变量名不重要
接下来要处理一个关键问题:
如果我把函数参数名从 x 改成 y,函数还是不是原来的那个函数?
直觉上当然是。比如下面两个 Python 函数,参数名不同,但行为一样:
def f(x): return x
def g(y): return y
在 Lambda 演算里,这种“只改了绑定变量名字、不改含义”的关系叫做 α-等价(alpha-equivalence)。
3.4.1 基本思想
例如:
这三个项的含义完全一样,因此它们 α-等价:
再比如:
这里改的只是绑定变量名;变量之间的绑定关系没有变,所以意义也没有变。
3.4.2 什么样的改名才是合法的?
并不是随便改名都可以。改名必须保持原来的绑定结构,并且不能引发变量捕获。
例如:
- 可以改成
- 但不能把它改成某个会让原本自由变量变成绑定变量的形式
更准确地说:
α-等价允许我们把某个抽象
λx.t中由这个λ绑定的x,统一改成一个新的变量名y,前提是这个改名不会改变自由变量和绑定变量的结构。
这件事通常叫做 α-重命名 或 α-转换。
3.4.3 为什么 α-等价重要?
因为后面定义替换时,我们会遇到“变量捕获”问题。
而 α-转换正是避免变量捕获的关键工具:
- 如果一个替换会导致捕获
- 那就先把冲突的绑定变量改名
- 再进行替换
所以你可以把 α-等价理解为:
“绑定变量的名字只是局部占位符,真正重要的是绑定结构,而不是字面上的名字。”
在形式化处理中,通常会把 α-等价的项视为“同一个项的不同写法”。
如果你读到这里时,已经对“绑定变量 / 自由变量 / α-等价”的关系有些混淆,建议先回看附录A中的对应条目,再继续读替换。
3.5 替换:把参数代入函数体
替换(substitution)是 Lambda 演算最重要的操作之一。
直观上,它就是“把一个项代入另一个项里的某个变量位置”。
我们记:
表示:把项 中自由出现的 x 替换成项 。
之所以强调“自由出现”,是因为被 λx 绑定住的那些 x,并不表示外部那个变量,不能替换。后面真正需要的,是避免捕获的替换(capture-avoiding substitution)。
3.5.1 直觉:替换就是“代入”
先用普通算术式来理解:
- 把
x换成3:x + 1变成3 + 1 - 把
x换成3:y + 1不变,因为里面没有x - 把
x换成3:x + y变成3 + y
Lambda 演算里的替换本质上也是一样的。
最简单的两条规则是:
也就是:
- 遇到目标变量
x,就换成s - 遇到别的变量,就保持不变
对于应用项,递归处理左右两边:
到这里为止,一切都很自然。
3.5.2 关键难点:变量捕获
问题出在抽象上。
考虑这个替换:
如果“无脑替换”,你可能会得到:
但这其实是错的。
为什么?
原来的 表示:“接收一个参数 y,但不返回它,而是返回外部的 x。”
这里的 x 是自由变量。
如果把 x 直接换成 y,变成 ,那这个 y 就不再是“外部的 y”,而是被 λy 绑定住的参数了。也就是说:
- 原本自由的
y - 被新的
λy意外“吃掉”了 - 含义彻底改变
这种现象就叫做变量捕获(variable capture)。
可以把它和自然语言中的误解类比:
- 原句:“把张三的电话告诉我”
- 错听成:“把你的电话告诉我”
只是因为同一个词碰巧重名,意思就变了。
3.5.3 抽象的三种情况
为了避免变量捕获,抽象项的替换必须分情况讨论。
情况一:绑定变量正好就是 x
因为这个 λx 已经把函数体里的 x 绑定住了。
替换只作用于自由出现的 x,而这里这些 x 都不是自由的,所以什么都不做。
情况二:绑定变量不是 x,而且不会捕获
如果 ,并且 ,那么可以安全地往里替换:
这里的条件 非常重要。
它保证了:当我们把 s 放进 t 里时,s 中的自由变量 y 不会被外面的 λy 捕获。
情况三:绑定变量不是 x,但会发生捕获
如果 ,同时 ,那么直接替换就危险了。
这时必须先做 α-重命名,把 λy 改成一个新鲜变量 z,再继续:
其中 z 必须是一个新鲜变量。更准确地说,它必须新鲜到足以避免新的名字冲突:至少不能与当前这一步中会产生影响的那些自由变量或已有绑定混淆。直觉上,可以把它理解为:
- 不等于当前正在讨论的绑定变量与被替换变量;
- 不出现在将要代入的项的自由变量中;
- 也不应引入新的捕获或歧义。
在实际推导里,你可以把它理解成:“选一个当前语境里没被相关部分使用过的名字”。
你不必把“新鲜”理解成“宇宙中从未出现过”,而应理解成:
对当前这一步替换来说,它足够新,因此不会改变原来的自由 / 绑定结构。
你可以把这个规则理解为:
如果将要发生“重名冲突”,先改名,再代入。
3.5.4 替换规则汇总
把前面的规则合起来,得到替换的完整定义:
这六条规则共同定义了避免捕获的替换(capture-avoiding substitution)。
这一套规则会在下一章立刻进入中心位置,因为 β-归约的本质就是避免捕获的替换。
如果你觉得这里已经有些抽象,不必急着一次把每个边角都记死;先抓住下面这条主线即可:
自由变量决定一个项依赖什么外部名字,α-重命名保证绑定名字可安全改写,而替换则是在不破坏这种结构的前提下进行代入。
3.5.5 一个最重要的例子:如何避免捕获
回到前面那个危险例子:
由于替换项 y 的自由变量集合是:
而抽象绑定的也是 y,所以直接替换会发生捕获。
因此必须先做 α-重命名,把 λy 改成 λz:
然后再替换:
最终结果是:
这个结果才是正确的。它表示:“接收一个参数 z,返回外部的 y。”
对照一下:
- 错误结果: —— 恒等函数
- 正确结果: —— 常函数,返回外部
y
两者含义完全不同。
3.5.6 完整计算示例
下面计算:
第一步:判断外层抽象是否安全
外层是 。
替换项是 。
先算它的自由变量:
因为 ,所以属于“不会捕获”的安全情况,可以直接进入函数体:
第二步:处理应用项
第三步:处理变量
所以:
代回去得到:
这就是最终结果。
3.5.7 替换为什么这么重要?
因为 Lambda 演算的核心计算规则——β-归约(beta-reduction)——本质上就是:
函数应用时,把实参代入函数体
形式上,下一章我们会看到类似这样的规则:
所以本章认真定义替换,并不是为了形式化而形式化,而是在为“函数如何计算”打基础。
3.6 本章小结
到这里,我们已经建立了 Lambda 演算最关键的静态基础。
这一章中,不同概念各自回答了不同的问题:
- 语法:什么样的式子是合法的 Lambda 项?
- 作用域与自由变量:一个变量出现时,它依赖外部环境,还是被某个
λ绑定? - α-等价:如果只改绑定变量名,项的意义是否不变?
- 替换:把一个项代入另一个项时,如何避免变量捕获?
如果把这一章和下一章联系起来,可以这样理解:
- 本章解决“式子长什么样,以及变量是什么意思”
- 下一章解决“式子如何计算”
也就是说,本章是静态结构,下一章将进入动态行为。
回看导航
如果你读完本章后,觉得“概念大致懂了,但一动手就容易乱”,建议按下面顺序回看:
-
先回看本章正文中的三个核心主线
- 项的抽象语法只有三种构造:变量、抽象、应用;
- 自由变量与绑定变量决定名字在项中的语义角色;
- α-等价与避免捕获的替换,为后面的 β-归约奠定基础。
-
再查附录A《术语表》
- 重点回看:
- 项
- 自由变量 / 绑定变量
- α-等价
- 替换
- 新鲜变量
- 变量捕获
- 重点回看:
-
再查附录B《符号速查表》
- 重点回看:
FV(t)[x \mapsto s]t\equiv_\alpha\lambda x.tt_1\ t_2
- 重点回看:
-
最后再做本章练习
- 如果自由变量集合和替换还能独立算出来,就说明这一章已经真正站稳了;
- 如果一到替换就混乱,通常不是“不会算”,而是“自由 / 绑定结构还没完全内化”。
本章练习
-
书写 Lambda 项
用 Lambda 演算写出下列函数:- 接收参数 ,返回 的函数
- 接收参数 ,返回一个接收 并返回 的函数
- 接收参数 和 ,将 应用于 的函数
-
分析自由变量
计算以下项的自由变量集合: -
判断是否 α-等价
判断下列各对项是否 α-等价,并说明理由:- 与
- 与
- 与
-
替换练习
计算下列替换,并写出每一步依据的规则:- ,其中 是自由变量
-
理解捕获问题
解释为什么下面这个“无脑替换”是错误的: 并给出正确结果。
在下一章,我们将正式引入 β-归约,看到 Lambda 演算如何真正“跑起来”。
第四章 Lambda 演算中的计算
第四章 Lambda 演算中的计算
阅读提示
本章会频繁使用“β-归约(beta-reduction)”“可归约表达式(redex)”“范式(normal form)”“发散(divergence)”“传名调用(call-by-name)/ 传值调用(call-by-value)”“小步语义(small-step semantics)/ 大步语义(big-step semantics)”等术语。若你一时记不清这些概念或记号,可随时回查:
- 附录A《术语表》中的 A.2“Lambda 演算中的计算术语”;
- 附录B《符号速查表》中的“Lambda 演算相关记号”“容易混淆的符号对照”。
上一章我们定义了 Lambda 演算的语法、变量作用域、α-等价和替换。本章继续回答一个自然的问题:
Lambda 项到底如何计算?
如果说第三章解决的是“式子长什么样,以及变量是什么意思”,那么这一章解决的就是:
- 一个函数应用如何真正执行;
- 多个可归约位置同时出现时应先算哪一个;
- 怎样用规则精确定义“程序运行一步”;
- 为什么“不同归约顺序”不会把我们带到互相冲突的结果。
本章会在三个层次上讨论“计算”:
- 归约规则层:先理解 β-归约(beta-reduction)——Lambda 演算最核心的计算规则;
- 策略层:再理解 归约策略(reduction strategy)/ 求值策略(evaluation strategy)——当有多个地方可算时,先算哪里;
- 操作语义层:最后用 操作语义(operational semantics) 把“计算过程”写成标准的形式化规则。
这里先特别说明一个阅读约定:
- 在 4.1–4.6 节,我们会先用一些开放项例子建立“归约 / 策略”的直觉;
- 到 4.8–4.9 节,我们再固定到更严格的操作语义写法,并明确值与一步关系;
- 到 4.10 节,我们再把“值 / 范式 / 卡住”这几个概念做一次统一区分,为第 5 章的类型安全做准备。
也就是说,本章既有“帮助你先看懂主线”的直觉层,也有“把计算写成规则”的形式化层。阅读时若一时觉得例子和规则的精度不同,这是有意安排的:前半先建立工作直觉,后半再固定形式化约定。
4.1 β-归约:函数应用就是代入
Lambda 演算最核心的计算规则只有一条:
它的直觉非常简单:
- 左边是一个函数
λx.t - 右边给了它一个实参
s - 于是就把
s代入函数体中所有自由出现的x
也就是说,函数应用的本质就是替换。
这里之所以要先学第三章的替换,是因为这个规则表面上看很简单,但真正严谨地执行它时,必须避免变量捕获。
4.1.1 最简单的例子
例 1:恒等函数
因为:
这说明 λx.x 的行为就是“把输入原样返回”。
例 2:常函数
应用左结合,所以它等于:
先归约最左边的应用:
于是整体变成:
这正是“忽略第二个参数,返回第一个参数”的行为。
4.1.2 为什么 β-归约依赖安全替换?
考虑下面这个项:
如果你无脑把 x 换成 y,会得到:
但这是错的。因为这里代入进来的 y 原本是自由变量,如果直接写成 λy.y,它就被内层的 λy 捕获了。
正确做法是先做 α-重命名:
然后再归约:
所以更准确地说:
β-归约不是“文本替换”,而是“避免捕获的替换”。
4.2 什么叫可归约项、范式与发散?
为了讨论“一个项还能不能继续算”,需要几个基本术语。
4.2.1 可归约表达式(redex)
形如
的项叫做 β-redex,简称 redex(reducible expression)。
直观上,它就是“一个已经准备好应用的函数调用”。
例如:
(\lambda x.x) y是 redex(\lambda x.\lambda y.x) a是 redexx y不是 redex,因为函数位置不是 Lambda 抽象
4.2.2 范式(normal form)
如果一个项中已经没有任何 redex,就说它处于 β-范式(β-normal form)。
例如:
xλx.xλx. x y
这些都没有形如 (\lambda x.t) s 的子项,因此是 β-范式。
要注意:
- 范式的定义只关心“还能不能继续按该归约规则走”;
- 它不等于“闭项”;
- 它也不等于“值(value)”的概念,后面讲求值策略时会再区分。
4.2.3 发散(divergence):不终止的归约
并不是所有 Lambda 项都能归约到范式。
最经典的例子是:
归约一步:
得到的还是它自己,所以会无限循环:
这种永远不会到达范式的现象,叫做发散(divergence)。
所以到这里为止,我们可以区分三种情况:
- 有些项已经是范式;
- 有些项可以归约若干步后到达范式;
- 有些项会无限归约下去,永远到不了范式。
这里先再提醒一次层次区别:
- 到目前为止,我们主要还在讨论归约关系本身;
- “值”与“按某种策略前进”要到 4.6 节以后才会进入中心位置;
- “卡住”则要到 4.10 节并结合第 5 章的类型安全一起看,才最自然。
4.3 一个项里可能有不止一个 redex
一旦项稍微复杂一些,就可能同时出现多个可归约位置。
例如:
这里有两个 redex:
- 外层的
(\lambda x.a) ((\lambda y.b) c) - 内层的
(\lambda y.b) c
于是会出现一个问题:
应该先归约哪一个?
我们可以先归约外层:
因为函数体 a 根本不使用 x,所以参数整个被丢弃了。
也可以先归约内层:
两条路径都到达了同一个结果 a。
但这只是一个例子。一般来说,我们需要一种更系统的方式来回答:
- 不同归约顺序会不会导致不同结果?
- 如果一个项能到达范式,这个范式是否唯一?
这就引出下面的合流性。
4.4 合流性(confluence)与 Church–Rosser 定理(Church–Rosser theorem)
Lambda 演算最重要的元性质之一,是 合流性(confluence)。
直观上,合流性说的是:
如果一个项可以沿不同的归约路径走下去,那么这些路径不会真正“分裂成互相冲突的结局”;它们总能重新汇合。
形式上,Church–Rosser 定理(Church–Rosser theorem)告诉我们:
如果 且 ,那么存在某个项 ,使得
且 。
其中:
- 表示“经过零步或多步 β-归约到达”
图形上可以画成:
t
/ \
* *
/ \
t1 t2
\ /
* *
\ /
u
4.4.1 这个定理意味着什么?
它最重要的推论是:
如果一个项有 β-范式,那么这个范式在 α-等价意义下是唯一的。
也就是说:
- 你可以用不同顺序归约;
- 中间过程可能不同;
- 但只要真的能归约到范式,最终结果不会互相矛盾。
这非常重要。否则“程序的结果”就会取决于你碰巧先算了哪一块,而不是由程序本身决定。
4.4.2 需要注意的边界
Church–Rosser 定理说的是:
- 不同路径可汇合
- 不是说所有路径都一样长
- 也不是说所有策略都一样高效
- 更不是说所有策略都一定会找到范式
例如对某些项:
- 一种策略可能很快到达范式;
- 另一种策略可能绕很久;
- 甚至还有一种策略可能一直在无关位置打转。
所以合流性解决的是结果一致性,而不是求值效率或一定终止。
4.5 归约策略:先归约哪个 redex?
既然一个项中可能有多个 redex,我们就需要规定“优先选哪个”。
这类规定统称为归约策略(reduction strategy)。
这里先强调:本节讨论的仍然主要是归约层面的策略,也就是“在允许的 redex 中先选哪一个”。它和后面 4.6 节开始讨论的求值策略有关,但还不是同一个层次:
- 归约策略更接近“在一般 β-归约图中怎么走”;
- 求值策略更接近“把哪类项当值、程序一步一步怎样执行”的语言语义约定。
4.5.1 正规序:最左最外优先
最经典的策略之一是 正规序(normal order):
总是优先归约最左边、最外层的 redex。
例如:
按照正规序,先归约外层,立刻得到:
正规序的重要性质是:
如果某个项存在 β-范式,那么正规序一定能找到它。
这使它在理论上非常重要。
4.5.2 另一种自然想法:先算参数
另一种很自然的想法是:
- 先把函数和参数都算好;
- 再执行函数应用。
这更接近许多实际编程语言的执行方式。
不过这里要先提醒一个边界:
下面这一小节里的例子,仍然只是为了说明“先算参数”这种一般归约直觉;它们未必已经满足后面 4.8 节里那套“封闭项 + 只把 Lambda 抽象当值”的传值调用小步语义。
例如:
如果先算参数,就得到:
而如果先做外层 β-归约,则得到:
两条路径都到达了 z,但中间步骤不同。
4.5.3 “策略不同”不等于“语义不同”
对于未类型化 Lambda 演算,策略的差异主要体现为:
- 中间步骤不同;
- 是否会多算一些本来不必算的部分;
- 是否更容易找到范式。
但由于 Church–Rosser 定理,若某项有范式,则不同策略不会把它带到两个不兼容的范式。
所以这里要区分:
- 归约关系本身:描述哪些步是允许的;
- 归约策略:在允许的步中实际优先选哪一步。
而到下一节,我们会进一步固定“值”的概念,并进入更接近编程语言语义的求值策略层面。
4.6 求值策略:传名调用与传值调用
在编程语言语义里,我们通常不使用“任意归约任意 redex”这种最自由的方式,而是定义一套更具体的求值策略(evaluation strategy)。
最常见的两种是:
- 传名调用(call-by-name)
- 传值调用(call-by-value)
为了讲清楚这两者,先引入“值”的概念。
这里也再次提醒层次区别:
- 本节仍然先用一些开放项例子说明策略直觉;
- 4.8 节开始,我们才会把这些直觉固定成更严格的小步语义规则;
- 因此本节的重点是“理解差异”,不是“给出最终正式定义”。
4.6.1 值(value)
在最小的未类型化 Lambda 演算里,通常把下面这种形式看作值:
也就是说,函数本身就是值。
变量一般不作为封闭程序的最终结果来讨论;但在开放项的例子里,我们有时仍会把它们当作无法继续归约的式子来看待。
4.6.2 传名调用(call-by-name)
传名调用的核心想法是:
调用函数时,不先把参数算成值,而是直接把参数项代入函数体。
例如:
传名调用会先做最外层应用:
这里参数 ((\lambda y.y) z) 在代入前没有先被化简。
要注意,这里用的是开放项上的策略直觉示例:它说明“先代入、后继续算参数”的思路,但还没有切换到 4.8 节那套对值和一步关系的严格限定。
4.6.3 传值调用(call-by-value)
传值调用的核心想法是:
只有当参数已经是值时,才执行函数应用。
所以同一个例子:
在“先把参数算好再应用”的传值直觉下,会先算参数:
不过同样要注意:这一段是在解释策略直觉,还不是后面 4.8 节那套严格的小步语义。
因为在 4.8 节里,我们会把值限定为 Lambda 抽象;在那套规则下,像 z 这样的自由变量不会被当作封闭程序求值的最终值来讨论。
4.6.4 一个能体现差异的例子
看这个项:
其中:
如果采用传名调用,由于函数体 a 不使用 x,我们直接有:
但如果采用传值调用,就必须先把参数 \Omega 算成值,而 \Omega 会永远发散,因此整个式子也发散。
这说明:
不同求值策略不仅中间步骤不同,甚至会影响一个程序是否终止。
4.6.5 Haskell 与“惰性”
很多教材会把 Haskell 粗略类比为“传名调用”,这是为了抓住“不急着算参数”这个核心直觉。
但更准确地说:
- Haskell 使用的是 按需求值(call-by-need)
- 它与传名调用关系很近
- 区别在于:按需求值会共享求值结果,避免同一参数被重复展开多次
在本章里,你只需要先记住:
- 传名调用:先代入,后计算;
- 传值调用:先把参数算成值,再代入。
而从 4.8 节开始,我们会把这里的直觉收束成一套固定的、可逐步执行的操作语义规则。
4.7 操作语义:把“如何计算”写成规则
到目前为止,我们已经有了很多直觉性描述:
- 什么是 β-归约;
- 什么是求值策略;
- 什么是值。
现在要做的是把“程序怎样执行”写成一套明确规则。这就是操作语义(operational semantics)。
最常见的两种写法是:
- 小步语义(small-step semantics)
- 大步语义(big-step semantics)
4.8 小步语义:一次只走一步
小步语义的目标是定义一个关系:
意思是:
项 经过一步计算变成 。
为了避免“任意 redex 任意归约”的非确定性,我们现在固定采用传值调用来举例。
4.8.1 传值调用下的值
在未类型化 Lambda 演算中,取:
4.8.2 小步规则
β-应用规则
这条规则说:
- 只有当参数已经是值
v时, - 才能执行函数应用。
函数位置先求值
这条规则说:
- 如果函数位置还能再算一步,
- 那么整个应用先跟着它往前走一步。
参数位置后求值
这条规则说:
- 如果函数位置已经是值,
- 那就开始求值参数位置。
这三条规则一起刻画了传值调用的顺序:
- 先算函数位置;
- 再算参数位置;
- 最后在两者都就绪时做 β-归约。
4.8.3 一个完整例子
为了和这里固定的传值调用小步语义完全一致,我们使用一个封闭项例子:
先看最左边的应用。函数位置和参数位置都已经是值,因此可用 E-AppAbs:
接着,外层应用再次满足 E-AppAbs:
最后再归约一次:
所以整体归约到:
这个例子和 4.6 节里那些“开放项上的策略直觉示例”不同:
这里每一步都严格符合本节给出的值定义与小步规则。
4.8.4 多步归约
如果一步关系记作 ,那么“经过零步或多步归约”记作:
例如:
这表示它可以经过若干步,最终到达 z。
4.9 大步语义:直接描述最终结果
大步语义不关心中间每一步,而直接定义:
意思是:
项 最终求值为值 。
对于传值调用的未类型化 Lambda 演算,可写出如下规则。
4.9.1 值规则
值求值到它自己。
4.9.2 应用规则
它的意思是:
- 先把函数位置求值为某个 Lambda 抽象;
- 再把参数求值为值;
- 然后把该值代入函数体继续求值;
- 最终得到结果值。
4.9.3 一个例子
仍然考虑:
按大步语义理解:
(\lambda f.\lambda x.f x)已经是值;(\lambda y.y)也是值;- 代入后得到
\lambda x.(\lambda y.y) x; - 再把它应用到
\lambda z.z; - 最终结果是
\lambda z.z。
所以:
4.9.4 小步与大步的差别
- 小步语义擅长描述“程序如何一步步运行”
- 大步语义擅长描述“程序最终得到什么结果”
各有侧重:
- 如果你想讨论中间状态、卡住、并发、异常传播等,小步语义通常更方便;
- 如果你只关心一个确定性语言的最终求值结果,大步语义更简洁。
本教程后面谈类型安全时,会更多依赖小步语义的视角,因为它更适合表达“要么继续走,要么已经是值”。
4.10 值、范式与卡住:先做一个概念区分
这一节先做概念澄清。严格意义上,在纯未类型化 Lambda 演算里:
- 我们通常讨论的是值(value)与 β-范式(β-normal form);
- “卡住状态(stuck term / stuck state)”这个概念在加入布尔值、自然数、条件表达式等扩展构造后会变得尤其清楚、尤其有教学意义。
4.10.1 值与范式不是同一个概念
- 范式:相对于某个归约关系,已经无法继续走;
- 值:相对于某个求值策略,被视为“计算完成”的形式。
在很多简单系统里,值往往也是范式;但这两个概念的定义来源不同,最好不要混为一谈。
4.10.2 “卡住”在扩展语言里最典型,但不是只能在那里出现
例如,在带有布尔值和应用的扩展语言里:
true 0
它既不是值,也没有任何求值规则适用,因此是“卡住”的。
这类例子之所以常被拿来讲“卡住”,是因为它非常直观:
程序表面上有某种“计算形状”,但语言规则根本不给它下一步。
不过更精确地说:
- 对纯 Lambda 演算的封闭项,在讨论完全 β-归约时,“卡住”通常不是最核心的组织概念;
- 但一旦你固定某种求值策略,特别是在讨论开放项时,也仍然可能出现“既不是值、又不能按当前策略前进”的情况。
因此,更稳妥的理解方式是:
“卡住”在扩展语言里最自然、最典型;而在纯 Lambda 演算里,我们本章更关心的仍然是归约、范式和值。
真正要把“良类型程序不会卡住”说严谨,要等到下一章引入类型规则之后再看才最自然。
所以本章先记住三点:
- 第四章前半主要在讨论归约与策略;
- 第四章后半把这些直觉收束成操作语义;
- 第五章才会把“卡住”正式接到类型安全上。
4.11 本章小结
这一章建立了 Lambda 演算中“计算”这一层的核心概念。
你应该已经掌握的主线
-
β-归约是 Lambda 演算的核心计算规则:
-
一个项里可能同时有多个 redex,因此需要讨论归约策略。
-
Church–Rosser 定理保证了:
- 若某项有范式,则其范式在 α-等价意义下唯一;
- 不同归约路径不会导致真正冲突的最终结果。
-
求值策略进一步规定:
- 到底先算函数位置还是参数位置;
- 是否要求参数先成为值。
-
操作语义把“程序如何运行”写成推导规则:
- 小步语义关注单步执行;
- 大步语义关注最终结果。
与下一章的关系
如果说本章回答的是:
“Lambda 项如何运行?”
那么下一章将回答:
“哪些 Lambda 项是被允许的?为什么它们不会在运行时出问题?”
也就是说:
- 本章是计算规则
- 下一章是类型规则
两者合在一起,才构成类型系统理论最核心的骨架。
回看导航
如果你读完本章后还觉得某些部分不够稳,可以按下面顺序回看:
- 若你对
FV(t)、替换、变量捕获、α-重命名仍然不稳,先回到第 3 章; - 若你对“值 / 范式 / 卡住”“
→/→*/⇓”这些术语或记号混淆,先查附录A和附录B; - 若你想把“如何计算”和“为什么良类型程序不会卡住”连起来,再继续看第 5 章。
本章练习
-
逐步归约下列项,并写出每一步:
-
解释为什么下面的归约不能直接做“无脑文本替换”: 并给出正确结果。
-
对项 分别先归约外层和先归约内层,观察结果。
-
比较下列项在传名调用和传值调用下的行为: 说明为什么两种策略的终止性不同。
-
用小步语义规则证明:
第五章 类型系统核心概念
第五章 类型系统核心概念
前两章建立了形式化工具,第 3–4 章建立了计算模型。现在终于可以进入本教程的核心问题:
我们怎样在程序运行之前,排除某些运行时错误?
本章要回答的问题是:
- 什么叫“一个项有某个类型”?
- 类型规则到底在检查什么?
- 为什么这些规则能阻止程序“卡住”?
阅读提示
本章会频繁使用
Γ ⊢ t : T、进展性(progress)、保持性(preservation)、类型安全(type safety)等术语。若你一时记不清它们的定义,可随时回查:
- 附录A《术语表》中的 A.3“类型系统核心术语”;
- 附录B《符号速查表》中的“逻辑与判断相关记号”“类型系统相关记号”。
同时,本章直接依赖:
- 第 3 章中的自由变量、替换(substitution)与避免捕获的替换;
- 第 4 章中的值(value)、小步语义(small-step semantics)与“卡住”。
这一章的任务,就是把这个问题形式化。我们会在一个最小而经典的语言上完成这件事:简单类型 Lambda 演算(Simply Typed Lambda Calculus,简称 STLC)。
不过这里要先固定一个本章约定:
本章的对象语言不是“只有函数的最纯 STLC 片段”,而是“以 STLC 为核心、加入布尔值与条件表达式的最小扩展语言”。
这样做的原因很简单:
- 纯函数片段当然足以建立类型判断的基本骨架;
- 但加入
Bool与if之后,“把布尔值当函数用”这类卡住例子会更直观; - 因而更适合在入门阶段把“类型系统到底在防什么”讲清楚。
和前几章一样,本章的重点不是一下子记住所有规则,而是先抓住主线:
- 什么叫“一个项有某个类型”?
- 类型规则到底在检查什么?
- 为什么这些规则能阻止程序“卡住”?
5.1 从“卡住”到“有类型”
第四章里我们已经见过“卡住状态”(stuck term / stuck state)这个概念。直观地说:
一个项如果既不是一个正常结果,又无法继续按照求值规则前进,那么它就卡住了。
例如,在一个同时含有布尔值和函数的语言里,下面这样的项就是典型的卡住项:
问题不在于它“语法不合法”,而在于它没有合理的计算规则:
true不是函数;- 因此不能把它应用到参数上;
- 这个项不是值,也不能继续求值。
这正是类型系统想排除的情形。它的基本思路可以概括为:
- 先给项分类;
- 分类的名字就叫类型;
- 然后只允许那些符合规则的组合方式出现。
于是,像“把布尔值当函数用”这种错误,就能在运行前被识别出来。
5.2 什么是类型系统?
Cardelli 的经典定义是:
类型系统(type system)是一种可判定的语法方法,通过对程序短语按其计算值的种类进行分类,来证明程序的某些行为不会发生。
把它放到这一章的语境里,可以读成:
- 可判定:存在一种检查过程,能够在有限时间内判断程序是否符合类型规则;
- 语法方法:主要根据程序结构来检查,而不是先运行程序;
- 分类:把项分成“布尔值”“函数”“积类型值”等不同种类;
- 某些行为不会发生:类型系统不是万能的,它只保证排除我们关心的那类错误。
因此,Milner 的著名口号:
Well-typed programs cannot go wrong.
更适合被理解为:
在选定的语言和错误模型下,良类型程序不会发生被类型系统禁止的那类错误。
这句话很重要,但也要克制理解。它并不意味着:
- 良类型程序一定得到你想要的业务结果;
- 良类型程序一定终止;
- 良类型程序一定高效。
类型系统保证的是一种受限但有力的可靠性。
5.3 本章使用的对象语言:带布尔值与条件表达式的 STLC 扩展
为了讨论类型系统,我们需要一门足够小、又足够能体现“卡住问题”的语言。
如果只看最纯粹的 STLC 函数片段,很多“明显的类型错误”其实没那么直观;因此本章采用一个带布尔值和条件表达式的 STLC 扩展。这样一来:
- 我们有函数;
- 也有基础类型
Bool; - 还可以明确展示“把布尔值当函数”之类的错误。
因此,后文若无特别说明:
“本章对象语言”都应理解为“以 STLC 为核心、加入
Bool与if的最小扩展语言”。
5.3.1 类型、项和值
类型定义为:
项定义为:
值定义为:
也就是说,在这一章里我们只考虑两类类型:
Bool- 函数类型
对应地,项包含:
- 变量
- 函数抽象
- 函数应用
- 布尔常量
- 条件表达式
5.3.2 为什么要有类型注解?
注意这里的抽象写成:
而不是:
这意味着我们采用的是 Church 风格(Church-style):函数参数的类型直接写在项里。这样做的好处是:
- 类型检查更直接;
- 规则更容易写成语法制导(syntax-directed)形式;
- 也更适合作为本教程入门阶段的对象语言。
第九章讲类型推断时,我们会再回头看不显式标注类型的 Curry 风格(Curry-style)。
5.4 类型判断:形式上到底在说什么?
类型系统的核心判断叫做类型判断(type judgment),写作:
读作:
在类型环境 下,项 的类型是 。
如果你对这个记号的读法还不熟,可以同时回看:
- 附录A中的“类型判断”“类型环境”条目;
- 附录B中的
Γ ⊢ t : T与⊢记号说明。
这里有三个组成部分:
- :类型环境
- :项
- :类型
5.4.1 类型环境
类型环境(type context / typing environment)是“变量到类型”的有限映射,通常写成:
它的作用很直观:
当你在函数体里遇到一个变量时,必须知道它被假定成什么类型。
例如:
- 在环境 下,变量
x的类型就是Bool - 在环境 下,项
f x应该能判断成Bool
可以把环境理解为一种形式化的“作用域上下文”。
5.4.2 一个直观例子
判断
表示:
- 假设变量
x被赋予类型Bool - 那么项
x的类型就是Bool
而判断
表示:
- 在空环境下
- 恒等函数
λx:Bool.x - 的类型是
Bool → Bool
这种“环境下的判断”形式,会在后面所有类型规则里反复出现。
5.5 STLC 的类型规则
现在进入本章最核心的部分:类型规则。
5.5.1 变量规则
这条规则的意思是:
- 如果环境里记着
x的类型是T - 那么就可以判断
x : T
这是一条公理式规则,没有前提。
5.5.2 抽象规则
这条规则表达了函数的最基本思想:
- 假设参数
x的类型是 - 如果在这个假设下,函数体
t的类型是 - 那么整个函数的类型就是
也就是说,函数类型来自两部分:
- 参数类型
- 返回类型
5.5.3 应用规则
它的意思是:
- 若 是一个从 到 的函数;
- 且 恰好是一个 类型的参数;
- 那么把 应用于 的结果类型就是 。
这是整章最重要的一条规则之一。
它精确表达了“函数调用必须类型匹配”。
5.5.4 布尔常量规则
这两条规则很简单:
true的类型是Boolfalse的类型也是Bool
5.5.5 条件表达式规则
这条规则说:
- 条件位置必须是布尔类型;
- 两个分支必须具有同一个类型;
- 整个
if表达式的类型就是那个共同的类型。
最后这一点非常关键。因为程序运行时会走哪一个分支,取决于条件真假;所以如果两个分支类型不同,整个表达式就无法有一个统一的类型。
5.6 如何读类型规则?
对初学者来说,最容易出问题的地方不是规则本身,而是不知道应该从哪里看起。
一个实用方法是:
- 从下往上看:这条规则允许我推出什么结论?
- 从上往下看:为了得到这个结论,我需要先满足哪些前提?
例如看应用规则:
你可以这样读:
- 想证明
t1 t2的类型是 - 我就必须先证明:
t1是一个函数,类型为t2是它期望的参数类型
这就是“按规则反向构造推导”的基本思路。
5.7 一个完整的推导树示例
我们来证明:
这个项的直觉含义是:
- 接收一个布尔值
x - 再接收一个布尔值
y - 返回第一个参数
x
5.7.1 证明思路
最外层是一个抽象,所以最后一步一定使用 (Abs):
- 若要证明整个项的类型是
Bool → Bool → Bool - 就需要在环境
x:Bool下证明内层函数的类型是Bool → Bool
内层仍然是抽象,再用一次 (Abs):
- 若要证明 的类型是
Bool → Bool - 就需要在环境
x:Bool, y:Bool下证明x : Bool
最后这个结论由 (Var) 直接得到。
5.7.2 推导树
这个例子很重要,因为它展示了:
- 类型判断不是“拍脑袋决定”;
- 而是严格由规则一步步推出;
- 一棵推导树,就是“这个项确实有这个类型”的正式证据。
5.8 为什么有些项无法通过类型检查?
类型系统的力量,恰恰体现在它拒绝某些项。
来看这个项:
如果想用 (App) 规则给它判类型,就必须满足:
- 函数位置
true的类型必须是某个函数类型 - 参数位置
λx:Bool.x的类型必须是
但问题在于:
而不是某个函数类型。
所以这个项没有办法构造出合法的类型推导树。也就是说:
它在类型系统里根本不是良类型项。
这就是类型系统排除“把布尔值当函数使用”的方式。它不是等程序运行出错后再抱怨,而是在构造推导树这一步就卡住了。
5.9 从类型规则到类型检查
目前为止,我们写出的都是声明式规则(declarative typing rules):
- 它们说明什么样的判断成立;
- 但还没有明确告诉你,编译器应当如何实现检查。
这里的“声明式规则”和“语法制导”也是后续章节会反复使用的关键词;若你想快速确认定义,可回查附录A中的“类型规则”“语法制导”“类型检查”等条目。
对 STLC 来说,这个实现过程相当直接,因为它的规则是语法制导(syntax-directed)的:
- 遇到变量,就查环境;
- 遇到抽象,就扩展环境并递归检查函数体;
- 遇到应用,就分别检查函数和参数,再判断是否匹配;
- 遇到
if,就检查条件是不是Bool,并确认两个分支类型一致。
也就是说,这里要区分两层东西:
- 声明式规则回答“什么情况下判断成立”;
- 语法制导检查过程回答“编译器怎样沿语法树把这个判断算出来”。
在本章这个最小系统里,这两层非常接近;但到后面更复杂的系统中,它们未必总是完全重合。
这就是为什么 STLC 的类型检查是可判定的。它不是通过复杂搜索得到的,而是沿着语法树递归进行。
更准确地说:
STLC 的类型判断关系可以被实现为一个总会终止的检查算法。
这和后面第九章的“类型推断”不同。
本章是在已知注解的前提下做类型检查;第九章则会讨论在没有显式注解时,如何自动推断类型。
5.10 类型安全:本章真正要到达的地方
写出类型规则还不够。我们还要回答一个更重要的问题:
为什么这些规则真的能阻止程序卡住?
类型理论里最经典的答案是:类型安全 = 进展性 + 保持性。
5.10.1 进展性(Progress)
进展性(progress)说的是:
一个良类型的封闭项,要么已经是值,要么还可以继续求值。
形式上可写为:
它排除了什么?
它排除的是“既不是值,又走不动”的情况,也就是卡住状态。
5.10.2 保持性(Preservation)
保持性(preservation)说的是:
如果一个项在求值前有类型 ,那么它求值一步之后,类型仍然是 。
形式上可写为:
它说明:
- 求值不会把一个良类型项“算坏”;
- 计算过程保持类型结构的一致性。
5.10.3 两者合起来意味着什么?
- 进展性保证:良类型程序不会卡住;
- 保持性保证:良类型程序在每一步计算后仍然良类型。
合起来就得到:
从一个良类型程序出发,整个计算过程始终不会掉进被类型系统禁止的坏状态。
这才是“良类型程序不会出错”这句话在形式化里的真正内容。
5.11 为什么保持性会依赖替换?
保持性的核心难点,其实来自第四章的 β-归约:
也就是说,函数应用真正做的事是:
- 把参数
v - 替换进函数体
t - 然后继续计算
因此,第五章的类型安全证明并不是凭空开始的,而是直接建立在:
- 第 3 章的替换与避免捕获的替换;
- 第 4 章的小步语义与“前进一步”的定义
之上。
因此,要证明保持性,通常需要一个关键引理:替换引理。
替换引理(substitution lemma)
它的直觉含义是:
如果
t在假设x : T1时是良类型的,而v本身确实是一个T1类型的值,那么把v替换给x之后,整体仍然保持原来的类型。
这正是第三章为什么要严肃定义“避免捕获的替换”的原因:
替换不仅是计算规则的一部分,也是类型安全证明的一部分。
5.12 一个小例子:类型安全如何发挥作用?
看下面这个项:
它显然是良类型的,类型为 Bool。
求值一步:
此时:
- 原项类型是
Bool - 求值后的结果仍然是
Bool
这体现了保持性。
同时,原项也不是卡住的:
- 它不是值;
- 但它可以继续归约一步。
这体现了进展性。
反过来看:
这个项没有类型,因此不在“类型安全”定理的适用范围内。类型安全不是说“所有项都不会卡住”,而是说:
所有良类型项都不会卡住。
这个限定一定要记住。
5.13 Church 风格、Curry 风格与本章的定位
前面已经提到,本章采用的是 Church 风格(Church-style):
- 项里直接写类型注解;
- 我们做的是“检查”。
而在 Curry 风格(Curry-style)中:
- 项本身不携带类型注解;
- 需要依靠推断或外部规则给项指派类型。
例如:
- Church 风格:
- Curry 风格:
两者的差别,不只是“写没写注解”,更关系到:
- 类型检查是否直接;
- 是否需要类型推断;
- 能否得到最一般类型。
本章故意选择 Church 风格,不是因为它“更高级”,而是因为:
对入门阶段来说,它最能清楚展示“类型规则如何工作”。
第九章再讨论 Curry 风格和 Hindley–Milner 推断,会更自然。
5.14 Curry–Howard:为什么函数类型像逻辑蕴含?(选读)
这一节不是本章主线,但值得知道它的直觉。
在合适的构造性逻辑系统里,存在一种深刻对应:
- 类型对应命题;
- 项对应证明;
- 函数类型 对应逻辑蕴含 。
为什么?
看抽象规则:
它可以被读成:
- 假设 成立;
- 在这个假设下构造出 ;
- 因而得到一个从 到 的函数。
这和逻辑里证明蕴含的结构非常像:
- 假设前件成立;
- 在此基础上推出后件;
- 因而得到“前件蕴含后件”。
最经典的例子是恒等函数:
它对应逻辑中的平凡命题:
不过这里仍要保持边界感:
Curry–Howard 对应不是一句“所有类型系统都等于逻辑系统”就能概括完的。它成立于特定的形式系统中,而且具体对应关系依赖于所选语言与逻辑。
所以把它当作一种非常重要的理论视角是合适的,但不应在入门阶段把它说得过满。
5.15 本章小结
这一章真正建立起来的,是一条完整链条:
- 程序可能卡住,而类型系统的目标是排除某类卡住状态。
- 类型判断写作 ,表示在某个环境下,项具有某种类型。
- 本章对象语言是以 STLC 为核心、加入
Bool与if的最小扩展语言。 - 良类型项不等于“绝对正确”,但它们满足一个关键的安全保证。
- 这个安全保证通常被分解为:
- 进展性:不会卡住;
- 保持性:求值不破坏类型。
- 保持性之所以成立,核心之一在于第三章已经准备好的替换理论。
如果把本章压缩成一句话,那就是:
先用类型规则给项做静态分类,再用进展性与保持性说明:良类型程序不会落入被本系统禁止的坏状态。
如果你读完本章,已经能清楚回答下面三个问题,就说明抓住主线了:
Γ ⊢ t : T到底是什么意思?- 为什么
(App)规则能防止“把布尔值当函数用”? - 为什么“类型安全”要拆成进展性和保持性?
回看导航
如果你读完本章后,想把这一章重新挂回全书主线,最推荐的回看顺序是:
- 回看第 3 章:重新确认自由变量、替换、变量捕获与 α-重命名,因为保持性与替换引理都建立在这套基础之上。
- 回看第 4 章:重新确认“值 / 范式 / 卡住”“小步语义 / 大步语义”的区别,因为类型安全正是建立在“程序如何前进”这层定义之上。
- 配合附录A:重点查看“类型判断”“类型环境”“进展性”“保持性”“类型安全”“替换引理”等条目。
- 配合附录B:重点确认
Γ ⊢ t : T、\vdash、函数类型箭头等记号。 - 若你准备继续读第 6 章,可以带着一个问题往下看:
- 当语言不只包含函数和布尔值,而开始加入积类型、和类型、递归类型、引用类型时,这套类型规则骨架会怎样扩展?
本章向后输出的核心内容是:
Γ ⊢ t : T这类类型判断的基本读法;- 类型规则、类型检查与语法制导之间的关系;
- “类型安全 = 进展性 + 保持性”这条后续各章都会反复依赖的主线。
本章练习
-
构造推导树,证明:
-
说明为什么下面这个项无法通过类型检查:
-
构造推导树,证明:
-
用自然语言解释进展性和保持性的区别,并说明它们如何一起排除卡住状态。
-
试着说明为什么下面这个说法不准确:
“良类型程序一定不会出任何问题。”
下一章,我们会在 STLC 的基础上继续扩展语言,加入更丰富的类型构造,如积类型、和类型、递归类型和引用类型。到那时,你会看到:类型规则的基本思想并没有变,只是它所处理的语言构造变得越来越丰富。
第六章 一阶类型系统:基本类型构造
第六章 一阶类型系统:基本类型构造
阅读提示
本章会频繁使用“基础类型(base type)”“积类型(product type)”“和类型(sum type)”“记录类型(record type)”“递归类型(recursive type)”“引用类型(reference type)”等术语。若你在阅读时想快速回查定义与记号,建议配合:
- 附录A《术语表》中的 A.4“一阶类型构造相关术语”
- 附录B《符号速查表》中的“类型系统相关记号”
同时,本章直接建立在第 5 章的类型判断
Γ ⊢ t : T之上;若你对“类型规则”“语法制导”“类型安全”的基本骨架还不稳,建议先回看第 5 章。
第五章建立了简单类型 Lambda 演算(STLC)的最小核心:变量、函数抽象、函数应用,以及最基本的类型判断与类型安全性质。但如果只停留在函数类型,语言的表达能力仍然非常有限——我们还不能自然地表示布尔值、数字、二元组、记录、列表、树,甚至也还没有办法讨论可变存储。
这一章的任务,就是在 STLC 的基础上逐步加入这些更接近真实语言的类型构造。
这里说的一阶类型系统(first-order type system),主要是指:
- 还没有引入对类型本身的量化(如 、);
- 因而还没有进入参数化多态或存在类型;
- 但已经可以表达很多常见的数据结构与程序构造。
换句话说,这一章研究的是:
在不引入“类型层面的抽象与量化”之前,一个类型系统能扩展到什么程度?
在阅读本章时,还需要先固定一个形式化层级上的约定:
本章的重点是建立一阶类型构造的统一直觉与最小规则骨架,而不是把所有扩展都写成同一精度的完整形式系统。
更具体地说:
- 基础类型、积类型、引用类型会给出较直接的规则形状;
- 和类型会同时提示“教学上的简写”和“更严格的 Church 风格写法”之间的区别;
- 记录类型会以最小形式骨架为主,为第 8 章的结构子类型做准备;
- 递归类型会明确采用同构递归(iso-recursive)视角,而不是把递归展开直接并入类型相等。
本章既延续第 5 章“类型规则如何工作”的主线,也为第 7–10 章埋下几个关键伏笔:
- 第 7 章会继续讨论“对类型本身做抽象”;
- 第 8 章会回到记录与引用,讨论子类型与变化方向;
- 第 10 章则会进一步讨论:类型不仅描述“值是什么”,还描述“值怎样被使用”。
6.1 为什么要超出函数类型?
在 STLC 中,最核心的类型构造是函数类型:
这当然很重要,因为函数是整个类型理论的核心对象。但如果一个语言只有函数类型,就会立刻遇到很多实际问题:
- 我们如何表示“真 / 假”?
- 如何表示“两个值的组合”?
- 如何表示“要么是这个,要么是那个”?
- 如何定义列表、树这样的递归结构?
- 如何讨论带状态的程序?
因此,从 STLC 走向更实际的类型系统,第一步通常不是“先加高级多态”,而是先加入这些最基本的数据构造。
这一章会按下面的顺序展开:
- 基础类型
- 积类型
- 和类型
- 记录类型
- 递归类型
- 可变引用
这些构造虽然看起来彼此分散,但它们背后有一条清晰主线:
类型系统不仅要给函数分类,还要给程序中出现的各种数据形态分类。
6.2 基础类型
最自然的扩展,是加入一些“原子性的”基础类型(base types),例如布尔值、自然数和单位类型。
6.2.1 基础类型的直觉
在 STLC 里,函数类型告诉我们:
- 一个项如果类型是 ,
- 那么它应该接收一个 类型的输入,
- 并产生一个 类型的结果。
但现实程序里还有很多根本不是“函数”的值,例如:
true、false0、1、2- “什么信息都不携带,只表示操作完成了”的值
因此,常见的一阶类型系统通常会引入如下基础类型:
| 类型 | 典型值 | 直觉含义 |
|---|---|---|
Bool | true, false | 布尔值 |
Nat | 0, 1, 2, … | 自然数 |
Unit | unit | 只有一个值的平凡类型 |
其中最容易误解的是 Unit。
它不是“空值”,而是“恰好只有一个值的类型”。
你可以把它理解成:
- 这个值本身不携带有趣信息;
- 但“返回了一个
Unit值”仍然是一件事情。
这和很多语言中的“过程调用完成了,但没有返回有用结果”的情况很接近。
6.2.2 一个简单的布尔系统
如果给 STLC 加上布尔值和条件表达式,可以得到如下语法扩展:
相应的类型规则是:
这里最重要的是 if 的规则:
- 条件部分必须是
Bool; - 两个分支必须有相同类型;
- 整个
if表达式的类型就是这个共同类型。
这条规则体现了一个非常典型的类型系统思想:
表达式的类型必须在运行前就能确定,而不能取决于运行时到底走了哪个分支。
如果你读到这里时,想快速确认 Bool、Nat、Unit、类型判断 Γ ⊢ t : T 等记号的含义,也可以顺手回查附录A和附录B中的对应条目。
6.2.3 关于 Nat 与机器整数
理论里的 Nat 一般表示数学意义上的自然数。
它和真实语言里的 int、i32、u64 并不完全一样。
更准确地说:
Nat常被用作一个理想化的、无限的自然数类型;- 而真实语言里的机器整数通常是有位宽限制的;
- 因此它们会涉及溢出、符号位、底层表示等额外问题。
所以,把 Nat 理解成“和真实语言整数很像的理论近似”是可以的;但不要把二者完全等同。
6.2.4 与真实语言的对照
| 理论类型 | 直观对应 |
|---|---|
Bool | 多数语言中的 bool 或布尔类型 |
Nat | “理想化的自然数”,直觉上类似整数的一部分 |
Unit | 类似“无有趣返回值”的结果类型 |
如果一定要做编程语言类比,更稳妥的说法是:
Unit在直觉上接近某些语言里的“无有趣返回值”;- 但它不应简单等同于
null; - 某些语言里的
void、某些函数式语言里的unit,通常更接近这个概念。
6.3 积类型:把两个值放在一起
基础类型解决了“原子值”的分类问题,但程序里经常还需要把多个值组合在一起。例如:
- 一个人的名字和年龄;
- 一个函数返回两个结果;
- 一个节点保存一个值和一个子结构。
这就引出积类型(product type)。
6.3.1 直觉
积类型 表示:
一个值同时包含一个 类型的部分和一个 类型的部分。
最简单的例子就是二元组:
如果:
- 的类型是
- 的类型是
那么整个二元组的类型就是:
6.3.2 语法与规则
语法可以扩展为:
对应的类型规则:
这三条规则的意思非常直观:
- 构造二元组时,要分别检查两个分量;
- 取第一分量时,要求原项确实是一个积类型;
- 取第二分量时也是一样。
6.3.3 例子
若:
那么:
进一步:
6.3.4 积类型与逻辑
在 Curry–Howard 对应下,积类型通常对应逻辑中的合取:
这非常自然:
- 一个 类型的值,同时提供一个 和一个 ;
- 一个命题 的证明,也意味着你同时拥有 和 的证明。
6.4 和类型:二选一的值
有些数据不是“同时拥有两部分”,而是“要么是这一种,要么是那一种”。
例如:
- 一个计算结果要么成功,要么失败;
- 一个表达式要么是整数字面量,要么是布尔字面量;
- 一个树节点要么是空,要么是非空结构。
这就引出和类型(sum type)。
6.4.1 直觉
和类型 表示:
一个值要么来自左边的 ,要么来自右边的 。
但仅仅说“要么是左边、要么是右边”还不够,因为运行时还必须能区分到底是哪一边。因此和类型的值通常带有一个标记。
常见写法是:
inl(t):表示这是左侧分支inr(t):表示这是右侧分支
不过这里要补一个形式化层面的说明。
如果我们采用带显式类型注解的 Church 风格(Church-style)项,那么仅写 inl(t) / inr(t) 往往还不够,因为:
inl(t)只告诉你“这是左侧注入”;- 但没有直接告诉你右侧到底是哪种类型;
- 因而同一个
inl(t)可能同时属于很多不同的和类型。
所以在更严格、语法制导(syntax-directed)的写法里,常会把它写成类似:
inl(t) as T1 + T2inr(t) as T1 + T2
本章后面的规则先沿用较简洁的 inl(t) / inr(t) 记法,强调其核心直觉;但你应当记住:
若想把和类型写成完全语法制导、可直接检查的 Church 风格系统,注入项通常需要把目标和类型也一并标出来。
也可以换一种理解方式:
- 若不显式写出目标和类型,
- 那么这里展示的就是一种更偏声明式的类型规则,
- 它说明“什么情况下该判断成立”,而不一定直接对应最简洁的检查算法。
6.4.2 语法与规则
语法扩展为:
若采用更严格的 Church 风格写法,也可以把前两项改写成:
这样注入项本身就携带了足够的目标类型信息。
类型规则:
最值得注意的是 case 规则:
- 被分析的对象必须确实是和类型;
- 左右两个分支都必须能产生同一个结果类型 ;
- 整个
case表达式的类型才是 。
这和前面的 if 很像:
虽然运行时只会走其中一个分支,但类型系统必须在静态上知道不管走哪边,结果类型都一致。
6.4.3 一个例子
若:
则:
如果再定义:
- 左分支把自然数转换成某个结果类型
- 右分支把布尔值也转换成同一个结果类型
那么整个 case 才是良类型的。
6.4.4 和类型与逻辑
在 Curry–Howard 对应下,和类型通常对应逻辑中的析取:
因为一个 类型的值,本质上就是:
- 要么给你一个 ;
- 要么给你一个 ;
- 并且告诉你是哪一种。
6.5 记录类型:带名字的积类型
普通积类型通过位置来访问分量:
- 第一项
- 第二项
但在实际编程中,我们经常更希望通过字段名访问。
这就引出记录类型(record type)。
6.5.1 直觉
一个记录类型看起来像这样:
它和积类型的区别不是“本质结构变了”,而是:
- 积类型按位置组织分量;
- 记录类型按名字组织分量。
所以记录类型可以理解为:
带标签的积类型。
不过从后续章节的角度看,还需要再补一句更精确的定位:
本教程后文默认把记录看作按字段名组织的结构类型。
这意味着:
- 记录是否“兼容”,主要看字段集合与字段类型;
- 而不是看某个类型名、类名或继承层次。
这条视角会在第 8 章讨论结构子类型(structural subtyping)时真正进入中心位置。
6.5.2 最小语法与规则骨架
为了让记录类型和前面的积类型、和类型在形式化层级上更平衡,这里给出一个最小骨架。
语法可以写成:
其中:
l_1,\dots,l_n是字段名;t.l表示从记录t中取出字段l。
最基本的类型规则形状是:
以及投影规则:
这些规则表达的直觉很简单:
- 构造记录时,要分别检查每个字段的值;
- 读取字段时,要求原项确实具有对应字段。
这里不再展开更完整的求值规则,因为本节的重点是建立“按字段名组织数据”的类型直觉,并为第 8 章的记录子类型做准备。
6.5.3 作用
记录类型的重要性不止在于“方便组织数据”。
更重要的是,它为后面第八章的子类型打下基础。
因为一旦有了命名字段,我们就可以自然地讨论:
- 一个包含更多字段的记录,
- 是否可以在只要求其中部分字段的地方使用?
这正是宽度子类型化会研究的问题。
也就是说,第八章里最直观的“记录子类型”例子,其实就是建立在这里的记录类型直觉之上。若你之后读到 S <: T、宽度子类型化、深度子类型化时觉得抽象,可以先回到这一节重新看“按字段名组织数据”这件事。
更进一步说,第 8 章默认采用的是:
结构性记录视角。
也就是说,记录的可替换性由字段结构决定,而不是由名字决定。这一点和很多现实语言中的名义类型(nominal typing)并不相同,阅读时要注意区分。
6.5.4 与现实语言的关系
许多语言中的以下构造,都可以在直觉上和记录类型对应:
- 结构体
- 对象中的字段集合
- 命名元组
- 简单的数据记录
不过要小心:
真实语言中的对象系统通常还涉及方法、继承、可变状态、封装等更多因素,因此不能把“对象 = 记录”简单画等号。更稳妥的说法是:
记录类型提供了面向对象类型系统的一部分基础结构。
6.6 递归类型:让类型引用自己(本章采用同构递归视角)
到目前为止,我们可以表示:
- 原子值
- 二元组
- 二选一的值
- 命名字段的数据
但还不能自然表示列表、树、链表这类自相似结构。
例如,一个整数列表显然应该满足这样的递归描述:
- 要么是空;
- 要么是一个整数,加上一个“更短的整数列表”。
这就需要递归类型(recursive type)。
本节先把立场说清楚:
本章采用的是同构递归(iso-recursive)视角,而不是等价递归(equi-recursive)视角。
也就是说,在本章里:
μX.T- 和它展开一层后的
[μX.T/X]T
不会被直接当作“字面上同一个类型”;
它们之间要通过显式的 fold / unfold 来往返。
这条约定很重要,因为后面第 9 章讲类型推断时,还会出现“无限类型(infinite type)为什么被 occur check 拒绝”的问题。那时你需要区分:
- 显式引入的递归类型
μX.T; - 推断过程中无约束地产生的无限展开类型。
这两者相关,但不是同一回事。
6.6.1 语法直觉
递归类型通常写作:
意思是:
- 引入一个类型变量 ;
- 允许它在 中出现;
- 整个式子表示“把这个自引用封起来”的递归类型。
这里的 是类型层面的绑定变量,不要把它和第七章的多态量化混淆。
在这一章里,它只是为了表达递归结构,而不是为了表达“任意类型”。
也正因为本章采用的是同构递归(iso-recursive)视角,所以后面会显式引入 fold 和 unfold;如果采用的是等价递归(equi-recursive)视角,则很多教材会把“展开一层”和“折叠回去”的关系直接并入类型相等。
6.6.2 列表的例子
一个元素类型为 的列表,可以写成:
它的意思是:
- 列表要么是空列表,对应
Unit; - 要么是一个头元素 和一个尾列表 的二元组。
这里要注意一个细节:
式子里的 是“元素类型参数”的占位记号,不是说我们已经进入了第七章那种显式多态系统。更准确地说,这里是在描述:
对于任意一个固定的元素类型 ,都可以写出一个相应的列表类型模式。
如果你想把它写得更明确,也可以说:
- 先固定某个元素类型
Elt - 再定义列表类型为
这样更不容易和多态量化混淆。
6.6.3 树的例子
类似地,一个带整数标签的二叉树可以写成:
它表示:
- 要么是空树;
- 要么是一个节点,节点里有一个
Nat值和两个子树。
6.6.4 折叠与展开
在同构递归的形式化处理中,通常需要两种操作:
foldunfold
其类型规则可写为:
直觉上:
unfold是把递归类型“打开一层”;fold是把展开后的结构“重新包回去”。
之所以需要这两个操作,是因为在同构递归视角下,类型系统需要精确地区分:
- “递归类型本身”
- 和“它展开一层之后的样子”
如果采用的是等价递归视角,很多教材会把这种“展开一层”和“折叠回去”的关系内化到类型相等里;
而本章没有这样做,正是因为我们选择了更适合入门说明规则结构的 fold / unfold 写法。
这里顺便提前埋一个和第 9 章有关的伏笔:
显式递归类型是语言设计中主动加入的构造;而 HM 推断里的 occur check 所阻止的,是系统在合一过程中无约束地产生无限类型。
因此:
- “允许
μX.T”
不等于 - “允许把任意类型变量都自动解成无限展开结构”。
这两者要到第 9 章放在一起看,边界才会完全清楚。
6.6.5 递归类型为什么重要?
因为很多最常见的数据结构都依赖递归定义:
- 列表
- 树
- 语法树
- 链表
- 某些对象结构
没有递归类型,语言虽然能表示很多局部数据,但很难自然表达这些层层嵌套的无限族结构。
6.7 可选类型与错误结果:和类型的常见实例
为了帮助你把和类型和真实语言联系起来,这里单独强调两个常见模式。
6.7.1 可选类型
“一个值要么存在,要么不存在”可以自然写成:
或者也可以写成:
这两种写法本质上都可以表达“可选值”,区别只在于你约定:
- 左边代表空,右边代表有值;
- 还是左边代表有值,右边代表空。
因此,在把某门具体语言的 optional、nullable、maybe 等机制和和类型对应时,最稳妥的说法是:
它们通常可以看作某种和类型编码的直觉对应。
但不要把“左注入一定是空”或“右注入一定是空”当成普遍规则——那只是具体约定。
6.7.2 错误联合与结果类型
“一个计算要么成功返回结果,要么返回错误”也很适合用和类型表达,例如:
或者反过来:
同样,哪一边表示成功、哪一边表示错误,取决于你的具体约定。
这种模式在很多语言里都非常常见,因此和类型不仅是一个理论构造,也是非常实用的程序设计工具。
6.8 可变引用:从纯计算走向状态
到目前为止,本章讨论的构造大多仍然适合纯函数式语言:
值一旦构造出来,就不会被“原地修改”。
但很多实际语言还支持可变存储。
这就引出引用类型(reference type):
6.8.1 直觉
如果一个项的类型是 ,可以把它理解成:
一个指向存储单元的引用,而这个存储单元里应该放一个 类型的值。
于是会有三种典型操作:
- 创建引用:
ref(t) - 读取内容:
!t - 写入内容:
t_1 := t_2
6.8.2 类型规则
这些规则表达的都是最自然的直觉:
- 只有当你确实有一个 类型的值时,才能创建
Ref(T); - 只有当你确实有一个
Ref(T)时,才能解引用得到T; - 只有当引用和写入值的类型匹配时,写入才合法。
6.8.3 为什么引用让系统更复杂?
一旦引入可变引用,程序的计算就不再只是“项自身如何归约”,而变成了:
- 项怎样变化;
- 存储(store)怎样变化。
这意味着第五章里的类型安全证明也要相应升级。
尤其是保持性,不再只需要跟踪项的类型,还要跟踪堆中位置存放的内容类型。
这通常需要额外引入存储类型(store typing)之类的辅助概念。
所以从理论角度说:
可变状态是从纯 Lambda 风格语言走向现实编程语言的重要分水岭。
这里也提前为第 8 章埋一个关键伏笔:
一旦再把子类型加入系统,
Ref(T)的变化方向就会立刻变得微妙。
原因是:
- 读取操作看起来希望它协变;
- 写入操作看起来又希望它逆变;
而这两种要求会彼此冲突。第 8 章讨论“不变性(invariance)”时,会回到这一点。
6.9 与真实语言的对照:应该怎样理解?
这一章里的很多构造在现实语言中都有明显影子,但类比时必须保持克制。
6.9.1 可以安全使用的直觉类比
- 积类型接近二元组、元组、结构体中的“同时包含多个部分”
- 和类型接近枚举、变体、结果类型、可选类型
- 记录类型接近按字段名组织的数据
- 递归类型接近列表、树、链表等自引用数据结构
- 引用类型接近“指向可变存储位置”的引用或地址
6.9.2 不应说得太满的地方
Unit不应简单等同于nullNat不应简单等同于机器整数- 某门语言的对象系统不应直接等同于记录类型
- 某门语言的 optional / result 机制也不应被说成“就是和类型本身”,更准确的说法是“可用和类型直觉理解”
理论构造和真实语言之间通常是:
相似、相关、可类比,但不严格一一对应。
保持这种边界感,会让后面的多态、子类型和引用讨论更清楚。
6.10 本章小结
这一章在 STLC 的基础上,逐步加入了更丰富的一阶类型构造:
- 基础类型让我们能够表示布尔值、自然数和单位值。
- 积类型表示“同时拥有两部分”的值。
- 和类型表示“二选一”的值,并要求通过分支分析来使用。
- 记录类型是带字段名的积类型,并为后续的结构子类型讨论打下基础。
- 递归类型让类型可以表达列表、树等自相似结构;本章明确采用的是同构递归(iso-recursive)视角。
- 可变引用把语言从纯计算推进到带状态的计算模型,也为后面“为什么引用通常不变”埋下伏笔。
如果把这一章压缩成一句话,那就是:
一阶类型系统的核心任务,是让类型系统能够描述常见的数据形态,而不仅仅是函数。
而从全书主线看,本章向后输出了三条特别重要的线索:
- 第 7 章会继续讨论:如何对类型本身做抽象;
- 第 8 章会回到本章的记录类型与引用类型,讨论结构子类型与变化方向;
- 第 9 章则会从另一个角度回看本章的递归类型,帮助你区分“显式递归类型”和“推断中被 occur check 拒绝的无限类型”。
下一章开始,我们会进一步扩展类型系统的能力,不再只给“具体类型”分类,而是开始讨论:
如何对类型本身做抽象。
回看导航
如果你读完本章后,想把这一章重新挂回全书主线,最推荐的回看顺序是:
- 回看第 5 章:重新确认
Γ ⊢ t : T、类型规则与类型安全的基本骨架; - 回看附录A:重点查看“基础类型”“积类型”“和类型”“记录类型”“递归类型”“可变引用”“存储类型”等条目;
- 回看附录B:重点确认
T_1 × T_2、T_1 + T_2、μX.T、Ref(T)、fold、unfold等记号; - 若你准备继续读第 8 章,建议先把本章的“记录类型”和“引用类型”再看一遍,因为它们会直接成为子类型讨论的背景;
- 若你之后读到第 9 章时对“无限类型为什么被拒绝”感到疑惑,也可以回到本章的递归类型部分,重新确认“显式递归类型”和“推断中自动生成的无限类型”不是同一回事。
本章练习
- 解释为什么
if t1 then t2 else t3的两个分支必须有相同类型。 - 写出一个
Bool × Nat类型值的例子,并说明如何分别取出它的两个分量。 - 用自然语言解释类型 中的值可能长什么样。
- 说明为什么列表类型可以写成“空列表或头元素加尾列表”的递归形式。
- 为什么引入
Ref(T)之后,类型安全证明需要额外跟踪存储的类型信息?
第七章 二阶类型系统:多态与抽象
第七章 二阶类型系统:多态与抽象
阅读提示
本章会频繁使用
∀X.T、∃X.T、ΛX.t、t[T]、参数化多态(parametric polymorphism)、存在类型(existential type)、类型算子(type operator)等术语。若你在阅读时想快速回查定义与记号,建议配合:
- 附录A《术语表》中的 A.5“多态、二阶系统与抽象相关术语”
- 附录B《符号速查表》中的“类型系统相关记号”“容易混淆的符号对照”
第六章的一阶类型系统已经能够描述很多常见的数据结构:布尔值、自然数、积类型、和类型、记录、递归类型、引用类型等。但它仍然有一个明显限制:
它只能谈“具体类型”,还不能自然表达“对任意类型都成立”的程序。
例如,恒等函数应该不只适用于 Bool,也不只适用于 Nat。我们真正想表达的是:
- 它对布尔值可用;
- 对自然数也可用;
- 对任意类型的值都可用。
这就引出本章的主题:二阶类型系统(second-order type system)。
这里的“二阶”主要是指:
- 类型系统中不仅有“项变量”;
- 还允许对“类型变量”进行抽象和应用;
- 因而可以在类型层面表达多态与抽象。
本章要回答的问题是:
- 为什么一阶系统不够;
- System F 如何表达参数化多态;
- 存在类型如何表达“隐藏实现、暴露接口”;
- 为什么类型算子会把我们进一步带向更高阶的类型层结构。
同时要先固定一个边界:
本章前半的核心对象是 System F(也叫二阶 Lambda 演算),重点讨论显式的全称多态;后半的存在类型与类型算子,则更适合看作围绕这条主线展开的典型扩展与延伸。
因此,本章的重点是建立这些概念的直觉和最基本形式,而不是完整覆盖所有高阶类型理论细节。尤其是存在类型和类型算子部分,会以“够用的形式化 + 清晰的直觉”来呈现;更深入的 kind、Fω 等主题,只做方向性介绍。
如果你之后要读第 9 章,也请先记住一个重要区分:
本章讨论的是显式写进对象语言的多态构造;第 9 章讨论的则是 HM 路线中更受限但更易自动推断的多态机制。两者相关,但不是同一个系统。
7.1 为什么需要二阶类型系统?
先看一个在前面章节里已经很熟悉的函数:恒等函数。
在带类型注解的一阶系统里,我们可以写:
也可以写:
这两个函数当然都叫“恒等函数”,但在一阶系统里,它们是两个不同的项:
- 一个作用于
Bool - 一个作用于
Nat
问题在于:
我们缺少一个统一表达,来说明“这其实是同一种程序模式,只不过适用于任意类型”。
也就是说,一阶系统缺少一种能力:
对类型本身做抽象。
而这正是参数化多态的核心思想。
7.1.1 两种“多态”直觉
在进入形式系统之前,先区分两种常见直觉:
- 参数化多态:程序对“任意类型”都以统一方式工作;
- 特设多态:程序根据不同类型采取不同实现方式。
本章讲的是第一种,即参数化多态。
它的典型特征是:
- 程序不会“检查当前类型到底是什么”;
- 而是对所有类型一视同仁地工作。
例如恒等函数:
- 不关心输入是布尔值还是自然数;
- 它只是把输入原样返回。
这正是参数化多态最纯粹的例子。
7.2 System F:最经典的参数化多态系统
表达参数化多态的经典形式系统是 System F,也叫二阶 Lambda 演算(second-order lambda calculus)。
它在普通 Lambda 演算的基础上,多加入了两类构造:
- 类型抽象(type abstraction)
- 类型应用(type application)
可以把它和普通函数抽象做一个平行类比:
| 值层面 | 类型层面 |
|---|---|
直觉上:
- :对“值”抽象
- :对“类型”抽象
以及:
- :把函数应用到一个值
- :把多态项应用到一个类型
这里先特别提醒一个后文会反复用到的边界:
在本章里,
∀X.T、ΛX.t、t[T]都是对象语言的一部分;也就是说,它们不是讲解时临时使用的元语言记号,而是 System F 本身的正式构造。
这点和第 9 章会出现的 HM 类型方案(type scheme)非常不同。到那时你会看到:虽然两边都会写出某种 ∀,但它们所在的层级并不相同。
7.2.1 类型与项的扩展语法
System F 在类型层面增加:
其中:
- 是类型变量
- 表示“对任意类型 ,类型 成立”
在项层面增加:
其中:
- 是类型抽象
- 是类型应用
7.2.2 ∀X.T 的直觉
类型
读作:
对任意类型 ,都有一个 类型的程序。
例如:
表示:
一个程序,对任意类型 ,都能接受一个 类型的值,并返回一个 类型的值。
这正是“多态恒等函数”的类型。
7.3 参数化多态的最基本例子:多态恒等函数
现在可以正式写出多态恒等函数:
它的类型是:
这比前面的一阶版本更强,因为它不是某个具体类型上的恒等函数,而是:
对任意类型都成立的恒等函数。
7.3.1 如何使用它?
要把多态函数用于某个具体类型,需要做一次类型应用。
例如,如果记:
那么:
以及:
更进一步:
它的使用过程和普通函数应用完全平行:
- 先把多态项实例化为某个具体类型;
- 再把普通值传给它。
7.3.2 这和“一阶里写两个恒等函数”有什么不同?
一阶系统里,我们只能分别写:
Bool -> Bool版本Nat -> Nat版本- 其他类型再写其他版本
而 System F 里,我们写出的是一个统一的项:
它明确表达了:
- 这个程序模式本身是统一的;
- 只是当你真正使用时,才把其中的类型变量实例化成具体类型。
这就是参数化多态的本质。
7.4 System F 的基本类型规则
为了让前面的写法不只是直觉,我们还需要给出最基本的类型规则。
7.4.1 类型抽象规则
它的意思是:
- 在环境中加入类型变量
- 如果项 的类型是
- 那么类型抽象 的类型就是
这里需要补一个记号层面的说明。
在第五章里,我们把 介绍成“项变量到类型的有限映射”;而到了 System F,这个记号通常会被略作扩展,用来同时记录:
- 项变量的类型假设;
- 当前可用的类型变量。
因此这里的 $\Gamma, X$ 最稳妥的理解是:
在原有项变量上下文的基础上,额外假定类型变量
X处于作用域中。
有些教材会把这两类信息拆成两个上下文分别书写,例如把“类型变量上下文”和“项变量上下文”分开;本章为了保持记号简洁,继续沿用同一个 $\Gamma$,但你应当记住它在这里的含义比第五章稍宽。
可以把它类比成普通函数抽象:
- 普通函数抽象:假设一个值变量,再构造函数体
- 类型抽象:假设一个类型变量,再构造项
也就是说,从这一章开始,Γ 不再只是“变量到类型的映射”,而是一个同时承载项变量假设与类型变量作用域信息的上下文。
7.4.2 类型应用规则
它的意思是:
- 如果 是一个对任意类型都成立的多态项;
- 那么就可以把其中的类型变量 用某个具体类型 替换掉;
- 从而得到实例化后的类型。
这里的 是类型层面的替换。
它和第三章项层面的替换在形式上很像,但对象不同:
- 第三章替换的是项中的变量;
- 这里替换的是类型中的类型变量。
7.4.3 一个完整的类型推导直觉
对于:
可以这样理解它的类型推导:
- 假设类型变量 可用;
- 在此基础上,判断 的类型;
- 显然它的类型是 ;
- 因此整个项的类型是:
这个过程本质上就是:
先在类型层面引入一个“任意类型”的占位符,再在其下构造一个普通函数。
7.5 参数化多态到底提供了什么?
如果只看恒等函数,参数化多态好像只是“少写几遍类型”。但它的意义远不止于此。
这里也可以顺手和第 9 章先做一个对照:
- 本章讨论的是显式写出的参数化多态;
- 第 9 章讨论的是 HM 路线中可自动推断的 let-多态。
前者表达力更强,后者更易算法化。理解这条分界线,会让后面读 HM 时轻松很多。
7.5.1 统一表达“对任意类型成立的程序”
例如下面这些程序模式都天然是多态的:
- 恒等函数
- 常函数
- 函数组合
- 把一个函数应用到一个值
- 在不检查具体类型的前提下重新组织数据
这些程序共同点在于:
它们的行为不依赖于具体类型的内部结构。
7.5.2 支持抽象与复用
参数化多态让我们可以写出真正可复用的程序,而不是为每种类型重复造轮子。
例如“一对相同类型值”的构造函数,就可以统一写成某种多态形式,而不必分别为:
BoolNatString- 记录类型
- 列表类型
都单独写一个版本。
7.5.3 与“只靠类型别名复用”不同
要注意,参数化多态不是简单的“类型写少一点”。
它是一种真正的表达能力增强,因为它允许类型变量进入程序结构,并在使用时被实例化。
7.6 参数化多态与真实语言中的泛型
System F 常被当作现代泛型与参数化多态语言的理论原型。
7.6.1 直觉对应
例如,许多语言或伪代码里都会有类似“恒等函数”的写法:
identity x = x
这类例子能帮助你抓住一个很弱但有用的直觉:
- 这个函数看起来不依赖输入的具体类型细节;
- 它只是把输入原样返回。
而在类型理论里,System F 给了这种直觉一个明确的形式:
不过这里要特别保持边界感。
如果你拿动态语言里的函数来做类比,它只能帮助你理解“同一段程序代码似乎能处理多种值”这一点,却不能直接等同于参数化多态。因为在动态语言里,函数往往仍然可以:
- 检查运行时值的种类;
- 根据不同输入走不同分支;
- 利用运行时信息改变行为。
而真正的参数化多态直觉更强调:
程序对所有类型以统一方式工作,而不是在运行时窥探“当前到底是什么类型”。
7.6.2 与真实语言并不完全一样
不过要保持边界感。
现实语言中的泛型机制并不一定就是完整的 System F;而动态语言里“一个函数能处理多种值”这件事,也不应直接被说成参数化多态本身。
更稳妥的说法是:
- System F 提供了参数化多态的一个经典理论核心;
- 某些静态语言中的泛型、某些动态语言里“统一处理多种值”的经验直觉,都能帮助你从不同角度靠近这个主题;
- 但真实语言通常还会加入额外特性,例如:
- 类型擦除
- 约束
- 类型类
- 特化
- 推断
- 运行时类型信息
因此,不能简单说:
“某门语言的泛型系统就是 System F。”
或者
“动态语言里一个看起来通用的函数,就已经等于参数化多态。”
更准确的说法是:
System F 是理解参数化多态的重要理论模型。
7.6.3 关于 Zig 的类比
如果要把本章内容和 Zig 联系起来,最稳妥的说法是:
- Zig 也支持把“类型”作为编译期参数参与程序构造;
- 但它的机制与 System F 中的纯粹类型量化并不完全相同;
- 因而更适合作为“相关设计”的比较对象,而不是“直接等价物”。
也就是说:
Zig 中的编译期类型参数可以帮助你建立“程序对多种类型适用”的直觉,但它不应被直接当成 System F 的字面实现。
7.7 System F 的一个重要边界:推断并不总是容易
从第五章到现在,我们一直使用显式类型注解。
这是有意为之,因为一旦进入多态系统,很多事情会立刻变得更微妙。
一个重要事实是:
在显式注解的 Church 风格下,System F 的类型检查是可以讨论和实现的;
但在不带注解的 Curry 风格下,完整的 System F 类型推断并不像 Hindley–Milner 那样简单。
这里最好把边界说得再硬一点:
- System F 允许你在对象语言里显式写出
∀X.T、ΛX.t、t[T]; - HM 则主要允许在
let绑定处得到可泛化的类型方案(type scheme); - 因而第 9 章不是在讲“如何自动推断本章完整系统”,而是在讲一条表达力更受限、但更适合自动推断的路线。
本章不展开这些技术细节,只先记住一条主线:
- System F 擅长表达参数化多态
- 但“如何自动推断出这些类型”是另一回事
这也是为什么现实语言常常在“表达力”和“可推断性”之间做取舍。第九章讲 Hindley–Milner 时,这条线索会重新出现。
7.8 存在类型:隐藏实现,只暴露接口
从这一节开始,本章会稍微离开“最小 System F 核心”,转而讨论围绕二阶多态展开的一个典型扩展:存在类型(existential type)。
也就是说,下面的内容仍然和本章主线紧密相关,但你最好把它理解成:
围绕显式多态展开的抽象机制扩展,而不是“前面最小 System F 核心的唯一继续写法”。
前面讲的全称类型
表达的是:
对任意类型都成立。
而现在要讲的存在类型
表达的是:
存在某个类型 ,使得 成立。
这两个量词虽然都作用在类型变量上,但直觉完全不同。
7.8.1 ∀ 与 ∃ 的区别
- :强调统一适用于所有类型
- :强调隐藏某个具体类型,只暴露其接口
可以把存在类型理解成一种“打包”机制:
- 包里确实有一个具体实现类型;
- 但包的使用者看不到它究竟是什么;
- 使用者只能通过给定接口操作它。
这正是数据抽象最核心的思想。
7.9 一个存在类型的例子:抽象计数器
考虑这样一种抽象对象:
- 它能创建一个计数器状态;
- 能读取当前值;
- 能得到递增后的新状态。
我们可以写成:
这里的直觉是:
C是内部状态类型;- 但外部用户并不知道
C到底是什么; - 用户只知道有一组操作围绕这个隐藏类型工作。
这个 C 可能是:
- 一个自然数;
- 一个更复杂的记录;
- 某种编码后的结构;
但这些实现细节都被隐藏起来了。
于是存在类型表达的是:
我不告诉你内部表示类型是什么,但我保证有这样一组接口。
这和很多现实语言里的“抽象数据类型”思想非常接近。
7.10 存在类型的基本直觉规则
为了让“隐藏实现”这件事更形式化,存在类型通常配套两种操作:
- 打包(pack)
- 拆包(unpack)
虽然本章不把完整规则系统展开到所有细节,但至少要理解这两步的角色。为了让直觉更稳,这里先给出一个最小规则形状。
一种常见写法是:
- 打包:
- 拆包:
它们的直觉分别是:
pack:选定某个具体实现类型S,再把对应实现值t封装进存在类型;unpack:临时打开这个包,在t_2中把隐藏类型当作抽象的X、把对应实现值当作x来使用。
这里最重要的不是记住完整形式细节,而是抓住一条作用域纪律:
拆包之后,你只能把隐藏类型当作一个抽象占位符来使用,而不能依赖它的真实实现身份。
7.10.1 打包
打包做的事情是:
- 选定某个具体类型 ;
- 构造一个使用该类型实现的值;
- 把它封装成某个存在类型。
直觉上类似于:
“这里有一个实现,它确实满足接口,但我不把实现类型直接告诉你。”
7.10.2 拆包
拆包做的事情是:
- 打开一个存在类型包;
- 临时拿到“隐藏类型变量”和对应值;
- 但之后只能按照接口使用它,不能依赖其真实实现类型。
直觉上类似于:
“我现在知道这里有个内部类型占位符,但我依然不能假装知道它具体是什么。”
7.10.3 为什么需要拆包规则限制?
因为如果拆包后你能随便窥探实现类型,那“抽象”就失效了。
存在类型的关键不是“里面有东西”,而是:
里面的具体实现必须被隐藏。
也正因此,存在类型通常被看作:
- 模块系统
- 抽象数据类型
- 接口与实现分离
的一个理论原型。
7.11 参数化多态与存在类型的关系
这两个概念经常一起出现,因为它们共同构成了“抽象”的两个方向。
7.11.1 参数化多态:对使用者统一
参数化多态表达的是:
不管你给我什么类型,我都按统一方式工作。
它强调的是泛化与统一行为。
7.11.2 存在类型:对实现者隐藏
存在类型表达的是:
我这里有某种实现,但我不把实现细节暴露给你。
它强调的是封装与隐藏表示。
这两者结合起来,就得到了数据抽象最经典的两面:
- 一方面,程序可以对任意类型编写通用逻辑;
- 另一方面,实现细节可以被包起来,只暴露必要接口。
因此,更稳妥的结论是:
参数化多态与存在类型共同构成了类型化抽象的重要基础。
而不是说它们“已经完整等于所有模块系统”——真实语言的模块机制通常还会包含更多东西。
7.12 类型算子:从二阶多态走向更高阶类型层结构
到目前为止,我们已经允许:
- 类型变量出现在类型里;
- 用量词对类型变量进行抽象。
再往前一步,一个自然问题是:
能不能像写函数那样,写“从类型到类型”的构造?
这就引出类型算子(type operator)。
这里要先明确一个边界:
类型算子已经不再只是“最小 System F 核心里的另一个普通构造”。它更像是从二阶多态继续向前走时,自然会遇到的类型层抽象扩展。
因此,本节的目标不是把完整更高阶系统讲完,而是帮助你建立一个稳定直觉:类型本身也可以像函数参数那样被组织、抽象和复用。
7.12.1 直觉
类型算子可以理解成:
以类型为输入,产生新类型的“类型层函数”。
例如,一个“二元组构造器”可以直觉写成:
那么:
就对应于:
这里要注意:
- 这已经不是普通项层面的 Lambda;
- 而是发生在类型层面的抽象与应用。
7.12.2 为什么它重要?
因为很多类型构造其实都带有“可参数化”的性质。例如:
- 列表类型构造器
- 结果类型构造器
- 映射类型构造器
- 某些模块签名中的类型模板
如果没有类型算子,我们只能得到很多具体展开后的类型;
有了类型算子,就可以把这些“类型模式”本身抽象出来。
7.13 关于类型算子的边界:为什么会引出更高阶系统?
一旦允许类型算子,就会出现新的问题:
- 哪些“类型层函数”是合法的?
- 类型算子的参数自己又是什么“种类”?
- 怎样区分“普通类型”和“从类型到类型的构造器”?
这就会引出 kind(种类)系统,以及更高阶的类型理论,例如 System Fω。
也就是说,到这里为止,本章已经从:
- 一阶系统中“只能谈具体类型”
- 走到 System F 中“可以显式量化类型变量”
- 再走到“可以抽象生成类型的方式”
这条线索说明:类型层面的抽象能力会一层层增强,而每增强一层,系统边界与形式化负担也会随之上升。
但对本章来说,你只需要先抓住一个最核心的认识:
类型算子让我们不仅能使用类型,还能抽象“生成类型的方式”。
这已经足以帮助你理解为什么“类型层面的抽象能力”会比一阶系统强得多。
7.14 本章小结
这一章引入了从一阶类型系统走向二阶类型系统的三个关键概念。
7.14.1 参数化多态
通过全称量化:
我们可以表达:
- 一个程序对任意类型都成立;
- 它的行为不依赖于具体类型的内部结构。
这正是 System F 最核心的内容。
7.14.2 存在类型
通过存在量化:
我们可以表达:
- 某个实现类型确实存在;
- 但它被隐藏起来;
- 外部只能通过接口使用它。
这提供了抽象数据类型的核心直觉。
7.14.3 类型算子
通过类型层面的抽象与应用,我们可以把:
- “某个具体类型”
- 推进为
- “一个生成类型的模式”
从而把类型本身也变成可组织、可抽象的对象。
7.14.4 如果压缩成一句话
本章最重要的一句话是:
二阶类型系统让我们不仅能给项分类,还能对类型本身做抽象。
这使得类型系统的表达力一下子扩大了很多:
- 可以表达真正的参数化多态;
- 可以表达隐藏实现的数据抽象;
- 还可以进一步走向类型层面的函数与高阶结构。
同时,也要记住本章内部的层次差别:
- System F 是本章前半的显式多态核心;
- 存在类型是围绕这条主线展开的重要抽象扩展;
- 类型算子则把我们进一步带向更高阶的类型层结构。
如果把本章和第 9 章放在一起看,最值得记住的分界是:
本章强调显式写进对象语言的多态表达力;第 9 章强调更受限但更易自动推断的 HM 路线。
回看导航
如果你读完本章后,想把“二阶类型系统”重新挂回全书主线,最推荐的回看顺序是:
- 回看第 6 章:重新确认一阶系统为什么只能处理“具体类型”,从而看清本章为什么必须引入“对类型本身的抽象”。
- 回看附录A:重点查看“参数化多态”“System F”“全称类型”“存在类型”“打包 / 拆包”“类型算子”“kind / 种类”等条目。
- 回看附录B:重点确认
∀X.T、∃X.T、ΛX.t、t[T]这些记号,以及它们和普通项层构造λx.t、t1 t2的对应关系。 - 若你之后继续读第 9 章,务必先把本章和 HM 路线做一个对照:
- 本章强调更强的显式多态表达力;
- 第 9 章强调更受限但更易自动推断的多态系统。
- 如果你在阅读第 9 章时开始把“对象语言中的
∀X.T”和“环境中的类型方案∀α.T”混在一起,也建议立刻回到本章 7.2、7.4、7.7 重新确认这条分界线。
本章练习
- 解释为什么一阶类型系统无法自然表达“对任意类型都成立的恒等函数”。
- 写出多态恒等函数的 System F 项,并说明它为什么具有类型:
- 比较 与 的直觉区别。
- 用自然语言解释为什么存在类型可以表达“隐藏实现、暴露接口”。
- 说明“类型算子”与“普通函数”的平行关系:它们分别作用在什么层面?
下一章,我们将继续扩展类型系统的表达能力,但方向会发生变化:
不再主要讨论“对任意类型的抽象”,而是讨论“一个类型能否安全地替代另一个类型”。
这就进入了子类型。
第八章 子类型
第八章 子类型
阅读提示
本章会频繁使用
S <: T、T-Sub、协变、逆变、不变等术语。若你一时记不清它们的定义或记号,可随时回查:
- 附录A《术语表》中的 A.6“子类型与变化方向相关术语”;
- 附录B《符号速查表》中的“类型系统相关记号”“容易混淆的符号对照”。
同时,本章直接依赖:
- 第 5 章中的类型判断
Γ ⊢ t : T与类型安全主线;- 第 6 章中的记录类型与可变引用。
前几章介绍的类型系统,默认都带着一个很强的假设:
一个项如果被判断为某个类型,那它通常就只能按这个类型来使用。
但在实际编程里,我们经常希望允许一种更灵活的情况:
- 如果一个值“比要求的还具体”,能不能把它当成“更一般”的值来用?
- 如果一个记录拥有比你要求更多的字段,能不能把它交给只关心其中一部分字段的代码?
- 如果一个函数返回更精确的结果,它能不能代替返回较一般结果的函数?
这类问题的统一答案,就是子类型(subtyping)。
本章默认讨论的是结构子类型(structural subtyping)语境。也就是说:
- 我们主要关心类型结构本身如何决定可替换性;
- 尤其以记录类型和函数类型为核心例子;
- 不把“类继承”“名义类型(nominal typing)”“运行时对象模型”直接当作本章的正式对象。
这一章的主线非常明确:
- 子类型的直觉是安全替换;
- 形式上我们写作 ;
- 真正关键的地方在于:子类型不仅是类型之间的关系,还会进入类型规则本身;
- 尤其是函数类型的子类型方向,最容易出错。
8.1 子类型的直觉:安全替换
如果 ,我们读作:
类型 是类型 的子类型。
它最自然的直觉解释是:
任何一个需要 的地方,都可以安全地放入一个 。
这通常也被叫做安全替换原则,它和面向对象文献里的里氏替换原则有很强的亲缘关系。
8.1.1 一个最直观的例子:记录
设有两个记录类型:
和
直觉上,前者“信息更多”,后者“要求更少”。如果某段代码只需要一个带 name 字段的记录,那么一个同时带 name 和 age 的记录当然也可以拿来用。
因此我们希望有:
这就是子类型最朴素的来源:
- 更丰富的数据
- 可以被当成更贫瘠的接口来使用
8.2 子类型关系本身:最基本的性质
子类型首先是一个关系。和前面章节中的等价关系、归约关系一样,我们先要说明它具有什么结构性质。
通常,子类型关系至少要求满足:
自反性
任何类型都是自己的子类型。
传递性
如果 可以安全替换为 ,而 又可以安全替换为 ,那么 当然也可以安全替换为 。
因此,子类型关系通常至少形成一个前序:
- 自反
- 传递
这里要注意一个小边界:
子类型关系不一定是偏序,因为不同类型有时可能彼此可替换却不一定在语法上相同。
不过在本章大多数例子里,把它先理解成“可安全替换关系”就足够了。
8.3 subsumption:子类型为什么会进入类型规则?
如果只给出 这样的关系定义,而不把它接入类型判断系统,那么子类型其实还没有真正发挥作用。
真正让子类型“活起来”的规则是 subsumption(包含、提升)规则:
这条规则的意思是:
- 如果一个项本来已经有类型
- 且 是 的子类型
- 那么我们就可以把这个项提升为类型
这正是“安全替换”在类型系统中的正式入口。
如果你对 subsumption 这个词还不熟,建议同时回看附录A中的对应条目;它和本章的主线几乎是同一个问题的两种表述。
8.3.1 为什么这条规则是主线?
很多初学者第一次学子类型时,会把注意力全部放在“哪些类型彼此有子类型关系”。这当然重要,但还不是核心。
真正的主线是:
子类型之所以重要,是因为它允许我们在类型判断里把“更具体的类型”当成“更一般的类型”来使用。
没有 T-Sub,你就只有“一个抽象的关系”;
有了 T-Sub,这个关系才能真正影响程序是否良类型。
8.4 记录子类型:最容易理解的一类
记录子类型是整个章节里最直观的一类。
8.4.1 宽度子类型化(width subtyping)
如果一个记录拥有更多字段,它可以被当成只要求其中部分字段的记录来使用:
这条规则表达的是:
- 左边字段更多
- 右边字段更少
- 左边可以看成右边的子类型
直觉上:
需要得少的地方,可以接受给得更多的值。
例如:
如果某段代码只要求一个类型为
的记录,那么它只会读取 name 字段;这时你给它一个同时带有 name 和 age 的记录,并不会破坏任何事情。也就是说:
- 调用方只要求
name - 实际值除了
name之外还额外带了age - 这些额外信息不会妨碍原本只依赖
name的代码
这正是宽度子类型化最核心的直觉。
8.4.2 深度子类型化(depth subtyping)
如果记录的字段一一对应,而字段类型本身存在子类型关系,那么整个记录也可以形成子类型关系:
这条规则的意思是:
- 同样的字段名
- 更具体的字段类型
- 可以形成更具体的记录类型
8.4.3 宽度与深度可以组合
因此,在复杂一点的记录类型里,你往往会同时用到:
- 宽度子类型化:字段更多
- 深度子类型化:字段类型更具体
- 传递性:把多步推导串起来
这也是为什么 S-Trans 虽然看起来只是“关系论里的常规规则”,但在实际推导中非常有用。
这里还值得补一个小边界:如果把记录理解成“按字段名索引的结构”,那么字段顺序通常不重要;也就是说,我们更自然地把记录看成:
- 一组带标签的字段;
- 而不是单纯按位置排列的序列。
因此,本章讨论记录子类型时,默认采用的也是这种按字段名决定结构的视角。
8.5 函数子类型:本章最容易出错的地方
现在进入本章真正的难点:函数类型的子类型。
设我们要比较两个函数类型:
哪一种情况下,前者应该是后者的子类型?
正确规则是:
这条规则非常重要,必须认真看清方向。
8.6 为什么函数参数要逆变,返回值要协变?
先分别看两个部分。
8.6.1 返回值:协变
函数返回值的方向最自然。
如果一个上下文期待得到类型 的结果,而你实际提供的函数返回的是更具体的 ,且:
那么这是安全的。
因为:
- 调用者只要求“给我一个 ”
- 你实际给了一个“更具体的 ”
- 这当然没问题
所以函数返回值是协变的。
8.6.2 参数:逆变
函数参数的方向最反直觉,也最关键。
如果一个上下文期待的是函数类型:
这意味着:
- 上下文可能拿一个 类型的参数来调用这个函数
那么你想拿来替代它的函数,至少必须能接受所有这些 类型的输入。
因此,这个替代函数的参数类型不能比 更窄;它必须至少一样宽,甚至更宽。形式上就是:
也就是:
- 期待的参数类型在左
- 实际函数的参数类型在右
- 方向反过来
所以函数参数是逆变的。
8.7 一个具体例子:为什么参数不能协变?
这是整个章节里最值得真正想清楚的一个反例。
设:
现在假设我们错误地认为函数参数可以协变。那我们就会允许:
也就是说:
- 一个“只会处理猫”的函数
- 可以被当成“能处理任意动物”的函数
这显然不安全。
为什么?
因为如果某段代码拿到了一个类型为 的函数,它就有权传入任意 Animal,例如一只狗 Dog。
但那个实际函数只能接受 Cat,于是就出问题了。
因此,参数协变会破坏类型安全。
这个反例几乎就是逆变规则最经典的解释:
如果你允许一个“只接受更窄参数”的函数去代替“应当接受更宽参数”的函数,那么调用者迟早会传入一个它处理不了的值。
8.8 重新读一遍函数子类型规则
现在再看一次:
你可以这样记:
- 参数逆变:左看期待,右看实际
- 结果协变:实际结果可以更具体
也可以记成一句话:
一个函数若想充当另一个函数,就必须“收得更宽,给得更窄(更具体)”。
更精确一点说:
- “收得更宽”指参数类型更一般
- “给得更具体”指返回类型更具体
8.9 把子类型接回类型规则:应用并不需要改写
有些教材会直接把应用规则改成“带子类型版本”,例如:
这当然表达了正确直觉,但从结构上说,更清楚的做法通常是:
-
保持原本的
App规则不变: -
再通过
T-Sub去完成“把更具体类型提升为更一般类型”的工作。
这样做的好处是主线更清楚:
App仍然只是“函数应用必须参数匹配”- 子类型的灵活性来自
T-Sub - 整个系统更模块化
不过这里也要补一个实现层面的边界:
这种写法在说明性上最清楚,但在真正设计算法化的类型检查过程时,很多系统还会进一步把规则改写成更语法制导(syntax-directed)的形式。
也就是说,本节的重点首先是把概念结构讲清楚,而不是直接给出某个工业编译器会采用的最终检查算法。
8.9.1 一个简单示意
如果你有:
那么由 T-Sub 可以得到:
这时就能把 t_2 用到要求 T_1 的 T-App 规则中。
所以本章真正的结构主线不是“到处把规则改写成带 <: 的版本”,而是:
先定义子类型关系,再通过 subsumption 让它服务于普通的类型规则。
8.10 其他常见类型构造的变化方向
函数是最难的一类。其他常见构造的方向通常更直观一些。
| 类型构造 | 常见变化方向 | 直觉 |
|---|---|---|
| 函数参数 | 逆变 | 必须接受至少和期待一样宽的输入 |
| 函数返回值 | 协变 | 可以返回更具体的结果 |
| 记录宽度 | 协变 | 字段更多的记录可替代字段更少的记录 |
| 记录深度 | 协变 | 字段类型更具体时整体更具体 |
| 积类型 | 通常协变 | 两边都更具体时整体更具体 |
| 和类型 | 通常协变 | 两边都更具体时整体更具体 |
这里的“通常协变”应理解为:
- 在最常见、纯的、没有额外可变性干扰的设定下;
- 这些构造往往会沿着“组成部分更具体,则整体更具体”的方向变化。
不过这里要立刻补一个边界提醒:
变化方向并不是“看上去顺眼就行”,而是必须由安全替换原则来论证。
尤其在带可变状态的系统中,某些本来直观看似协变的构造,最后可能必须变成不变。
8.11 为什么可变引用通常是不变的?
虽然本章还没有正式展开引用子类型,但这是一个非常重要的现象,值得在这里先建立直觉。
设你有引用类型:
它既可以:
- 被读取(读出一个 )
- 又可以被写入(写入一个 )
如果你让它协变,就会在“写入”时出问题;
如果你让它逆变,就会在“读取”时出问题。
因此,引用类型通常必须是不变的(invariance)。
这也是一个非常有代表性的模式:
一个既出现在“输出位置”又出现在“输入位置”的类型构造,往往不能简单协变或逆变。
这一点其实已经和第六章的可变引用形成呼应:在那里我们引入了 Ref(T) 及其读写操作,而一旦再把子类型加进来,就会立刻遇到“读取想要协变、写入想要逆变”的张力。
因此,更准确地说:
可变引用之所以通常取不变,不只是一个孤立结论,而是第六章“状态”与本章“变化方向”交叉后的自然结果。
如果你需要回忆引用类型、解引用、赋值和存储类型的基本背景,可以先回看第六章对应小节,再回来看这里的“不变性”会更顺。进一步说,一旦把子类型真正接入带存储的系统,类型安全证明也必须同时跟踪:
- 项本身的类型;
- 存储位置中允许放入什么类型;
- 以及这些位置在读写过程中不会因为错误的变化方向而被破坏。
这也是为什么“引用不变”不仅是一个局部技巧,而是和整个带状态系统的安全性直接相关。
8.12 子类型与类型安全
现在把这一章和第五章连起来。
第五章告诉我们:
- 类型系统的目标,是保证良类型程序不会掉进坏状态;
- 这通常通过进展性和保持性来表达。
引入子类型以后,这个目标并没有改变。变化在于:
- 项不再只有一个“完全精确”的使用方式;
- 一个更具体的值可以被提升为更一般的类型;
- 因此保持性和相关引理中,需要把子类型关系考虑进去。
直观上,这不会破坏安全性,因为:
T-Sub只允许安全提升<:关系本身就是按“安全替换”设计的
所以,子类型并不是“放松类型系统导致不安全”,而是:
在不牺牲安全性的前提下,给类型系统增加更灵活的可用性。
当然,前提是你真的把变化方向写对了。
这也是为什么 S-Arrow 的方向一旦写反,整个系统的安全直觉就会崩掉。
8.13 与真实语言的类比:应当怎样说才稳妥?
很多现实语言里都能看到子类型的影子,但类比时最好保持克制。
可以安全类比的地方
- 记录宽度子类型化,直觉上接近“只关心部分字段的接口”
- 某些面向对象语言中的“子类可在父类位置使用”,在非常粗略的层面上接近子类型思想
- 方法参数与返回值的变化方向,和函数子类型有深刻关系
- 第六章里记录类型与引用类型提供了本章最直接的前置背景:前者帮助理解记录子类型,后者帮助理解为什么某些构造不能自由协变
不应说得太满的地方
- 真实语言的对象系统不等于“记录 + 子类型”
- 类继承不等于语义上完美的子类型
- 真实语言往往还涉及可变状态、方法重写、名义类型、运行时机制等额外因素
因此,更稳妥的说法是:
子类型提供了一套形式化框架,帮助我们理解很多真实语言中的“更具体类型可在更一般位置使用”的现象。
而不是说“某语言的对象系统就是这里的子类型系统”。
换句话说,本章和真实语言之间最适合建立的是:
- 结构上的类比;
- 安全替换直觉上的类比;
- 而不是“逐个语言特性逐字对应”的类比。
8.14 本章小结
这一章的真正主线可以压缩成四句话:
-
子类型的直觉是安全替换:
表示任何需要 的地方,都可以安全地使用 。 -
subsumption 规则是真正把子类型接入类型系统的关键:
-
记录子类型最容易理解:字段更多、字段更具体,都可能带来子类型关系。
-
函数子类型最容易出错,但也最重要:
- 参数逆变
- 返回值协变
也就是说,正确的函数子类型规则是:
如果你读完这一章,只记住一个技术点,那就应该是:
函数参数逆变,返回值协变,而这一切都必须服从安全替换原则。
若你之后在阅读第九章类型推断时,发现自己一时把“类型相等约束”和“子类型关系”混在一起了,也可以回到本章重新确认:
HM 风格推断的主线主要处理“相等 / 合一”,而本章处理的是“可安全提升”的 <: 关系,这两者相关,但不是同一个问题。
回看导航
如果你读完本章后,想把这一章重新挂回全书主线,最推荐的回看顺序是:
- 回看第 6 章:重新确认记录类型与引用类型的直觉,因为它们分别对应本章中最典型的记录子类型与不变性讨论。
- 回看第 5 章:重新确认类型判断、类型安全、进展性与保持性,因为本章的“安全替换”最终仍然服务于“良类型程序不会落入坏状态”这条主线。
- 回看附录A:重点查看“子类型”“安全替换”“subsumption”“协变”“逆变”“不变”等条目。
- 回看附录B:重点确认
S <: T、T-Sub、函数类型箭头T_1 \to T_2与归约箭头\longrightarrow的区别。 - 若你准备继续读第 9 章,也可以先把本章和“类型相等 / 合一”刻意区分开来:本章处理的是“可安全提升”,而不是“必须相等”。
本章向后输出的核心内容是:
S <: T作为安全替换关系的读法;T-Sub如何把子类型真正接入类型判断;- 协变、逆变、不变这三种变化方向的基本直觉;
- 为什么函数参数逆变、返回值协变,以及为什么引用通常取不变。
本章练习
-
解释为什么下面的子类型关系成立:
-
说明为什么
T-Sub是子类型系统的主线,而不是一个“可有可无的补丁规则”。 -
给出一个具体反例,说明如果函数参数按协变处理,会产生什么类型安全问题。
-
设有: 判断下面哪个子类型关系应当成立,并说明理由:
-
用自然语言解释为什么引用类型通常不能简单协变或逆变。
下一章我们将进入另一个非常重要的问题:
如果程序里没有显式写出所有类型注解,编译器如何自动推断出类型?
这就引出类型推断与合一算法。
第九章 类型推断
第九章 类型推断
前几章中,我们主要讨论的是 Church 风格(Church-style)的类型系统:项里直接写出类型注解,例如 。这种做法的优点是规则清楚、类型检查直接,但也有一个明显缺点:
程序员需要手写大量类型注解。
现实中的许多语言并不要求你把所有类型都写出来。相反,编译器会根据程序结构自动推导出类型。这就是本章要讨论的主题:
类型推断(type inference)
阅读提示
本章会频繁使用“Curry 风格(Curry-style)”“约束生成(constraint generation)”“合一(unification)”“类型方案(type scheme)”“泛化(generalization)”“实例化(instantiation)”等术语。若你一时忘了这些词的定义,可随时回查:
- 附录A《术语表》中的 A.3 和 A.7 相关条目;
- 附录B《符号速查表》中的“类型推断与合一相关记号”。
同时,也建议把本章和第 7 章一起看:第 7 章讨论的是表达力更强的显式多态系统
System F,而本章讨论的是更适合自动推断的 HM 路线。
本章要回答的问题是:
- 什么是“推断类型”?
- 为什么可以把推断问题转化为“生成约束 + 求解约束”?
- 什么是合一(unification)?
- 为什么
let绑定可以多态,而普通函数参数通常不行?
为了聚焦核心思想,本章主要采用 Hindley–Milner(HM)风格 的类型推断视角。
在进入正文之前,先固定一个非常重要的边界:
本章讨论的不是“如何自动推断第 7 章完整
System F的显式二阶多态”,而是 HM 风格的let多态推断。
这两条路线都和“多态”有关,但层级不同:
- 第 7 章的
∀X.T、ΛX.t、t[T]是对象语言中的显式构造; - 本章的
∀α.T主要出现在环境中的类型方案(type scheme)里,用来支持let多态; - 因此,HM 可以看作一种表达力更受限、但更适合自动推断的多态路线,而不是“System F 的自动推断版”。
为了减少后文混淆,你可以先把本章中的对象分成三层来看:
-
项层
λx.tt1 t2let x = t1 in t2
-
类型层
α, β, γBoolT1 -> T2
-
环境层
Γ- 其中变量可能绑定到普通类型,也可能绑定到类型方案
带着这三层区分去读后面的“约束生成、合一、泛化、实例化”,会更不容易把第 7 章的显式多态和本章的 HM 推断混在一起。
9.1 从类型检查到类型推断
第五章里我们做的是类型检查:
- 项中已经带有类型注解;
- 我们要验证这些注解是否一致;
- 判断形式通常是 。
例如,对项:
我们不是“猜”它的类型,而是检查它是否能被规则推出为:
而类型推断则不同。它面对的是没有显式类型注解的项。例如:
这里的问题不再是“给定注解是否正确”,而是:
这个项最一般的类型是什么?
直觉上你已经知道答案:它应该是“从某个类型到同一类型的函数”。但要把这个直觉变成算法,我们需要一种系统方法。
9.2 Curry 风格:项不写类型,类型由系统赋予
在类型推断的语境下,我们通常使用 Curry 风格(Curry-style)的项,也就是说,项的语法里不直接包含类型注解。
例如:
与 Church 风格相比:
- Church 风格:
- Curry 风格:
差别不只是“少写了一个注解”。更深层地说:
- Church 风格里,类型检查主要是在验证;
- Curry 风格里,类型系统需要恢复这些省略掉的信息。
这就是类型推断的本质。
这里也顺手和第 7 章再做一次区分:
- 第 7 章里,显式多态项会真的写出
ΛX.t和t[T]; - 本章里,项语法并不包含这些显式类型层构造;
- 因而本章的多态能力主要来自
let绑定的泛化与实例化,而不是对象语言中显式写出的类型抽象与类型应用。
9.3 类型推断想求什么?
给定一个无注解项 ,类型推断希望找到一个类型 ,使得:
在简单例子里,这看起来像是在“直接猜类型”。但在实际算法中,我们不会一开始就猜中,而是:
- 先为未知类型位置放入类型变量
- 再从程序结构中生成约束
- 最后求解这些约束
例如,对项:
我们可以先假设:
- 应用结果
接着根据函数应用的结构知道:
- 如果 成立,那么 必须是一个函数
- 它的参数类型必须和 的类型一致
- 它的返回类型就是整个应用的结果类型
于是得到约束:
最后把这个约束解出来,就得到整个项的类型。
这就是 HM 推断的基本工作流。
9.4 约束生成:先把未知处都记下来
类型推断的第一步,不是立刻求答案,而是先把“必须满足什么条件”收集出来。
9.4.1 类型变量
我们用希腊字母表示未知类型:
它们不是具体类型,而是“待求解的占位符”。
例如:
- 一个还不知道参数类型的函数参数可以记作
- 一个还不知道结果类型的表达式可以记作
9.4.2 约束的来源
约束并不是凭空产生的,它来自类型规则的逆向阅读。
例如,应用规则在第五章写作:
反过来读它就是:
- 如果我想给
t1 t2找类型, - 那就必须让
t1的类型是某个函数类型, - 并且它的参数类型与
t2的类型匹配。
于是应用结构天然会产生“两个类型必须相等”之类的约束。
9.5 一个完整例子:推断
现在我们完整走一遍最经典的例子:
第一步:给未知部分分配类型变量
设:
那么整个项的外形是:
- 的类型应为
- 的类型应为
第二步:由应用结构生成约束
子项 是一个函数应用。
根据应用规则:
- 必须是一个函数;
- 其参数类型必须是 ;
- 其返回类型必须是 。
因此得到约束:
第三步:求解约束
将约束解出后,把它代回整体类型:
代入 ,得到:
第四步:得到最一般单态类型
这里要特别注意层级。
对这个裸 Lambda 项来说,我们首先得到的是它的最一般单态类型:
之所以这里先说“单态类型”,是因为在标准 HM 里:
- 对项结构本身做推断时,先得到的是带类型变量的普通类型;
- 只有当某个定义进入
let绑定环境时,才会进一步触发泛化(generalization),把它提升成类型方案。
因此,更精确地说:
- 这个项的最一般单态类型是
- 若把它放到顶层、或放到可泛化的位置来记录,也可以把对应的最一般类型方案(principal type scheme)写成
这正是我们熟悉的“函数应用器”类型。
9.6 合一:怎样求解类型等式?
约束生成之后,我们得到的是一组类型等式。接下来必须解决的问题是:
怎样求出一组替换,使这些等式同时成立?
这就是合一(unification)。
9.6.1 替换
一个替换 把类型变量映射为类型,例如:
把它作用到类型上时,就把对应变量替换掉。
例如:
本章里我们统一约定:
- 保留给替换使用;
- 类型方案不用 记,而改用
Sch(scheme)或文字说明。
这样可以避免“同一个符号既表示替换、又表示类型方案”的混淆。
9.6.2 合一问题
如果我们有一组约束,例如:
那么一个合一器就是某个替换 ,使得:
成立。
例如:
就是这个单个约束的一个合一器。
但更常见的是有多条约束要一起解,例如:
这时就需要一个替换同时满足两条约束。
9.7 合一算法的核心规则
合一算法的目标是:输入一组类型等式,输出一个最一般合一器(most general unifier, MGU),如果不存在就失败。
最核心的几类情形如下。
9.7.1 相同类型
若约束为:
那么它已经自动成立,不需要额外替换。
9.7.2 类型变量与类型相等
若约束为:
只要 不出现在 中,就可以产生替换:
这是合一算法中最基本的一步。
对称地,若有:
也同样处理。
9.7.3 函数类型相等
若约束为:
那么这不是“一步就能直接解完”的,而是要拆成两个更小的约束:
也就是说,两个函数类型相等,当且仅当:
- 参数类型相等
- 返回类型相等
9.7.4 其他构造子不匹配则失败
例如:
显然无解。
同样,如果两个类型的最外层构造子不同,也无法合一。
9.8 为什么要做 occur check?
在处理约束
时,有一个非常重要的附加检查:
不能出现在 中。
这叫 occur check。
9.8.1 一个危险例子
考虑约束:
如果你试图直接替换:
就会得到无限展开:
这意味着我们实际上在试图构造一个无限类型(infinite type)。
标准的 HM 系统不允许这种无限类型,因此这里必须判定为合一失败。
所以 occur check 的意义就是:
防止类型推断偷偷构造出无限递归类型。
这里要特别和第 6 章的递归类型(recursive type)区分开来。二者不是同一回事:
- 第 6 章里的
μX.T是显式引入的递归类型构造; - 而这里 occur check 阻止的是:在标准 HM 合一过程中,把一个普通类型变量隐式解成无限展开结构。
也就是说:
- “允许显式递归类型”
- 和
- “允许 HM 在没有额外构造的情况下自动推断出无限类型”
不是同一个问题。
这也是为什么第 6 章可以合法讨论 μX.T,而本章仍然必须拒绝 \\alpha = \\alpha \\to \\beta 这样的约束。
9.9 合一算法中的“替换传播”与“替换组合”
这是本章最容易被写错、也最值得澄清的地方。
很多初学者会误以为,若要合一:
只需分别求解:
然后把两个结果“并集”起来。
但这并不准确。真正正确的做法是:
- 先求出第一部分的合一器
- 用 去改写剩余约束
- 再求剩余约束的合一器
- 最终结果是替换的组合
9.9.1 为什么不能直接做“并集”?
因为前一步求出的替换会影响后面的约束。
例如,若有:
正确过程是:
- 先解参数部分:,得到
- 再把它作用到返回部分约束 上,得到:
- 继续求解,得到:
- 最终替换是:
如果你只是把两个结果“并起来”而不传播前一步替换,就会漏掉这种依赖关系。
9.9.2 更准确的函数类型合一写法
因此,函数类型合一更准确地写成:
- 先令
- 再令
- 最终结果为:
这就是标准算法里的“先解、再传播、再组合”。
9.10 一个失败例子:为什么 无法推断?
这是 HM 系统里最经典的失败案例之一。
考虑项:
第一步:分配类型变量
设:
第二步:生成约束
由于 x x 是函数应用:
- 函数位置
x必须是某个函数类型 - 参数位置
x必须匹配其参数类型
于是得到:
第三步:尝试合一
这里立刻触发 occur check:
- 左边是
- 右边是
- 出现在右边
因此无解。
这说明:
在标准 HM 系统中, 不可类型化。
这个例子非常重要,因为它让你看到:
- 不是所有无注解 Lambda 项都能被 HM 推断;
- 失败并不是算法“太弱”,而是该系统本身就不允许这种无限类型。
9.11 let 多态:为什么 let 可以多态复用?
到目前为止,我们得到的类型都还是“单次使用视角”下的。
但真实语言里,一个函数经常希望在多个不同类型上复用。
例如:
let id = fun x -> x in
(id true, id 1)
这里 id 被用了两次:
- 一次作用于
Bool - 一次作用于
Nat
如果 id 只能有一个固定单态类型,这就不成立了。
于是 HM 系统引入了一个非常关键的机制:
let 绑定可以在绑定点进行泛化(generalization),在使用点进行实例化(instantiation)。
9.12 类型方案:不只是一个类型,而是一族类型
为了表达“同一个名字可以在不同地方按不同实例使用”,我们引入类型方案(type scheme)。
为了避免与前面“替换”使用的 记号冲突,这里把类型方案记作:
例如:
表示的不是某一个具体类型,而是一族类型:
Bool -> BoolNat -> NatString -> String- …
这正是恒等函数 id 最一般的多态类型。
如果你把这一节和第 7 章对照起来看,会发现一个非常重要的区别:
- 第 7 章里的
∀X.T是对象语言中的显式多态类型构造; - 本章里的类型方案主要服务于 HM 推断中的环境记录、泛化与实例化;
- 第 7 章还会配套出现显式项构造
ΛX.t与t[T]; - 而本章的项语法里并没有这些显式类型层构造。
因此,虽然两边都写 ∀,但它们不是同一个层次上的东西。更准确地说:
第 7 章讨论的是显式写进对象语言的多态;本章讨论的是 HM 环境中的
let多态。
这也是为什么本章不应被理解成“自动推断完整 System F”,而应理解成:
在表达力更受限的前提下,用类型方案、泛化和实例化来获得可判定、可实现的多态推断。
9.13 泛化(generalization)
当我们看到:
let x = t1 in t2
类型推断的基本思路是:
- 先推断
t1的类型为 - 找出 中那些不被当前环境固定住的类型变量
- 对这些变量做全称量化,得到类型方案
- 再把这个类型方案绑定给
x,继续推断t2
形式上,常写成:
其中 是满足以下条件的类型变量集合:
- 出现在 中
- 但不自由出现在环境 中
直觉上,这表示:
- 若某个类型变量不受当前环境限制,
- 那它就是真正“任意的”,
- 因此可以被泛化成多态变量。
9.14 实例化(instantiation)
一旦某个名字在环境中绑定的是类型方案,例如:
那么每次使用它时,都可以把其中量化的类型变量换成新的实例。
例如:
- 第一次用
id时实例化成: - 第二次用
id时实例化成:
这个过程叫实例化。
其本质是:
每次取出一个多态绑定时,都为其中量化变量生成一份新的、彼此独立的类型变量副本。
这一步非常重要。
如果不生成“新鲜副本”,不同使用位置就会互相干扰,错误地把本应独立的实例绑在一起。
这也正是 HM 与第 7 章 System F 的一个重要阅读分界:
- 第 7 章强调“多态如何被显式写进项和类型里”;
- 本章强调“编译器如何在较受限但可判定的框架里自动恢复这种多态信息”。
因此,本章讨论 let 多态时,最好始终带着这条比较线索来看。
9.15 一个 let 多态的完整直觉例子
考虑:
let id = fun x -> x in
(id true, id 0)
第一步:推断右侧定义
对 fun x -> x,我们已知可推断出:
第二步:泛化
若当前环境没有约束这个 ,那么可泛化为:
于是环境里记录:
第三步:在使用点分别实例化
第一次使用 id true:
- 实例化为
第二次使用 id 0:
- 实例化为
由于这两次实例化彼此独立,因此整个表达式良类型。
这就是 HM 系统里 let 多态的核心。
9.16 为什么函数参数通常不能这样多态?
考虑:
fun id -> (id true, id 0)
直觉上,它和前面的 let id = ... in ... 很像,但在标准 HM 系统中,这个表达式通常不合法。
原因在于:
fun id -> ...中的id是一个普通参数;- 参数进入环境时只得到一个单态类型变量,例如 ;
- 接着由
id true得知: - 又由
id 0得知:
于是必须同时满足:
显然无解。
这说明:
在标准 HM 中,普通函数参数是单态的;只有
let绑定会触发泛化。
这也是为什么 HM 常被概括为:
let-polymorphism,而不是 full polymorphism
9.17 算法 W 的核心思想(直觉版)
经典的 HM 推断算法常被称为 算法 W(Algorithm W)。
这一节只给出它的核心骨架与工作直觉,不展开完整实现细节、正确性证明和所有工程优化。
对于一个项,算法 W 返回:
- 一个替换
- 一个类型
记作:
意思是:
在环境 下,项 经推断得到类型 ,但这个结果成立的前提是要应用替换 。
它对不同语法构造大致这样处理:
变量
- 从环境中取出其类型方案
- 实例化为一个新类型
抽象
- 给参数分配一个新类型变量
- 在扩展环境下递归推断函数体
- 组合结果得到函数类型
应用
- 分别推断函数和参数
- 生成“函数类型必须匹配”的新约束
- 调用合一
- 组合替换
let
- 先推断右侧定义
- 对结果做泛化
- 将泛化后的类型方案加入环境
- 再推断主体
如果你理解了“替换传播”和“let 的泛化 / 实例化”,那么算法 W 的主干就已经掌握了。
这里也再强调一次本章边界:
- 算法 W 服务的是 HM 风格的
let多态推断; - 它并不是“对第 7 章完整显式
System F做自动推断”的通用算法; - 这正体现了 HM 路线的核心取舍:牺牲一部分显式多态表达力,换取更好的自动推断性。
9.18 本章与现实语言的关系
HM 类型推断之所以重要,是因为它抓住了一个很强的工程平衡点:
- 类型注解可以大量省略;
- 推断依然可判定;
- 还能得到很一般的多态类型。
这正是 ML 家族语言成功的重要原因之一。
但也要注意边界:
- 完整的 System F 类型推断是不可判定的;
- 很多现代语言只采用 HM 的一部分思想;
- 实际工业语言常混合使用显式注解、局部推断、子类型、类型类、trait 约束等机制。
因此,更稳妥的说法是:
HM 不是“所有语言推断的统一模型”,但它是理解类型推断最重要的理论起点。
如果你在读到这里时想把主线再串一次,比较推荐的回看顺序是:
- 回看第 5 章:
Γ ⊢ t : T到底在表达什么; - 回看第 7 章:为什么更强的显式多态系统会让自动推断变难;
- 再回到本章:理解 HM 为什么选择“受限表达力 + 良好可推断性”的平衡点。
9.19 本章小结
这一章的核心内容可以压缩成下面几句话:
- 类型推断解决的是:在没有显式类型注解时,如何自动恢复类型。
- 标准方法是:
- 为未知位置分配类型变量;
- 从项结构中生成约束;
- 用合一求解这些约束。
- 合一算法的关键点包括:
- 类型变量替换;
- 构造子分解;
- occur check;
- 替换的传播与组合。
- 对函数类型的合一,不能把两个子问题的结果简单做“并集”,而必须:
- 先求第一部分;
- 再把替换传播到第二部分;
- 最后组合替换。
- let 多态的本质是:
- 在
let绑定处做泛化; - 在使用处做实例化。
- 在
- 标准 HM 中:
let绑定可以是多态的;- 普通函数参数通常是单态的。
如果你读完本章,已经能清楚回答下面三个问题,就说明你真正掌握了主线:
- 为什么约束来自类型规则的逆向阅读?
- 为什么合一需要替换传播,而不是简单拼接结果?
- 为什么
let id = ... in ...可以多态,而fun id -> ...通常不行?
回看导航
如果你读完本章后,想把这一章重新挂回全书主线,最推荐的回看顺序是:
- 回看第 5 章:重新确认
Γ ⊢ t : T到底在表达什么,以及类型规则为何能被“逆向阅读”为约束。 - 回看第 7 章:重新比较显式多态
System F与 HM 风格推断的区别,尤其注意“表达力更强”与“更难自动推断”之间的关系。 - 配合附录A《术语表》:重点回看“约束生成”“合一”“类型方案”“泛化”“实例化”“let 多态”“算法 W”“主类型”“主类型方案”。
- 配合附录B《符号速查表》:重点确认
\\alpha,\\beta,\\gamma、\\sigma、\\sigma_2 \\circ \\sigma_1、\\text{unify}(\\cdots)、\\text{Gen}(\\Gamma, T)这些记号。 - 最后再回做本章练习:如果你已经能独立手工生成约束、执行合一,并解释
let多态为何成立,就说明这一章已经真正站稳了。
本章练习
-
推断下列项的最一般类型:
-
对项 手工生成约束,并说明为什么合一失败。
-
对约束组 手工执行合一步骤,写出每次得到的替换以及最终结果。
-
解释为什么下面的表达式在 HM 中可以通过:
let id = fun x -> x in (id true, id 0) -
解释为什么下面的表达式在标准 HM 中通常不能通过:
fun id -> (id true, id 0) -
用自己的话说明:
- 泛化做了什么?
- 实例化做了什么?
- 为什么二者必须配合出现?
下一章,我们将转向另一个与“资源使用方式”相关的重要方向:子结构类型系统。那时,类型不再只描述“值是什么形状”,还会开始描述“值应该怎样被使用”。
第十章 子结构类型系统
第十章 子结构类型系统
阅读提示
本章会频繁使用“结构规则(structural rules)”“线性 / 仿射 / 相关 / 有序”“环境分裂(context splitting)”“线性函数类型(linear function type)”等术语。若你在阅读时想快速回查定义与记号,建议配合:
- 附录A《术语表》中的 A.8“子结构类型系统相关术语”
- 附录B《符号速查表》中的“子结构类型系统相关记号”
前面的章节里,我们逐步建立了一条很熟悉的主线:
- 程序有语法;
- 程序按操作语义运行;
- 类型系统在运行之前限制哪些程序是被允许的;
- 类型安全说明这些限制确实能排除某类坏状态。
但到目前为止,我们默认了一个很强、也很容易被忽略的前提:
变量可以随便用。
也就是说,在常规类型系统中,一个变量通常可以:
- 不用;
- 用一次;
- 用很多次;
- 交换使用顺序。
对大多数纯函数程序来说,这没有问题。但一旦程序里出现“资源”概念,这种默认自由就会变得危险:
- 文件句柄不能关闭两次;
- 内存不能释放后继续使用;
- 锁必须按协议获取和释放;
- 通信通道中的消息必须按顺序发送和接收。
这正是子结构类型系统(substructural type systems)要处理的问题。它们的核心思想不是给值增加新的“形状类型”,而是进一步限制:
一个变量可以被使用几次,以及是否必须按某种顺序使用。
本章要回答的问题是:
- 常规类型系统默认允许了哪些结构规则?
- 禁止其中一部分之后,类型系统会发生什么变化?
- 为什么这会把“类型”从“描述值的形状”推进到“描述值的使用纪律”?
本章的写法分三层:
- 前半先建立资源使用纪律的直觉;
- 中间给出一个最小形式核心,说明子结构系统究竟改动了哪些判断规则;
- 后半再把这些思想和 Rust、Zig、会话类型等实践方向对照起来。
也就是说,本章不只是做语言评论,而是要把“结构规则如何进入类型判断系统”这件事说清楚。
这一章的目标,是把这种思想讲清楚。你会看到:
- 常规类型系统其实隐含允许若干结构规则;
- 子结构类型系统通过禁止其中一部分规则,控制资源使用方式;
- 线性类型、仿射类型、相关类型、有序类型,正是不同限制强度下的系统;
- Rust 的所有权系统、会话类型等实践方向,都可以在这里找到理论影子。
10.1 常规类型系统里“默认允许”的东西
在 STLC 以及大多数常规类型系统中,类型环境通常写成:
然后类型判断写成:
表面上看,这只是“在某个环境下判断项的类型”。但实际上,这种写法通常默认了若干关于环境的结构性质。它们在逻辑里叫做结构规则(structural rules)。
10.1.1 交换(Exchange)
交换说的是:
环境中假设的顺序通常不重要。
如果你有:
那么通常也默认可以写成:
也就是说,x:A 和 y:B 的先后顺序不会影响判断。
10.1.2 弱化(Weakening)
弱化说的是:
允许在环境中加入一个根本没有用到的变量。
如果:
并且变量 x 不在 t 中自由出现,那么通常也允许:
这对应的直觉是:
- 你可以拥有一个资源;
- 但完全不使用它;
- 程序仍然是合法的。
10.1.3 收缩(Contraction)
收缩说的是:
如果同一个资源被写成两份,那么可以把它们合并,等价地理解为“这个变量可以重复使用”。
更准确地说,在逻辑里,收缩表达的是“同一假设可以被重复使用”。在类型系统直觉里,它对应:
- 一个变量可以被使用多次;
- 你不需要精确追踪它用了几次。
这条规则在资源语义里非常关键,因为它正是“复制使用”的来源。
10.2 为什么这些规则会出问题?
在纯函数式场景下,这些结构规则通常非常自然。
例如,给定一个普通整数变量 x:
- 你可以不用它;
- 也可以把它传给两个不同函数;
- 还可以交换它与别的变量在环境中的书写顺序。
这没有什么问题,因为整数不是“必须精确管理的一次性资源”。
但如果 x 表示的是文件句柄、数据库连接、锁、唯一所有权对象,那么情况就变了:
- 允许弱化:可能意味着“拿到了资源却忘记释放”;
- 允许收缩:可能意味着“同一个资源被复制使用,从而重复关闭、重复释放”;
- 允许交换:有时会破坏协议顺序,例如必须先发送再接收的通信过程。
所以子结构类型系统的出发点可以概括为:
不是所有值都应该像普通数学对象那样自由使用。
有些值代表资源,而资源的核心特征恰恰是:
- 使用次数受限;
- 使用顺序可能受限;
- 使用后状态发生变化。
10.3 子结构类型系统的总体图景
通过对交换、弱化、收缩三条规则做不同组合的保留与禁止,我们得到一系列不同系统。
| 系统 | 允许的结构规则 | 变量使用约束 |
|---|---|---|
| 常规(unrestricted) | Exchange + Weakening + Contraction | 可不用、可用一次、可用多次,顺序无关 |
| 仿射(affine) | Exchange + Weakening | 至多使用一次 |
| 相关(relevant) | Exchange + Contraction | 至少使用一次 |
| 线性(linear) | Exchange | 恰好使用一次 |
| 有序(ordered) | 无 | 恰好使用一次,且按顺序使用 |
这张表值得仔细读。
先看一个最小对照:
| 维度 | 常规系统 | 线性系统 |
|---|---|---|
| 变量使用 | 可丢弃、可复制 | 不可丢弃、不可复制 |
| 应用规则 | 同一环境可同时给左右子项 | 环境必须分裂给左右子项 |
| 函数类型 | A -> B | A ⊸ B |
| 关注重点 | 值是什么形状 | 值怎样被使用 |
后面 10.4–10.6 节真正要说明的是:这些差异不是“解释口味不同”,而是会直接进入类型判断规则本身。
10.3.1 常规系统
常规类型系统允许:
- 不用变量(弱化)
- 多次使用变量(收缩)
- 调整环境顺序(交换)
这是最宽松的情形。
10.3.2 仿射系统
仿射系统禁止收缩,但保留弱化和交换。
因此:
- 变量可以不用;
- 但一旦用了,就不能再复制使用。
所以仿射变量满足:
至多使用一次。
10.3.3 相关系统
相关系统禁止弱化,但保留收缩和交换。
因此:
- 变量不能完全不用;
- 但可以使用多次。
所以相关变量满足:
至少使用一次。
10.3.4 线性系统
线性系统既禁止弱化,也禁止收缩,只保留交换。
因此:
- 变量不能丢掉;
- 也不能复制;
- 必须恰好使用一次。
这就是最经典的线性资源约束。
10.3.5 有序系统
有序系统连交换也不允许。
因此:
- 变量必须恰好使用一次;
- 而且必须按环境中给定的顺序使用。
这比线性更强,因为它不仅控制“用了几次”,还控制“按什么顺序用”。
10.4 线性类型:最经典的资源控制系统
线性类型系统是子结构类型系统中最核心的一类。它表达的约束非常明确:
每个线性变量必须恰好使用一次。
在进入例子之前,先给出一个最小形式核心。这样后面讲“环境分裂”“线性函数”“无限制值”时,就不会显得像零散技巧。
10.4.0 一个最小形式核心
在常规 STLC 中,我们写:
其中环境 Γ 默认允许交换、弱化、收缩。
而在线性系统里,一个常见的最小写法是把环境分成两部分:
其中:
Γ放无限制假设(unrestricted assumptions),可自由复制、丢弃;Δ放线性假设(linear assumptions),必须被精确使用。
这不是唯一写法,但它最能表达本章主线:
子结构类型系统不是只改“解释”,而是直接改类型判断所允许的环境结构。
在这种最小骨架下,最典型的几条规则形状如下。
变量规则
无限制变量与线性变量通常分开处理:
直觉上:
- 若
x是无限制变量,它不会消耗线性资源; - 若
x是线性变量,那么这一步正好把它用掉。
线性抽象规则
它表示:
- 若函数体在使用一次
x:A的前提下得到B; - 那么这个函数本身就是一个线性函数
A ⊸ B。
普通抽象规则
它表示:
- 若参数
x:A被放在无限制环境中; - 那么函数可以按普通方式使用这个参数,得到普通函数类型
A \to B。
这里先不追求完整系统,只抓住一个最重要的变化:
一旦环境被区分成“可自由使用的部分”和“必须精确使用的部分”,类型规则的形状就已经不同于常规 STLC。
10.4.1 为什么“恰好一次”有用?
考虑一个抽象的文件接口:
type file
val open : string -> file
val read : file -> string * file
val close : file -> unit
这里把 file 当成一种必须精确管理的资源。若没有线性约束,下面这些错误都很难通过普通类型系统排除:
close f; close f:重复关闭close f; read f:关闭后继续使用let _ = open "a.txt":打开后丢失,不再关闭
线性类型的理想目标是:
- 打开资源后,必须用掉它;
- 用掉之后,旧变量失效;
- 不可能偷偷复制出第二份同一资源。
这正对应“恰好使用一次”的语义。
10.4.2 限定符:区分线性值和普通值
很多线性系统不会要求“所有东西都线性”,否则就太不实用了。更常见的做法是引入一个限定符(qualifier),区分:
- 线性值
- 无限制值
例如:
然后把类型写成带限定符的形式:
其中:
lin A表示线性地使用一个Aun A表示一个普通、可自由复制和丢弃的A
这样:
- 文件句柄可以是
lin File - 普通布尔值可以是
un Bool
这避免了“所有值都必须精确使用一次”的不现实要求。
这里也要补一个系统层面的提醒:
不同教材会用不同方式呈现“线性 / 无限制”的区分:有的把它写成限定符
lin / un,有的把它吸收到上下文分类或类型构造里。本章采用的是一种更适合入门的混合写法:同时区分两类值、两类环境和两类函数。
10.5 环境分裂:线性系统最关键的机制
线性类型系统里,最值得真正理解的机制是环境分裂(context splitting)。
为什么它重要?
因为一旦一个项有两个子项,类型系统就必须决定:
环境中的线性变量应该分给哪一边?
最典型的例子是函数应用:
如果变量 x 是线性的,那么它不可能同时被 t_1 和 t_2 两边都使用。否则就相当于复制了一份线性资源。
于是在线性系统中,应用规则不再像 STLC 那样简单地把同一个环境同时给两个前提,而是要把环境拆成两部分。
这也是为什么前面 10.4.0 节要先把判断写成:
因为一旦进入应用,真正被“分裂”的通常是线性部分 Δ,而不是无限制部分 Γ。
10.5.1 线性应用规则的形状
一个典型的线性应用规则可以写成:
这里:
- 常用来表示线性函数类型;
- 表示两个线性环境之间的合法合并 / 分裂对应关系;
- 同一个无限制环境
Γ可以同时出现在两边,而线性环境必须被精确拆分。
如果你更喜欢把所有环境都写成一个整体,也常会看到类似:
两种写法表达的是同一个核心思想:
线性资源不能被两个子推导共享。
核心直觉是:
t1用一部分资源;t2用另一部分资源;- 同一个线性变量不能出现在两边。
10.5.2 分裂规则的直觉
环境分裂通常满足如下原则:
- 无限制变量可以同时出现在两边;
- 线性变量只能分配给一边。
直觉上很像分配实物资源:
- 一本可复制的公开文档可以两边都拿到;
- 一张唯一的车票只能交给其中一方。
10.5.3 一个例子
设环境中有:
要检查应用 x y。
x是无限制的,因此可以出现在需要它的一侧,也不担心复制问题;y是线性的,因此必须只分给参数那一侧,不能再出现在函数那一侧。
所以环境大致分裂成:
- 左边给函数位置:
x : un(A → B) - 右边给参数位置:
x : un(A → B), y : lin A
或者在某些系统里更严格地只给y,取决于环境定义细节
真正重要的是:
线性变量不会被两个子推导共享。
这正是线性性得以维持的关键。
如果你想把这里的记号和前面章节统一起来,可以顺手对照:
- 附录A中的“环境分裂”“线性函数”“无限制值”条目;
- 附录B中的
A ⊸ B与Γ_1 \circ Γ_2记号说明。
10.6 线性函数与无限制函数
在线性系统中,函数本身也常常分成不同种类。
这里最好把 10.4 节的最小形式核心和本节连起来看:
一旦系统同时区分
- 无限制环境与线性环境;
- 普通值与线性值;
函数类型也自然会分成“普通函数”和“线性函数”两类。否则,“参数到底能不能被复制或丢弃”这件事就无法进入类型本身。
10.6.1 线性函数
线性函数通常写成:
它表示:
- 函数接受一个线性使用的
A - 并产生一个
B
直觉上,这个函数承诺:
- 不会偷偷复制它的参数;
- 也不会直接把参数丢掉不用。
10.6.2 普通函数
无限制函数则通常写成普通箭头:
它允许:
- 参数被使用零次、一次或多次;
- 函数自身也可以自由复制使用。
因此,本章中的一个最小对照可以写成:
| 函数种类 | 记号 | 对参数的使用纪律 |
|---|---|---|
| 普通函数 | A -> B | 可丢弃、可复制 |
| 线性函数 | A ⊸ B | 必须恰好使用一次 |
这张表虽然很小,但抓住了子结构类型系统和常规函数类型系统最关键的差别之一。
10.6.3 为什么要区分两类函数?
因为“函数是否会复制 / 丢弃参数”本身就是资源语义的一部分。
例如:
- 恒等函数
λx.x可以是线性的; - 常函数
λx.c会丢弃参数,因此不能被看成线性函数; λx.(x, x)会复制参数,也不能是线性函数。
所以在子结构类型系统里,函数类型不再只是“输入输出类型”的问题,还带着:
这个函数怎样使用它的参数?
10.7 仿射类型:至多使用一次
虽然线性类型很漂亮,但“恰好使用一次”在工程上有时过于严格。
因为现实程序里,某些值“最终没用到”并不一定是错误。
例如:
- 一个分支里提前返回;
- 一个对象构造出来后根据条件被直接丢弃;
- 某个资源由析构机制自动回收。
这时,仿射类型就变得更实用。
仿射系统保留弱化,但禁止收缩,因此:
仿射变量至多使用一次。
10.7.1 与线性类型的区别
线性与仿射的差别只有一个:
- 线性:必须使用一次
- 仿射:可以不用,但不能多次用
也就是说,仿射类型比线性类型宽松一点。
10.7.2 为什么仿射在实践中更常见?
因为很多编程场景里,“不用”比“复制”更容易安全处理。
- 如果一个资源没被使用,可以用析构、作用域结束、垃圾回收或其他机制统一处理;
- 但如果一个资源被复制成两份,再分别释放或修改,就更容易出错。
因此很多实际系统更愿意保留“可丢弃”,而重点禁止“可复制”。
10.8 相关类型:至少使用一次
相关类型(relevant types)走的是另一条路:
- 保留收缩;
- 禁止弱化。
于是变量必须至少被用一次,但可以用很多次。
这类系统在入门教材里不如线性和仿射常见,但它提供了一种有趣的资源视角:
你不能无视一个资源,但一旦开始使用,它可以重复使用。
这种思路适合表达某些“必须消费”或“必须响应”的约束,不过工程上使用得没有仿射和线性广泛。
10.9 有序类型:连顺序也要控制
如果连交换规则也去掉,就得到有序类型系统。
在本章的入门性概括里,可以先把它理解为:
- 每个变量恰好使用一次;
- 而且必须按环境给定顺序使用。
为什么会有人需要这种系统?
因为有些资源不是只关心“用没用、用了几次”,还关心:
有没有按正确顺序使用。
这里也要保留一点边界感:不同教材对 ordered / non-commutative 系统的具体形式化会更细,本章只抓住最核心的资源直觉——顺序本身也可能是类型系统要约束的对象。
这在通信协议中尤其明显。
10.9.1 一个协议直觉
假设某协议规定:
- 先发送用户名
- 再发送密码
- 最后等待登录结果
这里的问题不是“消息发送几次”,而是“顺序是否正确”。
如果你把第 2 步和第 1 步颠倒了,协议就坏了。
这说明某些程序性质天然具有顺序敏感性。
有序类型系统正是为这种顺序敏感资源提供形式基础。
10.10 Rust 的所有权系统:仿射思想的工程化
下面开始进入“理论与真实语言的对照”层。这里的目的不是评价某门语言优劣,而是说明:
资源安全可以通过不同语言设计路线实现,子结构类型只是其中一条重要路线。
子结构类型系统最著名的现实影响之一,就是 Rust 的所有权与借用系统。
当然,不能简单地说:
“Rust 就是线性类型系统” 或者 “Rust 就是仿射类型系统”
更准确的说法是:
Rust 的设计明显吸收了仿射 / 线性资源控制的核心思想,但它是一个更复杂的工程系统。
10.10.1 所有权移动的仿射直觉
在 Rust 中:
- 一个值通常有唯一所有者;
- move 之后,旧绑定不能继续使用。
例如:
#![allow(unused)]
fn main() {
let s1 = String::from("hello");
let s2 = s1;
// println!("{}", s1); // 编译错误
}
这非常接近仿射直觉:
s1表示的资源不能再被原绑定继续使用;- 使用权被转移给了
s2; - 资源不会被任意复制。
因此,Rust 中“默认按 move 使用的值”在直觉上很接近:
至多使用一次的资源。
10.10.2 为什么不能简单说“Rust = 仿射类型系统”?
因为 Rust 还包含很多额外机制:
- 借用(borrowing)
- 生命周期(lifetimes)
- 可变 / 不可变引用的区别
- trait、析构、内部可变性等复杂设计
这些都超出了一个最小仿射 λ-演算的范围。
所以更稳妥的表述是:
Rust 以仿射资源使用为核心直觉,并在其上构造了完整的工程化内存与别名控制系统。
10.10.3 &mut 与线性直觉
Rust 的可变借用 &mut T 常常被拿来和线性使用作类比,因为:
- 同一时刻只能有一个活跃可变借用;
- 它体现了“独占访问”的思想。
但这仍然只是类比,而不是简单的同构。
因为 Rust 的借用系统还涉及时间范围、重借用、生命周期推理等问题,不能直接等同于教科书里的线性变量。
10.11 Zig 的对照:不走子结构类型这条路
如果说 Rust 代表了“把资源纪律尽可能前移到编译期”的一条路线,那么 Zig 更接近另一种设计取向:
- 保持语言机制相对直接;
- 不引入完整的所有权 / 借用静态系统;
- 把更多责任交给程序员和约定;
- 再辅以调试模式和运行时检查帮助发现错误。
因此,和 Rust 相比,Zig 并没有把“变量使用次数和别名纪律”上升为同样强的静态类型约束。
所以从子结构类型系统的角度看,更合适的说法是:
Zig 可以作为一个有趣的对照样本:它处理资源问题,但并不通过完整的线性 / 仿射类型系统来处理。
这一节的重点不是说“哪条路线更好”,而是帮助你看清:
- Rust 更接近把资源纪律编码进静态系统;
- Zig 更接近把资源纪律留给程序员、约定和工具链共同承担;
- 因而“资源安全”与“子结构类型系统”关系很深,但并不是同义词。
这有助于你理解:
资源安全并不只有一条语言设计路线。
10.12 会话类型:为什么子结构思想会走向通信协议
子结构类型系统另一个非常重要的延伸方向,是会话类型(session types)。
会话类型要解决的问题不是普通数据结构,而是:
两个或多个进程之间,通信必须遵守某种协议。
例如,一个简单协议可能要求:
- 客户端先发送请求;
- 服务器再返回响应;
- 会话结束。
在这种场景里,通道使用天然具有:
- 次数约束:消息不能凭空多发或少发;
- 顺序约束:先请求后响应,不能反过来;
- 线性约束:某些通信端点不能被无约束复制。
因此:
- 线性思想帮助保证“通道不会被随意复制或丢弃”;
- 有序思想帮助保证“通信顺序符合协议”。
这就是为什么会话类型和子结构逻辑之间有很深联系。
10.13 子结构类型系统的理论意义
前面的类型系统主要告诉我们:
- 一个值长什么样;
- 一个函数接受什么、返回什么;
- 一个表达式最终会产生什么种类的结果。
而子结构类型系统进一步告诉我们:
一个值应当怎样被使用。
如果把这一章和前几章放在一起看,可以更清楚地看到它的独特位置:
- 第 5 章强调“良类型程序不会卡住”;
- 第 8 章强调“更具体的类型如何安全替代更一般的类型”;
- 第 9 章强调“类型信息如何自动恢复”;
- 而本章强调:即使值的形状完全正确,使用方式本身也可能需要被类型系统约束。
这是一个非常重要的观念转变。
普通类型系统更关注“形状”
例如:
BoolNatA × BA → B
这些主要描述的是值的结构和计算接口。
子结构类型系统进一步关注“使用纪律”
例如:
- 这个值能不能复制?
- 能不能不用?
- 必须用几次?
- 必须按什么顺序用?
这说明类型系统的表达能力不止于“描述数据形状”,还可以用于:
- 控制资源;
- 表达协议;
- 限制别名;
- 约束副作用。
从这个角度看,子结构类型系统让“类型”从单纯的数据分类工具,扩展成了:
程序行为约束工具。
10.14 本章小结
这一章最重要的收获有四点。
1. 常规类型系统其实默认允许结构规则
我们平时习以为常的环境使用方式,实际上依赖于:
- 交换
- 弱化
- 收缩
这些规则之所以“看不见”,只是因为它们太常见了。
2. 子结构类型系统通过禁止部分结构规则来控制资源
- 禁止收缩 → 不允许复制
- 禁止弱化 → 不允许丢弃
- 禁止交换 → 不允许任意改顺序
于是类型系统开始能表达资源纪律。
3. 线性、仿射、相关、有序是不同强度的系统
- 仿射:至多一次
- 相关:至少一次
- 线性:恰好一次
- 有序:恰好一次且按顺序
其中最核心、最值得牢记的是:
- 线性 = 不可丢弃 + 不可复制
- 仿射 = 可丢弃 + 不可复制
4. 现实语言和协议系统都能从这里找到理论影子
- Rust 的所有权系统与仿射 / 线性直觉密切相关;
- 会话类型与顺序敏感资源控制密切相关;
- 资源安全的语言设计不只有一种实现路径。
如果把本章压缩成一句话,那就是:
子结构类型系统把“类型描述值的形状”进一步推进为“类型约束值的使用纪律”。
本章向后输出的核心内容是:
- 结构规则如何进入类型判断系统;
- 为什么线性系统需要环境分裂;
- 为什么资源安全问题会自然引向仿射、线性、有序等不同纪律;
- 为什么 Rust、会话类型等实践方向能从这里找到理论影子。
本章练习
- 解释为什么“允许收缩”会让资源管理变得危险。
- 说明仿射类型和线性类型的区别,并各举一个更适合它们的资源场景。
- 为什么环境分裂是线性函数应用规则中的关键机制?
- 为什么不能简单说“Rust 就是线性类型系统”?
- 设想一个“先发送请求,再接收响应”的通信协议,说明为什么单纯的线性使用还不够,还需要顺序约束。
回看导航
如果你想继续沿这条线深入,比较自然的回看与延伸路径是:
- 回看第 5 章:重新确认类型判断
Γ ⊢ t : T的基本骨架,再对照本章看“环境结构本身”是如何被修改的; - 回看第 6 章中“引用与状态”的部分,重新理解为什么普通类型系统只描述“值的形状”还不够;
- 回看附录A《术语表》、附录B《符号速查表》,巩固“线性 / 仿射 / 相关 / 有序”“环境分裂”“线性函数类型”等核心概念;
- 再把本章与 Rust 的所有权 / 借用、以及会话类型的协议直觉放在一起理解,形成从理论到实践的整体视角。
也就是说,子结构类型系统并不是“最后补充的一章边角料”,而是把“类型不仅描述值是什么,还描述值该怎样被使用”这条主线推进到最鲜明位置的一章。
附录A:术语表
附录A:术语表
本术语表用于统一本教程中的中英文术语和最小定义。它的目标不是替代正文,而是帮助你在阅读时快速确认:
- 这个词在本教程里具体指什么;
- 它和相近概念有什么区别;
- 它在英文文献中通常对应哪个词;
- 它主要在哪一章进入主线讨论。
阅读提示
- 术语表中的定义以“本教程语境下够用”为原则,力求简明。
- 某些术语在不同文献中会有更强或更弱的版本;若存在这种情况,条目中会尽量提示。
- 术语表中的公式只用于帮助识别,不替代正文中的完整规则与推导。
- 为了把本附录同时做成“术语索引”,各小节标题后都补上了“主要对应章节”;若你忘了某个概念最该回看哪里,可以先看这些导航。
- 若正文中某个术语首次出现时给了英文原文,而你后来又忘了,也可以直接回到这里统一查。
A.1 Lambda 演算与语法相关术语(主要对应第 2–3 章)
| 中文 | 英文 | 本教程中的含义 | 主要回看 |
|---|---|---|---|
| 项 | term | 形式系统中的语法对象。在 Lambda 演算中,项由变量、抽象、应用等构造生成。 | 第 2–3 章 |
| 变量 | variable | 出现在项中的名字,如 x、y、z。 | 第 2–3 章 |
| 抽象 | abstraction | 函数定义,记作 λx.t。其中 x 是参数,t 是函数体。 | 第 3 章 |
| 应用 | application | 函数调用,记作 t1 t2,表示把 t1 应用于 t2。 | 第 3 章 |
| 绑定变量 | bound variable | 被某个抽象绑定的变量出现。例如在 λx.t 中,由这个 λx 管辖的 x 是绑定的。 | 第 3 章 |
| 自由变量 | free variable | 不受任何抽象绑定的变量出现。自由变量反映一个项对外部环境的依赖。 | 第 3 章 |
| 自由变量集合 | set of free variables | 记作 FV(t),表示项 t 中所有自由变量的集合。 | 第 3 章 |
| 作用域 | scope | 某个绑定生效的语法范围。 | 第 3 章 |
| 封闭项 | closed term | 没有自由变量的项。 | 第 3 章 |
| 组合子 | combinator | 在很多语境下,指没有自由变量的 Lambda 项。为避免歧义,本教程更常使用“封闭项”;在讨论 S、K、I 时会保留“组合子”这一说法。 | 第 3 章、附录E |
| α-等价 | alpha-equivalence | 只改变绑定变量名字而不改变绑定结构时得到的等价关系。 | 第 3 章 |
| α-重命名 / α-转换 | alpha-renaming / alpha-conversion | 安全地修改绑定变量名,以避免冲突或变量捕获。 | 第 3 章 |
| 替换 | substitution | 记作 [x ↦ s]t,表示把 t 中自由出现的 x 替换为 s。 | 第 3 章 |
| 避免捕获的替换 | capture-avoiding substitution | 替换时保证不会把原本自由的变量意外变成绑定变量。 | 第 3–4 章 |
| 变量捕获 | variable capture | 替换过程中,自由变量因同名绑定而被错误“吃掉”的现象。 | 第 3 章 |
| 新鲜变量 | fresh variable | 一个当前上下文中尚未使用、因此不会引发冲突的变量名。 | 第 3 章 |
A.2 Lambda 演算中的计算术语(主要对应第 4 章)
| 中文 | 英文 | 本教程中的含义 | 主要回看 |
|---|---|---|---|
| β-归约 | beta-reduction | Lambda 演算的核心计算规则:(λx.t) s → [x ↦ s]t。 | 第 4 章 |
| β-redex / 可归约表达式 | beta-redex / reducible expression | 形如 (λx.t) s 的项,可执行一次 β-归约。文献中常简称 redex。 | 第 4 章 |
| 归约关系 | reduction relation | 描述一个项如何一步变成另一个项的关系。 | 第 4 章 |
| 单步归约 | one-step reduction | 记作 t → t',表示 t 经过一步计算变成 t'。 | 第 4 章 |
| 多步归约 | multi-step reduction | 记作 t →* t',表示经过零步或多步计算从 t 到达 t'。 | 第 4 章 |
| 范式 | normal form | 相对于某个归约关系,已经无法继续归约的项。 | 第 4 章 |
| 值 | value | 相对于某个求值策略,被视为“计算完成”的项形态。值与范式相关,但不是同一个概念。 | 第 4 章、第 5 章 |
| 发散 | divergence | 一个项永远继续归约、无法到达范式的现象。 | 第 4 章、附录D |
| 合流性 | confluence | 若一个项能沿不同路径归约,则这些路径仍可重新汇合的性质。 | 第 4 章 |
| Church–Rosser 定理 | Church–Rosser theorem | Lambda 演算合流性的经典结果。其重要推论是:若某项有范式,则该范式在 α-等价意义下唯一。 | 第 4 章 |
| 归约策略 | reduction strategy | 当一个项中有多个可归约位置时,优先选择哪一个归约。 | 第 4 章 |
| 求值策略 | evaluation strategy | 编程语言层面对“先算哪里、何时把参数算成值”的更具体约定。 | 第 4 章、附录D、附录E |
| 传名调用 | call-by-name | 调用函数时,不先把参数求值为值,而是先把参数项代入函数体。 | 第 4 章、附录D |
| 传值调用 | call-by-value | 调用函数时,先把参数求值为值,再进行函数应用。 | 第 4 章、附录D、附录E |
| 按需求值 | call-by-need | 一种与传名调用接近但带共享的惰性求值策略。Haskell 通常以此为典型例子。 | 第 4 章 |
| 小步语义 | small-step semantics | 通过单步关系 t → t' 描述程序如何一步步执行。 | 第 4–5 章 |
| 大步语义 | big-step semantics | 通过关系 t ⇓ v 直接描述程序最终求值到什么结果。 | 第 4 章 |
| 卡住状态 | stuck term / stuck state | 既不是值,又无法继续按照求值规则前进的项。这个概念通常在扩展语言(如带布尔值、自然数等)中讨论最自然。 | 第 4–5 章 |
| η-归约 | eta-reduction | 形如 λx. f x → f 的化简,通常要求 x 不自由出现在 f 中。它表达了函数外延性的一种形式。 | 第 4 章(补充概念) |
A.3 类型系统核心术语(主要对应第 5 章)
| 中文 | 英文 | 本教程中的含义 | 主要回看 |
|---|---|---|---|
| 类型 | type | 对项进行静态分类的标签,用来约束其可能的计算行为。 | 第 5 章 |
| 类型系统 | type system | 一种可判定的语法方法,通过为程序片段赋予类型,排除某类被禁止的错误行为。 | 第 0 章、第 5 章 |
| 类型判断 | type judgment | 形如 Γ ⊢ t : T 的判断,表示“在环境 Γ 下,项 t 的类型是 T”。 | 第 5 章 |
| 类型环境 | type context / typing environment | 变量到类型的有限映射,通常记作 Γ。 | 第 5 章 |
| 类型规则 | typing rule | 规定什么条件下可以推出某个类型判断的推导规则。 | 第 5 章 |
| 类型检查 | type checking | 在给定必要类型信息的情况下,验证一个项是否符合类型规则。 | 第 5 章 |
| 类型推断 | type inference | 在类型注解不完整或缺失时,自动恢复类型信息。 | 第 9 章 |
| 良类型 | well-typed | 一个项若能在某环境下导出合法类型判断,则称其在该环境下良类型。 | 第 5 章 |
| 类型安全 | type safety / type soundness | 在选定语言与错误模型下,良类型程序不会进入被系统禁止的坏状态。常通过进展性与保持性表达。 | 第 5 章 |
| 进展性 | progress | 良类型的封闭项要么已经是值,要么还可以继续求值。 | 第 5 章 |
| 保持性 | preservation | 若 Γ ⊢ t : T 且 t → t',则仍有 Γ ⊢ t' : T。也常称 subject reduction。 | 第 5 章 |
| subject reduction | subject reduction | 保持性的常见别名,强调“归约保持类型”。 | 第 5 章 |
| 可判定性 | decidability | 存在一个总会在有限时间内结束的过程,用来判断某个性质是否成立。 | 第 0 章、第 5 章 |
| 声明式类型规则 | declarative typing | 先说明“什么情况下一个类型判断成立”的规则写法,不一定直接对应最简洁的检查算法。 | 第 5–6 章 |
| 语法制导 | syntax-directed | 规则与语法构造直接对应,因此检查过程通常可以沿语法树递归进行。 | 第 5–6 章 |
| 代换引理 | substitution lemma | 若某项在扩展环境下良类型,而替换进去的值类型匹配,则替换后的项仍保持原类型。 | 第 5 章 |
| 强正规化 | strong normalization | 所有良类型项都在有限步内归约到范式的性质。不是所有类型系统都满足这一性质。 | 第 5 章、附录D |
| Church 风格 | Church-style | 项本身携带显式类型注解的风格,如 λx:T.t。 | 第 5 章 |
| Curry 风格 | Curry-style | 项本身不直接携带类型注解,类型由外部规则或推断系统赋予的风格。 | 第 5 章、第 9 章 |
A.4 一阶类型构造相关术语(主要对应第 6 章)
| 中文 | 英文 | 本教程中的含义 | 主要回看 |
|---|---|---|---|
| 基础类型 | base type | 不再通过更小类型构造出来的类型,如 Bool、Nat、Unit。 | 第 6 章 |
| 单位类型 | unit type | 只有一个值的类型,常记作 Unit。它更接近“无有趣返回值的结果类型”,不应简单等同于 null。 | 第 6 章 |
| 自然数类型 | natural number type | 理想化的自然数类型,通常记作 Nat。它与现实语言中的机器整数相关,但不完全等同。 | 第 6 章 |
| 积类型 | product type | 形如 T1 × T2 的类型,表示同时包含一个 T1 和一个 T2。 | 第 6 章 |
| 和类型 | sum type | 形如 T1 + T2 的类型,表示“要么是 T1,要么是 T2”,通常带有标记。 | 第 6 章 |
| 左注入 | left injection | 构造和类型左侧分支的项,常写作 inl(t)。 | 第 6 章 |
| 右注入 | right injection | 构造和类型右侧分支的项,常写作 inr(t)。 | 第 6 章 |
| 分支分析 | case analysis | 对和类型值进行分类处理的形式,通常写作 case ... of ...。 | 第 6 章 |
| 记录类型 | record type | 带字段名的积类型,如 {name:String, age:Nat}。在本教程后续子类型讨论中,默认按结构性记录来理解。 | 第 6 章、第 8 章 |
| 递归类型 | recursive type | 允许类型引用自身的类型,常写作 μX.T。 | 第 6 章 |
| 同构递归 | iso-recursive | 把 μX.T 与其展开式看作非常接近、但仍需显式 fold / unfold 往返的递归类型视角。 | 第 6 章 |
| 等价递归 | equi-recursive | 把 μX.T 与其展开式直接视为同一个类型的递归类型视角。 | 第 6 章 |
| fold | fold | 把递归类型展开后的结构重新“包回”递归类型。 | 第 6 章 |
| unfold | unfold | 把递归类型“打开一层”,得到其展开形式。 | 第 6 章 |
| 可变引用 | mutable reference | 指向某个可变存储位置的引用类型,常写作 Ref(T)。 | 第 6 章、第 8 章 |
| 解引用 | dereference | 从引用中读出其当前内容的操作。 | 第 6 章 |
| 赋值 | assignment | 向某个可变引用写入新值的操作。 | 第 6 章 |
| 存储类型 | store typing | 在带可变存储的语言中,用来跟踪堆中位置应存储何种类型信息的辅助结构。 | 第 6 章 |
A.5 多态、二阶系统与抽象相关术语(主要对应第 7 章)
| 中文 | 英文 | 本教程中的含义 | 主要回看 |
|---|---|---|---|
| 参数化多态 | parametric polymorphism | 程序对任意类型都以统一方式工作的一类多态。 | 第 7 章 |
| 二阶类型系统 | second-order type system | 允许对类型变量进行抽象与应用的类型系统。 | 第 7 章 |
| System F | System F | 最经典的参数化多态理论系统之一,也叫二阶 Lambda 演算。 | 第 7 章 |
| 类型变量 | type variable | 出现在类型中的占位符,如 X、Y、α。 | 第 7 章、第 9 章 |
| 全称类型 | universal type | 形如 ∀X.T 的类型,表示“对任意类型 X,都有 T”。 | 第 7 章、第 9 章 |
| 类型抽象 | type abstraction | 对类型变量的抽象,常写作 ΛX.t。 | 第 7 章 |
| 类型应用 | type application | 把多态项实例化到某个具体类型,常写作 t[T]。 | 第 7 章 |
| 存在类型 | existential type | 形如 ∃X.T 的类型,表示存在某个类型 X 使 T 成立,常用于隐藏实现类型。 | 第 7 章 |
| 打包 | pack | 把某个具体实现连同其隐藏类型一起封装成存在类型值。 | 第 7 章 |
| 拆包 | unpack | 打开存在类型值,在受限作用域内以抽象方式使用其隐藏实现。 | 第 7 章 |
| 抽象数据类型 | abstract data type (ADT) | 隐藏内部表示,只暴露操作接口的数据抽象方式。注意这里的 ADT 指 abstract data type,不是 algebraic data type。 | 第 7 章 |
| 类型算子 | type operator | 以类型为输入、产生新类型的类型层函数。 | 第 7 章 |
| kind / 种类 | kind | 用来区分“普通类型”“从类型到类型的构造器”等层级的分类工具。本教程只做方向性介绍。 | 第 7 章 |
A.6 子类型与变化方向相关术语(主要对应第 8 章)
| 中文 | 英文 | 本教程中的含义 | 主要回看 |
|---|---|---|---|
| 子类型 | subtype | 若 S <: T,表示任何需要 T 的地方都可以安全地使用 S。 | 第 8 章 |
| 安全替换 | safe substitution / safe replacement | 子类型最核心的直觉:更具体的值可以放到要求更一般类型的位置。 | 第 8 章 |
| subsumption | subsumption | 通过 S <: T 把一个已有类型 S 的项提升为类型 T 的规则。 | 第 8 章 |
| 自反性 | reflexivity | 子类型关系满足 T <: T。 | 第 8 章 |
| 传递性 | transitivity | 若 S <: U 且 U <: T,则 S <: T。 | 第 8 章 |
| 宽度子类型化 | width subtyping | 对记录而言,字段更多的记录可作为字段更少记录的子类型。 | 第 8 章 |
| 深度子类型化 | depth subtyping | 对记录而言,若对应字段类型更具体,则整体记录类型也可更具体。 | 第 8 章 |
| 协变 | covariance | 子类型关系与类型构造方向一致。常见于函数返回值、许多只读结构。 | 第 8 章 |
| 逆变 | contravariance | 子类型关系与类型构造方向相反。经典例子是函数参数。 | 第 8 章 |
| 不变 | invariance | 既不协变也不逆变,通常要求类型完全相同。可变引用常是这种情形。 | 第 8 章 |
A.7 类型推断相关术语(主要对应第 9 章)
| 中文 | 英文 | 本教程中的含义 | 主要回看 |
|---|---|---|---|
| Hindley–Milner | Hindley–Milner (HM) | 最经典的可推断多态类型系统之一。本章主要以它为背景介绍类型推断。 | 第 9 章 |
| 约束生成 | constraint generation | 从项结构出发,收集“这些类型必须相等 / 必须匹配”的条件。 | 第 9 章 |
| 合一 | unification | 求解类型等式,寻找使这些等式同时成立的替换。 | 第 9 章 |
| 替换 | substitution | 把类型变量统一替换成具体类型或其他类型表达式的映射。 | 第 9 章 |
| 最一般合一器 | most general unifier (MGU) | 在所有能解决约束的替换中,最不“多做承诺”的那个。 | 第 9 章 |
| occur check | occur check | 在合一时检查一个类型变量是否出现在待替换目标内部,以防构造无限类型。 | 第 9 章 |
| 无限类型 | infinite type | 形如 α = α -> β 这类需要无限展开的类型。标准 HM 不允许。 | 第 9 章 |
| 类型方案 | type scheme | 可被多次实例化的一类类型,通常写作 ∀α1...αn.T。在 HM 中,它主要出现在环境里,而不是像 System F 那样直接作为对象语言项的一部分。 | 第 9 章 |
| 主类型 | principal type | 在给定系统中,一个项最一般、最不多做承诺的类型;若其他可赋予的类型都可由它实例化得到,就称它是主类型。 | 第 9 章 |
| 主类型方案 | principal type scheme | 对多态绑定而言,最一般的类型方案;HM 系统的重要性质之一就是很多项都存在主类型方案。 | 第 9 章 |
| 泛化 | generalization | 在 let 绑定处,把不受当前环境约束的类型变量量化为类型方案。 | 第 9 章 |
| 实例化 | instantiation | 在使用多态绑定时,把类型方案中的量化变量替换为新的具体类型或新鲜类型变量。 | 第 9 章 |
| let 多态 | let polymorphism | let 绑定的名字可以被泛化为多态类型方案,并在不同使用点独立实例化。 | 第 9 章 |
| 算法 W | Algorithm W | HM 推断的经典算法。它递归推断项的类型,并返回替换与类型。 | 第 9 章 |
A.8 子结构类型系统相关术语(主要对应第 10 章)
| 中文 | 英文 | 本教程中的含义 | 主要回看 |
|---|---|---|---|
| 子结构类型系统 | substructural type system | 通过禁止部分结构规则来限制变量使用方式的类型系统。 | 第 10 章 |
| 结构规则 | structural rules | 逻辑与类型环境中关于假设使用方式的规则,如交换、弱化、收缩。 | 第 10 章 |
| 交换 | exchange | 允许改变环境中假设的顺序。 | 第 10 章 |
| 弱化 | weakening | 允许把一个根本没用到的假设加入环境。 | 第 10 章 |
| 收缩 | contraction | 允许同一假设被重复使用。 | 第 10 章 |
| 线性类型 | linear type | 要求变量恰好使用一次的类型纪律。 | 第 10 章 |
| 仿射类型 | affine type | 要求变量至多使用一次。与线性相比,允许不用。 | 第 10 章 |
| 相关类型 | relevant type | 要求变量至少使用一次。 | 第 10 章 |
| 有序类型 | ordered type | 要求变量恰好使用一次,并且按环境顺序使用。 | 第 10 章 |
| 限定符 | qualifier | 用来区分线性值与无限制值的标记,如 lin 与 un。 | 第 10 章 |
| 环境分裂 | context splitting | 在应用等需要两个子推导的地方,把环境划分给左右子项的机制;线性变量通常只能分给一侧。 | 第 10 章 |
| 线性函数 | linear function | 在参数使用上服从线性纪律的函数类型,常记作 A ⊸ B 或类似变体。 | 第 10 章 |
| 无限制值 | unrestricted value | 可以自由复制、丢弃和重排使用的普通值。 | 第 10 章 |
| 所有权 | ownership | 资源由哪个绑定负责使用与释放的纪律。Rust 中常以此实现仿射式资源控制。 | 第 10 章 |
| 借用 | borrowing | 在不转移所有权的情况下临时获得访问权限的机制。 | 第 10 章 |
| 会话类型 | session type | 用于描述通信协议的类型系统,常与线性 / 有序资源思想相关。 | 第 10 章 |
A.9 逻辑与元理论相关术语(贯穿全书;第 1、5、8、10 章尤为重要)
| 中文 | 英文 | 本教程中的含义 | 主要回看 |
|---|---|---|---|
| 推导规则 | inference rule | 形如“若前提成立则结论成立”的形式化规则。 | 第 1 章、第 5 章 |
| 推导树 | derivation tree | 由推导规则逐层构造出的树形证明对象。 | 第 1 章、第 5 章 |
| 公理 | axiom | 没有前提的推导规则。 | 第 1 章 |
| 归纳定义 | inductive definition | 通过基础情况与构造规则定义一个无限对象族的方法。 | 第 1–3 章 |
| 结构归纳法 | structural induction | 对按归纳定义生成的对象结构做归纳证明的方法。 | 第 1 章、第 5 章 |
| 推导归纳法 | induction on derivations | 对推导树结构做归纳的证明方法。 | 第 1 章、第 5 章 |
| Curry–Howard 对应 | Curry–Howard correspondence | 在合适的形式系统中,“类型对应命题、项对应证明”的深刻联系。 | 第 6–7 章 |
| 合取 | conjunction | 逻辑中的“且”,常与积类型对应。 | 第 6 章 |
| 析取 | disjunction | 逻辑中的“或”,常与和类型对应。 | 第 6 章 |
| 蕴含 | implication | 逻辑中的“如果……那么……”,常与函数类型对应。 | 第 5 章 |
A.10 术语之间最容易混淆的几组概念(可作为快速回查索引)
1. 值 vs 范式
- 值是相对于求值策略定义的“计算完成形式”;
- 范式是相对于某个归约关系定义的“无法继续归约”;
- 在简单系统里二者常重合,但来源不同,不应直接混为一谈。
2. 封闭项 vs 组合子
- 封闭项的定义是“没有自由变量的项”;
- 组合子在很多文献里常指封闭 Lambda 项,尤其在
S、K、I语境中; - 为避免歧义,本教程优先使用“封闭项”,并在适合时说明其与“组合子”的关系。
3. 抽象数据类型(ADT)vs 代数数据类型(ADT)
- abstract data type:强调隐藏表示、暴露接口;
- algebraic data type:通常指由和类型、积类型等代数组合得到的数据类型;
- 两者英文缩写都常写成 ADT,因此阅读时必须看上下文,不应直接混用。
4. 类型检查 vs 类型推断
- 类型检查:给定足够多的类型信息,验证程序是否符合规则;
- 类型推断:在类型信息缺失时,自动恢复这些信息;
- 二者相关,但不是一回事。
5. Church 风格 vs Curry 风格
- Church 风格:项中显式写类型注解;
- Curry 风格:项中不直接写类型注解,类型由系统外部赋予或推断;
- “显式 / 隐式”只是表面差异,背后关系到检查与推断方式。
A.11 进阶附录相关术语索引
下面这些词主要出现在进阶附录中,但为了让本附录更像全书索引,这里也统一收录。
| 中文 | 英文 | 本教程中的含义 | 主要回看 |
|---|---|---|---|
| 不动点 | fixed point | 若 f(x) = x,则称 x 是 f 的一个不动点。 | 附录D |
| 不动点组合子 | fixed-point combinator | 给定函数 f,能够构造出某种不动点的 Lambda 项。 | 附录D |
| Y 组合子 | Y combinator | 最经典的不动点组合子之一,通常与传名调用语境联系最紧。 | 附录D |
| Z 组合子 | Z combinator | 为传值调用语境常见的递归编码而使用的一个变体不动点组合子。 | 附录D |
| Church 编码 | Church encoding | 用函数来表示布尔值、自然数等数据与操作的一类编码方法。 | 附录E |
| Church 布尔值 | Church booleans | 把布尔值编码成“二选一函数”的表示方式。 | 附录E |
| Church 数 | Church numerals | 把自然数编码成“重复作用若干次”的高阶函数。 | 附录E |
A.12 一句话回顾
如果把本教程中的术语主线压缩成一句话,可以这样说:
语法定义程序长什么样,语义定义程序如何计算,类型系统定义哪些程序被允许,而元理论说明这些限制为什么可靠。
术语表中的大多数词,最终都可以放回这条主线中理解;而“主要回看”这一列,则是帮助你把这些术语重新挂回对应章节的导航索引。
附录B:符号速查表
附录B:符号速查表
本附录是一个参考型速查表,适合在阅读正文时随时回查。
它的目标不是替代正文解释,而是帮助你快速确认:
- 某个符号怎么读;
- 它大致表示什么;
- 它在本教程的哪类语境中出现。
使用方式建议:
- 第一次阅读正文时,不必强行记住全部符号;
- 遇到不熟悉的记号时,再回到这里查;
- 若你想看更完整的术语解释,请配合附录A“术语表”一起使用。
阅读提醒:
- 同一个符号在不同章节里,可能处在不同层级;
- 例如第 7 章的
∀X.T是对象语言中的全称类型,而第 9 章的∀α.T更常出现在 HM 风格环境中的类型方案(type scheme);- 又例如第 7 章的
X, Y通常表示显式多态系统中的类型变量,而第 9 章的α, β, γ通常表示推断过程中临时生成的未知类型占位符。
一、逻辑与判断相关记号
| 记号 | 常见读法 | 含义 | 常见语境 |
|---|---|---|---|
| 且 | 逻辑合取(and) | 命题逻辑、性质并列 | |
| 或 | 逻辑析取(or) | 命题逻辑、分类讨论 | |
| 蕴含 / 如果……那么…… | 逻辑蕴含 | 规则描述、性质定义 | |
| 对所有 / 任意 | 全称量词 | 多态、数学定义 | |
| 存在 | 存在量词 | 存在类型、数学定义 | |
| 可推出 / 可判断为 | 在某套规则系统中可以推出某结论 | 类型判断、推导系统 | |
| 在环境 下, 的类型是 | 类型判断 | 第5章及以后 | |
| 环境中查找 的类型 | 环境查找结果 | 类型规则 |
说明:
$\vdash$不应简单理解成普通语言里的“所以”。它通常表示:
在某个形式系统的规则之下,可以推出右边的判断。
二、Lambda 演算相关记号
| 记号 | 常见读法 | 含义 | 常见语境 |
|---|---|---|---|
| lambda x 点 t | 函数抽象(函数定义) | 第3章起 | |
| 把 应用于 | 函数应用 | 第3章起 | |
| t 的自由变量集合 | 自由变量集合 | 第3章 | |
| 把 中的 替换为 | 项替换 | 第3章、第4章 | |
| α-等价 | 绑定变量重命名后的等价 | 第3章 | |
| β-归约到 | 一步 β-归约 | 第4章 | |
| 归约到 / 一步归约到 | 一步计算关系 | 第4章起 | |
| 多步归约到 | 零步或多步归约 | 第4章起 | |
| t 求值为 v | 大步语义中的求值关系 | 第4章 | |
| Omega | 经典发散项 | 第4章 | |
| redex | 可归约表达式 | 形如 的可归约子项或位置 | 第4章 |
| eta | 常用于 η-归约或 η-等价的记号 | 第4章(补充概念) |
说明:
$[x \mapsto s]t$`` 中的` 在这里读作“替换为”最自然;
但在别的上下文里,它也可能只是一般意义上的“映射到”。
三、类型系统相关记号
| 记号 | 常见读法 | 含义 | 常见语境 |
|---|---|---|---|
| $T, U, S$ | 类型元变量 / 类型占位记号 | 表示任意类型的占位符,不是对象语言里的类型变量 | 全书 |
| $A, B$ | 基础类型或一般类型名 | 视上下文而定 | 第5章起 |
| $T_1 \to T_2$ | 从 $T_1$ 到 $T_2$ | 函数类型 | 第5章起 |
| $\text{Bool}$ | Bool | 布尔类型 | 第5章起 |
| $\text{Nat}$ | Nat | 自然数类型 | 第5章起 |
| $\text{Unit}$ | Unit | 单位类型 | 第6章 |
| $T_1 \times T_2$ | 积类型 / 乘积类型 | 同时包含两个部分的类型 | 第6章 |
| $T_1 + T_2$ | 和类型 / 和 | 二选一的类型 | 第6章 |
| ${l_1:T_1,\dots,l_n:T_n}$ | 记录类型 | 带字段名的类型 | 第6章、第8章 |
| $\mu X.T$ | mu X 点 T | 递归类型 | 第6章 |
| $\text{fold}(t)$ | fold t | 把展开后的递归结构重新包回递归类型 | 第6章 |
| $\text{unfold}(t)$ | unfold t | 把递归类型打开一层 | 第6章 |
| $\text{Ref}(T)$ | Ref of T / T 的引用类型 | 可变引用类型 | 第6章 |
| $\forall X.T$ | 对所有类型 $X$,$T$ | 全称类型 | 第7章 |
| $\exists X.T$ | 存在某个类型 $X$ 使得 $T$ | 存在类型 | 第7章 |
| $\Lambda X.t$ | 大 Lambda X 点 t | 类型抽象 | 第7章 |
| $t[T]$ | t 作用于类型 T | 类型应用 | 第7章 |
| $\text{pack}$ | pack | 存在类型中的打包(packing)构造 | 第7章 |
| $\text{unpack}$ | unpack | 存在类型中的拆包(unpacking)构造 | 第7章 |
| $S <: T$ | S 是 T 的子类型 | 子类型关系 | 第8章 |
说明:
<span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8333em;vertical-align:-0.15em;"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.13889em;">T</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3011em;"><span style="top:-2.55em;margin-left:-0.1389em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight">1</span></span></span></span><span class="vlist-s"></span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">→</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.8333em;vertical-align:-0.15em;"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.13889em;">T</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3011em;"><span style="top:-2.55em;margin-left:-0.1389em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight">2</span></span></span></span><span class="vlist-s"></span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span></span></span></span>中的箭头表示函数类型,不是程序执行步骤。<span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.7224em;vertical-align:-0.0391em;"></span><span class="mord mathnormal" style="margin-right:0.05764em;">S</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel"><:</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.6833em;"></span><span class="mord mathnormal" style="margin-right:0.13889em;">T</span></span></span></span>表示“S可以安全地用在需要T的地方”。
四、类型推断与合一相关记号
| 记号 | 常见读法 | 含义 | 常见语境 |
|---|---|---|---|
| $\alpha, \beta, \gamma$ | 类型变量 | 推断中的未知类型占位符 | 第9章 |
| $\sigma$ | 替换 sigma | 把类型变量映射为类型的替换 | 第9章 |
| $T\text{-}Sub$ | T-Sub / subsumption | 把已有类型提升到其超类型的类型规则 | 第8章 |
| $\sigma(T)$ | 把替换 $\sigma$ 作用到类型 $T$ 上 | 替换作用结果 | 第9章 |
| $\sigma_2 \circ \sigma_1$ | 替换组合 | 先做 $\sigma_1$,再做 $\sigma_2$ | 第9章 |
| $\text{unify}(\cdots)$ | 合一 | 求解类型等式 | 第9章 |
| $\forall \alpha.T$ | 对类型变量 $\alpha$ 全称量化 | HM 风格类型方案中的量化 | 第9章 |
| $\text{Gen}(\Gamma, T)$ | 在环境 $\Gamma$ 下泛化 $T$ | 泛化操作 | 第9章 |
| $\text{Inst}(Sch)$ | 实例化一个类型方案 | 把类型方案中的量化变量替换为新鲜类型实例 | 第9章 |
| $Sch$ | scheme / 类型方案 | HM 风格环境中记录的一族类型 | 第9章 |
说明:
在第9章里,$\alpha,\beta,\gamma$ 通常不表示“真实语言中的泛型参数”,
而是推断算法中临时生成的未知类型占位符。
五、子结构类型系统相关记号
| 记号 | 常见读法 | 含义 | 常见语境 |
|---|---|---|---|
| $\text{lin}$ | linear | 线性限定符 | 第10章 |
| $\text{un}$ | unrestricted | 无限制限定符 | 第10章 |
| $A \multimap B$ | 线性函数类型 | 线性地消耗一个 $A$,产生一个 $B$ | 第10章 |
| $\Gamma_1 \circ \Gamma_2$ | 环境分裂 / 环境组合 | 表示线性系统中的环境拆分或对应组合 | 第10章 |
| Exchange | 交换规则 | 允许调整环境中假设的顺序 | 第10章 |
| Weakening | 弱化规则 | 允许引入未使用的变量假设 | 第10章 |
| Contraction | 收缩规则 | 允许重复使用同一假设 | 第10章 |
本教程约定:
<span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8333em;vertical-align:-0.15em;"></span><span class="mord"><span class="mord">Γ</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3011em;"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight">1</span></span></span></span><span class="vlist-s"></span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">∘</span><span class="mspace" style="margin-right:0.2222em;"></span></span><span class="base"><span class="strut" style="height:0.8333em;vertical-align:-0.15em;"></span><span class="mord"><span class="mord">Γ</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3011em;"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight">2</span></span></span></span><span class="vlist-s"></span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span></span></span></span>不是所有教材里都统一采用的通用记号。
在本教程中,它专门表示:
- 两个环境之间的线性分裂 / 组合关系;
- 直觉上可理解为“把资源分给左右两个子推导”。
阅读第10章时,请始终按这个约定理解它。
六、正文中常见的元变量约定
这些不是“语言里的变量”,而是作者在讲解时常用的元变量。
| 记号 | 常见含义 |
|---|---|
| $t, s, u$ | 项(term) |
| $x, y, z$ | 程序变量 |
| $v$ | 值(value) |
| $T, U, S$ | 类型(元变量) |
| $X, Y$ | 类型变量(多见于第 7 章显式多态系统) |
| $\alpha, \beta, \gamma$ | 推断中的未知类型变量(多见于第 9 章) |
| $\Gamma$ | 类型环境 / 上下文 |
| $\sigma$ | 替换 |
| $Sch$ | 类型方案(type scheme) |
| $e$ | 表达式(有时用于一般表达式语言) |
提醒:
元变量是“讲解语言”的记号,不是目标语言本身的一部分。
例如<span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6151em;"></span><span class="mord mathnormal">t</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">::=</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal">x</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">∣</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal">λ</span><span class="mord mathnormal">x</span><span class="mord">.</span><span class="mord mathnormal">t</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">∣</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.6151em;"></span><span class="mord mathnormal">t</span><span class="mspace"> </span><span class="mord mathnormal">t</span></span></span></span>里的t是在描述“任意项”的元变量,不是程序里真的有个变量叫t。特别注意:
- 第 7 章里的
<span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8778em;vertical-align:-0.1944em;"></span><span class="mord mathnormal" style="margin-right:0.07847em;">X</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord mathnormal" style="margin-right:0.22222em;">Y</span></span></span></span>通常表示对象语言类型中的类型变量;- 第 9 章里的
<span class="katex"><span class="katex-html" aria-hidden="true"><span class="mspace newline"></span><span class="base"><span class="strut" style="height:0.8889em;vertical-align:-0.1944em;"></span><span class="mord mathnormal">a</span><span class="mord mathnormal">lp</span><span class="mord mathnormal">ha</span><span class="mpunct">,</span></span><span class="mspace newline"></span><span class="base"><span class="strut" style="height:0.8889em;vertical-align:-0.1944em;"></span><span class="mord mathnormal">b</span><span class="mord mathnormal">e</span><span class="mord mathnormal">t</span><span class="mord mathnormal">a</span><span class="mpunct">,</span></span><span class="mspace newline"></span><span class="base"><span class="strut" style="height:0.625em;vertical-align:-0.1944em;"></span><span class="mord mathnormal" style="margin-right:0.03588em;">g</span><span class="mord mathnormal">amma</span></span></span></span>通常表示推断算法临时生成的未知类型占位符;- 两者都可被叫作“类型变量”,但层级和用途并不相同。
七、容易混淆的符号对照
| 记号 | 不要和什么混淆 | 区别 |
|---|---|---|
| $T_1 \to T_2$ | $\longrightarrow$ | 前者是函数类型,后者是归约关系 |
| $\lambda x.t$ | $\Lambda X.t$ | 前者是值层面抽象,后者是类型层面抽象 |
| $[x \mapsto s]t$ | $\sigma(T)$ | 前者是项替换,后者通常是类型替换 / 替换作用 |
| $\equiv_\alpha$ | $\longrightarrow_\beta$ | 前者是等价关系,后者是一步归约关系 |
| $\forall X.T$ | $\forall \alpha.T$ | 前者多见于第 7 章的显式多态对象语言,后者多见于第 9 章 HM 风格类型方案(type scheme) |
| $\forall X.T$ | $\exists X.T$ | 前者是全称类型,后者是存在类型 |
| $S <: T$ | $S = T$ | 前者是子类型关系,后者是相等 |
| $\Gamma \vdash t : T$ | $t \Downarrow v$ | 前者是类型判断,后者是求值关系 |
| $\mu X.T$ | 无限展开的类型 | 前者是显式递归类型构造,后者通常是第 9 章 occur check 要阻止的隐式无限类型(infinite type) |
| $\text{fold}(t)$ / $\text{unfold}(t)$ | 直接类型相等 | 前者对应 iso-recursive 视角下的显式往返,后者不是“自动视为同一类型” |
八、最后的阅读建议
如果你在阅读正文时总是被符号绊住,可以优先只抓住下面这几类最核心记号:
-
项的形状
- $\lambda x.t$
- $t_1\ t_2$
-
类型判断
- $\Gamma \vdash t : T$
-
计算关系
- $\longrightarrow$
- $\Downarrow$
-
多态与子类型
- $\forall X.T$
- $S <: T$
-
资源使用
- $\text{lin}$
- $\text{un}$
先把这些记号读顺,再回头看更细的符号,通常会轻松不少。
附录C:自检问题
附录C:自检问题
本附录不是“考试题集”,而是配合正文使用的阶段性自检清单。它的目标不是考你是否记住了每个公式,而是帮助你判断:
- 哪些概念你已经真正理解了;
- 哪些地方只是“看着眼熟”,但还不能独立推导;
- 下一步应该回看哪一章、补哪一类练习。
如何使用这份自检题?
建议把题目按三种层次来使用:
一、基础掌握
这类问题主要检查你是否掌握了每章的核心定义、规则和最基本例子。
如果这些题做不出来,说明你还不适合直接进入后续章节。
二、推导与分析
这类问题要求你手动做自由变量计算、替换、类型推导、约束生成、合一等操作。
如果这些题做不出来,通常表示你已经“知道这个概念是什么”,但还没有真正会用。
三、综合理解
这类问题要求你把一章内部的多个概念联系起来,或者把不同章节之间的主线串起来。
如果这些题做不出来,不一定说明你前面没学会,更可能说明你还没把本教程的整体结构建立起来。
如何把本附录和其他附录一起用?
本附录最适合和另外两个附录一起配合:
- 附录A:术语表
- 当你卡在“概念分不清”时先查这里;
- 例如:值 / 范式 / 卡住、类型检查 / 类型推断、协变 / 逆变。
- 附录B:符号速查表
- 当你卡在“符号看不顺”时先查这里;
- 例如:
Γ ⊢ t : T、→、∀、<:、⊸。
- 本附录C:自检问题
- 用来判断你是否已经能真正自己做推导、计算和解释。
也就是说,最常见的排错顺序通常是:
- 先看正文对应章节;
- 再查附录A确认术语;
- 再查附录B确认符号;
- 最后回到本附录重新做题。
如果你做题时总在术语或记号上卡住,优先回查附录A、附录B,而不是立刻回正文大段重读。
很多时候,问题并不是“整章没看懂”,而只是某个术语边界或某个记号读法还没稳定下来。
建议的使用节奏
- 每学完一章后,先做该章的“基础掌握”题。
- 如果能顺利完成,再做“推导与分析”题。
- 每学完两到三章后,回头做一次前面章节的“综合理解”题,检查自己是否还能把主线串起来。
- 如果你是第二遍阅读本教程,建议直接优先做“推导与分析”和“综合理解”部分。
建议的做题方式
为了让这份自检题真正起作用,建议尽量避免“看一眼题目,觉得自己会了”这种浏览式做法。更有效的方式通常是:
- 先闭卷回答
- 不看正文、不看附录,先尝试自己写出定义、规则或推导。
- 再对照正文与附录
- 检查自己错在:
- 术语没分清;
- 记号没读准;
- 规则会背但不会用;
- 还是跨章节主线没串起来。
- 检查自己错在:
- 最后做一次最小复现
- 例如:
- 再手算一次替换;
- 再手画一次推导树;
- 再手做一次合一;
- 再手判断一次环境分裂。
- 例如:
如果你能做到“闭卷写出一个最小例子”,通常就说明这个知识点已经开始内化了。
第1章 数学基础
若这一章做题不顺,建议先回看第 1 章正文;若你对“关系 / 推导规则 / 结构归纳 / 推导归纳”这些词本身已经有点混淆,再配合附录A对应条目一起看。
基础掌握
- 什么是二元关系?它与集合之间是什么关系?
- 什么是前序、偏序、等价关系?它们分别需要哪些性质?
- 归纳定义和归纳法各解决什么问题?
- 推导规则的一般形式是什么?什么是公理?
推导与分析
-
设 ,,计算:
-
用归纳定义的方式定义“合法括号串”。
-
用推导规则定义自然数上的“小于等于”关系,并尝试推出:
-
用结构归纳法证明:每个算术表达式的大小(节点数)至少为 1。
-
设有推导规则: 尝试手工构造一个推出 的推导树。
综合理解
- 为什么说后续的语法定义、类型规则和类型安全证明,都离不开“归纳定义 + 推导规则 + 归纳法”这三件工具?
第2章 形式文法与 BNF
若这一章做题不顺,建议先回看第 2 章正文;若你对“终结符 / 非终结符 / 具体语法 / 抽象语法 / AST”这些概念已经混在一起,再配合附录A与附录B一起看。
基础掌握
- 形式文法想解决什么问题?为什么自然语言描述语法不够?
- BNF 中的
::=和|分别表示什么? - 什么是终结符?什么是非终结符?
- 具体语法和抽象语法有什么区别?
推导与分析
- 用 BNF 定义一个简单的布尔表达式语言,包含:
truefalsenotandor
并体现:
not优先级高于andand优先级高于or
-
用你定义的文法推导字符串:
-
为表达式 画出对应的抽象语法树(AST)。
-
试着说明:为什么后续章节里写成 这样的生成式时,通常更应理解为抽象语法对象的归纳定义,而不是完整源代码语法?
综合理解
- 为什么说类型系统主要工作在抽象语法层,而不是源代码表面的具体写法上?
第3章 Lambda 演算基础
若这一章做题不顺,建议先回看第 3 章正文;若你对
FV(t)、[x ↦ s]t、\equiv_\alpha这些记号不熟,先查附录B;若你对“自由变量 / 绑定变量 / α-等价 / 替换 / 新鲜变量”这些术语分不清,再查附录A。
基础掌握
- Lambda 演算(Lambda Calculus)的三种基本构造是什么?
- 什么是绑定变量(bound variable)?什么是自由变量(free variable)?
- 什么叫 α-等价(alpha-equivalence)?
- 替换 的直觉含义是什么?
- 为什么替换时必须避免变量捕获(variable capture)?
推导与分析
- 计算下列项的自由变量集合:
- 判断下列各对项是否 α-等价,并说明理由:
- 与
- 与
- 与
- 计算下列替换,并写出关键步骤:
- ,其中 是自由变量
- 试着解释:为什么“新鲜变量”不是“宇宙中从未出现过的变量”,而是“对当前替换步骤足够新、不会引发冲突的变量”?
综合理解
- 为什么说 α-等价和避免捕获的替换,是后面 β-归约与类型系统形式化的基础?
第4章 Lambda 演算中的计算
若这一章做题不顺,建议先回看第 4 章正文;若你对“β-归约 / redex / 范式 / 发散 / 传名调用 / 传值调用 / 小步语义 / 大步语义”这些术语或记号不稳,先配合附录A、附录B一起查。
基础掌握
- 什么是 β-归约(beta-reduction)?
- 什么是可归约表达式(redex)?
- 什么是范式(normal form)?
- 传名调用(call-by-name)和传值调用(call-by-value)的区别是什么?
- 小步语义(small-step semantics)和大步语义(big-step semantics)分别强调什么?
推导与分析
- 逐步归约下列项:
-
解释为什么下面这个归约不能直接做“无脑替换”: 并给出正确结果。
-
对下列项分别先归约外层和先归约内层: 观察它们是否会得到一致结果。
-
比较下列项在传名调用和传值调用下的行为: 说明为什么两种策略的终止性不同。
-
试着说明:为什么 4.1–4.6 节里可以先用开放项建立策略直觉,而 4.8 节开始又要切换到更严格的小步语义规则?
综合理解
- Church–Rosser 定理保证了什么?
- 为什么本章主要解决“程序如何计算”,而“程序为什么不会卡住”要等到下一章才真正形式化?
第5章 类型系统核心概念
若这一章做题不顺,建议先回看第 5 章正文;若你对
Γ ⊢ t : T、进展性、保持性、类型安全、替换引理这些概念不稳,建议同时回查附录A和附录B。
基础掌握
- 类型系统在这一章里试图排除的核心坏状态是什么?
- 类型判断 的含义是什么?
- 本章对象语言为什么更准确地说是“带布尔值与条件表达式的 STLC 扩展”?
- 什么是进展性(progress)?
- 什么是保持性(preservation)?
推导与分析
-
构造推导树,证明:
-
说明为什么下面这个项无法通过类型检查:
-
构造推导树,证明:
-
用自然语言解释替换引理在保持性证明中的作用。
-
试着区分:
- 声明式类型规则在回答什么问题?
- 语法制导的类型检查过程又在回答什么问题?
综合理解
- 为什么“类型安全 = 进展性 + 保持性”?
- 为什么“良类型程序不会出错”不能被理解成“良类型程序一定正确”?
第6章 一阶类型系统:基本类型构造
若这一章做题不顺,建议先回看第 6 章正文;若你对积类型、和类型、记录类型、递归类型、
fold / unfold、Ref(T)这些构造已经混在一起,先查附录A与附录B,再回来做题。
基础掌握
- 什么是一阶类型系统(first-order type system)?这里的“一阶”主要排除了什么能力?
Bool、Nat、Unit的直觉含义分别是什么?- 什么是积类型(product type)?什么是和类型(sum type)?
- 什么是记录类型(record type)?它与积类型是什么关系?
- 什么是递归类型(recursive type)?为什么它对列表、树这类结构是必要的?
- 什么是引用类型
Ref(T)?
推导与分析
-
解释为什么
if t1 then t2 else t3的两个分支必须有相同类型。 -
写出一个
Bool × Nat类型值的例子,并说明如何分别取出它的两个分量。 -
用自然语言解释类型: 中的值可能长什么样。
-
解释为什么列表类型可以写成:
-
说明为什么引入
Ref(T)之后,类型安全证明需要额外跟踪存储的类型信息。 -
试着说明:为什么第 6 章允许显式写出递归类型
\mu X.T,而这并不等于说第 9 章的 HM 合一就会允许任意“无限类型”自动出现?
综合理解
- 为什么说一阶类型系统的核心任务,是让类型系统能够描述常见的数据形态,而不仅仅是函数?
第7章 二阶类型系统:多态与抽象
若这一章做题不顺,建议先回看第 7 章正文;若你对
∀、∃、ΛX.t、t[T]、参数化多态、存在类型、打包 / 拆包这些概念不稳,先配合附录A与附录B一起看。
基础掌握
- 为什么一阶类型系统无法自然表达“对任意类型都成立的恒等函数”?
- 什么是参数化多态(parametric polymorphism)?
System F比普通 STLC 多了哪两类核心构造?- 全称类型 的直觉含义是什么?
- 存在类型(existential type) 的直觉含义是什么?
- 什么是类型算子(type operator)?
推导与分析
-
写出多态恒等函数的 System F 项,并说明它为什么具有类型:
-
解释为什么: 和 可以看作同一个多态项的不同实例化。
-
比较:
并说明它们在直觉上的区别。
-
用自然语言解释为什么存在类型可以表达“隐藏实现、暴露接口”。
-
试着说明:为什么第 7 章对象语言中的
∀X.T与ΛX.t,不应直接和第 9 章 HM 环境中的类型方案∀α.T混为一谈?
综合理解
- 为什么说二阶类型系统让我们不仅能给项分类,还能对类型本身做抽象?
- 为什么参数化多态与存在类型一起构成了类型化抽象的重要基础?
第8章 子类型
若这一章做题不顺,建议先回看第 8 章正文;若你对
S <: T、T-Sub、安全替换、协变 / 逆变 / 不变这些概念仍然摇摆,先查附录A和附录B,再回来重新看函数子类型的方向。
基础掌握
- 子类型(subtyping)关系 的直觉含义是什么?
- 什么是安全替换原则?
- 为什么
T-Sub(subsumption)是子类型系统的关键规则? - 记录宽度子类型化(width subtyping)是什么意思?
- 为什么函数参数逆变(contravariance),而返回值协变(covariance)?
推导与分析
-
解释为什么下面的子类型关系成立:
-
给出一个具体反例,说明如果函数参数按协变处理,会产生什么类型安全问题。
-
设有: 讨论下列哪一个关系应成立,并说明理由:
-
用自然语言解释为什么引用类型通常不能简单协变或逆变。
-
试着说明:为什么本章主要处理的是“可安全提升”的
<:关系,而不是第 9 章里那种“必须相等”的类型等式与合一问题?
综合理解
- 为什么本章的真正主线不是“列举哪些类型有子类型关系”,而是“如何通过
T-Sub把子类型接入类型系统”?
第9章 类型推断
若这一章做题不顺,建议先回看第 9 章正文;如果你已经把“替换”“类型方案”“泛化”“实例化”“合一”“occur check”混在一起,最好同时回查附录A与附录B,并顺手对照第 7 章重新看“显式多态”和“HM 推断”的区别。
基础掌握
- 类型推断和类型检查的区别是什么?
- 什么是 Curry 风格(Curry-style)项?
- 类型推断为什么可以分解成“生成约束(constraint generation)+ 求解约束”?
- 什么是合一(unification)?
- 什么是 occur check?
- 什么是 let 多态(let polymorphism)?
推导与分析
- 推断下列项的最一般类型:
-
对项 手工生成约束,并说明为什么合一失败。
-
对约束组 手工执行合一步骤,写出每一步的替换传播与最终结果。
-
解释为什么下面的表达式在 HM 中可以通过:
let id = fun x -> x in (id true, id 0)
- 解释为什么下面的表达式在标准 HM 中通常不能通过:
fun id -> (id true, id 0)
- 试着说明:为什么第 9 章里对
\lambda f.\lambda x.f x的推断,最先得到的是一个最一般单态类型;而“全称量化后的类型方案”要到顶层或let绑定语境里才最自然?
综合理解
- 为什么函数类型的合一不能简单把两个子问题的结果做“并集”,而必须传播并组合替换?
- 为什么只有
let绑定触发泛化,而普通函数参数通常保持单态? - 为什么 HM 的
let多态不应被理解成“完整 System F 的自动推断版”?
第10章 子结构类型系统
若这一章做题不顺,建议先回看第 10 章正文;若你对“结构规则 / 环境分裂 / 线性函数 / 仿射资源 / 会话类型”这些概念还没有形成稳定图景,最好同时回查附录A与附录B,并顺手回看第 6 章引用类型、第 8 章变化方向的相关部分。
基础掌握
- 什么是结构规则?
- 交换、弱化、收缩分别表达什么?
- 线性、仿射、相关、有序类型系统分别限制了什么?
- 什么是环境分裂?
- 为什么子结构类型系统不仅关心“值是什么”,还关心“值怎样被使用”?
推导与分析
- 解释为什么“允许收缩”会让资源管理变得危险。
- 说明仿射类型和线性类型的区别,并各举一个更适合它们的资源场景。
- 为什么环境分裂是线性函数应用规则中的关键机制?
- 设想一个“先发送请求,再接收响应”的通信协议,说明为什么单纯的线性使用还不够,还需要顺序约束。
- 解释为什么不能简单说“Rust 就是线性类型系统”或“Rust 就是仿射类型系统”。
- 试着比较:
- 第 5 章主要关心“项是否良类型”;
- 第 10 章进一步关心“变量能否被丢弃、复制或乱序使用”。
说明这两类约束的层次差别。
综合理解
- 常规类型系统为什么会把“变量可自由复制与丢弃”当成默认前提?
- 为什么子结构类型系统可以看作一种把“资源使用纪律”纳入类型系统的方式?
跨章节综合题
下面这些问题适合在你完成正文一遍阅读后再做。
主线回顾
- 用自己的话说明本教程的大主线:
- 语法定义了什么?
- 语义定义了什么?
- 类型系统定义了什么?
- 元理论又说明了什么?
- 为什么要先学 Lambda 演算,再学类型系统?
- 为什么类型安全证明离不开第三章的替换和第四章的操作语义?
对比与联系
- 比较下列几组概念:
- α-等价 vs β-归约
- 值 vs 范式
- 类型检查 vs 类型推断
- 参数化多态 vs 子类型
- 线性类型 vs 仿射类型
- 为什么说:
- 第五章关心“程序为什么不会卡住”
- 第九章关心“程序员不写注解时系统怎么恢复类型”
- 第十章则进一步关心“值应该怎样被使用”
- 试着说明下列两组“全称量化”为什么不能简单混为一谈:
- 第 7 章中的
∀X.T - 第 9 章 HM 环境中的类型方案(type scheme)
∀α.T
- 试着说明下列两组“递归 / 无限”为什么也不能简单混为一谈:
- 第 6 章中的显式递归类型
μX.T - 第 9 章 occur check 所阻止的无限类型(infinite type)
更进一步的反思
- 为什么类型系统必须是“保守的近似”,而不能精确分析程序所有语义行为?
- 为什么“表达力更强”往往会和“推断更难、实现更复杂”同时出现?
- 如果让你用一句话分别概括第 3–10 章各章的核心贡献,你会怎么写?
使用这份自检题时的一个提醒
如果你发现自己:
- 能复述定义;
- 但做不出推导;
- 或者能做局部推导,但说不清章节主线;
这都很正常。它通常说明你正处在“从看懂走向会用”的阶段。
这份附录的作用,不是让你因为不会而沮丧,而是帮你定位:
- 是定义没有真正理解;
- 还是规则不会操作;
- 还是跨章节的主线还没有建立起来。
只要你能据此回到对应章节,有针对性地重做例子和练习,这份自检题就达到了它的目的。
如果你想把这份附录用得更有效,可以把每次做题后的结果简单记成三类:
- 会复述,但不会算
- 会算局部,但不会串主线
- 已经能闭卷做最小例子
这样你在第二轮、第三轮回看本教程时,会更容易判断自己下一步该补哪一类能力。
附录D:不动点组合子与递归(进阶选读)
附录D:不动点组合子与递归(进阶选读)
本附录适合与第 4 章“求值策略”、第 5 章“类型系统核心概念”配合阅读。
如果你一时忘了“不动点”“发散”“传名调用 / 传值调用”“强正规化”这些术语,也可以先回查附录A“术语表”。
阅读时也建议把它和附录E“Church 编码”对照起来看:两者共同展示了未类型化 Lambda 演算的表达能力,只是一个侧重递归与不动点,另一个侧重数据与控制结构的函数编码。
前面的章节里,我们已经看到:
- Lambda 演算的核心计算规则是 β-归约;
- 未类型化 Lambda 演算足以表达非常丰富的计算行为;
- 但在最基本的语法中,并没有“函数名字定义自己”的递归构造。
于是一个自然问题出现了:
如果语言里没有内建递归,递归函数是怎么表达出来的?
这就是本附录要讨论的主题:不动点组合子(fixed-point combinator)。
这部分内容对理解第五章以后的核心类型系统并不是必需的,但它非常值得一看,因为它展示了 Lambda 演算表达能力中最令人惊讶的一面:
递归并不一定需要语言原生提供;在未类型化 Lambda 演算中,它甚至可以被“编码”出来。
如果把它放回正文主线中看,那么本附录主要回扣三件事:
- 第 4 章:为什么求值策略会影响一个构造“是否按预期工作”;
- 第 5 章:为什么简单类型系统不会直接容纳这种一般递归能力;
- 附录E:为什么只靠函数本身,就已经能表达比直觉上更多的数据与计算结构。
D.1 什么叫“不动点”?
先不谈程序,先看一个更一般的数学概念。
如果一个对象 满足:
那么就说 是函数 的一个不动点(fixed point)。
直觉上,不动点就是:
被函数作用一次之后,结果仍然是自己。
例如,若某个函数把输入 3 仍然映射到 3,那 3 就是它的不动点。
这个概念之所以和递归有关,是因为递归函数本身常常可以写成一种“不动点方程”。
D.2 递归函数为什么和不动点有关?
考虑一个我们熟悉的递归定义,比如阶乘:
fact(n) =
if iszero(n)
then 1
else times(n, fact(pred(n)))
这个定义的问题在于:右边出现了 fact 自己。
在很多真实语言里,这没有问题,因为语言直接支持具名递归定义。但在最基本的 Lambda 演算语法里,我们只有:
- 变量
- 抽象
- 应用
并没有“let rec fact = ...”这样的构造。
所以直接写“函数体里引用自身名字”这件事,并不是基础语法自带的能力。
更准确地说:
基础 Lambda 演算里没有原生的具名递归绑定。
这不意味着它“完全不能表达递归”,而是说:
- 不能直接靠名字写出自引用定义;
- 需要换一种方式,把“递归”转化成别的结构。
D.3 先把“递归函数”改写成“接受自身作为参数的函数”
上面的阶乘可以先改写成一个“递归生成器”:
factGen =
λf. λn.
if iszero(n)
then 1
else times(n, f(pred(n)))
这里的 factGen 不再直接调用自己。相反:
- 它接收一个参数
f - 这个
f表示“递归调用时要用的那个函数”
如果某个函数 g 满足:
那么 g 就恰好是我们想要的递归函数。因为把 g 代入进去后,得到的正是:
- 对
n判断是否为零; - 否则递归调用
g自己。
这时问题就转化成了:
如何找到一个
g,使得 ?
而这正是不动点问题。
因为“找到一个 使得 ”本质上就是“找到函数 的不动点”。
D.4 不动点组合子是什么?
不动点组合子就是这样一种 Lambda 项:
给定任意函数
f,它能构造出f的一个不动点。
最经典的不动点组合子是 Y 组合子:
它的神奇之处在于:
这说明:
Y g归约后会变成g (Y g)- 因而
Y g在归约意义下表现得像g的一个不动点
要注意这里的表述边界:
更准确地说,
Y g与g (Y g)是通过归约相关联的,而不是简单的字面相等。
在形式语境里,最好把它理解为:
或者说二者在适当意义下是等价的,而不是把它写成“完全相同的文本”。
D.5 验证:为什么 Y g 会展开成 g (Y g)?
下面一步步验算。
从定义开始:
先对最外层做一次 β-归约:
再归约一次:
观察括号里的那一部分:
它正好就是前一步中的 Y g 展开结果。因此可写成:
于是得到:
这就是不动点的核心结构。
D.6 用 Y 表达递归
既然 Y g 会展开成 g (Y g),那么对前面的 factGen,就可以定义:
直觉上:
factGen描述“如果已经给你一个递归调用入口f,那该怎么计算”Y负责把这个“需要递归入口的函数”变成真正自引用的递归函数
因此:
而 Y factGen 本身就是 fact,所以它会表现得像一个真正递归定义的函数。
这就是“不动点 = 递归”的核心联系。
D.7 为什么这件事如此惊人?
因为在基础语法里:
- 没有
let rec - 没有
fix - 没有“函数名绑定到自己”这种专门机制
但仅仅靠:
- 抽象
- 应用
- 替换
就已经能表达递归行为。
这说明未类型化 Lambda 演算的表达能力非常强。它不是“少功能的玩具语言”,而是一个极简但极有表现力的计算模型。
D.8 关键边界:Y 与求值策略有关
这里必须特别小心一个非常重要的点:
标准 Y 组合子是否“按预期工作”,取决于求值策略。
这是学习不动点组合子时最常见的误区之一。
很多资料会直接写:
然后给人一种印象:只要有 Y,递归就“自动可用”。
但实际上,这里隐含了对归约方式的假设。
D.9 为什么 Y 在传名调用下可用?
先回忆第四章的区分:
- 传名调用:先展开外层应用,再按需计算参数
- 传值调用:先把参数算成值,再进行函数应用
对:
在传名调用下,我们可以直接做最外层 β-归约,于是逐步得到:
如果 g 在函数体里只在需要的时候才使用递归结果,那么整个程序就能像递归一样工作。
这也是为什么标准 Y 组合子通常和传名调用语境联系在一起。
D.10 为什么 Y 在传值调用下会出问题?
在传值调用下,函数应用前必须先把参数算成值。
观察 Y 的结构:
应用到 g 后得到:
这里要特别注意一个容易说错的细节:
本身其实已经是一个值,因为在传值调用下,Lambda 抽象就是值。
真正的问题不在于“它还不是值”,而在于:
- 当外层应用继续展开后,
- 自应用结构
x x会不断重新暴露出来; - 于是求值会被反复拉回这个自展开模式,
- 还没来得及把一个“可正常使用的递归入口”稳定交给
g的主体逻辑,就已经开始无限展开。
所以,更准确的说法是:
标准 Y 在传值调用下的问题,是自应用过早、过猛地被不断展开,而不是“参数本身不是值”。
更直观地说:
- 传名调用:先“把递归壳子展开一层”
- 传值调用:却试图“先把那个无限自展开的东西算完”
而那个东西本来就不会先算完。
所以标准 Y 在传值调用下通常不会按我们期望的方式产生可用递归,而是会过早地发散。
更稳妥的总结是:
标准 Y 组合子适合传名调用语境;在传值调用下,需要改造版本。
D.11 传值调用下的变体:Z 组合子
为适应传值调用,常用一个改造版:Z 组合子。
一个常见写法是:
和 Y 比较,关键变化是:
- 不再直接把
x x作为递归结果传给f - 而是包了一层额外的函数:
这层额外的 λv 非常重要,因为它起到了延迟求值的作用。
D.12 为什么多包一层 λv 就能延迟求值?
这是理解 Z 的关键。
在传值调用下,Lambda 抽象本身就是值。因此:
会被视为一个已经准备好的值,而不会立刻去展开其中的 x x。
也就是说:
Y里把x x直接暴露在应用位置,导致传值调用急着去算它;Z把它藏进一个函数体里;- 于是只有当这个递归入口真正被调用到某个参数
v时,x x才会继续展开。
所以 Z 的核心技巧可以概括成:
把“递归自展开”包装进一层函数,以推迟它真正发生的时机。
这正是传值调用下递归编码所需要的。
D.13 Z 的直觉用途
如果把递归生成器写成:
那么在传值调用语境下,更常见的写法是:
这样得到的项在行为上就更接近我们期待的递归函数。
要注意,这里的“更接近”不是一句空话,而是强调:
Z是为了配合传值调用设计的;- 它修复的是标准
Y在严格求值下“自应用结构过早反复展开”的问题。
D.14 与真实语言中的递归有什么关系?
真实语言通常不会要求程序员手写 Y 或 Z 组合子。
更常见的做法是,语言直接提供某种递归机制,例如:
let recfix- 递归函数定义
- 对象 / 方法层面的自引用机制
从理论上看,这些机制往往都可以理解成“给语言加入一个求不动点的能力”。
在很多类型理论教材里会直接把递归写成一个显式构造:
并赋予规则:
这可以看成是“把求不动点的能力直接作为语言构造提供出来”,而不要求程序员手写 Y 或 Z。
这和 Y / Z 组合子的思想本质上一样,只是把“不动点”从编码技巧变成了语言原生构造。
所以更准确的说法是:
Y/Z展示了递归可以被编码;而fix展示了递归也可以被原生提供。
D.15 与类型系统的关系:为什么 STLC 不直接接受它?
到这里你可能会问:
既然不动点组合子这么强,为什么第五章的简单类型系统里没有它?
原因在于,简单类型 Lambda 演算(STLC)有一个非常重要的性质:
良类型项总是强正规化的。
这正好回扣第五章的主线:第五章之所以能在那个最小核心上讨论类型安全、并保持一个相对干净的元理论图景,部分原因就在于我们还没有把“一般递归”直接加入对象语言。
也就是说:
- 所有良类型项都会终止;
- 不会出现像
Ω那样的无限展开。
而递归的本质恰恰常常依赖“不一定终止”。
阶乘当然会终止,但一个一般递归机制必须允许我们表达那些可能无限循环的程序。
因此:
- 若把完整不动点组合子直接加入 STLC,
- 就会打破强正规化。
所以在类型化语言里,递归通常需要通过更仔细的扩展方式引入,而不能简单把未类型化的 Y 原封不动搬进去。
这也解释了为什么本教程正文没有在第 5 章的 STLC 核心中直接加入一般递归:那会改变该章所依赖的重要元性质,并把讨论重心从“最小安全核心”转移到“如何控制递归带来的额外复杂性”。
这也是为什么“不动点组合子”在未类型化 Lambda 演算中极其自然,而在类型系统里会变得更微妙。
D.16 本附录小结
本附录真正想建立的是下面这条主线:
- 基础 Lambda 演算没有原生具名递归定义。
- 但递归函数可以改写成“接受自身作为参数的生成器”。
- 于是“递归”转化成了“求某个函数的不动点”。
- Y 组合子展示了:在未类型化 Lambda 演算中,不动点可以被编码出来。
- 但
Y是否按预期工作,和求值策略密切相关:- 传名调用下,标准
Y可自然展开; - 传值调用下,标准
Y会过早发散。
- 传名调用下,标准
- Z 组合子通过多包一层 Lambda,延迟自展开,从而更适合传值调用。
- 真实语言中的递归构造,常可视为“把求不动点的能力原生加入语言”。
如果把本附录压缩成一句话,那就是:
递归本质上可以理解为“不动点”,而不动点组合子展示了 Lambda 演算如何仅凭函数本身表达递归。
如果你想把这条线继续延伸,比较自然的回看路径是:
- 回到第 4 章,重新看“传名调用 / 传值调用”与求值策略差异;
- 回到第 5 章,重新看 STLC、类型安全与强正规化的边界;
- 再对照附录E,体会未类型化 Lambda 演算如何同时编码“递归能力”和“数据表示能力”。
进一步思考
- 为什么把递归函数改写成“接受自身作为参数的函数”后,问题就变成了不动点问题?
Y g与g (Y g)为什么更适合说成“通过归约相关联”,而不是简单地“字面相等”?- 为什么传值调用会让标准
Y提前发散? Z组合子里的额外\lambda v起到了什么作用?- 为什么简单类型 Lambda 演算不能直接容纳完整的不动点组合子?
附录E:Church 编码(进阶选读)
附录E:Church 编码(进阶选读)
本附录适合在读完第 3 章和第 4 章之后阅读。它不是理解后续类型系统章节的前置条件,但非常适合用来体会一个重要事实:
即使语言极端简化到只剩变量、抽象和应用,也仍然能够表示我们熟悉的数据与计算。同时,这一附录也可以和第 6 章“一阶类型系统:基本类型构造”对照着看:
第 6 章把Bool、Nat、if等构造当作语言的原生部分来引入,是为了让类型规则与类型安全主线更清楚;而本附录展示的是:即使不把这些构造当作原生语法,未类型化 Lambda 演算也仍然可以通过编码方式表示出相应行为。
在前面的章节里,我们一直强调 Lambda 演算的“极简”:
- 没有内置数字
- 没有内置布尔值
- 没有内置
if - 没有内置列表、元组、树
这时一个自然问题就出现了:
如果什么都没有,Lambda 演算到底拿什么来“编程”?
Church 编码(Church encoding)给出的答案是:
用函数来表示数据。
也就是说,我们不再把“布尔值”“数字”“数据结构”看成语言的原始成分,而是把它们都编码成某种 Lambda 项。
这件事之所以重要,不是因为实际编程语言真的会让你手写这些编码,而是因为它展示了 Lambda 演算的表达能力:
即使语法极度贫瘠,只要函数和应用还在,很多看似“必须内置”的概念都可以被表示出来。
E.1 为什么布尔值可以编码成函数?
先从最简单的对象开始:布尔值。
如果你仔细想想,布尔值最核心的用途是什么?
不是“它长得像 true 或 false 这两个单词”,而是:
它能帮助我们在两个分支之间做选择。
例如:
if true then a else b的结果是aif false then a else b的结果是b
所以,从行为角度看:
true就是“选第一个”false就是“选第二个”
而“从两个候选里选一个”这件事,本来就可以用函数表达。
E.2 Church 布尔值
按照上面的想法,我们定义:
这两个定义的行为分别是:
true接收两个参数,返回第一个false接收两个参数,返回第二个
这和“真假控制分支”的直觉完全一致。
E.2.1 条件表达式
既然 true 和 false 已经能“选分支”,那么条件表达式也可以编码为:
它的含义是:
- 先给一个布尔值
b - 再给两个候选
x和y - 然后让
b决定选哪一个
也就是说,if 不再是语言内置语法,而只是一个普通函数。
不过这里必须立刻补一个重要边界:
Church 编码里的布尔值与
if,最自然地表现为“分支选择行为”;但它并不自动等同于现实严格语言里的原生条件表达式。
原因是:
在第四章讨论过的不同求值策略下,编码后的 if 行为会有差异。
- 在更接近传名调用 / 正规序的语境里,
b x y的“先选哪一支、再继续算那一支”的直觉最自然; - 但在传值调用语境里,若把
x和y都当作普通实参传入,那么它们往往会在进入b x y之前就先被求值; - 这样一来,Church 编码得到的
if就不再像很多语言内置的条件语句那样,天然具有“只计算被选中分支”的效果。
因此,更稳妥的理解方式是:
Church 布尔值准确编码的是“二选一的控制接口”;至于它在某种求值策略下是否表现得像原生条件语句,还要结合具体求值规则一起看。
E.3 一个完整演算:为什么 if true a b 会得到 a?
我们来验证:
把定义展开:
同理:
所以,这套编码确实实现了“真假控制分支”。
E.3.1 这里真正发生了什么?
从表面上看,我们好像“发明了布尔值”。
但更准确地说,我们做的是:
- 不再把布尔值当成“某种原始数据”
- 而是只保留它最核心的行为
- 再用函数把这种行为表示出来
这就是 Church 编码的基本精神:
数据不由“长相”定义,而由“可观察行为”定义。
E.4 为什么自然数也可以编码成函数?
接下来是数字。
布尔值的关键行为是“二选一”;
而自然数的关键行为是什么?
一种很自然的答案是:
一个自然数表示“把某个操作重复多少次”。
例如:
0:做 0 次1:做 1 次2:做 2 次3:做 3 次
这个想法非常适合函数编码,因为“重复应用某个函数若干次”本来就是高阶函数能表达的事情。
E.5 Church 数
据此,我们定义:
一般地:
它们的含义是:
c0:把f作用 0 次,直接返回xc1:把f作用 1 次c2:把f作用 2 次c3:把f作用 3 次
所以 Church 数并不是“看起来像数字的值”,而是:
一个接收函数
f和初始值x,并把f重复应用若干次的高阶函数。
E.6 一个直觉例子:c_2 为什么像 2?
来看:
如果你给它一个函数 f 和一个起点 x,它会产生:
也就是“做两次 f”。
因此,c_2 并不是“符号 2 的替身”,而是“二次迭代器”。
这就是 Church 数最应该记住的直觉:
自然数被编码成“迭代次数”。
E.7 后继函数:如何把 n 变成 n+1?
如果一个 Church 数表示“重复应用 次”,那么“后继”最自然的定义就是:
先做原来的 次,再额外做一次。
因此定义:
它的含义非常直观:
n f x先做n次f- 外面再套一个
f - 总共就做了
n+1次
E.7.1 验证 succ c_1 = c_2
我们来算:
再展开 c_1 = \lambda f.\lambda x.f x:
这正是 c_2。
E.8 加法:为什么“先做 n 次,再做 m 次”就是加法?
如果数字表示“重复应用次数”,那么:
m + n就应该表示:- 先做
n次 - 再做
m次
因此定义:
读法是:
- 先计算
n f x - 再把
f额外做m次
总计就是 m+n 次。
E.8.1 例子:plus c_1 c_2
展开:
而:
c_2 f x = f(f x)c_1 f y = f y
所以整体变成:
这正是 c_3。
E.9 乘法:为什么“n 次地做 m 次”就是乘法?
如果一个数字表示“迭代”,那么乘法自然可以理解成:
把“做 次”这个过程本身,再重复做 次。
因此定义:
直觉是:
n f是“做 n 次 f”的新函数- 然后
m (n f)表示“把这个 n 次迭代器再做 m 次” - 总效果就是
m \times n次
这一定义初看有点抽象,但一旦抓住“Church 数 = 迭代器”的核心,乘法就会自然很多。
E.10 Church 编码真正展示了什么?
到这里为止,我们已经看到:
- 布尔值可以编码成函数
- 条件表达式可以编码成函数
- 自然数可以编码成高阶函数
- 后继、加法、乘法也都可以定义出来
这说明了一个重要事实:
许多你以为必须由语言原生提供的数据和操作,其实都可以由函数组织出来。
这并不意味着真实语言不该提供数字和布尔值。
真实语言当然应该内置这些东西,因为:
- 更高效
- 更直观
- 更适合工程实现
Church 编码的意义不在于“替代真实语言实现”,而在于展示:
- Lambda 演算的表达能力极强;
- “数据”与“行为”之间可以有非常深的联系;
- 极简语言依然可以成为严肃的计算模型。
如果把它和第 6 章放在一起看,可以更清楚地理解两种写法的分工:
- 第 6 章的写法是:把
Bool、Nat、和类型等当作语言原生构造,以便直接讨论类型规则与类型安全; - 本附录的写法是:退回到更极简的未类型化 Lambda 演算,只保留函数与应用,再展示这些“看似必须内建”的对象其实也能被编码出来。
因此,这两部分不是互相冲突,而是分别服务于两条不同目标:
- 正文主线强调:怎样建立清楚、可证明的类型系统骨架;
- 本附录强调:极简计算模型本身具有多强的表达能力。
E.11 Church 编码的边界
看到这里,也要避免把结论说得太满。
更稳妥的说法是:
- 在适当编码下,未类型化 Lambda 演算可以表示许多熟悉的数据和计算过程;
- 这与它作为通用计算模型的地位一致;
- 但“能表示”不等于“适合实际工程实现”。
此外,Church 编码在不同求值策略下的行为细节也可能不同;某些编码在理论上很优雅,但在实际求值时未必高效。
特别是前面提到的 Church 布尔值与编码后的 if:它们在“分支选择”意义上非常清楚,但在严格求值语言里,并不应直接被理解成对原生条件表达式的逐字替代。
所以更好的理解方式是:
Church 编码是一种理论展示:它说明函数足以承担比你第一眼想象更多的表达任务。
E.12 本附录小结
这一附录最重要的内容只有三点:
-
布尔值可以看作“在两个候选中选一个”的函数
true选第一个false选第二个
-
自然数可以看作“重复应用多少次”的迭代器
0是做 0 次1是做 1 次2是做 2 次- 以此类推
-
一旦接受“数据由行为定义”这个视角,许多熟悉的数据结构都可以编码成函数
- 布尔值
- 数字
- 条件表达式
- 算术运算
如果把这一附录压缩成一句话,那就是:
Church 编码告诉我们:在 Lambda 演算里,函数不仅能表示计算过程,也能表示数据本身。
练习
-
验证:
-
验证:
-
用自然语言解释为什么: 确实表示加法。
-
试着说明为什么 Church 数最自然的直觉不是“数字长什么样”,而是“它让某个操作重复多少次”。
附录F:学习建议
附录F:学习建议
这一附录不讲新的形式系统,而是回答一个更实际的问题:
本教程应该怎样学,才更容易真正掌握?
类型系统和编程语言理论有一个很典型的特点:“看懂”往往不等于“会用”。
你也许能顺着文字读懂自由变量、替换、类型规则、合一算法这些概念,但如果没有亲手推导、计算和实现,它们很容易停留在“似懂非懂”的状态。
因此,这份学习建议不追求面面俱到,而是给出一条阶段化的自学路径。你可以把它当作:
- 第一次通读时的阅读策略
- 第二次精读时的练习指南
- 后续继续深入时的延伸路线
一、第一阶段:先抓主线,不求一次学全
第一次阅读本教程时,最重要的目标不是记住每个公式,而是建立一条稳定主线:
语法定义程序长什么样,语义定义程序怎么计算,类型系统定义哪些程序被允许,元理论说明这些规则为什么可靠。
这一阶段建议把几个附录一起当作“导航工具”来使用,而不是等正文全看完再回头翻:
- 附录A:术语表 —— 用来快速确认“值 / 范式 / 卡住”“类型检查 / 类型推断”“Church 风格 / Curry 风格”等术语差别;
- 附录B:符号速查表 —— 用来快速确认
Γ ⊢ t : T、→、∀、<:、⊸这类记号的读法与语境; - 附录C:自检问题 —— 用来判断你是“看懂了”,还是已经能自己推导与计算;
- 附录D、E —— 适合在读完第 4 章之后作为进阶选读,帮助你把“求值策略”“递归”“编码”这些概念连起来。
如果你第一次阅读时能把这条主线建立起来,后面的细节就更容易找到位置。
第一阶段建议的阅读顺序
建议按正文顺序阅读,并把附录A、B作为随查随用的参考:
- 第零章:为什么需要类型系统
- 第一章:数学基础
- 第二章:形式文法与 BNF
- 第三章:Lambda 演算基础
- 第四章:Lambda 演算中的计算
- 第五章:类型系统核心概念
读到这里时,你应该至少已经能回答:
- 什么是自由变量、绑定变量、替换?
- β-归约在做什么?
Γ ⊢ t : T这类判断是什么意思?- 为什么类型安全通常拆成进展性与保持性?
如果这些问题已经比较稳,再继续往后读:
- 第六章:一阶类型系统
- 第七章:二阶类型系统
- 第八章:子类型
- 第九章:类型推断
- 第十章:子结构类型系统
而在每读完一章后,建议立刻配合:
- 回查一次附录A / 附录B,确认这章新增术语与符号;
- 做一小部分附录C里的对应自检题;
- 在读完第 4 章后,再决定是否进入附录D / E做进阶扩展。
第一阶段不要强求的事
第一次读时,不必要求自己:
- 立刻背下所有规则名
- 一次掌握所有细节证明
- 对每个扩展系统都做到完全形式化理解
- 看到公式就立刻能自己重建全部推导
更合理的目标是:
- 知道这个概念想解决什么问题
- 能用自然语言解释它的核心直觉
- 能看懂至少一个最基本的例子
二、第二阶段:从“看懂”过渡到“会做”
如果第一遍阅读建立了整体轮廓,第二阶段就应该把重点放在手动操作上。
这一阶段最重要的事情主要有三类:
- 手算
- 手推
- 手写实现
2.1 手算:训练对形式对象的熟悉感
以下内容特别适合手算:
- 自由变量集合
- α-重命名
- 替换
- β-归约
- 小步语义推导
- 合一过程
例如,遇到下列内容时,不要只看答案,最好亲手写一遍:
FV(λx.λy.x y z)[x ↦ y](λy.x)为什么不能直接替换(\lambda x.x) ((\lambda y.y) z)在不同策略下如何归约\lambda f.\lambda x.f x的约束如何生成α = β → γ这样的约束如何代回整体类型
这些练习的价值,不在于它们“像考试题”,而在于它们会训练你对形式系统的操作感。
没有这种操作感,后面再看类型安全证明、推断算法、子类型方向时会非常吃力。
2.2 手推:训练对规则系统的理解
以下内容特别适合手推导:
- 类型推导树
- 子类型推导
- 环境分裂
- 小步或大步语义推导
推荐至少亲手做这些最小例子:
- 证明
⊢ λx:Bool.x : Bool → Bool - 证明
f:Bool→Bool, x:Bool ⊢ f x : Bool - 说明为什么
true (λx:Bool.x)无法类型化 - 推出一个简单记录子类型关系
- 在子结构系统里手动拆一次环境
如果你只是“看懂推导树长什么样”,往往还不够。
真正重要的是:你能不能反过来,从结论往上找规则,从前提往下拼出整棵树。
2.3 手写实现:训练从理论到程序的转换
如果你有编程基础,建议至少实现下面两个最小工具中的一个:
- 一个简单的 Lambda 项求值器
- 一个简单的 STLC 类型检查器
如果时间允许,更进一步可以做:
- 替换函数(注意避免变量捕获)
- 小步求值器
- Hindley–Milner 风格的最小类型推断器
- 合一算法
实现语言不重要。以下都可以:
- OCaml
- Python
- TypeScript
- Rust
- Zig
关键不是“用哪门语言最正统”,而是你能否把抽象规则变成可以运行的程序。
如果你能亲手把
App规则、替换、合一算法写出来,很多原本抽象的概念会立刻变得具体。
三、第三阶段:按主题回读,而不是只按章节重读
学到中后期时,建议不要只是“从头再看一遍”,而是按主题回读。
下面是一种很有效的回读方式。
3.1 主题一:变量与替换
重点回看:
- 第三章:自由变量、α-等价、替换
- 第四章:β-归约
- 第五章:替换引理在保持性中的作用
这一轮回读的目标是回答:
- 为什么替换必须避免捕获?
- β-归约为什么本质上是替换?
- 保持性证明为什么离不开替换引理?
3.2 主题二:规则、推导与类型安全
重点回看:
- 第一章:推导规则、结构归纳、推导归纳
- 第五章:类型规则、进展性、保持性
- 第八章:subsumption 与子类型规则
这一轮回读的目标是回答:
- 一个类型判断为什么等价于存在一棵推导树?
- 为什么进展性和保持性合起来才构成类型安全?
- 子类型是怎样进入普通类型规则系统的?
3.3 主题三:抽象能力如何逐步增强
重点回看:
- 第六章:积、和、递归、引用
- 第七章:参数化多态、存在类型、类型算子
- 第八章:子类型
- 第九章:类型推断
这一轮回读的目标是回答:
- 一阶系统解决了什么?
- 二阶系统比一阶系统强在哪里?
- 子类型、推断、多态分别解决了“灵活性”的哪一部分问题?
3.4 主题四:类型不仅描述“值是什么”,还描述“值怎么用”
重点回看:
- 第六章:引用与状态
- 第十章:线性、仿射、相关、有序类型
这一轮回读的目标是回答:
- 为什么普通类型系统不足以描述资源纪律?
- 子结构类型系统比普通类型系统多描述了什么?
- 为什么 Rust、会话类型等方向都和这里有关?
四、把正文与附录串成一条学习路径
如果你想把这本书读得更顺,下面是一条很实用的“正文 + 附录”配合方式:
-
读正文主线
- 先按章节顺序推进,优先理解每章的核心动机、定义与最小例子。
-
遇到陌生术语,立刻查附录A
- 不必硬扛着继续往下读。
- 尤其适合处理:
- 值 / 范式 / 卡住
- 进展性 / 保持性
- 子类型 / subsumption
- 泛化 / 实例化
- 线性 / 仿射 / 有序
-
遇到陌生符号,立刻查附录B
- 尤其适合处理:
Γ ⊢ t : T→、→*、⇓∀、∃<:、⊸fold / unfold相关记号
- 尤其适合处理:
-
每学完一章,用附录C做一次最小自检
- 如果“基础掌握”做不出来,先不要急着冲下一章;
- 如果“推导与分析”做不出来,说明你需要回到正文手算几个例子;
- 如果“综合理解”做不出来,说明你还需要把这一章和前后章节的主线重新串起来。
-
在第 4 章之后阅读附录D、E
- 附录D:不动点组合子与递归
- 适合和第 4 章的求值策略一起看;
- 也适合与第 5 章“为什么 STLC 不能直接容纳这种递归”形成对照。
- 附录E:Church 编码
- 适合和第 3–4 章一起看;
- 能帮助你更深地理解“函数不仅表示计算,也能表示数据”。
- 附录D:不动点组合子与递归
-
学完整本书后,再回到附录A、B做一次总复习
- 这时附录A会更像“概念地图”;
- 附录B会更像“记号导航图”;
- 你会更容易看到整本书的结构一致性。
五、每一章最值得优先掌握什么?
如果你时间有限,下面是每章最值得优先掌握的“最低核心”。
| 章节 | 最低核心 |
|---|---|
| 第零章 | 类型系统不是保证“程序绝对正确”,而是排除某类错误 |
| 第一章 | 归纳定义、推导规则、结构归纳 |
| 第二章 | BNF、抽象语法、具体语法的区别 |
| 第三章 | 自由变量、α-等价、避免捕获的替换 |
| 第四章 | β-归约、求值策略、小步语义 |
| 第五章 | 类型判断、类型规则、进展性、保持性 |
| 第六章 | 积类型、和类型、递归类型、引用类型的直觉与规则 |
| 第七章 | ∀ 的参数化多态直觉,∃ 的抽象数据类型直觉 |
| 第八章 | 子类型 = 安全替换;函数参数逆变、返回值协变 |
| 第九章 | 约束生成、合一、let 多态 |
| 第十章 | 结构规则、线性 vs 仿射、环境分裂 |
如果你发现自己学到某一章时开始吃力,可以先退回来,检查上一章的“最低核心”是否已经真正掌握。
这时最推荐的补救顺序通常是:
- 先回看该章正文中的最小例子;
- 再查附录A,确认术语有没有混淆;
- 再查附录B,确认记号有没有读错;
- 最后做附录C中该章对应的“基础掌握”和“推导与分析”题。
六、推荐的练习方式:少做“浏览式练习”,多做“闭卷复现”
很多人学形式系统时有一个常见误区:
- 看懂例子
- 觉得自己会了
- 结果一离开页面就写不出来
解决这个问题的最好方法,不是继续“多看几遍”,而是做闭卷复现。
推荐的闭卷练习方法
- 看完一个例子后,先合上页面
- 自己从头复现
- 写不出来再回去看
- 过几小时或第二天再重复一次
特别适合闭卷复现的内容包括:
- 一个替换计算
- 一个 β-归约序列
- 一棵简单的类型推导树
- 一个合一过程
- 一个 let 多态的实例化过程
如果你能闭卷复现这些最小例子,说明你已经从“读懂”进入“掌握”。
而如果你复现失败,也不要只反复看正文。更高效的做法通常是:
- 先回查附录A,确认你到底卡在“术语理解”还是“规则使用”;
- 再回查附录B,确认是不是某个符号读错了;
- 最后回到附录C里找同类型题,再做一次针对性练习。
七、实现练习建议:从最小可运行版本开始
如果你想把本教程真正转化成自己的能力,最值得做的一件事是:
自己实现一个最小版本的解释器 / 类型检查器 / 类型推断器。 为书中的核心语言写一个最小实现。
建议按下面顺序逐步做,而不是一开始就追求“大而全”。
6.1 第一步:Lambda 项表示与自由变量
先实现:
- 变量
- 抽象
- 应用
- 自由变量计算
这一步能帮助你真正理解第三章的语法与作用域。
6.2 第二步:替换与 β-归约
再实现:
- 避免捕获的替换
- 单步 β-归约
- 多步归约
这是第四章最核心的程序化版本。
6.3 第三步:简单类型检查
然后实现:
Bool- 函数类型
if- 变量、抽象、应用的类型规则
这一步会把第五章真正落地。
6.4 第四步:类型推断
如果你已经比较熟悉前面内容,再实现:
- 类型变量
- 约束生成
- 合一
- let 泛化 / 实例化
这是第九章最值得亲手做的部分。
6.5 不建议一开始就做的事
不建议刚入门就直接实现:
- 完整 System F
- 子类型 + 推断 + 引用同时存在的大系统
- 完整 borrow checker
- 高阶 kind 系统
这些当然都很重要,但如果没有前面的最小实现打底,会很容易陷入“代码能跑,概念没稳”或者“概念懂一点,代码完全写不动”的双重挫败感。
八、如何使用原始文献与经典教材?
如果你准备进一步深入,本教程之外最值得持续对照的材料通常有三类:
7.1 经典教材
最重要的仍然是:
- Benjamin C. Pierce,《Types and Programming Languages》
它的价值在于:
- 形式化非常规范
- 主题覆盖面广
- 许多后来文献都默认你熟悉它的表述方式
如果你阅读 TAPL,建议把它当成:
- 形式系统的标准表达参考
- 证明结构的模板来源
- 术语口径的校准工具
7.2 综述型材料
例如:
- Luca Cardelli,《Type Systems》
这类材料的价值在于:
- 适合建立大图景
- 能帮助你看见各类系统之间的关系
- 适合理解“为什么这些主题值得学”
7.3 进阶专题材料
例如:
- ATAPL 中的子结构类型、会话类型、依赖类型等专题
- 各类论文、讲义、课程笔记
这类材料更适合在你已经有一条稳定主线之后再进入。否则很容易“局部很炫,整体很乱”。
九、什么情况下说明你已经学得不错了?
你不需要把所有定理都背下来,才算掌握。
更实际的判断标准是:你是否已经具备下面这些能力。
8.1 形式对象的操作能力
你能自己完成:
- 自由变量计算
- α-重命名
- 替换
- β-归约
- 简单类型推导
- 简单合一过程
8.2 规则系统的解释能力
你能用自然语言解释:
Γ ⊢ t : T是什么意思- 为什么
if的两个分支类型要一致 - 为什么函数参数逆变、返回值协变
- 为什么
let绑定可以多态,而参数通常不行
8.3 主题之间的连接能力
你能把几章内容串起来,例如:
- 第三章的替换为什么会在第五章的保持性证明里再次出现
- 第六章的一阶构造为什么还不够,需要第七章的多态
- 第八章的子类型为什么必须通过 subsumption 进入类型系统
- 第十章为什么说类型不仅描述值的“形状”,还描述值的“使用方式”
如果你已经能做到这些,说明你不是在“背章节”,而是在真正理解这门学科的结构。
十、最后的建议:始终把“最小例子”握在手里
类型系统和编程语言理论最怕的一种学习方式是:
- 一路往后读
- 术语越来越多
- 每章都觉得“大概懂了”
- 但没有一个概念真正落地
避免这种情况的最好方法,是始终保留几个你能熟练手算和手推的最小例子。
例如:
λx.xλf.λx.f x(\lambda x.x) y⊢ λx:Bool.x : Bool → Boollet id = fun x -> x in (id true, id 0)
这些看起来很小,但它们几乎贯穿了整本教程最核心的思想。
如果你能不断回到这些最小例子,并从中重新解释:
- 变量
- 归约
- 类型
- 推断
- 多态
- 子类型
- 资源使用
那么你对这门学科的理解就会越来越稳。
十一、一个可执行的自学路线总结
如果把整份建议压缩成一条可执行路径,可以这样走:
第 1 轮:通读
目标:建立主线,不求细节全掌握
- 顺序读完正文
- 每章至少能说出“这个概念解决什么问题”
第 2 轮:手算与手推
目标:从“看懂”过渡到“会做”
- 手算自由变量、替换、β-归约
- 手推类型规则与简单子类型推导
- 手做合一和 let 多态例子
第 3 轮:实现最小系统
目标:把理论变成程序
- 实现 AST
- 实现替换与求值
- 实现简单类型检查
- 进一步尝试 HM 推断
第 4 轮:按主题回读
目标:建立跨章节连接
- 变量与替换
- 规则与安全性
- 多态与抽象
- 资源与使用纪律
第 5 轮:延伸阅读
目标:进入更高层次材料
- TAPL
- Cardelli 综述
- ATAPL 专题
- 相关论文与课程材料
最后一段话
如果你学到中途感觉吃力,这并不说明你“不适合”这类内容。
编程语言理论和类型系统本来就不是靠“快速浏览”掌握的领域。它更像一种慢慢建立的能力:
- 先能看懂定义
- 再能操作例子
- 然后能重建规则
- 最后能把不同主题连接起来
真正重要的,不是你一天看了多少页,而是你是否逐渐拥有了下面这件能力:
看到一个语言构造时,你会自然地去问:它的语法是什么?它怎么计算?它的类型规则是什么?它为什么安全?
一旦这种提问方式开始变成习惯,你就已经真正进入这门学科了。