Lean 函数式编程

作者:David Thrane Christiansen

版权所有:Microsoft Corporation 2023

Lean-zh 项目组

这是一本免费的书,介绍了如何将 Lean 4 作为编程语言使用。本书中所有代码示例均经过了 Lean 4 版本 4.1.0 验证。

发行历史

2024 年 1 月

这是一个次要的 bug 修复版本,修复了示例程序中一个回退问题。

2023 年 10 月

在首次维护版本中,修复了许多较小的错误,并根据 Lean 的最新版本更新了文本。

2023 年 5 月

本书现已完成!与 4 月份的预发布版本相比,许多小细节得到了改进,并修复了一些小错误。

2023 年 4 月

此版本添加了关于使用策略编写证明的插曲,以及添加了将性能和成本模型的讨论,与停机证明和程序等价性证明相结合的最后一章。这是最终版本前的最后一个版本。

2023 年 3 月

此版本添加了关于使用依值类型和索引族编程的章节。

2023 年 1 月

此版本添加了关于单子变换器的章节,其中包括对 do-记法中可用的命令式特性的描述。

2022 年 12 月

此版本添加了关于应用函子的章节,此外还更加详细地描述了结构和类型类。此外改进了对单子的描述。由于冬季假期,2022 年 12 月版本被推迟到 2023 年 1 月。

2022 年 11 月

此版本添加了关于使用单子编程的章节。此外,强制转换一节中使用 JSON 的示例已更新为包含完整代码。

2022 年 10 月

此版本完成了类型类的章节。此外,在类型类章节之前添加了一个简短的插曲,介绍了命题、证明和策略,因为简单了解一下这些概念有助于理解一些标准库中的类型类。

2022 年 9 月

此版本添加了一个关于类型类的章节的前半部分,这是 Lean 的运算符重载机制,也是组织代码和构建函数库的重要手段。此外,还更新了第二章以适应 Lean 中 Stream 流 API 的变化。

2022 年 8 月

第三次公开发布增加了第二章,其中描述了编译和运行程序以及 Lean 的副作用模型。

2022 年 7 月

第二次公开发布,完成了第一章。

2022 年 6 月

这是第一次公开发布,包括引言和第一章的一部分。

关于作者

David Thrane Christiansen 已使用函数式语言二十年,并使用依值类型十年。他与 Daniel P. Friedman 合著了《The Little Typer》,介绍了依值类型论的关键思想。他拥有哥本哈根 IT 大学的博士学位。在学习期间,他为 Idris 语言的第一个版本做出了重大贡献。离开学术界后,他曾在俄勒冈州波特兰的 Galois 和丹麦哥本哈根的 Deon Digital 担任软件开发人员,并担任 Haskell 基金会执行董事。在撰写本文时,他受雇于 Lean 专注研究组织,全职从事 Lean 的工作。"

授权许可

Creative Commons License
本作品采用知识共享-署名 4.0 国际许可协议授权。

引言

Lean 是 Microsoft Research 开发的交互式定理证明器,基于依值类型论(Dependent Type Theory)。 依值类型论将程序和证明的世界统一了起来,因此,Lean 也是一门编程语言。 Lean 认真地对待其双重性质,并且被设计为适合作为通用编程语言使用,Lean 甚至是用它自己实现的。本书介绍了如何使用 Lean 编程。

作为一门编程语言,Lean 是一种具有依值类型的严格纯函数式语言。 学习使用 Lean 编程很大一部分内容在于学习这些属性是如何影响程序编写方式的, 以及如何像函数式程序员一样思考。

  • 严格性(Strictness) 意味着 Lean 中的函数调用与大多数语言中的工作方式类似: 在函数体开始运行之前,参数会被完全计算。
  • 纯粹性(Purity) 意味着 Lean 程序除非明确声明,否则无法产生副作用, 例如修改内存中的位置、发送电子邮件或删除文件等。 Lean 是一种 函数式(Functional) 语言,这意味着函数就像任何其他值一样是一等值, 并且执行模型受数学表达式的求值启发。
  • 依值类型(Dependent type) 是 Lean 最不寻常的特性,它使类型成为语言的一等部分, 允许类型包含程序,而程序计算类型。

本书面向想要学习 Lean 的程序员,但未必以前使用过函数式编程语言。 读者不需要熟悉 Haskell、OCaml 或 F# 等函数式语言。而另一方面,本书确实假定读者了解循环、 函数和数据结构等大多数编程语言中的常见概念。虽然本书旨在成为一本关于函数式编程的优秀入门书, 但它并不是一本关于一般编程的入门书。

对于将 Lean 作为证明助手的数学家来说,他们可能需要在某个时间点编写自定义的证明自动化工具, 本书也适用于他们。随着这些工具变得越来越复杂,它们也会越来越像函数式语言编写的程序, 但大多数在职数学家接受的是 Python 和 Mathematica 等语言的培训。 本书可以帮助他们弥合这一差距,让更多数学家能够编写可维护且易于理解的证明自动化工具。

本书旨在从头到尾线性阅读。概念一次引入一个,后面的章节会假定读者熟悉前面的章节。 有时,后面的章节会深入探讨一个之前仅简要讨论过的主题。本书的某些章节包含练习。 为了巩固你对该章节的理解,这些练习值得一做。在阅读本书时探索 Lean 也很有用, 可以发现能利用你所学知识的创造性新方法。

获取 Lean

在编写并运行 Lean 所编写的程序之前,你需要在自己的计算机上设置 Lean。Lean 工具包括以下内容:

  • elan:用于管理 Lean 编译器工具链,类似于 rustupghcup
  • lake:用于构建 Lean 包及其依赖项,类似于 cargomake 或 Gradle。
  • lean:用于对 Lean 文件进行类型检查和编译,并向程序员工具提供当前正在编写的文件的相关信息。 通常,lean 是由其他工具而非用户直接调用的。
  • 编辑器插件,如 Visual Studio Code 或 Emacs,可与 Lean 通信并方便地显示其信息。

有关安装 Lean 的最新说明,请参阅 Lean 手册

排版约定

作为 输入 提供给 Lean 的代码示例格式如下:

def add1 (n : Nat) : Nat := n + 1

#eval add1 7

上面最后一行(以 #eval 开头)是指示 Lean 计算答案的命令。Lean 的回复格式如下:

8

Lean 返回的错误消息格式如下:

application type mismatch
  add1 "seven"
argument
  "seven"
has type
  String : Type
but is expected to have type
  Nat : Type

警告格式如下:

declaration uses 'sorry'

Unicode

惯用的 Lean 代码会使用各种不属于 ASCII 的 Unicode 字符。例如,希腊字母(如 αβ) 和箭头()都出会现在本书的第一章中。这使得 Lean 代码更接近于普通的数学记法。

在默认的 Lean 设置中,Visual Studio Code 和 Emacs 都允许使用反斜杠 (\) 后跟名称来输入这些字符。 例如,要输入 α,请键入 \alpha。要了解如何在 Visual Studio Code 中键入字符, 请将鼠标指向该字符并查看工具提示。在 Emacs 中,将光标置于相关字符上,然后使用 C-c C-k 即可查看提示。

鸣谢

这本免费在线书籍的出版得益于微软研究院的慷慨支持,他们出资编写并免费赠送了这本书。 在编写过程中,他们提供了 Lean 开发团队的专业知识来回答我的问题并使 Lean 更易于使用。 特别是,Leonardo de Moura 发起了这个项目并帮助我入门,Chris Lovett 设置了 CI 和部署自动化并作为测试读者提供了很好的反馈,Gabriel Ebner 提供了技术评论, Sarah Smith 使管理方面工作顺利,Vanessa Rodriguez 帮助我诊断了源代码高亮显示库和 iOS 上某些版本的 Safari 之间的棘手交互。

编写这本书占用了正常工作时间以外的很多时间。 我的妻子 Ellie Thrane Christiansen 承担了比平时更多的家庭事务,如果没有她,这本书就不可能存在。 每周多一天工作对我的家人来说并不容易——感谢你们在我写作期间的耐心和支持。

围绕 Lean 的在线社区为该项目提供了热情的支持,包括技术和情感支持。 特别是,Sebastian Ullrich 在我学习 Lean 的元编程系统时提供了关键帮助, 以便编写支持代码,使错误消息的文本既可以在 CI 中进行检查,又可以轻松地包含在书中。 在发布新修订版的几个小时内,兴奋的读者就会发现错误、提供建议并向我表达善意。 我要特别感谢 Arien Malec、Asta Halkjær From、Bulhwi Cha、Craig Stuntz、Daniel Fabian、 Evgenia Karunus、eyelash、Floris van Doorn、František Silváši、Henrik Böving、 Ian Young、Jeremy Salwen、Jireh Loreaux、Kevin Buzzard、Lars Ericson、Liu Yuxi、 Mac Malone、Malcolm Langfield、Mario Carneiro、Newell Jensen、Patrick Massot、 Paul Chisholm、Pietro Monticone、Tomas Puverle、Yaël Dillies、Zhiyuan Bao 和 Zyad Hassan 提出的许多建议,包括风格和技术方面的建议。

了解 Lean

按照惯例,介绍一门编程语言通常会编译并运行一个在控制台上显示「Hello, world!」 的程序。这个简单的程序能确保语言工具安装正确,且程序员能够运行已编译的代码。

然而,自 20 世纪 70 年代以来,编程发生了许多变化。 如今,编译器通常集成到了文本编辑器中,编程环境会在编写程序时提供反馈。 Lean 也是如此:它实现了语言服务器协议(Language Server Protocol,LSP) 的扩展版本,允许它与文本编辑器通信并在用户键入时提供反馈。

从 Python、Haskell 到 JavaScript 等各种语言都提供读取-求值-打印-循环(REPL), 也称为交互式顶层环境或浏览器控制台,可以在其中输入表达式或语句。 然后,该语言会计算并显示用户输入的结果。另一方面,Lean 将这些特性集成到了与编辑器的交互中, 它提供的命令能让文本编辑器将程序的反馈集成到程序文本中。 本章简要介绍了在编辑器中与 Lean 的交互,而 Hello, World! 则描述了如何在批处理模式下以传统的命令行方式使用 Lean。

阅读本书最好的方式是在编辑器中打开 Lean,输入书中的每个示例并运行他们,然后看看会发生什么。

求值表达式

作为学习 Lean 的程序员,最重要的是理解求值的工作原理。求值是求得表达式的值的过程,就像算术那样。 例如,15 - 6 的值为 9,2 × (3 + 1) 的值为 8。要得到后一个表达式的值,首先将 3 + 1 替换为 4, 得到 2 × 4,它本身又可以归约为 8。有时,数学表达式包含变量:在知道 x 的值之前, 无法计算 x + 1 的值。在 Lean 中,程序首先是表达式,思考计算的主要方式是对表达式求值。

大多数编程语言都是 命令式的(Imperative),其中程序由一系列语句组成, 这些语句会按顺序执行以得到程序的结果。程序可以访问可变内存, 因此变量引用的值可以随时间而改变。除了可变状态外,程序还可能产生其他副作用, 例如删除文件、建立传出的网络连接、抛出或捕获异常以及从数据库读取数据等等。 「副作用(Side Effect)」本质上是一个统称,用于描述程序运行过程中可能发生的事情, 这些事情不遵循数学表达式求值的模型。

然而,在 Lean 中,程序的工作方式与数学表达式相同。变量一旦被赋予一个值, 就不能再被重新赋值。求值表达式不会产生副作用。如果两个表达式的值相同, 那么用一个表达式替换另一个表达式并不会导致程序计算出不同的结果。 这并不意味着不能使用 Lean 向控制台写入 Hello, world!,而是执行 I/O 并不是以求值表达式的方式使用 Lean 的核心部分。因此,本章重点介绍如何使用 Lean 交互式地求值表达式,而下一章将介绍如何编写、编译并运行 Hello, world! 程序。

要让 Lean 对一个表达式求值,请在编辑器中的表达式前面加上 #eval, 然后它会返回结果。通常可以将光标或鼠标指针放在 #eval 上查看结果。例如,

#eval 1 + 2

会产生值 3

Lean 遵循一般的算术运算符优先级和结合性规则。也就是说,

#eval 1 + 2 * 5

会产生值 11 而非 15

虽然普通的数学符号和大多数编程语言都使用括号(例如 f(x))将函数应用到其参数上, 但 Lean 只是将参数写在函数后边(例如 f x)。 函数应用是最常见的操作之一,因此保持简洁很重要。与其编写

#eval String.append("Hello, ", "Lean!")

来计算 "Hello, Lean!",不如编写

#eval String.append "Hello, " "Lean!"

其中函数的两个参数只是写在后面用空格隔开。

就像算术运算的顺序需要在表达式中使用括号(如 (1 + 2) * 5)表示一样, 当函数的参数需要通过另一个函数调用来计算时,括号也是必需的。例如,在

#eval String.append "great " (String.append "oak " "tree")

中需要括号,否则第二个 String.append 将被解释为第一个函数的参数,而非一个接受 "oak ""tree" 作为参数的函数。必须先得到内部 String.append 调用的值,然后才能将其追加到 "great " 后面,从而产生最终的值 "great oak tree"

命令式语言通常有两种条件:根据布尔值确定要执行哪些指令的条件 语句(Statement) , 以及根据布尔值确定要计算两个表达式中哪一个的条件 表达式(Expression) 。 例如,在 C 和 C++ 中,条件语句使用 ifelse 编写,而条件表达式使用三元运算符 ?: 编写。 在 Python 中,条件语句以 if 开头,而条件表达式则将 if 放在中间。 由于 Lean 是一种面向表达式的函数式语言,因此没有条件语句,只有条件表达式。 条件表达式使用 ifthenelse 编写。例如,

String.append "it is " (if 1 > 2 then "yes" else "no")

会求值为

String.append "it is " (if false then "yes" else "no")

进而求值为

String.append "it is " "no"

最终求值为 "it is no"

为简洁起见,有时会用箭头表示一系列求值步骤:

String.append "it is " (if 1 > 2 then "yes" else "no")
===>
String.append "it is " (if false then "yes" else "no")
===>
String.append "it is " "no"
===>
"it is no"

可能会遇到的信息

让 Lean 对缺少参数的函数应用进行求值会产生错误信息。举例来说,

#eval String.append "it is "

会产生一个很长的错误信息:

expression
  String.append "it is "
has type
  String → String
but instance
  Lean.MetaEval (String → String)
failed to be synthesized, this instance instructs Lean on how to display the resulting value, recall that any type implementing the `Repr` class also implements the `Lean.MetaEval` class
表达式
  String.append "it is "
类型为
  String → String
但实例
  Lean.MetaEval (String → String)
合成失败,此实例指示 Lean 如何显示结果值,回想一下任何实现了
`Repr` 类的类型也实现了 `Lean.MetaEval` 类。

会出现此信息是因为在 Lean 中,仅接受了部分参数的函数会返回一个等待其余参数的新函数。 Lean 无法向用户显示函数,因此在被要求这样做时会返回错误。

练习

以下表达式的值是什么?请手动计算,然后输入 Lean 来检查你的答案。

  • 42 + 19
  • String.append "A" (String.append "B" "C")
  • String.append (String.append "A" "B") "C"
  • if 3 == 3 then 5 else 7
  • if 3 == 4 then "equal" else "not equal"

类型

类型根据程序可以计算的值对程序进行分类。类型在程序中扮演着多种角色:

  1. 让编译器对值在内存中的表示做出决策。

  2. 帮助程序员向他人传达他们的意图,作为函数输入和输出的轻量级规范,编译器可以确保程序遵守该规范。

  3. 防止各种潜在错误,例如将数字加到字符串上,从而减少程序所需的测试数量。

  4. 帮助 Lean 编译器自动生成辅助代码,可以节省样板代码。

Lean 的类型系统具有非同寻常的表现力。类型可以编码强规范, 如「此排序函数返回其输入的顺序排列」,以及灵活的规范, 如「此函数具有不同的返回类型,具体取决于其参数的值」。 类型系统甚至可以用作证明数学定理的完整逻辑系统。 然而,这种顶尖的表现力并不能消除对更简单类型的需求, 理解这些更简单的类型是使用更高级的功能的先决条件。

Lean 中的每个程序都必须有一个类型。特别是,每个表达式在求值之前都必须具有类型。 在迄今为止的示例中,Lean 已经能够自行推导出类型,但有时也需要提供一个类型。 这是使用冒号运算符完成的:

#eval (1 + 2 : Nat)

在这里,Nat自然数 的类型,它们是任意精度的无符号整数。在 Lean 中,Nat 是非负整数字面量的默认类型。此默认类型并不总是最佳选择。 在 C 中,当减法运算结果小于零时,无符号整数会下溢到最大的可表示数字。然而,Nat 可以表示任意大的无符号数字,因此没有最大的数字可以下溢。 因此,当答案原本为负数时,Nat 上的减法运算会返回 0。例如,

#eval 1 - 2

会求值为 0 而非 -1。 若要使用可以表示负整数的类型,请直接提供它:

#eval (1 - 2 : Int)

使用此类型后,结果为 -1,符合预期。

若要检查表达式的类型而不求值,请使用 #check 而非 #eval。例如:

#check (1 - 2 : Int)

会报告 1 - 2 : Int 而不会实际执行减法运算。

当无法为程序指定类型时,#check#eval 都会返回错误。例如:

#check String.append "hello" [" ", "world"]

会输出

application type mismatch
  String.append "hello" [" ", "world"]
argument
  [" ", "world"]
has type
  List String : Type
but is expected to have type
  String : Type
应用程序类型不匹配"
  String.append "hello" [" ", "world"]
参数
  [" ", "world"]
类型为
  List String : Type
但预期类型为
  String : Type

因为 String.append 的第二个参数需要是字符串,但提供的是字符串列表。

函数与定义

在 Lean 中,需使用 def 关键字引入定义。例如,若要定义名称 hello 来引用字符串 "Hello",请编写:

def hello := "Hello"

在 Lean 中,使用冒号加等号运算符 := 而非 = 来定义新名称。这是因为 = 用于描述现有表达式之间的相等性,而使用两个不同的运算符有助于避免混淆。

hello 的定义中,表达式 "Hello" 足够简单,Lean 能够自动确定定义的类型。然而,大多数定义并不那么简单,因此通常需要添加类型。 这可以通过在要定义的名称后使用冒号来完成。

def lean : String := "Lean"

定义了名称后,就可以使用它们了,因此

#eval String.append hello (String.append " " lean)

会输出

"Hello Lean"

在 Lean 中,定义的名称只能在其定义之后使用。

在很多语言中,函数定义的语法与其他值的不同。例如,Python 函数定义以 def 关键字开头, 而其他定义则以等号定义。在 Lean 中,函数使用与其他值相同的 def 关键字定义。 尽管如此,像 hello 这类的定义引入的名字会 直接 引用其值,而非每次调用一个零参函数返回等价的值。

定义函数

在 Lean 中有多种方法可以定义函数,最简单的就是在定义的类型之前写上函数的参数,并用空格分隔。 例如,可以编写一个将其参数加 1 的函数:

def add1 (n : Nat) : Nat := n + 1

测试此函数时,#eval 给出了 8,符合预期:

#eval add1 7

就像将函数应用于多个参数会用空格分隔一样,接受多个参数的函数定义也是在参数名与类型之间加上空格。 函数 maximum 的结果等于其两个参数中最大的一个,它接受两个 Nat 参数 nk,并返回一个 Nat

def maximum (n : Nat) (k : Nat) : Nat :=
  if n < k then
    k
  else n

当向 maximum 这样的已定义函数提供参数时,其结果会首先用提供的值替换函数体中对应的参数名称, 然后对产生的函数体求值。例如:

maximum (5 + 8) (2 * 7)
===>
maximum 13 14
===>
if 13 < 14 then 14 else 13
===>
14

求值结果为自然数、整数和字符串的表达式具有表示它们的类型(分别为 NatIntString)。 函数也是如此,接受一个 Nat 并返回一个 Bool 的函数的类型为 Nat → Bool,接受两个 Nat 并返回一个 Nat 的函数的类型为 Nat → Nat → Nat

作为一个特例,当函数的名称直接与 #check 一起使用时,Lean 会返回函数的签名。 输入 #check add1 会产生 add1 (n : Nat) : Nat。 但是,可以通过用括号括住函数名称来「欺骗」Lean 显示函数的类型, 这会导致函数被视为一个普通表达式,所以 #check (add1) 会产生 add1 : Nat → Nat#check (maximum) 会产生 maximum : Nat → Nat → Nat。 此箭头也可以写作 ASCII 的箭头 ->,因此前面的函数类型可以分别写作 Nat -> NatNat -> Nat -> Nat

在幕后,所有函数实际上都刚好接受一个参数。像 maximum 这样的函数看起来需要多个参数, 但实际上它们会接受一个参数并返回一个新的函数,新函数接受下一个参数, 直到不再需要更多参数为止。可以通过向一个多参数函数提供一个参数来看到这一点: #check maximum 3 会产生 maximum 3 : Nat → Nat, 而 #check String.append "Hello " 会产生 String.append "Hello " : String → String。 使用返回函数的函数来实现多参数函数被称为" 柯里化(Currying) , 以数学家哈斯克尔·柯里(Haskell Curry)命名。 函数箭头是右结合的,这意味着 Nat → Nat → Nat 等价于 Nat → (Nat → Nat)

练习

  • 定义函数 joinStringsWith,类型为 String -> String -> String -> String, 它通过将第一个参数放在第二个和第三个参数之间来创建一个新字符串。 joinStringsWith ", " "one" "and another" 应当会求值为 "one, and another"
  • joinStringsWith ": " 的类型是什么?用 Lean 检查你的答案。
  • 定义一个函数 volume,类型为 Nat → Nat → Nat → Nat, 它计算给定高度、宽度和深度的矩形棱柱的体积。

定义类型

大多数类型化编程语言都有一些方法来定义类型的别名,例如 C 语言的 typedef。 然而,在 Lean 中,类型是语言的一等部分——它们与其他表达式一样都是表达式, 这意味着定义可以引用类型,就像它们可以引用其他值一样。

例如,如果 String 输入起来太长,可以定义一个简写 Str

def Str : Type := String

然后就可以使用 Str 而非 String 作为定义的类型:

def aStr : Str := "This is a string."

之所以能这样做,是因为类型遵循与 Lean 其他部分相同的规则。 类型是表达式,而在表达式中,已定义的名称可以用其定义替换。由于 Str 已被定义为 String,因此 aStr 的定义是有意义的。

你可能会遇到的信息

由于 Lean 支持重载整数字面量,因此使用定义作为类型进行实验会变得更加复杂。 如果 Nat 太短,可以定义一个较长的名称 NaturalNumber

def NaturalNumber : Type := Nat

然而,使用 NaturalNumber 作为定义的类型而非 Nat 并没有达到预期的效果。特别是,定义:

def thirtyEight : NaturalNumber := 38

会导致以下错误:

failed to synthesize instance
  OfNat NaturalNumber 38

产生该错误的原因是 Lean 允许数字字面量被 重载(Overload) 。 当有意义时,自然数字面量可用作新类型,就像这些类型内置在系统中一样。 这能让 Lean 方便地表示数学,而数学的不同分支会将数字符号用作完全不同的目的。 这种允许重载的特性,并不会在找到重载之前用其定义替换所有已定义的名称, 这正是导致出现以上错误消息的原因。

解决此限制的一种方法是在定义的右侧提供类型 Nat,从而让 Nat 的重载规则用于 38

def thirtyEight : NaturalNumber := (38 : Nat)

该定义的类型仍然正确,因为根据定义,NaturalNumberNat 是同一种类型!

另一种解决方案是为 NaturalNumber 定义一个重载,其作用等同于 Nat 的重载。 然而,这需要 Lean 的更多高级特性。

最后,使用 abbrev 而非 def 来为 Nat 定义新名称, 能够让重载解析以其定义来替换所定义的名称。使用 abbrev 编写的定义总是会展开。例如,

abbrev N : Type := Nat

def thirtyNine : N := 39

都会被接受而不会出现问题。

在幕后,一些定义会在重载解析期间被内部标记为可展开的,而另一些则不会标记。 可展开的定义称为 可约的(Reducible) 。控制可约性对 Lean 的灵活性而言至关重要: 完全展开所有的定义可能会产生非常大的类型,这对于机器处理和用户理解来说都很困难。 使用 abbrev 生成的定义会被标记为可约定义。

结构体

编写程序的第一步通常是找出问题域中的概念,然后用合适的代码表示它们。 有时,一个域概念是其他更简单概念的集合。此时,将这些更简单的组件分组到一个「包」中会很方便, 然后可以给它取一个有意义的名称。在 Lean 中,这是使用 结构体(Structure) 完成的, 它类似于 C 或 Rust 中的 struct 和 C# 中的 record

定义一个结构体会向 Lean 引入一个全新的类型,该类型不能化简为任何其他类型。 这很有用,因为多个结构体可能表示不同的概念,但它们包含相同的数据。 例如,一个点可以用笛卡尔坐标或极坐标表示,每个都是一对浮点数。 分别定义不同的结构体可以防止 API 的用户将一个与另一个混淆。

Lean 的浮点数类型称为 Float,浮点数采用通常的表示法。

#check 1.2
1.2 : Float
#check -454.2123215
-454.2123215 : Float
#check 0.0
0.0 : Float

当浮点数使用小数点书写时,Lean 会推断其类型为 Float。 如果不使用小数点书写,则可能需要类型标注。

#check 0
0 : Nat
#check (0 : Float)
0 : Float

笛卡尔点是一个结构体,它有两个 Float 字段,称为 xy。 它使用 structure 关键字声明。

structure Point where
  x : Float
  y : Float
deriving Repr

声明之后,Point 就是一个新的结构体类型了。最后一行写着 deriving Repr, 它要求 Lean 生成代码以显示类型为 Point 的值。此代码用于 #eval 显示求值结果以供程序员使用,类似于 Python 中的 repr 函数。 编译器生成的显示代码也可以被覆盖。

创建结构体类型值通常的方法是在大括号内为其所有字段提供值。 笛卡尔平面的原点是 xy 均为零的点:

def origin : Point := { x := 0.0, y := 0.0 }

如果 Point 定义中的 deriving Repr 行被省略,则尝试 #eval origin 会产生类似于省略函数参数时产生的错误:

expression
  origin
has type
  Point
but instance
  Lean.MetaEval Point
failed to be synthesized, this instance instructs Lean on how to display the resulting value, recall that any type implementing the `Repr` class also implements the `Lean.MetaEval` class

该消息表明求值机制不知道如何将求值结果传达给用户。

幸运的是,使用 deriving Repr#eval origin 的结果看起来非常像 origin 的定义。

{ x := 0.000000, y := 0.000000 }

由于结构体是用来「打包」一组数据,并将其命名后作为单个单元进行处理的, 因此能够提取结构体的各个字段也很重要。这可以使用点记法,就像在 C、Python 或 Rust 中一样。

#eval origin.x
0.000000
#eval origin.y
0.000000

可以定义以结构体为参数的函数。例如,点的加法可通过底层坐标值相加来执行。 #eval addPoints { x := 1.5, y := 32 } { x := -8, y := 0.2 } 会产生

{ x := -6.500000, y := 32.200000 }

该函数本身以两个 Points 作为参数,分别为 p1p2。 结果点基于 p1p2xy 字段:

def addPoints (p1 : Point) (p2 : Point) : Point :=
  { x := p1.x + p2.x, y := p1.y + p2.y }

类似地,两点之间的距离(即其 xy 分量之差的平方和的平方根)可以写成:

def distance (p1 : Point) (p2 : Point) : Float :=
  Float.sqrt (((p2.x - p1.x) ^ 2.0) + ((p2.y - p1.y) ^ 2.0))

例如,(1, 2) 和 (5, -1) 之间的距离为 5:

#eval distance { x := 1.0, y := 2.0 } { x := 5.0, y := -1.0 }
5.000000

不同结构体可能具有同名的字段。例如,三维点数据类型可能共享字段 xy, 并使用相同的字段名实例化:

structure Point3D where
  x : Float
  y : Float
  z : Float
deriving Repr

def origin3D : Point3D := { x := 0.0, y := 0.0, z := 0.0 }

这意味着必须知道结构体的预期类型才能使用大括号语法。 如果类型未知,Lean 将无法实例化结构体。例如,

#check { x := 0.0, y := 0.0 }

会导致错误

invalid {...} notation, expected type is not known

通常,可以通过提供类型标注来补救这种情况。

#check ({ x := 0.0, y := 0.0 } : Point)
{ x := 0.0, y := 0.0 } : Point

为了使程序更加简洁,Lean 还允许在大括号内标注结构体类型。

#check { x := 0.0, y := 0.0 : Point}
{ x := 0.0, y := 0.0 } : Point

更新结构体

设想一个函数 zeroX,它将 Pointx 字段置为 0.0。 在大多数编程语言社区中,这句话意味着指向 x 的内存位置将被新值覆盖。 但是,Lean 没有可变状态。在函数式编程社区中,这种说法几乎总是意味着分配一个新的 Point, 其 x 字段指向新值,而其他字段指向输入中的原始值。 编写 zeroX 的一种方法是逐字遵循此描述,填写 x 的新值并手动传入 y

def zeroX (p : Point) : Point :=
  { x := 0, y := p.y }

然而,这种编程风格也存在一些缺点。首先,如果向结构体中添加了一个新字段, 那么所有更新任何字段的代码都需要更新,这会导致维护困难。 其次,如果结构体中包含多个具有相同类型的字段,那么存在真正的风险, 即复制粘贴代码会导致字段内容被复制或交换。最后,程序会变得冗长且呆板。

Lean 提供了一种便捷的语法,用于替换结构体中的一些字段,同时保留其他字段。 这是通过在结构体初始化中使用 with 关键字来完成的。未更改字段的源代码写在 with 之前, 而新字段写在 with 之后。例如,zeroX 可以仅使用新的 x 值编写:

def zeroX (p : Point) : Point :=
  { p with x := 0 }

请记住,此结构体更新语法不会修改现有值,它会创建一个与旧值共享某些字段的新值。 例如,给定点 fourAndThree

def fourAndThree : Point :=
  { x := 4.3, y := 3.4 }

对其进行求值,然后使用 zeroX 对其进行更新,然后再次对其进行求值,将产生原始值:

#eval fourAndThree
{ x := 4.300000, y := 3.400000 }
#eval zeroX fourAndThree
{ x := 0.000000, y := 3.400000 }
#eval fourAndThree
{ x := 4.300000, y := 3.400000 }

结构体更新不会修改原始结构体,这样更容易推理新值是从旧值计算得出的。 对旧结构体的所有引用会在所有提供的新值中继续引用相同的字段值。

幕后

每个结构体都有一个 构造子(Constructor) 。「Constructor」一词在英文中可能会引起混淆。 与 Java 或 Python 等语言中的构造函数不同,Lean 中的构造子不是在初始化数据类型时运行的任意代码。 相反,构造子只会收集要存储在新分配的数据结构中的数据。 不可能提供一个预处理数据或拒绝无效参数的自定义构造子。 这实际上是「Constructor」一词在两种情况下具有不同但相关的含义的情况。

默认情况下,名为 S 的结构体的构造子命名为 S.mk。其中,S 是命名空间限定符, mk 是构造子本身的名称。除了使用大括号初始化语法外,还可以直接应用构造子。

#check Point.mk 1.5 2.8

然而,这通常不被认为是良好的 Lean 风格,Lean 甚至会使用标准结构体初始化语法返回其结果。

{ x := 1.5, y := 2.8 } : Point

构造子具有函数类型,这意味着它们可以在需要函数的任何地方使用。 例如,Point.mk 是一个接受两个 Float(分别是 xy),并返回一个新 Point 的函数。

#check (Point.mk)
Point.mk : Float → Float → Point

要覆盖结构体的构造子名称,请在开头写出新的名称后跟两个冒号。 例如,要使用 Point.point 而非 Point.mk,请编写:

structure Point where
  point ::
  x : Float
  y : Float
deriving Repr

除了构造子,结构体的每个字段还定义了一个访问器函数。 它们在结构体的命名空间中与字段具有相同的名称。对于 Point, 会生成访问器函数 Point.xPoint.y

#check (Point.x)
Point.x : Point → Float
#check (Point.y)
Point.y : Point → Float

实际上,就像大括号结构体构造语法会在幕后转换为对结构体构造函数的调用一样, addPoints 中先前定义中的语法 p1.x 会被转换为对 Point.x 访问器的调用。 也就是说,#eval origin.x#eval Point.x origin 都会产生

0.000000

访问器的点记法不仅可以与结构字段一起使用。它还可以用于接受任意数量参数的函数。 更一般地说,访问器记法具有以下形式:TARGET.f ARG1 ARG2 ...。如果 TARGET 的类型为 T,则调用名为 T.f 的函数。 TARGET 是其类型为 T 的最左边的参数,它通常但并非总是第一个参数,并且 ARG1 ARG2 ... 按顺序作为其余参数提供。例如,即使 String 不是具有 append 字段的结构体,也可以使用访问器记法从字符串中调用 String.append

#eval "one string".append " and another"
"one string and another"

在该示例中,TARGET 表示 "one string"ARG1 表示 " and another"

Point.modifyBoth 函数(即在 Point 命名空间中定义的 modifyBoth) 将一个函数应用于 Point 中的两个字段:

def Point.modifyBoth (f : Float → Float) (p : Point) : Point :=
  { x := f p.x, y := f p.y }

即使 Point 参数位于函数参数之后,也可以使用点记法:

#eval fourAndThree.modifyBoth Float.floor
{ x := 4.000000, y := 3.000000 }

在这种情况下,TARGET 表示 fourAndThree,而 ARG1Float.floor。 这是因为访问器记法的目标用作第一个类型匹配的参数,而不一定是第一个参数。

练习

  • 定义一个名为 RectangularPrism 的结构体,其中包含一个矩形棱柱的高度、宽度和深度,每个都是 Float
  • 定义一个名为 volume : RectangularPrism → Float 的函数,用于计算矩形棱柱的体积。
  • 定义一个名为 Segment 的结构,它通过其端点表示线段,并定义一个函数 length : Segment → Float,用于计算线段的长度。Segment 最多应有两个字段。
  • RectangularPrism 的声明引入了哪些名称?
  • 以下 HamsterBook 的声明引入了哪些名称?它们的类型是什么?
structure Hamster where
  name : String
  fluffy : Bool
structure Book where
  makeBook ::
  title : String
  author : String
  price : Float

数据类型与模式匹配

结构体使多个独立的数据块可以组合成一个连贯的整体,该整体由一个全新的类型表示。 将一组值组合在一起的类型(如结构体)称为 积类型(Product Type) 。 然而,许多领域的概念不能自然地表示为结构体。例如,应用程序可能需要跟踪用户权限, 其中一些用户是文档所有者,一些用户可以编辑文档,而另一些用户只能阅读文档。 计算器具有许多二元运算符,例如加法、减法和乘法。结构体无法提供一种简单的方法来编码多项选择。

同样,尽管结构体是跟踪固定字段集的绝佳方式,但许多应用程序需要可能包含任意数量元素的数据。 大多数经典的数据结构(例如树和列表)具有递归结构,其中列表的尾部本身是一个列表, 或者二叉树的左右分支本身是二叉树。在上述计算器中,表达式本身的结构体是递归的。 例如,加法表达式中的加数本身可能是乘法表达式。

允许选择的类型称为 和类型(Sum Type) ,而可以包含自身实例的类型称为 递归类型(Recursive Datatype) 。递归和类型称为 归纳类型(Inductive Datatype) , 因为可以用数学归纳法来证明有关它们的陈述。在编程时,归纳类型通过模式匹配和递归函数来消耗。

许多内置类型实际上是标准库中的归纳类型。例如,Bool 就是一个归纳类型:

inductive Bool where
  | false : Bool
  | true : Bool

此定义有两个主要部分。第一行提供了新类型(Bool)的名称,而其余各行分别描述了一个构造子。 与结构体的构造子一样,归纳类型的构造子只是其他数据的接收器和容器, 而不是插入任意初始化代码和验证代码的地方。与结构体不同,归纳类型可以有多个构造子。 这里有两个构造子,truefalse,并且都不接受任何参数。 就像结构体声明将其名称放在与类型同名的命名空间中一样,归纳类型也将构造子的名称放在命名空间中。 在 Lean 标准库中,truefalse 从此命名空间中重新导出,以便可以单独编写它们, 而不是分别写作 Bool.trueBool.false

从数据建模的角度来看,对于归纳数据类型的使用场景, 在许多与其他语言中会使用 密封抽象类(Sealed Abstract Class)。 在 C# 或 Java 等语言中,人们可能会编写类似这样的 Bool 定义:

abstract class Bool {}
class True : Bool {}
class False : Bool {}

然而,这些表示的具体方式有很大不同。特别是,每个非抽象类都会创建一种新类型和分配数据的新方式。 在面向对象的示例中,TrueFalse 都是比 Bool 更具体的类型,而 Lean 定义仅引入了新类型 Bool

非负整数的类型 Nat 是一个归纳数据类型:

inductive Nat where
  | zero : Nat
  | succ (n : Nat) : Nat

在这里,zero 表示 0,而 succ 表示其他数字的后继。succ 声明中提到的 Nat 正是我们正在定义的类型 Nat后继(Successor) 表示「比...大一」,因此 5 的后继是 6, 32,185 的后继是 32,186。使用此定义,4 会表示为 Nat.succ (Nat.succ (Nat.succ (Nat.succ Nat.zero)))。 这个定义与 Bool 的定义非常类似,只是名称略有不同。唯一真正的区别是 succ 后面跟着 (n : Nat),它指定构造子 succ 接受类型为 Nat 的参数,该参数恰好命名为 n。 名称 zerosucc 位于以其类型命名的命名空间中,因此它们分别称为 Nat.zeroNat.succ

参数名称(如 n)可能出现在 Lean 的错误消息以及编写数学证明时提供的反馈中。 Lean 还具有按名称提供参数的可选语法。然而,通常情况下, 参数名的选择不如结构体字段名的选择重要,因为它不构成 API 的主要部分。

在 C# 或 Java 中,Nat 的定义如下:

abstract class Nat {}
class Zero : Nat {}
class Succ : Nat {
  public Nat n;
  public Succ(Nat pred) {
    n = pred;
  }
}

与上面 Bool 的示例类似,这样会定义比 Lean 中等价的项更多的类型。 此外,该示例突出显示了 Lean 数据类型构造子更像是抽象类的子类,而不是像 C# 或 Java 中的构造函数,因为此处显示的构造函数包含要执行的初始化代码。

和类型也类似于使用字符串标签来对 TypeScript 中的无交并进行编码。 在 TypeScript 中,Nat 可以定义如下:

interface Zero {
    tag: "zero";
}

interface Succ {
    tag: "succ";
    predecessor: Nat;
}

type Nat = Zero | Succ;

与 C# 和 Java 一样,这种编码最终会产生比 Lean 中更多的类型,因为 ZeroSucc 都是它们自己的类型。它还说明了 Lean 构造子对应于 JavaScript 或 TypeScript 中的对象, 这些对象包含一个标识内容的标记。

模式匹配

在很多语言中,这类数据首先会使用 instance-of 运算符来检查接收了哪个子类, 然后读取给定子类中可用的字段值。instance-of 会检查确定要运行哪个代码, 以确保此代码所需的数据可用,而数据由字段本身提供。 在 Lean 中,这两个目的均由 模式匹配(Pattern Matching) 实现。

使用模式匹配的函数示例是 isZero,这是一个当其参数为 Nat.zero 时返回 true 的函数,否则返回 false

def isZero (n : Nat) : Bool :=
  match n with
  | Nat.zero => true
  | Nat.succ k => false

match 表达式为函数参数 n 提供了解构。若 nNat.zero 构建, 则采用模式匹配的第一分支,结果为 true。若 nNat.succ 构建, 则采用第二分支,结果为 false

isZero Nat.zero 的逐步求值过程如下:

isZero Nat.zero
===>
match Nat.zero with
| Nat.zero => true
| Nat.succ k => false
===>
true

isZero 5 的求值过程类似:

isZero 5
===>
isZero (Nat.succ (Nat.succ (Nat.succ (Nat.succ (Nat.succ Nat.zero)))))
===>
match Nat.succ (Nat.succ (Nat.succ (Nat.succ (Nat.succ Nat.zero)))) with
| Nat.zero => true
| Nat.succ k => false
===>
false

isZero 中模式的第二分支中的 k 并非装饰性符号。它使 succ 的参数 Nat 可见,并提供了名称。然后可以使用该较小的数字计算表达式的最终结果。

正如某个数字 \( n \) 的后继比 \( n \) 大 1(即 \( n + 1\)), 某个数字的前驱比它小 1。如果 pred 是一个查找 Nat 前驱的函数, 那么以下示例应该能得到预期的结果:

#eval pred 5
4
#eval pred 839
838

由于 Nat 无法表示负数,因此 0 有点令人费解。在使用 Nat 时, 会产生负数的运算符通常会被重新定义为产生 0 本身:

#eval pred 0
0

要查找 Nat 的前驱,第一步是检查它是使用哪个构造子创建的。如果是 Nat.zero,则结果为 Nat.zero。 如果是 Nat.succ,则使用名称 k 引用其下的 Nat。而这个 Nat 即是所需的前驱,因此 Nat.succ 分支的结果是 k

def pred (n : Nat) : Nat :=
  match n with
  | Nat.zero => Nat.zero
  | Nat.succ k => k

将此函数应用于 5 会产生以下步骤:

pred 5
===>
pred (Nat.succ 4)
===>
match Nat.succ 4 with
| Nat.zero => Nat.zero
| Nat.succ k => k
===>
4

模式匹配不仅可以用于和类型,还可用于结构体。 例如,一个从 Point3D 中提取第三维度的函数可以写成如下:

def depth (p : Point3D) : Float :=
  match p with
  | { x:= h, y := w, z := d } => d

在这种情况下,直接使用 z 访问器会简单得多,但结构体模式有时是编写函数的最简单方法。

递归函数

引用正在定义的名称的定义称为 递归定义(Recursive Definition) 。 归纳数据类型允许是递归的,事实上,Nat 就是这样的数据类型的一个例子, 因为 succ 需要另一个 Nat。递归数据类型可以表示任意大的数据,仅受可用内存等技术因素限制。 就像不可能在数据类型定义中为每个自然数编写一个构造器一样,也不可能为每个可能性编写一个模式匹配用例。

递归数据类型与递归函数可以很好地互补。一个简单的 Nat 递归函数是检查其参数是否是偶数。 在这种情况下,zero 是偶数。像这样的代码的非递归分支称为 基本情况(Base Case) 。 奇数的后继是偶数,偶数的后继是奇数。这意味着使用 succ 构建的数字当且仅当其参数不是偶数时才是偶数。

def even (n : Nat) : Bool :=
  match n with
  | Nat.zero => true
  | Nat.succ k => not (even k)

这种思维模式对于在 Nat 上编写递归函数是非常典型的。首先,确定对 zero 做什么。 然后,确定如何将任意 Nat 的结果转换为其后继的结果,并将此转换应用于递归调用的结果。 此模式称为 结构化递归(Structural Recursion)

不同于许多语言,Lean 默认确保每个递归函数最终都会到达基本情况。 从编程角度来看,这排除了意外的无限循环。但此特性在证明定理时尤为重要, 而无限循环会产生重大的困难。由此产生的一个结果是, Lean 不会接受尝试对原始数字递归调用自身的 even 版本:

def evenLoops (n : Nat) : Bool :=
  match n with
  | Nat.zero => true
  | Nat.succ k => not (evenLoops n)

错误消息的主要部分是 Lean 无法确定递归函数是否最终会到达基本情况(因为它不会)。

fail to show termination for
  evenLoops
with errors
structural recursion cannot be used

well-founded recursion cannot be used, 'evenLoops' does not take any (non-fixed) arguments

尽管加法需要两个参数,但只需要检查其中一个参数。要将零加到数字 \( n \) 上, 只需返回 \( n \)。要将 \( k \) 的后继加到 \( n \) 上,则需要得到将 \( k \) 加到 \( n \) 的结果的后继。

def plus (n : Nat) (k : Nat) : Nat :=
  match k with
  | Nat.zero => n
  | Nat.succ k' => Nat.succ (plus n k')

plus 的定义中,选择名称 k' 表示它与参数 k 相关联,但并不相同。 例如,展开 plus 3 2 的求值过程会产生以下步骤:

plus 3 2
===>
plus 3 (Nat.succ (Nat.succ Nat.zero))
===>
match Nat.succ (Nat.succ Nat.zero) with
| Nat.zero => 3
| Nat.succ k' => Nat.succ (plus 3 k')
===>
Nat.succ (plus 3 (Nat.succ Nat.zero))
===>
Nat.succ (match Nat.succ Nat.zero with
| Nat.zero => 3
| Nat.succ k' => Nat.succ (plus 3 k'))
===>
Nat.succ (Nat.succ (plus 3 Nat.zero))
===>
Nat.succ (Nat.succ (match Nat.zero with
| Nat.zero => 3
| Nat.succ k' => Nat.succ (plus 3 k')))
===>
Nat.succ (Nat.succ 3)
===>
5

考虑加法的一种方法是 \( n + k \) 将 Nat.succ 应用于 \( n \) \( k \) 次。 类似地,乘法 \( n × k \) 将 \( n \) 加到自身 \( k \) 次,而减法 \( n - k \) 将 \( n \) 的前驱求得 \( k \) 次。

def times (n : Nat) (k : Nat) : Nat :=
  match k with
  | Nat.zero => Nat.zero
  | Nat.succ k' => plus n (times n k')

def minus (n : Nat) (k : Nat) : Nat :=
  match k with
  | Nat.zero => n
  | Nat.succ k' => pred (minus n k')

并非每个函数都可以轻松地使用结构化递归来编写。将加法理解为迭代的 Nat.succ, 将乘法理解为迭代的加法,将减法理解为迭代的前驱,这表明除法可以实现为迭代的减法。 在这种情况下,如果分子小于分母,则结果为零。否则,结果是将分子减去分母除以再除以分母所得的后继。

def div (n : Nat) (k : Nat) : Nat :=
  if n < k then
    0
  else Nat.succ (div (n - k) k)

只要第二个参数不为 0,这个程序就会终止,因为它始终朝着基本情况前进。然而,它不是结构化递归, 因为它不遵循「为零找到一个结果,然后将较小的 Nat 的结果转换为其后继的结果」的模式。 特别是,该函数的递归调用,应用于另一个函数调用的结果,而非输入构造子的参数。 因此,Lean 会拒绝它,并显示以下消息:

fail to show termination for
  div
with errors
argument #1 was not used for structural recursion
  failed to eliminate recursive application
    div (n - k) k

argument #2 was not used for structural recursion
  failed to eliminate recursive application
    div (n - k) k

structural recursion cannot be used

failed to prove termination, use `termination_by` to specify a well-founded relation

此消息表示 div 需要手动证明停机。这个主题在 最后一章 中进行了探讨。

多态

和大多数语言一样,Lean 中的类型可以接受参数。例如,类型 List Nat 描述自然数列表, List String 描述字符串列表,List (List Point) 描述点列表的列表。这与 C# 或 Java 中的 List<Nat>List<String>List<List<Point>> 非常相似。就像 Lean 使用空格将参数传递给函数一样,它也使用空格将参数传递给类型。

在函数式编程中,术语 多态(Polymorphism) 通常指将类型作为参数的数据类型和定义。 这不同于面向对象编程社区,其中该术语通常指可以覆盖其超类某些行为的子类。 在这本书中,「多态」总是指这个词的第一个含义。这些类型参数可以在数据类型或定义中使用, 通过将数据类型和定义的类型参数替换为其他类型,可以产生新的不同类型。

Point 结构体要求 xy 字段都是 Float。然而,对于点来说, 并没有每个坐标都需要特定表示形式的要求。Point 的多态版本称为 PPoint, 它可以将类型作为参数,然后将该类型用于两个字段:

structure PPoint (α : Type) where
  x : α
  y : α
deriving Repr

就像函数定义的参数紧跟在被定义的名称之后一样,结构体的参数紧跟在结构体的名称之后。 在 Lean 中,当没有更具体的名称时,通常使用希腊字母来命名类型参数。 Type 是描述其他类型的类型,因此 NatList StringPPoint Int 都具有 Type 类型。

List 一样,PPoint 可以通过提供特定类型作为其参数来使用:

def natOrigin : PPoint Nat :=
  { x := Nat.zero, y := Nat.zero }

在此示例中,期望的两个字段都是 Nat。就和通过用其参数值替换其参数变量来调用函数一样, 向 PPoint 传入类型参数 Nat 会产生一个结构体,其中字段 xy 具有类型 Nat, 因为参数名称 α 已被参数类型 Nat 替换。类型是 Lean 中的普通表达式, 因此向多态类型(如 PPoint)传递参数不需要任何特殊语法。

定义也可以将类型作为参数,这使得它们具有多态性。函数 replaceX 用新值替换 PPointx 字段。为了能够让 replaceX任何 多态的点一起使用,它本身必须是多态的。 这是通过让其第一个参数成为点字段的类型,后面的参数引用第一个参数的名称来实现的。

def replaceX (α : Type) (point : PPoint α) (newX : α) : PPoint α :=
  { point with x := newX }

换句话说,当参数 pointnewX 的类型提到 α 时,它们指的是 作为第一个参数提供的任何类型 。这类似于函数参数名称引用函数体中提供的值的方式。

可以通过让 Lean 检查 replaceX 的类型,然后让它检查 replaceX Nat 的类型来看到这一点。

#check (replaceX)
replaceX : (α : Type) → PPoint α → α → PPoint α

此函数类型包括第一个参数的 名称 ,类型中的后续参数会引用此名称。 就像函数应用的值,是通过在函数体中,用所提供的参数值替换参数名称来得到的那样, 函数应用的类型,也是通过在函数的返回类型中,用所提供的参数值替换参数的名称来得到的。 提供第一个参数 Nat,会导致类型其余部分中所有的 α 都替换为 Nat

#check replaceX Nat
replaceX Nat : PPoint Nat → Nat → PPoint Nat

由于剩余的参数没有明确命名,所以随着提供更多参数,并不会发生进一步的替换:

#check replaceX Nat natOrigin
replaceX Nat natOrigin : Nat → PPoint Nat
#check replaceX Nat natOrigin 5
replaceX Nat natOrigin 5 : PPoint Nat

整个函数应用表达式的类型是通过传递类型作为参数来确定的,这一事实与对它进行求值的能力无关。

#eval replaceX Nat natOrigin 5
{ x := 5, y := 0 }

多态函数通过接受一个命名的类型参数并让后续类型引用参数的名称来工作。 然而,类型参数并没有什么可以让它们被命名的特殊之处。给定一个表示正负号的数据类型:

inductive Sign where
  | pos
  | neg

可以编写一个函数,其参数是一个符号。如果参数为正,则函数返回 Nat,如果为负,则返回 Int

def posOrNegThree (s : Sign) : match s with | Sign.pos => Nat | Sign.neg => Int :=
  match s with
  | Sign.pos => (3 : Nat)
  | Sign.neg => (-3 : Int)

由于类型是一等公民,且可以使用 Lean 语言的普通规则进行计算, 因此可以通过针对数据类型的模式匹配来计算它们。当 Lean 检查此函数时,它根据函数体中的 match 表达式与类型中的 match 表达式相对应,使 Nat 成为 pos 情况的期望类型, Int 成为 neg 情况的期望类型。

posOrNegThree 应用于 Sign.pos 会导致函数体和其返回类型中的参数名称 s 都被 Sign.pos 替换。求值可以在表达式及其类型中同时发生:

(posOrNegThree Sign.pos : match Sign.pos with | Sign.pos => Nat | Sign.neg => Int)
===>
((match Sign.pos with
  | Sign.pos => (3 : Nat)
  | Sign.neg => (-3 : Int)) :
 match Sign.pos with | Sign.pos => Nat | Sign.neg => Int)
===>
((3 : Nat) : Nat)
===>
3

链表

Lean 的标准库包含一个典型的链表数据类型,称为 List,以及使其更易于使用的特殊语法。 链表写在方括号中。例如,包含小于 10 的质数的链表可以写成:

def primesUnder10 : List Nat := [2, 3, 5, 7]

在幕后,List 是一个归纳数据类型,其定义如下:

inductive List (α : Type) where
  | nil : List α
  | cons : α → List α → List α

标准库中的实际定义略有不同,因为它使用了尚未介绍的特性,但它们大体上是相似的。 此定义表示 List 将单个类型作为其参数,就像 PPoint 那样。 此类型是存储在列表中的项的类型。根据构造子,List α 可以使用 nilcons 构造。 构造子 nil 表示空列表,构造子 cons 用于非空列表。 cons 的第一个参数是列表的头部,第二个参数是其尾部。包含 \( n \) 个项的列表包含 \( n \) 个 cons 构造子,最后一个以 nil 为尾部。

primesUnder10 示例可以通过直接使用 List 的构造函数更明确地编写:

def explicitPrimesUnder10 : List Nat :=
  List.cons 2 (List.cons 3 (List.cons 5 (List.cons 7 List.nil)))

这两个定义完全等价,但 primesUnder10explicitPrimesUnder10 更易读。

使用 List 的函数可以与使用 Nat 的函数以相同的方式定义。 事实上,一种考虑链表的方式是将其视为一个 Nat,每个 succ 构造函数都悬挂着一个额外的数据字段。从这个角度来看,计算列表的长度的过程是将每个 cons 替换为 succ,将最终的 nil 替换为 zero。就像 replaceX 将点的字段类型作为参数一样,length 接受列表项的类型。例如,如果列表包含字符串, 则第一个参数是 Stringlength String ["Sourdough", "bread"]。 它会这样计算:

length String ["Sourdough", "bread"]
===>
length String (List.cons "Sourdough" (List.cons "bread" List.nil))
===>
Nat.succ (length String (List.cons "bread" List.nil))
===>
Nat.succ (Nat.succ (length String List.nil))
===>
Nat.succ (Nat.succ Nat.zero)
===>
2

length 的定义既是多态的(因为它将列表项类型作为参数),又是递归的(因为它引用了自身)。 通常,函数遵循数据的形状:递归数据类型对应递归函数,多态数据类型对应多态函数。"

def length (α : Type) (xs : List α) : Nat :=
  match xs with
  | List.nil => Nat.zero
  | List.cons y ys => Nat.succ (length α ys)

按照惯例,xsys 等名称用于表示未知项的列表。名称中的 s 表示它们是复数, 因此它们的发音是「exes」和「whys」,而不是「x s」和「y s」。

为了便于阅读列表上的函数,可以使用方括号记法 [] 来匹配模式 nil, 并且可以使用中缀 :: 来代替 cons

def length (α : Type) (xs : List α) : Nat :=
  match xs with
  | [] => 0
  | y :: ys => Nat.succ (length α ys)

隐式参数

replaceXlength 这两个函数使用起来有些繁琐,因为类型参数通常由后面的值唯一确定。 事实上,在大多数语言中,编译器完全有能力自行确定类型参数,并且只需要偶尔从用户那里获得帮助。 在 Lean 中也是如此。在定义函数时,可以通过用大括号而非小括号将参数括起来,以将参数声明为 隐式(Implicit) 的。例如,一个具有隐式类型参数的 replaceX 如下所示:

def replaceX {α : Type} (point : PPoint α) (newX : α) : PPoint α :=
  { point with x := newX }

它可以与 natOrigin 一起使用,而无需显式提供 Nat,因为 Lean 可以从后面的参数中推断 α 的值:

#eval replaceX natOrigin 5
{ x := 5, y := 0 }

类似地,length 可以重新定义为隐式获取输入类型:

def length {α : Type} (xs : List α) : Nat :=
  match xs with
  | [] => 0
  | y :: ys => Nat.succ (length ys)

length 函数可以直接应用于 primesUnder10

#eval length primesUnder10
4

在标准库中,Lean 将此函数称为 List.length, 这意味着用于结构体字段访问的点语法也可以用于获得列表的长度:

#eval primesUnder10.length
4

正如 C# 和 Java 要求偶尔显式提供类型参数一样,Lean 并不总是能够得出隐式参数。 在这些情况下,可以使用它们的名称来提供它们。例如,可以通过将 α 设置为 Int 来特化出适用于整数列表的 List.length

#check List.length (α := Int)
List.length : List Int → Nat

更多内置数据类型

除了列表之外,Lean 的标准库还包含许多其他结构体和归纳数据类型,可用于各种场景。

Option 可选类型

并非每个列表都有第一个条目,有些列表是空的。许多集合操作可能无法得出它们正在查找的内容。 例如,查找列表中第一个条目的函数可能找不到任何此类条目。因此,必须有一种方法来表示没有第一个条目。

许多语言都有一个 null 值来表示没有值。Lean 没有为现有类型配备一个特殊的 null 值, 而是提供了一个名为 Option 的数据类型,为其他类型配备了一个缺失值指示器。 例如,一个可为空的 IntOption Int 表示,一个可为空的字符串列表由类型 Option (List String) 表示。引入一个新类型来表示可空性意味着类型系统确保无法忘记对 null 的检查,因为 Option Int 不能在需要 Int 的上下文中使用。

Option 有两个构造函数,称为 somenone,它们分别表示基础类型的非空和空的版本。 非空构造函数 some 包含基础值,而 none 不带参数:

inductive Option (α : Type) : Type where
  | none : Option α
  | some (val : α) : Option α

Option 类型与 C# 和 Kotlin 等语言中的可空类型非常相似,但并非完全相同。 在这些语言中,如果一个类型(比如 Boolean)总是引用该类型的实际值(truefalse), 那么类型 Boolean?Nullable<Boolean> 则额外允许 null 值。 在类型系统中跟踪这一点非常有用:类型检查器和其他工具可以帮助程序员记住检查 null, 并且通过类型签名明确描述可空性的 API 比不描述可空性的 API 更具有信息性量。 然而,这些可空类型与 Lean 的 Option 在一个非常重要的方面有所不同,那就是它们不允许多层可选项性。 Option (Option Int) 可以用 nonesome nonesome (some 360) 构造。另一方面,C# 禁止多层可空性,只允许将 ? 添加到不可空类型,而 Kotlin 将 T?? 视为等同于 T?。这种细微的差别在实践中大多无关紧要,但有时会很重要。

要查找列表中的第一个条目(如果存在),请使用 List.head?。 问号是名称的一部分,与在 C# 或 Kotlin 中使用问号表示可空类型并不相同。在 List.head? 的定义中,下划线用于表示列表的尾部。在模式匹配中,下划线匹配任何内容, 但不会引入变量来引用匹配的数据。使用下划线而不是名称是一种向读者清楚传达输入部分被忽略的方式。

def List.head? {α : Type} (xs : List α) : Option α :=
  match xs with
  | [] => none
  | y :: _ => some y

Lean 的命名约定是使用后缀 ? 定义可能失败的操作,用于返回 Option 的版本, 用于在提供无效输入时崩溃的版本,D 用于在操作在其他情况下失败时返回默认值的版本。 例如,head 要求调用者提供数学证据证明列表不为空,head? 返回 Optionhead! 在传递空列表时使程序崩溃,headD 采用一个默认值,以便在列表为空时返回。 问号和感叹号是名称的一部分,而不是特殊语法,因为 Lean 的命名规则比许多语言更自由。

由于 head?List 命名空间中定义,因此它可以使用访问器记法:

#eval primesUnder10.head?
some 2

然而,尝试在空列表上测试它会产生两个错误:

#eval [].head?
don't know how to synthesize implicit argument
  @List.nil ?m.20264
context:
⊢ Type ?u.20261

don't know how to synthesize implicit argument
  @_root_.List.head? ?m.20264 []
context:
⊢ Type ?u.20261

这是因为 Lean 无法完全确定表达式的类型。特别是,它既找不到 List.head? 的隐式类型参数, 也找不到 List.nil 的隐式类型参数。在 Lean 的输出中,?m.XYZ 表示程序中无法推断的部分。 这些未知部分称为 元变量(Metavariable) ,它们出现在一些错误消息中。为了计算一个表达式, Lean 需要得到它的类型,而类型不可用,因为空列表没有任何条目可以从中得出类型。 显式提供类型可以让 Lean 继续执行:

#eval [].head? (α := Int)
none

类型也可以用类型标注提供:

#eval ([] : List Int).head?
none

错误信息提供了一个有用的线索。两个信息都使用 相同 的元变量来描述缺少的隐式参数, 这意味着 Lean 已经确定两个缺少的部分将共享一个解决方案,即使它无法确定解决方案的实际值。

Prod 积类型

Prod 结构体,即 积(Product) 的简写,是一种将两个值连接在一起的通用方法。 例如,Prod Nat String 包含一个 Nat 和一个 String。换句话说,PPoint Nat 可以替换为 Prod Nat NatProd 非常类似于 C# 的元组、Kotlin 中的 PairTriple 类型以及 C++ 中的 tuple。许多应用最适合定义自己的结构体,即使对于像 Point 这样的简单情况也是如此,因为使用领域术语可以使代码更加易读。 此外,定义结构体类型有助于通过为不同的领域概念分配不同的类型来捕获更多错误,防止它们混淆。

另一方面,在某些情况下,并不值得定义新类型。此外,一些库足够通用, 以至于没有比 偶对(Pair) 更具体的概念。最后,标准库也包含了各种便利函数, 让使用内置偶对类型变得更容易。

标准偶对结构体叫做 Prod

structure Prod (α : Type) (β : Type) : Type where
  fst : α
  snd : β

列表的使用如此频繁,以至于有特殊的语法使它们更具可读性。 出于同样的原因,积类型及其构造子都有特殊的语法。类型 Prod α β 通常写为 α × β, 反映了集合的笛卡尔积的常用记法。与此类似,偶对的常用数学记法可用于 Prod。换句话说,不必写:

def fives : String × Int := { fst := "five", snd := 5 }

只需写

def fives : String × Int := ("five", 5)

即可。这两种表示法都是右结合的。这意味着以下定义是等价的:

def sevens : String × Int × Nat := ("VII", 7, 4 + 3)

def sevens : String × (Int × Nat) := ("VII", (7, 4 + 3))

换句话说,所有超过两种类型的积及其对应的构造子,实际上都是嵌套的积和嵌套的偶对。

Sum 和类型

和(Sum 数据类型是一种允许在两种不同类型的值之间进行选择的一般方式。 例如,Sum String Int 要么是 String,要么是 Int。与 Prod 一样, Sum 应该在编写非常通用的代码时使用,对于没有合适的特定领域类型的一小段代码, 或者当标准库包含有用的函数时使用。在大多数情况下,使用自定义归纳类型更具可读性和可维护性。

Sum α β 类型的取值要么是应用于 α 类型的构造子 inl,要么是应用于 β 类型的构造子 inr

inductive Sum (α : Type) (β : Type) : Type where
  | inl : α → Sum α β
  | inr : β → Sum α β

这些名称分别是「左注入(left injection)」和「右注入(right injection)」的缩写。 就像笛卡尔积符号用于 Prod 一样,「圆圈加号」符号用于 Sum,因此 α ⊕ βSum α β 的另一种记法。Sum.inlSum.inr 没有特殊语法。

例如,如果宠物名称可以是狗名或猫名,那么它们的类型可以作为字符串的和来引入:

def PetName : Type := String ⊕ String

在实际程序中,通常最好为此目的自定义一个归纳数据类型,并使用有意义的构造子名称。 在这里,Sum.inl 用于狗的名字,Sum.inr 用于猫的名字。这些构造子可用于编写动物名称列表:

def animals : List PetName :=
  [Sum.inl "Spot", Sum.inr "Tiger", Sum.inl "Fifi", Sum.inl "Rex", Sum.inr "Floof"]

模式匹配可用于区分两个构造子。例如,一个函数用于统计动物名称列表中狗的数量(即 Sum.inl 构造子的数量),如下所示:

def howManyDogs (pets : List PetName) : Nat :=
  match pets with
  | [] => 0
  | Sum.inl _ :: morePets => howManyDogs morePets + 1
  | Sum.inr _ :: morePets => howManyDogs morePets

函数调用在中缀运算符之前进行求值,因此 howManyDogs morePets + 1 等价于 (howManyDogs morePets) + 1。如预期的那样,#eval howManyDogs animals 会产生 3

Unit 单位类型

Unit 是仅有一个无参构造子(称为 unit)的类型。换句话说,它只描述一个值, 该值由没有应用于任何参数的构造子组成。Unit 定义如下:

inductive Unit : Type where
  | unit : Unit

单独使用时,Unit 并不是特别有用。但是,在多态代码中,它可以用作缺少数据的占位符。 例如,以下归纳数据类型表示算术表达式:

inductive ArithExpr (ann : Type) : Type where
  | int : ann → Int → ArithExpr ann
  | plus : ann → ArithExpr ann → ArithExpr ann → ArithExpr ann
  | minus : ann → ArithExpr ann → ArithExpr ann → ArithExpr ann
  | times : ann → ArithExpr ann → ArithExpr ann → ArithExpr ann

类型参数 ann 表示标注,每个构造子都有标注。来自解析器的表达式可能带有源码位置标注, 因此 ArithExpr SourcePos 的返回类型需要确保解析器在每个子表达式中放置 SourcePos。 然而,不来自于解析器的表达式没有源码位置,因此它们的类型可以是 ArithExpr Unit

此外,由于所有 Lean 函数都有参数,因此其他语言中的零参数函数可以表示为接受 Unit 参数的函数。 在返回位置,Unit 类型类似于 C 的语言中的 void。在 C 系语言中, 返回 void 的函数会将控制权返回给调用者,但不会返回任何有意义的值。 Unit 作为一个特意表示无意义的值,可以在类型系统无需具有特殊用途的 void 特性的情况下表达这一点。Unit 的构造子可以写成空括号: () : Unit

Empty 空类型

Empty 数据类型没有任何构造子。 因此,它表示不可达代码,因为任何调用序列都无法以 Empty 类型的返回值终止。

Empty 的使用频率远不及 Unit。然而,它在一些特殊情况下很有用。 许多多态数据类型并非在其所有构造子中使用其所有类型参数。例如,Sum.inlSum.inr 各自只使用 Sum 的一个类型参数。将 Empty 用作 Sum 的类型参数之一可以在程序的特定点排除一个构造子。这能让我们在具有额外限制的语境中使用泛型代码。

命名:和类型,积类型与单位类型

一般来说,提供多个构造子的类型称为 和类型(Sum Type) , 而其单个构造子接受多个参数的类型称为 积类型(Product Type) 。 这些术语与普通算术中使用的和与积有关。当涉及的类型包含有限数量的值时,这种关系最容易看出。 如果 αβ 是分别包含 \( n \) 和 \( k \) 个不同值的数据类型, 则 α ⊕ β 包含 \( n + k \) 个不同值,α × β 包含 \( n \times k \) 个不同值。 例如,Bool 有两个值:truefalseUnit 有一个值:Unit.unit。 积 Bool × Unit 有两个值 (true, Unit.unit)(false, Unit.unit), 和 Bool ⊕ Unit 有三个值 Sum.inl trueSum.inl falseSum.inr unit。 类似地,\( 2 \times 1 = 2 \),\( 2 + 1 = 3 \)。

你可能会遇到的信息

并非所有可定义的结构体或归纳类型都可以具有类型 Type。 特别是,如果一个构造子将任意类型作为参数,则归纳类型必须具有不同的类型。 这些错误通常会说明一些关于「宇宙层级」的内容。例如,对于这个归纳类型:

inductive MyType : Type where
  | ctor : (α : Type) → α → MyType

Lean 会给出以下错误:

invalid universe level in constructor 'MyType.ctor', parameter 'α' has type
  Type
at universe level
  2
it must be smaller than or equal to the inductive datatype universe level
  1

后面的章节会描述为什么会这样,以及如何修改定义使其正常工作。 现在,尝试将类型作为参数传递给整个归纳类型,而不是传递给构造子。

与此类似,如果构造子的参数是一个将正在定义的数据类型作为参数的函数,那么该定义将被拒绝。例如:

inductive MyType : Type where
  | ctor : (MyType → Int) → MyType

会产生以下信息:

(kernel) arg #1 of 'MyType.ctor' has a non positive occurrence of the datatypes being declared

出于技术原因,允许这些数据类型可能会破坏 Lean 的内部逻辑,使其不适合用作定理证明器。

忘记归纳类型的参数也可能产生令人困惑的消息。 例如,当参数 α 没有传递给 ctor 的类型中的 MyType 时:

inductive MyType (α : Type) : Type where
  | ctor : α → MyType

Lean 会返回以下错误:

type expected, got
  (MyType : Type → Type)

该错误信息表明 MyType 的类型 Type → Type 本身并不描述类型。 MyType 需要一个参数才能成为一个真正的类型。

在其他语境中省略类型参数时也会出现相同的消息,例如在定义的类型签名中:

inductive MyType (α : Type) : Type where
  | ctor : α → MyType α

def ofFive : MyType := ctor 5

练习

  • 编写一个函数来查找列表中的最后一个条目。它应该返回一个 Option
  • 编写一个函数,在列表中找到满足给定谓词的第一个条目。从 def List.findFirst? {α : Type} (xs : List α) (predicate : α → Bool) : Option α := 开始定义。
  • 编写一个函数 Prod.swap,用于交换偶对中的两个字段。 定义以 def Prod.swap {α β : Type} (pair : α × β) : β × α := 开始。
  • 使用自定义数据类型重写 PetName 示例,并将其与使用 Sum 的版本进行比较。
  • 编写一个函数 zip,用于将两个列表组合成一个偶对列表。结果列表的长度应与最短的输入列表相同。 定义以 def zip {α β : Type} (xs : List α) (ys : List β) : List (α × β) := 开始。
  • 编写一个多态函数 take,返回列表中的前 \( n \) 个条目,其中 \( n \) 是一个 Nat。 如果列表包含的条目少于 n 个,则结果列表应为输入列表。 #eval take 3 ["bolete", "oyster"] 应当产生 ["bolete", "oyster"],而 #eval take 1 ["bolete", "oyster"] 应当产生 ["bolete"]
  • 利用类型和算术之间的类比,编写一个将积分配到和上的函数。 换句话说,它的类型应为 α × (β ⊕ γ) → (α × β) ⊕ (α × γ)
  • 利用类型和算术之间的类比,编写一个将乘以 2 转换为和的函数。 换句话说,它的类型应为 Bool × α → α ⊕ α

其他便利功能

Lean 包含许多便利功能,能够让程序更加简洁。

自动隐式参数

在 Lean 中编写多态函数时,通常不必列出所有隐式参数。相反,它们可以简单地被提及。 如果 Lean 可以确定它们的类型,那么它们将自动插入为隐式参数。换句话说,length 的先前定义:

def length {α : Type} (xs : List α) : Nat :=
  match xs with
  | [] => 0
  | y :: ys => Nat.succ (length ys)

可以不写 {α : Type}:

def length (xs : List α) : Nat :=
  match xs with
  | [] => 0
  | y :: ys => Nat.succ (length ys)

这能极大简化地需要很多隐式参数的高级多态定义。

模式匹配定义

def 定义函数时,通常会给参数命名,然后立即用模式匹配使用它。 例如,在 length 中,参数 xs 仅在 match 中使用。在这些情况下,match 表达式的 case 可以直接编写,而无需给参数命名。

第一步是将参数类型移到冒号的右侧,因此返回类型是函数类型。例如,length 的类型是 List α → Nat。然后,用模式匹配的每个 case 替换 :=

def length : List α → Nat
  | [] => 0
  | y :: ys => Nat.succ (length ys)

此语法还可用于定义接受多个参数的函数。在这种情况下,它们的模式用逗号分隔。 例如,drop 接受一个数字 \( n \) 和一个列表,并返回删除前 \( n \) 个条目的列表。

def drop : Nat → List α → List α
  | Nat.zero, xs => xs
  | _, [] => []
  | Nat.succ n, x :: xs => drop n xs

已命名的参数和模式也可以在同一定义中使用。例如,一个函数接受一个默认值和一个可选值, 当可选值为 none 时返回默认值,可以写成:

def fromOption (default : α) : Option α → α
  | none => default
  | some x => x

此函数在标准库中称为 Option.getD,可以用点表示法调用:

#eval (some "salmonberry").getD ""
"salmonberry"
#eval none.getD ""
""

局部定义

在计算中对中间步骤命名通常很有用。在许多情况下,中间值本身就代表有用的概念, 明确地命名它们可以使程序更易于阅读。在其他情况下,中间值被使用多次。与大多数其他语言一样, 在 Lean 中两次写下相同的代码会导致计算两次,而将结果保存在变量中会导致计算的结果被保存并重新使用。

例如,unzip 是一个将偶对的列表转换为一对列表的函数。当偶对列表为空时, unzip 的结果是一对空列表。当偶对列表的头部有一个偶对时, 则该偶对的两个字段将添加到列表的其余部分 unzip 后的结果中。 以下 unzip 的定义完全遵循该描述:

def unzip : List (α × β) → List α × List β
  | [] => ([], [])
  | (x, y) :: xys =>
    (x :: (unzip xys).fst, y :: (unzip xys).snd)

不幸的是,这里存在一个问题:此代码比预期的速度要慢。 对列表中的每个条目都会导致两个递归调用,这使得此函数需要指数时间。 然而,两个递归调用都会有相同的结果,因此没有理由进行两次递归调用。

在 Lean 中,可以使用 let 命名递归调用的结果,从而保存它。 使用 let 的局部定义类似于使用 def 的顶层定义:它需要一个局部定义的名称, 如果需要的话,还有参数、类型签名,然后是 := 后面的主体。在局部定义之后, 局部定义可用的表达式(称为 let 表达式的 主体 )必须在新行上, 从文件中的列开始,该列小于或等于 let 关键字的列。 例如,let 可以像这样用于 unzip

def unzip : List (α × β) → List α × List β
  | [] => ([], [])
  | (x, y) :: xys =>
    let unzipped : List α × List β := unzip xys
    (x :: unzipped.fst, y :: unzipped.snd)

要在单行中使用 let,请使用分号将局部定义与主体分隔开。

当一个模式足以匹配数据类型的全部情况时,使用 let 的局部定义也可以使用模式匹配。 在 unzip 的情况下,递归调用的结果是个偶对。因为偶对只有一个构造子,所以名称 unzipped 可以替换为偶对模式:

def unzip : List (α × β) → List α × List β
  | [] => ([], [])
  | (x, y) :: xys =>
    let (xs, ys) : List α × List β := unzip xys
    (x :: xs, y :: ys)

巧妙地使用带有 let 的模式可以使代码更易读,而无需手动编写访问器调用。

letdef 之间最大的区别在于,递归 let 定义必须通过编写 let rec 明确表示。 例如,反转列表的一种方法涉及递归辅助函数,如下所示:

def reverse (xs : List α) : List α :=
  let rec helper : List α → List α → List α
    | [], soFar => soFar
    | y :: ys, soFar => helper ys (y :: soFar)
  helper xs []

辅助函数遍历输入列表,一次将一个条目移动到 soFar。 当它到达输入列表的末尾时,soFar 包含输入的反转版本。

类型推断

在许多情况下,Lean 可以自动确定表达式的类型。在这些情况下, 可以从顶层定义(使用 def)和局部定义(使用 let)中省略显式类型。 例如,对 unzip 的递归调用不需要标注:

def unzip : List (α × β) → List α × List β
  | [] => ([], [])
  | (x, y) :: xys =>
    let unzipped := unzip xys
    (x :: unzipped.fst, y :: unzipped.snd)

根据经验,省略字面量(如字符串和数字)的类型通常有效, 尽管 Lean 可能会为字面量数字选择比预期类型更具体的类型。 Lean 通常可以确定函数应用的类型,因为它已经知道参数类型和返回类型。 省略函数定义的返回类型通常有效,但函数参数通常需要标注。 对于非函数的定义(如示例中的 unzipped),若其主体不需要类型标注, 且该定义的主体是一个函数应用,则该定义不需要类型标注

在使用显式 match 表达式时,可省略 unzip 的返回类型:

def unzip (pairs : List (α × β)) :=
  match pairs with
  | [] => ([], [])
  | (x, y) :: xys =>
    let unzipped := unzip xys
    (x :: unzipped.fst, y :: unzipped.snd)

一般来说,宁可多加类型标注,也不要太少。首先,显式类型向读者传达了对代码的假设。 即使 Lean 可以自行确定类型,但无需反复查询 Lean 以获取类型信息,代码仍然更容易阅读。 其次,显式类型有助于定位错误。程序对其类型越明确,错误消息就越有信息量。 这在 Lean 这样的具有非常丰富的类型系统的语言中尤为重要。第三,显式类型使编写程序变得更容易。 类型是一种规范,编译器的反馈可以成为编写符合规范的程序的有用工具。 最后,Lean 的类型推断是一种尽力而为的系统。由于 Lean 的类型系统非常丰富, 因此无法为所有表达式找到「最佳」或最通用的类型。这意味着即使你得到了一个类型, 也不能保证它是给定应用的「正确」类型。例如,14 可以是 NatInt

#check 14
14 : Nat
#check (14 : Int)
14 : Int

缺少类型标注可能会产生令人困惑的错误信息。从 unzip 的定义中省略所有类型:

def unzip pairs :=
  match pairs with
  | [] => ([], [])
  | (x, y) :: xys =>
    let unzipped := unzip xys
    (x :: unzipped.fst, y :: unzipped.snd)

会产生有关 match 表达式的信息:

invalid match-expression, pattern contains metavariables
  []

这是因为 match 需要知道正在检查的值的类型,但该类型不可用。 「元变量」是程序中未知的部分,在错误消息中写为 ?m.XYZ, 它们在多态性一节中进行了描述。 在此程序中,参数上的类型标注是必需的。

即使一些非常简单的程序也需要类型标注。例如,恒等函数只返回传递给它的任何参数。 使用参数和类型标注,它看起来像这样:

def id (x : α) : α := x

Lean 能够自行确定返回类型:

def id (x : α) := x

然而,省略参数类型会导致错误:

def id x := x
failed to infer binder type

一般来说,类似于「无法推断」或提及元变量的消息通常表示需要更多类型标注。 特别是在学习 Lean 时,显式提供大多数类型是很有用的。

同时匹配

模式匹配表达式,和模式匹配定义一样,可以一次匹配多个值。 要检查的表达式和它们匹配的模式都用逗号分隔,类似于用于定义的语法。 以下是使用同时匹配的 drop 版本:

def drop (n : Nat) (xs : List α) : List α :=
  match n, xs with
  | Nat.zero, ys => ys
  | _, [] => []
  | Nat.succ n , y :: ys => drop n ys

自然数模式

数据类型与模式一节中,even 被定义为:

def even (n : Nat) : Bool :=
  match n with
  | Nat.zero => true
  | Nat.succ k => not (even k)

就像列表模式的特殊语法比直接使用 List.consList.nil 更具可读性一样, 自然数可以使用字面数字和 + 进行匹配。例如,even 也可以这样定义:

def even : Nat → Bool
  | 0 => true
  | n + 1 => not (even n)

在此记法中,+ 模式的参数扮演着不同的角色。在幕后,左参数(上面的 n)成为一些 Nat.succ 模式的参数,右参数(上面的 1)确定包裹该模式的 Nat.succ 数量有多少。 halve 中的显式模式将 Nat 除以二并丢弃余数:

def halve : Nat → Nat
  | Nat.zero => 0
  | Nat.succ Nat.zero => 0
  | Nat.succ (Nat.succ n) => halve n + 1

可用数值字面量和 + 代替:

def halve : Nat → Nat
  | 0 => 0
  | 1 => 0
  | n + 2 => halve n + 1

在幕后,这两个定义完全等价。记住:halve n + 1 等价于 (halve n) + 1,而非 halve (n + 1)

在使用这个语法时,+的第二个参数应始终是一个字面量 Nat。 尽管加法是可交换的,但是在模式中交换参数可能会产生以下错误:

def halve : Nat → Nat
  | 0 => 0
  | 1 => 0
  | 2 + n => halve n + 1
invalid patterns, `n` is an explicit pattern variable, but it only occurs in positions that are inaccessible to pattern matching
  .(Nat.add 2 n)

此限制使 Lean 能够将模式中所有 + 号的用法转换为底层 Nat.succ 的用法, 从而在幕后使语言更简单。

匿名函数

Lean 中的函数不必在顶层定义。作为表达式,函数使用 fun 语法定义。 函数表达式以关键字 fun 开头,后跟一个或多个参数,这些参数使用 => 与返回表达式分隔。 例如,可以编写一个将数字加 1 的函数:

#check fun x => x + 1
fun x => x + 1 : Nat → Nat

类型标注的写法与 def 相同,使用括号和冒号:

#check fun (x : Int) => x + 1
fun x => x + 1 : Int → Int

同样,隐式参数可以用大括号编写:

#check fun {α : Type} (x : α) => x
fun {α} x => x : {α : Type} → α → α

这种匿名函数表达式风格通常称为 λ-表达式(Lambda Expression) , 因为编程语言在数学描述中使用的典型符号,将 Lean 中使用关键字 fun 的地方换成了希腊字母 λ(Lambda)。即使 Lean 允许使用 λ 代替 fun, 但最常见的仍然是写作 fun

匿名函数还支持 def 中使用的多模式风格。例如,可以编写一个返回自然数的前驱(如果存在)的函数:

#check fun
  | 0 => none
  | n + 1 => some n
fun x =>
  match x with
  | 0 => none
  | Nat.succ n => some n : Nat → Option Nat

注意,Lean 函数的描述本身有一个命名参数和一个 match 表达式。 Lean 的许多便捷语法缩写都会在幕后扩展为更简单的语法,但有时会泄漏抽象,暴露出具体细节。

使用 def 定义带有参数的函数可以重写为函数表达式。例如,一个将其参数翻倍的函数可以写成以下形式:

def double : Nat → Nat := fun
  | 0 => 0
  | k + 1 => double k + 2

当匿名函数非常简单时,例如 fun x => x + 1, 创建函数的语法会相当冗长。在此例中,有六个非空白字符用于引入函数, 其函数体仅包含三个非空白字符。对于这些简单的情况,Lean 提供了一个简写。 在括号包围的表达式中,间点号 · 可以表示一个参数,括号内的表达式为函数体, 因此该函数也可以写成 (· + 1)

间点号总是将 最靠近 的一对括号创建为函数。 例如,(· + 5, 3) 是返回一对数字的函数, 而 ((· + 5), 3) 是一个函数和一个数字的偶对。 如果使用多个点,则它们按从左到右的顺序成为参数:

(· , ·) 1 2
===>
(1, ·) 2
===>
(1, 2)

匿名函数的应用方式与 deflet 定义的函数完全相同。 命令 #eval (fun x => x + x) 5 的结果是:

10

#eval (· * 2) 5 的结果是:

10

命名空间

Lean 中的每个名称都出现在一个 命名空间(Namespace) 中,它是一个名称的集合。 名称使用 . 放在命名空间中,因此 List.mapList 命名空间中的名称 map。 不同命名空间中的名称不会相互冲突,即使它们在其他方面是相同的。 这意味着 List.mapArray.map 是不同的名称。 命名空间可以嵌套,因此 Project.Frontend.User.loginTime 是嵌套命名空间 Project.Frontend.User 中的名称 loginTime

命名空间中可以直接定义名称。例如,名称 double 可以定义在 Nat 命名空间中:

def Nat.double (x : Nat) : Nat := x + x

由于 Nat 也是一个类型的名称,因此可以使用点记法对类型为 Nat 的表达式调用 Nat.double

#eval (4 : Nat).double
8

除了直接在命名空间中定义名称外,还可以使用 namespaceend 命令将一系列声明放在命名空间中。例如,以下代码在 NewNamespace 命名空间中定义了 triplequadruple

namespace NewNamespace
def triple (x : Nat) : Nat := 3 * x
def quadruple (x : Nat) : Nat := 2 * x + 2 * x
end NewNamespace

要引用它们,请在其名称前加上 NewNamespace.

#check NewNamespace.triple
NewNamespace.triple (x : Nat) : Nat
#check NewNamespace.quadruple
NewNamespace.quadruple (x : Nat) : Nat

命名空间可以 打开 ,这允许在不显式指定的情况下使用其中的名称。 在表达式之前编写 open MyNamespace in 会使 MyNamespace 中的内容在表达式中可用。例如,timesTwelve 在打开 NewNamespace 后同时使用了 quadrupletriple

def timesTwelve (x : Nat) :=
  open NewNamespace in
  quadruple (triple x)

命名空间也可以在命令之前打开。这能让命令中的所有部分引用命名空间的内容, 而不仅仅是一个表达式。为此,请在命令之前写上 open ... in

open NewNamespace in
#check quadruple
NewNamespace.quadruple (x : Nat) : Nat

函数签名会显示名称的完整命名空间,还可以为文件其余部分的所有后续命令打开命名空间。 为此,只需从 open 的顶级用法中省略 in

if let

在使用具有和类型的值时,通常只对一个构造子感兴趣。 例如,给定一个表示 Markdown 内联元素子集的类型:

inductive Inline : Type where
  | lineBreak
  | string : String → Inline
  | emph : Inline → Inline
  | strong : Inline → Inline

可以编写一个识别字符串元素并提取其内容的函数:

def Inline.string? (inline : Inline) : Option String :=
  match inline with
  | Inline.string s => some s
  | _ => none

另一种编写此函数体的方法是将 iflet 联用:

def Inline.string? (inline : Inline) : Option String :=
  if let Inline.string s := inline then
    some s
  else none

这与模式匹配 let 的语法非常相似,不同之处在于它可以与和类型一起使用, 因为在 else 的情况中提供了备选项。在某些情况下,使用 if let 代替 match 可以让代码更易读。

带位置的结构体参数

结构体一节中介绍了构造结构体的两种方法:

  1. 构造子可以直接调用,如 Point.mk 1 2.
  2. 可以使用大括号记法,如 { x := 1, y := 2 }.

在某些情况下,按位置传递参数要比按名称传递参数更方便,因为无需直接命名构造子。 例如,定义各种相似的结构体类型有助于保持领域概念的隔离, 但阅读代码的自然方式可能是将它们都视为本质上是一个元组。 在这种情况下,参数可以用尖括号 括起来,如 Point 可以写成 ⟨1, 2⟩。 注意!即使它们看起来像小于号 < 和大于号 >,这些括号也不同。 它们可以分别使用 \<\> 来输入。

与命名构造子参数的大括号记法一样,此位置语法只能在 Lean 可以根据类型标注或程序中的其他类型信息,来确定结构体类型的语境中使用。 例如,#eval ⟨1, 2⟩ 会产生以下错误:

invalid constructor ⟨...⟩, expected type must be an inductive type 
  ?m.34991

错误中出现元变量是因为没有可用的类型信息。添加标注,例如 #eval (⟨1, 2⟩ : Point),可以解决此问题:

{ x := 1.000000, y := 2.000000 }

字符串插值

在 Lean 中,在字符串前加上 s! 会触发 插值(Interpolation) , 其中字符串中大括号内的表达式会被其值替换。这类似于 Python 中的 f 字符串和 C# 中以 $ 为前缀的字符串。例如,

#eval s!"three fives is {NewNamespace.triple 5}"

会产生输出

"three fives is 15"

并非所有的表达式都可以插值到字符串中。例如,尝试插值一个函数会产生错误。

#check s!"three fives is {NewNamespace.triple}"

会产生输出

failed to synthesize instance
  ToString (Nat → Nat)

这是因为没有将函数转换为字符串的标准方法。Lean 编译器维护了一个表, 描述如何将各种类型的值转换为字符串,而消息 failed to synthesize instance 意味着 Lean 编译器未在此表中找到给定类型的条目。 这使用了与结构体一节中描述的 deriving Repr 语法相同的语言特性。

总结

求值表达式

在 Lean 中,计算发生在求值表达式时。这遵循数学表达式的通常规则: 子表达式按照通常的运算顺序替换为其值,直到整个表达式变为一个值。 在求值 ifmatch 时,分支中的表达式不会被求值,直到找到条件为真或匹配主项的值。

变量一旦被赋予值,就不会再改变。这与数学类似,但与大多数编程语言不同, Lean 变量只是值的占位符,而非可以写入新值的位置。变量的值可能来自带有 def 的全局定义、带有 let 的局部定义、作为函数的命名参数或模式匹配。

函数

Lean 中的函数是一等的值,这意味着它们可以作为参数传递给其他函数、 保存在变量中,并像任何其他值一样使用。每个 Lean 函数只接受一个参数。 为了对接受多个参数的函数进行编码,Lean 使用了一种称为 柯里化(Currying) 的技术, 其中提供第一个参数会返回一个期望剩余参数的函数。为了对不接受任何参数的函数进行编码, Lean 使用了 Unit 类型,这是最没有信息量的可用参数。

创建函数的主要方式有三种:

  1. 匿名函数使用 fun 编写。例如,一个交换 Point 字段的函数可以写成 fun (point : Point) => { x := point.y, y := point.x : Point }
  2. 非常简单的匿名函数通过在括号内放置一个或多个间点 · 来编写。 每个间点都是函数的一个参数,用括号限定其主体。 例如,一个从其参数中减去 1 的函数可以写成 (· - 1) 而非 fun x => x - 1
  3. 函数可以用 deflet 定义,方法是添加参数列表或使用模式匹配记法。

类型

Lean 会检查每个表达式是否具有类型。类型(例如 IntPoint{α : Type} → Nat → α → List αOption (String ⊕ (Nat × String)))描述了表达式最终可能求出的值。 与其他语言一样,Lean 中的类型可以表达由 Lean 编译器检查的程序的轻量级规范, 从而消除对具体的类进行单元测试的需求。与大多数语言不同,Lean 的类型还可以表示任意数学, 统一了编程和定理证明的世界。虽然将 Lean 用于定理证明在很大程度上超出了本书的范围, 但《Lean 4 定理证明》 一书中包含了有关该主题的更多信息。

某些表达式可被赋予多种类型。例如,3 可以是 IntNat。 在 Lean 中,这应该理解为两个独立的表达式,一个类型为 Nat,另一个类型为 Int, 只是它们碰巧以相同的方式编写,而非同一事物的两种不同类型。

Lean 有时能够自动确定类型,但类型通常必须由用户提供。 这是因为 Lean 的类型系统非常具有表现力。即使 Lean 可以找到一种类型, 它找到的也可能不是需要的类型。例如 3 可能打算用作 Int,但如果没有任何进一步的约束, Lean 将赋予它 Nat 类型。一般来说,最好显式地写出大多数类型, 只让 Lean 填写非常明显的类型。这样能改进 Lean 的错误信息,并有助于使程序员的意图更加清晰。

某些函数或数据类型将类型作为参数。它们被称为 多态(Polymorphic) 。 多态性能让计算列表长度这类的程序不必关心列表中条目的具体类型。 由于类型在 Lean 中是一等公民,因此多态性不需要任何特殊语法,类型就能像其他参数一样传递。 在函数类型中为参数指定名称能让稍后的类型引用该参数, 并且通过将参数的名称替换为参数的值,就能知道应该将此函数应用于何种类型的参数。

结构体与归纳类型

可以使用 structureinductive 特性向 Lean 引入全新的数据类型。 即使它们的定义在其他方面相同,这些新类型也不被认为等同于任何其他类型。 数据类型具有 构造子(Constructor) ,解释了可以构造其值的方式, 每个构造子都接受一些参数。Lean 中的构造子与面向对象语言中的构造函数不同: Lean 的构造子只是数据的单纯持有者,而非初始化已分配对象的活动代码。

通常,structure 用于引入积类型(即,只有一个构造子且该构造子可以接受任意数量参数的类型), 而 inductive 用于引入和类型(即,具有多个不同构造子的类型)。 使用 structure 定义的数据类型会为构造子的每个参数提供一个访问器函数。 结构体和归纳数据类型都可以使用模式匹配来使用, 模式匹配使用调用所述构造子的语法的一个子集来表达存储在构造子中的值。 模式匹配意味着知道如何创建值就意味着知道如何使用它。

递归

当正在定义的名称在定义本身中使用时,定义就是递归的。 由于 Lean 除了是一种编程语言之外,还是一个交互式定理证明器, 因此对递归定义施加了某些限制。在 Lean 的逻辑方面,循环定义可能会导致逻辑不一致。

为确保递归定义的函数不会破坏 Lean 的逻辑方面,无论使用什么参数调它们, Lean 都必须能够证明所有函数都会停机。在实践中,这意味着递归调用都会在输入的结构中更小的部分上执行, 这确保了函数始终朝着基本情况推进,或者用户必须提供一些其他证据来证明函数必定会停机。 类似地,递归归纳类型不允许拥有 从类型中 接受一个函数作为参数的构造子, 因为这会让 Lean 能够编码不停机的函数。

Hello, World!

虽然 Lean 被设计为一个丰富的交互式环境,程序员无需离开他们最喜欢的文本编辑器, 就能从语言中获得相当多的反馈,但它同时也是一门可以编写现实程序的语言。 这意味着它还具有批量编译器、构建系统、包管理器以及编写程序所需的一切工具。

上一章介绍了 Lean 函数式编程的基础知识, 本章将解释如何开始一个编程项目、编译它并运行出结果。 运行并与环境交互的程序(例如通过读取标准输入或创建文件) 很难和将计算理解为数学表达式的求值相协调。除了介绍 Lean 构建工具之外, 本章还提供了一种思考函数式程序与世界如何交互的方法。

运行程序

运行 Lean 程序最简单的方法是使用 Lean 可执行文件的 --run 选项。 创建一个名为 Hello.lean 的文件并输入以下内容:

def main : IO Unit := IO.println "Hello, world!"

然后,在命令行运行:

lean --run Hello.lean

该程序会在显示 Hello, world! 后退出。

问候程序的剖析

当使用 --run 选项调用 Lean 时,它会调用程序的 main 定义。 对于不从命令行接受参数的程序,main 的类型应该是 IO Unit。 这意味着 main 不是一个函数,因为它的类型中没有箭头()。 main 不是一个具有副作用的函数,而是由要执行的作用的描述组成。

上一章所述,Unit 是最简单的归纳类型。 它有一个名为 unit 的构造子,不接受任何参数。C 系的语言中有一个 void 函数的概念, 它不返回任何值。在 Lean 中,所有函数都接受一个参数并返回一个值, 而使用 Unit 类型可以表示没什么参数或返回值。如果 Bool 表示一个比特的信息, 那么 Unit 就表示零比特的信息。

IO α 是一个程序的类型,当执行时,它要么抛出一个异常,要么返回一个类型为 α 的值。 在执行期间,此程序可能会产生副作用。这些程序被称为 IO 活动(Action) 。 Lean 区分表达式的 求值(Evaluation)(严格遵循用变量值替换值和无副作用地归约子表达式的数学模型) 和 IO 活动的 执行(Execution)(依赖于外部系统与世界交互)。 IO.println 是一个从字符串到 IO 活动的函数,当执行时,它将给定的字符串写入标准输出。 由于此活动在发出字符串的过程中不会从环境中读取任何有趣的信息, 因此 IO.println 的类型为 String → IO Unit。如果它确实要返回一些有趣的东西, 那么将通过 Unit 类型以外的 IO 活动来表示。

函数式编程与副作用

Lean 的计算模型基于数学表达式的求值,其中变量会被赋予一个不会随时间改变的精确值。 求值表达式的结果不会改变,再次求值相同的表达式会始终产生相同的结果。

另一方面,有用的程序必须与世界交互。既不进行输入也不进行输出的程序无法向用户询问数据、 创建磁盘文件或打开网络连接。Lean 是用它自己编写的,而 Lean 编译器当然会读取文件、 创建文件并与文本编辑器交互。当这些文件的内容可能随时间而改变时, 在文件内容可能随时间改变的情况下,一种相同的表达式总是产生相同结果的语言, 要如何编写可以从磁盘读取文件的程序呢?

只要换个角度思考副作用,即可解决这种明显的矛盾。想象一家出售咖啡和三明治的咖啡厅。 这家咖啡厅有两个员工:一名厨师负责完成订单,一名柜员负责与顾客互动并下订单。 厨师是个暴脾气的人,他真的不喜欢与外界有任何接触, 但他非常擅长始终如一地提供咖啡厅著名的食物和饮料。然而,为了做到这一点, 厨师需要安静,不能被谈话打扰。柜员很友好,但在厨房里完全没有能力。 顾客与柜员互动,后者将所有实际烹饪委托给厨师。如果厨师对顾客有疑问,例如澄清过敏源, 他们会给柜台工作人员发一张小纸条,柜员与顾客互动,并将一张写有结果的纸条传回给厨师。

在这个类比中,厨师是 Lean 语言。当收到订单时,厨师会忠实且始终如一地提供所要求的内容。 柜员是与世界交互的外围运行时系统,它可以接受付款、分发食物并与顾客交谈。 这两名员工共同承担了餐厅的所有职能,但他们的职责是分开的,每个人都执行自己最擅长的任务。 就像让顾客远离可以让厨师专注于制作真正美味的咖啡和三明治一样, Lean 缺乏副作用可以让程序用作形式化数学证明的一部分。它还有助于程序员独立理解程序的各个部分, 因为没有隐藏的状态改变会在组件之间造成微妙的耦合。 厨师的笔记表示通过求值 Lean 表达式产生的 IO 活动,而柜员的回复是通过副作用传递回来的值。

这种副作用模型与 Lean 语言、编译器和运行时系统(Run-Time System,RTS)整体配合的工作方式非常相似。 运行时系统中的原语(Primitive,用 C 语言编写)实现了所有基本副作用。在运行程序时, RTS 调用 main 活动,该活动将新的 IO 活动返回给 RTS 以执行。 RTS 执行这些活动,委托给用户的 Lean 代码来执行计算。从 Lean 的内部角度来看, 程序没有副作用,而 IO 活动只是要执行的任务的描述。从程序用户的外部角度来看, 存在一层副作用,它创建了一个与程序核心逻辑交互的接口。

现实世界的函数式编程

考虑 Lean 中副作用的另一种方式,就是将 IO 活动看做一个函数,它将整个世界作为参数输入, 并返回一个值和一个新的世界。在这种情况下,从标准输入读取一行文本是一个 纯(Pure) 函数, 因为每次都提供了一个不同的世界作为参数。向标准输出写入一行文本也是一个纯函数, 因为函数返回的世界与它最初的世界不同。程序确实需要小心,不要重复使用世界, 也不要没能返回一个新世界——毕竟,这将相当于时间旅行或者世界末日。 谨小慎微的抽象边界可以使这种编程风格变得安全。如果每个原语 IO 活动都接受一个世界并返回一个新世界,并且它们只能与保持这种不变性的工具结合使用, 那么问题就不会发生。

当然,这种模型无法真正实现,毕竟整个世界无法变成 Lean 的值放入内存中。 然而,可以实现一个此模型的变体,它带有代表世界的抽象标识。 当程序启动时,它会提供一个世界标识。然后将此标识传递给 IO 原语, 之后它们的返回标识同样地传递到下一步。在程序结束时,标识将返回给操作系统。

这种副作用模型很好地描述了 IO 活动作为 RTS 要执行的任务描述是如何在 Lean 内部表示的。 用于转换现实世界的实际函数隐藏在抽象屏障之后。但实际的程序通常不只有一个作用, 而是由一系列作用组成。为了使程序能够使用多个作用,Lean 中有一个称为 do-记法的子语言, 它能够将这些原语 IO 活动安全地组合成一个更大、更有用的程序。

组合 IO 活动

大多数有用的程序除了产生输出外还要接受输入。此外,它们可以根据输入做出决策, 将输入数据用作计算的一部分。以下程序名为 HelloName.lean,它向用户询问他们的姓名,然后向他们问好:

def main : IO Unit := do
  let stdin ← IO.getStdin
  let stdout ← IO.getStdout

  stdout.putStrLn "How would you like to be addressed?"
  let input ← stdin.getLine
  let name := input.dropRightWhile Char.isWhitespace

  stdout.putStrLn s!"Hello, {name}!"

在此程序中,main 活动由一个 do 代码块组成。此块包含一系列的 语句(Statement), 它们既可以是局部变量(使用 let 引入),也可以是要执行的活动。 正如 SQL 可以被认为是与数据库交互的专用语言一样,do 语法可以被认为是 Lean 中的一个专用子语言,专门用于建模命令式程序。使用 do 块构建的 IO 活动通过按顺序执行语句来执行。

此程序可以与之前的程序一样的方式运行:

lean --run HelloName.lean

如果用户回应 David,则与程序交互的会话会读取回应:

How would you like to be addressed?
David
Hello, David!

它的类型签名与 Hello.lean 的类型签名一样:

def main : IO Unit := do

唯一的区别是它以关键字 do 结尾,该关键字会执行一系列命令。 关键字 do 后面的每一行缩进都是同一系列命令的一部分。

前两行,读取:

  let stdin ← IO.getStdin
  let stdout ← IO.getStdout

它们分别通过执行库中的活动 IO.getStdinIO.getStdout 来检索 stdinstdout 的勾柄(Handle)。在 do 块中,let 的含义与在普通表达式中略有不同。 通常,let 中的局部定义只能在一个表达式中使用,该表达式紧跟在局部定义之后。 在 do 块中,由 let 引入的局部绑定在 do 块其余部分的所有语句中都可用, 而不仅仅是下一个语句。此外,let 通常使用 := 将所定义的名称与其定义关联起来, 而 do 中的一些 let 绑定则使用向左箭头(<-)代替。 使用箭头表示表达式的值是一个 IO 活动,该活动应该被执行,活动的结果保存在局部变量中。 换句话说,如果箭头右侧的表达式的类型为 IO α,那么该变量在 do 块的其余部分中的类型为 αIO.getStdinIO.getStdoutIO 活动,以便允许在程序中局部覆盖 stdinstdout, 这很方便。如果它们是像 C 中那样的全局变量,那么将不存在有意义的方法来覆盖它们, 但是 IO 活动每次执行时都可以返回不同的值。

do 块的下一部分负责询问用户姓名:

  stdout.putStrLn "How would you like to be addressed?"
  let input ← stdin.getLine
  let name := input.dropRightWhile Char.isWhitespace

第一行将问题写入 stdout,第二行从 stdin 请求输入,第三行从输入行中删除尾部的换行符 (以及任何其他尾部空格)。name 的定义使用 := 而非 ,因为 String.dropRightWhile 是作用于字符串的普通函数,而非 IO 活动。

最后,程序中的最后一行是:

  stdout.putStrLn s!"Hello, {name}!"

它使用字符串插值 将提供的名称插入到问候字符串中,并将结果写入到 stdout

逐步执行

do 块可以逐行执行。从上一节的程序开始:

  let stdin ← IO.getStdin
  let stdout ← IO.getStdout
  stdout.putStrLn "How would you like to be addressed?"
  let input ← stdin.getLine
  let name := input.dropRightWhile Char.isWhitespace
  stdout.putStrLn s!"Hello, {name}!"

标准 IO

第一行是 let stdin ← IO.getStdin,其余部分是:

  let stdout ← IO.getStdout
  stdout.putStrLn "How would you like to be addressed?"
  let input ← stdin.getLine
  let name := input.dropRightWhile Char.isWhitespace
  stdout.putStrLn s!"Hello, {name}!"

要执行使用 let 语句,首先求值箭头右侧的表达式(在本例中为 IO.getStdIn)。 因为该表达式只是一个变量,所以查找它的值。结果值是一个内置的 IO 原语活动。 下一步是执行此 IO 活动,结果是一个表示标准输入流的值,其类型为 IO.FS.Stream。 然后将标准输入与箭头左侧的名称(此处为 stdin)关联,以用于 do 块的其余部分。"

执行第二行 let stdout ← IO.getStdout 的过程类似。 首先,求值表达式 IO.getStdout,得到一个 IO 活动,该活动将返回标准输出。 接下来,执行此活动,实际返回标准输出。最后,将此值与 do 块的其余部分关联起来,并命名为 stdout。"

提问

现在已经有了 stdinstdout,该代码块的其余部分包括一个问题和一个答案:

  stdout.putStrLn "How would you like to be addressed?"
  let input ← stdin.getLine
  let name := input.dropRightWhile Char.isWhitespace
  stdout.putStrLn s!"Hello, {name}!"

该代码块中的第一个语句 stdout.putStrLn "How would you like to be addressed?" 由一个表达式组成。要执行一个表达式,首先要对其进行求值。在这种情况下,IO.FS.Stream.putStrLn 的类型为 IO.FS.Stream → String → IO Unit。这意味着它是一个接受流和字符串并返回 IO 活动的函数。 该表达式使用访问器记法进行函数调用。 此函数应用于两个参数:标准输出流和字符串。表达式的值为一个 IO 活动, 该活动将字符串和换行符写入输出流。得到此值后,下一步是执行它,这会导致字符串和换行符写入到 stdout。仅由表达式组成的语句不会引入任何新变量。

下一条语句是 let input ← stdin.getLineIO.FS.Stream.getLine 的类型为 IO.FS.Stream → IO String, 这意味着它是一个从流到 IO 活动的函数,该函数将返回一个字符串。 同样,这也是访问器表示法的示例。此 IO 活动被执行时,程序会等待用户键入一行完整的输入。 假设用户输入了「David」,则结果行(「David\n」)会与 input 关联,其中转义序列 \n 表示换行符。

  let name := input.dropRightWhile Char.isWhitespace
  stdout.putStrLn s!"Hello, {name}!"

下一行 let name := input.dropRightWhile Char.isWhitespace 是一个 let 语句。 与本程序中的其他 let 语句不同,它使用 := 而不是 。这意味着将计算表达式, 但结果值不必是 IO 活动,并且不会执行。在这种情况下,String.dropRightWhile 接受一个字符串和一个字符的谓词,并返回一个新字符串,其中字符串末尾满足谓词的所有字符都会被删除。例如,

#eval "Hello!!!".dropRightWhile (· == '!')

会产生

"Hello"

#eval "Hello...   ".dropRightWhile (fun c => not (c.isAlphanum))

会产生

"Hello"

其中所有非字母数字的字符均已从字符串的右侧删除。在程序的当前行中, 空格符(包括换行符)从输入字符串的右侧删除,得到 「David」, 它在代码块的剩余部分与 name 关联。

向用户问好

do 块中剩余要执行的只有一条语句:

  stdout.putStrLn s!"Hello, {name}!"

传递给 putStrLn 的字符串参数通过字符串插值构建,生成字符串 "Hello, David!"。 由于此语句是一个表达式,因此它被求值以生成一个 IO 活动, 该活动会将此字符串后加上换行符打印到标准输出。表达式求值后,将执行生成的 IO 活动,从而生成问候语。

IO 活动作为值

在上面的描述中,可能很难看出为什么需要区分求值表达式和执行 IO 活动。 毕竟,每个活动在生成后都会立即执行。为什么不干脆在求值期间执行副作用, 就像在其他语言中所做的那样呢?

答案有两个。首先,将求值与执行分开意味着程序必须明确说明哪些函数可以产生副作用。 由于没有副作用的程序部分更适合数学推理,无论是在程序员的头脑中还是使用 Lean 的形式化证明工具, 这种分离可以更容易地避免错误。其次,并非所有 IO 活动都需要在它们产生时执行。 在不执行活动的情况下提及活动的能力能够将普通函数用作控制结构。

例如,函数 twiceIO 活动作为其参数,返回一个新的活动,该活动将第一个活动执行两次。

def twice (action : IO Unit) : IO Unit := do
  action
  action

例如,执行

twice (IO.println "shy")

会打印

shy
shy

这可以推广为一个通用函数,它可以运行底层活动任意次:

def nTimes (action : IO Unit) : Nat → IO Unit
  | 0 => pure ()
  | n + 1 => do
    action
    nTimes action n

Nat.zero 的基本情况下,结果是 pure ()。函数 pure 创建一个没有副作用的 IO 活动, 但返回 pure 的参数,在本例中是 Unit 的构造子。作为不执行任何活动且不返回任何有趣内容的活动, pure () 既非常无聊又非常有用。在递归步骤中,do 块用于创建一个活动,该活动首先执行 action, 然后执行递归调用的结果。执行 nTimes (IO.println "Hello") 3 会输出以下内容:

Hello
Hello
Hello

除了将函数用作控制结构体之外,IO 活动是一等值的事实意味着它们可以保存在数据结构中供以后执行。 例如,函数 countdown 接受一个 Nat 并返回一个未执行的 IO 活动列表,每个 Nat 对应一个:

def countdown : Nat → List (IO Unit)
  | 0 => [IO.println "Blast off!"]
  | n + 1 => IO.println s!"{n + 1}" :: countdown n

此函数没有副作用,并且不打印任何内容。例如,它可以应用于一个参数,并且可以检查结果活动列表的长度:

def from5 : List (IO Unit) := countdown 5

此列表包含六个元素(每个数字一个,外加一个对应零的 "Blast off!" 活动):

#eval from5.length
6

函数 runActions 接受一个活动列表,并构造一个按顺序运行所有活动的单个活动:

def runActions : List (IO Unit) → IO Unit
  | [] => pure ()
  | act :: actions => do
    act
    runActions actions

其结构本质上与 nTimes 相同,只是没有一个对每个 Nat.succ 执行的活动, 而是在每个 List.cons 下的活动将被执行。类似地,runActions 本身不会运行这些活动。 而是创建一个将要运行这些活动的新活动,并且该活动必须放置在将作为 main 的一部分执行的位置:

def main : IO Unit := runActions from5

运行此程序会产生以下输出:

5
4
3
2
1
Blast off!

当运行此程序时会发生什么?第一步是求值 main,它产生如下输出:

main
===>
runActions from5
===>
runActions (countdown 5)
===>
runActions
  [IO.println "5",
   IO.println "4",
   IO.println "3",
   IO.println "2",
   IO.println "1",
   IO.println "Blast off!"]
===>
do IO.println "5"
   IO.println "4"
   IO.println "3"
   IO.println "2"
   IO.println "1"
   IO.println "Blast off!"
   pure ()

产生的 IO 活动是一个 do 块。然后逐个执行 do 块的每个步骤,产生预期的输出。 最后一步 pure () 没有产生任何作用,它的存在只是因为 runActions 的定义需要一个基本情况。

练习

在纸上逐步执行以下程序:

def main : IO Unit := do
  let englishGreeting := IO.println "Hello!"
  IO.println "Bonjour!"
  englishGreeting

在逐步执行程序时,要察觉到何时正在求值表达式,以及何时正在执行 IO 活动。 当执行 IO 活动产生副作用时,请将其写下来。在纸上执行完毕后,使用 Lean 运行程序, 并仔细检查你对副作用的预测是否正确。

创建项目

随着 Lean 中编写的程序变得越来越复杂,基于提前编译器(Ahead-of-Time,AoT)的工作流变得更具吸引力, 因为它可以生成可执行文件。与其他语言类似,Lean 具有构建多文件包和管理依赖项的工具。 标准的 Lean 构建工具称为 Lake(「Lean Make」的缩写),它在 Lean 中进行配置。 正如 Lean 包含一门用于编写带副作用程序的特殊语言(do 语言)一样, Lake 也包含一门用于配置构建的特殊语言。 这些语言被称为 嵌入式领域专用语言(Embedded Domain-Specific Languages) (或有时称为 领域专用嵌入式语言(Domain-Specific Embedded Languages) ,缩写为 EDSL 或 DSEL)。 它们是 领域专用(Domain-Specific) 的,因为它们用于专用的目的,包含来自某个子领域的术语, 并且通常不适用于通用编程。它们是 嵌入式(Embedded) 的,因为它们出现在另一种语言的语法中。 虽然 Lean 包含丰富的用于创建 EDSL 的工具,但它们超出了本书的范围。

入门

要创建一个使用 Lake 的项目,请在一个不包含名为 greeting 的文件或目录的目录下执行命令 lake new greeting,这将创建一个名为 greeting 的目录,其中包含以下文件:

  • Main.lean 是 Lean 编译器将查找 main 活动的文件。
  • Greeting.leanGreeting/Basic.lean 是程序支持库的脚手架。
  • lakefile.lean 包含 lake 构建应用程序所需的配置。
  • lean-toolchain 包含用于项目的特定 Lean 版本的标识符。

此外,lake new 会将项目初始化为 Git 代码库,并配置其 .gitignore 文件以忽略构建过程的中间产物。 通常,应用程序逻辑的主要部分将位于程序的库集合中,而 Main.lean 则包含这些部分的一个小的包装, 它执行诸如解析命令行以及执行核心应用程序逻辑之类的活动。要在已存在的目录中创建项目, 请运行 lake init 而非 lake new

默认情况下,库文件 Greeting/Basic.lean 包含一个定义:

def hello := "world"

库文件 Greeting.lean 导入了 Greeting/Basic.lean

-- This module serves as the root of the `Greeting` library.
-- Import modules here that should be built as part of the library.
import «Greeting».Basic

这意味着在 Greetings/Basic.lean 中定义的所有内容也对导入 Greetings.lean 的文件可用。 在 import 语句中,点号被解释为磁盘上的目录。在名称周围放置引号,如 «Greeting», 能够让名称包含空格或其他通常不允许在 Lean 名称中出现的字符,并且它允许通过编写 «if»«def» 将保留关键字(如 ifdef)用作普通名称。 当提供给 lake new 的包名包含此类字符时,这可以防止出现问题。

可执行源文件 Main.lean 包含:

import «Greeting»

def main : IO Unit :=
  IO.println s!"Hello, {hello}!"

由于 Main.lean 导入了 Greetings.lean,而 Greetings.lean 导入了 Greetings/Basic.lean, 因此 hello 的定义可以在 main 中使用。

要构建包,请运行命令 lake build。 在滚动显示一些构建命令后,产生的二进制文件会被放置在 build/bin 中。 运行 ./build/bin/greeting 会输出 Hello, world!

Lakefile 构建文件

lakefile.lean 描述了一个 包(Package) ,它是一个连贯的 Lean 代码集合,用于分发, 类似于 npmnuget 包或 Rust 的 crate。一个包可以包含任意数量的库或可执行文件。 虽然 Lake 文档中描述了 lakefile 中的可用选项,但它使用了此处尚未描述的许多 Lean 特性。生成的 lakefile.lean 包含以下内容:

import Lake
open Lake DSL

package «greeting» where
  -- add package configuration options here

lean_lib «Greeting» where
  -- add library configuration options here

@[default_target]
lean_exe «greeting» where
  root := `Main
  -- Enables the use of the Lean interpreter by the executable (e.g.,
  -- `runFrontend`) at the expense of increased binary size on Linux.
  -- Remove this line if you do not need such functionality.
  supportInterpreter := true

此初始 Lakefile 由三项组成:

  • 一个 声明,名为 greeting
  • 一个 声明,名为 Greeting,以及
  • 一个 可执行文件 ,同样名为 greeting

这些名称中的每一个都用引号括起来,以允许用户在选择包名称时有更大的自由度。

每个 Lakefile 只会包含一个包,但可以包含任意数量的库或可执行文件。 此外,Lakefile 可能包含 外部库(External Library) (即并非用 Lean 编写的库,将与结果可执行文件静态链接)、 自定义目标(Custom Target) (即特定于具体执行平台的库/可执行文件的构建目标)、 依赖项(Dependency) (即其他 Lean 包的声明,可能来自本地或远程 Git 代码库)、 以及 脚本(Script) (本质上是类似于 mainIO 活动,但还可以访问有关包配置的元数据)。 Lakefile 中的项允许配置源文件位置、模块层次结构和编译器参数。不过一般来说,默认值就够用了。

库、可执行文件和自定义目标统称为 目标(Target) 。默认情况下,lake build 会构建那些标注了 @[default_target] 的目标。此标注是一个 属性(Attribute) , 它是一种可以与 Lean 声明关联的元数据。属性类似于 Java 中的注解或 C# 和 Rust 的特性。 它们在 Lean 中被广泛使用。要构建未标注 @[default_target] 的目标, 请在 lake build 后指定目标名称作为参数。

库与导入

一个 Lean 库由一个分层组织的源文件集合组成,可以从中导入名称,称为 模块(Module) 。 默认情况下,一个库有一个与它名称相同的单一根文件。在本例中, 库 Greeting 的根文件是 Greeting.leanMain.lean 的第一行是 import Greeting, 它使 Greeting.lean 的内容在 Main.lean 中可用。

可以通过创建一个名为 Greeting 的目录,并将文件放在里面来向库中添加额外的模块文件。 用点替换目录分隔符可以导入这些名称。例如,创建文件 Greeting/Smile.lean,其内容为:

def expression : String := "a big smile"

这意味着 Main.lean 可以使用如下定义:

import Greeting
import Greeting.Smile

def main : IO Unit :=
  IO.println s!"Hello, {hello}, with {expression}!"

模块名的层次结构与命名空间层次结构是分离的。在 Lean 中,模块是代码的分发单元, 而命名空间是代码的组织单元。也就是说,在模块 Greeting.Smile 中定义的名称不会自动出现在相应的命名空间 Greeting.Smile 中。 模块可以将某个名称放入任何它们想要的命名空间中,而导入它们的代码可以选择是否用 open 打开命名空间。import 用于使源文件的内容可用,而 open 可以让命名空间中的名称在当前上下文中可用, 而无需前缀。在 Lakefile 中,import Lake 会使 Lake 模块的内容可用, 而 open Lake DSL 使 LakeLake.DSL 命名空间的内容可用,而无需任何前缀。 此时 Lake.DSL 已被打开,因为打开 Lake 会让 Lake.DSL 可以只使用名字 DSL 访问, 就像 Lake 命名空间中的其他名称一样。Lake 模块将名称放入到了 LakeLake.DSL 命名空间中。

命名空间也可以 选择性 打开,只公开部分名称而无需显式前缀。 这可以通过在括号中写出所需名称来完成。例如,Nat.toFloat 将自然数转换为 Float。 可以使用 open Nat (toFloat) 将其公开为 toFloat

现实示例 cat

标准 Unix 实用程序 cat 接受多个命令行选项,后跟零个或多个输入文件。 如果没有提供文件,或者其中一个文件是横线(-),则它将标准输入作为相应的输入,而不是读取文件。 输入的内容将按顺序写入标准输出。如果指定的输入文件不存在,则会在标准错误中注明, 但 cat 会继续连接剩余的输入。如果任何输入文件不存在,则返回非零退出代码。

本节介绍了 cat 的简化版本,称为 feline。与 cat 的常用版本不同, feline 没有用于诸如对行编号、指示不可打印字符或显示帮助文本等功能的命令行选项。 此外,它无法从与终端设备关联的标准输入中多次读取。

要充分学习本节内容,请自己动手操作。复制粘贴代码示例是可以的,但最好手动输入它们。 这使得学习输入代码、从错误中恢复以及解释编译器反馈的机械过程变得更加容易。

开始

第一步是创建包并决定如何组织代码。在本例中,由于程序非常简单,所有代码都将放在 Main.lean 中。 首先运行 lake new feline。编辑 Lakefile 以删除库,并删除生成的库代码及其在 Main.lean 中的引用。完成后,lakefile.lean 应包含:

import Lake
open Lake DSL

package «feline» {
  -- add package configuration options here
}

@[default_target]
lean_exe «feline» {
  root := `Main
}

Main.lean 应包含类似以下内容:

def main : IO Unit :=
  IO.println s!"Hello, cats!"

或者,运行 lake new feline exe 指示 lake 使用不包含库部分的模板,从而无需编辑文件。

运行 lake build 确保可以构建代码。

连接流

现在已经构建了程序的基本框架,是时候实际输入代码了。cat 的正确实现可以与无限 IO 流(例如 /dev/random)一起使用,这意味着它不能在输出之前将其输入读到内存中。 此外,它不应一次处理一个字符,因为这会导致性能变差。 相反,最好一次读取连续的数据块,一次将数据定向到标准输出。

第一步是确定要读取的块的大小。为了简单起见,此实现使用保守的 20kb 字节块。 USize 类似于 C 中的 size_t,它是一个无符号整数类型,足以表示所有有效的数组大小。

def bufsize : USize := 20 * 1024

feline 的主要工作由 dump 完成,它一次读取一个块的输入,将结果转储到标准输出,直到抵达输入的末尾:

partial def dump (stream : IO.FS.Stream) : IO Unit := do
  let buf ← stream.read bufsize
  if buf.isEmpty then
    pure ()
  else
    let stdout ← IO.getStdout
    stdout.write buf
    dump stream

dump 函数被声明为 partial,因为它在输入上递归调用自身,该输入不会立即小于一个参数。 当一个函数被声明为 partial 时,Lean 不要求证明它会终止。另一方面,partial 函数也不太适合正确性证明,因为允许在 Lean 的逻辑中进行无限循环会使其不可靠(Sound)。 然而,我们没有办法证明 dump 会终止,因为无限输入(例如来自 /dev/random)意味着它实际上不会终止。 在这种情况下,除了将函数声明为 partial 之外别无选择。

类型 IO.FS.Stream 表示一个 POSIX 流。在幕后,它被表示为一个结构体, 该结构体为每个 POSIX 流活动提供一个字段。每个活动都表示为一个 IO 活动,它提供了相应的活动:

structure Stream where
  flush   : IO Unit
  read    : USize → IO ByteArray
  write   : ByteArray → IO Unit
  getLine : IO String
  putStr  : String → IO Unit

Lean 编译器包含 IO 活动(例如 IO.getStdout,它在 dump 中被调用)以获取表示标准输入、 标准输出和标准错误的流。这些都是是 IO 活动,而非普通定义,因为 Lean 允许在进程中替换这些标准 POSIX 流,这使得通过编写自定义 IO.FS.Stream 将程序的输出捕获到字符串中变得更容易。

dump 中的控制流本质上是一个 while 循环。当调用 dump 时,如果流已达到文件末尾, pure () 就会通过返回 Unit 的构造子来终止函数。如果流尚未达到文件末尾,则读取一个块, 并将它的内容写入 stdout,之后 dump 直接调用自身。递归调用会一直持续到 stream.read 返回一个空字节数组,这表示已达到文件末尾。

if 表达式作为 do 中的语句出现时,如 dump 中,if 的每个分支都会隐式地提供一个 do。 换句话说,跟在 else 之后的步骤序列会被视为要执行的 IO 活动序列,就像它们在开头有一个 do 一样。 在 if 分支中用 let 引入的名称只在其自己的分支中可见,而不在 if 之外的范围。

在调用 dump 时,不会出现耗尽堆栈空间的危险,因为递归调用发生在函数的最后一步, 并且其结果会被直接返回,而不会被活动或计算。这种递归称为 尾递归(Tail Recursion) , 将在本书后面的章节中详细描述。 由于编译后的代码不需要保留任何状态,因此 Lean 编译器可以将递归调用编译为跳转。

如果 feline 只将标准输入重定向到标准输出,那么 dump 就足够了。 但是,它还需要能够打开命令行参数提供的文件并输出其内容。当其参数是存在的文件名时, fileStream 返回读取文件内容的流。当参数不是文件时,fileStream 报告错误并返回 none

def fileStream (filename : System.FilePath) : IO (Option IO.FS.Stream) := do
  let fileExists ← filename.pathExists
  if not fileExists then
    let stderr ← IO.getStderr
    stderr.putStrLn s!"File not found: {filename}"
    pure none
  else
    let handle ← IO.FS.Handle.mk filename IO.FS.Mode.read
    pure (some (IO.FS.Stream.ofHandle handle))

打开一个文件作为流需要两个步骤。首先,通过以读取模式打开文件来创建一个文件勾柄。 Lean 文件勾柄跟踪了一个底层文件的描述符。当没有对文件勾柄值进行引用时, 收尾器(finalizer)会关闭文件描述符。其次,使用 IO.FS.Stream.ofHandle 为文件勾柄提供与 POSIX 流相同的接口,该接口会使用文件勾柄上工作的相应 IO 活动填充 Stream 结构体的每个字段。

处理输入

feline 的主循环是另一个尾递归函数,称为 process。为了在无法读取任何输入时返回非零退出代码, process 接受一个参数 exitCode,该参数表示整个程序的当前退出码。 此外,它还接受一个要处理的输入文件列表。

def process (exitCode : UInt32) (args : List String) : IO UInt32 := do
  match args with
  | [] => pure exitCode
  | "-" :: args =>
    let stdin ← IO.getStdin
    dump stdin
    process exitCode args
  | filename :: args =>
    let stream ← fileStream ⟨filename⟩
    match stream with
    | none =>
      process 1 args
    | some stream =>
      dump stream
      process exitCode args

if 一样,在 do 中作为语句使用的 match 的每个分支都隐式地提供了自己的 do

分支有三种可能的情况。一种是没有更多文件需要处理,此时,process 返回未更改的错误代码。 另一种是指定的文件名为 "-", 此时,process 转储标准输入的内容,然后处理剩余的文件名。 最后一种情况是指定了实际文件名。此时,fileStream 用于尝试将文件作为 POSIX 流打开。 它的参数被封装在 ⟨ ... ⟩ 中,因为 FilePath 是一个包含字符串的单字段结构体。 若无法打开文件,则跳过该文件,对process 的递归调用会将退出代码设置为 1; 若可以打开,则将其转储,对 process 的递归调用会将使退出代码保持不变。

process 无需标记为 partial,因为它在结构上是递归的。 每次递归调用都会提供输入列表的尾部,而所有的 Lean 列表都是有限的, 因此,process 不会引入任何非终止。

Main 活动

最后一步是编写 main 活动。与之前的示例不同,feline 中的 main 是一个函数。在 Lean 中,main 可以有三种类型之一:

  • main : IO Unit 对应于无法读取其命令行参数并始终以退代码 0 表示成功的程序,
  • main : IO UInt32 对应于 C 中的 int main(void),用于没有参数且返回退出码的程序,
  • main : List String → IO UInt32 对应于 C 中的 int main(int argc, char **argv), 用于获取参数并发出成功或失败信号的程序。

如果没有提供参数,feline 应从标准输入读取,就像使用单个 "-" 参数调用它一样。 否则,应依次处理参数。

def main (args : List String) : IO UInt32 :=
  match args with
  | [] => process 0 ["-"]
  | _ =>  process 0 args

喵!

要检查 feline 是否工作,第一步是用 lake build 构建它。首先,在没有参数的情况下调用它时,它应当返回从标准输入接收到的内容。检查

echo "It works!" | ./build/bin/feline

会返回 It works!.

其次,当以文件作为参数调用它时,它应该打印它们。如果文件 test1.txt 包含

It's time to find a warm spot

test2.txt 包含

and curl up!

那么命令

./build/bin/feline test1.txt test2.txt

应当返回

It's time to find a warm spot
and curl up!

最后,参数 - 应得到适当处理。

echo "and purr" | ./build/bin/feline test1.txt - test2.txt

应产生

It's time to find a warm spot
and purr
and curl up!

练习

扩展 feline 使其支持用法信息。扩展版本应接受命令行参数 --help, 产生关于可用命令行选项的文档并写入到标准输出。

其他便利功能

嵌套活动

feline 中的很多函数都表现出一种重复模式,其中 IO 操作的结果被赋予一个名称, 然后立即且仅使用一次。例如,在 dump 中:

partial def dump (stream : IO.FS.Stream) : IO Unit := do
  let buf ← stream.read bufsize
  if buf.isEmpty then
    pure ()
  else
    let stdout ← IO.getStdout
    stdout.write buf
    dump stream

该模式出现在 stdout 中:

    let stdout ← IO.getStdout
    stdout.write buf

同样,fileStream 包含以下片段

  let fileExists ← filename.pathExists
  if not fileExists then

当 Lean 编译 do 块时,由括号下方的左箭头组成的表达式会被提升到最近的封闭 do 中,并且其结果会被绑定到一个唯一的名称。这个唯一名称替换了原始的表达式。 这意味着 dump 也可以写成如下形式:

partial def dump (stream : IO.FS.Stream) : IO Unit := do
  let buf ← stream.read bufsize
  if buf.isEmpty then
    pure ()
  else
    (← IO.getStdout).write buf
    dump stream

此版本的 dump 避免了引入仅使用一次的名称,这可以极大地简化程序。 Lean 从嵌套表达式上下文中提升的 IO 活动称为 嵌套活动(Nested Action)

fileStream 也可以用相同的技巧来简化:

def fileStream (filename : System.FilePath) : IO (Option IO.FS.Stream) := do
  if not (← filename.pathExists) then
    (← IO.getStderr).putStrLn s!"File not found: {filename}"
    pure none
  else
    let handle ← IO.FS.Handle.mk filename IO.FS.Mode.read
    pure (some (IO.FS.Stream.ofHandle handle))

在这种情况下,局部名称 handle 也可以使用嵌套操作来消除, 但由此产生的表达式会长而复杂。尽管使用嵌套操作通常是一种良好的代码风格, 但有时对中间结果进行命名仍然很有帮助。

但需要记住的是,嵌套操作只是对包围在 do 块中的 IO 活动的一种简短记法。 执行它们所涉及的副作用仍然会以相同的顺序发生,并且副作用的执行不会与表达式的求值交替进行。 举个可能令人困惑的例子,请考虑以下辅助定义,它们在向世界宣布它们已被执行后才返回数据:

def getNumA : IO Nat := do
  (← IO.getStdout).putStrLn "A"
  pure 5

def getNumB : IO Nat := do
  (← IO.getStdout).putStrLn "B"
  pure 7

这些定义旨在表达更复杂的 IO 代码,这些代码可能用于验证用户输入、读取数据库或打开文件。

一个「当数字 A 为 5 时打印 0,否则打印数字 B」的程序可以写成如下形式:

def test : IO Unit := do
  let a : Nat := if (← getNumA) == 5 then 0 else (← getNumB)
  (← IO.getStdout).putStrLn s!"The answer is {a}"

不过,此程序可能具有比预期更多的副作用(例如提示用户输入或读取数据库)。 getNumA 的定义明确指出它将始终返回 5,因此程序不应读取数字 B。 然而,运行此程序会产生以下输出:

A
B
The answer is 0

getNumB 被执行是因为 test 等价于以下定义:

def test : IO Unit := do
  let x ← getNumA
  let y ← getNumB
  let a : Nat := if x == 5 then 0 else y
  (← IO.getStdout).putStrLn s!"The answer is {a}"

这是因为嵌套活动会被提升到最近的包含 do 块的规则。if 的分支没有被隐式地包装在 do 块中, 因为 if 本身不是 do 块中的语句,语句是定义 alet。 事实上,它们不能以这种方式包装,因为条件表达式的类型是 Nat,而非 IO Nat

do 的灵活布局

在 Lean 中,do 表达式对空格敏感。do 中的每个 IO 活动或局部绑定都应该从自己的行开始, 并且它们都应该有相同的缩进。几乎所有 do 的用法都应该这样写。 然而,在一些罕见的情况下,可能需要手动控制空格和缩进,或者在单行上有多个小的活动可能会很方便。 在这些情况下,换行符可以替换为分号,缩进可以替换为花括号。

例如,以下所有程序都是等价的:

-- This version uses only whitespace-sensitive layout
def main : IO Unit := do
  let stdin ← IO.getStdin
  let stdout ← IO.getStdout

  stdout.putStrLn "How would you like to be addressed?"
  let name := (← stdin.getLine).trim
  stdout.putStrLn s!"Hello, {name}!"

-- This version is as explicit as possible
def main : IO Unit := do {
  let stdin ← IO.getStdin;
  let stdout ← IO.getStdout;

  stdout.putStrLn "How would you like to be addressed?";
  let name := (← stdin.getLine).trim;
  stdout.putStrLn s!"Hello, {name}!"
}

-- This version uses a semicolon to put two actions on the same line
def main : IO Unit := do
  let stdin ← IO.getStdin; let stdout ← IO.getStdout

  stdout.putStrLn "How would you like to be addressed?"
  let name := (← stdin.getLine).trim
  stdout.putStrLn s!"Hello, {name}!"

地道的 Lean 代码极少使用带有 do 的大括号。

#eval 运行 IO 活动

Lean 的 #eval 命令可用于执行 IO 活动,而不仅仅是对它们进行求值。 通常,向 Lean 文件添加 #eval 命令会让 Lean 求值提供的表达式,然后将结果值转换为字符串, 并在工具提示和信息窗口中提供该字符串。#eval 不会因为 IO 活动无法转换为字符串而失败, 而是执行它们,并执行它们的副作用。如果执行结果是 Unit(),则不显示结果字符串, 但如果它是可以转换为字符串的类型,则 Lean 会显示结果值。

这意味着,给定 countdownrunActions,

#eval runActions (countdown 3)

会显示

3
2
1
Blast off!

这是运行 IO 活动产生的输出,而不是活动本身的不透明表示。 换句话说,对于 IO 活动,#eval求值(Evaluate) 提供的表达式, 又 执行(Execute) 结果活动值。

使用 #eval 快速测试 IO 动作比编译和运行整个程序方便得多,只是有一些限制。 例如,从标准输入读取只会返回空输入。此外,每当 Lean 需要更新它提供给用户的诊断信息时, IO 动作都会重新执行,这可能会在难以预料的时间发生。 例如,读取和写入文件的动作可能会在不合适的时间执行。

总结

求值与执行

副作用是程序执行中超出数学表达式求值范围的部分,例如读取文件、抛出异常或驱动工业机械。 虽然大多数语言允许在求值期间发生副作用,但 Lean 不会。相反,Lean 有一个名为 IO 的类型, 它表示使用副作用的程序的 描述(Description) 。然后由语言的运行时系统执行这些描述, 该系统会调用 Lean 表达式求值器来执行特定计算。类型为 IO α 的值称为 IO 活动 。 最简单的是 pure,它返回其参数并且没有实际副作用。

IO 操作还可以理解为将整个世界作为参数并返回一个副作用已经发生的全新世界的函数。在幕后, IO 库确保世界永远不会被复制、创建或销毁。虽然这种副作用模型实际上无法实现, 因为整个宇宙太大而无法放入内存,但现实世界可以用一个在程序中传递的令牌来表示。

IO 活动 main 会在程序启动时执行。main 可拥有以下三种类型:

  • main : IO Unit 用于无法读取其命令行参数且始终返回退出码 0 的简单程序,
  • main : IO UInt32 用于没有参数的程序,该程序可能会发出成功或失败信号,以及
  • main : List String → IO UInt32 用于获取命令行参数并发出成功或失败信号的程序。

do 记法

Lean 标准库提供了许多基本 IO 活动,表示诸如读写文件以及与标准输入和标准输出交互之类的作用。 这些基本 IO 活动使用 do 语法组合成更大的 IO 活动, do 语法是用于编写具有副作用的程序描述的内置领域专用语言。

do 表达式包含一系列 语句(Statement) ,这些语句可以是:

  • 表示 IO 活动的表达式,
  • 使用 let:= 的普通局部定义,该定义的名称引用了所提供表达式的值,或者
  • 使用 let 的局部定义,该定义的名称引用了执行所提供表达式的结果值。

使用 do 编写的 IO 活动一次只执行一条语句。

此外,直接出现在 do 下面的 ifmatch 表达式隐式地被认为在每个分支中都有自己的 do。 在 do 表达式内部, 嵌套活动(Nested Action) 是括号下紧跟左箭头的表达式。 Lean 编译器会隐式地将它们提升到最近的封闭 do 中,该 do 可能隐式地是 matchif 表达式分支的一部分,并为它们提供一个唯一的名称。然后,此唯一名称将替换嵌套活动的原始位置。

编译并运行程序

一个由包含 main 定义的单个文件组成的 Lean 程序可以使用 lean --run FILE 运行。 虽然这可能是开始使用简单程序的好方法,但大多数程序最终都会升级为多文件项目, 在运行之前应该对其进行编译。

Lean 项目被组织成 包(Package) ,它们是库和可执行文件的集合,以及相关的依赖项和构建配置的信息。 包使用 Lean 构建工具 Lake 来描述。使用 lake new 可在新目录中创建一个 Lake 包, 或使用 lake init 在当前目录中创建一个包。Lake 包配置是另一种领域专用的语言。 可使用 lake build 构建项目。

偏函数

遵循表达式求值的数学模型的一个结果,就是每个表达式都必定有一个值。 这排除了不完全的模式匹配(即无法覆盖数据类型的全部构造子)和可能陷入无限循环的程序。 Lean 确保了所有 match 表达式会涵盖所有情况,并且所有递归函数要么是结构化递归的, 要么具有明确的停机证明。

然而,一些现实的程序需要编写无限循环的能力,因为它们需要处理潜在的无限数据,例如 POSIX 流。 Lean 提供了一个逃生舱:标记为 partial偏函数(Partial Function) 定义不需要终止。 然而这是有代价的,由于类型是 Lean 语言的一等部分,所以函数可以返回类型。 然而,偏函数在类型检查期间不会被求值,因为函数中的无限循环可能会导致类型检查器进入死循环。 此外,数学证明无法检查偏函数的定义,这意味着使用它们的程序更难进行形式化证明。

插曲:命题、证明与索引

与许多语言一样,Lean 使用方括号对数组和列表进行索引。 例如,若 woodlandCritters 定义如下:

def woodlandCritters : List String :=
  ["hedgehog", "deer", "snail"]

则可以提取各个组件:

def hedgehog := woodlandCritters[0]
def deer := woodlandCritters[1]
def snail := woodlandCritters[2]

然而,试图提取第四个元素会导致编译时错误,而非运行时错误:

def oops := woodlandCritters[3]
failed to prove index is valid, possible solutions:
  - Use `have`-expressions to prove the index is valid
  - Use `a[i]!` notation instead, runtime check is perfomed, and 'Panic' error message is produced if index is not valid
  - Use `a[i]?` notation instead, result is an `Option` type
  - Use `a[i]'h` notation instead, where `h` is a proof that index is valid
⊢ 3 < List.length woodlandCritters

此错误消息表明 Lean 尝试自动数学证明 3 < List.length oodlandCritters, 这意味着查找是安全的,但它无法做到。越界错误是一类常见的错误,而 Lean 会利用其作为编程语言和定理证明器的双重特性来排除尽可能多的错误。

要理解它是如何工作的,需要理解三个关键概念:命题、证明与策略。

命题与证明

命题(Proposition) 是可以为真或为假的陈述句。以下所有句子都是命题:

  • 1 + 1 = 2
  • 加法满足交换律
  • 质数有无穷多个
  • 1 + 1 = 15
  • 巴黎是法国的首都
  • 布宜诺斯艾利斯是韩国的首都
  • 所有鸟都会飞

另一方面,无意义的陈述不是命题。以下都不是命题:

  • 1 + 绿色 = 冰激凌
  • 所有首都都是质数
  • 至少有一个韟韚是一个棴囄䪖

命题有两种类型:纯粹的数学命题,仅依赖于我们对概念的定义;以及关于世界的事实。 像 Lean 这样的定理证明器关注的是前一类,而对企鹅的飞行能力或城市的法律地位无话可说。

证明(Proof) 是说明命题是否为真的令人信服的论证。对于数学命题, 这些论证利用了所涉及概念的定义以及逻辑论证规则。 大多数证明都是为人的理解而写的,并省略了许多繁琐的细节。 像 Lean 这样的计算机辅助定理证明器旨在允许数学家在省略许多细节的情况下编写证明, 而软件负责填写缺失的明显步骤。这降低了疏忽或出错的可能性。

在 Lean 中,程序的类型描述了与它交互的方式。例如,类型为 Nat → List String 的程序是一个函数,它接受一个 Nat 参数并生成一个字符串列表。 换句话说,每个类型都指定了具有该类型的程序的内容。

在 Lean 中,命题即是类型。它们指定了语句为真的证据应有的内容。 通过提供此证据即可证明命题。另一方面,如果命题为假,则不可能构造此证据。

例如,命题「1 + 1 = 2」可以直接写在 Lean 中。此命题的证据是构造子 rfl, 它是 自反性(Reflexivity) 的缩写:

def onePlusOneIsTwo : 1 + 1 = 2 := rfl

另一方面,rfl 不能证明错误命题「1 + 1 = 15」:

def onePlusOneIsFifteen : 1 + 1 = 15 := rfl
type mismatch
  rfl
has type
  1 + 1 = 1 + 1 : Prop
but is expected to have type
  1 + 1 = 15 : Prop

此错误消息表明,当等式语句的两边已经是相同的数字时,rfl 可以证明两个表达式相等。 因为 1 + 1 直接计算为 2,所以它们被认为是相同的,这允许接受 onePlusOneIsTwo。 就像 Type 描述了表示数据结构和函数的类型(例如 NatStringList (Nat × String × (Int → Float)))一样,Prop 描述了命题。

当一个命题被证明后,它被称为一个 定理(Theorem) 。 在 Lean 中,惯例是用 theorem 关键字而非 def 来声明定理。 这有助于读者看出哪些声明旨在被解读为数学证明,哪些是定义。 一般来说,对于一个证明,重要的是有证据表明一个命题是正确的, 但提供 哪个 个证据并不特别重要。另一方面,对于定义,选择哪个特定值非常重要。 毕竟,一个总是返回 0 的加法定义显然是错误的。

前面的例子可以改写如下:

def OnePlusOneIsTwo : Prop := 1 + 1 = 2

theorem onePlusOneIsTwo : OnePlusOneIsTwo := rfl

策略

证明通常使用 策略(Tactic) 来编写,而非直接提供证据。策略是为命题构建证据的小程序。 这些程序在一个 证明状态(Proof Statemen) 中运行,该状态跟踪要证明的陈述(称为 目标(Goal)) 以及可用于证明它的假设。在目标上运行策略会产生一个包含新目标的新证明状态。 当所有目标都被证明后,证明就完成了。

要使用策略编写证明,请以 by 开始定义。编写 by 会将 Lean 置于策略模式, 直到下一个缩进块的末尾。在策略模式下,Lean 会持续提供有关当前证明状态的反馈。 使用策略编写的 onePlusOneIsTwo 仍然很短:

theorem onePlusOneIsTwo : 1 + 1 = 2 := by
  simp

simp 策略,即「化简(Simplify)」的缩写,是 Lean 证明的主力。 它将目标重写为尽可能简单的形式,处理足够小的证明部分。特别是,它用于证明简单的相等陈述。 在幕后,它会构建一个详细的形式化证明,但使用 simp 隐藏了这种复杂性。

策略在许多方面很有用:

  1. 许多证明在写到最小的细节时都很复杂且乏味,而策略可以自动完成这些无趣的部分。
  2. 使用策略编写的证明更容易维护,因为灵活的自动化可以弥补定义的细微更改。
  3. 由于一个策略可以证明许多不同的定理,Lean 可以使用幕后的策略来解放用户亲手写证明。 例如,数组查找需要证明索引在范围内,而策略通常可以在用户无需担心它的情况下构造该证明。

在幕后,索引记法使用策略来证明用户的查找操作是安全的。 这个策略是 simp,它被配置为考虑某些算术恒等式。

连词

逻辑的基本构建块,例如「与」、「或」、「真」、「假」和「非」,称为 逻辑连词(Logical Connective)。 每个连词定义了什么算作其真值的证据。例如,要证明一个陈述「AB」,必须证明 AB。 这意味着「AB」的证据是一对,其中包含 A 的证据和 B 的证据。 类似地,「AB」的证据由 A 的证据或 B 的证据组成。

特别是,大多数这些连词都像数据类型一样定义,并且它们有构造子。若 AB 是命题, 则「AB」(写作 A ∧ B)也是一个命题。 A ∧ B 的证据由构造子 And.intro 组成, 其类型为 A → B → A ∧ B。用具体命题替换 AB, 可以用 And.intro rfl rfl 证明 1 + 1 = 2 ∧ "Str".append "ing" = "String"。 当然,simp 也足够强大到可以找到这个证明:

theorem addAndAppend : 1 + 1 = 2 ∧ "Str".append "ing" = "String" := by simp

与此类似,「AB」(写作 A ∨ B)有两个构造子, 因为「AB」的证明仅要求两个底层命题中的一个为真。它有两个构造子: Or.inl, 类型为 A → A ∨ B, 以及 Or.inr, 类型为 B → A ∨ B

蕴含(若 AB)使用函数表示。特别是,将 A 的证据转换为 B 的证据的函数本身就是 A 蕴涵 B 的证据。这与蕴涵的通常描述不同, 其中 A → B¬A ∨ B 的简写,但这两个式子是等价的。

由于「与」的证据是一个构造子,所以它可以与模式匹配一起使用。 例如,证明 AB 蕴涵 AB 的证明是一个函数, 它从 AB 的证据中提取 A(或 B)的证据,然后使用此证据来生成 AB 的证据:

theorem andImpliesOr : A ∧ B → A ∨ B :=
  fun andEvidence =>
    match andEvidence with
    | And.intro a b => Or.inl a
连词Lean 语法证据
TrueTrue.intro : True
False无证据
ABA ∧ BAnd.intro : A → B → A ∧ B
ABA ∨ BOr.inl : A → A ∨ BOr.inr : B → A ∨ B
A 蕴含 BA → BA 的证据转换到 B 的证据的函数
A¬AA 的证据转换到 False 的证据的函数

The simp 策略可以证明使用了这些连接词的定理。例如:

theorem onePlusOneAndLessThan : 1 + 1 = 2 ∨ 3 < 5 := by simp
theorem notTwoEqualFive : ¬(1 + 1 = 5) := by simp
theorem trueIsTrue : True := True.intro
theorem trueOrFalse : True ∨ False := by simp
theorem falseImpliesTrue : False → True := by simp

证据作为参数

尽管 simp 在证明涉及特定数字的等式和不等式的命题时表现出色, 但它在证明涉及变量的语句时效果不佳。例如,simp 可以证明 4 < 15, 但它不能轻易地判断出因为 x < 4,所以 x < 15 也成立。由于索引记法在幕后使用 simp 来证明数组访问是安全的,因此它可能需要一些人工干预。

要让索引记法正常工作的最简单的方式之一,就是让执行数据结构查找的函数将所需的安全性证据作为参数。 例如,返回列表中第三个条目的函数通常不安全,因为列表可能包含零、一或两个条目:

def third (xs : List α) : α := xs[2]
failed to prove index is valid, possible solutions:
  - Use `have`-expressions to prove the index is valid
  - Use `a[i]!` notation instead, runtime check is perfomed, and 'Panic' error message is produced if index is not valid
  - Use `a[i]?` notation instead, result is an `Option` type
  - Use `a[i]'h` notation instead, where `h` is a proof that index is valid
α : Type ?u.3908
xs : List α
⊢ 2 < List.length xs

然而,可以通过添加一个参数来强制调用者证明列表至少有三个条目,该参数包含索引操作安全的证据:

def third (xs : List α) (ok : xs.length > 2) : α := xs[2]

在本例中,xs.length > 2 并不是一个检查 xs 是否有 2 个以上条目的程序。 它是一个可能是真或假的命题,参数 ok 必须是它为真的证据。

当函数在一个具体的列表上调用时,它的长度是已知的。在这些情况下,by simp 可以自动构造证据:

#eval third woodlandCritters (by simp)
"snail"

无证据索引

在无法证明索引操作在边界内的情况下,还有其他选择。添加一个问号会产生一个 Option, 如果索引在边界内,结果为 some,否则为 none。例如:

def thirdOption (xs : List α) : Option α := xs[2]?

#eval thirdOption woodlandCritters
some "snail"
#eval thirdOption ["only", "two"]
none

还有一个版本,当索引超出边界时会使程序崩溃,而非返回一个 Option

#eval woodlandCritters[1]!
"deer"

小心!因为使用 #eval 运行的代码在 Lean 编译器的上下文中运行, 选择错误的索引可能会使你的 IDE 崩溃。

你可能会遇到的信息

除了 Lean 在找不到编译时证据而无法证明索引操作安全时产生的错误之外, 使用不安全索引的多态函数可能会产生以下消息:

def unsafeThird (xs : List α) : α := xs[2]!
failed to synthesize instance
  Inhabited α

这是由于技术限制,该限制是将 Lean 同时用作证明定理的逻辑和编程语言的一部分。 特别是,只有类型中至少包含一个值的程序才允许崩溃。 这是因为 Lean 中的命题是一种对真值证据进行分类的类型。假命题没有这样的证据。 如果具有空类型的程序可能崩溃,那么该崩溃程序可以用作对假命题的一种假的证据。

在内部,Lean 包含一个已知至少有一个值的类型的表。此错误表明某个任意类型 α 不一定在该表中。 下一章描述如何向此表添加内容,以及如何成功编写诸如 unsafeThird 之类的函数。

在列表和用于查找的括号之间添加空格会产生另一条消息:

#eval woodlandCritters [1]
function expected at
  woodlandCritters
term has type
  List String

添加空格会导致 Lean 将表达式视为函数应用,并将索引视为包含单个数字的列表。 此错误消息是由 Lean 尝试将 woodlandCritters 视为函数而产生的。

练习

  • 使用 rfl 证明以下定理:2 + 3 = 515 - 8 = 7"Hello, ".append "world" = "Hello, world"。 如果使用 rfl 证明 5 < 18 会发生什么?为什么?
  • 使用 by simp 证明以下定理:2 + 3 = 515 - 8 = 7"Hello, ".append "world" = "Hello, world"5 < 18
  • 编写一个函数,用于查找列表中的第五个条目。将此查找安全的证据作为参数传递给函数。

重载与类型类

在许多语言中,内置数据类型有特殊的优待。 例如,在 C 和 Java 中,+ 可以被用于 floatint,但不能用于其他第三方库的数字。 类似地,数字字面量可以被直接用于内置类型,但是不能用于用户定义的数字类型。 其他语言为运算符提供 重载(overloading) 机制,使得同一个运算符可以在新的类型有意义。 在这些语言中,比如 C++ 和 C#,多种内置运算符都可以被重载,编译器使用类型检查来选择一个特定的实现。

除了数字字面量和运算符,许多语言还可以重载函数或方法。 在 C++,Java,C# 和 Kotlin 中,对于不同的数字和类型参数,一个方法可以有多种实现。 便是其使用参数的数字和它们的类型来决定使用哪个重载。

函数和运算符的重载有一个关键的受限之处:多态函数无法限定它们的类型参数为重载存在的那些类型。 例如,一个重载方法可能在字符串,字节数组和文件指针上有定义,但是没有任何方法能写第二个方法能在任意这些类型上适用。 如果想这样做的话,这第二个方法必须本身也为每一个类型都有一个原始方法的重载,最终产生许多繁琐的定义而不是一个简单的多态定义。 这种限制的另一个后果是一些运算符(例如 Java 中的等号)对 每一个 参数组合都要有定义,即使这样做是完全没必要的。 如果程序员没有很谨慎的话,这可能会导致程序在运行时崩溃,或者静静地计算出错误的结果。

Lean 用 类型类(type classes) 机制(源于 Haskell)来实现重载。 这使得运算符,函数和字面量重载与多态有一个很好的配合。 一个类型类描述了一族可重载的运算符。 要将这些运算符重载到新的类型上,你需要创建一个包含对新类型的每一个运算的实现方式的 实例(instance) 。 例如,Add 类型类描述了可加的类型,一个对 Nat 类型的 Add 实例提供了 Nat 上加法的实现。

实例 这两个词可能会使面向对象程序员感到混淆,因为 Lean 中的它们与面向对象语言中的类和实例关系不大。 然而,它们有相同的基本性质:在日常语言中,“类”这个词指的是具有某些共同属性的组。 虽然面向对象编程中的类确实描述了具有共同属性的对象组,但该术语还指代描述此类对象组的特定编程语言机制。 类型类也是描述共享共同属性的类型(即某些操作的实现)的一种方式,但它们与面向对象编程中的类并没有其他共同点。

Lean 的类型类更像是 Java 或 C# 中的 接口(interface)。 类型类和接口都描述了在概念上有联系的运算的集合,这些运算为一个类型或一个类型集合实现。 类似地,类型类的实例也很像 Java 或 C# 中描述实现了的接口的类,而不是 Java 或 C# 中类的实例。 不像 Java 或 C# 的接口,对于一个类型,该类型的作者并不能访问的类型类也可以给这个类型实例。 从这种意义上讲,这和 Rust 的 traits 很像。

正数

在一些应用场景下,我们只需要用到正数。 对于编译器和解释器来说,它们通常使用起始于1的行和列数来表示源代码位置, 并且一个用于表示非空列表的数据结构永远不会出现长度为零的情况。

一种表示正数的方法其实和 Nat 十分相似,只是用 one 作为基本情况而不是 zero

inductive Pos : Type where
  | one : Pos
  | succ : Pos → Pos

这个数据类型很好的代表了我们期望的值的集合,但是它用起来并不是很方便。比如说,无法使用数字字面量。

def seven : Pos := 7
failed to synthesize instance
  OfNat Pos 7

而是必须要直接使用构造子。

def seven : Pos :=
  Pos.succ (Pos.succ (Pos.succ (Pos.succ (Pos.succ (Pos.succ Pos.one)))))

类似地,加法和乘法用起来也很费劲。

def fourteen : Pos := seven + seven
failed to synthesize instance
  HAdd Pos Pos ?m.291
def fortyNine : Pos := seven * seven
failed to synthesize instance
  HMul Pos Pos ?m.291

这类错误都会以 failed to synthesize instance 开头。这意味着这个错误是因为使用的操作符重载还没有被实现, 并且指出了应该实现的类型类。

类与实例

一个类型类是由名称,一些参数,和一族 方法(method) 构成的。参数定义了可重载运算符的类型, 而方法则是可重载运算符的名称和类型签名。这里再次出现了与面向对象语言之间的术语冲突。在面向对象编程中, 一个方法本质上是一个与内存中的一个特定对象有关联的函数,并且具有访问该对象的私有状态的特权。我们通过方法与对象进行交互。 在 Lean 中,“方法”这个词项指一个被声明为可重载的运算符,与对象、值或是私有字段并无特殊关联。

一种重载加法的方法是定义一个名为 Plus 的类型类,其加法方法名为 plus。 一旦为 Nat 定义了 Plus 的实例,就使得用 Plus.plus 将两个 Nat 相加成为可能:

#eval Plus.plus 5 3
8

添加更多的实例可以使 Plus.plus 能够接受更多类型的参数

在下面的类型类声明中,Plus 是类的名称,α : Type 是唯一的参数,并且 plus : α → α → α 是唯一的方法:

class Plus (α : Type) where
  plus : α → α → α

此声明表示存在类型类 Plus,它对类型 α 的操作进行重载。 具体到这段代码,存在一个称为 plus 的重载操作,它接受两个 α 并返回一个 α

类型类是一等公民,就像类型是一等公民一样。 我们更可以说,类型类是另一种类型。 Plus 的类型是 Type → Type,因为它获取一个类型作为参数(α),并导致一个新类型,它描述了 Plus 的运算符对于 α 的重载。

写一个实例来为特定类型重载 Plus

instance : Plus Nat where
  plus := Nat.add

instance 后跟的冒号暗示了 Plus Nat 的确是一个类型。 Plus 类中的每个方法都要用 := 来赋值。 在这个例子中,只有一个 plus 方法。

默认情况下,类型类方法在与类型类同名的命名空间中定义。 如果将该命名空间打开(使用 open 指令)会使该方法使用起来十分方便——这样用户就不用先输入类名了。 open 指令后跟的括号表示只有括号内指定的名称才可以被访问。

open Plus (plus)

#eval plus 5 3
8

Pos 定义一个加法函数和一个 Plus Pos 的实例,这样就可以使用 plus 来相加 PosNat 值。

def Pos.plus : Pos → Pos → Pos
  | Pos.one, k => Pos.succ k
  | Pos.succ n, k => Pos.succ (n.plus k)

instance : Plus Pos where
  plus := Pos.plus

def fourteen : Pos := plus seven seven

因为我们还没有 Plus Float 的实例, 所以尝试使用 plus 将两个浮点数相加会得到类似的错误信息:

#eval plus 5.2 917.25861
failed to synthesize instance
  Plus Float

这个报错意味着对于所给的类型类,Lean 并不能找到一个实例。

重载加法

Lean 的内置加法运算符是 HAdd 类型类的语法糖,这使加法运算符可以灵活的接受不同类型的参数。 HAdd异质加法(Heterogeneous Addition) 的缩写。 比如说,我们可以写一个 HAdd 实例来允许 NatFloat 相加,其结果为一个新的 Float。 当程序员写了 x + y 时,它会被解释为 HAdd.hAdd x y

虽然对 HAdd 的全部通用性的理解依赖于另一章节中讨论的特性,但还有一个更简单的类型类叫做 Add,它不允许出现不同类型的参数。 Lean 库被设置成当搜索一个 HAdd 实例时,如果两个参数具有相同的类型,就会找到一个 Add 的实例。

定义一个 Add Pos 的实例来让 Pos 类型的值可以使用常规的加法语法。

instance : Add Pos where
  add := Pos.plus

def fourteen : Pos := seven + seven

转换为字符串

另一个有用的内置类叫做 ToStringToString 的实例提供了一种将给定类型转换为字符串的标准方式。 例如,当值出现在插值字符串中时,会使用 ToString 实例。它决定了在IO 的描述开始处使用的 IO.println 函数如何显示一个值。

例如,一种将 Pos 转换为 String 的方式就是解析它的内部结构。 函数 posToString 接受一个决定是否给Pos.succ加上括号的 Bool 值,该值在第一次调用时应该为 true,在后续递归调用中应该为 false

def posToString (atTop : Bool) (p : Pos) : String :=
  let paren s := if atTop then s else "(" ++ s ++ ")"
  match p with
  | Pos.one => "Pos.one"
  | Pos.succ n => paren s!"Pos.succ {posToString false n}"

使用这个函数作为 ToString 的一个实例:

instance : ToString Pos where
  toString := posToString true

会产生信息丰富但可能过于冗长的输出:

#eval s!"There are {seven}"
"There are Pos.succ (Pos.succ (Pos.succ (Pos.succ (Pos.succ (Pos.succ Pos.one)))))"

另一方面,每个正数都有一个对应的 Nat。 将其转换为 Nat,然后使用 ToString Nat 实例(即对 NattoString 重载)是一种生成更简短输出的快捷方法:

def Pos.toNat : Pos → Nat
  | Pos.one => 1
  | Pos.succ n => n.toNat + 1

instance : ToString Pos where
  toString x := toString (x.toNat)

#eval s!"There are {seven}"
"There are 7"

当定义了多个实例时,最近的实例优先级最高。 此外,如果一个类型有一个 ToString 实例,那么它可以用来显示 #eval 的结果,即使该类型并没有使用 deriving Repr 定义,所以 #eval seven 输出 7

重载乘法运算符

对乘法来说,也有一个被称为 HMul 的类型类可以接受不同类型的参数相乘,就像 HAdd 一样。 就像 x + y 会被解释为 HAdd.hAdd x yx * y 也会被解释为 HMul.hMul x y。 对于两个相同类型的参数的乘法的常见情况,一个 Mul 实例就足够了。

实现一个 Mul 的实例就可以使常规乘法语法被用于 Pos 类型:

def Pos.mul : Pos → Pos → Pos
  | Pos.one, k => k
  | Pos.succ n, k => n.mul k + k

instance : Mul Pos where
  mul := Pos.mul

有了这个实例,乘法就会按我们预想的方式进行了:

#eval [seven * Pos.one,
       seven * seven,
       Pos.succ Pos.one * seven]
[7, 49, 14]

数字字面量

写一串构造子来表示正数是非常不方便的。 一种解决问题的方法是提供一个将 Nat 转换为 Pos 的函数。 然而,这种方法也有不足。 首先,因为 Pos 并不能表示 0,用来表示结果的函数要么将 Nat 转换为更大的数字,要么就需要返回 Option Pos。 这两种方式对用户来说都非常不方便。 其次,需要显式调用函数会让使用正数的程序不如使用 Nat 的程序那么方便。 在精确的类型和方便的 API 之间权衡一下后,精确的类型还是没那么有用。

Lean 是通过使用一个叫做 OfNat 的类型类来解释数字字面量的:

class OfNat (α : Type) (_ : Nat) where
  ofNat : α

这个类型类接受两个参数:α 是需要重载自然数的类型,未命名的 Nat 类型参数是你希望在程序中实际使用的数字字面量。 ofNat 方法被用作数字字面量的值。 由于原类包含了 Nat 参数,因此可以仅为那些使数字有意义的值定义实例。

OfNat展示了类型类的参数不需要是类型。 因为在 Lean 中,类型是语言中的一等公民,可以作为参数传递给函数,并且可以使用 defabbrev 给出定义。 Lean 并不阻止非类型参数出现在类型类的参数位置上,但一些不够灵活的语言则不允许这种操作。 这种灵活性能为特定的值以及特定的类型提供运算符重载。

例如,一个表示小于4的自然数的和类型可被定义如下:

inductive LT4 where
  | zero
  | one
  | two
  | three
deriving Repr

然而,并不是 每个 数字字面量对于这个类型都是合理的,只有小于4的数是合理的:

instance : OfNat LT4 0 where
  ofNat := LT4.zero

instance : OfNat LT4 1 where
  ofNat := LT4.one

instance : OfNat LT4 2 where
  ofNat := LT4.two

instance : OfNat LT4 3 where
  ofNat := LT4.three

有了上面的实例,我们就可以使用它们了:

#eval (3 : LT4)
LT4.three
#eval (0 : LT4)
LT4.zero

另一方面,越界的字面量也是不行的。

#eval (4 : LT4)
failed to synthesize instance
OfNat LT4 4

对于 Pos 来说,OfNat 实例应该适用于除 Nat.zero 外的 任何 Nat。 另一种表达方式是说,对于所有的自然数 n,该实例应该适用于 n + 1。 就像 α 这样的名称会自动成为 Lean 自动填充的函数的隐式参数一样,实例也可以接受自动隐式参数。 在这个实例中,参数 n 代表任何 Nat,并且该实例是为一个比给定 Nat 大一的 Nat 定义的:

instance : OfNat Pos (n + 1) where
  ofNat :=
    let rec natPlusOne : Nat → Pos
      | 0 => Pos.one
      | k + 1 => Pos.succ (natPlusOne k)
    natPlusOne n

因为 n 代表的数比用户实际写的要小一,所以辅助函数 natPlusOne 返回一个比它的参数大一的 Pos。这使得用自然数字面量表示正数成为可能,同时不会表示零:

def eight : Pos := 8

def zero : Pos := 0
failed to synthesize instance
  OfNat Pos 0

练习

另一种表示

另一种方式来表示正数是用某个 Nat 的后继。 用一个名为 succ 的结构体替换 Pos 的定义,该结构体包含一个 Nat

structure Pos where
  succ ::
  pred : Nat

定义 AddMulToString,和 OfNat的实例来让这个版本的 Pos 用起来更方便。

偶数

定义一个只表示偶数的数据类型。定义AddMul,和 ToString来让它用起来更方便。 定义 OfNat 需要下一节中介绍的特性。

HTTP 请求

一个 HTTP 请求以一个 HTTP 方法的标识开始,比如 GETPOST,还包括一个 URI 和一个 HTTP 版本。 定义一个归纳类型,代表 HTTP 方法的一个有趣的子集,并且定义一个表示 HTTP 响应的结构体。 响应应该有一个 ToString 实例,使得可以对其进行调试。 使用一个类型类来将不同的 IO 操作与每个 HTTP 方法关联起来, 并编写一个测试工具作为一个 IO 操作,调用每个方法并打印结果。

类型类与多态

编写适用于给定函数的 任意 重载可能会很有用。 例如,IO.println 适用于任何具有 ToString 实例的类型。这通过在所需实例周围使用方括号来表示: IO.println 的类型是 {α : Type} → [ToString α] → α → IO Unit。 这个类型表示 IO.println 接受一个类型为 α 的参数,并且 Lean 应该自动确定这个类型, 而且必须有一个可用于 αToString 实例。 它返回一个 IO 操作。

对多态函数的类型检查

对接受隐式参数,或使用了类型类的函数进行类型检查时,我们需要用到一些额外的语法。 简单地写

#check (IO.println)

会产生一个包含元变量的类型。

IO.println : ?m.3620 → IO Unit

这里显示出了元变量是因为即使 Lean 尽全力去寻找隐式参数,但还是没有找到足够的类型信息来做到这一点。 要理解函数的签名,可以在函数名之前加上一个 at 符号(@)来抑制此特性。

#check @IO.println
@IO.println : {α : Type u_1} → [inst : ToString α] → α → IO Unit

在这个输出信息中,实例本身被给予了 inst 这个名字。 此外,Type 后面有一个 u_1 ,这里使用了目前尚未介绍的 Lean 的特性。 我们可以暂时忽略这些 Type 的参数。

定义含隐式实例的多态函数

一个对列表中所有条目求和的函数需要两个实例:Add允许对条目进行加法运算,而OfNat实例为0提供了一个合理的值,以便对空列表进行返回。

def List.sum [Add α] [OfNat α 0] : List α → α
  | [] => 0
  | x :: xs => x + xs.sum

这个函数可以被用于 Nat 列表:

def fourNats : List Nat := [1, 2, 3, 4]

#eval fourNats.sum
10

但不能被用于 Pos 列表:

def fourPos : List Pos := [1, 2, 3, 4]

#eval fourPos.sum
failed to synthesize instance
  OfNat Pos 0

在方括号中的所需实例规范被称为 隐式实例(instance implicits) 。 在幕后,每个类型类都定义了一个结构,该结构具有每个重载操作的字段。 实例是该结构类型的值,每个字段包含一个实现。 在调用时,Lean负责为每个隐式实例参数找到一个实例值传递。 普通的隐式参数和隐式实例最重要的不同就是 Lean 寻找参数值的策略。 对于普通的隐式参数,Lean 使用一种被称为 归一化(unification) 的技术来找到一个唯一的能使程序通过类型检查的参数值。 这个过程只依赖于函数定义中的具体类型和调用时。

就像对 PosOfNat 实例用一个自然数 n 作为自动隐式参数,实例本身也可能接受隐式实例参数。 在多态那一节中展示了一个多态点类型:

structure PPoint (α : Type) where
  x : α
  y : α
deriving Repr

点之间的加法需要将从属的 xy 字段相加。 因此,PPointAdd 实例需要这些字段所具有的类型的 Add 实例。 换句话说,PPointAdd 实例需要进一步的 αAdd 实例。

instance [Add α] : Add (PPoint α) where
  add p1 p2 := { x := p1.x + p2.x, y := p1.y + p2.y }

当 Lean 遇到两点之间的加法,它会寻找并找到这个实例。 然后会更进一步寻找 Add α 实例。

用这种方式构造的实例值是类型类的结构体类型的值。 一个成功的递归实例搜索会产生一个结构体值,该结构体值引用了另一个结构体值。 一个 Add (PPoint Nat) 实例包含对找到的 Add Nat 实例的引用。

这种递归搜索意味着类型类显著地比普通重载函数更加强大。 一个多态实例库是一个由代码砖块组成的集合,编译器会根据所需的类型自行搭建。 接受实例参数的多态函数是对类型类机制的潜在请求,以在幕后组装辅助函数。 API的客户端无需手工组合所有必要的部分,从而使用户从这类烦人的工作中解放出来。

方法与隐式参数

@OfNat.ofNat 的类型可能会令人惊讶。 它是 {α : Type} → (n : Nat) → [OfNat α n] → α,其中 Nat 参数 n 作为显式函数参数出现。 然而,在方法的声明中,ofNat 只是类型 α。 这种看似的不一致是因为声明一个类型类实际上会产生以下结果:

  • 声明一个包含了每个重载操作的实现的结构体类型
  • 声明一个与类同名的命名空间
  • 对于每个方法,会在类的命名空间中声明一个函数,该函数从实例中获取其实现。

这类似于声明新结构也声明访问器函数的方式。 主要区别在于结构的访问器函数将结构值作为显式参数,而类型类方法将实例值作为隐式实例,由 Lean 自动查找。

为了让Lean找到一个实例,它的参数必须是可用的。 这意味着类型类的每个参数必须是出现在实例之前的方法的参数。 当这些参数是隐式的时候最方便,因为Lean会发现它们的值。 例如,@Add.add 的类型是 {α : Type} → [Add α] → α → α → α。 在这种情况下,类型参数 α 可以是隐式的,因为对 Add.add 的参数提供了关于用户意图的类型信息。 然后,可以使用这种类型来搜索 Add 实例。

而在 ofNat 的例子中,要被解码的特定 Nat 字面量并没有作为其他参数的一部分出现。 这意味着 Lean 在尝试确定隐式参数 n 时将没有足够的信息可以用。 如果Lean选择使用隐式参数,那么结果将是一个非常不方便的 API。 因此,在这些情况下,Lean 选择为类方法提供一个显式参数。

练习

偶数数字字面量

上一节的练习题中的偶数数据类型写一个使用递归实例搜索的 OfNat 实例。 对于基本实例,有必要编写 OfNat Even Nat.zero 而不是 OfNat Even 0

递归实例搜索深度

Lean 编译器尝试进行递归实例搜素的次数是有限的。 这限制了前面的练习中定义的偶数字面量的尺寸。 实验性地确定这个上限是多少。

控制实例搜索

要方便地相加两个 Pos 类型,并产生另一个 Pos,一个 Add 类的的实例就足够了。 但是,在许多情况下,参数可能有不同的类型,重载一个灵活的 异质 运算符是更为有用的。 例如,让 NatPos,或 PosNat 相加总会是一个 Pos

def addNatPos : Nat → Pos → Pos
  | 0, p => p
  | n + 1, p => Pos.succ (addNatPos n p)

def addPosNat : Pos → Nat → Pos
  | p, 0 => p
  | p, n + 1 => Pos.succ (addPosNat p n)

这些函数允许自然数与正数相加,但他们不能在 Add 类型类中,因为它希望 add 的两个参数都有同样的类型。

异质重载

就像在重载加法一节提到的,Lean 提供了名为 HAdd 的类型类来重载异质加法。 HAdd 类接受三个类型参数:两个参数的类型和一个返回类型。 HAdd Nat Pos PosHAdd Pos Nat Pos 的实例可以让常规加法符号可以接受不同类型。

instance : HAdd Nat Pos Pos where
  hAdd := addNatPos

instance : HAdd Pos Nat Pos where
  hAdd := addPosNat

有了上面两个实例,就有了下面的例子:

#eval (3 : Pos) + (5 : Nat)
8
#eval (3 : Nat) + (5 : Pos)
8

HAdd 的定义和下面 HPlus 的定义很像。下面是 HPlus 和它对应的实例:

class HPlus (α : Type) (β : Type) (γ : Type) where
  hPlus : α → β → γ

instance : HPlus Nat Pos Pos where
  hPlus := addNatPos

instance : HPlus Pos Nat Pos where
  hPlus := addPosNat

然而,HPlus 的实例明显没有 HAdd 的实例有用。 当尝试用 #eval 使用这些实例时,一个错误就出现了:

#eval HPlus.hPlus (3 : Pos) (5 : Nat)
typeclass instance problem is stuck, it is often due to metavariables
  HPlus Pos Nat ?m.7527

发生错误是因为类型中有元变量,Lean 没办法解决它。

就像我们在多态一开始的描述里说的那样,元变量代表了程序无法被推断的未知部分。 当一个表达式被写在 #eval 后时,Lean 会尝试去自动确定它的类型。 在这种情况下,它无法做到自动确定类型。 因为 HPlus 的第三个类型参数依然是未知的,Lean 没办法进行类型类实例搜索,但是实例搜索是 Lean 唯一可能确定表达式的类型的方式。 也就是说,HPlus Pos Nat Pos 实例只能在表达式的类型为 Pos 时应用,但除了实例本身之外,程序中没有其他东西表明它应该具有这种类型。

一种解决方法是保证全部三个类型都是已知的,通过给整个表达式添加一个类型标记来实现这一点:

#eval (HPlus.hPlus (3 : Pos) (5 : Nat) : Pos)
8

然而,这种解决方式对使用我们的正数库的用户来说并不是很方便。

输出参数

刚才的问题也可以通过声明 γ 是一个 输出参数(output parameter) 来解决。 多数类型类参数是作为搜索算法的输入:它们被用于选取一个实例。 例如,在 OfNat 实例中,类型和自然数都被用于选取一个数字字面量的特定解释。 然而,在一些情况下,在尽管有些类型参数仍然处于未知状态时就开始进行搜索是更方便的。 这样就能使用在搜索中发现的实例来决定元变量的值。 在开始搜索实例时不需要用到的参数就是这个过程的结果,该参数使用 outParam 修饰符来声明。

class HPlus (α : Type) (β : Type) (γ : outParam Type) where
  hPlus : α → β → γ

有了这个输出参数,类型类实例搜索就能够在不需要知道 γ 的情况下选取一个实例了。 例如:

#eval HPlus.hPlus (3 : Pos) (5 : Nat)
8

认为输出参数相当于是定义某种函数在思考时可能会有帮助。 任意给定的,类型类的实例都有一个或更多输出参数提供给 Lean。这能指导 Lean 通过输入(的类型参数)来确定输出(的类型)。 一个可能是递归的实例搜索过程,最终会比简单的重载更为强大。 输出参数能够决定程序中的其他类型,实例搜索能够将一族附属实例组合成具有这种类型的程序。

默认实例

确定一个参数是否是一个输入或输出参数控制了 Lean 会在何时启动类型类搜索。 具体而言,直到所有输入都变为已知,类型类搜索才会开始。 然而,在一些情况下,输出参数是不足的。此时,即使一些输入参数仍然处于未知状态,实例搜索也应该开始。 这有点像是 Python 或 Kotlin 中可选函数参数的默认值,但在这里是默认 类型

默认实例当并不是全部输入均为已知时 可用的实例。 当一个默认实例能被使用时,他就将会被使用。 这能帮助程序成功通过类型检查,而不是因为关于未知类型和元变量的错误而失败。 但另一方面,默认类型会让实例选取变得不那么可预测。 具体而言,如果一个不合适的实例被选取了,那么表达式将可能具有和预期不同的类型。 这会导致令人困惑的类型错误发生在程序中。 明智地选择要使用默认实例的地方!

默认实例可以发挥作用的一个例子是可以从 Add 实例派生出的 HPlus 实例。 换句话说,常规的加法是异质加法在三个参数类型都相同时的特殊情况。 这可以用下面的实例来实现:

instance [Add α] : HPlus α α α where
  hPlus := Add.add

有了这个实例,hPlus 就可以被用于任何可加的类型,就像 Nat

#eval HPlus.hPlus (3 : Nat) (5 : Nat)
8

然而,这个实例只会用在两个参数类型都已知的情况下。 例如:

#check HPlus.hPlus (5 : Nat) (3 : Nat)

产生类型

HPlus.hPlus 5 3 : Nat

就像我们预想的那样,但是

#check HPlus.hPlus (5 : Nat)

产生了一个包含剩余参数和返回值类型的两个元变量的类型:

HPlus.hPlus 5 : ?m.7706 → ?m.7708

在绝大多数情况下,当提供一个加法参数时,另一个参数也会是同一个类型。 来让这个实例成为默认实例,应用 default_instance 属性:

@[default_instance]
instance [Add α] : HPlus α α α where
  hPlus := Add.add

有了默认实例,这个例子就有了更有用的类型:

#check HPlus.hPlus (5 : Nat)

结果为:

HPlus.hPlus 5 : Nat → Nat

每个同时重载了异质和同质运算的运算符,都能在默认实例需要异质运算的语境中使用同质运算。 中缀运算符会被替换为异质运算,并且在需要时尽可能选择同质的默认实例。

简单来说,简单地写 5 会给出一个 Nat 而不是一个需要更多信息来选取 OfNat 实例的一个包含元变量的类型。 这是因为 OfNatNat 作为默认实例。

默认实例也可以被赋予 优先级 ,这会影响在可能的应用多于一种的情况下的选择。 更多关于默认实例优先级的信息,请查阅 Lean 手册。

练习

定义一个 HMul (PPoint α) α (PPoint α) 的实例,该实例将两个投影都乘以标量。 它应适用于任何存在 Mul α 实例的类型 α。例如:

#eval {x := 2.5, y := 3.7 : PPoint Float} * 2.0

结果应为

{ x := 5.000000, y := 7.400000 }

数组与索引

插入章节中描述了如何使用索引符号来通过位置查找列表中的条目。 此语法也由类型类管理,并且可以用于各种不同的类型。

数组

比如说,Lean 中的数组在多数情况下就比链表更为高效。在 Lean 中,Array α 类型是一个动态大小的数组,可以用来装类型为 α 的值。 这很像是 Java 中的 ArrayList,C++ 中的 std::vector,或者 Rust 中的 Vec。 不像是 List 在每一次用到 cons 构造子的地方都会有一个指针指向每个节点,数组会占用内存中一段连续的空间。这会带来更好的处理器缓存效果。 并且,在数组中查找值的时间复杂度为常数,但在链表中查找值所需要的时间则与遍历的节点数量成正比。

在像 Lean 这样的纯函数式语言中,在数据结构中改变某位置上的数据的值是不可能的。 相反,Lean 会制作一个副本,该副本具有所需的修改。 当使用一个数组时,Lean 编译器和运行时包含了一个优化:当该数组只被引用了一次时,会在幕后将制作副本优化为原地操作。

数组写起来很像列表,只是在开头多了一个 #

def northernTrees : Array String :=
  #["sloe", "birch", "elm", "oak"]

数组中值的数量可以通过 Array.size 找到。 例如:northernTrees.size 结果是 4。 对于小于数组大小的索引值,索引符号可以被用来找到对应的值,就像列表一样。 就是说,northernTrees[2] 会被计算为 "elm"。 类似地,编译器需要一个索引值未越界的证明。尝试去查找越界的值会导致编译时错误,就和列表一样。 例如:northernTrees[8] 的结果为:

failed to prove index is valid, possible solutions:
  - Use `have`-expressions to prove the index is valid
  - Use `a[i]!` notation instead, runtime check is perfomed, and 'Panic' error message is produced if index is not valid
  - Use `a[i]?` notation instead, result is an `Option` type
  - Use `a[i]'h` notation instead, where `h` is a proof that index is valid
⊢ 8 < Array.size northernTrees

非空列表

一个表示非空列表的数据类型可以被定义为一个结构,这个结构有一个列表头字段,和一个尾字段。尾字段是一个常规的,可能为空的列表。

structure NonEmptyList (α : Type) : Type where
  head : α
  tail : List α

例如:非空列表 idahoSpiders(包含了一些美国爱达荷州的本土蜘蛛品种)由 "Banded Garden Spider" 和四种其它蜘蛛构成,一共有五种蜘蛛:

def idahoSpiders : NonEmptyList String := {
  head := "Banded Garden Spider",
  tail := [
    "Long-legged Sac Spider",
    "Wolf Spider",
    "Hobo Spider",
    "Cat-faced Spider"
  ]
}

通过递归函数在列表中查找特定索引的值需要考虑到三种情况:

  1. 索引是 0,此时应返回列表头。
  2. 索引是 n + 1 并且列表尾是空的,这意味着索引越界了。
  3. 索引是 n + 1 并且列表尾非空,此时应该在列表尾上递归调用函数并传入 n

例如,一个返回 Option 的查找函数可以写成如下形式:

def NonEmptyList.get? : NonEmptyList α → Nat → Option α
  | xs, 0 => some xs.head
  | {head := _, tail := []}, _ + 1 => none
  | {head := _, tail := h :: t}, n + 1 => get? {head := h, tail := t} n

每种模式匹配的情况都对应于上面的一种可能性。 get? 的递归调用不需要 NonEmptyList 命名空间标识符,因为定义内部隐式地在定义的命名空间中。 另一种方式来编写这个函数是:当索引大于零时就将 get? 应用在列表上。

def NonEmptyList.get? : NonEmptyList α → Nat → Option α
  | xs, 0 => some xs.head
  | xs, n + 1 => xs.tail.get? n

如果列表包含一个条目,那么只有 0 是合法的索引。 如果它包含两个条目,那么 01 是合法的索引。 如果它包含三个条目,那么 0, 1, 和 2 都是合法的索引。 换句话说,非空列表的合法索引是严格小于列表长度的自然数。同时它也是小于等于列表尾的长度的。

“索引值没有出界”意味着什么的这个定义,应该被写成一个 abbrev。 因为这个可以用来证明索引值未越界的策略(tactics)要在不知道 NonEmptyList.inBounds 这个方法的情况下解决数字之间的不等关系。 (此处原文表意不明,按原文字面意思译出。原文大致意思应为 abbrevdef 对tactic的适应性更好)

abbrev NonEmptyList.inBounds (xs : NonEmptyList α) (i : Nat) : Prop :=
  i ≤ xs.tail.length

这个函数返回一个可能为真也可能为假的命题。 例如,2 对于 idahoSpiders未越界,而 5 就越界了。

theorem atLeastThreeSpiders : idahoSpiders.inBounds 2 := by simp

theorem notSixSpiders : ¬idahoSpiders.inBounds 5 := by simp

逻辑非运算符有很低的结合度,这意味着 ¬idahoSpiders.inBounds 5 等价于 ¬(idahoSpiders.inBounds 5)

这个事实可被用于编写能证明索引值合法的查找函数,并且无需返回一个 Option。 该证据会在编译时检查。下面给出代码:

def NonEmptyList.get (xs : NonEmptyList α) (i : Nat) (ok : xs.inBounds i) : α :=
  match i with
  | 0 => xs.head
  | n + 1 => xs.tail[n]

当然,将这个函数写成直接用证据的形式也是可能的。 但这需要会玩证明和命题的一些技术,这些内容会在本书后续内容中提到。

重载索引

对于集合类型的索引符号,可通过定义 GetElem 类型类的实例来重载。 出于灵活性考虑,GetElem 有四个参数:

  • 集合的类型
  • 索引的类型
  • 集合中元素的类型
  • 一个函数,用于确定什么是索引在边界内的证据

元素类型和证明函数都是输出参数。 GetElem 有一个方法 —— getElem —— 接受一个集合值,一个索引值,和一个索引未越界的证明,并且返回一个元素:

class GetElem (coll : Type) (idx : Type) (item : outParam Type) (inBounds : outParam (coll → idx → Prop)) where
  getElem : (c : coll) → (i : idx) → inBounds c i → item

NonEmptyList α 中,这些参数是:

  • 集合是 NonEmptyList α
  • 索引的类型是 Nat
  • 元素的类型是 α
  • 索引如果小于等于列表尾那么就没有越界

事实上,GetElem 实例可以直接使用 NonEmptyList.get

instance : GetElem (NonEmptyList α) Nat α NonEmptyList.inBounds where
  getElem := NonEmptyList.get

有了这个实例,NonEmptyList 就和 List 一样方便了。 计算 idahoSpiders[0] 结果为 "Banded Garden Spider",而 idahoSpiders[9] 会导致编译时错误:

failed to prove index is valid, possible solutions:
  - Use `have`-expressions to prove the index is valid
  - Use `a[i]!` notation instead, runtime check is perfomed, and 'Panic' error message is produced if index is not valid
  - Use `a[i]?` notation instead, result is an `Option` type
  - Use `a[i]'h` notation instead, where `h` is a proof that index is valid
⊢ NonEmptyList.inBounds idahoSpiders 9

因为集合的类型和索引的类型都是 GetElem 类型类的参数,所以可以使用新类型来索引现有的集合。 之前的 Pos 是一个完全合理的可以用来索引 List 的类型,但注意它不能指向第一个条目。 下面 GetElem 的实例使 Pos 在查找列表条目方面和 Nat 一样方便。

instance : GetElem (List α) Pos α (fun list n => list.length > n.toNat) where
  getElem (xs : List α) (i : Pos) ok := xs[i.toNat]

使用非数字索引值来进行索引也可以是合理的。 例如:Bool 也可以被用于选择点中的字段,比如我们可以让 false 对应于 xtrue 对应于 y

instance : GetElem (PPoint α) Bool α (fun _ _ => True) where
  getElem (p : PPoint α) (i : Bool) _ :=
    if not i then p.x else p.y

在这个例子中,布尔值都是合法的索引。 因为每个可能的 Bool 值都是未越界的,证据我们只需简单地给出 True 命题。

标准类

本节中展示了各种可重载的运算符和函数。在 Lean 中,它们都通过类型类来重载。 每个运算符或函数都对应于一个类型类中的方法。 不像 C++,Lean 中的中缀操作符定义为命名函数的缩写;这意味着为新类型重载它们不是使用操作符本身,而是使用其底层名称(例如 HAdd.hAdd)。

算术符号

多数算术运算符都是可以进行异质运算的。 这意味着参数可能有不同的类型,并且输出参数决定了结果表达式的类型。 对于每个异质运算符,都有一个同质运算符与其对应。 只要把字母 h 去掉就能找到那个同质运算符了,HAdd.hAdd 对应 Add.add。 下面的算术运算符都可以被重载:

ExpressionDesugaringClass Name
x + yHAdd.hAdd x yHAdd
x - yHSub.hSub x yHSub
x * yHMul.hMul x yHMul
x / yHDiv.hDiv x yHDiv
x % yHMod.hMod x yHMod
x ^ yHPow.hPow x yHPow
(- x)Neg.neg xNeg

位运算符

Lean 包含了许多标准位运算符,他们也可以用类型类来重载。 Lean 中有对于定长类型的实例,例如 UInt8UInt16UInt32UInt64,和 USize

ExpressionDesugaringClass Name
x &&& yHAnd.hAnd x yHAnd
x ||| y HOr.hOr x yHOr
x ^^^ yHXor.hXor x yHXor
~~~ xComplement.complement xComplement
x >>> yHShiftRight.hShiftRight x yHShiftRight
x <<< yHShiftLeft.hShiftLeft x yHShiftLeft

由于 AndOr 已经是逻辑连接词了,所以 HAndHOr 的同质对应叫做 AndOpOrOp 而不是 AndOr

相等性与有序性

测试两个值之间的相等性通常会用 BEq 类,该类名是 Boolean equality(布尔等价)的缩写。 由于 Lean 是一个定理证明器,所以在 Lean 中其实有两种类型的相等运算符:

  • 布尔等价(Boolean equality) 和你能在其他编程语言中看到的等价是一样的。 这是一个接受两个值并且返回一个 Bool 的函数。 布尔等价使用两个等号表示,就像在 Python 和 C# 中那样。 因为 Lean 是一个纯函数式语言,指针并不能被直接看到,所以引用和值等价并没有符号上的区别。
  • 命题等价(Propositional equality) 是一个 数学陈述(mathematical statement) ,指两个东西是等价的。 命题等价并不是一个函数,而是一个可以证明的数学陈述。 可以用一个单等号表示。 一个命题等价的陈述就像一个能检查等价性证据的类型。

这两种等价都很重要,它们有不同的用处。 布尔等价在程序中很有用,有时我们需要考察两个值是否是相等的。 例如:"Octopus" == "Cuttlefish" 结果为 false,以及 "Octopodes" == "Octo".append "podes" 结果为 true。 有一些值,比如函数,无法检查等价性。 例如,(fun (x : Nat) => 1 + x) == (Nat.succ ·) 会报错:

failed to synthesize instance
  BEq (Nat → Nat)

就像这条信息说的,== 是使用了类型类重载的。 表达式 x == y 事实上是 BEq.beq x y 的缩写。

命题等价是一个数学陈述,而不是程序调用。 因为命题就像描述一些数学陈述的证据的类型,命题等价和像是 StringNat → List Int 这样的类型有更多的相同之处,而不是布尔等价。 这意味着它并不能被自动检查。 然而,在 Lean 中,只要两个表达式具有相同的类型,就可以陈述它们的相等性。 (fun (x : Nat) => 1 + x) = (Nat.succ ·) 是一个十分合理的陈述。 从数学角度来讲,如果两个函数把相等的输入映射到相等的输出,那么这两个函数就是相等的。所以那个陈述是真的,尽管它需要一个两行的证明来让 Lean 相信这个事实。

通常来说,当把 Lean 作为一个编程语言来用时,用布尔值函数会比用命题要更简单。

在 Lean 中,if 语句适用于可判定命题。 例如:2 < 4 是一个命题。

#check 2 < 4
2 < 4 : Prop

尽管如此,将其写作 if 语句中的条件是完全可以接受的。 例如,if 2 < 4 then 1 else 2 的类型是 Nat,并且计算结果为 1

并不是所有的命题都是可判定的。 如果所有的命题都是可判定的,那么计算机通过运行判定程序就可以证明任何的真命题,数学家们就此失业了。 更具体来说,可判定的命题都会有一个 Decidable 类型的实例,实例中的方法是判定程序。 因为认为会返回一个 Bool 而尝试去用一个不可判定的命题,最终会报错,因为 Lean 无法找到 Decidable 实例。 例如,if (fun (x : Nat) => 1 + x) = (Nat.succ ·) then "yes" else "no" 会导致:

failed to synthesize instance
  Decidable ((fun x => 1 + x) = fun x => Nat.succ x)

下面的命题,通常都是重载了可判定类型类的:

ExpressionDesugaringClass Name
x < yLT.lt x yLT
x ≤ yLE.le x yLE
x > yLT.lt y xLT
x ≥ yLE.le y xLE

因为还没有演示如何定义新命题,所以定义新的 LTLE 实例可能会比较困难。

另外,使用 <, ==, 和 > 来比较值可能效率不高。 首先检查一个值是否小于另一个值,然后再检查它们是否相等,这可能需要对大型数据结构进行两次遍历。 为了解决这个问题,Java 和 C# 分别有标准的 compareToCompareTo 方法,可以通过类来重写以同时实现这三种操作。 这些方法在接收者小于参数时返回负整数,等于时返回零,大于时返回正整数。 Lean 与其重载整数,不如有一个内置的归纳类型来描述这三种可能性:

inductive Ordering where
| lt
| eq
| gt

Ord 类型类可以被重载,这样就可以用于比较。 对于 Pos 一个实现可以是:

def Pos.comp : Pos → Pos → Ordering
  | Pos.one, Pos.one => Ordering.eq
  | Pos.one, Pos.succ _ => Ordering.lt
  | Pos.succ _, Pos.one => Ordering.gt
  | Pos.succ n, Pos.succ k => comp n k

instance : Ord Pos where
  compare := Pos.comp

对于 Java 中应该使用 compareTo 的情形,在 Lean 中用 Ord.compare 就对了。

哈希

Java 和 C# 有 hashCodeGetHashCode 方法,用于计算值的哈希值,以便在哈希表等数据结构中使用。 Lean 中的等效类型类称为 Hashable

class Hashable (α : Type) where
  hash : α → UInt64

对于两个值而言,如果它们根据各自类型的 BEq 实例是相等的,那么它们也应该有相同的哈希值。 换句话说,如果 x == y,那么有 hash x == hash y。 如果 x ≠ y,那么 hash x 不一定就和 hash y 不一样(毕竟 Nat 有无穷多个,而 UInt64 最多只能有有限种组合方式。), 但是如果不一样的值有不一样的哈希值的话,那么建立在其上的数据结构会有更好的表现。 这与 Java 和 C# 中对哈希的要求是一致的。

在标准库中包含了一个函数 mixHash,它的类型是 UInt64 → UInt64 → UInt64。 它可以用来组合构造子不同字段的哈希值。 一个合理的归纳数据类型的哈希函数可以通过给每个构造函数分配一个唯一的数字,然后将该数字与每个字段的哈希值混合来编写。 例如,可以这样编写 PosHashable 实例:

def hashPos : Pos → UInt64
  | Pos.one => 0
  | Pos.succ n => mixHash 1 (hashPos n)

instance : Hashable Pos where
  hash := hashPos

Hashable 实例对于多态可以使用递归类型搜索。 哈希化一个 NonEmptyList α 需要 α 是可以被哈希化的。

instance [Hashable α] : Hashable (NonEmptyList α) where
  hash xs := mixHash (hash xs.head) (hash xs.tail)

在二叉树的 BEqHashable 的实现中,递归和递归实例搜索这二者都被用到了。

inductive BinTree (α : Type) where
  | leaf : BinTree α
  | branch : BinTree α → α → BinTree α → BinTree α

def eqBinTree [BEq α] : BinTree α → BinTree α → Bool
  | BinTree.leaf, BinTree.leaf =>
    true
  | BinTree.branch l x r, BinTree.branch l2 x2 r2 =>
    x == x2 && eqBinTree l l2 && eqBinTree r r2
  | _, _ =>
    false

instance [BEq α] : BEq (BinTree α) where
  beq := eqBinTree

def hashBinTree [Hashable α] : BinTree α → UInt64
  | BinTree.leaf =>
    0
  | BinTree.branch left x right =>
    mixHash 1 (mixHash (hashBinTree left) (mixHash (hash x) (hashBinTree right)))

instance [Hashable α] : Hashable (BinTree α) where
  hash := hashBinTree

派生标准类

BEqHashable 这样的类的实例,手动实现起来通常相当繁琐。Lean 包含一个称为 实例派生(instance deriving) 的特性,它使得编译器可以自动构造许多类型类的良好实例。事实上,结构那一节Point 定义中的 deriving Repr 短语就是实例派生的一个例子。

派生实例的方法有两种。 第一种在定义一个结构体或归纳类型时使用。 在这种情况下,添加 deriving 到类型声明的末尾,后面再跟实例应该派生自的类。 对于已经定义好的类型,单独的 deriving 也是可用的。 写 deriving instance C1, C2, ... for T 来为类型 T 派生 C1, C2, ... 实例。

PosNonEmptyList 上的 BEqHashable 实例可以用很少量的代码派生出来:

deriving instance BEq, Hashable for Pos
deriving instance BEq, Hashable, Repr for NonEmptyList

至少以下几种类型类的实例都是可以派生的:

  • Inhabited
  • BEq
  • Repr
  • Hashable
  • Ord

然而,有些时候 Ord 的派生实例可能不是你想要的。 当发生这种事情的时候,就手写一个 Ord 实例把。 你如果对自己的 Lean 水平足够有自信的话,你也可以自己添加可以派生实例的类型类。

实例派生除了在开发效率和代码可读性上有很大的优势外,它也使得代码更易于维护,因为实例会随着类型定义的变化而更新。 对数据类型的一系列更新更易于阅读,因为不需要一行又一行地对相等性测试和哈希计算进行公式化的修改。

Appending

许多数据类型都有某种形式的连接操作符。 在 Lean 中,连接两个值的操作被重载为类型类 HAppend,这是一个异质操作,与用于算术运算的操作类似:

class HAppend (α : Type) (β : Type) (γ : outParam Type) where
  hAppend : α → β → γ

语法 xs ++ ys 会被脱糖为 HAppend.hAppend xs ys. 对于同质的情形,按照常规模式实现一个 Append 即可:

instance : Append (NonEmptyList α) where
  append xs ys :=
    { head := xs.head, tail := xs.tail ++ ys.head :: ys.tail }

在定义了上面的实例后,

#eval idahoSpiders ++ idahoSpiders

就有了下面的结果:

{ head := "Banded Garden Spider",
tail := ["Long-legged Sac Spider",
         "Wolf Spider",
         "Hobo Spider",
         "Cat-faced Spider",
         "Banded Garden Spider",
         "Long-legged Sac Spider",
         "Wolf Spider",
         "Hobo Spider",
         "Cat-faced Spider"] }

类似地:定义一个 HAppend 来使常规列表可以和一个非空列表连接。

instance : HAppend (NonEmptyList α) (List α) (NonEmptyList α) where
  hAppend xs ys :=
    { head := xs.head, tail := xs.tail ++ ys }

有了这个实例后,

#eval idahoSpiders ++ ["Trapdoor Spider"]

结果为

{ head := "Banded Garden Spider",
  tail := ["Long-legged Sac Spider", "Wolf Spider", "Hobo Spider", "Cat-faced Spider", "Trapdoor Spider"] }

函子

如果一个多态类型重载了一个函数 map,这个函数将位于上下文中的每个元素都用一个函数来映射,那么这个类型就是一个 函子(functor) 。 虽然大多数语言都使用这个术语,但C#中等价于 map 的是 System.Linq.Enumerable.Select。 例如,用一个函数对一个列表进行映射会产生一个新的列表,列表中的每个元素都是函数应用在原列表中元素的结果。 用函数 f 对一个 Option 进行映射,如果 Option 的值为 none,那么结果仍为 none; 如果为 some x,那么结果为 some (f x)

下面是一些函子,这些函子是如何重载 map 的例子:

  • Functor.map (· + 5) [1, 2, 3] 结果为 [6, 7, 8]
  • Functor.map toString (some (List.cons 5 List.nil)) 结果为 some "[5]"
  • Functor.map List.reverse [[1, 2, 3], [4, 5, 6]] 结果为 [[3, 2, 1], [6, 5, 4]]

因为 Functor.map 这个操作很常用,但它的名字又有些长了,所以 Lean 也提供了一个中缀运算符来映射函数,叫做 <$>。 下面是一些简单的例子:

  • (· + 5) <$> [1, 2, 3] 结果为 [6, 7, 8]
  • toString <$> (some (List.cons 5 List.nil)) 结果为 some "[5]"
  • List.reverse <$> [[1, 2, 3], [4, 5, 6]] 结果为 [[3, 2, 1], [6, 5, 4]]

Functor 对于 NonEmptyList 的实例需要我们提供 map 函数。

instance : Functor NonEmptyList where
  map f xs := { head := f xs.head, tail := f <$> xs.tail }

在这里,map 使用 List 上的 Functor 实例来将函数映射到列表尾。 这个实例是在 NonEmptyList 下定义的,而不是 NonEmptyList α。 因为类型参数 α 在当前类型类中用不上。 无论条目的类型是什么 ,我们都可以用一个函数来映射 NonEmptyList。 如果 α 是类型类的一个参数,那么我们就可以做出只工作在某个 α 类型上的 Functor,比如 NonEmptyList Nat。 但成为一个函子类型的必要条件就是 map 对任意条目类型都是有效的。

这里有一个将 PPoint 实现为一个函子的实例:

instance : Functor PPoint where
  map f p := { x := f p.x, y := f p.y }

在这里,f 被应用到 xy 上。

即使包含在一个函子类型中的类型本身也是一个函子,映射一个函数也只会向下一层。 也就是说,当在 NonEmptyList (PPoint Nat)map 时,被映射的函数会接受 PPoint Nat 作为参数,而不是 Nat

Functor 类型类的定义中用到了一个还没介绍的语言特性:默认方法定义。 正常来说,一个类型类会指定一些有意义的最小的可重载操作集合,然后使用具有隐式实例参数的多态函数,这些函数建立在重载操作的基础上,以提供更大的功能库。 例如,函数 concat 可以连接任何非空列表的条目,只要条目是可连接的:

def concat [Append α] (xs : NonEmptyList α) : α :=
  let rec catList (start : α) : List α → α
    | [] => start
    | (z :: zs) => catList (start ++ z) zs
  catList xs.head xs.tail

然而,对于一些类型类,如果你对数据类型的内部又更深的理解的话,那么就会有一些更高效的运算实现。

在这些情况下,可以提供一个默认方法定义。 默认方法定义提供了一个基于其他方法的默认实现。 然而,实例实现者可以选择用更高效的方法来重写这个默认实现。 默认方法定义在 class 定义中,包含 :=

对于 Functor 而言,当被映射的函数并不需要参数时,许多类型有更高效的 map 实现方式。

class Functor (f : Type → Type) where
  map : {α β : Type} → (α → β) → f α → f β

  mapConst {α β : Type} (x : α) (coll : f β) : f α :=
    map (fun _ => x) coll

就像不符合 BEqHashable 实例是有问题的一样,一个在映射函数时移动数据的 Functor 实例也是有问题的。 例如,一个有问题的 ListFunctor 实例可能会丢弃其参数并总是返回空列表,或者它可能会反转列表。 一个有问题的 PPoint 实例可能会将 f x 放在 xy 字段中。 具体来说,Functor 实例应遵循两条规则:

  1. 映射恒等函数应返回原始参数。
  2. 映射两个复合函数应具有与它们的映射组合相同的效果。

更形式化的讲,第一个规则说 id <$> x 等于 x。 第二个规则说 map (fun y => f (g y)) x 等于 map f (map g x)fun y => f (g y) 也可以写成 f ∘ g。 这些规则能防止 map 的实现移动数据或删除一些数据。

你也许会遇到的问题

Lean 不能为所有类派生实例。 例如代码

deriving instance ToString for NonEmptyList

会导致如下错误:

default handlers have not been implemented yet, class: 'ToString' types: [NonEmptyList]

调用 deriving instance 会使 Lean 查找一个类型类实例的内部代码生成器的表。 如果找到了代码生成器,那么就会调用它来创建实例。 然而这个报错就意味着没有发现对 ToString 的代码生成器。

练习

  • 写一个 HAppend (List α) (NonEmptyList α) (NonEmptyList α) 的实例并测试它
  • 为二叉树实现一个 Functor 的实例。

强制转换

在数学中,用同一个符号来在不同的语境中代表数学对象的不同方面是很常见的。 例如,如果在一个需要集合的语境中给出了一个环,那么理解为该环对应的集合也是很有道理的。 在编程语言中,有一些规则自动地将一种类型转换为另一种类型也是很常见的。 例如,Java 允许 byte 自动转换为一个 int,Kotlin 也允许非空类型在可为空的语境中使用。

在 Lean 中,这两个目的都是用一个叫做 强制转换(coercions) 的机制实现的。 当 Lean 遇到了在某语境中某表达式的类型与期望类型不一致时,Lean 在报错前会尝试进行强制转换。 不像 Java,C,和 Kotlin,强制转换是通过定义类型类实例实现的,并且是可扩展的。

正数

例如,每个正数都对应一个自然数。 之前定义的函数 Pos.toNat 可以将一个 Pos 转换成对应的 Nat

def Pos.toNat : Pos → Nat
  | Pos.one => 1
  | Pos.succ n => n.toNat + 1

函数 List.drop,的类型是 {α : Type} → Nat → List α → List α,它将列表的前缀移除。 将 List.drop 应用到 Pos 会产生一个类型错误:

[1, 2, 3, 4].drop (2 : Pos)
application type mismatch
  List.drop 2
argument
  2
has type
  Pos : Type
but is expected to have type
  Nat : Type

因为 List.drop 的作者没有让它成为一个类型类的方法,所以它没有办法通过定义新实例的方式来重写。

Coe 类型类描述了类型间强制转换的重载方法。

class Coe (α : Type) (β : Type) where
  coe : α → β

一个 Coe Pos Nat 的实例就足够让先前的代码正常工作了。

instance : Coe Pos Nat where
  coe x := x.toNat

#eval [1, 2, 3, 4].drop (2 : Pos)
[3, 4]

#check 来看隐藏在幕后的实例搜索。

#check [1, 2, 3, 4].drop (2 : Pos)
List.drop (Pos.toNat 2) [1, 2, 3, 4] : List Nat

链式强制转换

在寻找强制转换时,Lean 会尝试通过一系列较小的强制转换来组成一个完整的强制转换。 例如,已经存在一个从 NatInt 的强制转换实例。 由于这个实例结合了 Coe Pos Nat 实例,我们就可以写出下面的代码:

def oneInt : Int := Pos.one

这个定义用到了两个强制转换:从 PosNat,再从 NatInt

Lean 编译器在存在循环强制转换的情况下不会陷入无限循环。 例如,即使两个类型 AB 可以互相强制转换,在转换中 Lean 也可以找到一个路径。

inductive A where
  | a

inductive B where
  | b

instance : Coe A B where
  coe _ := B.b

instance : Coe B A where
  coe _ := A.a

instance : Coe Unit A where
  coe _ := A.a

def coercedToB : B := ()

提示:双括号 () 是构造子 Unit.unit 的简写。 在派生 Repr B 实例后,

#eval coercedToB

结果为:

B.b

Option 类型类似于 C# 和 Kotlin 中可为空的类型:none 构造子就代表了一个不存在的值。 Lean 标准库定义了一个从任意类型 αOption α 的强制转换,效果是会将值包裹在 some 中。 这使得 option 类型用起来更像是其他语言中可为空的类型,因为 some 是可以忽略的。 例如,可以找到列表中最后一个元素的函数 List.getLast?,就可以直接返回值 x 而无需加上 some

def List.last? : List α → Option α
  | [] => none
  | [x] => x
  | _ :: x :: xs => last? (x :: xs)

实例搜索找到强制转换,并插入对 coe 的调用,该调用会将参数包装在 some 中。这些强制转换可以是链式的,这样嵌套使用 Option 时就不需要嵌套的 some 构造子:

def perhapsPerhapsPerhaps : Option (Option (Option String)) :=
  "Please don't tell me"

仅当 Lean 遇到推断出的类型和剩下的程序需要的类型不匹配时,才会自动使用强制转换。 在遇到其它错误时,强制转换不会被使用。 例如,如果遇到的错误是实例缺失,强制类型转换不会被使用:

def perhapsPerhapsPerhapsNat : Option (Option (Option Nat)) :=
  392
failed to synthesize instance
  OfNat (Option (Option (Option Nat))) 392

这可以通过手动指定 OfNat 所需的类型来解决:

def perhapsPerhapsPerhapsNat : Option (Option (Option Nat)) :=
  (392 : Nat)

此外,强制转换用一个上箭头手动调用。

def perhapsPerhapsPerhapsNat : Option (Option (Option Nat)) :=
  ↑(392 : Nat)

在一些情况下,这可以保证 Lean 找到了正确的实例。 这也会让程序员的意图更加清晰。

非空列表与依值强制转换

β 类型中的值可以对应每一个 α 类型中的值时,Coe α β 实例才是合理的。 将 Nat 强制转换为 Int 是合理的,因为 Int 类型中包含了全部的自然数。 类似地,一个从非空列表到常规列表的强制转换也是合理的,因为 List 类型可以表示每一个非空列表:

instance : Coe (NonEmptyList α) (List α) where
  coe
    | { head := x, tail := xs } => x :: xs

这使得非空列表可以使用全部的 List API。

另一方面,我们不可能写出一个 Coe (List α) (NonEmptyList α) 的实例,因为没有任何一个非空列表可以表示一个空列表。 这个限制可以通过其他方式的强制转换来解决,该强制转换被称为 依值强制转换(dependent coercions) 。 当是否能将一种类型强制转换到另一种类型依赖于具体的值时,依值强制转换就派上用场了。 就像 OfNat 类型类需要具体的 Nat 来作为参数,依值强制转换也接受要被强制转换的值作为参数:

class CoeDep (α : Type) (x : α) (β : Type) where
  coe : β

这可以使得只选取特定的值,通过加上进一步的类型类约束或者直接写出特定的构造子。 例如,任意非空的 List 都可以被强制转换为一个 NonEmptyList

instance : CoeDep (List α) (x :: xs) (NonEmptyList α) where
  coe := { head := x, tail := xs }

强制转换为类型(本节中 sort 的翻译待讨论

在数学中,一个建立在集合上,但是比集合具有额外的结构的概念是很常见的。 例如,一个幺半群就是一些集合 S,一个 S 中的元素 s,以及一个 S 上结合的二元运算,使得 s 在运算的左侧和右侧都是中性的。 S 是这个幺半群的“载体集”。 自然数集上的零和加法构成一个幺半群,因为加法是满足结合律的,并且为任何一个数字加零都是恒等的。 类似地,自然数上的一和乘法也构成一个幺半群。 幺半群在函数式编程中的应用也很广泛:列表,空列表,和连接运算符构成一个幺半群。 字符串,空字符串,和连接运算符也构成一个幺半群:

structure Monoid where
  Carrier : Type
  neutral : Carrier
  op : Carrier → Carrier → Carrier

def natMulMonoid : Monoid :=
  { Carrier := Nat, neutral := 1, op := (· * ·) }

def natAddMonoid : Monoid :=
  { Carrier := Nat, neutral := 0, op := (· + ·) }

def stringMonoid : Monoid :=
  { Carrier := String, neutral := "", op := String.append }

def listMonoid (α : Type) : Monoid :=
  { Carrier := List α, neutral := [], op := List.append }

给定一个幺半群,我们就可以写出一个 foldMap 函数,该函数在一次遍历中将整个列表中的元素映射到载体集中,然后使用幺半群的运算符将它们组合起来。 由于幺半群有单位元,所以当列表为空时我们就可以返回这个值。 又因为运算符是满足结合律的,这个函数的用户不需要关心函数结合元素的顺序到底是从左到右的还是从右到左的。

def foldMap (M : Monoid) (f : α → M.Carrier) (xs : List α) : M.Carrier :=
  let rec go (soFar : M.Carrier) : List α → M.Carrier
    | [] => soFar
    | y :: ys => go (M.op soFar (f y)) ys
  go M.neutral xs

尽管一个幺半群是由三部分信息组成的,但在提及它的载体集时使用幺半群的名字也是很常见的。 说“令 A 为一个幺半群,并令 xyA 中的元素”是很常见的,而不是说“令 A 为一个幺半群,并令 xy 为载体集中的元素”。 这种方式可以通过定义一种新的强制转换来在 Lean 中实现,该转换从幺半群到它的载体集。

CoeSort 类型类和 Coe 大同小异,只是要求强制转换的目标一定要是一个 sort,即 TypeProp。 词语 sort 指的是这些分类其他类型的类型——Type 分类那些本身分类数据的类型,而 Prop 分类那些本身分类其真实性证据的命题。 正如在类型不匹配时会检查 Coe 一样,当在预期为 sort 的上下文中提供了其他东西时,会使用 CoeSort

从一个幺半群到它的载体集的强制转换会返回该载体集:

instance : CoeSort Monoid Type where
  coe m := m.Carrier

有了这个强制转换,类型签名变得不那么繁琐了:

def foldMap (M : Monoid) (f : α → M) (xs : List α) : M :=
  let rec go (soFar : M) : List α → M
    | [] => soFar
    | y :: ys => go (M.op soFar (f y)) ys
  go M.neutral xs

另一个有用的 CoeSort 使用场景是它可以让 BoolProp 建立联系。 就像在有序性和等价性那一节我们提到的,Lean 的 if 表达式需要条件为一个可判定的命题而不是一个 Bool。 然而,程序通常需要能够根据布尔值进行分支。 Lean 标准库并没有定义两种 if 表达式,而是定义了一种从 Bool 到命题的强制转换,即该 Bool 值等于 true

instance : CoeSort Bool Prop where
  coe b := b = true

如此,这个 sort 将是一个 Prop 而不是 Bool

强制转换为函数 (本节翻译需要润色

许多在编程中常见的数据类型都会有一个函数和一些额外的信息组成。 例如,一个函数可能附带一个名称以在日志中显示,或附带一些配置数据。 此外,将一个类型放在结构体的字段中(类似于 Monoid 的例子)在某些上下文中是有意义的,这些上下文中存在多种实现操作的方法,并且需要比类型类允许的更手动的控制。 例如,JSON 序列化器生成的值的具体细节可能很重要,因为另一个应用程序期望特定的格式。 有时,仅从配置数据就可以推导出函数本身。

CoeFun 类型类可以将非函数类型的值转换为函数类型的值。 CoeFun 有两个参数:第一个是需要被转变为函数的值的类型,第二个是一个输出参数,决定了到底应该转换为哪个函数类型。

class CoeFun (α : Type) (makeFunctionType : outParam (α → Type)) where
  coe : (x : α) → makeFunctionType x

第二个参数本身是一个可以计算类型的函数。 在 Lean 中,类型是一等公民,可以作为函数参数被传递,也可以作为返回值,就像其他东西一样。

例如,一个将常量加到其参数的函数可以表示为围绕要添加的量的包装,而不是通过定义一个实际的函数:

structure Adder where
  howMuch : Nat

一个为参数加上5的函数的 howMuch 字段为 5

def add5 : Adder := ⟨5⟩

这个 Adder 类型并不是一个函数,将它应用到一个参数会报错:

#eval add5 3
function expected at
  add5
term has type
  Adder

定义一个 CoeFun 实例让 Lean 来将 adder 转换为一个 Nat → Nat 的函数:

instance : CoeFun Adder (fun _ => Nat → Nat) where
  coe a := (· + a.howMuch)

#eval add5 3
8

因为所有的 Adder 都应该被转换为 Nat → Nat 的函数,CoeFun 的第二个参数就被省略了。

当我们需要这个值来决定正确的函数类型时,CoeFun 的第二个参数就派上用场了。 例如,给定下面的 JSON 值表示:

inductive JSON where
  | true : JSON
  | false : JSON
  | null : JSON
  | string : String → JSON
  | number : Float → JSON
  | object : List (String × JSON) → JSON
  | array : List JSON → JSON
deriving Repr

一个 JSON 序列化器是一个结构体,它不仅包含它知道如何序列化的类型,还包含序列化代码本身:

structure Serializer where
  Contents : Type
  serialize : Contents → JSON

对字符串的序列化器只需要将所给的字符串包装在 JSON.string 构造子中即可:

def Str : Serializer :=
  { Contents := String,
    serialize := JSON.string
  }

将 JSON 序列化器视为序列化其参数的函数需要提取可序列化数据的内部类型:

instance : CoeFun Serializer (fun s => s.Contents → JSON) where
  coe s := s.serialize

有了这个实例,一个序列化器就能直接应用在参数上。

def buildResponse (title : String) (R : Serializer) (record : R.Contents) : JSON :=
  JSON.object [
    ("title", JSON.string title),
    ("status", JSON.number 200),
    ("record", R record)
  ]

这个序列化器可以直接传入 buildResponse

#eval buildResponse "Functional Programming in Lean" Str "Programming is fun!"
JSON.object
  [("title", JSON.string "Functional Programming in Lean"),
   ("status", JSON.number 200.000000),
   ("record", JSON.string "Programming is fun!")]

附注:将 JSON 表示为字符串

当 JSON 被编码为 Lean 对象时可能有点难以理解。 为了帮助保证序列化的响应是我们所期望的,写一个简单的从 JSONString 的转换器可能会很方便。 第一步是简化数字的显示。 JSON 不区分整数和浮点数,Float 类型即可用来代表二者。 在 Lean 中,Float.toString 包括数字的后继零。

#eval (5 : Float).toString
"5.000000"

解决方案是写一个小函数,这个函数可以清理掉所有的后继零,和后继的小数点:

def dropDecimals (numString : String) : String :=
  if numString.contains '.' then
    let noTrailingZeros := numString.dropRightWhile (· == '0')
    noTrailingZeros.dropRightWhile (· == '.')
  else numString

有了这个定义,#eval dropDecimals (5 : Float).toString 结果为 "5"#eval dropDecimals (5.2 : Float).toString 结果为 "5.2"

下一步是定义一个辅助函数来连接字符串列表,并在中间添加分隔符:

def String.separate (sep : String) (strings : List String) : String :=
  match strings with
  | [] => ""
  | x :: xs => String.join (x :: xs.map (sep ++ ·))

这个函数用于处理 JSON 数组和对象中的逗号分隔元素。 #eval ", ".separate ["1", "2"] 结果为 "1, 2"#eval ", ".separate ["1"] 结果为 "1"#eval ", ".separate [] 结果为 ""

最后,需要一个字符串转义程序来处理 JSON 字符串,以便包含 "Hello!" 的 Lean 字符串可以输出为 ""Hello!"”。 幸运的是,Lean 编译器已经包含了一个用于转义 JSON 字符串的内部函数,叫做 Lean.Json.escape。 要使用这个函数,可以在文件开头添加 import Lean

JSON 值转换为字符串的函数被声明了 partial,因为 Lean 并不知道它是否停机。 这是因为出现在函数中的 asString 的递归调用被应用到了 List.map,这种模式的递归已经复杂到 Lean 无法知道递归过程中值的规模是否是减小的。 在一个只需要产生 JSON 字符串而不需要让过程在数学上是合理的的应用中,让函数是 partial 的不太可能造成麻烦。

partial def JSON.asString (val : JSON) : String :=
  match val with
  | true => "true"
  | false => "false"
  | null => "null"
  | string s => "\"" ++ Lean.Json.escape s ++ "\""
  | number n => dropDecimals n.toString
  | object members =>
    let memberToString mem :=
      "\"" ++ Lean.Json.escape mem.fst ++ "\": " ++ asString mem.snd
    "{" ++ ", ".separate (members.map memberToString) ++ "}"
  | array elements =>
    "[" ++ ", ".separate (elements.map asString) ++ "]"

有了这个定义,序列化的结果更加易读了:

#eval (buildResponse "Functional Programming in Lean" Str "Programming is fun!").asString
"{\\"title\\": \\"Functional Programming in Lean\\", \\"status\\": 200, \\"record\\": \\"Programming is fun!\\"}"

可能会遇到的问题

自然数字面量是通过 OfNat 类型类重载的。 因为在类型不匹配时才会触发强制转换,而不是在找不到实例时,所以当对于某类型的 OfNat 实例缺失时,并不会触发强制转换:

def perhapsPerhapsPerhapsNat : Option (Option (Option Nat)) :=
  392
failed to synthesize instance
  OfNat (Option (Option (Option Nat))) 392

设计原则

强制转换是一个强大的工具,请负责任地使用它。 一方面,它可以使 API 设计得更贴近领域内使用习惯。 这是繁琐的手动转换函数和一个清晰的程序间的差别。 正如 Abelson 和 Sussman 在《计算机程序的构造和解释》( Structure and Interpretation of Computer Programs )(麻省理工学院出版社,1996年)前言中所写的那样:

写程序须以让人读明白为主,让计算机执行为辅。

明智地使用强制转换,可以使得代码更加易读——这是与领域内专家的交流的基础。 然而,严重依赖强制转换的 API 会有许多限制。 在你自己的代码中使用强制转换前,认真思考这些限制。

首先,强制转换只应该出现在类型信息充足,Lean 能够知道所有参与的类型的语境中。 因为强制转换类型类中并没有输出参数这么一说。 这意味着在函数上添加返回类型注释可以决定是类型错误还是成功应用强制转换。 例如,从非空列表到列表的强制转换使以下程序得以运行:

def lastSpider : Option String :=
  List.getLast? idahoSpiders

另一方面,如果类型注释被省略了,那么结果的类型就是未知的,那么 Lean 就无法找到对应的强制转换。

def lastSpider :=
  List.getLast? idahoSpiders
application type mismatch
  List.getLast? idahoSpiders
argument
  idahoSpiders
has type
  NonEmptyList String : Type
but is expected to have type
  List ?m.34258 : Type

通常来讲,如果一个强制转换因为一些原因失败了,用户会收到原始的类型错误,这会使在强制转换链上定位错误变得十分困难。

最后,强制转换不会在字段访问符号的上下文中应用。 这意味着需要强制转换的表达式与不需要强制转换的表达式之间仍然存在重要区别,而这个区别对用户来说是肉眼可见的。

其他便利功能

实例的构造子语法

在幕后,类型类都是一些结构体类型,实例都是那些类型的值。 唯一的区别是 Lean 存储关于类型类的额外信息,例如哪些参数是输出参数,和记录要被搜索的实例。 虽然结构体类型的值通常要么是用 ⟨...⟩ 定义的,要么是用大括号和字段定义的,而实例通常使用where定义,但这两种语法都适用于这两种定义方式。

例如,一个林业应用程序可能会这样表示树木:

structure Tree : Type where
  latinName : String
  commonNames : List String

def oak : Tree :=
  ⟨"Quercus robur", ["common oak", "European oak"]⟩

def birch : Tree :=
  { latinName := "Betula pendula",
    commonNames := ["silver birch", "warty birch"]
  }

def sloe : Tree where
  latinName := "Prunus spinosa"
  commonNames := ["sloe", "blackthorn"]

这些语法都是等价的

类似地,这三种语法也可以用于定义类型类的实例。

class Display (α : Type) where
  displayName : α → String

instance : Display Tree :=
  ⟨Tree.latinName⟩

instance : Display Tree :=
  { displayName := Tree.latinName }

instance : Display Tree where
  displayName t := t.latinName

一般来说,where 语法应该用于实例,单书名号应该用于结构体。 当强调一个结构类型非常类似于一个字段被命名的元组,但名字此刻并不重要时,⟨...⟩ 语法会很有用。 然而,有些情况用其他方式可能才是合理的。 具体而言,一个库可能提供一个构建实例值的函数。 在实例声明中的 := 之后调用这个函数是使用此类函数的最简单方法。

例子

当用 Lean 代码做实验时,定义可能比 #eval#check 指令更方便。 首先,定义不会产生任何输出,这可以让读者的注意力集中在最有趣的输出上。 第二,从一个类型签名开始一个 Lean 程序是最简单的方式,这也会使 Lean 能够提供更多的协助和更好的错误信息。 另一方面,#eval#check 在 Lean 可以通过表达式给出类型时用起来最简单。 第三,#eval 并不能用于没有 ToStringRepr 实例的类型,例如函数。 最后,多步的 do 语法块,let 表达式,和其他多行语法形式在 #eval#check 中有时候是一个需要多层括号区分优先级的长表达式,在这里面插入类型标注会很难读。

为了绕开这些问题,Lean 支持源码中例子的显式类型推断。 一个例子就是一个无名的定义。 例如,一个在 Copenhagen's green spaces 中常见鸟类的非空列表可以写成这样:

example : NonEmptyList String :=
  { head := "Sparrow",
    tail := ["Duck", "Swan", "Magpie", "Eurasian coot", "Crow"]
  }

接受参数的例子可以用来定义函数:

example (n : Nat) (k : Nat) : Bool :=
  n + k == k + n

这会在幕后创建一个函数,这个函数没有名字,也不能被调用。 此外,这可以用来检查某库中的函数是否可以在任意的或一些类型的未知值上正常工作。 在源码中,example 声明很适合与解释概念的注释搭配使用。

总结

类型类和重载

类型类是 Lean 重载函数和运算符的机制。 一个多态函数可以用于多种类型,但是不管是什么类型,它的行为都是一致的。 例如,一个连接两个列表的多态函数在使用时不关心列表中元素的类型,但它也不可能根据具体的元素类型有不一样的行为。 另一方面,一个用类型类重载的运算符,也可以用在多种类型上。 然而,每个类型都需要自己的重载运算实现。 这意味着可以根据不同的类型有不同的行为。

一个 类型类 有名称,参数,和一个包含了名称和类型的类体。 名字是一种代指重载运算符的方式,参数决定了哪些方面的定义可以被重载,类体提供了可重载运算的名称和类型签名。 每一个可重载运算都被称为类型类的一个方法。 类型类可能会提供一些方法的默认实现,使得程序员从手动实现每个重载(只要实现可以被自动完成)中解放出来。

一个类型类的 实例 为给定参数提供了方法的实现。 实例可能是多态的,这种情况下它能接受多种参数,同时也可能在对于一些类型存在更高效的实现时提供更具体实现。

类型类参数要么是一个 输入参数(input parameters) (默认情况下),或者是一个 输出参数 (通过 outParam 修饰)。 在所有输出参数变为已知前,Lean 不会开始实例搜索。输出参数会在实例搜索过程中给出。 类型类的参数不一定要是一个类型,它也可以是一个常规值。 OfNat 类型类被用于重载自然数字面量,接受要被重载的 Nat 本身作为参数,这可以使实例限制允许的数字。

实例可能会被标注为 @[default_instance] 属性。 当一个实例是默认实例时,那么就会作为 Lean 因存在元变量而无法找到实例的回退。

常见语法的类型类

Lean 中多数中缀运算符都是用类型类来重载的。 例如,加法对应于 Add 类型类。 多数运算符都有与之对应的异质运算,该运算的两个参数不需要是同一种类型。 这些异质运算符使用前面加个 H 的类型类来重载,比如 HAdd

索引语法使用 GetElem 类型类来重载,该类型类包含证明。 GetElem 有两个输出参数,一个是要被从中提取出的元素的类型,另一个是用来证明索引值未越界的函数。 这个证明是用命题来描述的,Lean 会在索引时尝试证明这个命题。 当 Lean 在编译时不能检查列表或元组索引是否越界时,可以通过为索引操作添加 ? 来让检查发生在运行时。

函子

一个函子是一个支持映射运算的泛型。 这个映射运算“在原地”映射所有的元素,不会改变其他结构。 例如,列表是函子,所以列表上的映射不会删除,复制或混合列表中的元素。

如果定义了 map,那么这个类型就是一个函子。 Lean 中的 Functor 类型类还包含了额外的默认方法,这些方法可以将映射常数函数到值,替换所有类型是由多态变量给出的值为一个相同的新值。 对于一些函子,这比转换整个结构更高效。

派生实例

许多类型类都有非常标准的实现。 例如,布尔等价类型类 BEq 经常被实现为先检查参数是否有一样的构造子,然后检查他们的值是否相等。 这些类型类的实例可以 自动 创建。

当定义一个归纳类型或者结构体时,出现在声明末尾的 deriving 会让实例自动创建。 此外,deriving instance ... for ... 指令可以在数据类型定义外使用来生成一个实例。 因为每个可以派生实例的类都需要特殊处理,所以并不是所有的类都是可派生的。

强制转换

强制转换允许 Lean 向一个正常来说应该出现编译错误的地方插入一个函数调用,该调用将转换数据的类型,从而从错误中恢复。 例如,一个从任意类型 αOption α 的强制转换使得值可以直接写出,而不是被包裹在 some 构造子中。 这样 Option 就像是有空值类型的语言中的空值那样。

有许多不同的强制转换。 他们可以从不同的错误类型中恢复,他们都是用自己的类型类来描述的。 Coe 类型类用于从类型错误中恢复。 当 Lean 有一个 α 类型的表达式,但却希望这里是一个 β 类型时,Lean 会首先尝试串起一个能将 α 强制转换为 β 的链,仅当它无法这么做的时候才会宝座。 CoeDep 类将被强制转换的具体值作为额外参数,这样可以对该值进行进一步的类型类搜索,或者在实例中使用构造函数来限制转换的范围。 CoeFun 类在编译函数应用时会拦截“不是函数”的错误,并允许将函数位置的值转换为实际函数(如果可能的话)。

Monads

在C#和Kotlin中,?.运算符是一种在可能为null的值上查找属性或调用方法的方式。如果?.前的值为null,则整个表达式为null。否则,该非null值会被用于调用。多个?.可以链接起来,在这种情况下,第一个 null 结果将终止查找链。像这样链接null检查比编写和维护深层嵌套的 if 方便得多。

类似地,异常机制比手动检查和传递错误码方便得多。同时,通过使用专用日志记录框架(而不是让每个函数同时返回其日志结果和返回值)可以轻松地完成日志记录。 链接的空检查和异常通常要求语言设计者预料到这种用法,而日志记录框架通常利用副作用将记录日志的代码与日志的累积解耦。

所有这些功能以及更多功能都可以作为通用 API —— Monad 的实例在库代码中实现。Lean 提供了专门的语法,使此 API 易于使用,但也会妨碍理解幕后发生的事情。 本章从手动嵌套空检查的细节介绍开始,并由此构建到方便、通用的 API。 在此期间,请暂时搁置你的怀疑。

检查none:避免重复代码

在Lean中,模式匹配可用于链接空检查。 从列表中获取第一个项可通过可选的索引记号:

def first (xs : List α) : Option α :=
  xs[0]?

"结果必须是Option,因为空列表没有第一个项。 提取第一个和第三个项需要检查每个项都不为 none

def firstThird (xs : List α) : Option (α × α) :=
  match xs[0]? with
  | none => none
  | some first =>
    match xs[2]? with
    | none => none
    | some third =>
      some (first, third)

类似地,提取第一个、第三个和第五个项需要更多检查,以确保这些值不是 none

def firstThirdFifth (xs : List α) : Option (α × α × α) :=
  match xs[0]? with
  | none => none
  | some first =>
    match xs[2]? with
    | none => none
    | some third =>
      match xs[4]? with
      | none => none
      | some fifth =>
        some (first, third, fifth)

而将第七个项添加到此序列中则开始变得相当难以管理:

def firstThirdFifthSeventh (xs : List α) : Option (α × α × α × α) :=
  match xs[0]? with
  | none => none
  | some first =>
    match xs[2]? with
    | none => none
    | some third =>
      match xs[4]? with
      | none => none
      | some fifth =>
        match xs[6]? with
        | none => none
        | some seventh =>
          some (first, third, fifth, seventh)

这段代码有两个问题:提取数字和检查它们是否全部存在,但第二个问题是通过复制粘贴处理none情况的代码来解决的。 通常鼓励将重复的片段提取到辅助函数中:

def andThen (opt : Option α) (next : α → Option β) : Option β :=
  match opt with
  | none => none
  | some x => next x

该辅助函数类似于C#和Kotlin中的?.,用于处理none值。 它接受两个参数:一个可选值和一个在该值非none时应用的函数。 如果第一个参数是none,则辅助函数返回none。 如果第一个参数不是none,则该函数将应用于some 构造器的内容。

现在,firstThird 可以使用andThen重写:

def firstThird (xs : List α) : Option (α × α) :=
  andThen xs[0]? fun first =>
  andThen xs[2]? fun third =>
  some (first, third)

在Lean中,作为参数传递时,函数不需要用括号括起来。 以下等价定义使用了更多括号并缩进了函数体:

def firstThird (xs : List α) : Option (α × α) :=
  andThen xs[0]? (fun first =>
    andThen xs[2]? (fun third =>
      some (first, third)))

andThen辅助函数提供了一种让值流过的“管道”,具有特殊缩进的版本更能说明这一事实。 改进 andThen的语法可以使其更容易阅读和理解。

中缀运算符

在 Lean 中,可以使用 infixinfixlinfixr命令声明中缀运算符,分别用于非结合、左结合和右结合的情况。 当连续多次使用时, 左结合 运算符会将(开)括号堆叠在表达式的左侧。 加法运算符+ 是左结合的,因此 w + x + y + z 等价于 (((w + x) + y) + z)。 指数运算符 ^ 是右结合的,因此 w ^ x ^ y ^ z 等价于 (w ^ (x ^ (y ^ z)))。 比较运算符(如 <)是非结合的,因此 x < y < z是一个语法错误,需要手动添加括号。

以下声明将andThen声明为中缀运算符:

infixl:55 " ~~> " => andThen

冒号后面的数字声明了新中缀运算符的 优先级 。 在一般的数学记号中,x + y * z 等价于 x + (y * z),即使 +* 都是左结合的。 在 Lean 中,+ 的优先级为 65,* 的优先级为 70。 优先级更高的运算符应用于优先级较低的运算符之前。 根据~~>的声明,+*都具有更高的优先级,因此会被首先计算。 通常来说,找出最适合一组运算符的优先级需要一些实验和大量的示例。

在新的中缀运算符后面是一个双箭头 =>,指定中缀运算符使用的命名的函数。 Lean的标准库使用此功能将+*定义为指向HAdd.hAddHMul.hMul的中缀运算符,从而允许将类型类用于重载中缀运算符。 不过这里的andThen只是一个普通函数。

通过为andThen定义一个中缀运算符,firstThird可以被改写成一种,显化none检查的“管道”风格的方式:

def firstThirdInfix (xs : List α) : Option (α × α) :=
  xs[0]? ~~> fun first =>
  xs[2]? ~~> fun third =>
  some (first, third)

这种风格在编写较长的函数时更加精炼:

def firstThirdFifthSeventh (xs : List α) : Option (α × α × α × α) :=
  xs[0]? ~~> fun first =>
  xs[2]? ~~> fun third =>
  xs[4]? ~~> fun fifth =>
  xs[6]? ~~> fun seventh =>
  some (first, third, fifth, seventh)

错误消息的传递

像Lean这样的纯函数式语言并没有用于错误处理的内置异常机制,因为抛出或捕获异常超出了表达式逐步求值模型考虑的范围。 然而函数式程序肯定需要处理错误。 在firstThirdFifthSeventh的情况下,用户很可能需要知道列表有多长以及查找失败发生的位置。

这通常通过定义一个——错误或结果——的数据类型,并让带有异常的函数返回此类型来实现:

inductive Except (ε : Type) (α : Type) where
  | error : ε → Except ε α
  | ok : α → Except ε α
deriving BEq, Hashable, Repr

类型变量ε表示函数可能产生的错误类型。 调用者需要处理错误和成功两种情况,因此类型变量ε有点类似Java中需要检查的异常列表。

类似于 OptionExcept可用于指示在列表中找不到项的情况。 此时,错误的类型为 String

def get (xs : List α) (i : Nat) : Except String α :=
  match xs[i]? with
  | none => Except.error s!"Index {i} not found (maximum is {xs.length - 1})"
  | some x => Except.ok x

查找没有越界的值会得到Except.ok

def ediblePlants : List String :=
  ["ramsons", "sea plantain", "sea buckthorn", "garden nasturtium"]

#eval get ediblePlants 2
Except.ok "sea buckthorn"

查找越界的值将产生Except.error

#eval get ediblePlants 4
Except.error "Index 4 not found (maximum is 3)"

单个列表查找可以方便地返回一个值或错误:

def first (xs : List α) : Except String α :=
  get xs 0

然而,连续的两次列表查找则需要处理可能发生的失败情况:

def firstThird (xs : List α) : Except String (α × α) :=
  match get xs 0 with
  | Except.error msg => Except.error msg
  | Except.ok first =>
    match get xs 2 with
    | Except.error msg => Except.error msg
    | Except.ok third =>
      Except.ok (first, third)

向函数中添加另一个列表查找需要额外的错误处理:

def firstThirdFifth (xs : List α) : Except String (α × α × α) :=
  match get xs 0 with
  | Except.error msg => Except.error msg
  | Except.ok first =>
    match get xs 2 with
    | Except.error msg => Except.error msg
    | Except.ok third =>
      match get xs 4 with
      | Except.error msg => Except.error msg
      | Except.ok fifth =>
        Except.ok (first, third, fifth)

再继续添加一个列表查找则开始变得相当难以管理:

def firstThirdFifthSeventh (xs : List α) : Except String (α × α × α × α) :=
  match get xs 0 with
  | Except.error msg => Except.error msg
  | Except.ok first =>
    match get xs 2 with
    | Except.error msg => Except.error msg
    | Except.ok third =>
      match get xs 4 with
      | Except.error msg => Except.error msg
      | Except.ok fifth =>
        match get xs 6 with
        | Except.error msg => Except.error msg
        | Except.ok seventh =>
          Except.ok (first, third, fifth, seventh)

同样,一个通用的模式可以提取为一个辅助函数。 函数中的每一步都会检查错误,并只有在成功的情况下才进行之后的计算。 可以为Except定义andThen的新版本:

def andThen (attempt : Except e α) (next : α → Except e β) : Except e β :=
  match attempt with
  | Except.error msg => Except.error msg
  | Except.ok x => next x

Option一样,该andThen允许更简洁地定义firstThird

def firstThird' (xs : List α) : Except String (α × α) :=
  andThen (get xs 0) fun first  =>
  andThen (get xs 2) fun third =>
  Except.ok (first, third)

OptionExcept情况下,都有两个重复的模式:每一步都有对中间结果的检查,并已提取为andThen;有最终的成功结果,分别是someExcept.ok。 为了方便起见,成功的情况可提取为辅助函数ok

def ok (x : α) : Except ε α := Except.ok x

类似地,失败的情况可提取为辅助函数fail

def fail (err : ε) : Except ε α := Except.error err

okfail使得get可读性更好:

def get (xs : List α) (i : Nat) : Except String α :=
  match xs[i]? with
  | none => fail s!"Index {i} not found (maximum is {xs.length - 1})"
  | some x => ok x

在为andThen添加中缀运算符后,firstThird可以和返回Option的版本一样简洁:

infixl:55 " ~~> " => andThen

def firstThird (xs : List α) : Except String (α × α) :=
  get xs 0 ~~> fun first =>
  get xs 2 ~~> fun third =>
  ok (first, third)

该技术同样适用于更长的函数:

def firstThirdFifthSeventh (xs : List α) : Except String (α × α × α × α) :=
  get xs 0 ~~> fun first =>
  get xs 2 ~~> fun third =>
  get xs 4 ~~> fun fifth =>
  get xs 6 ~~> fun seventh =>
  ok (first, third, fifth, seventh)

日志记录

当一个数字除以2时没有余数则称它为偶数:

def isEven (i : Int) : Bool :=
  i % 2 == 0

函数sumAndFindEvens计算列表所有元素的加和,同时记录沿途遇到的偶数:

def sumAndFindEvens : List Int → List Int × Int
  | [] => ([], 0)
  | i :: is =>
    let (moreEven, sum) := sumAndFindEvens is
    (if isEven i then i :: moreEven else moreEven, sum + i)

此函数是一个常见模式的简化示例。 许多程序需要遍历一次数据结构,计算一个主要结果的同时累积某种额外的结果。 一个例子是日志记录:一个类型为IO的程序会将日志输出到磁盘上的文件中,但是由于磁盘在 Lean 函数的数学世界之外,因此对基于IO的日志相关的证明变得十分困难。 另一个例子是同时计算树的中序遍历和所有节点的加和的函数,它必须记录每个访问的节点:

def inorderSum : BinTree Int → List Int × Int
  | BinTree.leaf => ([], 0)
  | BinTree.branch l x r =>
    let (leftVisited, leftSum) := inorderSum l
    let (hereVisited, hereSum) := ([x], x)
    let (rightVisited, rightSum) := inorderSum r
    (leftVisited ++ hereVisited ++ rightVisited, leftSum + hereSum + rightSum)

sumAndFindEvensinorderSum都具有共同的重复结构。 计算的每一步都返回一个对(pair),由由数据列表和主要结果组成。 在下一步中列表会被附加新的元素,计算新的主要结果并与附加的列表再次配对。 通过对sumAndFindEvens稍微改写,保存偶数和计算加和的关注点则更加清晰地分离,共同的结构变得更加明显:

def sumAndFindEvens : List Int → List Int × Int
  | [] => ([], 0)
  | i :: is =>
    let (moreEven, sum) := sumAndFindEvens is
    let (evenHere, ()) := (if isEven i then [i] else [], ())
    (evenHere ++ moreEven, sum + i)

为了清晰起见,可以给由累积结果和值组成的对(pair)起一个专有的名字:

structure WithLog (logged : Type) (α : Type) where
  log : List logged
  val : α

同样,保存累积结果列表的同时传递一个值到下一步的过程,可以提取为一个辅助函数,再次命名为 andThen

def andThen (result : WithLog α β) (next : β → WithLog α γ) : WithLog α γ :=
  let {log := thisOut, val := thisRes} := result
  let {log := nextOut, val := nextRes} := next thisRes
  {log := thisOut ++ nextOut, val := nextRes}

在可能发生错误的语境下,ok表示一个总是成功的操作。然而在这里,它仅简单地返回一个值而不产生任何日志:

def ok (x : β) : WithLog α β := {log := [], val := x}

正如Except提供fail作为一种可能性,WithLog应该允许将项添加到日志中。 它不需要返回任何有意义的结果,所以返回类型为Unit

def save (data : α) : WithLog α Unit :=
  {log := [data], val := ()}

WithLogandThenoksave可以将两个程序中的,日志记录与求和问题分开:

def sumAndFindEvens : List Int → WithLog Int Int
  | [] => ok 0
  | i :: is =>
    andThen (if isEven i then save i else ok ()) fun () =>
    andThen (sumAndFindEvens is) fun sum =>
    ok (i + sum)

def inorderSum : BinTree Int → WithLog Int Int
  | BinTree.leaf => ok 0
  | BinTree.branch l x r =>
    andThen (inorderSum l) fun leftSum =>
    andThen (save x) fun () =>
    andThen (inorderSum r) fun rightSum =>
    ok (leftSum + x + rightSum)

同样地,中缀运算符有助于专注于正确的过程:

infixl:55 " ~~> " => andThen

def sumAndFindEvens : List Int → WithLog Int Int
  | [] => ok 0
  | i :: is =>
    (if isEven i then save i else ok ()) ~~> fun () =>
    sumAndFindEvens is ~~> fun sum =>
    ok (i + sum)

def inorderSum : BinTree Int → WithLog Int Int
  | BinTree.leaf => ok 0
  | BinTree.branch l x r =>
    inorderSum l ~~> fun leftSum =>
    save x ~~> fun () =>
    inorderSum r ~~> fun rightSum =>
    ok (leftSum + x + rightSum)

对树节点编号

树的每个节点的 中序编号 指的是:在中序遍历中被访问的次序。例如,考虑如下aTree

open BinTree in
def aTree :=
  branch
    (branch
       (branch leaf "a" (branch leaf "b" leaf))
       "c"
       leaf)
    "d"
    (branch leaf "e" leaf)

它的中序编号为:

BinTree.branch
  (BinTree.branch
    (BinTree.branch (BinTree.leaf) (0, "a") (BinTree.branch (BinTree.leaf) (1, "b") (BinTree.leaf)))
    (2, "c")
    (BinTree.leaf))
  (3, "d")
  (BinTree.branch (BinTree.leaf) (4, "e") (BinTree.leaf))

树用递归函数来处理最为自然,但树的常见的递归模式并不适合计算中序编号。 这是因为左子树中分配的最大编号将用于确定当前节点的编号,然后用于确定右子树编号的起点。 在命令式语言中,可以使用持有下一个被分配编号的可变变量来解决此问题。 以下Python程序使用可变变量计算中序编号:

class Branch:
    def __init__(self, value, left=None, right=None):
        self.left = left
        self.value = value
        self.right = right
    def __repr__(self):
        return f'Branch({self.value!r}, left={self.left!r}, right={self.right!r})'

def number(tree):
    num = 0
    def helper(t):
        nonlocal num
        if t is None:
            return None
        else:
            new_left = helper(t.left)
            new_value = (num, t.value)
            num += 1
            new_right = helper(t.right)
            return Branch(left=new_left, value=new_value, right=new_right)

    return helper(tree)

aTree在Python中等价定义是:

a_tree = Branch("d",
                left=Branch("c",
                            left=Branch("a", left=None, right=Branch("b")),
                            right=None),
                right=Branch("e"))

并且它的编号是:

>>> number(a_tree)
Branch((3, 'd'), left=Branch((2, 'c'), left=Branch((0, 'a'), left=None, right=Branch((1, 'b'), left=None, right=None)), right=None), right=Branch((4, 'e'), left=None, right=None))

尽管Lean没有可变变量,但有另外一种解决方法。 可变变量可以认为具有两个相关方面:函数调用时的值和函数返回时的值。 换句话说,使用可变变量的函数可以看作:将变量的起始值作为参数、返回变量的最终值和结果构成的元组的函数。 然后可以将此最终值作为参数传递给下一步。

正如Python示例中定义可变变量的外部函数和更改变量的内部辅助函数一样,Lean版本使用:提供变量初值并明确返回结果的外部函数,以及计算编号树的同时传递变量值的内部辅助函数:

def number (t : BinTree α) : BinTree (Nat × α) :=
  let rec helper (n : Nat) : BinTree α → (Nat × BinTree (Nat × α))
    | BinTree.leaf => (n, BinTree.leaf)
    | BinTree.branch left x right =>
      let (k, numberedLeft) := helper n left
      let (i, numberedRight) := helper (k + 1) right
      (i, BinTree.branch numberedLeft (k, x) numberedRight)
  (helper 0 t).snd

此代码与传递noneOption代码、传递errorExcept代码、以及累积日志的WithLog代码一样,混杂了两个问题:传递计数器的值,以及实际遍历树以查找结果。 与那些情况一样,可以定义一个andThen辅助函数,将状态在计算的步骤之间传递。 第一步是为以下模式命名:将输入状态作为参数并返回输出状态和值:

def State (σ : Type) (α : Type) : Type :=
  σ → (σ × α)

State的情况下,ok函数原封不动地传递输入状态、以及输入的值:

def ok (x : α) : State σ α :=
  fun s => (s, x)

在使用可变变量时,有两个基本操作:读取和新值替换旧值。 读取当前值意味着——记录输入状态、将其放入输出状态,然后直接返回记录的输入状态:

def get : State σ σ :=
  fun s => (s, s)

写入新值意味着——忽略输入状态,并将提供的新值直接放入输出状态:

def set (s : σ) : State σ Unit :=
  fun _ => (s, ())

最后,可以将first函数的输出状态和返回值传递到next函数中,以此实现这两个函数的先后调用:

def andThen (first : State σ α) (next : α → State σ β) : State σ β :=
  fun s =>
    let (s', x) := first s
    next x s'

infixl:55 " ~~> " => andThen

通过State和它的辅助函数,可以模拟局部可变状态:

def number (t : BinTree α) : BinTree (Nat × α) :=
  let rec helper : BinTree α → State Nat (BinTree (Nat × α))
    | BinTree.leaf => ok BinTree.leaf
    | BinTree.branch left x right =>
      helper left ~~> fun numberedLeft =>
      get ~~> fun n =>
      set (n + 1) ~~> fun () =>
      helper right ~~> fun numberedRight =>
      ok (BinTree.branch numberedLeft (n, x) numberedRight)
  (helper t 0).snd

因为State只模拟一个局部变量,所以getset不需要任何特定的变量名。

单子:一种函数式设计模式

以上的每个示例都包含下述结构:

  • 一个多态类型,例如OptionExcept εWithLog loggedState σ
  • 一个运算符andThen,用来处理连续、重复、具有此类型的程序序列
  • 一个运算符ok,它(在某种意义上)是使用该类型最无聊的方式
  • 一系列其他操作,例如nonefailsaveget,指出了使用对应类型的方式

这种风格的API统称为 单子 (Monad)。 虽然单子的思想源自于一门称为范畴论的数学分支,但为了将它们用于编程,并不需要理解范畴论。 单子的关键思想是,每个单子都使用纯函数式语言Lean提供的工具对特定类型的副作用进行编码。 例如Option表示可能通过返回none而失败的程序,Except表示可能抛出异常的程序,WithLog表示在运行过程中累积日志的程序,State表示具有单个可变变量的程序。

Monad类型类

无需为每个单子都实现 okandThen 这样的运算符,Lean标准库包含一个类型类, 允许它们被重载,以便相同的运算符可用于 任何 单子。 单子有两个操作,分别相当于 okandThen

class Monad (m : Type → Type) where
  pure : α → m α
  bind : m α → (α → m β) → m β

这个定义略微简化了。 Lean 标准库中的实际定义更复杂一些,稍后会介绍。

OptionExceptMonad 实例,可以通过调整它们各自的 andThen 操作的定义来创建:

instance : Monad Option where
  pure x := some x
  bind opt next :=
    match opt with
    | none => none
    | some x => next x

instance : Monad (Except ε) where
  pure x := Except.ok x
  bind attempt next :=
    match attempt with
    | Except.error e => Except.error e
    | Except.ok x => next x

例如 firstThirdFifthSeventh 原本对 Option αExcept String α 类型分别定义。 现在,它可以被定义为对 任何 单子都有效的多态函数。 但是,它需要接受一个参数作为查找函数,因为不同的单子可能以不同的方式找不到结果。 bind 的中缀运算符是 >>=, 它扮演与示例中 ~~> 相同的角色。

def firstThirdFifthSeventh [Monad m] (lookup : List α → Nat → m α) (xs : List α) : m (α × α × α × α) :=
  lookup xs 0 >>= fun first =>
  lookup xs 2 >>= fun third =>
  lookup xs 4 >>= fun fifth =>
  lookup xs 6 >>= fun seventh =>
  pure (first, third, fifth, seventh)

给定作为示例的slowMammals和fastBirds列表,该 firstThirdFifthSeventh 实现可与 Option 一起使用:

def slowMammals : List String :=
  ["Three-toed sloth", "Slow loris"]

def fastBirds : List String := [
  "Peregrine falcon",
  "Saker falcon",
  "Golden eagle",
  "Gray-headed albatross",
  "Spur-winged goose",
  "Swift",
  "Anna's hummingbird"
]

#eval firstThirdFifthSeventh (fun xs i => xs[i]?) slowMammals
none
#eval firstThirdFifthSeventh (fun xs i => xs[i]?) fastBirds
some ("Peregrine falcon", "Golden eagle", "Spur-winged goose", "Anna's hummingbird")

在将 Except 的查找函数 get 重命名为更具体的形式后, 完全相同的 firstThirdFifthSeventh 实现也可以与 Except 一起使用:

def getOrExcept (xs : List α) (i : Nat) : Except String α :=
  match xs[i]? with
  | none => Except.error s!"Index {i} not found (maximum is {xs.length - 1})"
  | some x => Except.ok x

#eval firstThirdFifthSeventh getOrExcept slowMammals
Except.error "Index 2 not found (maximum is 1)"
#eval firstThirdFifthSeventh getOrExcept fastBirds
Except.ok ("Peregrine falcon", "Golden eagle", "Spur-winged goose", "Anna's hummingbird")

m 必须有 Monad 实例,这一事实这意味着可以使用 >>=pure 运算符。

通用的单子运算符

由于许多不同类型都是单子,因此对 任何 单子多态的函数非常强大。 例如,函数 mapMmap 的另一个版本,它使用 Monad 将函数调用的结果按顺序连接起来:

def mapM [Monad m] (f : α → m β) : List α → m (List β)
  | [] => pure []
  | x :: xs =>
    f x >>= fun hd =>
    mapM f xs >>= fun tl =>
    pure (hd :: tl)

函数参数 f 的返回类型决定了将使用哪个 Monad 实例。 换句话说,mapM可用于生成日志的函数、可能失败的函数、或使用可变状态的函数。 由于 f 的类型直接决定了可用的效应(Effects),因此API设计人员可以对其进行严格控制。 译者注:效应(Effects)是函数式编程中与 Monad 密切相关的主题, 实际上对效应的控制比此处原文所述更复杂一些,但超出了本文的内容。 另外副作用(Side Effects)也是一种效应。

本章简介所介绍的,State σ α表示使用类型为 σ 的可变变量,并返回类型为 α 的值的程序。 这些程序实际上是从起始状态到值和最终状态构成的对(pair)的函数。 Monad类型类要求:类型参数期望另一个类型参数,即它应该是Type → Type。 这意味着 State 的实例应提及状态类型σ,使它成为实例的参数:

instance : Monad (State σ) where
  pure x := fun s => (s, x)
  bind first next :=
    fun s =>
      let (s', x) := first s
      next x s'

这意味着在使用 bindgetset 排序时,状态的类型不能更改,这是具有状态的计算的合理规则。运算符 increment 将保存的状态加上一定量,并返回原值:

def increment (howMuch : Int) : State Int Int :=
  get >>= fun i =>
  set (i + howMuch) >>= fun () =>
  pure i

mapMincrement 一起使用会得到一个:计算列表元素加和的程序。 更具体地说,可变变量包含到目前为止的和,而作为结果的列表包含各个步骤前状态变量的值。 换句话说,mapM increment的类型为List Int → State Int (List Int),展开 State 的定义得到List Int → Int → (Int× List Int)。 它将初始值作为参数,应为0

#eval mapM increment [1, 2, 3, 4, 5] 0
(15, [0, 1, 3, 6, 10])

可以使用 WithLog 表示日志记录效应。 就和 State 一样,它的 Monad 实例对于被记录数据的类型也是多态的:

instance : Monad (WithLog logged) where
  pure x := {log := [], val := x}
  bind result next :=
    let {log := thisOut, val := thisRes} := result
    let {log := nextOut, val := nextRes} := next thisRes
    {log := thisOut ++ nextOut, val := nextRes}

saveIfEven函数记录偶数,但将参数原封不动返回:

def saveIfEven (i : Int) : WithLog Int Int :=
  (if isEven i then
    save i
   else pure ()) >>= fun () =>
  pure i

mapM 和该函数一起使用,会生成一个记录偶数的日志、和未更改的输入列表:

#eval mapM saveIfEven [1, 2, 3, 4, 5]
{ log := [2, 4], val := [1, 2, 3, 4, 5] }

恒等单子

单子将具有效应(Effects)的程序(例如失败、异常或日志记录)编码为数据和函数的显式表示。 有时API会使用单子来提高灵活性,但API的使用方可能不需要任何效应。 恒等单子 (Identity Monad)是一个没有任何效应的单子,允许将纯(pure)代码与monadic API一起使用:

def Id (t : Type) : Type := t

instance : Monad Id where
  pure x := x
  bind x f := f x

pure的类型应为 α → Id α,但Id α 归约α。类似地,bind 的类型应为α → (α → Id β) → Id β。 由于这 归约α → (α → β) → β,因此可以将第二个参数应用于第一个参数得到结果。 译者注:此处 归约 一词原文为reduces to,实际含义为beta-reduction,请见类型论相关资料。

"使用恒等单子时,mapM等同于map。但是要以这种方式调用它,Lean需要额外的提示来表明目标单子是Id

#eval mapM (m := Id) (· + 1) [1, 2, 3, 4, 5]
[2, 3, 4, 5, 6]

省略提示则会导致错误:

#eval mapM (· + 1) [1, 2, 3, 4, 5]
failed to synthesize instance
  HAdd Nat Nat (?m.9063 ?m.9065)

导致错误的原因是:一个元变量应用于另一个元变量,使得Lean不会反向运行类型计算。 函数的返回类型应该是应用于其他类型参数的单子。 类似地,将 mapM 和未提供任何特定单子类型信息的函数一起使用,会导致"instance problem stuck"错误:

#eval mapM (fun x => x) [1, 2, 3, 4, 5]
typeclass instance problem is stuck, it is often due to metavariables
  Monad ?m.9063

单子约定

正如 BEqHashable 的每一对实例都应该确保任何两个相等的值具有相同的哈希值,有一些是固有的约定是每个 Monad 的实例都应遵守的。 首先,pure应为 bind 的左单位元,即 bind (pure v) f 应与 f v 等价。 其次,pure应为 bind 的右单位元,即 bind v pure 应与 v 等价。 最后,bind应满足结合律,即 bind (bind v f) g 应与 bind v (fun x => bind (f x) g) 等价。

这些约定保证了具有效应的程序的预期属性。 由于 pure 不导致效应,因此用 bind 将其与其他效应接连执行不应改变结果。 bind满足的结合律则意味着先计算哪一部分无关紧要,只要保证效应的顺序不变即可。

练习

映射一棵树

定义函数BinTree.mapM。 通过类比列表的mapM,此函数应将单子函数应用于树中的每个节点,作为前序遍历。 类型签名应为:

def BinTree.mapM [Monad m] (f : α → m β) : BinTree α → m (BinTree β)

Option单子的约定

首先充分论证 OptionMonad 实例满足单子约定。 然后,考虑以下实例:

instance : Monad Option where
  pure x := some x
  bind opt next := none

这两个方法都有正确的类型。 但这个实例却违反了单子约定,为什么?

例子:利用单子实现算术表达式求值

单子是一种将具有副作用的程序编入没有副作用的语言中的范式。 但很容易将此误解为:承认纯函数式编程缺少一些重要的东西,程序员要越过这些障碍才能编写一个普通的程序。 虽然使用 Monad 确实给程序带来了语法上的成本,但它带来了两个重要的优点:

  1. 程序必须在类型中诚实地告知它们使用的作用(Effects)。因此看一眼类型签名就可以知道程序能做的所有事情,而不只是知道它接受什么和返回什么。
  2. 并非每种语言都提供相同的作用。例如只有某些语言有异常。其他语言具有独特的新奇作用,例如 Icon's searching over multiple values以及Scheme 或Ruby的continuations。由于单子可以编码 任何 作用,因此程序员可以选择最适合给定程序的作用,而不是局限于语言开发者提供的作用。

对许多单子都有意义的一个例子是算术表达式的求值器。

算术表达式

一条算术表达式要么是一个字面量(Literal),要么是应用于两个算术表达式的二元运算。运算符包括加法、减法、乘法和除法:

inductive Expr (op : Type) where
  | const : Int → Expr op
  | prim : op → Expr op → Expr op → Expr op


inductive Arith where
  | plus
  | minus
  | times
  | div

表达式 2 + 3 表示为:

open Expr in
open Arith in
def twoPlusThree : Expr Arith :=
  prim plus (const 2) (const 3)

14 / (45 - 5 * 9) 表示为:

open Expr in
open Arith in
def fourteenDivided : Expr Arith :=
  prim div (const 14) (prim minus (const 45) (prim times (const 5) (const 9)))

对表达式求值

由于表达式包含除法,而除以零是未定义的,因此求值可能会失败。 表示失败的一种方法是使用 Option

def evaluateOption : Expr Arith → Option Int
  | Expr.const i => pure i
  | Expr.prim p e1 e2 =>
    evaluateOption e1 >>= fun v1 =>
    evaluateOption e2 >>= fun v2 =>
    match p with
    | Arith.plus => pure (v1 + v2)
    | Arith.minus => pure (v1 - v2)
    | Arith.times => pure (v1 * v2)
    | Arith.div => if v2 == 0 then none else pure (v1 / v2)

此定义使用 Monad Option 实例,来传播从二元运算符的两个分支求值产生的失败。 然而该函数混合了两个问题:对子表达式的求值和对运算符的计算。 可以将其拆分为两个函数:

def applyPrim : Arith → Int → Int → Option Int
  | Arith.plus, x, y => pure (x + y)
  | Arith.minus, x, y => pure (x - y)
  | Arith.times, x, y => pure (x * y)
  | Arith.div, x, y => if y == 0 then none else pure (x / y)

def evaluateOption : Expr Arith → Option Int
  | Expr.const i => pure i
  | Expr.prim p e1 e2 =>
    evaluateOption e1 >>= fun v1 =>
    evaluateOption e2 >>= fun v2 =>
    applyPrim p v1 v2

运行 #eval evaluateOption fourteenDivided 产生 none, 与预期一样, 但这个报错信息却并不十分有用. 由于代码使用 >>= 而非显式处理none,所以只需少量修改即可在失败时提供错误消息:

def applyPrim : Arith → Int → Int → Except String Int
  | Arith.plus, x, y => pure (x + y)
  | Arith.minus, x, y => pure (x - y)
  | Arith.times, x, y => pure (x * y)
  | Arith.div, x, y =>
    if y == 0 then
      Except.error s!"Tried to divide {x} by zero"
    else pure (x / y)


def evaluateExcept : Expr Arith → Except String Int
  | Expr.const i => pure i
  | Expr.prim p e1 e2 =>
    evaluateExcept e1 >>= fun v1 =>
    evaluateExcept e2 >>= fun v2 =>
    applyPrim p v1 v2

唯一区别是:类型签名提到的是 Except String 而非 Option,并且失败时使用 Except.error 而不是 none。通过让 evaluate 对单子多态,并将对应的求值函数作为参数 applyPrim 传递,单个求值器就足够以两种形式报告错误:

def applyPrimOption : Arith → Int → Int → Option Int
  | Arith.plus, x, y => pure (x + y)
  | Arith.minus, x, y => pure (x - y)
  | Arith.times, x, y => pure (x * y)
  | Arith.div, x, y =>
    if y == 0 then
      none
    else pure (x / y)

def applyPrimExcept : Arith → Int → Int → Except String Int
  | Arith.plus, x, y => pure (x + y)
  | Arith.minus, x, y => pure (x - y)
  | Arith.times, x, y => pure (x * y)
  | Arith.div, x, y =>
    if y == 0 then
      Except.error s!"Tried to divide {x} by zero"
    else pure (x / y)

def evaluateM [Monad m] (applyPrim : Arith → Int → Int → m Int): Expr Arith → m Int
  | Expr.const i => pure i
  | Expr.prim p e1 e2 =>
    evaluateM applyPrim e1 >>= fun v1 =>
    evaluateM applyPrim e2 >>= fun v2 =>
    applyPrim p v1 v2

将其与 applyPrimOption 一起使用作用就和最初的 evaluate 一样:

#eval evaluateM applyPrimOption fourteenDivided
none

类似地,和 applyPrimExcept 函数一起使用时作用与带有错误消息的版本相同:

#eval evaluateM applyPrimExcept fourteenDivided
Except.error "Tried to divide 14 by zero"

代码仍有改进空间。 applyPrimOptionapplyPrimExcept 函数仅在除法处理上有所不同,因此可以将它提取到另一个参数中:

def applyDivOption (x : Int) (y : Int) : Option Int :=
    if y == 0 then
      none
    else pure (x / y)

def applyDivExcept (x : Int) (y : Int) : Except String Int :=
    if y == 0 then
      Except.error s!"Tried to divide {x} by zero"
    else pure (x / y)

def applyPrim [Monad m] (applyDiv : Int → Int → m Int) : Arith → Int → Int → m Int
  | Arith.plus, x, y => pure (x + y)
  | Arith.minus, x, y => pure (x - y)
  | Arith.times, x, y => pure (x * y)
  | Arith.div, x, y => applyDiv x y

def evaluateM [Monad m] (applyDiv : Int → Int → m Int): Expr Arith → m Int
  | Expr.const i => pure i
  | Expr.prim p e1 e2 =>
    evaluateM applyDiv e1 >>= fun v1 =>
    evaluateM applyDiv e2 >>= fun v2 =>
    applyPrim applyDiv p v1 v2

在重构后的代码中,两条路径仅在对失败情况的处理上有所不同,这一事实显而易见。

额外的作用

在考虑求值器时,失败和异常并不是唯一值得在意的作用。虽然除法的唯一副作用是失败,但若要增加其他运算符的支持,则可能需要表达对应的作用。

第一步是重构,从原始数据类型中提取除法:

inductive Prim (special : Type) where
  | plus
  | minus
  | times
  | other : special → Prim special

inductive CanFail where
  | div

名称 CanFail 表明被除法引入的作用是可能发生的失败。

第二步是将 evaluateM 的作为除法计算的参数扩展,以便它可以处理任何特殊运算符:

def divOption : CanFail → Int → Int → Option Int
  | CanFail.div, x, y => if y == 0 then none else pure (x / y)

def divExcept : CanFail → Int → Int → Except String Int
  | CanFail.div, x, y =>
    if y == 0 then
      Except.error s!"Tried to divide {x} by zero"
    else pure (x / y)

def applyPrim [Monad m] (applySpecial : special → Int → Int → m Int) : Prim special → Int → Int → m Int
  | Prim.plus, x, y => pure (x + y)
  | Prim.minus, x, y => pure (x - y)
  | Prim.times, x, y => pure (x * y)
  | Prim.other op, x, y => applySpecial op x y

def evaluateM [Monad m] (applySpecial : special → Int → Int → m Int): Expr (Prim special) → m Int
  | Expr.const i => pure i
  | Expr.prim p e1 e2 =>
    evaluateM applySpecial e1 >>= fun v1 =>
    evaluateM applySpecial e2 >>= fun v2 =>
    applyPrim applySpecial p v1 v2

无作用

Empty类型没有构造子,因此没有任何取值,就像Scala或Kotlin中的 Nothing 类型。 在Scala和Kotlin中,返回类型为 Nothing 表示永不返回结果的计算,例如导致程序崩溃、或引发异常、或陷入无限循环的函数。 参数类型为 Nothing 表示函数是死代码,因为我们永远无法构造出合适的参数值来调用它。 Lean 不支持无限循环和异常,但 Empty 仍然可作为向类型系统说明函数不可被调用的标志。 当 E 是一条表达式,但它的类型没有任何取值时,使用 nomatch E 向Lean说明当前表达式不返回结果,因为它永远不会被调用。

Empty 用作 Prim 的参数,表示除了 Prim.plusPrim.minusPrim.times 之外没有其他情况,因为不可能找到一个 Empty 类型的值来放在 Prim.other 构造子中。 由于类型为 Empty 的运算符应用于两个整数的函数永远不会被调用,所以它不需要返回结果。 因此,它可以在 任何 单子中使用:

def applyEmpty [Monad m] (op : Empty) (_ : Int) (_ : Int) : m Int :=
  nomatch op

这可以与恒等单子 Id 一起使用,用来计算没有任何副作用的表达式:

open Expr Prim in
#eval evaluateM (m := Id) applyEmpty (prim plus (const 5) (const (-14)))
-9

非确定性搜索

遇到除以零时,除了直接失败并结束之外,还可以回溯并尝试不同的输入。 给定适当的单子,同一个 evaluateM 可以对不致失败的答案 集合 执行非确定性搜索。 这要求除了除法之外,还需要指定选择结果的方式。 一种方法是在表达式的语言中添加一个函数choose,告诉求值器在搜索非失败结果时选择其中一个参数。

求值结果现在变成一个多重集合(multiset),而不是一个单一值 求值到多重集合的规则如下:

  • 常量 \( n \) 求值为单元素集合 \( {n} \)。
  • 除法以外的算术运算符作用于两个参数的笛卡尔积中的每一对,所以 \( X + Y \) 求值为 \( \{ x + y \mid x ∈ X, y ∈ Y \} \)。
  • 除法 \( X / Y \) 求值为 \( \{ x / y \mid x ∈ X, y ∈ Y, y ≠ 0\} \). 换句话说,所有 \( Y \) 中的 \( 0 \) 都被丢弃。
  • 选择 \( \mathrm{choose}(x, y) \) 求值为 \( \{ x, y \} \)。

例如, \( 1 + \mathrm{choose}(2, 5) \) 求值为 \( \{ 3, 6 \} \), \(1 + 2 / 0 \) 求值为 \( \{\} \),并且 \( 90 / (\mathrm{choose}(-5, 5) + 5) \) 求值为 \( \{ 9 \} \)。 使用多重集合而非集合,是为了避免处理元素重复的情况而使代码过于复杂。

表示这种非确定性作用的单子必须能够处理没有答案的情况,以及至少有一个答案和其他答案的情况:

inductive Many (α : Type) where
  | none : Many α
  | more : α → (Unit → Many α) → Many α

该数据类型看起来非常像List。 不同之处在于,cons存储列表的其余部分,而 more 存储一个函数,该函数仅在需要时才会被调用来计算下一个值。 这意味着 Many 的使用者可以在找到一定数量的结果后停止搜索。

单个结果由 more 构造子表示,该构造子不返回任何进一步的结果:

def Many.one (x : α) : Many α := Many.more x (fun () => Many.none)

两个作为结果的多重集合的并集,可以通过检查第一个是否为空来计算。 如果第一个为空则第二个多重集合就是并集。 如果非空,则并集由第一个多重集合的第一个元素,紧跟着其余部分与第二个多重集的并集:

def Many.union : Many α → Many α → Many α
  | Many.none, ys => ys
  | Many.more x xs, ys => Many.more x (fun () => union (xs ()) ys)

对值列表搜索会比手动构造多重集合更方便。 函数 Many.fromList 将列表转换为结果的多重集合:

def Many.fromList : List α → Many α
  | [] => Many.none
  | x :: xs => Many.more x (fun () => fromList xs)

类似地,一旦搜索已经确定,就可以方便地提取固定数量的值或所有值:

def Many.take : Nat → Many α → List α
  | 0, _ => []
  | _ + 1, Many.none => []
  | n + 1, Many.more x xs => x :: (xs ()).take n

def Many.takeAll : Many α → List α
  | Many.none => []
  | Many.more x xs => x :: (xs ()).takeAll

Monad Many实例需要一个 bind 运算符。 在非确定性搜索中,对两个操作进行排序包括:从第一步中获取所有可能性,并对每种可能性都运行程序的其余部分,取结果的并集。 换句话说,如果第一步返回三个可能的答案,则需要对这三个答案分别尝试第二步。 由于第二步为每个输入都可以返回任意数量的答案,因此取它们的并集表示整个搜索空间。

def Many.bind : Many α → (α → Many β) → Many β
  | Many.none, _ =>
    Many.none
  | Many.more x xs, f =>
    (f x).union (bind (xs ()) f)

Many.oneMany.bind 遵循单子约定。 要检查 Many.bind (Many.one v) f 是否与 f v 相同,首先应最大限度地计算表达式:

Many.bind (Many.one v) f
===>
Many.bind (Many.more v (fun () => Many.none)) f
===>
(f v).union (Many.bind Many.none f)
===>
(f v).union Many.none

空集是 union 的右单位元,因此答案等同于f v。 要检查 Many.bind v Many.one 是否与 v 相同,需要考虑 Many.one 应用于 v 的各元素结果的并集。 换句话说,如果 v 的形式为 {v1, v2, v3, ..., vn},则Many.bind v Many.one{v1} ∪ {v2} ∪ {v3} ∪ ... ∪ {vn},即{v1, v2, v3, ..., vn}

最后,要检查 Many.bind 是否满足结合律,需要检查 Many.bind (Many.bind bind v f) g 是否与 Many.bind v (fun x => Many.bind (f x) g) 相同。 如果 v 的形式为{v1, v2, v3, ..., vn},则:

Many.bind v f
===>
f v1 ∪ f v2 ∪ f v3 ∪ ... ∪ f vn

which means that

Many.bind (Many.bind bind v f) g
===>
Many.bind (f v1) g ∪
Many.bind (f v2) g ∪
Many.bind (f v3) g ∪
... ∪
Many.bind (f vn) g

与此类似,

Many.bind v (fun x => Many.bind (f x) g)
===>
(fun x => Many.bind (f x) g) v1 ∪
(fun x => Many.bind (f x) g) v2 ∪
(fun x => Many.bind (f x) g) v3 ∪
... ∪
(fun x => Many.bind (f x) g) vn
===>
Many.bind (f v1) g ∪
Many.bind (f v2) g ∪
Many.bind (f v3) g ∪
... ∪
Many.bind (f vn) g

因此两边相等,所以 Many.bind 满足结合律。

由此得到的单子实例为:

instance : Monad Many where
  pure := Many.one
  bind := Many.bind

利用此单子,下例可找到列表中所有加起来等于15的数字组合:

def addsTo (goal : Nat) : List Nat → Many (List Nat)
  | [] =>
    if goal == 0 then
      pure []
    else
      Many.none
  | x :: xs =>
    if x > goal then
      addsTo goal xs
    else
      (addsTo goal xs).union
        (addsTo (goal - x) xs >>= fun answer =>
         pure (x :: answer))

(译者注:这是一个动态规划算法)对列表进行递归搜索。 当输入列表为空且目标为 0 时,返回空列表表示成功;否则返回 Many.none 表示失败,因为空输入不可能得到非0加和。 当列表非空时,有两种可能性:若输入列表的第一个元素大于goal,此时它的任何加和都大于 0 因此不可能是候选者;若第一个元素不大于goal,可以参与后续的搜索。 如果列表的头部x 不是 候选者,对列表的尾部xs递归搜索。 如果头部是候选者,则有两种用 Many.union 合并起来的可能性:找到的解含有当前的x,或者不含有。 不含x的解通过xs递归搜索找到;而含有x的解则通过从goal中减去x,然后将x附加到递归的解中得到。

让我们回到产生多重集合的算术求值器,bothneither 运算符可以写成如下形式:

inductive NeedsSearch
  | div
  | choose

def applySearch : NeedsSearch → Int → Int → Many Int
  | NeedsSearch.choose, x, y =>
    Many.fromList [x, y]
  | NeedsSearch.div, x, y =>
    if y == 0 then
      Many.none
    else Many.one (x / y)

可以用这些运算符对前面的示例求值:

open Expr Prim NeedsSearch

#eval (evaluateM applySearch (prim plus (const 1) (prim (other choose) (const 2) (const 5)))).takeAll
[3, 6]
#eval (evaluateM applySearch (prim plus (const 1) (prim (other div) (const 2) (const 0)))).takeAll
[]
#eval (evaluateM applySearch (prim (other div) (const 90) (prim plus (prim (other choose) (const (-5)) (const 5)) (const 5)))).takeAll
[9]

自定义环境

可以通过允许将字符串当作运算符,然后提供从字符串到它们的实现函数之间的映射,使求值器可由用户扩展。 例如,用户可以用余数运算或最大值运算来扩展求值器。 从函数名称到函数实现的映射称为 环境

环境需要在每层递归调用之间传递。 因此一开始 evaluateM 看起来需要一个额外的参数来保存环境,并且该参数需要在每次递归调用时传递。 然而,像这样传递参数是单子的另一种形式,因此一个适当的 Monad 实例允许求值器本身保持不变。

将函数当作单子,这通常称为 reader 单子。 在reader单子中对表达式求值使用以下规则:

  • 常量 \( n \) 映射为常量函数 \( λ e . n \),
  • 算术运算符映射为将参数各自传递然后计算的函数,因此 \( f + g \) 映射为 \( λ e . f(e) + g(e) \),并且
  • 自定义运算符求值为将自定义运算符应用于参数的结果,因此 \( f \ \mathrm{OP}\ g \) 映射为 \[ λ e . \begin{cases} h(f(e), g(e)) & \mathrm{if}\ e\ \mathrm{contains}\ (\mathrm{OP}, h) \\ 0 & \mathrm{otherwise} \end{cases} \] 其中 \( 0 \) 用于运算符未知的情况。

要在Lean中定义reader单子,第一步是定义 Reader 类型,和用户获取环境的作用:

def Reader (ρ : Type) (α : Type) : Type := ρ → α

def read : Reader ρ ρ := fun env => env

按照惯例,希腊字母ρ(发音为“rho”)用于表示环境。

算术表达式中的常量映射为常量函数这一事实表明,Readerpure 的适当定义是一个常量函数:

def Reader.pure (x : α) : Reader ρ α := fun _ => x

另一方面 bind 则有点棘手。 它的类型是Reader ρ α → (α → Reader ρ β) → Reader ρ β。 通过展开 Reader 的定义,可以更容易地理解此类型,从而产生(ρ → α) → (α → ρ → β) → ρ → β。 它将读取环境的函数作为第一个参数,而第二个参数将第一个参数的结果转换为另一个读取环境的函数。 组合这些结果本身就是一个读取环境的函数。

可以交互式地使用Lean,获得编写该函数的帮助。 为了获得尽可能多的帮助,第一步是非常明确地写下参数的类型和返回的类型,用下划线表示定义的主体:

def Reader.bind {ρ : Type} {α : Type} {β : Type}
  (result : ρ → α) (next : α → ρ → β) : ρ → β :=
  _

Lean提供的消息描述了哪些变量在作用域内可用,以及结果的预期类型。 符号,由于它类似于地铁入口而被称为 turnstile ,将局部变量与所需类型分开,在此消息中为ρ → β

don't know how to synthesize placeholder
context:
ρ α β : Type
result : ρ → α
next : α → ρ → β
⊢ ρ → β

因为返回类型是一个函数,所以第一步最好在下划线外套一层fun

def Reader.bind {ρ : Type} {α : Type} {β : Type}
  (result : ρ → α) (next : α → ρ → β) : ρ → β :=
  fun env => _

产生的消息说明现在函数的参数已经成为一个局部变量:

don't know how to synthesize placeholder
context:
ρ α β : Type
result : ρ → α
next : α → ρ → β
env : ρ
⊢ β

上下文中唯一可以产生 β 的是 next, 并且它需要两个参数。 每个参数都可以用下划线表示:

def Reader.bind {ρ : Type} {α : Type} {β : Type}
  (result : ρ → α) (next : α → ρ → β) : ρ → β :=
  fun env => next _ _

这两个下划线分别有如下的消息:

don't know how to synthesize placeholder
context:
ρ α β : Type
result : ρ → α
next : α → ρ → β
env : ρ
⊢ α
don't know how to synthesize placeholder
context:
ρ α β : Type
result : ρ → α
next : α → ρ → β
env : ρ
⊢ ρ

先处理第一条下划线,注意到上下文中只有一个东西可以产生α,即result

def Reader.bind {ρ : Type} {α : Type} {β : Type}
  (result : ρ → α) (next : α → ρ → β) : ρ → β :=
  fun env => next (result _) _

现在两条下划线都有一样的报错了:

don't know how to synthesize placeholder
context:
ρ α β : Type
result : ρ → α
next : α → ρ → β
env : ρ
⊢ ρ

值得高兴的是,两条下划线都可以被 env 替换,得到:

def Reader.bind {ρ : Type} {α : Type} {β : Type}
  (result : ρ → α) (next : α → ρ → β) : ρ → β :=
  fun env => next (result env) env

要得到最后的版本,只需要把我们前面对 Reader 的展开撤销,并且去掉过于明确的细节:

def Reader.bind (result : Reader ρ α) (next : α → Reader ρ β) : Reader ρ β :=
  fun env => next (result env) env

仅仅跟着类型信息走并不总是能写出正确的函数,并且有未能完全理解产生的程序的风险。 然而理解一个已经写出的程序比理解还没写出的要简单,而且逐步填充下划线的内容也可以提供思路。 这张情况下,Reader.bindIdbind 很像,唯一区别在于它接受一个额外的参数并传递到其他参数中。这个直觉可以帮助理解它的原理。

Reader.pureReader.bind 遵循单子约定。 要检查 Reader.bind (Reader.pure v) ff v 等价, 只需要不断地展开定义即可:

Reader.bind (Reader.pure v) f
===>
fun env => f ((Reader.pure v) env) env
===>
fun env => f ((fun _ => v) env) env
===>
fun env => f v env
===>
f v

对任意函数 f 来说,fun x => f xf 是等价的,所以约定的第一部分已经满足。 要检查 Reader.bind r Reader.purer 等价,只需要相似的技巧:

Reader.bind r Reader.pure
===>
fun env => Reader.pure (r env) env
===>
fun env => (fun _ => (r env)) env
===>
fun env => r env

因为 r 本身是函数,所以这和 r 也是等价的。 要检查结合律,只需要对 Reader.bind (Reader.bind r f) gReader.bind r (fun x => Reader.bind (f x) g) 重复同样的步骤:

Reader.bind (Reader.bind r f) g
===>
fun env => g ((Reader.bind r f) env) env
===>
fun env => g ((fun env' => f (r env') env') env) env
===>
fun env => g (f (r env) env) env
Reader.bind r (fun x => Reader.bind (f x) g)
===>
Reader.bind r (fun x => fun env => g (f x env) env)
===>
fun env => (fun x => fun env' => g (f x env') env') (r env) env
===>
fun env => (fun env' => g (f (r env) env') env') env
===>
fun env => g (f (r env) env) env

至此,Monad (Reader ρ)实例已经得到了充分验证:

instance : Monad (Reader ρ) where
  pure x := fun _ => x
  bind x f := fun env => f (x env) env

要被传递给表达式求值器的环境可以用键值对的列表来表示:

abbrev Env : Type := List (String × (Int → Int → Int))

例如,exampleEnv包含最大值和模函数:

def exampleEnv : Env := [("max", max), ("mod", (· % ·))]

Lean已提供函数 List.lookup 用来在键值对的列表中根据键寻找对应的值,所以 applyPrimReader 只需要确认自定义函数是否存在于环境中即可。如果不存在则返回0

def applyPrimReader (op : String) (x : Int) (y : Int) : Reader Env Int :=
  read >>= fun env =>
  match env.lookup op with
  | none => pure 0
  | some f => pure (f x y)

evaluateMapplyPrimReader、和一条表达式一起使用,即得到一个接受环境的函数。 而我们前面已经准备好了exampleEnv

open Expr Prim in
#eval evaluateM applyPrimReader (prim (other "max") (prim plus (const 5) (const 4)) (prim times (const 3) (const 2))) exampleEnv
9

Many 一样,Reader是难以在大多数语言中编码的作用,但类型类和单子使其与任何其他作用一样方便。 Common Lisp、Clojure和Emacs Lisp中的动态或特殊变量可以用作Reader。 类似地,Scheme和Racket的参数对象是一个与 Reader 完全对应的作用。 Kotlin的上下文对象可以解决类似的问题,但根本上是一种自动传递函数参数的方式,因此更像是作为reader单子的编码,而不是语言中实现的作用。

练习

检查约定

检查 State σExcept ε 满足单子约定。

允许Reader失败

调整例子中的reader单子,使得它可以在自定义的运算符不存在时提供错误信息而不是直接返回0。 换句话说,给定这些定义:

def ReaderOption (ρ : Type) (α : Type) : Type := ρ → Option α

def ReaderExcept (ε : Type) (ρ : Type) (α : Type) : Type := ρ → Except ε α

要做的是:

  1. 实现恰当的 purebind 函数
  2. 验证这些函数满足 Monad 约定
  3. ReaderOptionReaderExcept 实现 Monad 实例
  4. 为它们定义恰当的 applyPrim 运算符,并且将它们和 evaluateM 一起测试一些例子

带有跟踪信息的求值器

WithLog类型可以和求值器一起使用,来实现对某些运算的跟踪。 特别地,可以使用 ToTrace 类型来追踪某个给定的运算符:

inductive ToTrace (α : Type) : Type where
  | trace : α → ToTrace α

对于带有跟踪信息的求值器,表达式应该具有类型Expr (Prim (ToTrace (Prim Empty))). 这说明表达式中的运算符由附加参数的加、减、乘运算组成。最内层的参数是 Empty,说明在trace 内部没有特殊运算符,只有三种基本运算。

要做的是:

  1. 实现 Monad (WithLog logged) 实例
  2. 写一个 applyTraced 来将被追踪的运算符应用到参数,将运算符和参数记录到日志,类型为:ToTrace (Prim Empty) → Int → Int → WithLog (Prim Empty × Int × Int) Int

如果练习已经正确实现,那么

open Expr Prim ToTrace in
#eval evaluateM applyTraced (prim (other (trace times)) (prim (other (trace plus)) (const 1) (const 2)) (prim (other (trace minus)) (const 3) (const 4)))

将有如下结果

{ log := [(Prim.plus, 1, 2), (Prim.minus, 3, 4), (Prim.times, 3, -1)], val := -3 }

提示:Prim Empty会出现在日志中。为了让它们能被 #eval 输出,需要下面几个实例:

 deriving instance Repr for WithLog
deriving instance Repr for Empty
deriving instance Repr for Prim

单子的 do-记法

基于单子的 API 非常强大,但显式使用 >>= 和匿名函数仍然有些繁琐。 正如使用中缀运算符代替显式调用 HAdd.hAdd 一样,Lean 提供了一种称为 do-记法 的单子语法,它可以使使用单子的程序更易于阅读和编写。 这与用于编写 IO 程序的 do-记法完全相同,而 IO 也是一个单子。

Hello, World! 中,do 语法用于组合 IO 活动, 但这些程序的含义是直接解释的。理解如何运用单子进行编程意味着现在可以用 do 来解释它如何转换为对底层单子运算符的使用。

do 中的唯一语句是单个表达式 E 时,会使用 do 的第一种翻译。 在这种情况下,do 被删除,因此

do E

会被翻译为

E

do 的第一个语句是带有箭头的 let 绑定一个局部变量时,则使用第二种翻译。 它会翻译为使用 >>= 以及绑定同一变量的函数,因此

do let x ← E1
   Stmt
   ...
   En

会被翻译为

E1 >>= fun x =>
do Stmt
   ...
   En

do 块的第一个语句是一个表达式时,它会被认为是一个返回 Unit 的单子操作, 因此该函数匹配 Unit 构造子,而

do E1
   Stmt
   ...
   En

会被翻译为

E1 >>= fun () =>
do Stmt
   ...
   En

最后,当 do 块的第一个语句是使用 :=let 时,翻译后的形式是一个普通的 let 表达式,因此

do let x := E1
   Stmt
   ...
   En

会被翻译为

let x := E1
do Stmt
   ...
   En

使用 Monad 类的 firstThirdFifthSeventh 的定义如下:

def firstThirdFifthSeventh [Monad m] (lookup : List α → Nat → m α) (xs : List α) : m (α × α × α × α) :=
  lookup xs 0 >>= fun first =>
  lookup xs 2 >>= fun third =>
  lookup xs 4 >>= fun fifth =>
  lookup xs 6 >>= fun seventh =>
  pure (first, third, fifth, seventh)

使用 do-记法,它会变得更加易读:

def firstThirdFifthSeventh [Monad m] (lookup : List α → Nat → m α) (xs : List α) : m (α × α × α × α) := do
  let first ← lookup xs 0
  let third ← lookup xs 2
  let fifth ← lookup xs 4
  let seventh ← lookup xs 6
  pure (first, third, fifth, seventh)

若没有 Monad 类型,则对树的节点进行编号的函数 number 写作如下形式:

def number (t : BinTree α) : BinTree (Nat × α) :=
  let rec helper : BinTree α → State Nat (BinTree (Nat × α))
    | BinTree.leaf => ok BinTree.leaf
    | BinTree.branch left x right =>
      helper left ~~> fun numberedLeft =>
      get ~~> fun n =>
      set (n + 1) ~~> fun () =>
      helper right ~~> fun numberedRight =>
      ok (BinTree.branch numberedLeft (n, x) numberedRight)
  (helper t 0).snd

有了 Monaddo,其定义就简洁多了:

def number (t : BinTree α) : BinTree (Nat × α) :=
  let rec helper : BinTree α → State Nat (BinTree (Nat × α))
    | BinTree.leaf => pure BinTree.leaf
    | BinTree.branch left x right => do
      let numberedLeft ← helper left
      let n ← get
      set (n + 1)
      let numberedRight ← helper right
      ok (BinTree.branch numberedLeft (n, x) numberedRight)
  (helper t 0).snd

使用 doIO 的所有便利性在使用其他单子时也可用。 例如,嵌套操作也适用于任何单子。mapM 的原始定义为:

def mapM [Monad m] (f : α → m β) : List α → m (List β)
  | [] => pure []
  | x :: xs =>
    f x >>= fun hd =>
    mapM f xs >>= fun tl =>
    pure (hd :: tl)

使用 do-记法,可以写成:

def mapM [Monad m] (f : α → m β) : List α → m (List β)
  | [] => pure []
  | x :: xs => do
    let hd ← f x
    let tl ← mapM f xs
    pure (hd :: tl)

使用嵌套活动会让它与原始非单子 map 一样简洁:

def mapM [Monad m] (f : α → m β) : List α → m (List β)
  | [] => pure []
  | x :: xs => do
    pure ((← f x) :: (← mapM f xs))

使用嵌套活动,number 可以变得更加简洁:

def increment : State Nat Nat := do
  let n ← get
  set (n + 1)
  pure n

def number (t : BinTree α) : BinTree (Nat × α) :=
  let rec helper : BinTree α → State Nat (BinTree (Nat × α))
    | BinTree.leaf => pure BinTree.leaf
    | BinTree.branch left x right => do
      pure (BinTree.branch (← helper left) ((← increment), x) (← helper right))
  (helper t 0).snd

练习

  • 使用 do-记法而非显式调用 >>= 重写 evaluateM、辅助函数以及不同的特定用例。
  • 使用嵌套操作重写 firstThirdFifthSeventh

IO 单子

IO 作为单子可以从两个角度理解,这在 运行程序 一节中进行了描述。每个角度都可以帮助理解 IOpurebind 的含义。

从第一个视角看,IO 活动是 Lean 运行时系统的指令。 例如,指令可能是「从该文件描述符读取字符串,然后使用该字符串重新调用纯 Lean 代码」。 这是一种 外部 的视角,即从操作系统的视角看待程序。 在这种情况下,pure 是一个不请求 RTS 产生任何作用的 IO 活动, 而 bind 指示 RTS 首先执行一个产生潜在作用的操作,然后使用结果值调用程序的其余部分。

从第二个视角看,IO 活动会变换整个世界。IO 活动实际上是纯(Pure)的, 因为它接受一个唯一的世界作为参数,然后返回改变后的世界。 这是一种 内部 的视角,它对应了 IO 在 Lean 中的表示方式。 世界在 Lean 中表示为一个标记,而 IO 单子的结构化可以确保标记刚好使用一次。

为了了解其工作原理,逐层解析它的定义会很有帮助。 #print 命令揭示了 Lean 数据类型和定义的内部结构。例如,

#print Nat

的结果为

inductive Nat : Type
number of parameters: 0
constructors:
Nat.zero : Nat
Nat.succ : Nat → Nat

#print Char.isAlpha

的结果为

def Char.isAlpha : Char → Bool :=
fun c => Char.isUpper c || Char.isLower c

有时,#print 的输出包含了本书中尚未展示的 Lean 特性。例如,

#print List.isEmpty

会产生

def List.isEmpty.{u} : {α : Type u} → List α → Bool :=
fun {α} x =>
  match x with
  | [] => true
  | head :: tail => false

它在定义名的后面包含了一个 .{u} ,并将类型标注为 Type u 而非只是 Type。 目前可以安全地忽略它。

打印 IO 的定义表明它是根据更简单的结构定义的:

#print IO
@[reducible] def IO : Type → Type :=
EIO IO.Error

IO.Error 表示 IO 活动可能抛出的所有错误:

#print IO.Error
inductive IO.Error : Type
number of parameters: 0
constructors:
IO.Error.alreadyExists : Option String → UInt32 → String → IO.Error
IO.Error.otherError : UInt32 → String → IO.Error
IO.Error.resourceBusy : UInt32 → String → IO.Error
IO.Error.resourceVanished : UInt32 → String → IO.Error
IO.Error.unsupportedOperation : UInt32 → String → IO.Error
IO.Error.hardwareFault : UInt32 → String → IO.Error
IO.Error.unsatisfiedConstraints : UInt32 → String → IO.Error
IO.Error.illegalOperation : UInt32 → String → IO.Error
IO.Error.protocolError : UInt32 → String → IO.Error
IO.Error.timeExpired : UInt32 → String → IO.Error
IO.Error.interrupted : String → UInt32 → String → IO.Error
IO.Error.noFileOrDirectory : String → UInt32 → String → IO.Error
IO.Error.invalidArgument : Option String → UInt32 → String → IO.Error
IO.Error.permissionDenied : Option String → UInt32 → String → IO.Error
IO.Error.resourceExhausted : Option String → UInt32 → String → IO.Error
IO.Error.inappropriateType : Option String → UInt32 → String → IO.Error
IO.Error.noSuchThing : Option String → UInt32 → String → IO.Error
IO.Error.unexpectedEof : IO.Error
IO.Error.userError : String → IO.Error

EIO ε α 表示一个 IO 活动,它将以类型为 ε 的错误表示终止,或者以类型为 α 的值表示成功。 这意味着,与 Except ε 单子一样,IO 单子也包括定义错误处理和异常的能力。

剥离另一层,EIO 本身又是根据更简单的结构定义的:

#print EIO
def EIO : Type → Type → Type :=
fun ε => EStateM ε IO.RealWorld

EStateM 单子同时包括错误和状态——它是 ExceptState 的组合。 它使用另一个类型 EStateM.Result 定义:

#print EStateM
def EStateM.{u} : Type u → Type u → Type u → Type u :=
fun ε σ α => σ → EStateM.Result ε σ α

换句话说,类型为 EStateM ε σ α 的程序是一个函数, 它接受类型为 σ 的初始状态并返回一个 EStateM.Result ε σ α

EStateM.ResultExcept 的定义非常相似,一个构造子表示成功终止, 令一个构造子表示错误:

#print EStateM.Result
inductive EStateM.Result.{u} : Type u → Type u → Type u → Type u
number of parameters: 3
constructors:
EStateM.Result.ok : {ε σ α : Type u} → α → σ → EStateM.Result ε σ α
EStateM.Result.error : {ε σ α : Type u} → ε → σ → EStateM.Result ε σ α

就像 Except ε α 一样,ok 构造子包含类型为 α 的结果, error 构造子包含类型为 ε 的异常。与 Except 不同, 这两个构造子都有一个附加的状态字段,其中包含计算的最终状态。

EStateM ε σMonad 实例需要 purebind。 与 State 一样,EStateMpure 实现接受一个初始状态并将其返回而不改变, 并且与 Except 一样,它在 ok 构造子中返回其参数:

#print EStateM.pure
protected def EStateM.pure.{u} : {ε σ α : Type u} → α → EStateM ε σ α :=
fun {ε σ α} a s => EStateM.Result.ok a s

protected 意味着即使打开了 EStateM 命名空间,也需要完整的名称 EStateM.pure

类似地,EStateMbind 将初始状态作为参数。它将此初始状态传递给其第一个操作。 与 Exceptbind 一样,它然后检查结果是否为错误。如果是,则错误将保持不变, 并且 bind 的第二个参数保持未使用。如果结果成功,则将第二个参数应用于返回值和结果状态。

#print EStateM.bind
protected def EStateM.bind.{u} : {ε σ α β : Type u} → EStateM ε σ α → (α → EStateM ε σ β) → EStateM ε σ β :=
fun {ε σ α β} x f s =>
  match x s with
  | EStateM.Result.ok a s => f a s
  | EStateM.Result.error e s => EStateM.Result.error e s

综上所述,IO 是同时跟踪状态和错误的单子。可用错误的集合由数据类型 IO.Error 给出, 该数据类型具有描述程序中可能出错的许多情况的构造子。状态是一种表示现实世界的类型, 称为 IO.RealWorld。每个基本的 IO 活动都会接收这个现实世界并返回另一个,与错误或结果配对。 在 IO 中,pure 返回未更改的世界,而 bind 将修改后的世界从一个活动传递到下一个活动。

由于计算机内存无法容纳整个宇宙,因此传递的世界仅仅是一种表示。 只要不重复使用世界标记,该表示就是安全的。这意味着世界标记根本不需要包含任何数据:

#print IO.RealWorld
def IO.RealWorld : Type :=
Unit

其他便利功能

共享参数类型

定义具有相同类型的多个参数时,可以把它们写在同一个冒号之前。 例如:

def equal? [BEq α] (x : α) (y : α) : Option α :=
  if x == y then
    some x
  else
    none

可以写成

def equal? [BEq α] (x y : α) : Option α :=
  if x == y then
    some x
  else
    none

这在类型签名很长的时候特别有用。

开头的点号

一个归纳类型的所有构造子都存在于一个命名空间中。 因此允许不同的归纳类型有同名构造子,但是这也会导致程序变得啰嗦。 当问题中的归纳类型已知时,可以命名空间可以省略,只需要在构造子前保留点号,Lean可以根据该处期望的类型来决定如何选择构造子。 例如将二叉树镜像的函数:

def BinTree.mirror : BinTree α → BinTree α
  | BinTree.leaf => BinTree.leaf
  | BinTree.branch l x r => BinTree.branch (mirror r) x (mirror l)

省略命名空间使代码显著变短,但代价是在没有Lean编译器,例如code review时,代码会变得难以阅读:

def BinTree.mirror : BinTree α → BinTree α
  | .leaf => .leaf
  | .branch l x r => .branch (mirror r) x (mirror l)

通过期望的类型来消除命名空间的歧义,同样可以应用于构造子之外的名称。 例如BinTree.empty定义为一种创建BinTree的方式,那么它也可以和点号一起使用:

def BinTree.empty : BinTree α := .leaf

#check (.empty : BinTree Nat)
BinTree.empty : BinTree Nat

或-模式

当有多个模式匹配的分支时,例如match表达式,那么不同的模式可以共享同一个结果表达式。 表示一周的每一天的类型Weekday

inductive Weekday where
  | monday
  | tuesday
  | wednesday
  | thursday
  | friday
  | saturday
  | sunday
  deriving Repr

可以用模式匹配检查某一天是否是周末:

def Weekday.isWeekend (day : Weekday) : Bool :=
  match day with
  | Weekday.saturday => true
  | Weekday.sunday => true
  | _ => false

首先可以用点号来简化:

def Weekday.isWeekend (day : Weekday) : Bool :=
  match day with
  | .saturday => true
  | .sunday => true
  | _ => false

因为周末的两天都有相同的结果true,所以可以精简成:

def Weekday.isWeekend (day : Weekday) : Bool :=
  match day with
  | .saturday | .sunday => true
  | _ => false

进一步可以简化成没有参数名称的函数:

def Weekday.isWeekend : Weekday → Bool
  | .saturday | .sunday => true
  | _ => false

实际上结果表达式只是简单地被复制。所以模式也可以绑定变量,这个例子在和类型(Sum Type)两边具有相同类型时,将inlinr构造子去除:

def condense : α ⊕ α → α
  | .inl x | .inr x => x

但是因为结果表达式只是被复制,所以模式绑定的变量也可以具有不同类型。 重载的函数可以让同一个结果表达式用于多个绑定不同类型的变量的模式:

def stringy : Nat ⊕ Weekday → String
  | .inl x | .inr x => s!"It is {repr x}"

实践中,只有在所有模式都存在的变量才可以在结果表达式中引用,因为这条表达式必须对所有分支都有意义。 getTheNat中只有n可以被访问,使用xy将会导致错误。

def getTheNat : (Nat × α) ⊕ (Nat × β) → Nat
  | .inl (n, x) | .inr (n, y) => n

这种类似的情况中访问x同样会导致错误,因为x在第二个模式中不存在:

def getTheAlpha : (Nat × α) ⊕ (Nat × α) → α
  | .inl (n, x) | .inr (n, y) => x
unknown identifier 'x'

简单地对结果表达式进行复制,会导致某些令人惊讶的行为。 例如,下列定义是合法的,因为inr分支实际上引用的是全局定义str

def str := "Some string"

def getTheString : (Nat × String) ⊕ (Nat × β) → String
  | .inl (n, str) | .inr (n, y) => str

在不同分支上调用该函数会让人困惑。 第一种情况中,需要提供类型标记告诉Lean类型β是什么:

#eval getTheString (.inl (20, "twenty") : (Nat × String) ⊕ (Nat × String))
"twenty"

第二种情况被使用的是全局定义:

#eval getTheString (.inr (20, "twenty"))
"Some string"

使用或-模式可以极大简化某些定义,让它们更加清晰,例如Weekday.isWeekend. 但因为存在可能导致困惑的行为,需要十分小心地使用,特别是涉及不同类型的变量,或不相交的变量集合时。

总结

编码副作用

是一种纯函数式语言。这意味着它不包含副作用,例如可变变量、日志记录或异常。 但是,大多数副作用都可以使用函数和归纳类型或结构体的组合进行编码。 例如,可变状态可以编码为从初始状态到一对最终状态和结果的函数, 异常可以编码为具有成功终止构造子和错误构造子的归纳类型。

每组编码的作用都是一种类型。因此,如果程序使用这些编码作用,那么这在它的类型中是显而易见的。 函数式编程并不意味着程序不能使用作用,它只是要求它们 诚实地 说明它们使用的作用。 Lean 类型签名不仅描述了函数期望的参数类型和它返回的结果类型,还描述了它可能使用的作用。

单子类型类

在允许在任何地方使用作用的语言中编写纯函数式程序是可能的。 例如,2 + 3 是一个有效的 Python 程序,它没有任何作用。 类似地,组合具有作用的程序需要一种方法来说明作用必须发生的顺序。 毕竟,异常是在修改变量之前还是之后抛出是有区别的。

类型类 Monad 刻画了这两个重要属性。它有两个方法:pure 表示没有副作用的程序, bind 顺序执行有副作用的程序。Monad 实例的约束确保了 bindpure 实际上刻画了纯计算和顺序执行。

单子的 do-记法

do 符号不仅限于 IO,它也适用于任何单子。 它允许使用单子的程序以类似于面向语句的语言的风格编写,语句一个接一个地顺序执行。 此外,do-记法还支持许多其他方便的简写,例如嵌套动作。 使用 do 编写的程序在幕后会被翻译为 >>= 的应用。

定制单子

不同的语言提供不同的副作用集。虽然大多数语言都具有可变变量和文件 I/O, 但并非所有语言都具有异常等特性。其他语言提供罕见或独特的副作用, 例如 Icon 基于搜索的程序执行、Scheme 和 Ruby 的续体以及 Common Lisp 的可恢复异常。 用单子对副作用进行编码的一个优点是,程序不受语言提供的副作用集的限制。 由于 Lean 被设计为能方便地使用任何单子进行编程, 因此程序员可以自由选择最适合任何给定应用的副作用集。

IO 单子

可以在现实世界中产生影响的程序在 Lean 中被写作 IO 活动。 IO 是众多单子中的一个。IO 单子对状态和异常进行编码,其中状态用于跟踪世界的状态, 异常则对失败和恢复进行建模。

函子、应用函子与单子

FunctorMonad 都描述了那些仍在等待类型参数的类型的操作。 一种理解它们的方式是,Functor 描述了容器,其中容器内的数据可以被转换,而 Monad 描述了具有副作用的程序编码。 然而,这种理解是不完整的。 毕竟,Option 同时拥有 FunctorMonad 的实例,并且同时代表着一个可选值 一个可能无法返回值的计算。

从数据结构的角度来看,Option 有点像一个可为空的类型,或者像一个最多可以包含一个条目的列表。 从控制结构的角度来看,Option 代表着一种可能会提前终止而没有结果的计算。 通常,使用 Functor 实例的程序最容易被理解为将 Option 用作数据结构,而使用 Monad 实例的程序则更容易被理解为将 Option 用于支持早期失败,但熟练地掌握这两种视角对于精通函数式编程至关重要。

函子 (Functor) 和 单子 (Monad) 之间有一个更深层次的关系。 事实证明,每个单子都是一个函子。 换句话说,单子抽象 (Monad Abstraction) 比函子抽象 (Functor Abstraction) 更强大,因为不是每个函子都是单子。 此外,还有一个额外的中间抽象,被称为 应用函子 (Applicative Functors),它有足够的能力来编写许多有趣的程序,而且还适用于那些无法使用 Monad 接口的库。 类型类 Applicative 提供了应用函子的可重载操作。 每个单子都是一个应用函子,而每个应用函子也都是一个函子,但反之则不成立。

结构体和继承

为了理解 FunctorApplicativeMonad 的完整定义,另一个 Lean 的特性必不可少:结构体继承 (Structure Inheritance)。 结构体继承允许一种结构体类型提供另一种结构体类型的接口,并添加额外的属性。 这在对具有明确分类关系的概念进行建模时非常有用。 例如,以 神话生物 (Mythical Creature) 的模型为例。 其中有些很大型,有些很小型:

structure MythicalCreature where
  large : Bool
deriving Repr

在幕后,定义 MythicalCreature 结构体会创建一个具有名为 mk 的单一构造子的归纳类型:

#check MythicalCreature.mk
MythicalCreature.mk (large : Bool) : MythicalCreature

类似地,当一个函数 MythicalCreature.large 被创建,它实际上从构造子中提取了属性:

#check MythicalCreature.large
MythicalCreature.large (self : MythicalCreature) : Bool

在大多数古老的故事中,每个怪物都可以用某种方式被击败。 一只怪物 (Monster) 的描述应该包括以下信息,以及它是否庞大:

structure Monster extends MythicalCreature where
  vulnerability : String
deriving Repr

标题中的 extends MythicalCreature 表明每个 Monster 也都是 MythicalCreature。 要定义一个 Monster,其 MythicalCreature 的属性和 Monster 的属性应被同时提供。 巨魔 (Troll) 是一种对阳光敏感的大型怪物。

def troll : Monster where
  large := true
  vulnerability := "sunlight"

在幕后,继承是通过组合来实现的。构造子 Monster.mkMythicalCreature 作为其参数:

#check Monster.mk
Monster.mk (toMythicalCreature : MythicalCreature) (vulnerability : String) : Monster

除了定义函数来提取每个新属性的值之外,一个类型为 Monster → MythicalCreature 的函数 Monster.toMythicalCreature 也被定义了。 其可以被用于提取底层的生物。

在 Lean 的继承层级体系中逐级上升与面向对象语言中的向上转型(Upcasting)并不相同。 向上转型运算符会使派生类的值被视为父类的实例,但该值会保留其原有的特性和结构体。 然而,在 Lean 中,在继承层级体系内逐级上升实际上会擦除原有的底层信息。 要查看此操作,请看 troll.toMythicalCreature 的求值结果:

#eval troll.toMythicalCreature
{ large := true }

只有 MythicalCreature 的属性被保留了。

如同 where 语法一样,使用属性名称的花括号表示法也适用于结构体继承:

def troll : Monster := {large := true, vulnerability := "sunlight"}

不过,委托给底层构造子的匿名尖括号表示法揭示了内部的细节:

def troll : Monster := ⟨true, "sunlight"⟩
application type mismatch
  Monster.mk true
argument
  true
has type
  Bool : Type
but is expected to have type
  MythicalCreature : Type

需要额外的一对尖括号,这将对 true 调用 MythicalCreature.mk

def troll : Monster := ⟨⟨true⟩, "sunlight"⟩

Lean 的点表示法能够考虑继承。 换句话说,现有的 MythicalCreature.large 可以和 Monster 一起使用,并且 Lean 会在调用 MythicalCreature.large 之前自动插入对 Monster.toMythicalCreature 的调用。 不过,这仅在使用点表示法时发生,并且使用正常的函数调用语法来应用属性查找函数会致使一个类型错误的发生:

#eval MythicalCreature.large troll
application type mismatch
  troll.large
argument
  troll
has type
  Monster : Type
but is expected to have type
  MythicalCreature : Type

对于用户定义函数 (User-Defined Function),点表示法还可以考虑其继承关系。 小型生物是指那些不大的生物:

def MythicalCreature.small (c : MythicalCreature) : Bool := !c.large

对于 troll.small 的求值结果是 false,而尝试对 MythicalCreature.small troll 求值则会产生以下结果:

application type mismatch
  MythicalCreature.small troll
argument
  troll
has type
  Monster : Type
but is expected to have type
  MythicalCreature : Type

多重继承

助手是一种神话生物,当给予适当的报酬时,它就可以提供帮助。

structure Helper extends MythicalCreature where
  assistance : String
  payment : String
deriving Repr

例如,nisse 是一种小精灵,众所周知,当给他提供美味的粥时,它就会帮忙打理家务。

def nisse : Helper where
  large := false
  assistance := "household tasks"
  payment := "porridge"

如果巨魔被驯化,它们便会成为出色的助手。 它们强壮到可以在一个晚上耕完整片田地,尽管它们需要模型山羊来让它们对自己的生活感到满意。 怪物助手是既是怪物又是助手。

structure MonstrousAssistant extends Monster, Helper where
deriving Repr

这种结构体类型的值必须由两个父结构体的所有属性进行填充:

def domesticatedTroll : MonstrousAssistant where
  large := false
  assistance := "heavy labor"
  payment := "toy goats"
  vulnerability := "sunlight"

这两种父结构体类型都扩展自 MythicalCreature。 如果多重继承被简单地实现,那么这可能会导致“菱形问题”,即在一个给定的 MonstrousAssistant 中,不清楚应该采用哪条路径来获取 large。 它应该从所包含的 Monster 还是 Helper 中去获取 large 呢? 在 Lean 中,答案是采用第一条指定到祖先结构体的路径,并且其他父结构体的属性会被复制,而不是让新的结构体直接包含两个父结构体。

通过检验 MonstrousAssistant 的构造子的签名可以看到这一点。

#check MonstrousAssistant.mk
MonstrousAssistant.mk (toMonster : Monster) (assistance payment : String) : MonstrousAssistant

它接受一个 Monster 作为参数,以及 HelperMythicalCreature 之上引入的两个属性。 类似地,虽然 MonstrousAssistant.toMonster 仅仅是从构造子中提取出 Monster,但 MonstrousAssistant.toHelper 并没有 Helper 可以提取。 #print 命令展现了其实现方式:

#print MonstrousAssistant.toHelper
@[reducible] def MonstrousAssistant.toHelper : MonstrousAssistant → Helper :=
fun self =>
  { toMythicalCreature := self.toMonster.toMythicalCreature, assistance := self.assistance, payment := self.payment }

此函数从 MonstrousAssistant 的属性中构造了一个 Helper@[reducible] 属性的作用与编写 abbrev 相同。

默认声明

当一个结构体继承自另一个结构体时,可以使用默认属性定义,即基于子结构体的属性去实例化父结构体的属性。 如果需要比生物是否庞大更具体的尺寸特征,则可以结合使用描述尺寸的专用数据类型和继承机制,以此产生一个结构体,其中 large 属性是根据 size 属性的内容计算得出的:

inductive Size where
  | small
  | medium
  | large
deriving BEq

structure SizedCreature extends MythicalCreature where
  size : Size
  large := size == Size.large

但是,这个默认定义只是一个默认定义。 与 C# 或 Scala 等语言中的属性继承不同,子结构体中的定义仅在没有提供 large 的具体值时才会使用,并且可能会出现无意义的结果:

def nonsenseCreature : SizedCreature where
  large := false
  size := .large

如果子结构体不应偏离父结构体,则有以下几种选择:

  1. 记录其关系,如同 BEqHashable 所做的那样
  2. 定义一个属性之间适当关联的命题,并设计 API 以在需要的地方要求提供命题为真的证据
  3. 完全不使用继承

第二种选择可以如同这样:

abbrev SizesMatch (sc : SizedCreature) : Prop :=
  sc.large = (sc.size == Size.large)

请注意,单个等号用于表示等式 命题 ,而双等号用于表示一个检查相等性并返回 Bool 的函数。 SizesMatch 被定义为 abbrev,因为它应该在证明中自动展开,以使得 simp 能看到需要被证明的等式。

huldre 是一种中等体型的神话生物——实际上,它们与人类的体型相同。 huldre 上的两个大小属性是相互匹配的:

def huldre : SizedCreature where
  size := .medium

example : SizesMatch huldre := by
  simp

类型类继承

在幕后,类型类是结构体。 定义一个新的类型类会定义一个新的结构体,而定义一个实例会创建该结构体类型的一个值。 然后,它们被添加到 Lean 的内部表中,以便 Lean 可以根据请求找到实例。 这样做的结果是类型类能够继承其他类型类。

由于使用了完全相同的语言特性,类型类继承支持结构体继承的所有特性,包括多重继承、父类型方法的默认实现以及自动解决菱形继承问题。 这在许多情况下都很有用,就像 Java、C# 和 Kotlin 等语言中的多重接口继承。 通过精心设计类型类的继承层级体系,程序员可以兼得两方面的优势:一方面是得到一个可独立实现的抽象的精细集合,另一方面是从更大、更通用的抽象中自动构造出这些特定的抽象。

应用函子

应用函子 (Applicative Functor) 是一种具有两个附加操作的函子:pureseqpureMonad 中使用的相同的运算符,因为 Monad 实际上继承自 Applicativeseq 非常类似于 map:它允许使用一个函数来转换数据类型的内容。 然而,在使用 seq 时,函数本身也被包含在数据类型中:f (α → β) → (Unit → f α) → f β。 将函数置于类型 f 之下会允许 Applicative 的实例去控制函数被应用的方式,而 Functor.map 则无条件地应用函数。 第二个参数的类型以 Unit → 开头,以允许在函数永远不会被应用的情况下,定义 seq 时短路。

这种短路行为的价值可以在 Applicative Option 的实例中看到:

instance : Applicative Option where
  pure x := .some x
  seq f x :=
    match f with
    | none => none
    | some g => g <$> x ()

在这种情况下,如果没有函数供 seq 应用,那么就不需要计算其参数,因此 x 永远不会被调用。 同样的考虑也适用于 ExceptApplicative 实例。

instance : Applicative (Except ε) where
  pure x := .ok x
  seq f x :=
    match f with
    | .error e => .error e
    | .ok g => g <$> x ()

这种短路行为仅依赖于 包围 着函数的 OptionExcept 结构,而不是函数本身。

单子 (Monad) 可以被看作是一种将按顺序执行语句的概念引入纯函数式语言的方法。 一个语句的结果会影响接下来要执行的语句。 这可以从 bind 的类型中看出:m α → (α → m β) → m β。 第一个语句的结果值是作为一个函数的输入,该函数会计算下一个要执行的语句。 连续使用 bind 类似于命令式编程语言中的语句序列,而且 bind 足够强大,可以实现诸如条件语句和循环等控制结构。

按照这个类比,Applicative 捕获了在具有副作用的语言中函数的应用。 在像 Kotlin 或 C# 这样的语言中,函数的参数是从左到右进行求值的。 较早的参数所执行的副作用在较晚的参数执行的副作用之前发生。 然而,函数不足以实现依赖于参数特定 的自定义短路运算符。

通常情况下,不会直接调用 seq。 而是使用运算符 <*>。 这个运算符将其第二个参数包装在 fun () => ... 中,从而简化了调用位置。 换句话说,E1 <*> E2Seq.seq E1 (fun () => E2) 的语法糖。

允许 seq 与多个参数一起使用的关键特性在于,在 Lean 中的多参数函数实际上是一个单参数函数,该函数会返回另一个正在等待其余参数的函数。 换句话说,如果 seq 的第一个参数正在等待多个参数,那么 seq 的输出结果将等待其余的参数。 例如,some Plus.plus 可以具有类型 Option (Nat → Nat → Nat)。 提供一个参数后,some Plus.plus <*> some 4 的类型将转变为 Option (Nat → Nat)。 这本身也可以与 seq 一起使用,因此 some Plus.plus <*> some 4 <*> some 7 的类型为 Option Nat

不是每个函子都是应用函子。 Pair 类似于内置的乘积类型 Prod

structure Pair (α β : Type) : Type where
  first : α
  second : β

如同 ExceptPair 的类型是 Type → Type → Type。 这意味着 Pair α 的类型是 Type → Type,因此可以有一个 Functor 实例:

instance : Functor (Pair α) where
  map f x := ⟨x.first, f x.second⟩

此实例遵循 Functor 契约。

要检查的两个属性是 id <$> Pair.mk x y = Pair.mk x yf <$> g <$> Pair.mk x y = (f ∘ g) <$> Pair.mk x y。 第一个属性可以通过从左侧的逐步求值直到右侧来检查:

id <$> Pair.mk x y
===>
Pair.mk x (id y)
===>
Pair.mk x y

第二个属性可以通过逐步检查两侧来验证,并注意它们产生了相同的结果:

f <$> g <$> Pair.mk x y
===>
f <$> Pair.mk x (g y)
===>
Pair.mk x (f (g y))

(f ∘ g) <$> Pair.mk x y
===>
Pair.mk x ((f ∘ g) y)
===>
Pair.mk x (f (g y))

但是,尝试定义一个 Applicative 实例的效果并不理想。 它需要 pure 的定义:

def Pair.pure (x : β) : Pair α β := _
don't know how to synthesize placeholder
context:
β α : Type
x : β
⊢ Pair α β

在当前作用域内有一个类型为 β 的值(即 x),下划线处的错误信息表明了下一步是使用构造子 Pair.mk

def Pair.pure (x : β) : Pair α β := Pair.mk _ x
don't know how to synthesize placeholder for argument 'first'
context:
β α : Type
x : β
⊢ α

不幸的是,没有可用的 α。 因为 pure 需要适用于 所有可能的类型 α ,才能定义 Applicative (Pair α) 的实例,所以这是不可能的。 毕竟,调用者可以选择 αEmpty,而 Empty 根本没有任何值。

一个非单子的应用函子

在验证表单中的用户输入时,通常认为最好一次性提供多个错误,而不是一次提供一个错误。 这样,用户可以大致了解需要做什么才可以满足计算机的要求,而不是逐个对属性地纠正错误时感到烦恼。

理想情况下,验证用户输入将在执行验证的函数类型中可见。 它应该返回一个特定的数据类型——例如,检验包含数字的文本框是否返回一个实际的数字类型。 验证例程可能会在输入未通过验证时抛出 异常 (Exception)。 然而,异常有一个主要缺点:它们在第一个错误出现时终止程序,从而无法累积错误列表。

另一方面,累积错误列表并在列表非空时失效的常见设计模式也是有问题的。 一个用于验证输入数据的每个子部分的长嵌套 if 语句序列难以维护,而且很容易遗漏一两条错误信息。 理想情况下,可以使用一个 API 来执行验证,该 API 不仅可以返回一个新的值,还能自动跟踪和累积错误信息。

一个名为 Validate 的应用函子提供了一种实现这种风格的 API 的方法。 像 Except 单子一样,Validate 允许构造一个准确描述验证数据的新的值 与 Except 不同,它允许累积多个错误,而不必担心忘记检查列表是否为空。

用户输入

作为用户输入的示例,请考虑以下结构体:

structure RawInput where
  name : String
  birthYear : String

要实现的业务逻辑如下:

  1. 姓名不能为空
  2. 出生年份必须是数字且非负
  3. The birth year must be greater than 1900, and less than or equal to the year in which the form is validated 出生年份必须大于1900,并且小于或等于表单被验证的年份

将这些表示为数据类型将会需要一个新功能,称为 子类型 (Subtype)。 有了这个工具,可以编写一个使用应用函子来跟踪错误的验证框架,并且可以在框架中实现这些规则。

子类型

表示这些条件最简单的方法就是使用 Lean 内一种额外的类型,称为 Subtype

structure Subtype {α : Type} (p : α → Prop) where
  val : α
  property : p val

该结构体有两个类型参数:一个隐式参数是数据 α 的类型,另一个显式参数 pα 上的谓词。 谓词 (Predicate) 是一个逻辑语句,其中包含一个变量,可以用值替换改变量以生成一个实际的语句,就像 GetElem 的参数 那样,它描述了索引在查找范围内的意义。 在 Subtype 的情况下,谓词划分出 α 的一些值的子集,且这些值满足谓词的条件。 该结构体的两个属性分别是一个来自 α 的值,以及该值满足谓词 p 的证据。 Lean 对 Subtype 有特殊的语法。 如果 p 的类型是 α → Prop,那么类型 Subtype p 也可以写作 {x : α // p x},或者在类型可以被自动推断时,甚至可以写作 {x // p x}

将正数表示为归纳类型 是清晰且易于编程的。 但是,它有一个主要的缺点。 虽然从 Lean 程序的角度来看,NatInt 具有普通归纳类型的结构,但编译器会特殊对待它们,并使用快速的任意精度数字库来实现它们。 对于其他用户定义的类型则不是这样的情况。 然而,一个将 Nat 限制为非零数的子类型允许新类型使用高效的表示方式,同时在编译时仍然排除零:

def FastPos : Type := {x : Nat // x > 0}

最小的快速正数仍然是 1。 现在,它不再是归纳类型的构造子,而是由尖括号构造的结构实例。 第一个参数是底层的 Nat,第二个参数是描述着 Nat 大于零的证据:

def one : FastPos := ⟨1, by simp⟩

OfNat 实例十分类似于 Pos 实例,只不过它使用了一个简短的策略证明来提供 n + 1 > 0 的证据:

instance : OfNat FastPos (n + 1) where
  ofNat := ⟨n + 1, by simp_arith⟩

simp_arith 策略是 simp 的一个版本,它考虑了额外的算术恒等式。

子类型是一把双刃剑。 它们允许高效地表示验证规则,但它们将维护这些规则的负担转移到了库的使用者身上,使用者必须 证明 他们没有违反重要的不变量。 通常,最好在库的内部使用子类型,这为使用者提供了一个自动确保满足所有不变量的API,并且将任何必要的证明放在库的内部去进行处理。

检查类型为 α 的值是否属于子类型 {x : α // p x},通常需要命题 p x可判定的 (Decidable)关于相等性和排序类的小节 描述了如何将可判定命题与 if 一起使用。 当 if 与一个可判定命题一起使用时,可以提供一个名称。 在 then 分支中,该名称会与命题为真的证据绑定,在 else 分支中,该名称会与命题为假的证据绑定。 这在检查给定的 Nat 是否为正数时非常有用:

def Nat.asFastPos? (n : Nat) : Option FastPos :=
  if h : n > 0 then
    some ⟨n, h⟩
  else none

then 分支中,h 会与 n > 0 的证据绑定,且这个证据可以用作 Subtype 构造子的第二个参数。

经验证输入

经过验证后的用户输入是一种使用多种技术表达业务逻辑的结构:

  • 结构类型本身编码了被检验了有效性的年份,因此 CheckedInput 2019CheckedInput 2020 不是相同的类型
  • 出生年份表示为 Nat 而不是 String
  • 子类型被用来约束名称属性和出生年份属性中的允许值
structure CheckedInput (thisYear : Nat) : Type where
  name : {n : String // n ≠ ""}
  birthYear : {y : Nat // y > 1900 ∧ y ≤ thisYear}

一个输入验证器应接受当前年份和一个 RawInput 作为参数,然后返回一个经检验的输入或者至少一个验证失败。 这由 Validate 类型来表示:

inductive Validate (ε α : Type) : Type where
  | ok : α → Validate ε α
  | errors : NonEmptyList ε → Validate ε α

它看起来很像 Except。 唯一的区别是 error 构造子可能包含多个失败。

Validate 是一个函子。 将一个函数映射到其上会转换任何可能存在的成功值,就像在 ExceptFunctor 实例中一样:

instance : Functor (Validate ε) where
  map f
   | .ok x => .ok (f x)
   | .errors errs => .errors errs

ValidateApplicative 实例与 Except 的实例有一个重要的区别:Except 的实例会在遇到第一个错误时终止,而 Validate 的实例则会小心地累积 同时 来自函数和参数分支中的所有错误:

instance : Applicative (Validate ε) where
  pure := .ok
  seq f x :=
    match f with
    | .ok g => g <$> (x ())
    | .errors errs =>
      match x () with
      | .ok _ => .errors errs
      | .errors errs' => .errors (errs ++ errs')

.errorsNonEmptyList 的构造子一起使用会有点繁琐。 像 reportError 这样的辅助函数可以使代码更易读。 在这个应用中,错误报告将由属性名称和消息一起组成:

def Field := String

def reportError (f : Field) (msg : String) : Validate (Field × String) α :=
  .errors { head := (f, msg), tail := [] }

ValidateApplicative 实例允许每个属性的检查过程被独立编写,然后进行组合。 检查一个名称包括着确保字符串非空,然后以 Subtype 的形式返回这一事实证据。 这使用了 if 的证据绑定的版本:

def checkName (name : String) : Validate (Field × String) {n : String // n ≠ ""} :=
  if h : name = "" then
    reportError "name" "Required"
  else pure ⟨name, h⟩

then 分支中,h 绑定着 name = "" 的证据,而在 else 分支中,它绑定着 ¬name = "" 的证据。

某些验证错误确实会导致其他检查无法进行。 例如,若一个困惑的用户在出生年份的属性处输入了 "syzygy" 这个词而不是一个数字,那么检查该属性是否大于 1900 就没有意义。 只有在确保该属性实际上包含一个数字之后,检查数字的允许范围才有意义。 这可以使用函数 andThen 来表示:

def Validate.andThen (val : Validate ε α) (next : α → Validate ε β) : Validate ε β :=
  match val with
  | .errors errs => .errors errs
  | .ok x => next x

虽然这此数的类型签名使其适合作为一个 Monad 实例中的 bind 去使用,但也有充分的理由不这样做。 这些理由在描述 Applicative 契约的小节中进行了说明。

要检查出生年份是否为数字,一个名为 String.toNat? : String → Option Nat 的内置函数非常有用。 最方便用户的做法是先使用 String.trim 消除前导和尾随的空格:

def checkYearIsNat (year : String) : Validate (Field × String) Nat :=
  match year.trim.toNat? with
  | none => reportError "birth year" "Must be digits"
  | some n => pure n

为了检验提供的年份是否在预期范围内,需要嵌套使用提供证据形式的 if 语句:

def checkBirthYear (thisYear year : Nat) : Validate (Field × String) {y : Nat // y > 1900 ∧ y ≤ thisYear} :=
  if h : year > 1900 then
    if h' : year ≤ thisYear then
      pure ⟨year, by simp [*]⟩
    else reportError "birth year" s!"Must be no later than {thisYear}"
  else reportError "birth year" "Must be after 1900"

最后,这三个组件可以使用 seq 进行组合:

def checkInput (year : Nat) (input : RawInput) : Validate (Field × String) (CheckedInput year) :=
  pure CheckedInput.mk <*>
    checkName input.name <*>
    (checkYearIsNat input.birthYear).andThen fun birthYearAsNat =>
      checkBirthYear year birthYearAsNat

测试 checkInput 体现了它确实可以返回多条反馈信息:

#eval checkInput 2023 {name := "David", birthYear := "1984"}
Validate.ok { name := "David", birthYear := 1984 }
#eval checkInput 2023 {name := "", birthYear := "2045"}
Validate.errors { head := ("name", "Required"), tail := [("birth year", "Must be no later than 2023")] }
#eval checkInput 2023 {name := "David", birthYear := "syzygy"}
Validate.errors { head := ("birth year", "Must be digits"), tail := [] }

使用 checkInput 进行表单验证展示了 Applicative 相对于 Monad 的一个关键优势。 由于 >>= 提供了足够的能力来根据第一步的值修改程序其余部分的执行,所以它 必须 接收到第一步的值才能继续。 如果没有接收到值(例如由于一个错误发生了),那么 >>= 就无法执行程序的其余部分。 Validate 演示了为什么继续运行程序的其余部分可能是有用的:在不需要前面数据的情况下,运行程序的其余部分可以提供有用的信息(在这种情况下,是更多的验证错误)。 Applicative<*> 可以在重新组合结果之前运行它的两个参数。 类似地,>>= 会强制按照顺序执行。 每一步都必须完成后才能运行下一步。 这通常是有用的,但这导致了不可能并行执行那些自然地从程序实际数据依赖中所产生的不同线程。 像 Monad 这样更强大的抽象增加了 API 使用者可用的灵活性,但减少了 API 实现者可用的灵活性。

应用函子的契约

就像 FunctorMonad 以及实现了 BEqHashable 的类型一样,Applicative 也有一套所有实例都应遵守的规则。

应用函子应该遵循四条规则:

  1. 应遵循同一律,即 pure id <*> v = v
  2. 应遵循函数复合律,即 pure (· ∘ ·) <*> u <*> v <*> w = u <*> (v <*> w)
  3. 对纯操作进行排序应等同于无操作,即 pure f <*> pure x = pure (f x)
  4. 纯操作的顺序不应影响结果,即 u <*> pure x = pure (fun f => f x) <*> u

要检验对于 Applicative Option 实例的这些规则,首先将 pure 展开为 some

第一条规则表明 some id <*> v = vOptionseq 定义指明,这与 id <$> v = v 相同,这是已被检查过的 Functor 规则之一。

第二条规则指出 some (· ∘ ·) <*> u <*> v <*> w = u <*> (v <*> w)。 如果 uvw 中有任何一个是 none,则两边均为 none,因此该属性成立。 假设 usome fvsome gwsome x,那么这等价于声明 some (· ∘ ·) <*> some f <*> some g <*> some x = some f <*> (some g <*> some x)。 对两边求值得到相同的结果:

some (· ∘ ·) <*> some f <*> some g <*> some x
===>
some (f ∘ ·) <*> some g <*> some x
===>
some (f ∘ g) <*> some x
===>
some ((f ∘ g) x)
===>
some (f (g x))

some f <*> (some g <*> some x)
===>
some f <*> (some (g x))
===>
some (f (g x))

第三条规则直接源于 seq 的定义:

some f <*> some x
===>
f <$> some x
===>
some (f x)

在第四种情况下,假设 usome f,因为如果它是 none,则等式的两边都是 nonesome f <*> some x 的求值结果直接为 some (f x),正如 some (fun g => g x) <*> some f 也是如此。

所有的应用函子都是函子

Applicative 的两个运算符足以定义 map

def map [Applicative f] (g : α → β) (x : f α) : f β :=
  pure g <*> x

但是,只有当 Applicative 的契约保证了 Functor 的契约时,这才能用来实现 FunctorFunctor 的第一条规则是 id <$> x = x,这直接源于 Applicative 的第一条规则。 Functor 的第二条规则是 map (f ∘ g) x = map f (map g x)。 在这里展开 map 的定义会得到 pure (f ∘ g) <*> x = pure f <*> (pure g <*> x)。 使用将纯操作进行排序视为无操作的规则,其左侧可以重写为 pure (· ∘ ·) <*> pure f <*> pure g <*> x。 这是应用函子遵守函数复合规则的一个实例。

这证明了定义一个扩展自 FunctorApplicative 是合理的,其中 map 的默认定义可以用 pureseq 来表示:

class Applicative (f : Type → Type) extends Functor f where
  pure : α → f α
  seq : f (α → β) → (Unit → f α) → f β
  map g x := seq (pure g) (fun () => x)

所有单子都是应用函子

Monad 的一个实例已经需要实现 pure。 结合 bind,这足以定义 seq

def seq [Monad m] (f : m (α → β)) (x : Unit → m α) : m β := do
  let g ← f
  let y ← x ()
  pure (g y)

再一次,检验 Monad 的契约暗含 Applicative 的契约,如果 Monad 扩展自 Applicative,这将允许将其用作 seq 的默认定义。

本节的其余部分包含一个论点,即基于 bindseq 的这种实现实际上满足了 Applicative 契约。 函数式编程的一个美妙之处在于,这种论点可以用铅笔在纸上完成,使用表达式求值初步小节中的求值规则。 在阅读这些论点时,思考操作的含义有时有助于理解。

do 表示法替换为 >>= 的显式使用可以更容易地应用 Monad 规则:

def seq [Monad m] (f : m (α → β)) (x : Unit → m α) : m β := do
  f >>= fun g =>
  x () >>= fun y =>
  pure (g y)

为了检查这个定义遵循恒等性,请检验 seq (pure id) (fun () => v) = v。 左侧等价于 pure id >>= fun g => (fun () => v) () >>= fun y => pure (g y)。 中间的单位函数可以立即消除,得到 pure id >>= fun g => v >>= fun y => pure (g y)。 利用 pure>>= 的左恒等性这一事实,其等同于 v >>= fun y => pure (id y),也就是 v >>= fun y => pure y。 因为 fun x => f xf 是相同的,所以这与 v >>= pure 相同,并且 pure>>= 的右恒等性可以被用来获取 v

这种非正式的推理可以通过稍微的重新编排来使其更易阅读。 在下表中,读取 "EXPR1 ={ REASON }= EXPR2" 时,请将其理解为 "EXPR1 因为 REASON 与 EXPR2 相同":

pure id >>= fun g => v >>= fun y => pure (g y)
={ pure is a left identity of >>= }=
v >>= fun y => pure (id y)
={ Reduce the call to id }=
v >>= fun y => pure y
={ fun x => f x is the same as f }=
v >>= pure
={ pure is a right identity of >>= }=
v

要检查它遵守函数复合的规则,请验证 pure (· ∘ ·) <*> u <*> v <*> w = u <*> (v <*> w)。 第一步是用 seq 的这个定义替换 <*>。 之后,使用 Monad 契约中的恒等律和结合律规则的一系列(有点长的)步骤,以从一个得到另一个:

seq (seq (seq (pure (· ∘ ·)) (fun _ => u))
      (fun _ => v))
  (fun _ => w)
={ Definition of seq }=
((pure (· ∘ ·) >>= fun f =>
   u >>= fun x =>
   pure (f x)) >>= fun g =>
  v >>= fun y =>
  pure (g y)) >>= fun h =>
 w >>= fun z =>
 pure (h z)
={ pure is a left identity of >>= }=
((u >>= fun x =>
   pure (x ∘ ·)) >>= fun g =>
   v >>= fun y =>
  pure (g y)) >>= fun h =>
 w >>= fun z =>
 pure (h z)
={ Insertion of parentheses for clarity }=
((u >>= fun x =>
   pure (x ∘ ·)) >>= (fun g =>
   v >>= fun y =>
  pure (g y))) >>= fun h =>
 w >>= fun z =>
 pure (h z)
={ Associativity of >>= }=
(u >>= fun x =>
  pure (x ∘ ·) >>= fun g =>
 v  >>= fun y => pure (g y)) >>= fun h =>
 w >>= fun z =>
 pure (h z)
={ pure is a left identity of >>= }=
(u >>= fun x =>
  v >>= fun y =>
  pure (x ∘ y)) >>= fun h =>
 w >>= fun z =>
 pure (h z)
={ Associativity of >>= }=
u >>= fun x =>
v >>= fun y =>
pure (x ∘ y) >>= fun h =>
w >>= fun z =>
pure (h z)
={ pure is a left identity of >>= }=
u >>= fun x =>
v >>= fun y =>
w >>= fun z =>
pure ((x ∘ y) z)
={ Definition of function composition }=
u >>= fun x =>
v >>= fun y =>
w >>= fun z =>
pure (x (y z))
={ Time to start moving backwards!pure is a left identity of >>= }=
u >>= fun x =>
v >>= fun y =>
w >>= fun z =>
pure (y z) >>= fun q =>
pure (x q)
={ Associativity of >>= }=
u >>= fun x =>
v >>= fun y =>
 (w >>= fun p =>
  pure (y p)) >>= fun q =>
 pure (x q)
={ Associativity of >>= }=
u >>= fun x =>
 (v >>= fun y =>
  w >>= fun q =>
  pure (y q)) >>= fun z =>
 pure (x z)
={ This includes the definition of seq }=
u >>= fun x =>
seq v (fun () => w) >>= fun q =>
pure (x q)
={ This also includes the definition of seq }=
seq u (fun () => seq v (fun () => w))

以检验对纯操作进行排序为无操作:

seq (pure f) (fun () => pure x)
={ Replacing seq with its definition }=
pure f >>= fun g =>
pure x >>= fun y =>
pure (g y)
={ pure is a left identity of >>= }=
pure f >>= fun g =>
pure (g x)
={ pure is a left identity of >>= }=
pure (f x)

最后,以检验纯操作的顺序是无关紧要的:

seq u (fun () => pure x)
={ Definition of seq }=
u >>= fun f =>
pure x >>= fun y =>
pure (f y)
={ pure is a left identity of >>= }=
u >>= fun f =>
pure (f x)
={ Clever replacement of one expression by an equivalent one that makes the rule match }=
u >>= fun f =>
pure ((fun g => g x) f)
={ pure is a left identity of >>= }=
pure (fun g => g x) >>= fun h =>
u >>= fun f =>
pure (h f)
={ Definition of seq }=
seq (pure (fun f => f x)) (fun () => u)

这证明了 Monad 的定义扩展自 Applicative,并提供一个 seq 的默认定义:

class Monad (m : Type → Type) extends Applicative m where
  bind : m α → (α → m β) → m β
  seq f x :=
    bind f fun g =>
    bind (x ()) fun y =>
    pure (g y)

Applicative 自身对 map 的默认定义意味着每个 Monad 实例都会自动生成 ApplicativeFunctor 实例。

附加规定

除了遵守每个类型类相关的单独契约之外,FunctorApplicativeMonad 的组合实现应与这些默认实现等效。 换句话说,一个同时提供 ApplicativeMonad 实例的类型,其 seq 的实现不应与 Monad 实例生成的默认实现不同。 这很重要,因为多态函数可以被重构,以使用 <*> 的等效使用去替换 >>=,或者用 >>= 的等效使用去替换 <*>。 这种重构不应改变使用该代码的程序的含义。

这条规则解释了为什么在 Monad 实例中不应使用 Validate.andThen 来实现 bind。 就其本身而言,它遵守单子契约。 然而,当它用于实现 seq 时,其行为并不等同于 seq 本身。 要了解它们的区别,举一个两个计算都返回错误的例子。 首先来看一个应该返回两个错误的情况,一个来自函数验证(这也可能是由函数先前的一个参数导致的),另一个来自参数验证:

def notFun : Validate String (Nat → String) :=
  .errors { head := "First error", tail := [] }

def notArg : Validate String Nat :=
  .errors { head := "Second error", tail := [] }

将它们与 ValidateApplicative 实例中的 <*> 版本结合起来,会导致两个错误都被报告给用户:

notFun <*> notArg
===>
match notFun with
| .ok g => g <$> notArg
| .errors errs =>
  match notArg with
  | .ok _ => .errors errs
  | .errors errs' => .errors (errs ++ errs')
===>
match notArg with
| .ok _ => .errors { head := "First error", tail := [] }
| .errors errs' => .errors ({ head := "First error", tail := [] } ++ errs')
===>
.errors ({ head := "First error", tail := [] } ++ { head := "Second error", tail := []})
===>
.errors { head := "First error", tail := ["Second error"]}

使用由 >>= 实现的 seq 版本(这里重写为 andThen)会导致只出现第一个错误:

seq notFun (fun () => notArg)
===>
notFun.andThen fun g =>
notArg.andThen fun y =>
pure (g y)
===>
match notFun with
| .errors errs => .errors errs
| .ok val =>
  (fun g =>
    notArg.andThen fun y =>
    pure (g y)) val
===>
.errors { head := "First error", tail := [] }

选择子

从失败中恢复

Validate 还可以用于当输入有多种可接受方式的情况。 对于输入表单 RawInput,实现来自遗留系统的约定的替代业务规则集合可能如下:

  1. 所有人类用户必须提供四位数的出生年份。
  2. 由于旧记录不完整,1970年以前出生的用户不需要提供姓名。
  3. 1970年以后出生的用户必须提供姓名。
  4. 公司应输入 "FIRM" 作为其出生年份并提供公司名称。

对于出生于1970年的用户,没有做出特别的规定。 预计他们要么放弃,要么谎报出生年份,要么打电话咨询。 公司认为这是可以接受的经营成本。

以下归纳类型捕获了可以从这些既定规则中生成的值:

abbrev NonEmptyString := {s : String // s ≠ ""}

inductive LegacyCheckedInput where
  | humanBefore1970 :
    (birthYear : {y : Nat // y > 999 ∧ y < 1970}) →
    String →
    LegacyCheckedInput
  | humanAfter1970 :
    (birthYear : {y : Nat // y > 1970}) →
    NonEmptyString →
    LegacyCheckedInput
  | company :
    NonEmptyString →
    LegacyCheckedInput
deriving Repr

然而,一个针对这些规则的验证器会更复杂,因为它必须处理所有三种情况。 虽然可以将其写成一系列嵌套的 if 表达式,但更容易的方式是独立设计这三种情况,然后再将它们组合起来。 这需要一种在保留错误信息的同时从失败中恢复的方法:

def Validate.orElse (a : Validate ε α) (b : Unit → Validate ε α) : Validate ε α :=
  match a with
  | .ok x => .ok x
  | .errors errs1 =>
    match b () with
    | .ok x => .ok x
    | .errors errs2 => .errors (errs1 ++ errs2)

这种从失败中恢复的模式非常常见,以至于 Lean 为此内置了一种语法,并将其附加到了一个名为 OrElse 的类型类上:

class OrElse (α : Type) where
  orElse : α → (Unit → α) → α

表达式 E1 <|> E2OrElse.orElse E1 (fun () => E2) 的简写形式。 ValidateOrElse 实例允许使用这种语法去进行错误恢复:

instance : OrElse (Validate ε α) where
  orElse := Validate.orElse

LegacyCheckedInput 的验证器可以由每个构造子的验证器构建而成。 对于公司的规则,规定了其出生年份应为字符串 "FIRM",且名称应为非空。 然而,构造子 LegacyCheckedInput.company 根本没有出生年份的表示,因此无法通过 <*> 去轻松执行此操作。 关键是使用一个忽略其参数的函数与 <*> 一起使用。

要检查一个布尔条件是否成立,而无需在类型中记录此事实的任何证据,可以通过 checkThat 来完成:

def checkThat (condition : Bool) (field : Field) (msg : String) : Validate (Field × String) Unit :=
  if condition then pure () else reportError field msg

这个 checkCompany 的定义使用了 checkThat,然后丢弃了生成的 Unit 值:

def checkCompany (input : RawInput) : Validate (Field × String) LegacyCheckedInput :=
  pure (fun () name => .company name) <*>
    checkThat (input.birthYear == "FIRM") "birth year" "FIRM if a company" <*>
    checkName input.name

但是,这个定义相当繁琐。 可以通过两种方式来简化它。 第一种方法是将第一次使用的 <*> 替换为一个专门的版本,称为 *>,改版本会自动忽略第一个参数所返回的值。 这个运算符也是由一个类型类控制,称为 SeqRightE1 *> E2SeqRight.seqRight E1 (fun () => E2) 的语法糖:

class SeqRight (f : Type → Type) where
  seqRight : f α → (Unit → f β) → f β

基于 seqseqRight 有一个默认实现:seqRight (a : f α) (b : Unit → f β) : f β := pure (fun _ x => x) <*> a <*> b ()

使用 seqRight 后,checkCompany 变得更简单了:

def checkCompany (input : RawInput) : Validate (Field × String) LegacyCheckedInput :=
  checkThat (input.birthYear == "FIRM") "birth year" "FIRM if a company" *>
  pure .company <*> checkName input.name

还可以进行进一步简化。 对于每个 Applicativepure F <*> E 等价于 f <$> E。 换句话说,使用 seq 来应用到使用 pure 放入 Applicative 类型的函数是多余的,这个函数完全可以使用 Functor.map 来应用。 这种简化得到的结果是:

def checkCompany (input : RawInput) : Validate (Field × String) LegacyCheckedInput :=
  checkThat (input.birthYear == "FIRM") "birth year" "FIRM if a company" *>
  .company <$> checkName input.name

LegacyCheckedInput 的其余两个构造子在其属性中使用了子类型。 一个用于检查子类型的通用工具将使这些构造函数更易读:

def checkSubtype {α : Type} (v : α) (p : α → Prop) [Decidable (p v)] (err : ε) : Validate ε {x : α // p x} :=
  if h : p v then
    pure ⟨v, h⟩
  else
    .errors { head := err, tail := [] }

在函数的参数列表中,重要的是类型类 [Decidable (p v)] 应出现在参数 vp 的指定之后。 否则,它将引用一组额外的自动隐式参数,而不是手动提供的值。 Decidable 实例允许使用 if 来检查命题 p v

这两种人类情况不需要任何额外的工具:

def checkHumanBefore1970 (input : RawInput) : Validate (Field × String) LegacyCheckedInput :=
  (checkYearIsNat input.birthYear).andThen fun y =>
    .humanBefore1970 <$>
      checkSubtype y (fun x => x > 999 ∧ x < 1970) ("birth year", "less than 1970") <*>
      pure input.name

def checkHumanAfter1970 (input : RawInput) : Validate (Field × String) LegacyCheckedInput :=
  (checkYearIsNat input.birthYear).andThen fun y =>
    .humanAfter1970 <$>
      checkSubtype y (· > 1970) ("birth year", "greater than 1970") <*>
      checkName input.name

可以使用 <|> 将这三种情况的验证器组合在一起:

def checkLegacyInput (input : RawInput) : Validate (Field × String) LegacyCheckedInput :=
  checkCompany input <|> checkHumanBefore1970 input <|> checkHumanAfter1970 input

成功的情况会返回 LegacyCheckedInput 的构造子,正如预期的那样:

#eval checkLegacyInput ⟨"Johnny's Troll Groomers", "FIRM"⟩
Validate.ok (LegacyCheckedInput.company "Johnny's Troll Groomers")
#eval checkLegacyInput ⟨"Johnny", "1963"⟩
Validate.ok (LegacyCheckedInput.humanBefore1970 1963 "Johnny")
#eval checkLegacyInput ⟨"", "1963"⟩
Validate.ok (LegacyCheckedInput.humanBefore1970 1963 "")

最糟糕的输入会返回所有可能的失败:

#eval checkLegacyInput ⟨"", "1970"⟩
Validate.errors
  { head := ("birth year", "FIRM if a company"),
    tail := [("name", "Required"),
             ("birth year", "less than 1970"),
             ("birth year", "greater than 1970"),
             ("name", "Required")] }

Alternative

许多类型都支持失败和恢复的概念。 多种单子中对算术表达式的求值小节中的 Many 单子就是其中的一种,Option 也是如此。 这两种类型都支持失败但不提供失败的原因的情况(不同于 ExceptValidate,它们需要对出错的原因进行某些指示)。

Alternative 类描述了具有用于失败和恢复的附加运算符的应用函子:

class Alternative (f : Type → Type) extends Applicative f where
  failure : f α
  orElse : f α → (Unit → f α) → f α

正如 Add α 的实现者可以免费获得 HAdd α α α 实例一样,Alternative 的实现者也可以免费获得 OrElse 实例:

instance [Alternative f] : OrElse (f α) where
  orElse := Alternative.orElse

OptionAlternative 实现保留着第一个非 none 的参数:

instance : Alternative Option where
  failure := none
  orElse
    | some x, _ => some x
    | none, y => y ()

同样,Many 的实现遵循着 Many.union 的一般结构,由于惰性诱导的 Unit 参数放置位置不同,它们仅在一些细节上有所差异:

def Many.orElse : Many α → (Unit → Many α) → Many α
  | .none, ys => ys ()
  | .more x xs, ys => .more x (fun () => orElse (xs ()) ys)

instance : Alternative Many where
  failure := .none
  orElse := Many.orElse

和其他类型类一样,Alternative 允许为实现了 Alternative任意 应用函子定义各种操作。 其中最重要的是 guard,当一个可判定的命题为假时,它会导致 failure

def guard [Alternative f] (p : Prop) [Decidable p] : f Unit :=
  if p then
    pure ()
  else failure

在单子程序中,提前终止执行是非常有用的。 在 Many 中,它可以用来过滤掉搜索中的整个分支,如以下程序所示,该程序计算一个自然数的所有偶因数:

def Many.countdown : Nat → Many Nat
  | 0 => .none
  | n + 1 => .more n (fun () => countdown n)

def evenDivisors (n : Nat) : Many Nat := do
  let k ← Many.countdown (n + 1)
  guard (k % 2 = 0)
  guard (n % k = 0)
  pure k

20 上运行它会产生预期的结果:

#eval (evenDivisors 20).takeAll
[20, 10, 4, 2]

练习题

提高验证的友好性

使用 <|>Validate 程序返回的错误可能难以阅读,因为被包含在错误列表中仅意味着通过 某些 代码路径可以到达该错误。 可以使用更结构化的错误报告来更准确地指导用户完成这个过程:

  • Validate.error 中的 NonEmptyList 替换为裸类型变量,然后更新 Applicative (Validate ε)OrElse (Validate ε α) 实例的定义,以此来仅要求存在一个 Append ε 实例。
  • 定义一个函数 Validate.mapErrors : Validate ε α → (ε → ε') → Validate ε' α,该函数会转换验证运行中的所有错误。
  • 使用数据类型 TreeError 来表示错误,重写遗留验证系统,以通过三种选择子去追踪其路径。
  • 编写一个函数 report : TreeError → String,该函数会输出 TreeError 的累计警告和错误的用户友好视图。
inductive TreeError where
  | field : Field → String → TreeError
  | path : String → TreeError → TreeError
  | both : TreeError → TreeError → TreeError

instance : Append TreeError where
  append := .both

宇宙

为了简化,本书到目前为止略去了 Lean 的一个重要特性:宇宙 (Universes)。 宇宙是一种对其他类型进行分类的类型。 其中两个是我们熟悉的:TypePropType 分类了普通类型,例如 NatStringInt → String × CharIO UnitProp 分类了可能为真或假的命题,例如 "nisse" = "elf"3 > 2Prop 的类型是 Type

#check Prop
Prop : Type

出于技术原因,我们需要比这两个更多的宇宙。 具体而言,Type 本身不能是一个 Type。 这会导致逻辑悖论的产生,并削弱 Lean 作为定理证明器的实用性。

对此的正式论证被称为 吉拉德悖论 (Girard's Paradox)。 它与一个更著名的悖论有关,称为 罗素悖论 (Russell's Paradox),该悖论用于展示早期版本的集合论是不一致的。 在这些集合论中,一个集合可以通过一个属性来定义。 例如,所有红色事物的集合,所有水果的集合,所有自然数的集合,甚至所有集合的集合。 给定一个集合,可以询问一个给定的元素是否被包含在其中。 例如,一只蓝色的鸟不会被包含在所有红色事物的集合中,但所有红色事物的集合被包含在所有集合的集合中。 实际上,所有集合的集合甚至包含其自身。

那么,所有不包含自身的集合的集合呢? 它包含所有红色事物的集合,因为所有红色事物的集合本身并不是红色的。 它不包含所有集合的集合,因为所有集合的集合包含自身。 但它是否包含自身呢? 如果它包含自身,那么它就不能包含自身。 但如果它不包含自身,那么它就必须包含自身。

这是一个矛盾,表明了初始的假设存在问题。 具体而言,允许通过提供任意属性来构造集合的做法过于强大。 集合论的后续版本限制了集合的构造以消除这种悖论。

在那些可以将类型 Type 分配给 Type依赖类型理论 (Dependent Type Theory) 的版本中,可以构建一个相关的悖论。 为了确保 Lean 具有自洽的逻辑基础并且能够被用作数学工具,Type 需要有其他类型。 这个类型称为 Type 1

#check Type
Type : Type 1

类似地,Type 1 是一个 Type 2Type 2 是一个 Type 3Type 3 是一个 Type 4,等等。

函数类型占据了可以同时包含参数类型和返回类型的最小宇宙。 这意味着 Nat → Nat 是一个 TypeType → Type 是一个 Type 1,而 Type 1 → Type 2 是一个 Type 3

这个规则有一个例外。 如果一个函数的返回类型是 Prop,那么即使参数在更大的宇宙中,例如 Type 或甚至 Type 1,整个函数类型也在 Prop 中。 具体而言,这意味着具有普通类型的值的谓词在 Prop 中。 例如,类型 (n : Nat) → n = n + 0 表示了从一个 Nat 到它等于自身加零的证据的函数。 尽管 NatType 中,根据这个规则,这个函数类型在 Prop 中。 同样,尽管 TypeType 1 中,函数类型 Type → 2 + 2 = 4 仍在 Prop 中。

用户定义类型

结构体和归纳数据类型可以声明为存在于特定的宇宙中。 Lean 随后会检查每个数据类型是否通过位于足够大的宇宙中来避免悖论,从而防止它包含其自身的类型。 例如,在以下声明中,MyList 被声明为驻留在 Type 中,而它的类型参数 α 也是如此:

inductive MyList (α : Type) : Type where
  | nil : MyList α
  | cons : α → MyList α → MyList α

MyList 本身是一个 Type → Type。 这意味着它不能用于包含实际类型,因为那样的话它的参数将会是 Type,也就是一个 Type 1

def myListOfNat : MyList Type :=
  .cons Nat .nil
application type mismatch
  MyList Type
argument
  Type
has type
  Type 1 : Type 2
but is expected to have type
  Type : Type 1

更新 MyList 使其参数为一个 Type 1,这会导致该定义被 Lean 拒绝:

inductive MyList (α : Type 1) : Type where
  | nil : MyList α
  | cons : α → MyList α → MyList α
invalid universe level in constructor 'MyList.cons', parameter has type
  α
at universe level
  2
it must be smaller than or equal to the inductive datatype universe level
  1

发生此错误的原因是,类型为 αcons 的参数来自一个比 MyList 更大的宇宙。 将 MyList 本身置于 Type 1 中可以解决这个问题,但代价是 MyList 本身在需要 Type 的内容中变得不便使用。

决定某种数据类型是否被允许的具体规则有些复杂。 通常来说,最简单的方法是,从其最大的参数所属的宇宙与自身所属的宇宙相同的数据类型开始。 然后,如果 Lean 拒绝了该定义,那就将其层级增加一级,这通常会奏效。

宇宙多态

在特定的宇宙中定义一个数据类型可能会导致代码重复。 将 MyList 置于 Type → Type 中意味着它不能被用于实际的类型列表。 将它放在 Type 1 → Type 1 内意味着它不能用于类型列表的列表。 与其复制粘贴数据类型以在 TypeType 1Type 2 等中创建不同版本,不如使用一种称为 宇宙多态 的特性来编写单个可以在任意这些宇宙中实例化的定义。

普通的多态类型在定义中使用变量来表示类型。 这使得 Lean 可以以不同的方式填充这些变量,从而使这些定义可以与各种类型一起使用。 同样,宇宙多态性允许变量在定义中表示宇宙,使得 Lean 可以以不同的方式去填充它们,以便可以用于各种宇宙。 正如类型参数通常用希腊字母命名一样,宇宙参数通常命名为 uvw

MyList 的这个定义没有指定特定的宇宙层级,而是使用变量 u 来表示任意层级。 如果最终的数据类型与 Type 一起使用,那么 u0;如果与 Type 3 一起使用,那么 u3

inductive MyList (α : Type u) : Type u where
  | nil : MyList α
  | cons : α → MyList α → MyList α

通过这个定义,MyList 的相同定义可以用于包含实际的自然数以及自然数类型本身:

def myListOfNumbers : MyList Nat :=
  .cons 0 (.cons 1 .nil)

def myListOfNat : MyList Type :=
  .cons Nat .nil

它甚至可以包含其自身:

def myListOfList : MyList (Type → Type) :=
  .cons MyList .nil

这似乎使得写出一个逻辑悖论成为可能。 毕竟,宇宙系统的全部意义在于排除自指类型。 然而,在幕后,每次出现 MyList 时都会提供一个宇宙层级的参数。 本质上,MyList 的宇宙多态定义在每个层级创建了数据类型的一个 副本,层级参数选择要使用哪个副本。 这些层级参数使用一个点和大括号书写,例如 MyList.{0} : Type → TypeMyList.{1} : Type 1 → Type 1,和 MyList.{2} : Type 2 → Type 2

明确地写出所有层次,之前的例子变成了:

def myListOfNumbers : MyList.{0} Nat :=
  .cons 0 (.cons 1 .nil)

def myListOfNat : MyList.{1} Type :=
  .cons Nat .nil

def myListOfList : MyList.{1} (Type → Type) :=
  .cons MyList.{0} .nil

当一个宇宙多态定义接受了多个类型作为参数时,最好给每个参数赋予其自己的层级变量,以实现最大的灵活性。 例如,一个带有单个层级参数的 Sum 版本可以写成如下形式:

inductive Sum (α : Type u) (β : Type u) : Type u where
  | inl : α → Sum α β
  | inr : β → Sum α β

这个定义可以在多个层级上使用:

def stringOrNat : Sum String Nat := .inl "hello"

def typeOrType : Sum Type Type := .inr Nat

但是,它要求两个参数位于同一个宇宙内:

def stringOrType : Sum String Type := .inr Nat
application type mismatch
  Sum String Type
argument
  Type
has type
  Type 1 : Type 2
but is expected to have type
  Type : Type 1

通过为两个类型参数的宇宙层级使用不同的变量,并声明生成的数据类型是两者中最大的层级,这可以使该数据类型更加灵活:

inductive Sum (α : Type u) (β : Type v) : Type (max u v) where
  | inl : α → Sum α β
  | inr : β → Sum α β

这使得 Sum 可以与来自不同宇宙的参数一起使用:

def stringOrType : Sum String Type := .inr Nat

在 Lean 需要宇宙层级的位置,以下任意一种都是被允许的:

  • 具体的层级,如 01
  • 代表层级的变量,如 uv
  • 两个层级的最大值,写作 max 应用于这些层级
  • 层级增加,写作 + 1

编写宇宙多态定义

到目前为止,本书中定义的每种数据类型都在 Type 中,即最小的数据宇宙。 在展示 Lean 标准库中的多态数据类型时,例如 ListSum,本书创建了它们的非宇宙多态的版本。 实际的版本使用了宇宙多态性来实现类型层级和非类型层级程序之间的代码复用。

在编写宇宙多态类型时,有一些通用的指导准则需要遵守。 首先,独立的类型参数应具有不同的宇宙变量,这使得多态定义能够与更多种类的参数一起使用,从而增加代码复用的可能性。 其次,整个类型本身通常要么位于所有宇宙变量的最大值,要么位于比这个最大值大一的层级。 先尝试使用两者中较小的那个。 最后,最好将新类型放在一个尽可能小的宇宙中,这使得它在其他内容中可以更灵活地使用。 非多态类型,如 NatString,可以直接放在 Type 0 中。

Prop 和多态

就像 TypeType 1 等描述了对程序和数据进行分类的类型一样,Prop 则用于对逻辑命题进行分类。 Prop 中的类型描述了什么可以作为令人信服的证据以证明一个陈述的真。 命题在许多方面与普通类型相似:它们可以被归纳地声明,它们可以有构造子,并且函数也可以将命题作为参数。 然而,与数据类型不同的是,通常来说,为证明陈述的真实性所提供的 那个 证据的具体内容并不重要,重要的是提供了 那个 证据。 另一方面,程序不仅要返回一个 Nat,而且要返回 正确的 Nat,这一点非常重要。

Prop 位于宇宙层级体系的底部,且 Prop 的类型是 Type。 这意味着 Prop 适合作为 List 的一个参数,原因和 Nat 一样。 命题列表的类型是 List Prop

def someTruePropositions : List Prop := [
  1 + 1 = 2,
  "Hello, " ++ "world!" = "Hello, world!"
]

显式地填写宇宙参数表明了 Prop 是一个 Type

def someTruePropositions : List.{0} Prop := [
  1 + 1 = 2,
  "Hello, " ++ "world!" = "Hello, world!"
]

在幕后,PropType 被统一到一个称为 Sort 的层级体系中。 PropSort 0 相同,Type 0Sort 1Type 1Sort 2,依此类推。 实际上,Type u 就是 Sort (u+1)。 在使用 Lean 编写程序时,这通常并不相关,但它可能有时会出现在错误消息中,并解释 CoeSort 类的名称。 此外,将 Prop 作为 Sort 0 可以使得一个额外的宇宙运算符变得有用。 宇宙级别 imax u vv0 时为 0,否则为 uv 中较大的那个。 结合 Sort,这使得在编写代码时可以使用一个特殊规则,该规则允许返回 Prop 的函数在 PropType 宇宙之间尽可能地具有可移植性。

多态的实际应用

在本书的其余部分,多态数据类型、结构体和类的定义将使用宇宙多态性,以便与 Lean 的标准库保持一致。 这将使 FunctorApplicativeMonad 类的完整展示与它们的实际定义完全一致。

完整定义

现在所有相关的语言特性都已介绍完毕,本节将讲述 Lean 的标准库中 FunctorApplicativeMonad 的完整、准确的定义。 为了便于理解,没有任何细节会被省略。

函子

Functor 类的完整定义使用了宇宙多态性和默认方法实现:

class Functor (f : Type u → Type v) : Type (max (u+1) v) where
  map : {α β : Type u} → (α → β) → f α → f β
  mapConst : {α β : Type u} → α → f β → f α :=
    Function.comp map (Function.const _)

在这个定义中,Function.comp 是函数复合,通常用 运算符表示。 Function.const常量函数,它是一个忽略第二个参数的二元函数。 将该函数应用于仅一个参数上时,会生成一个总是返回相同值的函数,这在 API 需要一个函数但程序不需要根据不同参数去计算不同结果时非常有用。 一个简单版本的 Function.const 可以编写如下:

def simpleConst  (x : α) (_ : β) : α := x

将其与一个参数一起使用作为 List.map 的函数参数可以演示它的实用性:

#eval [1, 2, 3].map (simpleConst "same")
["same", "same", "same"]

实际的函数具有以下签名:

Function.const.{u, v} {α : Sort u} (β : Sort v) (a : α) (a✝ : β) : α

这里,类型参数 β 是一个显式参数,因此 Functor.mapConst 的默认定义提供了一个 _ 参数,这个参数指示着 Lean 去找到一个唯一的类型来传递给 Function.const,以使程序通过类型检查。 (Function.comp map (Function.const _) : α → f β → f α) 等价于 fun (x : α) (y : f β) => map (fun _ => x) y

Functor 类型类所处的宇宙是 u+1v 中较大的一个。 这里,u 是作为 f 所接受的参数的宇宙层级,而 vf 返回的宇宙。 要理解实现了 Functor 类型类的结构为何必须处于比 u 更大的宇宙中,请从该类的简化定义开始:

class Functor (f : Type u → Type v) : Type (max (u+1) v) where
  map : {α β : Type u} → (α → β) → f α → f β

该类型类的结构类型等同于以下的归纳类型:

inductive Functor (f : Type u → Type v) : Type (max (u+1) v) where
  | mk : ({α β : Type u} → (α → β) → f α → f β) → Functor f

作为参数传递给 Functor.mkmap 方法的实现包含着一个函数,该函数将 Type u 中的两个类型作为参数。 这意味着函数本身的类型在 Type (u+1) 中,因此 Functor 也必须至少处于 u+1 层级。 类似地,函数的其他参数的类型是通过应用 f 构建的,所以它们也必须至少在 v 级别。 本节中的所有类型类都具有这一属性。

应用类型类

Applicative 类型类实际上是由多个较小的类构成的,其中每个较小的类都包含着一些相关的方法。 首先是 PureSeq,它们分别包含着 pureseq 方法:

class Pure (f : Type u → Type v) : Type (max (u+1) v) where
  pure {α : Type u} : α → f α

class Seq (f : Type u → Type v) : Type (max (u+1) v) where
  seq : {α β : Type u} → f (α → β) → (Unit → f α) → f β

除了这些之外,Applicative 还依赖于 SeqRight 以及一个类似的 SeqLeft 类:

class SeqRight (f : Type u → Type v) : Type (max (u+1) v) where
  seqRight : {α β : Type u} → f α → (Unit → f β) → f β

class SeqLeft (f : Type u → Type v) : Type (max (u+1) v) where
  seqLeft : {α β : Type u} → f α → (Unit → f β) → f α

seqRight 函数在关于选择子和验证的小节中介绍过,从作用的角度来看,它是最容易理解的。 E1 *> E2,其去除语法糖后的形式为 SeqRight.seqRight E1 (fun () => E2),可以理解为先执行 E1,然后执行 E2,最终仅保留 E2 的结果。 E1 的作用可能导致 E2 未被执行,或被多次运行。 实际上,如果 f 有一个 Monad 实例,那么 E1 *> E2 等价于 do let _ ← E1; E2,但 seqRight 可以与像 Validate 这样不是单子的类型一起使用。

它的近亲 seqLeft 非常相似,只不过其返回的是最左边的表达式的值。 E1 <* E2 被去除语法糖后的形式为 SeqLeft.seqLeft E1 (fun () => E2)SeqLeft.seqLeft 的类型是 f α → (Unit → f β) → f α,与 seqRight 的类型相同,只是它返回 f αE1 <* E2 可以理解为一个先执行 E1,然后执行 E2,最后返回 E1 的原始结果的程序。 如果 f 有一个 Monad 实例,那么 E1 <* E2 等价于 do let x ← E1; _ ← E2; pure x。 通常来说,seqLeft 在验证或类似解析器的工作流程中,可用于为一个值指定的额外条件而不改变该值本身。

Applicative 的定义扩展自所有这些类,以及 Functor

class Applicative (f : Type u → Type v) extends Functor f, Pure f, Seq f, SeqLeft f, SeqRight f where
  map      := fun x y => Seq.seq (pure x) fun _ => y
  seqLeft  := fun a b => Seq.seq (Functor.map (Function.const _) a) b
  seqRight := fun a b => Seq.seq (Functor.map (Function.const _ id) a) b

完整定义 Applicative 只需要 pureseq 的定义。 这是因为 FunctorSeqLeftSeqRight 的所有方法都有默认定义。 FunctormapConst 方法有一个基于 Functor.map 的自己的默认实现。 这些默认实现只应被行为上等价但更高效的新函数覆盖。 默认实现应被视为正确性的规范以及自动创建的代码。

seqLeft 的默认实现非常简洁。 将其中一些名称替换为它们的语法糖或它们的定义可以提供另一种视角,因此:

fun a b => Seq.seq (Functor.map (Function.const _) a) b

变成了

fun a b => Seq.seq ((fun x _ => x) <$> a) b

(fun x _ => x) <$> a 应该如何理解? 这里,a 的类型是 f α,且 f 是一个函子。 如果 fList,那么 (fun x _ => x) <$> [1, 2, 3] 的求值结果为 [fun _ => 1, fun _ => 2, fun _ => 3]。 如果 fOption,那么 (fun x _ => x) <$> some "hello" 的求值结果为 some (fun _ => "hello")。 在每种情况下,函子中的值都被替换为忽略其参数并返回原始值的函数。 当与 seq 组合时,该函数会舍弃 seq 的第二个参数的值。

seqRight 的默认实现非常相似,只不过 const 有一个额外的参数 id。 这个定义可以类似地理解,首先引入一些标准的语法糖,然后用它们的定义替换一些名称:

fun a b => Seq.seq (Functor.map (Function.const _ id) a) b
===>
fun a b => Seq.seq ((fun _ => id) <$> a) b
===>
fun a b => Seq.seq ((fun _ => fun x => x) <$> a) b
===>
fun a b => Seq.seq ((fun _ x => x) <$> a) b

(fun _ x => x) <$> a 应该如何理解? 同样地,例子很有用。 (fun _ x => x) <$> [1, 2, 3] 等价于 [fun x => x, fun x => x, fun x => x],而 (fun _ x => x) <$> some "hello" 等价于 some (fun x => x)。 换句话说,(fun _ x => x) <$> a 保留了 a 的整体形状,但每个值都被恒等函数替换。 从作用的角度来看,a 的副作用发生了,但是当它与 seq 一起使用时,其值会被丢弃。

单子

正如 Applicative 的组成操作被分成各自的类型类一样,Bind 也有它自己的类型类:

class Bind (m : Type u → Type v) where
  bind : {α β : Type u} → m α → (α → m β) → m β

Monad 扩展自 Applicative,以及 Bind

class Monad (m : Type u → Type v) extends Applicative m, Bind m : Type (max (u+1) v) where
  map      f x := bind x (Function.comp pure f)
  seq      f x := bind f fun y => Functor.map y (x ())
  seqLeft  x y := bind x fun a => bind (y ()) (fun _ => pure a)
  seqRight x y := bind x fun _ => y ()

从整个层级结构中追踪继承的方法和默认方法的集合可以看出,一个 Monad 实例只需要实现 bindpure。 换句话说,Monad 实例会自动生成 seqseqLeftseqRightmapmapConst 的实现。 从 API 边界的角度来看,任何具有 Monad 实例的类型都会获得 BindPureSeqFunctorSeqLeftSeqRight 的实例。

练习

  1. 通过研究诸如 OptionExcept 等例子来理解 MonadmapseqseqLeftseqRight 的默认实现。换句话说,将它们对 bindpure 的定义去替换默认定义,并简化它们以恢复手写版本的 mapseqseqLeftseqRight
  2. 在纸上或文本文件中,向自己证明 mapseq 的默认实现满足 FunctorApplicative 的契约。在这个论证中,你允许使用 Monad 契约中的规则以及普通的表达式求值。

总结

类型类与结构体

在幕后,类型类由结构体表示。 定义一个类就是定义一个结构体,并额外创建一个空的实例表。 定义一个实例会创建一个值,该值要么具有该结构体作为其类型,要么是一个可以返回该结构体的函数,并另外在表中添加一个条目。 实例搜索包括通过查询实例表来构建一个实例。 结构体和类都可以为属性提供默认值(即方法的默认实现)。

结构体和继承

结构体可以继承自其他结构体。 在幕后,继承自另一个结构体的结构体将原始结构体的实例作为一个属性。 换句话说,继承是通过复合实现的。 当使用多重继承时,只有附加父结构体中的唯一属性会被使用以避免菱形问题,并且通常用来提取父值的函数则被组织起来构造一个函数。 记录点表示法会考虑结构体继承。

因为类型类只是应用了一些额外自动化的结构体,所以所有这些功能都可以在类型类中使用。 结合默认方法,这可以用来创建一个精细的接口层次结构,但不会给用户带来很大的负担,因为大型类所继承自的小型类可以自动实现。

应用函子

应用函子是具有两个附加操作的函子:

  • pure,与 Monad 中的运算符相同
  • seq,允许在函子中应用一个函数

虽然单子可以表示具有控制流的任意程序,但应用函子只能从左到右运行函数参数。 由于它们的功能较弱,因此它们对针对于接口所编写的程序提供的控制较少,而方法的实现者则有更大的自由度。 一些有用的类型可以实现 Applicative,但无法实现 Monad

实际上,类型类 FunctorApplicativeMonad 形成了一个能力层级体系。 在这个层级体系中,从 FunctorMonad 逐级上升,可以编写更强大的程序,但实现更强大类的类型会更少。 多态程序应尽可能使用较弱的抽象,而数据类型应赋予尽可能强大的实例。 这样可以最大限度地提高代码的复用率。 更强大的类型类扩展自较弱的类型类,这意味着 Monad 的实现会免费提供 FunctorApplicative 的实现。

每个类都有一组需要实现的方法和一个相应的契约,该契约规定了这些方法的附加规则。 针对这些接口编写的程序期望遵循这些附加规则,否则可能会出现错误。 Functor 方法在 Applicative 的基础上的默认实现,以及 Applicative 方法在 Monad 的基础上实现 的默认实现,都将遵循这些规则。

宇宙

为了使 Lean 既能用作编程语言又能用作定理证明器,对该语言的一些限制是必要的。 这包括对递归函数的限制,以确保它们要么全部终止,要么被标记为 partial 并且返回的类型不是空类型。 此外,必须确保某些逻辑悖论不可能表示为类型。

排除某些悖论的限制之一是,每个类型都被分配到一个 宇宙。 宇宙是诸如 PropTypeType 1Type 2 等类型。 这些类型描述了其他类型——就像 017 是由 Nat 描述,Nat 本身是由 Type 描述,而 Type 是由 Type 1 描述。 以类型作为参数的函数的类型必须是比参数的宇宙更大的宇宙。

由于每个声明的数据类型都有一个宇宙,因此编写使用类型的数据等等的代码会变得非常麻烦,因为需要每个多态类型都用复制粘贴以接受 Type 1 的参数。 一种称为 宇宙多态 的特性允许 Lean 程序和数据类型将宇宙层级作为参数,就像普通的多态允许程序将类型作为参数一样。 通常来说,Lean 的库在实现多态操作的库时应使用宇宙多态性。

单子转换器

单子是一种在纯语言中编码某些副作用的方式。 不同的单子可以编码不同的副作用,例如状态和错误处理。 很多单子甚至会提供在大多数语言中不可用的有用作用,例如非确定性搜索、读取器,甚至续体。

一个典型的应用程序有一组易于测试的不包含单子的核心函数,并配对了一个使用单子来编码必要应用逻辑的外部封装。 这些单子是由常见的组件构建的。

比如:

  • 可变状态通过具有相同类型的函数参数和返回值来编码
  • 错误处理通过具有类似于 Except 的返回类型来编码,该类型具有用于表示成功和失败的构造函数
  • 通过将返回值与日志配对,对日志进行编码

然而,手动编写每个单子是繁琐的,需要定义各种类型类的样板代码。 每个组件也都可以提取到一个定义中,该定义修改某个其他单子以添加额外的作用。 这种定义称为 单子转换器 (Monad Transformer)。 一个具体的单子可以从一组单子转换器构建,从而实现更多代码的重用。

组合 IO 与 Reader

当应用程序存在类似“当前配置”的数据需要通过多次递归调用传递时,读取器单子(Reader Monad)就会派上用场。 这种程序有一个例子是 tree,它递归地打印当前目录及其子目录中的文件,并用字符表示它们的树形结构。 本章中的 tree 版本名为 doug ,取自北美西海岸的道格拉斯冷杉,在显示目录结构时,它提供了 Unicode 框画字符或其 ASCII 对应字符选项。

例如,以下命令将在名为 doug-demo 的目录中创建一个目录结构和一些空文件:

$ cd doug-demo
$ mkdir -p a/b/c
$ mkdir -p a/d
$ mkdir -p a/e/f
$ touch a/b/hello
$ touch a/d/another-file
$ touch a/e/still-another-file-again

运行 doug 的结果如下:

$ doug
├── doug-demo/
│   ├── a/
│   │   ├── e/
│   │   │   ├── still-another-file-again
│   │   │   ├── f/
│   │   ├── d/
│   │   │   ├── another-file
│   │   ├── b/
│   │   │   ├── hello
│   │   │   ├── c/

实现

在内部,doug 在递归遍历目录结构时会向下传递一个配置值。 该配置包含两个字段: useASCII 决定是否使用 Unicode 框画字符或 ASCII 垂直线和破折号字符来表示结构,而 currentPrefix 字段包含了一个字符串,用于在每行输出前添加。

随着当前目录的深入,前缀字符串会不断积累目录中的指标。 配置是一个结构体:

structure Config where
  useASCII : Bool := false
  currentPrefix : String := ""

该结构体的两个字段都有默认定义。 默认的 Config 使用 Unicode 显示,不带前缀。

调用 doug 的用户需要提供命令行参数。 用法如下:

def usage : String :=
  "Usage: doug [--ascii]
Options:
\t--ascii\tUse ASCII characters to display the directory structure"

据此,可以通过查看命令行参数列表来构建配置:

def configFromArgs : List String → Option Config
  | [] => some {} -- both fields default
  | ["--ascii"] => some {useASCII := true}
  | _ => none

main 函数是一个名为 dirTree 的内部函数的包装,它根据一个配置来显示目录的内容。 在调用 dirTree 之前,main 需要处理命令行参数。 它还必须向操作系统返回适当的退出状态码:

def main (args : List String) : IO UInt32 := do
  match configFromArgs args with
  | some config =>
    dirTree config (← IO.currentDir)
    pure 0
  | none =>
    IO.eprintln s!"Didn't understand argument(s) {" ".separate args}\n"
    IO.eprintln usage
    pure 1

并非所有路径都应显示在目录树中。 特别是名为... 的文件,因为它们实际上是用于导航的特殊标记,而不是文件本身。 应该显示的文件有两种:普通文件和目录:

inductive Entry where
  | file : String → Entry
  | dir : String → Entry

为了确定是否要显示某个文件以及它是哪种条目,doug 依赖 toEntry 函数 :

def toEntry (path : System.FilePath) : IO (Option Entry) := do
  match path.components.getLast? with
  | none => pure (some (.dir ""))
  | some "." | some ".." => pure none
  | some name =>
    pure (some (if (← path.isDir) then .dir name else .file name))

System.FilePath.components 在目录分隔符处分割路径名,并将路径转换为路径组件的列表。 如果没有最后一个组件,那么该路径就是根目录。 如果最后一个组件是一个特殊的导航文件(...),则应排除该文件。 否则,目录和文件将被包装在相应的构造函数中。

Lean 的逻辑无法确定目录树是否有限。 事实上,有些系统允许构建循环目录结构。 因此,dirTree 函数必须被声明为 partial

partial def dirTree (cfg : Config) (path : System.FilePath) : IO Unit := do
  match ← toEntry path with
  | none => pure ()
  | some (.file name) => showFileName cfg name
  | some (.dir name) =>
    showDirName cfg name
    let contents ← path.readDir
    let newConfig := cfg.inDirectory
    doList contents.toList fun d =>
      dirTree newConfig d.path

toEntry 的调用是一个嵌套操作 —— 在箭头没有其他含义的位置,如 match,括号是可以省略的。 当文件名与树中的条目不对应时(例如,因为它是 ..),dirTree 什么也不做。 当文件名指向一个普通文件时,dirTree 会调用一个辅助函数,以当前配置来显示该文件。 当文件名指向一个目录时,将通过一个辅助函数来显示该目录,然后其内容将递归地显示在一个新的配置中,其中的前缀已被扩写,以说明它位于一个新的目录中。

文件和目录的名称通过 showFileNameshowDirName 函数来显示:

def showFileName (cfg : Config) (file : String) : IO Unit := do
  IO.println (cfg.fileName file)

def showDirName (cfg : Config) (dir : String) : IO Unit := do
  IO.println (cfg.dirName dir)

这两个辅助函数都委托给了将 ASCII 与 Unicode 设置考虑在内的 Config 上的函数:

def Config.preFile (cfg : Config) :=
  if cfg.useASCII then "|--" else "├──"

def Config.preDir (cfg : Config) :=
  if cfg.useASCII then "|  " else "│  "

def Config.fileName (cfg : Config) (file : String) : String :=
  s!"{cfg.currentPrefix}{cfg.preFile} {file}"

def Config.dirName (cfg : Config) (dir : String) : String :=
  s!"{cfg.currentPrefix}{cfg.preFile} {dir}/"

同样,Config.inDirectory 用目录标记扩写了前缀:

def Config.inDirectory (cfg : Config) : Config :=
  {cfg with currentPrefix := cfg.preDir ++ " " ++ cfg.currentPrefix}

doList 函数可以在目录内容的列表中迭代 IO 操作。 由于 doList 只执行列表中的所有操作,并不根据任何操作返回的值来决定控制流,因此不需要使用 Monad 的全部功能,它适用于任何 Applicative 应用程序:

def doList [Applicative f] : List α → (α → f Unit) → f Unit
  | [], _ => pure ()
  | x :: xs, action =>
    action x *>
    doList xs action

使用自定义单子

虽然这种 doug 实现可以正常工作,但手动传递配置不仅费事还容易出错。 例如,类型系统无法捕获向下传递的错误配置。 读取器作用不仅可以确保在所有递归调用中都传递相同的配置,而且有助于优化冗长的代码。

要创建一个同时也是 Config 读取器的 IO ,首先要按照求值器示例中的方法定义类型及其 Monad 实例:

def ConfigIO (α : Type) : Type :=
  Config → IO α

instance : Monad ConfigIO where
  pure x := fun _ => pure x
  bind result next := fun cfg => do
    let v ← result cfg
    next v cfg

这个 Monad 实例与 Reader 实例的区别在于,它使用 IO 单子中的 do 标记 作为 bind 返回函数的主体,而不是直接将 next 应用于 result 返回的值。 由 result 执行的任何 IO 作用都必须在调用 next 之前发生,这一点由 IO 单子的 bind 操作符来保证。 ConfigIO 不是宇宙多态的,因为底层的 IO 类型也不是宇宙多态的。

运行 ConfigIO 操作需要向其提供一个配置,从而将其转换为 IO 操作:

def ConfigIO.run (action : ConfigIO α) (cfg : Config) : IO α :=
  action cfg

这个函数其实并无必要,因为调用者只需直接提供配置即可。 不过,给操作命名可以让我们更容易看出代码的各部分会在哪个单子中运行。

下一步是定义访问当前配置的方法,作为 ConfigIO 的一部分:

def currentConfig : ConfigIO Config :=
  fun cfg => pure cfg

这与求值器示例中的 read 相同,只是它使用了 IOpure 来返回其值,而不是直接返回。 因为进入一个目录会修改递归调用范围内的当前配置,因此有必要提供一种修改配置的方法:

def locally (change : Config → Config) (action : ConfigIO α) : ConfigIO α :=
  fun cfg => action (change cfg)

doug 中的大部分代码都不需要配置,因此 doug 会从标准库中调用普通的 Lean IO 操作,这些操作当然也不需要 Config。 普通的 IO 操作可以使用 runIO 运行,它会忽略配置参数:

def runIO (action : IO α) : ConfigIO α :=
  fun _ => action

有了这些组件,showFileNameshowDirName 可以修改为使用 ConfigIO 单子来隐式获取配置参数。 它们使用 嵌套动作 来获取配置,并使用 runIO 来实际执行对 IO.println 的调用:

def showFileName (file : String) : ConfigIO Unit := do
  runIO (IO.println ((← currentConfig).fileName file))

def showDirName (dir : String) : ConfigIO Unit := do
  runIO (IO.println ((← currentConfig).dirName dir))

在新版的 dirTree 中,对 toEntrySystem.FilePath.readDir 的调用被封装在 runIO 中。 此外,它不再构建一个新的配置,然后要求程序员跟踪将哪个配置传递给递归调用,而是使用 locally 自然地将修改后的配置限定在程序的一小块区域内,在该区域内,它是 唯一 有效的配置:

partial def dirTree (path : System.FilePath) : ConfigIO Unit := do
  match ← runIO (toEntry path) with
    | none => pure ()
    | some (.file name) => showFileName name
    | some (.dir name) =>
      showDirName name
      let contents ← runIO path.readDir
      locally (·.inDirectory)
        (doList contents.toList fun d =>
          dirTree d.path)

新版本的 main 使用 ConfigIO.run 来调用带有初始配置的 dirTree

def main (args : List String) : IO UInt32 := do
    match configFromArgs args with
    | some config =>
      (dirTree (← IO.currentDir)).run config
      pure 0
    | none =>
      IO.eprintln s!"Didn't understand argument(s) {" ".separate args}\n"
      IO.eprintln usage
      pure 1

与手动传递配置相比,这种自定义单子有很多优点:

  1. 能更容易确保配置被原封不动地向下传递,除非需要更改
  2. 传递配置与打印目录内容之间的关系更加清晰
  3. 随着程序的增长,除了传播配置外,将有越来越多的中间层无需对配置进行处理,这些层并不需要随着配置逻辑的变化而重写。

不过,也有一些明显的缺点:

  1. 随着程序的发展和单子需要更多功能,比如 locallycurrentConfig 等基本算子都需要更新。
  2. 将普通的 IO 操作封装在 runIO 中会产生语法噪音,影响程序的流畅性
  3. 手写单子实例是重复性的工作,而且向另一个单子添加读取器作用的技术是一种依赖文档和交流开销的设计模式

使用一种名为 单子转换器 的技术,可以解决所有这些弊端。 单子转换器以一个单子作为参数,并返回一个新的单子。 单子转换器包括:

  1. 转换器本身的定义,通常是一个从类型到类型的函数
  2. 假定内部类型已经是一个单子的 Monad 实例
  3. 从内部单子“提升”一个操作到转换后的单元的操作符,类似于 runIO.

将读取器添加到任意单子

ConfigIO中,通过将 IO α 包装成一个函数类型,为 IO 添加了读取器作用。 Lean 的标准库有一个函数,可以对 任意 多态类型执行此操作,称为 ReaderT

def ReaderT (ρ : Type u) (m : Type u → Type v) (α : Type u) : Type (max u v) :=
  ρ → m α

它的参数如下:

  • ρ 是读取器可以访问的环境
  • m 是被转换的单子,例如 IO
  • α 是单子计算返回值的类型

αρ 都在同一个宇宙中,因为在单子中检索环境的算子将具有 m ρ 类型。

有了 “ReaderT”,“ConfigIO” 就变成了:

abbrev ConfigIO (α : Type) : Type := ReaderT Config IO α

它是一个 abbrev ,因为在标准库中定义了许多关于 ReaderT 的有用功能,而不可归约的定义会隐藏这些功能。 与其让 ConfigIO 直接使用这些功能,不如让 ConfigIO 的行为与 ReaderT Config IO 保持一致。

手动编写的 currentConfig 从读取器中获取了环境。 这种作用可以以通用形式定义,适用于 ReaderT 的所有用途,名为 read

def read [Monad m] : ReaderT ρ m ρ :=
   fun env => pure env

然而,并不是每个提供读取器作用的单子都是用 ReaderT 构建的。 类型类 MonadReader 允许任何单子提供 read 操作符:

class MonadReader (ρ : outParam (Type u)) (m : Type u → Type v) : Type (max (u + 1) v) where
  read : m ρ

instance [Monad m] : MonadReader ρ (ReaderT ρ m) where
  read := fun env => pure env

export MonadReader (read)

类型 ρ 是一个输出参数,因为任何给定的单子通常只通过读取器提供单一类型的环境,所以在已知单子时自动选择它可以使程序编写更方便。

ReaderTMonad 实例与 ConfigIOMonad 实例基本相同,只是 IO 被某个表示任意单子的参数 m 所取代:

instance [Monad m] : Monad (ReaderT ρ m) where
  pure x := fun _ => pure x
  bind result next := fun env => do
    let v ← result env
    next v env

下一步是消除对 runIO 的使用。 当 Lean 遇到单子类型不匹配时,它会自动尝试使用名为 MonadLift 的类型类,将实际的单子转换为预期单子。 这一过程与使用强制转换相似。 MonadLift 的定义如下:

class MonadLift (m : Type u → Type v) (n : Type u → Type w) where
  monadLift : {α : Type u} → m α → n α

方法 monadLift 可以将单子 m 转换为单子 n。 这个过程被称为“提升”,因为它将嵌入到单子中的动作转换成周围单子中的动作。 在本例中,它将用于把 IO “提升”到 ReaderT Config IO,尽管该实例适用于 任何 内部单子 m

instance : MonadLift m (ReaderT ρ m) where
  monadLift action := fun _ => action

monadLift 的实现与 runIO 非常相似。 事实上,只需定义 showFileNameshowDirName 即可,无需使用 runIO

def showFileName (file : String) : ConfigIO Unit := do
  IO.println s!"{(← read).currentPrefix} {file}"

def showDirName (dir : String) : ConfigIO Unit := do
  IO.println s!"{(← read).currentPrefix} {dir}/"

原版 ConfigIO 中的最后一个操作还需要翻译成 ReaderT 的形式:locally。 该定义可以直接翻译为 ReaderT,但 Lean 标准库提供了一个更通用的版本。 标准版本被称为 withReader,它是名为 MonadWithReader 的类型类的一部分:

class MonadWithReader (ρ : outParam (Type u)) (m : Type u → Type v) where
  withReader {α : Type u} : (ρ → ρ) → m α → m α

正如在 MonadReader 中一样,环境 ρ 是一个 outParamwithReader 操作是被导出的,所以在编写时不需要在前面加上类型类名:

export MonadWithReader (withReader)

ReaderT 的实例与 locally 的定义基本相同:

instance : MonadWithReader ρ (ReaderT ρ m) where
  withReader change action :=
    fun cfg => action (change cfg)

有了这些定义,我们便可以定义新版本的 dirTree:

partial def dirTree (path : System.FilePath) : ConfigIO Unit := do
  match ← toEntry path with
    | none => pure ()
    | some (.file name) => showFileName name
    | some (.dir name) =>
      showDirName name
      let contents ← path.readDir
      withReader (·.inDirectory)
        (doList contents.toList fun d =>
          dirTree d.path)

除了用 withReader 替换 locally 外,其他内容保持不变。

在本节中,用 ReaderT 代替自定义的 ConfigIO 类型并没有节省大量代码行数。 不过,使用标准库中的组件重写代码确实有长远的好处。 首先,了解 ReaderT 的读者不需要花时间去理解 ConfigIOMonad 实例,也不需要逆向理解单子本身的含义。 相反,他们可以沿用自己的初步理解。 接下来,给单子添加更多的作用(例如计算每个目录中的文件并在最后显示计数的状态作用)所需的代码改动要少得多,因为库中提供的单子转换器和 MonadLift 实例配合得很好。 最后,使用标准库中包含的一组类型类,多态代码的编写方式可以使其适用于各种单子,而无需关心单子转换器的应用顺序等细节。 正如某些函数可以在任何单子中工作一样,另一些函数也可以在任何提供特定类型状态或特定类型异常的单子中工作,而不必特别描述特定的具体单子提供状态或异常的 方式

练习

控制点文件的显示

文件名以点字符 ('.') 开头的文件通常代表隐藏文件,如源代码管理的元数据和配置文件。 修改 doug 并加入一个选项,以显示或隐藏以点开头的文件名。 应使用命令行选项 -a 来控制该选项。

起始目录作为参数

修改 doug ,使其可以将起始目录作为额外的命令行参数。

单子构建工具包

ReaderT 并不是唯一有用的单子转换器。 本节将介绍一些额外的转换器。 每个单子转换器都由以下部分组成:

  1. 一个以单子为参数定义或数据类型 T。 它的类型应类似于 (Type u → Type v) → Type u → Type v,尽管它可以接受单子之前的其他参数。
  2. T mMonad 实例依赖于 Monad m 实例。这使得转换后的单子也可以作为单子使用。
  3. 一个 MonadLift 实例,可将任意单子 mm α 类型的操作转换为 T m α 类型的操作。这使得底层单子中的操作可以在转换后的单子中使用。

此外,转换器的 Monad 实例也应该遵守 Monad 的约定,至少在底层的 Monad 实例遵守的情况下。 另外,monadLift (pure x) 应该等价于转换后的单子中的 pure x ,而且 monadLift 应对于 bind 可分配,这样 monadLift (x >>= f) 就等同于 monadLift x >>= fun y => monadLift (f y)

许多单子转换器还定义了 MonadReader 风格的类型类,用于描述单子中可用的实际作用。 这可以提供更大的灵活性:它允许编写只依赖接口的程序,而不限制底层单子必须由给定的转换器实现。 类型类是程序表达其需求的一种方式,而单子转换器则是满足这些需求的一种便捷方式。

使用 OptionT 失败

Option 单子表示的失败和由 Except 单子表示的异常都有相应的转换器。 对于 Option 单子,可以通过让单子包含 Option α 类型的值来为单子添加失败,否则单子将包含 α 类型的值。 例如,IO (Option α) 表示并不总是返回 α 类型值的 IO 操作。 这就需要定义单子转换器 OptionT

def OptionT (m : Type u → Type v) (α : Type u) : Type v :=
  m (Option α)

我们以一个向用户提问的程序为例来说明 OptionT 的作用。 函数 getSomeInput 要求输入一行内容,并删除两端的空白。 如果修剪后的输入是非空的,就会返回,但如果没有非空格字符,函数就会失败:

def getSomeInput : OptionT IO String := do
  let input ← (← IO.getStdin).getLine
  let trimmed := input.trim
  if trimmed == "" then
    failure
  else pure trimmed

这个应用软件可以追踪用户的姓名和他们最喜欢的甲虫种类:

structure UserInfo where
  name : String
  favoriteBeetle : String

询问用户输入并不比只使用 IO 的函数更冗长:

def getUserInfo : OptionT IO UserInfo := do
  IO.println "What is your name?"
  let name ← getSomeInput
  IO.println "What is your favorite species of beetle?"
  let beetle ← getSomeInput
  pure ⟨name, beetle⟩

然而,由于函数是在 OptionT IO 上下文中运行的,而不仅仅是在 IO 中,因此第一次调用 getSomeInput 失败会导致整个 getUserInfo 失败,控制权永远不会到达关于甲虫的问题。 主函数 interact 在纯的 IO 上下文中调用 getUserInfo,这样就可以通过匹配内部的 Option 来检查调用成功还是失败:

def interact : IO Unit := do
  match ← getUserInfo with
  | none => IO.eprintln "Missing info"
  | some ⟨name, beetle⟩ => IO.println s!"Hello {name}, whose favorite beetle is {beetle}."

单子实例

在编写单子实例发现了一个难题。 根据类型,pure 应该使用底层单子 m 中的 puresome。 正如 Optionbind 在第一个参数上分支,然后传播 noneOptionTbind 应该运行构成第一个参数的单子操作,在结果上分支,然后传播 none。 按照这个框架可以得到 Lean 不接受的如下定义:

instance [Monad m] : Monad (OptionT m) where
  pure x := pure (some x)
  bind action next := do
    match (← action) with
    | none => pure none
    | some v => next v

错误信息显示了一个隐含的类型不匹配:

application type mismatch
  pure (some x)
argument
  some x
has type
  Option α✝ : Type ?u.25
but is expected to have type
  α✝ : Type ?u.25

这里的问题是 Lean 为周围的 pure 使用选择了错误的 Monad 实例。 类似的错误也发生在 bind 的定义中。 一种解决方案是使用类型标注来引导 Lean 选择正确的 Monad 实例:

instance [Monad m] : Monad (OptionT m) where
  pure x := (pure (some x) : m (Option _))
  bind action next := (do
    match (← action) with
    | none => pure none
    | some v => next v : m (Option _))

虽然这种解决方案可行,但它不够优雅,代码也变得有点啰嗦。

另一种解决方案是定义函数,由函数的类型签名引导 Lean 找到正确的实例。 事实上,OptionT 自身可以定义为一个结构:

structure OptionT (m : Type u → Type v) (α : Type u) : Type v where
  run : m (Option α)

这可以解决这个问题,因为构造函数 OptionT.mk 和字段访问函数 OptionT.run 将引导类型类推理到正确的实例。 但这样做的缺点是,在运行使用结构体的代码时,结构体值需要反复分配和释放,而直接定义是编译期专用的功能。 我们可以通过定义与 OptionT.mkOptionT.run 具有相同作用的函数来实现两全其美的效果,但这些函数要与直接定义一起使用:

def OptionT.mk (x : m (Option α)) : OptionT m α := x

def OptionT.run (x : OptionT m α) : m (Option α) := x

这两个函数直接返回的其原输入,但它们指明了旨在呈现 OptionT 接口的代码与旨在呈现底层单子 m 接口的代码之间的边界。 使用这些辅助函数,Monad 实例变得更加可读:

instance [Monad m] : Monad (OptionT m) where
  pure x := OptionT.mk (pure (some x))
  bind action next := OptionT.mk do
    match ← action with
    | none => pure none
    | some v => next v

在这里,使用 OptionT.mk 表示其参数应被视为使用 m 接口的代码,它允许 Lean 选择正确的 Monad 实例。

定义完单子实例后,最好检查一下单子约定是否满足。 第一步是证明 bind (pure v) ff v 相同。 步骤如下:

bind (pure v) f
={ Unfolding the definitions of bind and pure }=
OptionT.mk do
  match ← pure (some v) with
  | none => pure none
  | some x => f x
={ Desugaring nested action syntax }=
OptionT.mk do
  let y ← pure (some v)
  match y with
  | none => pure none
  | some x => f x
={ Desugaring do-notation }=
OptionT.mk
  (pure (some v) >>= fun y =>
    match y with
    | none => pure none
    | some x => f x)
={ Using the first monad rule for m }=
OptionT.mk
  (match some v with
   | none => pure none
   | some x => f x)
={ Reduce match }=
OptionT.mk (f v)
={ Definition of OptionT.mk }=
f v

第二条规则指出,bind w purew 相同。 为了证明这一点,展开 bindpure 的定义,得出:

OptionT.mk do
    match ← w with
    | none => pure none
    | some v => pure (some v)

在这个模式匹配中,两种情况的结果都与被匹配的模式相同,只是在其周围加上了 pure。 换句话说,它等同于 w >>= fun y => pure y,这是 m 的第二个单子规则的一个实例。

最后一条规则指出 bind (bind v f) gbind v (fun x => bind (f x) g)相同。 通过扩展 bindpure 的定义,然后将其委托给底层单子 m,可以用同样的方法对其进行检查。

一个 Alternative 实例

一种使用 OptionT 的便捷方法是通过 Alternative 类型类。 成功返回已经由 pure 表示,而 AlternativefailureorElse 方法提供了一种编写程序的方式,可以从多个子程序中返回第一个成功的结果:

instance [Monad m] : Alternative (OptionT m) where
  failure := OptionT.mk (pure none)
  orElse x y := OptionT.mk do
    match ← x with
    | some result => pure (some result)
    | none => y ()

提升

将一个操作从 m 移植到 OptionT m 只需要用 some 包装计算结果:

instance [Monad m] : MonadLift m (OptionT m) where
  monadLift action := OptionT.mk do
    pure (some (← action))

异常

单子转换器版本的 Except 与单子转换器版本的 Option 非常相似。 向 m α 类型的单子动作添加 ε 类型的异常,可以通过向 α 添加异常来实现,从而产生 m (Except ε α)

def ExceptT (ε : Type u) (m : Type u → Type v) (α : Type u) : Type v :=
  m (Except ε α)

OptionT 提供了 mkrun 函数来引导类型检查器找到正确的 Monad 实例。 这个技巧对 ExceptT 也很有用:

def ExceptT.mk {ε α : Type u} (x : m (Except ε α)) : ExceptT ε m α := x

def ExceptT.run {ε α : Type u} (x : ExceptT ε m α) : m (Except ε α) := x

用于 ExceptTMonad 实例与用于 OptionTMonad 实例也非常相似。 唯一不同的是,它传播的是一个特定的错误值,而不是 none

instance {ε : Type u} {m : Type u → Type v} [Monad m] : Monad (ExceptT ε m) where
  pure x := ExceptT.mk (pure (Except.ok x))
  bind result next := ExceptT.mk do
    match ← result with
    | .error e => pure (.error e)
    | .ok x => next x

ExceptT.mkExceptT.run 的类型签名包含一个微妙的细节:它们明确地注释了 αε 的宇宙层级。 如果它们没有被明确注释,那么 Lean 会生成一个更通用的类型签名,其中它们拥有不同的多态宇宙变量。 然而, ExceptT 的定义希望它们在同一个宇宙中,因为它们都可以作为参数提供给 m。 这会导致 Monad 实例出现问题,即宇宙层级求解器无法找到有效的解决方案:

def ExceptT.mk (x : m (Except ε α)) : ExceptT ε m α := x

instance {ε : Type u} {m : Type u → Type v} [Monad m] : Monad (ExceptT ε m) where
  pure x := ExceptT.mk (pure (Except.ok x))
  bind result next := ExceptT.mk do
    match (← result) with
    | .error e => pure (.error e)
    | .ok x => next x
stuck at solving universe constraint
  max ?u.12144 ?u.12145 =?= u
while trying to unify
  ExceptT ε m α✝
with
  (ExceptT ε m α✝) ε m α✝

这种错误信息通常是由欠约束的宇宙变量引起的。 诊断起来可能很棘手,但第一步可以查找某些定义中重复使用的宇宙变量,而其他定义中没有重复使用的宇宙变量。

Option 不同,Except 数据类型通常不作为数据结构使用。 它总是作为控制结构与其 Monad 实例一起使用。 这意味着将 Except ε 操作提升到 ExceptT ε m 以及对底层单子 m 的操作都是合理的。 通过用 mpureExcept 操作进行包装,可以将其提升为 ExceptT 操作,因为一个只有异常作用的动作不可能有来自单子 m 的任何作用:

instance [Monad m] : MonadLift (Except ε) (ExceptT ε m) where
  monadLift action := ExceptT.mk (pure action)

由于 m 中的操作不包含任何异常,因此它们的值应该用 Except.ok 封装。 这可以利用 FunctorMonad 的超类这一事实来实现,因此可以使用 Functor.map,将函数应用于任何单子计算的结果:

instance [Monad m] : MonadLift m (ExceptT ε m) where
  monadLift action := ExceptT.mk (.ok <$> action)

异常的类型类

异常处理从根本上说包括两种操作:抛出异常的能力和恢复异常的能力。 到目前为止,我们分别使用 Except 的构造函数和模式匹配来实现这一点。 然而,这将使用异常的程序与异常处理作用的特定编码联系在一起。 使用类型类来捕获这些操作,可以让使用异常的程序在 任何 支持抛出和捕获的单子中使用。

抛出异常应该以异常作为参数,而且应该允许在任何要求执行单子动作的上下文中抛出异常。 规范中 "任何上下文" 的部分可以写成一种类型,即 m α ——— 因为没有办法产生任意类型的值,所以 throw 操作必须能使控制权离开程序的这一部分。 捕获异常应该接受任何单子操作和处理程序,处理程序应该解释如何从异常返回到操作的类型:

class MonadExcept (ε : outParam (Type u)) (m : Type v → Type w) where
  throw : ε → m α
  tryCatch : m α → (ε → m α) → m α

MonadExcept 的宇宙层级与 ExceptT 不同。 在 ExceptT 中,εα 具有相同的层级,而 MonadExcept 则没有这种限制。 这是因为 MonadExcept从不将异常值置于 m 内。 在这个定义中,最通用的宇宙签名承认 εα 是完全独立的。 更通用意味着类型类可以为更多类型实例化。

下面是一个简单的除法服务,作为使用 MonadExcept 的一个示例程序。 程序分为两部分:前端提供基于字符串的用户界面,用于处理错误;后端实际执行除法操作。 前后端都可以抛出异常,前者用于处理格式错误的输入,后者用于处理除数为零的错误。 定义异常为一种归纳类型:

inductive Err where
  | divByZero
  | notANumber : String → Err

后端检查是否为零,如果为零,则进行除法:

def divBackend [Monad m] [MonadExcept Err m] (n k : Int) : m Int :=
  if k == 0 then
    throw .divByZero
  else pure (n / k)

如果传入的字符串不是数字,前端的辅助函数 asNumber 会抛出异常。 整个前端会将输入转换为 Int 并调用后端,通过返回友好的错误字符串来处理异常:

def asNumber [Monad m] [MonadExcept Err m] (s : String) : m Int :=
  match s.toInt? with
  | none => throw (.notANumber s)
  | some i => pure i

def divFrontend [Monad m] [MonadExcept Err m] (n k : String) : m String :=
  tryCatch (do pure (toString (← divBackend (← asNumber n) (← asNumber k))))
    fun
      | .divByZero => pure "Division by zero!"
      | .notANumber s => pure s!"Not a number: \"{s}\""

抛出和捕获异常非常常见,因此 Lean 提供了使用 MonadExcept 的特殊语法。 正如 +HAdd.hAdd 的缩写,trycatch 可以作为 tryCatch 方法的缩写:

def divFrontend [Monad m] [MonadExcept Err m] (n k : String) : m String :=
  try
    pure (toString (← divBackend (← asNumber n) (← asNumber k)))
  catch
    | .divByZero => pure "Division by zero!"
    | .notANumber s => pure s!"Not a number: \"{s}\""

除了 ExceptExceptT 之外,还有一些有用的 MonadExcept 实例,用于处理其他类型的异常,这些异常乍看起来可能不像是异常。 例如,Option 导致的失败可以被看作是抛出了一个不包含任何数据的异常,因此有一个实例 MonadExcept Unit Option 允许将 try ... catch ... 语法与 Option 一起使用。

状态

通过让单子动作接受一个起始状态作为参数,并返回一个最终状态及其结果,就可以在单子中加入对可变状态的模拟。 状态单子的绑定操作符将一个动作的最终状态作为下一个动作的参数,从而将状态贯穿整个程序。 这种模式也可以用单子转换器来表示:

def StateT (σ : Type u) (m : Type u → Type v) (α : Type u) : Type (max u v) :=
  σ → m (α × σ)

同样,该单子实例与 State 非常相似。 唯一不同的是,输入和输出状态是在底层单子中传递和返回的,而不是纯代码:

instance [Monad m] : Monad (StateT σ m) where
  pure x := fun s => pure (x, s)
  bind result next := fun s => do
    let (v, s') ← result s
    next v s'

相应的类型类有 getset 方法。 getset 的一个缺点是,在更新状态时很容易 set 错误的状态。 这是因为检索状态、更新状态并保存更新后的状态是编写某些程序的一种很自然的方式。 例如,下面的程序会计算一串字母中不含音素的英语元音和辅音的数量:

structure LetterCounts where
  vowels : Nat
  consonants : Nat
deriving Repr

inductive Err where
  | notALetter : Char → Err
deriving Repr

def vowels :=
  let lowerVowels := "aeiuoy"
  lowerVowels ++ lowerVowels.map (·.toUpper)

def consonants :=
  let lowerConsonants := "bcdfghjklmnpqrstvwxz"
  lowerConsonants ++ lowerConsonants.map (·.toUpper )

def countLetters (str : String) : StateT LetterCounts (Except Err) Unit :=
  let rec loop (chars : List Char) := do
    match chars with
    | [] => pure ()
    | c :: cs =>
      let st ← get
      let st' ←
        if c.isAlpha then
          if vowels.contains c then
            pure {st with vowels := st.vowels + 1}
          else if consonants.contains c then
            pure {st with consonants := st.consonants + 1}
          else -- modified or non-English letter
            pure st
        else throw (.notALetter c)
      set st'
      loop cs
  loop str.toList

非常容易将 set st 误写成 set st' 。 在大型程序中,这种错误会导致难以诊断的 bug。

虽然使用嵌套操作来调用 get 可以解决这个问题,但它不能解决所有此类问题。 例如,一个函数可能会根据另外两个字段的值来更新结构体上的一个字段。 这就需要对 get 进行两次单独的嵌套操作调用。 由于 Lean 编译器包含的优化功能只有在对值进行单个引用时才有效,因此重复引用状态可能会导致代码速度大大降低。 使用 modify(即使用函数转换状态)可以解决潜在的性能问题和 bug:

def countLetters (str : String) : StateT LetterCounts (Except Err) Unit :=
  let rec loop (chars : List Char) := do
    match chars with
    | [] => pure ()
    | c :: cs =>
      if c.isAlpha then
        if vowels.contains c then
          modify fun st => {st with vowels := st.vowels + 1}
        else if consonants.contains c then
          modify fun st => {st with consonants := st.consonants + 1}
        else -- modified or non-English letter
          pure ()
      else throw (.notALetter c)
      loop cs
  loop str.toList

类型类包含一个类似于 modify 的函数,称为 modifyGet,它允许函数在一个步骤中同时计算返回值和转换旧状态。 该函数返回一个二元组,其中第一个元素是返回值,第二个元素是新状态;modify 只是将 Unit 的构造函数添加到 modifyGet 中使用的二元组中:

def modify [MonadState σ m] (f : σ → σ) : m Unit :=
  modifyGet fun s => ((), f s)

MonadState 的定义如下:

class MonadState (σ : outParam (Type u)) (m : Type u → Type v) : Type (max (u+1) v) where
  get : m σ
  set : σ → m PUnit
  modifyGet : (σ → α × σ) → m α

PUnitUnit 类型的一个版本,它具有宇宙多态性,允许以 Type u 代替 Type。 虽然可以用 getset 来提供 modifyGet 的默认实现,但这样就无法进行使 modifyGet 有用的优化,从而使该方法变得无用。

Of 类和 The 函数

到目前为止,每个需要额外信息的单子类型类,如 MonadExcept 的异常类型或 MonadState 的状态类型,都有这类额外信息作为输出参数。 对于简单的程序来说,这通常很方便,因为结合使用了 StateTReaderTExceptT 的单子只有单一的状态类型、环境类型和异常类型。 然而,随着单子的复杂性增加,它们可能会涉及多个状态或错误类型。 在这种情况下,输出参数的使用使得无法在同一个 do 块中同时针对两种状态。

应对这些情况,还有一些额外的类型类,其中的额外信息不是输出参数。 这些版本的类型类在名称中使用了 Of 字样。 例如,MonadStateOfMonadState 类似,但没有 outParam 修饰符。

同样,也有一些版本的类型类方法接受额外信息的类型作为 显式 参数,而不是隐式参数。 对于 MonadStateOf,有 getThe,类型为

(σ : Type u) → {m : Type u → Type v} → [MonadStateOf σ m] → m σ

以及 modifyThe,类型为

(σ : Type u) → {m : Type u → Type v} → [MonadStateOf σ m] → (σ → σ) → m PUnit

没有 setThe 函数,因为新状态的类型足以决定使用哪个状态单子转换器。

在 Lean 标准库中,有非 Of 版本的类型类实例是根据带 Of 版本的类型类实例定义的。 换句话说,实现 Of 版本可以同时实现这两个版本。 一般来说,实现 Of 版本是个好主意,然后开始使用类的非 Of 版本编写程序,如果输出参数变得不方便,就过渡到 Of 版本。

转换器和 Id

恒等单子 Id 是没有任何作用的单子,可用于上下文因某种原因需要单子,但实际上不需要的情况。 Id 的另一个用途是作为单子转换器栈的底层。 例如,StateT σ Id 的作用与 State σ 相同。

练习

单子约定

用纸笔检查本节中每个单子转换器是否符合单子转换器的规则。

日志转换器

定义 WithLog 的单子转换器版本。 同时定义相应的类型类 MonadWithLog,并编写一个结合日志和异常的程序。

文件计数

StateT 来修改 doug 的单子,使它能统计所看到的目录和文件的数量。 在执行结束时,它应该显示如下报告:

  Viewed 38 files in 5 directories.

对单子转换器排序

在使用单子转换器栈组成单子时,必须注意单子转换器的分层顺序。 同一组转换器的不同排列顺序会产生不同的单子。

这个版本的 countLetters 和之前的版本一样,只是它使用类型类来描述可用的作用集,而不是提供一个具体的单子:

def countLetters [Monad m] [MonadState LetterCounts m] [MonadExcept Err m] (str : String) : m Unit :=
  let rec loop (chars : List Char) := do
    match chars with
    | [] => pure ()
    | c :: cs =>
      if c.isAlpha then
        if vowels.contains c then
          modify fun st => {st with vowels := st.vowels + 1}
        else if consonants.contains c then
          modify fun st => {st with consonants := st.consonants + 1}
        else -- modified or non-English letter
          pure ()
      else throw (.notALetter c)
      loop cs
  loop str.toList

状态和异常单子转换器可以两种不同的顺序组合,每种组合都会产生一个同时拥有这两种类型实例的单子:

abbrev M1 := StateT LetterCounts (ExceptT Err Id)
abbrev M2 := ExceptT Err (StateT LetterCounts Id)

当程序运行接受没有抛出异常的输入时,这两个单子都会产生类似的结果:

#eval countLetters (m := M1) "hello" ⟨0, 0⟩
Except.ok ((), { vowels := 2, consonants := 3 })
#eval countLetters (m := M2) "hello" ⟨0, 0⟩
(Except.ok (), { vowels := 2, consonants := 3 })

然而,这些返回值之间有一个微妙的区别。 对于 M1,最外层的构造函数是 Except.ok,它包含单元构造函数与最终状态的元组。 对于 M2,最外层的构造函数是一个元组,其中包含只应用于单元构造函数的 Except.ok。 最终状态在 Except.ok 之外。 在这两种情况下,程序都会返回元音和辅音的数目。

另一方面,当字符串导致抛出异常时,只有一个单子会产生元音和辅音的计数。 使用 M1,只会返回一个异常值:

#eval countLetters (m := M1) "hello!" ⟨0, 0⟩
Except.error (StEx.Err.notALetter '!')

使用 M2 时,异常值与抛出异常时的状态配对:

#eval countLetters (m := M2) "hello!" ⟨0, 0⟩
(Except.error (StEx.Err.notALetter '!'), { vowels := 2, consonants := 3 })

我们可能会认为M2M1更优越,因为它提供了更多在调试时可能有用的信息。 同样的程序在M1中计算出的答案可能与在M2中计算出的答案 不同 ,没有原则性的理由说其中一个答案一定比另一个好。 在程序中增加一个处理异常的步骤,就可以看到这一点:

def countWithFallback
    [Monad m] [MonadState LetterCounts m] [MonadExcept Err m]
    (str : String) : m Unit :=
  try
    countLetters str
  catch _ =>
    countLetters "Fallback"

该程序总会成功,但成功的结果可能不同。 如果没有抛出异常,则结果与 countLetters 相同:

#eval countWithFallback (m := M1) "hello" ⟨0, 0⟩
Except.ok ((), { vowels := 2, consonants := 3 })
#eval countWithFallback (m := M2) "hello" ⟨0, 0⟩
(Except.ok (), { vowels := 2, consonants := 3 })

但是,如果异常被抛出并捕获,那么最终状态就会截然不同。 对于 M1,最终状态只包含来自 "Fallback"的字母计数:

#eval countWithFallback (m := M1) "hello!" ⟨0, 0⟩
Except.ok ((), { vowels := 2, consonants := 6 })

对于 M2,最终状态包含来自 "hello""Fallback" 二者的字母计数,这与是命令式语言的结果相似:

#eval countWithFallback (m := M2) "hello!" ⟨0, 0⟩
(Except.ok (), { vowels := 4, consonants := 9 })

M1中,抛出异常会将状态 “回滚” 到捕获异常的位置。 而在 M2中,对状态的修改会在抛出和捕获异常时持续存在。 通过展开 M1M2 的定义,我们可以看到这种区别。 M1 α 展开为 LetterCounts → Except Err (α × LetterCounts) ,而 M2 α 展开为 LetterCounts → Except Err α × LetterCounts 。 也就是说,M1 α 描述的函数获取初始字母计数,返回错误或与更新计数配对的 α。 当 M1 中抛出异常时,没有最终状态。 M2 α 描述的是获取初始字母计数并返回新字母计数的函数,同时返回错误或 α。 当M2中抛出异常时,会伴随一个状态。

交换单子

在函数式编程的行话中,如果两个单子转换器可以重新排序而不改变程序的意义,那么这两个单子转换器就被称为 可交换。 当 StateTExceptT 被重新排序时,程序的结果可能会不同,这意味着状态和异常并不可交换。 一般来说,单子转换器不应该可交换。

尽管并非所有的单子转换器都可交换,但有些单子转换器还是可交换的的。 例如,StateT 的两种用法可以重新排序。 对 StateT σ (StateT σ' Id) α 中的定义进行扩展,可以得到 σ → σ' → ((α × σ) × σ') 类型。 而 StateT σ' (StateT σ Id) α 则生成 σ' → σ → ((α × σ') × σ) 类型。 换句话说,它们的区别在于将 σσ' 类型嵌套到了返回类型的不同位置,而且接受参数的顺序也不同。 客户端代码仍需提供相同的输入,并接收相同的输出。

大多数既有可变状态又有异常的编程语言的工作方式类似于 M2。 在这些语言中,当异常抛出时应该回滚的状态是很难表达的,通常需要用像 M1 中那样传递显式状态值的方式来模拟。 单子转换器允许自由选择适合当前问题的作用序的解释,两种选择都同样易于编程。 然而,在选择转换器的序时也需要小心谨慎。 有强大的表达能力,我们也有责任检查所表达的是否是我们想要的,而 countWithFallback 的类型签名可能比它应有的多态性更强。

练习

  • 通过扩展 ReaderTStateT 的定义并推理得到的类型,检查 ReaderTStateT 是否可交换。
  • ReaderTExceptT 是否可交换?通过扩展它们的定义和推理得到的类型来检验你的答案。
  • 根据 Many 的定义,用一个合适的 Alternative 实例构造一个单子转换器 ManyT。检查它是否满足 Monad 约定。
  • ManyT 是否与 StateT 交换?如果是,通过扩展定义和推理得到的类型来检查答案。如果不是,请写一个 ManyT (StateT σ Id) 的程序和一个 StateT σ (ManyT Id) 的程序。每个程序都应该是比给定的单子转换器排序更合理的程序。

更多 do 的特性

Lean 的 do-标记为使用单子编写程序提供了一种类似命令式编程语言的语法。 除了为使用单子的程序提供方便的语法外,do-标记还提供了使用某些单子转换器的语法。

单分支 if

在单子中工作时,一种常见的模式是只有当某些条件为真时才执行副作用。 例如,countLetters 包含对元音或辅音的检查,而两者都不是的字母对状态没有影响。 通过将 else 分支设置为 pure (),可以达成这一目的,因为 pure () 不会产生任何影响:

def countLetters (str : String) : StateT LetterCounts (Except Err) Unit :=
  let rec loop (chars : List Char) := do
    match chars with
    | [] => pure ()
    | c :: cs =>
      if c.isAlpha then
        if vowels.contains c then
          modify fun st => {st with vowels := st.vowels + 1}
        else if consonants.contains c then
          modify fun st => {st with consonants := st.consonants + 1}
        else -- modified or non-English letter
          pure ()
      else throw (.notALetter c)
      loop cs
  loop str.toList

如果 if 是一个 do 块中的语句,而不是一个表达式,那么 else pure () 可以直接省略,Lean 会自动插入它。 下面的 countLetters 定义完全等价:

def countLetters (str : String) : StateT LetterCounts (Except Err) Unit :=
  let rec loop (chars : List Char) := do
    match chars with
    | [] => pure ()
    | c :: cs =>
      if c.isAlpha then
        if vowels.contains c then
          modify fun st => {st with vowels := st.vowels + 1}
        else if consonants.contains c then
          modify fun st => {st with consonants := st.consonants + 1}
      else throw (.notALetter c)
      loop cs
  loop str.toList

使用状态单子计算列表中满足某种单子检查的条目的程序,可以写成下面这样:

def count [Monad m] [MonadState Nat m] (p : α → m Bool) : List α → m Unit
  | [] => pure ()
  | x :: xs => do
    if ← p x then
      modify (· + 1)
    count p xs

同样,if not E1 then STMT... 可以写成 unless E1 do STMT...count 的相反(计算不满足单子检查的条目),的可以用 unless 代替 if

def countNot [Monad m] [MonadState Nat m] (p : α → m Bool) : List α → m Unit
  | [] => pure ()
  | x :: xs => do
    unless ← p x do
      modify (· + 1)
    countNot p xs

理解单分支的 ifunless 不需要考虑单子转换器。 它们只需用 pure () 替换缺失的分支。 然而,本节中的其余扩展要求 Lean 自动重写 do 块,以便在写入 do 块的单子上添加一个局部转换器。

提前返回

标准库中有一个函数 List.find?,用于返回列表中满足某些检查条件的第一个条目。 一个简单的实现并没有利用 Option 是一个单子的事实,而是使用一个递归函数在列表中循环,并使用 if 在找到所需条目时停止循环:

def List.find? (p : α → Bool) : List α → Option α
  | [] => none
  | x :: xs =>
    if p x then
      some x
    else
      find? p xs

命令式语言通常会使用 return 关键字来终止函数的执行,并立即将某个值返回给调用者。 在 Lean 中,这个关键字在 do-标记中可用,return 停止了一个 do 块的执行,且 return 的参数是从单子返回的值。 换句话说,List.find? 可以这样写:

def List.find? (p : α → Bool) : List α → Option α
  | [] => failure
  | x :: xs => do
    if p x then return x
    find? p xs

在命令式语言中,提前返回有点像异常,只能导致当前堆栈帧被释放。 提前返回和异常都会终止代码块的执行,从而有效地用抛出的值替换周围的代码。 在后台,Lean 中的提前返回是使用 ExceptT 的一个版本实现的。 每个使用提前返回的 do 代码块都被包裹在异常处理程序中(在函数 tryCatch 的意义上)。 提前返回被转换为将值作为异常抛出,处理程序捕获抛出的值并立即返回。 换句话说,do 块的原始返回值类型也被用作异常类型。

更具体地说,当异常类型和返回类型相同时,辅助函数 runCatch 会从单子转换器栈的顶部删除一层 ExceptT

def runCatch [Monad m] (action : ExceptT α m α) : m α := do
  match ← action with
  | Except.ok x => pure x
  | Except.error x => pure x

List.find? 中使用提前返回的 do 块封装为使用 runCatchdo 块,并用 throw 代替提前返回,从而将其转换为不使用提前返回的 do 块:

def List.find? (p : α → Bool) : List α → Option α
  | [] => failure
  | x :: xs =>
    runCatch do
      if p x then throw x else pure ()
      monadLift (find? p xs)

提前返回有用的另一种情况是,如果参数或输入不正确,命令行应用程序会提前终止。 许多程序在进入主体部分之前,都会有一个验证参数和输入的部分。 以下版本的 问候程序 hello-name 会检查是否没有提供命令行参数:

def main (argv : List String) : IO UInt32 := do
  let stdin ← IO.getStdin
  let stdout ← IO.getStdout
  let stderr ← IO.getStderr

  unless argv == [] do
    stderr.putStrLn s!"Expected no arguments, but got {argv.length}"
    return 1

  stdout.putStrLn "How would you like to be addressed?"
  stdout.flush

  let name := (← stdin.getLine).trim
  if name == "" then
    stderr.putStrLn s!"No name provided"
    return 1

  stdout.putStrLn s!"Hello, {name}!"

  return 0

在不带参数的情况下运行该程序并输入姓名 David,得到的结果与前一版本相同:

$ lean --run EarlyReturn.lean
How would you like to be addressed?
David
Hello, David!

将名称作为命令行参数而不是答案提供会导致错误:

$ lean --run EarlyReturn.lean David
Expected no arguments, but got 1

不提供名字也会导致其他错误:

$ lean --run EarlyReturn.lean
How would you like to be addressed?

No name provided

使用提前返回的程序可以避免像下面这个不使用提前返回的版本一样嵌套控制流:

def main (argv : List String) : IO UInt32 := do
  let stdin ← IO.getStdin
  let stdout ← IO.getStdout
  let stderr ← IO.getStderr

  if argv != [] then
    stderr.putStrLn s!"Expected no arguments, but got {argv.length}"
    pure 1
  else
    stdout.putStrLn "How would you like to be addressed?"
    stdout.flush

    let name := (← stdin.getLine).trim
    if name == "" then
      stderr.putStrLn s!"No name provided"
      pure 1
    else
      stdout.putStrLn s!"Hello, {name}!"
      pure 0

Lean 中的提前返回与命令式语言中的提前返回之间的一个重要区别是,Lean 的提前返回仅适用于当前的 do 块。 当函数的整个定义都在同一个 do 块中时,这个区别并不重要。 但如果 do 出现在其他结构之下,那么这种差异就会变得很明显。 例如,下面这个 greet 的定义:

def greet (name : String) : String :=
  "Hello, " ++ Id.run do return name

表达式 greet "David" 被求值为 "Hello, David" ,而不只是 "David"

循环

正如每个具有可变状态的程序都可以改写成将状态作为参数传递的程序一样,每个循环都可以改写成递归函数。 从某个角度看,List.find? 作为递归函数是最清晰不过的了。 毕竟,它的定义反映了列表的结构:如果头部通过了检查,那么就应该返回;否则就在尾部查找。 当没有条目时,答案就是 none。 从另一个角度看,List.find? 作为一个循环最为清晰。 毕竟,程序会按顺序查询条目,直到找到合适的条目,然后终止。 如果循环没有返回就终止了,那么答案就是 none

使用 ForM 循环

Lean 包含一个类型类,用于描述在某个单子中对容器类型的循环。 这个类型类叫做 ForM

class ForM (m : Type u → Type v) (γ : Type w₁) (α : outParam (Type w₂)) where
  forM [Monad m] : γ → (α → m PUnit) → m PUnit

该类型类非常通用。 参数 m 是一个具有某些预期作用的单子, γ 是要循环的集合,α 是集合中元素的类型。 通常情况下,m 可以是任何单子,但也可以是只支持在 IO 中循环的数据结构。 方法 forM 接收一个集合、一个要对集合中每个元素产生影响的单子操作,然后负责运行这些动作。

List 的实例允许 m 是任何单子,它将 γ 设置为 List α,并将类型类的 α 设置为列表中的 α

def List.forM [Monad m] : List α → (α → m PUnit) → m PUnit
  | [], _ => pure ()
  | x :: xs, action => do
    action x
    forM xs action

instance : ForM m (List α) α where
  forM := List.forM

来自 doug 的函数 doList 是针对列表的 forM。 由于 forM 的目的是在 do 块中使用,它使用了 Monad 而不是 Applicative。 使用 forM 可以使 countLetters 更短:

def countLetters (str : String) : StateT LetterCounts (Except Err) Unit :=
  forM str.toList fun c => do
    if c.isAlpha then
      if vowels.contains c then
        modify fun st => {st with vowels := st.vowels + 1}
      else if consonants.contains c then
        modify fun st => {st with consonants := st.consonants + 1}
    else throw (.notALetter c)

Many 的实例也差不多:

def Many.forM [Monad m] : Many α → (α → m PUnit) → m PUnit
  | Many.none, _ => pure ()
  | Many.more first rest, action => do
    action first
    forM (rest ()) action

instance : ForM m (Many α) α where
  forM := Many.forM

因为 γ 可以是任何类型,所以 ForM 可以支持非多态集合。 一个非常简单的集合是按相反顺序排列的小于某个给定数的自然数:

structure AllLessThan where
  num : Nat

它的 forM 操作符将给定的操作应用于每个更小的 Nat

def AllLessThan.forM [Monad m] (coll : AllLessThan) (action : Nat → m Unit) : m Unit :=
  let rec countdown : Nat → m Unit
    | 0 => pure ()
    | n + 1 => do
      action n
      countdown n
  countdown coll.num

instance : ForM m AllLessThan Nat where
  forM := AllLessThan.forM

在每个小于 5 的数字上运行 IO.println 可以用 forM 来实现:

#eval forM { num := 5 : AllLessThan } IO.println
4
3
2
1
0

一个仅在特定单子中工作的 ForM 实例示例是,循环读取从 IO 流(如标准输入)获取的行:

structure LinesOf where
  stream : IO.FS.Stream

partial def LinesOf.forM (readFrom : LinesOf) (action : String → IO Unit) : IO Unit := do
  let line ← readFrom.stream.getLine
  if line == "" then return ()
  action line
  forM readFrom action

instance : ForM IO LinesOf String where
  forM := LinesOf.forM

forM 的定义被标记为 partial ,因为无法保证流是有限的。 在这种情况下,IO.FS.Stream.getLine 只在 IO 单子中起作用,因此不能使用其他单子进行循环。

本示例程序使用这种循环结构过滤掉不包含字母的行:

def main (argv : List String) : IO UInt32 := do
  if argv != [] then
    IO.eprintln "Unexpected arguments"
    return 1

  forM (LinesOf.mk (← IO.getStdin)) fun line => do
    if line.any (·.isAlpha) then
      IO.print line

  return 0

test-data 文件包含:

Hello!
!!!!!
12345
abc123

Ok

调用保存在 ForMIO.lean 的这个程序,产生如下输出:

$ lean --run ForMIO.lean < test-data
Hello!
abc123
Ok

中止循环

使用 forM 时很难提前终止循环。 要编写一个在 AllLessThan 中遍历 Nat 直到 3 的函数,就需要一种中途停止循环的方法。 实现这一点的方法之一是使用 forMOptionT 单子转换器。 第一步是定义 OptionT.exec,它会丢弃有关返回值和转换计算是否成功的信息:

def OptionT.exec [Applicative m] (action : OptionT m α) : m Unit :=
  action *> pure ()

然后,AlternativeOptionT 实例中的失败可以用来提前终止循环:

def countToThree (n : Nat) : IO Unit :=
  let nums : AllLessThan := ⟨n⟩
  OptionT.exec (forM nums fun i => do
    if i < 3 then failure else IO.println i)

快速测试表明,这一解决方案是可行的:

#eval countToThree 7
6
5
4
3

然而,这段代码并不容易阅读。 提前终止循环是一项常见的任务,Lean 提供了更多语法糖来简化这项任务。 同样的函数也可以写成下面这样:

def countToThree (n : Nat) : IO Unit := do
  let nums : AllLessThan := ⟨n⟩
  for i in nums do
    if i < 3 then break
    IO.println i

测试后发现,它用起来与之前的版本一样:

#eval countToThree 7
6
5
4
3

在撰写本文时,for ... in ... do ... 语法会解糖为使用一个名为 ForIn 的类型类,它是 ForM 的一个更为复杂的版本,可以跟踪状态和提前终止。 不过,我们计划重构 for 循环,使用更简单的 ForM,并在必要时插入单子转换器。 与此同时,我们还提供了一个适配器,可将 ForM 实例转换为 ForIn 实例,称为 ForM.forIn。 要启用基于 ForM 实例的 for 循环,请添加类似下面的内容,并适当替换 AllLessThanNat

instance : ForIn m AllLessThan Nat where
  forIn := ForM.forIn

但请注意,这个适配器只适用于保持无约束单子的 ForM 实例,大多数实例都是如此。 这是因为适配器使用的是 StateTExceptT 而不是底层单子。

for 循环支持提前返回。 将提前返回的 do 块转换为异常单子转换器的使用,与之前使用 OptionT 来停止迭代一样,同样适用于 forM 循环。 这个版本的 List.find? 同时使用了这两种方法:

def List.find? (p : α → Bool) (xs : List α) : Option α := do
  for x in xs do
    if p x then return x
  failure

除了 break 以外,for 循环还支持 continue 以在迭代中跳过循环体的其余部分。 List.find? 的另一种表述方式(但容易引起混淆)是跳过不满足检查条件的元素:

def List.find? (p : α → Bool) (xs : List α) : Option α := do
  for x in xs do
    if not (p x) then continue
    return x
  failure

Range 是一个由起始数、终止数和步长组成的结构。 它们代表一个自然数序列,从起始数到终止数,每次增加一个步长。 Lean 有特殊的语法来构造范围,由方括号、数字和冒号组成,有四种类型。 必须始终提供终止数,而起始数和步长是可选的,默认值分别为 01

表达式起始数终止数步长转化为列表
[:10]0101[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
[2:10]2101[2, 3, 4, 5, 6, 7, 8, 9]
[:10:3]0103[0, 3, 6, 9]
[2:10:3]2103[2, 5, 8]

请注意,起始数 包含 在范围内,而终止数不包含在范围内。 所有三个参数都是 Nat,这意味着范围不能向下计数 —— 当起始数大于或等于终止数时,范围中就不包含任何数字。

范围可与 for 循环一起使用,从范围中抽取数字。 该程序将偶数从 4 数到 8:

def fourToEight : IO Unit := do
  for i in [4:9:2] do
    IO.println i

运行它会输出:

4
6
8

最后,for 循环支持并行迭代多个集合,方法是用逗号分隔 in 子句。 当第一个集合中的元素用完时,循环就会停止,因此定义:

def parallelLoop := do
  for x in ["currant", "gooseberry", "rowan"], y in [4:8] do
    IO.println (x, y)

产生如下输出:

#eval parallelLoop
(currant, 4)
(gooseberry, 5)
(rowan, 6)

可变变量

除了提前 return、无 ifelsefor 循环之外,Lean 还支持在 do 代码块中使用局部可变变量。 在后台,这些可变变量是通过使用 StateT 来实现的,而不是通过真正的可变变量来实现。 函数式编程再次被用来模拟命令式编程。

使用 let mut 而不是普通的 let 来引入局部可变变量。 定义 two 使用恒等单子 Id 来启用 do 语法,但不引入任何副作用,计数到 2

def two : Nat := Id.run do
  let mut x := 0
  x := x + 1
  x := x + 1
  return x

这段代码等同于使用 StateT 添加两次 1 的定义:

def two : Nat :=
  let block : StateT Nat Id Nat := do
    modify (· + 1)
    modify (· + 1)
    return (← get)
  let (result, _finalState) := block 0
  result

局部可变变量与 do-标记的所有其他特性配合得很好,这些特性为单子转换器提供了方便的语法。 定义 three 计算一个三条目列表中的条目数:

def three : Nat := Id.run do
  let mut x := 0
  for _ in [1, 2, 3] do
    x := x + 1
  return x

同样,six 将条目添加到一个列表中:

def six : Nat := Id.run do
  let mut x := 0
  for y in [1, 2, 3] do
    x := x + y
  return x

List.count 计算列表中满足某些检查条件的条目的数量:

def List.count (p : α → Bool) (xs : List α) : Nat := Id.run do
  let mut found := 0
  for x in xs do
    if p x then found := found + 1
  return found

局部可变变量比局部显式使用 StateT 更方便,也更易于阅读。 然而,它们并不具备命令式语言中无限制的可变变量的全部功能。 特别是,它们只能在引入它们的 do 块中被修改。 例如,这意味着 for 循环不能被其他等价的递归辅助函数所替代。 该版本的 List.count

def List.count (p : α → Bool) (xs : List α) : Nat := Id.run do
  let mut found := 0
  let rec go : List α → Id Unit
    | [] => pure ()
    | y :: ys => do
      if p y then found := found + 1
      go ys
  return found

在尝试修改 found 时产生以下错误:

`found` cannot be mutated, only variables declared using `let mut` can be mutated. If you did not intent to mutate but define `found`, consider using `let found` instead

这是因为递归函数是用恒等单子编写的,只有引入变量的 do 块的单子才会被 StateT 转换。

什么算作 do 区块?

do-标记的许多特性只适用于单个 do 块。 提前返回会终止当前代码块,可变变量只能在其定义的代码块中被改变。 要有效地使用它们,了解什么是 “同一代码块” 尤为重要。

一般来说,do 关键字后的缩进块算作一个块,其下的语句序列是该块的一部分。 独立代码块中的语句如果包含在另一个代码块中,则不被视为该独立代码块的一部分。 不过,关于哪些语句属于同一代码块的规则略有微妙,因此需要举例说明。 可以通过设置一个带有可变变量的程序来测试规则的精确性,并查看允许修改的地方。 这个程序中的允许可变的区域显然与可变变量位于同一快中:

example : Id Unit := do
  let mut x := 0
  x := x + 1

如果变化发生在使用 := 定义名称的 let 语句的一部分的 do 块中,则它不被视为该块的一部分:

example : Id Unit := do
  let mut x := 0
  let other := do
    x := x + 1
  other
`x` cannot be mutated, only variables declared using `let mut` can be mutated. If you did not intent to mutate but define `x`, consider using `let x` instead

但是,在 let 语句下,使用 定义名称的 do 块被视为周围块的一部分。 以下程序是可以接受的:

example : Id Unit := do
  let mut x := 0
  let other ← do
    x := x + 1
  pure other

同样,作为函数参数出现的 do 块与周围的块无关。 以下程序并不合理:

example : Id Unit := do
  let mut x := 0
  let addFour (y : Id Nat) := Id.run y + 4
  addFour do
    x := 5
`x` cannot be mutated, only variables declared using `let mut` can be mutated. If you did not intent to mutate but define `x`, consider using `let x` instead

如果 do 关键字完全是多余的,那么它就不会引入一个新的程序块。 这个程序可以接受,等同于本节的第一个程序:

example : Id Unit := do
  let mut x := 0
  do x := x + 1

无论是否添加了多余的 dodo 下的分支内容(例如由 matchif 引入的分支)都被视为周围程序块的一部分。 以下程序均可接受:

example : Id Unit := do
  let mut x := 0
  if x > 2 then
    x := x + 1

example : Id Unit := do
  let mut x := 0
  if x > 2 then do
    x := x + 1

example : Id Unit := do
  let mut x := 0
  match true with
  | true => x := x + 1
  | false => x := 17

example : Id Unit := do
  let mut x := 0
  match true with
  | true => do
    x := x + 1
  | false => do
    x := 17

同样,作为 forunless 语法的一部分出现的 do 只是其语法的一部分,并不引入新的 do 块。 这些程序也被接受:

example : Id Unit := do
  let mut x := 0
  for y in [1:5] do
   x := x + y

example : Id Unit := do
  let mut x := 0
  unless 1 < 5 do
    x := x + 1

命令式还是函数式编程?

Lean 的 do-标记提供的命令式特性让许多程序与 Rust、Java 或 C# 等语言中的对应程序非常相似。 在将命令式算法转化为 Lean 的算法时,这种相似性非常方便,而且有些任务可以很自然地以命令式的方式进行思考。 单子和单子转换器的引入使得命令式程序可以用纯函数式语言编写,而作为单子(可能是局部转换的)专用语法的 do-标记则让函数式程序员获得了两全其美的结果:不变性提供了强大的推理原则,通过类型系统对可用作用进行了严格控制,同时还结合了语法和库,使得具有副作用的程序看起来熟悉且易于阅读。 单子和单子转换器让函数式编程与命令式编程成为一个视角问题。

练习

  • 重写 doug 以使用 for 代替 doList 函数。是否还有其他机会使用本节介绍的功能来改进代码?如果有,请使用它们!

其他便利功能

管道操作符

函数通常写在参数之前。 当从左往右阅读程序时,这种做法会让人觉得函数的 输出 是最重要的 —— 函数有一个要实现的目标(也就是要计算的值),在这个过程中,函数会得到参数的支持。 但有些程序更容易理解,通过想象不断完善输入来产生输出。 针对这些情况,Lean 提供了与 F# 类似的 管道 操作符。 管道操作符在与 Clojure 的线程宏相同的情况下非常有用。

管道 E1 |> E2E2 E1 的缩写。 举个例子,求值:

#eval some 5 |> toString

可得:

"(some 5)"

虽然这种侧重点的变化可以使某些程序的阅读更加方便,但当管道包含许多组件时,才是它真正派上用场的时刻。

有如下定义

def times3 (n : Nat) : Nat := n * 3

下面的管道:

#eval 5 |> times3 |> toString |> ("It is " ++ ·)

会产生:

"It is 15"

更一般地说,一系列管道 E1 |> E2 |> E3 |> E4 是嵌套函数应用 E4 (E3 (E2 E1)) 的简称。

管道也可以反过来写。 在这种情况下,它们并不优先考虑数据转换这个主旨;而是在许多嵌套括号给读者带来困难的情况下,给出明确应用的步骤。 前面的例子可以这样写成等价形式:

#eval ("It is " ++ ·) <| toString <| times3 <| 5

是下面代码的缩写:

#eval ("It is " ++ ·) (toString (times3 5))

Lean 的方法点符号(Dot notation)使用点前的类型名称来解析点后操作符的命名空间,其作用与管道类似。 即使没有管道操作符,我们也可以写出 [1, 2, 3].reverse 而不是 List.reverse [1, 2, 3] 。 不过,管道运算符也适用于使用多个带点函数的情况。 ([1, 2, 3].reverse.drop 1).reverse 也可以写成 [1, 2, 3] |> List.reverse |> List.drop 1 |> List.reverse 。 该版本避免了表达式因接受参数而必须使用括号的麻烦,和 Kotlin 或 C# 等语言中方法调用链一样简便。 不过,它仍然需要手动提供命名空间。 作为最后一种便利功能,Lean 提供了“管道点”(Pipeline dot)操作符,它像管道一样对函数进行分组,但使用类型名称来解析命名空间。 使用“管道点”,可以将示例改写为 [1, 2, 3] |>.reverse |>.drop 1 |>.reverse

无限循环

在一个 do 块中,repeat 关键字会引入一个无限循环。 例如,一个发送垃圾邮件字符串“Spam!”的程序可用它完成:

def spam : IO Unit := do
  repeat IO.println "Spam!"

repeat 循环还支持和 breakcontinue,和 for 循环一样。

feline实现 中的函数 dump 使用递归函数来永远运行:

partial def dump (stream : IO.FS.Stream) : IO Unit := do
  let buf ← stream.read bufsize
  if buf.isEmpty then
    pure ()
  else
    let stdout ← IO.getStdout
    stdout.write buf
    dump stream

利用 repeat 可将这个函数大大缩短:

def dump (stream : IO.FS.Stream) : IO Unit := do
  let stdout ← IO.getStdout
  repeat do
    let buf ← stream.read bufsize
    if buf.isEmpty then break
    stdout.write buf

无论是 spam 还是 dump 都不需要声明为 partial 类型,因为它们本身并不是无限递归的。 相反,repeat 使用了一个类型,该类型的 ForM 实例是 partial。 部分性不会“感染”函数调用者。

While 循环

在使用局部可变性编程时,while 循环可以方便地替代带有 if 修饰的 breakrepeat 循环:

def dump (stream : IO.FS.Stream) : IO Unit := do
  let stdout ← IO.getStdout
  let mut buf ← stream.read bufsize
  while not buf.isEmpty do
    stdout.write buf
    buf ← stream.read bufsize

在后端, while 只是 repeat 的一个更简单的标记。

总结

组合单子

在从头开始编写单子时,有一些设计模式倾向于描述将每种作用添加到单子中的方式。 读取器作用是通过让单子的类型成为读取器环境中的一个函数来添加的,状态作用是通过包含一个从初始状态到与最终状态配对的值的函数来添加的,失败或异常是通过在返回类型中包含一个和类型来添加的,日志或其他输出是通过在返回类型中包含一个积类型来添加的。 现有的单子也可以成为返回类型的一部分,从而将其作用包含在新的单子中。

这些设计模式通过定义单子转换器(Monad transformers),将某种作用添加到某个基本单子中,从而形成一个可重复使用的软件组件库。 单子转换器以较简单的单子类型为参数,返回增强的单子类型。 单子转换器至少应提供以下实例:

  1. 假定内部类型已经是一个单子的 Monad 实例
  2. 一个 MonadLift 实例,用于将作用从内部单子转换到转换后的单子中

单子转换器可以多态结构或归纳数据类型的形式实现,但最常见的形式是从底层单子类型到增强单子类型的函数。

作用的类型类

一种常见的设计模式是,通过定义一个具有特定作用的单子、一个将作用添加到另一个单子的单子转换器,以及一个为作用提供通用接口的类型类,来实现特定的作用。 这样编写的程序只需指定所需的作用,因此调用者可以提供任何具有合适作用的单子。

有时,辅助类型信息(如提供状态的单子中的状态类型,或提供异常的单子中的异常类型)是输出参数,有时则不是。 输出参数对于每种作用只使用一次的简单程序最有用,但当给定程序中使用同一作用的多个实例时,类型检查器有可能过早提交错误的类型。 因此,通常会提供两种版本(的类型类),普通参数版本的类型类名称以 -Of 结尾。

单子转换器不可交换

需要注意的是,改变单子中转换器的顺序会改变使用该单子的程序的含义。 例如,对 StateTExceptT 重新排序可能导致程序在抛出异常时丢失状态修改,也可能导致程序保持变化。 虽然大多数命令式语言只提供了后者,但单子转换器所提供的更大灵活性要求我们深思熟虑,为手头的任务选择正确的转换器。

单子转换器的 do-标记

Lean 的 do 代码块支持提前返回(代码块以某个值结束)、局部可变变量、带有 breakcontinuefor 循环,以及单分支的 if 语句。 虽然这看似引入了命令式特性,会妨碍使用 Lean 编写证明,但实际上它只不过是为单子转换器的某些常见用法提供了一种更方便的语法。 在幕后,do 代码块所使用的单子会通过适当使用 ExceptTStateT 进行转换,以支持这些附加作用。

使用依值类型编程

在大多数静态类型编程语言中,类型世界和程序世界之间有一个明确的界限。 类型和程序有不同的语法,并且它们在不同阶段起作用。 类型通常在编译阶段被使用,以检查程序是否遵守某些不变量。 程序在运行时阶段被使用,以实际执行计算。 当两者互动时,通常是以类型案例运算符的形式,例如instance-of检查或提供类型检查器无法获得的信息的转换运算符,以在运行时验证。 换句话说,这种交互一般是将类型插入程序世界,使得类型具有一些有限的运行时含义。

Lean 并不严格地区分类型和程序。 在 Lean 中,程序可以计算类型,类型可以包含程序。 允许程序出现在类型中使得编译阶段可以使用编程语言全部的计算能力。 允许函数返回类型则使得类型成为编程过程中的一等参与者。

依值类型(Dependent types) 是包含非类型表达式的类型。

def natOrStringThree (b : Bool) : if b then Nat else String :=
  match b with
  | true => (3 : Nat)
  | false => "three"

更多依值类型的例子包括:

依值类型大大增加了类型系统的能力。 根据参数的值的不同返回不同类型的灵活性使得其他类型系统中很难给出类型的程序可以被接受。 同时,依值类型使得类型签名可以表达“函数只能返回特定的值”等概念,使得编译阶段可以检查一些更加严格的不变量。

然而,使用依值类型进行编程是一个非常复杂的问题,需要一些一般函数式编程中不会用到的技能。 依值类型编程中很可能出现“设计了一个复杂类型以表达一个非常细致的规范,但是无法写出满足这个类型的程序”之类的问题。 当然,这些问题也经常会引发对问题的新的理解,从而得到一个更加细化且可以被满足的类型。 虽然本章只是简单介绍使用依值类型编程,但它是一个值得花一整本书专门讨论的深刻主题。

索引族

多态归纳类型接受类型参数。 例如,List 接受一个类型参数以决定列表中条目的类型,而 Except 接受两个类型参数以决定异常或值的类型。 这些在数据类型的每个构造子中都一致的类型参数,被称为 参量(parameters)

然而,归纳类型中每个构造子的接受的类型参数并不一定要相同。这种不同构造子可以接受不同类型作为参数的归纳类型被称为 索引族(indexed families),而这些不同的参数被称为 索引(indices)。 索引族最为人熟知的例子是**向量(vectors)**类型:这个类型类似列表类型,但它除了包含列表中元素的类型,还包含列表的长度。这种类型在 Lean 中的定义如下:

inductive Vect (α : Type u) : Nat → Type u where
   | nil : Vect α 0
   | cons : α → Vect α n → Vect α (n + 1)

函数声明可以在冒号之前接受一些参数,表示这些参数在整个定义中都是可用的,也可以在冒号之后接受一些参数,函数会对它们进行模式匹配,并根据不同情形定义不同的函数体。 归纳数据类型也有类似的原则:以 Vect 为例,在其顶部的数据类型声明中,参数 α 出现在冒号之前,表示它是一个必须提供的参量,而在冒号之后出现的 Nat 参数表示它是一个索引,(在不同的构造子中)可以变化。 事实上,在 nilcons 构造子的声明中,三个出现 Vect 的地方都将 α 作为第一个参数提供,而第二个参数在每种情形下都不同。

nil 构造子的声明表明它的类型是 Vect α 0。 这意味着在期望 Vect String 3 的上下文中使用 Vect.nil 会导致类型错误,就像在期望 List String 的上下文中使用 [1, 2, 3] 一样:

example : Vect String 3 := Vect.nil
type mismatch
  Vect.nil
has type
  Vect String 0 : Type
but is expected to have type
  Vect String 3 : Type

在这个例子中,03 之间的不匹配和其他例子中类型的不匹配是一模一样的情况,尽管 03 本身并不是类型。

类型族被称为 族(families),因为不同的索引值意味着可以使用的构造子不同。 在某种意义上,一个索引族并不是一个类型;它更像是一组相关的类型的集合,不同索引的值对应了这个集合中的一个类型。 选择索引 5 作为 Vect 的索引意味着只有 cons 构造子可用,而选择索引 0 意味着只有 nil 构造子可用。

如果索引是一个未知的量(例如,一个变量),那么在它变得已知之前,任何构造子都不能被使用。 如果一个 Vect 的长度为 n,那么 Vect.nilVect.cons 都无法被用来构造这个类型,因为无法知道变量 n 作为一个 Nat 应该匹配 0n + 1

example : Vect String n := Vect.nil
type mismatch
  Vect.nil
has type
  Vect String 0 : Type
but is expected to have type
  Vect String n : Type
example : Vect String n := Vect.cons "Hello" (Vect.cons "world" Vect.nil)
type mismatch
  Vect.cons "Hello" (Vect.cons "world" Vect.nil)
has type
  Vect String (0 + 1 + 1) : Type
but is expected to have type
  Vect String n : Type

让列表的长度作为类型的一部分意味着类型具有更多的信息。 例如 Vect.replicate 是一个创建包含某个值(x)的特定份数 (n) 副本的 Vect 的函数。 可以精确地表示这一点的类型是:

def Vect.replicate (n : Nat) (x : α) : Vect α n := _

参数 n 出现在结果的类型的长度中。 以下消息描述了下划线占位符对应的任务:

don't know how to synthesize placeholder
context:
α : Type u_1
n : Nat
x : α
⊢ Vect α n

当编写使用索引族的程序时时,只有当 Lean 能够确定构造子的索引与期望类型中的索引匹配时,才能使用该构造子。 然而,两个构造子的索引与 n 均不匹配——nil 匹配 Nat.zero,而 cons 匹配 Nat.succ。 就像在上面的类型错误示例中的情况一样,变量 n 可能代表其中一个,取决于具体调用函数时 Nat 参数的值。 解决这一问题的方案是利用模式匹配来同时考虑两种情形:

def Vect.replicate (n : Nat) (x : α) : Vect α n :=
  match n with
  | 0 => _
  | k + 1 => _

因为 n 出现在期望的类型中,对 n 进行模式匹配会在匹配的两种情形下 细化(refine) 期望的类型。 在第一个下划线中,期望的类型变成了 Vect α 0

don't know how to synthesize placeholder
context:
α : Type u_1
n : Nat
x : α
⊢ Vect α 0

在第二个下划线中,它变成了 Vect α (k + 1)

don't know how to synthesize placeholder
context:
α : Type u_1
n : Nat
x : α
k : Nat
⊢ Vect α (k + 1)

当模式匹配不仅发现值的结构,还细化程序的类型时,这种模式匹配被称为 依值模式匹配(dependent pattern matching)

细化后的类型允许我们使用对应的构造子。 第一个下划线匹配 Vect.nil,而第二个下划线匹配 Vect.cons

def Vect.replicate (n : Nat) (x : α) : Vect α n :=
  match n with
  | 0 => .nil
  | k + 1 => .cons _ _

.cons 下的第一个下划线应该是一个具有类型 α 的值。 恰好我们有这么一个值:x

don't know how to synthesize placeholder
context:
α : Type u_1
n : Nat
x : α
k : Nat
⊢ α

第二个下划线应该是一个具有类型 Vect α k 的值。这个值可以通过对 replicate 的递归调用产生:

don't know how to synthesize placeholder
context:
α : Type u_1
n : Nat
x : α
k : Nat
⊢ Vect α k

下面是 replicate 的最终定义:

def Vect.replicate (n : Nat) (x : α) : Vect α n :=
  match n with
  | 0 => .nil
  | k + 1 => .cons x (replicate k x)

除了在编写函数时提供帮助之外,Vect.replicate 的类型信息还允许调用方不必阅读源代码就明白它一定不是某些错误的实现。 一个可能会产生错误长度的列表的 replicate 实现如下:

def List.replicate (n : Nat) (x : α) : List α :=
  match n with
  | 0 => []
  | k + 1 => x :: x :: replicate k x

然而,在实现 Vect.replicate 时犯下同样的错误会引发一个类型错误:

def Vect.replicate (n : Nat) (x : α) : Vect α n :=
  match n with
  | 0 => .nil
  | k + 1 => .cons x (.cons x (replicate k x))
application type mismatch
  cons x (cons x (replicate k x))
argument
  cons x (replicate k x)
has type
  Vect α (k + 1) : Type ?u.1998
but is expected to have type
  Vect α k : Type ?u.1998

List.zip 函数通过将两个列表中对应的项配对(第一个列表中的第一项和第二个列表的第一项,第一个列表中的第二项和第二个列表的第二项, ……)来合并两个列表。 这个函数可以用来将一个包含美国俄勒冈州前三座最高峰的列表和一个包含丹麦前三座最高峰的列表合并:

["Mount Hood",
 "Mount Jefferson",
 "South Sister"].zip ["Møllehøj", "Yding Skovhøj", "Ejer Bavnehøj"]

The result is a list of three pairs:

[("Mount Hood", "Møllehøj"),
 ("Mount Jefferson", "Yding Skovhøj"),
 ("South Sister", "Ejer Bavnehøj")]

当列表的长度不同时,结果应该如何呢? 与许多其他语言一样,Lean 选择忽略长的列表中的额外条目。 例如,将一个具有俄勒冈州前五座最高峰的高度的列表与一个具有丹麦前三座最高峰的高度的列表合并会产生一个含有三个有序对的列表。

[3428.8, 3201, 3158.5, 3075, 3064].zip [170.86, 170.77, 170.35]

求值结果为

[(3428.8, 170.86), (3201, 170.77), (3158.5, 170.35)]

这个函数总是返回一个结果,所以它非常易用。但当输入的两个列表意外地具有不同的长度时,一些数据会被悄悄地丢弃。 F# 采用了不同的方法:它的 List.zip 函数在两个列表长度不匹配时抛出异常,如下面 fsi 会话中展示的那样:

> List.zip [3428.8; 3201.0; 3158.5; 3075.0; 3064.0] [170.86; 170.77; 170.35];;
System.ArgumentException: The lists had different lengths.
list2 is 2 elements shorter than list1 (Parameter 'list2')
   at Microsoft.FSharp.Core.DetailedExceptions.invalidArgDifferentListLength[?](String arg1, String arg2, Int32 diff) in /builddir/build/BUILD/dotnet-v3.1.424-SDK/src/fsharp.3ef6f0b514198c0bfa6c2c09fefe41a740b024d5/src/fsharp/FSharp.Core/local.fs:line 24
   at Microsoft.FSharp.Primitives.Basics.List.zipToFreshConsTail[a,b](FSharpList`1 cons, FSharpList`1 xs1, FSharpList`1 xs2) in /builddir/build/BUILD/dotnet-v3.1.424-SDK/src/fsharp.3ef6f0b514198c0bfa6c2c09fefe41a740b024d5/src/fsharp/FSharp.Core/local.fs:line 918
   at Microsoft.FSharp.Primitives.Basics.List.zip[T1,T2](FSharpList`1 xs1, FSharpList`1 xs2) in /builddir/build/BUILD/dotnet-v3.1.424-SDK/src/fsharp.3ef6f0b514198c0bfa6c2c09fefe41a740b024d5/src/fsharp/FSharp.Core/local.fs:line 929
   at Microsoft.FSharp.Collections.ListModule.Zip[T1,T2](FSharpList`1 list1, FSharpList`1 list2) in /builddir/build/BUILD/dotnet-v3.1.424-SDK/src/fsharp.3ef6f0b514198c0bfa6c2c09fefe41a740b024d5/src/fsharp/FSharp.Core/list.fs:line 466
   at <StartupCode$FSI_0006>.$FSI_0006.main@()
Stopped due to error

这种方法避免了数据的意外丢失,但一个输入不正确时直接崩溃的程序并不容易使用。 在 Lean 中相似的实现可以在返回值中使用 OptionExcept 单子,但是为了避免(可能不大的)数据丢失的风险而引入额外的(操作单子的)编程负担又并不太值得。

然而,如果使用 Vect,可以一个 zip 函数,其类型要求两个输入的列表一定具有相同的长度,如下所示:

def Vect.zip : Vect α n → Vect β n → Vect (α × β) n
  | .nil, .nil => .nil
  | .cons x xs, .cons y ys => .cons (x, y) (zip xs ys)

这个定义只需要考虑两个参数都是 Vect.nil 或都是 Vect.cons 的情形。Lean 接受这个定义,而不会像 List 的类似定义那样产生一个“存在缺失情形”的错误:

def List.zip : List α → List β → List (α × β)
  | [], [] => []
  | x :: xs, y :: ys => (x, y) :: zip xs ys
missing cases:
(List.cons _ _), []
[], (List.cons _ _)

这是因为第一个模式匹配中得到的两个构造子,nilcons细化 了类型检查器对长度 n 的知识。 当它是 nil 时,类型检查器还可以确定长度是 0,因此第二个模式的唯一可能选择是 nil。 当它是 cons 时,类型检查器可以确定长度是 k+1,因此第二个模式的唯一可能选择是 cons。 事实上,添加一个同时使用 nilcons 的情形会导致类型错误,因为长度不匹配:

def Vect.zip : Vect α n → Vect β n → Vect (α × β) n
  | .nil, .nil => .nil
  | .nil, .cons y ys => .nil
  | .cons x xs, .cons y ys => .cons (x, y) (zip xs ys)
type mismatch
  Vect.cons y ys
has type
  Vect β (?m.4718 + 1) : Type ?u.4530
but is expected to have type
  Vect β 0 : Type ?u.4530

长度的细化可以通过将 n 变成一个显式参数来观察:

def Vect.zip : (n : Nat) → Vect α n → Vect β n → Vect (α × β) n
  | 0, .nil, .nil => .nil
  | k + 1, .cons x xs, .cons y ys => .cons (x, y) (zip k xs ys)

练习

熟悉使用依值类型编程需要经验,本节的练习非常重要。 对于每个练习,尝试看看类型检查器可以捕捉到哪些错误,哪些错误它无法捕捉。 请通过实验代码来进行尝试。 这也是培养理解错误信息能力的好方法。

  • 仔细检查 Vect.zip 在将俄勒冈州的三座最高峰与丹麦的三座最高峰组合时是否给出了正确的答案。 由于 Vect 没有 List 那样的语法糖,因此最好从定义 oregonianPeaks : Vect String 3danishPeaks : Vect String 3 开始。
  • 定义一个函数 Vect.map。它的类型为 (α → β) → Vect α n → Vect β n
  • 定义一个函数 Vect.zipWith,它将一个接受两个参数的函数依次作用在两个 Vect 中的每一项上。 它的类型应该是 (α → β → γ) → Vect α n → Vect β n → Vect γ n
  • 定义一个函数 Vect.unzip,它将一个包含有序对的 Vect 分割成两个 Vect。它的类型应该是 Vect (α × β) n → Vect α n × Vect β n
  • 定义一个函数 Vect.snoc,它将一个条目添加到 Vect末尾。它的类型应该是 Vect α n → α → Vect α (n + 1)#eval Vect.snoc (.cons "snowy" .nil) "peaks" 应该输出 {{#example_out Examples/ DependentTypes.lean snocSnowy}}snoc 这个名字是函数式编程常见的习语:将 cons 倒过来写。
  • 定义一个函数 Vect.reverse,它反转一个 Vect 的顺序。
  • 定义一个函数 Vect.drop。 它的类型 (n : Nat) → Vect α (k + n) → Vect α k。 通过检查 #eval danishPeaks.drop 2 输出 Vect.cons "Ejer Bavnehøj" (Vect.nil) 来验证它是否正确。
  • 定义一个函数 Vect.take。它的类型为 (n : Nat) → Vect α (k + n) → Vect α n。它返回 Vect 中的前 n 个条目。检查它在一个例子上的结果。

宇宙设计模式

在 Lean 中,用于分类其他类型的类型被称为宇宙,如 TypeType 3Prop 等。 然而, 宇宙(universe) 也用于表示一种设计模式:使用数据类型来表示 Lean 类型的子集,并通过一个解释函数将数据类型的构造子映射为实际类型。 这种数据类型的值被称为其映射到的类型的 编码(codes)

尽管实现方式不同。使用这种设计模式实现的宇宙是一组类型的类型,与 Lean 内置的宇宙具有相同的含义。 在 Lean 中,TypeType 3Prop 等类型直接描述其他类型的类型。 这种方式被称为 Russell 风格的宇宙(universes à la Russell)。 本节中描述的用户定义的宇宙将所有其包含的类型表示为 数据,并用一个显式的函数将这些编码映射到实际的类型。 这种方式被称为 Tarski 风格的宇宙(universes à la Tarski)。 基于依值类型理论的语言(如 Lean)几乎总是使用 Russell 风格的宇宙,而 Tarski 风格的宇宙是这些语言中定义 API 的有用模式。

自定义宇宙使得我们可以划分出一组可以与 API 一起使用的类型的封闭集合。 因为这个集合是封闭的,因此只需要对编码的递归就能使程序适用于该宇宙中的 任何 类型。 下面是一个自定义宇宙的例子。它包括具有编码 natbool 的数据类型,和一个解释函数将nat 映射到 Nat, bool 映射到 Bool

inductive NatOrBool where
  | nat | bool

abbrev NatOrBool.asType (code : NatOrBool) : Type :=
  match code with
  | .nat => Nat
  | .bool => Bool

对编码进行模式匹配允许类型被细化,就像对 Vect 的值进行模式匹配会细化其长度一样。 例如,一个从字符串反序列化此宇宙中的类型的值的程序如下:

def decode (t : NatOrBool) (input : String) : Option t.asType :=
  match t with
  | .nat => input.toNat?
  | .bool =>
    match input with
    | "true" => some true
    | "false" => some false
    | _ => none

t 进行依值模式匹配允许将期望的结果类型 t.asType 分别细化为 NatOrBool.nat.asTypeNatOrBool.bool.asType,并且这些计算为实际的类型 NatBool

与任何其他数据一样,编码可能是递归的。 类型 NestedPairs 编码了任意嵌套的自然数有序对:

inductive NestedPairs where
  | nat : NestedPairs
  | pair : NestedPairs → NestedPairs → NestedPairs

abbrev NestedPairs.asType : NestedPairs → Type
  | .nat => Nat
  | .pair t1 t2 => asType t1 × asType t2

解释函数 NestedPairs.asType 是递归定义的。 这意味着需要对编码进行递归才能实现该宇宙的 BEq

def NestedPairs.beq (t : NestedPairs) (x y : t.asType) : Bool :=
  match t with
  | .nat => x == y
  | .pair t1 t2 => beq t1 x.fst y.fst && beq t2 x.snd y.snd

instance {t : NestedPairs} : BEq t.asType where
  beq x y := t.beq x y

尽管 NestedPairs 宇宙中的每种类型已经有一个 BEq 实例,但类型类的搜索不会在实例声明中自动检查数据类型的所有情形,因为这样的情形可能有无限多种,就像 NestedPairs 一样。 试图让 Lean 直接给出该类型的 BEq 实例会导致错误。需要通过对编码进行递归来向 Lean 解释如何找到这样的实例。

instance {t : NestedPairs} : BEq t.asType where
  beq x y := x == y
failed to synthesize instance
  BEq (NestedPairs.asType t)

错误信息中的 t 代表类型 NestedPairs 的未知值。

类型类 vs 宇宙

类型类使得 API 可以被用在任何类型上,只要这些类型实现了必要的接口。 在大多数情况下,这是更合适的做法,因为很难提前预测 API 的所有用例。 类型类允许库代码被原始作者预期之外的更多类型使用。

Tarski 风格的宇宙使得 API 仅能用在实现决定好的一组类型上。在一些情况下,这是有用的:

  • 当一个函数应该根据传递的类型不同而有非常不同的表现时—无法对类型本身进行模式匹配,但可以对类型的编码进行模式匹配;
  • 当外部系统本身就限制了可能提供的数据类型,并且不需要额外的灵活性;
  • 当实现某些操作需要类型的一些额外属性时。

类型类在 在类似 Java 或 C# 中适合使用接口的场景下更加有用,而 Tarski 风格的宇宙则在类似适合使用封闭类(sealed class)的场景下,且一般的归纳定义数据类型无法使用的情况下更加有用。

一个有限类型的宇宙

将 API 限制为只能用于给定的类型允许 API 实现通常情况下不可能的操作。 例如,比较函数是否相等。两个函数相等定义为它们总是将相同的输入映射到相同的输出时。 检查这一点可能需要无限长的时间,例如比较两个类型为 Nat → Bool 的函数需要检查函数对每个 Nat 返回相同的 Bool

换句话说,参数类型为无限类型的函数本身也是无限类型。 函数可以被视为表格,参数类型为无限类型的函数需要无限多行来描述每种情形。 但来参数类型为限类型的函数只需要有限行,意味着该函数类型也是有限类型。 如果两个函数的参数类型均为有限类型,则可以通过枚举参数所有的可能性,然后比较它们在所有这些输入下的输出结果来检查它们是否相等。 检查高阶函数是否相等需要生成给定类型的所有可能函数,此外还需要返回类型是有限的,以便将参数类型的每个元素映射到返回类型的每个元素。 这不是一种快速的方法,但它确实在有限时间内完成。

表示有限类型的一种方法是定义一个宇宙:

inductive Finite where
  | unit : Finite
  | bool : Finite
  | pair : Finite → Finite → Finite
  | arr : Finite → Finite → Finite

abbrev Finite.asType : Finite → Type
  | .unit => Unit
  | .bool => Bool
  | .pair t1 t2 => asType t1 × asType t2
  | .arr t1 t2 => asType t1 → asType t2

在这个宇宙中,构造子 arr 表示函数类型(因为函数的箭头符号叫做 arr ow)。

比较这个宇宙中的两个值是否相等与 NestedPairs 宇宙中几乎相同。 唯一重要的区别是增加了 arr 的情形,它使用一个名为 Finite.enumerate 的辅助函数来生成由 t1 编码的类型的每个值,然后检查两个函数对每个可能的输入返回相同的结果:

def Finite.beq (t : Finite) (x y : t.asType) : Bool :=
  match t with
  | .unit => true
  | .bool => x == y
  | .pair t1 t2 => beq t1 x.fst y.fst && beq t2 x.snd y.snd
  | .arr t1 t2 =>
    t1.enumerate.all fun arg => beq t2 (x arg) (y arg)

标准库函数 List.all 检查提供的函数在列表的每个条目上返回 true。 这个函数可以用来比较布尔值上的函数是否相等:

#eval Finite.beq (.arr .bool .bool) (fun _ => true) (fun b => b == b)
true

它也可以用来比较标准库中的函数:

#eval Finite.beq (.arr .bool .bool) (fun _ => true) not
false

它甚至可以比较使用函数复合等工具构建的函数:

#eval Finite.beq (.arr .bool .bool) id (not ∘ not)
true

这是因为 Finite 宇宙编码了 Lean 的实际函数类型,而非某些特殊的近似。

enumerate 的实现也是通过对 Finite 的编码进行递归。

  def Finite.enumerate (t : Finite) : List t.asType :=
    match t with
    | .unit => [()]
    | .bool => [true, false]
    | .pair t1 t2 => t1.enumerate.product t2.enumerate
    | .arr t1 t2 => t1.functions t2.enumerate

Unit 只有一个值。Bool 有两个值(truefalse)。 有序对的值则是 t1 编码的类型的值和 t2 编码的类型的值的笛卡尔积。换句话说,t1 的每个值都应该与 t2 的每个值配对。 辅助函数 List.product 可以用普通的递归函数编写,但这里恒等单子中定义for实现:

def List.product (xs : List α) (ys : List β) : List (α × β) := Id.run do
  let mut out : List (α × β) := []
  for x in xs do
    for y in ys do
      out := (x, y) :: out
  pure out.reverse

最后,Finite.enumerate 将对函数的情形的处理委托给一个名为 Finite.functions 的辅助函数,该函数将返回类型的所有值的列表作为参数。

简单来说,生成从某个有限类型到结果的值的所有函数可以被认为是生成函数的表格。 每个函数将一个输出分配给每个输入,这意味着当有 \( k \) 个可能的参数时,给定函数的表格有 \( k \) 行。 因为表格的每一行都可以选择 \( n \) 个可能的输出中的任何一个,所以有 \( n ^ k \) 个潜在的函数要生成。

与之前类似,生成从有限类型到一些值列表的函数是通过对描述有限类型的编码进行递归完成的:

  def Finite.functions (t : Finite) (results : List α) : List (t.asType → α) :=
    match t with

Unit 的函数表格包含一行,因为函数不能根据提供的输入选择不同的结果。 这意味着为每个潜在的输入生成一个函数。

      | .unit =>
        results.map fun r =>
          fun () => r

Bool 到 \( n \) 个结果值时,有 \( n^2 \) 个函数,因为类型 Bool → α 的每个函数根据 Bool 选择两个特定的 α

      | .bool =>
        (results.product results).map fun (r1, r2) =>
          fun
            | true => r1
            | false => r2

从有序对中生成函数可以通过利用柯里化来实现:把这个函数转化为一个接受有序对的第一个元素并返回一个等待有序对的第二个元素的函数。 这样做允许在这种情形下递归使用 Finite.functions

      | .pair t1 t2 =>
        let f1s := t1.functions <| t2.functions results
        f1s.map fun f =>
          fun (x, y) =>
            f x y

生成高阶函数有点烧脑。 一个函数可以根据其输入/输出行为与其他函数区分开来。 高阶函数的输入行为则又依赖于其函数参数的输入/输出行为: 因此高阶函数的所有行为可以表示为将函数参数应用于所有它所有可能的输入值,然后根据该函数应用的结果的不同产生不同的行为。 这提供了一种构造高阶函数的方法:

  • 构造参数函数 t1 → t2 的参数 t1 的所有可能值。
  • 对于每个可能的值,构造可以由应用参数函数到可能的参数的观察结果产生的所有可能行为。 这可以使用 Finite.functions 和对其余参数的递归来完成,因为递归的结果表示基于其余可能参数的观察的函数。Finite.functions 根据当前对参数的观察构造所有实现这些方式的方法。
  • 对基于每个观察结果的潜在行为,构造一个将函数参数应用于当前可能参数的高阶函数。然后将此结果传递给观察行为。
  • 递归的基情形是对每个结果值观察无事可做的高阶函数——它忽略函数参数,只是返回结果值。

直接定义这个递归函数导致 Lean 无法证明整个函数终止。 然而,一种更简单的递归形式,右折叠(right fold),可以让终止检查器明确地知道函数终止。 右折叠接受三个参数:(1)步骤函数,它将列表的头与对尾部的递归得到的结果组合在一起;(2)列表为空时的默认值;(3)需要处理的列表。 这个函数会分析列表,将列表中的每个 :: 替换为对步骤函数的调用,并将 [] 替换为默认值:

def List.foldr (f : α → β → β) (default : β) : List α → β
  | []     => default
  | a :: l => f a (foldr f default l)

可以使用 foldr 求出列表中 Nat 的和:

[1, 2, 3, 4, 5].foldr (· + ·) 0
===>
(1 :: 2 :: 3 :: 4 :: 5 :: []).foldr (· + ·) 0
===>
(1 + 2 + 3 + 4 + 5 + 0)
===>
15

使用 foldr,可以创建如下的高阶函数:

      | .arr t1 t2 =>
        let args := t1.enumerate
        let base :=
          results.map fun r =>
            fun _ => r
        args.foldr
          (fun arg rest =>
            (t2.functions rest).map fun more =>
              fun f => more (f arg) f)
          base

Finite.Functions 的完整定义是:

  def Finite.functions (t : Finite) (results : List α) : List (t.asType → α) :=
    match t with
      | .unit =>
        results.map fun r =>
          fun () => r
      | .bool =>
        (results.product results).map fun (r1, r2) =>
          fun
            | true => r1
            | false => r2
      | .pair t1 t2 =>
        let f1s := t1.functions <| t2.functions results
        f1s.map fun f =>
          fun (x, y) =>
            f x y
      | .arr t1 t2 =>
        let args := t1.enumerate
        let base :=
          results.map fun r =>
            fun _ => r
        args.foldr
          (fun arg rest =>
            (t2.functions rest).map fun more =>
              fun f => more (f arg) f)
          base

因为 Finite.enumerateFinite.functions 互相调用,它们必须在一个 mutual 块中定义。 换句话说,在 Finite.enumerate 的定义前需要加入 mutual 关键字:

mutual
  def Finite.enumerate (t : Finite) : List t.asType :=
    match t with

and right after the definition of Finite.functions is the end keyword:

      | .arr t1 t2 =>
        let args := t1.enumerate
        let base :=
          results.map fun r =>
            fun _ => r
        args.foldr
          (fun arg rest =>
            (t2.functions rest).map fun more =>
              fun f => more (f arg) f)
          base
end

这种比较函数的算法并不特别实用。 要检查的情形数量呈指数增长;即使是一个简单的类型,如 ((Bool × Bool) → Bool) → Bool,也描述了 65536 个不同的函数。 为什么会有这么多? 根据上面的推理,并使用 \( \left| T \right| \) 表示类型 \( T \) 描述的值的数量,那么上述函数的值的数量应该为

\[ \left| \left( \left( \mathtt{Bool} \times \mathtt{Bool} \right) \rightarrow \mathtt{Bool} \right) \rightarrow \mathtt{Bool} \right| \]。

这个值可以一步步化简为

\[ \left|\mathrm{Bool}\right|^{\left| \left( \mathtt{Bool} \times \mathtt{Bool} \right) \rightarrow \mathtt{Bool} \right| }, \]

\[ 2^{2^{\left| \mathtt{Bool} \times \mathtt{Bool} \right| }}, \]

\[ 2^{2^4} \]

65536

指数的嵌套会很快地增长。这样的高阶函数还有很多。

练习

  • 编写一个函数,将由 Finite 编码的类型的值转换为字符串。函数应该以表格的方式表示。
  • 将空类型 Empty 添加到 FiniteFinite.beq
  • Option 添加到 FiniteFinite.beq

实际案例:类型化查询

类型族在构建一个模仿其他语言的 API 时非常有用。 它们可以用来编写一个保证生成合法页面的 HTML 生成器,或者编码某种文件格式的配置,或是用来建模复杂的业务约束。 本节描述了如何在 Lean 中使用索引族对关系代数的一个子集进行编码,然而本节的展示的技术完全可以被用来构建一个更加强大的数据库查询语言。

这个子集使用类型系统来保证某些要求,比如字段名称的不相交性,并使用类型上的计算将数据库模式(Schema)反映到从查询返回的值的类型中。 它并不是一个实际的数据库系统——数据库用链表的链表表示;类型系统比 SQL 的简单得多;关系代数的运算符与 SQL 的运算符并不完全匹配。 然而,它足够用来展示使用索引族的一些有用的原则和技术。

一个数据的宇宙

在这个关系代数中,保存在列中的基本数据的类型包括 IntStringBool,并由宇宙 DBType 描述:

inductive DBType where
  | int | string | bool

abbrev DBType.asType : DBType → Type
  | .int => Int
  | .string => String
  | .bool => Bool

asType 将这些编码转化为类型:

#eval ("Mount Hood" : DBType.string.asType)
"Mount Hood"

可以对三种类型的任何两个值都判断是否相等。 然而,向 Lean 解释这一点需要一些工作。 直接使用 BEq 会失败:

def DBType.beq (t : DBType) (x y : t.asType) : Bool :=
  x == y
failed to synthesize instance
  BEq (asType t)

就像在嵌套对的宇宙中一样,类型类搜索不会自动检查 t 的值的每种可能性。 解决方案是使用模式匹配来细化 xy 的类型:

def DBType.beq (t : DBType) (x y : t.asType) : Bool :=
  match t with
  | .int => x == y
  | .string => x == y
  | .bool => x == y

在这个版本的函数中,xy 在三种情形下的类型分别为 IntStringBool,这些类型都有 BEq 实例。 dbEq 的定义可以用来为 DBType 编码的类型定义一个 BEq 实例:

instance {t : DBType} : BEq t.asType where
  beq := t.beq

这个实例与编码本身的实例不同:

instance : BEq DBType where
  beq
    | .int, .int => true
    | .string, .string => true
    | .bool, .bool => true
    | _, _ => false

前一个实例允许比较编码描述的类型中的值,而后一个实例允许比较编码本身。

一个 Repr 实例可以使用相同的技术编写。 Repr 类的方法被称为 reprPrec,因为它在显示值时考虑了操作符优先级等因素。 通过依值模式匹配细化类型,可以使用 IntStringBoolRepr 实例的 reprPrec 方法:

instance {t : DBType} : Repr t.asType where
  reprPrec :=
    match t with
    | .int => reprPrec
    | .string => reprPrec
    | .bool => reprPrec

数据库模式和表

一个数据库模式描述了数据库中每一列的名称和类型:

structure Column where
  name : String
  contains : DBType

abbrev Schema := List Column

事实上,数据库模式可以看作是描述表中行的宇宙。 空数据库模式描述了 Unit 类型,具有单个列的数据库模式描述了那个值本身,具有至少两个列的数据库模式可以有由元组表示:

abbrev Row : Schema → Type
  | [] => Unit
  | [col] => col.contains.asType
  | col1 :: col2 :: cols => col1.contains.asType × Row (col2::cols)

正如在积类型的起始节中描述的那样,Lean 的积类型和元组是右结合的。 这意味着嵌套对等同于普通的展平元组。

表是一个共享数据库模式的行的列表:

abbrev Table (s : Schema) := List (Row s)

例如,可以用数据库模式 peak 表示对山峰的拜访日记:

abbrev peak : Schema := [
  ⟨"name", DBType.string⟩,
  ⟨"location", DBType.string⟩,
  ⟨"elevation", DBType.int⟩,
  ⟨"lastVisited", .int⟩
]

本书作者拜访过的部分山峰以元组的列表呈现:

def mountainDiary : Table peak := [
  ("Mount Nebo",       "USA",     3637, 2013),
  ("Moscow Mountain",  "USA",     1519, 2015),
  ("Himmelbjerget",    "Denmark",  147, 2004),
  ("Mount St. Helens", "USA",     2549, 2010)
]

另一个例子包括瀑布和对它们的拜访日记:

abbrev waterfall : Schema := [
  ⟨"name", .string⟩,
  ⟨"location", .string⟩,
  ⟨"lastVisited", .int⟩
]

def waterfallDiary : Table waterfall := [
  ("Multnomah Falls", "USA", 2018),
  ("Shoshone Falls",  "USA", 2014)
]

回顾递归和宇宙

将行结构化为元组的方便性是有代价的:Row 将其两个基情形的分开处理意味着在类型中使用 Row 和在编码(即数据库模式)上递归定义的函数需要做出相同的区分。 一个具体的例子是一个通过对数据库模式递归检查行是否相等的函数。 下面的实现无法通过 Lean 的类型检查:

def Row.bEq (r1 r2 : Row s) : Bool :=
  match s with
  | [] => true
  | col::cols =>
    match r1, r2 with
    | (v1, r1'), (v2, r2') =>
      v1 == v2 && bEq r1' r2'
type mismatch
  (v1, r1')
has type
  ?m.6559 × ?m.6562 : Type (max ?u.6571 ?u.6570)
but is expected to have type
  Row (col :: cols) : Type

问题在于模式 col :: cols 并没有足够细化行的类型。 这是因为 Lean 无法确定到底是 Row 定义中的哪种模式被匹配上:单例模式 [col] 或是 col1 :: col2 :: cols 模式。因此对 Row 的调用不会计算到一个有序对类型。 解决方案是在 Row.bEq 的定义中反映 Row 的结构:

def Row.bEq (r1 r2 : Row s) : Bool :=
  match s with
  | [] => true
  | [_] => r1 == r2
  | _::_::_ =>
    match r1, r2 with
    | (v1, r1'), (v2, r2') =>
      v1 == v2 && bEq r1' r2'

instance : BEq (Row s) where
  beq := Row.bEq

不同于其他上下文,出现在类型中的函数不能仅仅考虑其输入/输出行为。 使用这些类型的程序将发现自己被迫镜像那些类型中使用到的函数所使用的算法,以便它们的结构与类型的模式匹配和递归行为相匹配。 使用依赖类型编程的技巧的一个重要部分是在类型的计算中选择具有正确计算行为函数。

列指针

如果数据库模式包含特定列,那么某些查询才有意义。 例如,一个返回海拔高于 1000 米的山的查询只在包含整数的 "elevation" 列的数据库模式中才有意义。 一种表示数据库模式包含某个列的方法是直接提供指向这个列的指针。将指针定义为一个索引族使得可以排除无效指针。

列可以出现在数据库模式的两个地方:要么在它的开头,要么在它的后面的某个地方。 如果列出现在模式的后面的某个地方,那么它也必然是某一个尾数据库模式的开头。

索引族 HasCol 将这种规范表达为 Lean 的代码:

inductive HasCol : Schema → String → DBType → Type where
  | here : HasCol (⟨name, t⟩ :: _) name t
  | there : HasCol s name t → HasCol (_ :: s) name t

这个族的三个参数是数据库模式、列名和它的类型。 所有三个参数都是索引,但重新排列参数,将数据库模式放在列名和类型之后,可以使列名和类型成为参量。 当数据库模式以列 ⟨name, t⟩ 开头时,可以使用构造子 here:它是一个指向当前数据库模式的第一列的指针,只有当第一列具有所需的名称和类型时才能使用。 构造子 there 将一个指向较小数据库模式的指针转换为一个指向在头部包含在一个额外列的数据库模式的指针。

因为 "elevation"peak 中的第三列,所以可以通过 there 跳过前两列然后使用 here 找到它。 换句话说,要满足类型 HasCol peak "elevation" .int,使用表达式 .there (.there .here)HasCol 也可以理解为是一种带有修饰的 Nat——zero 对应于 heresucc 对应于 there。 额外的类型信息使得不可能出现列序号偏差了一位之类的错误。

指向数据库模式中的列的指针可以用来从行中提取该列的值:

def Row.get (row : Row s) (col : HasCol s n t) : t.asType :=
  match s, col, row with
  | [_], .here, v => v
  | _::_::_, .here, (v, _) => v
  | _::_::_, .there next, (_, r) => get r next

第一步是对数据库模式进行模式匹配,因为这决定了行是元组还是单个值。 空模式的情形不需要考虑,因为 HasCol的两个构造子都对应着非空的数据库模式。 如果数据库模式只有一个列,那么指针必须指向它,因此只需要匹配 HasColhere 构造子。 如果数据库模式有两个或更多列,那么必须有一个 here 的情形,此时值是行中的第一个值,以及一个 there 的情形,此时需要进行递归调用。 HasCol 类型保证了列存在于行中,所以 Row.get 不需要返回一个 Option

HasCol 扮演了两个角色:

  1. 它作为证据,证明模式中存在具有特定名称和类型的列。
  1. 它作为数据,可以用来在行中找到与列关联的值。

第一个角色,即证据的角色,类似于命题的使用方式。 索引族 HasCol 的定义可以被视为一个规范,说明什么样的证据可以证明给定的列存在。 然而,与命题不同,使用 HasCol 的哪个构造子很重要。 在第二个角色中,构造子起到类似 Nat的作用,用于在集合中查找数据。 使用索引族编程通常需要能够流畅地使用它的这两个角色。

子数据库模式

关系代数中的一个重要操作是将表或行投影到一个较小的数据库模式中。 不在这一数据库模式中的每一列都会被舍弃。 为了使投影有意义,小数据库模式必须是大数据库模式的子数据库模式:小数据库模式中的每一列都必须存在于大数据库模式中。 正如 HasCol 允许我们编写一个从行中提取某个列函数且这个函数一定不会失败一样, 将子模式关系表示为索引族允许我们编写一个不会失败的投影函数。

可以将“一个数据库模式是另一个数据库模式的子数据库模式”定义为一个索引族。 基本思想是,如果小数据库模式中的每一列都出现在大数据库模式中,那么小数据库模式就是大数据库模式的子数据库模式。 如果小数据库模式为空,则它肯定是大数据库模式的子数据库模式,由构造子 nil 表示。 如果小数据库模式有一列,那么该列必须在大数据库模式中且子数据库模式中的其余列也必须是大数据库模式的子数据库模式。 这由子 cons 表示。

inductive Subschema : Schema → Schema → Type where
  | nil : Subschema [] bigger
  | cons :
      HasCol bigger n t →
      Subschema smaller bigger →
      Subschema (⟨n, t⟩ :: smaller) bigger

换句话说,Subschema 为小数据库模式的每一列分配一个 HasCol,该 HasCol 指向大数据库模式中的位置。

模式 travelDiary 表示 peakwaterfall 共有的字段:

abbrev travelDiary : Schema :=
  [⟨"name", .string⟩, ⟨"location", .string⟩, ⟨"lastVisited", .int⟩]

正如这个例子所示,它肯定是 peak 的子数据库模式:

example : Subschema travelDiary peak :=
  .cons .here
    (.cons (.there .here)
      (.cons (.there (.there (.there .here))) .nil))

然而,这样的代码很难阅读和维护。 改进的一种方法是指导 Lean 自动编写 SubschemaHasCol 构造子。 这可以通过使用关于命题和证明的插曲中介绍的策术特性来完成。 该插曲使用 by simp 提供了各种命题的证据。

此时,两种策术是有用的:

  • constructor 策术指示 Lean 使用数据类型的构造子解决问题。
  • repeat 策术指示 Lean 重复一个策术,直到它失败或证明完成。

下一个例子中,by constructor 的效果与直接写 .nil 是一样的:

example : Subschema [] peak := by constructor

然而,在一个稍微复杂的类型下尝试相同的策术会失败:

example : Subschema [⟨"location", .string⟩] peak := by constructor
unsolved goals
case a
⊢ HasCol peak "location" DBType.string

case a
⊢ Subschema [] peak

unsolved goals 开头的错误描述了策术未能完全构建它们应该构建的表达式。 在 Lean 的策略语言中,证明目标(goal) 是策术需要通过构造适当的表达式来实现的类型。 在这种情形下,constructor 导致应用 Subschema.cons,两个目标表示 cons 期望的两个参数。 添加另一个 constructor 实例导致第一个目标(HasCol peak \"location\" DBType.string)被 HasCol.there 处理,因为 peak 的第一列不是 "location"

example : Subschema [⟨"location", .string⟩] peak := by
  constructor
  constructor
unsolved goals
case a.a
⊢ HasCol
    [{ name := "location", contains := DBType.string }, { name := "elevation", contains := DBType.int },
      { name := "lastVisited", contains := DBType.int }]
    "location" DBType.string

case a
⊢ Subschema [] peak

然而,添加第三个 constructor 解决了第一个证明目标,因为 HasCol.here 是适用的:

example : Subschema [⟨"location", .string⟩] peak := by
  constructor
  constructor
  constructor
unsolved goals
case a
⊢ Subschema [] peak

第四个 constructor 实例解决了 Subschema peak [] 目标:

example : Subschema [⟨"location", .string⟩] peak := by
  constructor
  constructor
  constructor
  constructor

事实上,一个没有使用策术的版本有四个构造子:

example : Subschema [⟨"location", .string⟩] peak :=
  .cons (.there .here) .nil

不要尝试找到写 constructor 的正确次数,可以使用 repeat 策术要求 Lean 只要取得进展就继续尝试 constructor

example : Subschema [⟨"location", .string⟩] peak := by repeat constructor

这个更灵活的版本也适用于更有趣的 Subschema 问题:

example : Subschema travelDiary peak := by repeat constructor

example : Subschema travelDiary waterfall := by repeat constructor

盲目尝试构造子直到某个符合预期类型的值被构造出来的方法对于 NatList Bool 这样的类型并不是很有用。 毕竟,一个表达式的类型是 Nat 并不意味着它是 正确的 Nat。 但 HasColSubschema 这样的类型受到索引的约束, 只有一个构造子适用。 这意味着程序本身是平凡的,计算机可以选择正确的构造子。

如果一个数据库模式是另一个数据库模式的子数据库模式,那么它也是扩展了一个额外列的更大数据库模式的子数据库模式。 这个事实被下列函数定义表示出来。 Subschema.addColumn 接受 smallerbigger 的子数据库模式的证据,然后返回 smallerc :: bigger 的子数据库模式的证据,即,bigger 增加了一个额外列:

def Subschema.addColumn (sub : Subschema smaller bigger) : Subschema smaller (c :: bigger) :=
  match sub with
  | .nil  => .nil
  | .cons col sub' => .cons (.there col) sub'.addColumn

子数据库模式描述了在大数据库模式中找到小数据库模式的每一列的位置。 Subschema.addColumn 必须将这些描述从指向原始的大数据库模式转换为指向扩展后的更大数据库模式。 在 nil 的情形下,小数据库模式是 []nil 也是 []c :: bigger 的子数据库模式的证据。 在 cons 的情形下,它描述了如何将 smaller 中的一列放入 larger,需要使用 there 调整列的放置位置以考虑新列 c,递归调用调整其余列。

另一个思考 Subschema 的方式是它定义了两个数据库模式之间的 关系 —— 存在一个类型为 Subschema bigger smaller 的表达式意味着 (bigger, smaller) 在这个关系中。 这个关系是自反的,意味着每个数据库模式都是自己的子数据库模式:

def Subschema.reflexive : (s : Schema) → Subschema s s
  | [] => .nil
  | _ :: cs => .cons .here (reflexive cs).addColumn

投影行

给定 s's 的子数据库模式的证据,可以将 s 中的行投影到 s' 中的行。 这是通过分析 s's 的子数据库模式的证据完成的:它解释了 s' 的每一列在 s 中的位置。 在 s' 中的新行是通过从旧行的适当位置检索值逐列构建的。

执行这种投影的函数 Row.project 有三种情形,分别对应于 Row 本身的三种情形。 它使用 Row.getSubschema 参数中的每个 HasCol 一起构造投影行:

def Row.project (row : Row s) : (s' : Schema) → Subschema s' s → Row s'
  | [], .nil => ()
  | [_], .cons c .nil => row.get c
  | _::_::_, .cons c cs => (row.get c, row.project _ cs)

条件和选取

投影从表中删除不需要的列,但查询也必须能够删除不需要的行。 这个操作称为 选择(selection)。 选择的前提是有一种表达“哪些行是需要的”的方式。

示例查询语言包含表达式,类似于 SQL 中可以写在 WHERE 子句中的内容。 表达式由索引族 DBExpr 表示。 表达式可以引用数据库中的列,但不同的子表达式都有相同的数据库模式。DBExpr 以数据库模式作为参量。 此外,每个表达式都有一个类型,这些类型不同,所以这是一个索引:

inductive DBExpr (s : Schema) : DBType → Type where
  | col (n : String) (loc : HasCol s n t) : DBExpr s t
  | eq (e1 e2 : DBExpr s t) : DBExpr s .bool
  | lt (e1 e2 : DBExpr s .int) : DBExpr s .bool
  | and (e1 e2 : DBExpr s .bool) : DBExpr s .bool
  | const : t.asType → DBExpr s t

col 构造子表示对数据库中的列的引用。 eq 构造子比较两个表达式是否相等,lt 检查一个是否小于另一个,and 是布尔合取,const 是某种类型的常量值。

例如,在 peak 中检查 elevation 列的值大于 1000 并且位置等于 "Denmark" 的表达式可以写为:

def tallInDenmark : DBExpr peak .bool :=
  .and (.lt (.const 1000) (.col "elevation" (by repeat constructor)))
       (.eq (.col "location" (by repeat constructor)) (.const "Denmark"))

这有点复杂。 特别是,对列的引用包含了重复的对 by repeat constructor 的调用。 Lean 的一个特性叫做 宏(macro),可以消除这些重复代码,使表达式更易于阅读:

macro "c!" n:term : term => `(DBExpr.col $n (by repeat constructor))

这个声明为 Lean 添加了 c! 关键字,并指示 Lean 用相应的 DBExpr.col 构造替换后面跟着的任何 c! 实例。 这里,term 代表 Lean 表达式,而不是命令、策术或语言的其他部分。 Lean 宏有点像 C 预处理器宏,只是它们更好地集成到语言中,并且它们自动避免了 CPP 的一些陷阱。 事实上,它们与 Scheme 和 Racket 中的宏非常密切相关。

有了这个宏,表达式就容易阅读得多:

def tallInDenmark : DBExpr peak .bool :=
  .and (.lt (.const 1000) (c! "elevation"))
       (.eq (c! "location") (.const "Denmark"))

求某行在一个表达式下的值包括对表达式中的 .col 调用 Row.get 提取列引用,其他构造子则委托给 Lean 中对应的运算进行处理:

def DBExpr.evaluate (row : Row s) : DBExpr s t → t.asType
  | .col _ loc => row.get loc
  | .eq e1 e2  => evaluate row e1 == evaluate row e2
  | .lt e1 e2  => evaluate row e1 < evaluate row e2
  | .and e1 e2 => evaluate row e1 && evaluate row e2
  | .const v => v

对 Valby Bakke,哥本哈根地区最高的山,求值得到 false,因为 Valby Bakke 的海拔远低于 1 km:

#eval tallInDenmark.evaluate ("Valby Bakke", "Denmark", 31, 2023)
false

对一个海拔 1230 米的虚构的山求值得到 true

#eval tallInDenmark.evaluate ("Fictional mountain", "Denmark", 1230, 2023)
true

为美国爱达荷州最高峰求值得到 false,因为爱达荷州不是丹麦的一部分:

#eval tallInDenmark.evaluate ("Mount Borah", "USA", 3859, 1996)
false

查询

查询语言基于关系代数。 除了表之外,它还包括以下运算符:

  1. 并,将两个具有相同数据库模式的表达式的查询的结果行合并
  2. 差,定义在两个具有相同数据库模式的表达式,从第一个表达式的查询结果中删除同时存在于第二个表达式的查询结果的行
  3. 选择,按照某些标准,根据表达式过滤查询的结果
  4. 投影,从查询结果中删除列
  5. 笛卡尔积,将一个查询的每一行与另一个查询的每一行组合
  6. 重命名,修改查询结果中某一个列的名字
  7. 添加前缀,为查询中的所有列名添加一个前缀

最后一个运算符不是严格必要的,但它使语言更方便使用。

查询同样由一个索引族表示:

inductive Query : Schema → Type where
  | table : Table s → Query s
  | union : Query s → Query s → Query s
  | diff : Query s → Query s → Query s
  | select : Query s → DBExpr s .bool → Query s
  | project : Query s → (s' : Schema) → Subschema s' s → Query s'
  | product :
      Query s1 → Query s2 →
      disjoint (s1.map Column.name) (s2.map Column.name) →
      Query (s1 ++ s2)
  | renameColumn :
      Query s → (c : HasCol s n t) → (n' : String) → !((s.map Column.name).contains n') →
      Query (s.renameColumn c n')
  | prefixWith :
      (n : String) → Query s →
      Query (s.map fun c => {c with name := n ++ "." ++ c.name})

select 构造子要求用于选择的表达式返回一个布尔值。 product 构造子的类型包含对 disjoint 的调用,它确保两个数据库模式没有相同的列名:

def disjoint [BEq α] (xs ys : List α) : Bool :=
  not (xs.any ys.contains || ys.any xs.contains)

Bool 类型的表达式用在期望一个类型的位置会触发从 BoolProp 的强制转换。

正如可判定命题被视为一个布尔值:命题的证据被强制转换为 true,命题的反驳被强制转换为 false,布尔值也可以反过来被强制转换为表达式等于 true 的命题。 因为预期所有库的使用将发生在数据库模式已经给定的场景下,所以这个命题可以用 by simp 证明。 类似地,renameColumn 构造子检查新名称是否已经存在于数据库模式中。 它使用辅助函数 Schema.renameColumn 来更改 HasCol 指向的列的名称:

def Schema.renameColumn : (s : Schema) → HasCol s n t → String → Schema
  | c :: cs, .here, n' => {c with name := n'} :: cs
  | c :: cs, .there next, n' => c :: renameColumn cs next n'

执行查询

执行查询需要一些辅助函数。 查询的结果是一个表。 这意味着查询语言中的每个操作都需要一个可以与表一起工作的实现。

笛卡尔积

取两个表的笛卡尔积是通过将第一个表的每一行附加到第二个表的每一行来完成的。 首先,由于 Row 的结构,将一列添加到行中需要对数据库模式进行模式匹配,以确定结果是一个裸值还是一个元组。 这是一个常见的操作,所以我们将模式匹配提取到一个辅助函数中方便复用:

def addVal (v : c.contains.asType) (row : Row s) : Row (c :: s) :=
  match s, row with
  | [], () => v
  | c' :: cs, v' => (v, v')

对两行进行附加需要同时对第一行和它的数据库模式进行递归,因为行的结构与模式的结构是绑定的。 当第一行为空时,返回第二行。 当第一行是一个单例时,将值添加到第二行。 当第一行包含多列时,将第一列的值添加到其余列和第二行附加的递归调用的结果上。

def Row.append (r1 : Row s1) (r2 : Row s2) : Row (s1 ++ s2) :=
  match s1, r1 with
  | [], () => r2
  | [_], v => addVal v r2
  | _::_::_, (v, r') => (v, r'.append r2)

List.flatMap 接受两个一个函数参数和一个列表,函数对列表中的每一项均会返回一个列表,然后List.flatMap按将列表的列表含顺序依次附加:

def List.flatMap (f : α → List β) : (xs : List α) → List β
  | [] => []
  | x :: xs => f x ++ xs.flatMap f

类型签名表明 List.flatMap 可以用来实现 Monad List 实例。 实际上,与 pure x := [x] 一起,List.flatMap 确实实现了一个单子。 然而,这不是一个非常有用的 Monad 实例。 List 单子基本上是一个提前探索搜索空间中的 每一条 可能路径的 Many 单子,尽管用户可能只需要其中的某些值。 由于这种性能陷阱,通常不建议为 List 定义 Monad 实例。 然而查询语言没有限制返回的结果数量的运算符,因此返回所有的组合正是所需要的结果:

def Table.cartesianProduct (table1 : Table s1) (table2 : Table s2) : Table (s1 ++ s2) :=
  table1.flatMap fun r1 => table2.map r1.append

正如 List.product 一样,这个函数也可以通过在恒等单子下使用带变更(mutation)的循环实现:

def Table.cartesianProduct (table1 : Table s1) (table2 : Table s2) : Table (s1 ++ s2) := Id.run do
  let mut out : Table (s1 ++ s2) := []
  for r1 in table1 do
    for r2 in table2 do
      out := (r1.append r2) :: out
  pure out.reverse

从表中删除不需要的行可以使用 List.filter 完成,它接受一个列表和一个返回 Bool 的函数。 返回一个新列表,这个新列表仅包含旧列表中函数值为 true 的条目。 例如,

["Willamette", "Columbia", "Sandy", "Deschutes"].filter (·.length > 8)

求值为

["Willamette", "Deschutes"]

因为 "Columbia""Sandy" 的长度小于或等于 8。 可以使用辅助函数 List.without 删除表的条目:

def List.without [BEq α] (source banned : List α) : List α :=
  source.filter fun r => !(banned.contains r)

这个将在执行查询时与 RowBEq 实例一起使用。

重命名

在一行数据中重命名一个列需要使用一个递归函数遍历整行直到找到需要重命名的列, 然后将用一个新名字指向该列,而值仍然为这列原有的值:

def Row.rename (c : HasCol s n t) (row : Row s) : Row (s.renameColumn c n') :=
  match s, row, c with
  | [_], v, .here => v
  | _::_::_, (v, r), .here => (v, r)
  | _::_::_, (v, r), .there next => addVal v (r.rename next)

这个函数改变了其参数的 类型,但实际返回的数据完全相同。 从运行时的角度看,renameRow 只是一个拖慢运行的恒等函数。 这暗示了使用索引族进行编程时的一个常见问题,当性能很重要时,这种操作可能会造成不必要的性能损失。 需要非常小心,但通常很脆弱的设计来消除这种 重新索引 函数。

添加前缀

添加前缀与重命名列非常相似。 然而prefixRow 必须处理所有列,而非找到一个特定的列然后直接返回:

def prefixRow (row : Row s) : Row (s.map fun c => {c with name := n ++ "." ++ c.name}) :=
  match s, row with
  | [], _ => ()
  | [_], v => v
  | _::_::_, (v, r) => (v, prefixRow r)

这个可以与 List.map 一起使用,以便为表中的所有行添加前缀。 和重命名函数一样,这个函数只改变一个值的类型,但不改变值本身。

将所有东西组合在一起

定义了所有这些辅助函数后,执行查询只需要一个简短的递归函数:

def Query.exec : Query s → Table s
  | .table t => t
  | .union q1 q2 => exec q1 ++ exec q2
  | .diff q1 q2 => exec q1 |>.without (exec q2)
  | .select q e => exec q |>.filter e.evaluate
  | .project q _ sub => exec q |>.map (·.project _ sub)
  | .product q1 q2 _ => exec q1 |>.cartesianProduct (exec q2)
  | .renameColumn q c _ _ => exec q |>.map (·.rename c)
  | .prefixWith _ q => exec q |>.map prefixRow

构造子的一些参数在执行过程中没有被用到。 特别是,构造器 project 和函数 Row.project 都将较小的数据库模式作为显式参数,但表明这个数据库模式是较大数据库模式的子数据库模式的 证据 的类型包含足够的信息,以便 Lean 自动填充参数。 类似地,product 构造子要求两个表具有不同的列名,但 Table.cartesianProduct 不需要。 一般来说,依值类型编程中让 Lean 可以代替程序员自己填写很多参数。

对查询的结果使用点符号(dot notation)以调用在 TableList 命名空间中定义的函数,如 List.mapList.filterTable.cartesianProduct。 因为 Table 是使用 abbrev 定义的,所以这样做是可行的。 就像类型类搜索一样,点符号可以看穿使用 abbrev 创建的定义。

select的实现也非常简洁。 在执行查询 q 后,使用 List.filter 删除不满足表达式的行。 filter 需要一个从 Row sBool 的函数作为参数,但 DBExpr.evaluate 的类型是 Row s → DBExpr s t → t.asType。 但这并不会产生类型错误,因为 select 构造器的类型要求表达式的类型为 DBExpr s .bool,所以 t.asType 实际上就是 Bool

一个找到所有海拔高于 500 米的山峰的高度的查询可以写成:

open Query in
def example1 :=
  table mountainDiary |>.select
  (.lt (.const 500) (c! "elevation")) |>.project
  [⟨"elevation", .int⟩] (by repeat constructor)

执行它返回预期的整数列表:

#eval example1.exec
[3637, 1519, 2549]

为了规划一个观光旅行,可能需要同一位置的所有山和瀑布的有序对。 这可以通过取两个表的笛卡尔积,选择它们 location 相等的行,然后投影出山和瀑布的名称来完成:

open Query in
def example2 :=
  let mountain := table mountainDiary |>.prefixWith "mountain"
  let waterfall := table waterfallDiary |>.prefixWith "waterfall"
  mountain.product waterfall (by simp)
    |>.select (.eq (c! "mountain.location") (c! "waterfall.location"))
    |>.project [⟨"mountain.name", .string⟩, ⟨"waterfall.name", .string⟩] (by repeat constructor)

因为示例数据只包括美国的瀑布,执行查询返回美国的山和瀑布有序对:

#eval example2.exec
[("Mount Nebo", "Multnomah Falls"),
 ("Mount Nebo", "Shoshone Falls"),
 ("Moscow Mountain", "Multnomah Falls"),
 ("Moscow Mountain", "Shoshone Falls"),
 ("Mount St. Helens", "Multnomah Falls"),
 ("Mount St. Helens", "Shoshone Falls")]

可能遇到的错误

很多潜在的错误都被 Query 的定义排除了。 例如,忘记在 "mountain.location" 中添加限定符会导致编译时错误,突出显示列引用 c! "location"

open Query in
def example2 :=
  let mountains := table mountainDiary |>.prefixWith "mountain"
  let waterfalls := table waterfallDiary |>.prefixWith "waterfall"
  mountains.product waterfalls (by simp)
    |>.select (.eq (c! "location") (c! "waterfall.location"))
    |>.project [⟨"mountain.name", .string⟩, ⟨"waterfall.name", .string⟩] (by repeat constructor)

这是一个很棒的反馈! 但是,很难从这个错误信息知道下面应该做什么:

unsolved goals
case a.a.a.a.a.a.a
mountains : Query (List.map (fun c => { name := "mountain" ++ "." ++ c.name, contains := c.contains }) peak) :=
  prefixWith "mountain" (table mountainDiary)
waterfalls : Query (List.map (fun c => { name := "waterfall" ++ "." ++ c.name, contains := c.contains }) waterfall) :=
  prefixWith "waterfall" (table waterfallDiary)
⊢ HasCol (List.map (fun c => { name := "waterfall" ++ "." ++ c.name, contains := c.contains }) []) "location" ?m.109970

类似地,忘记为两个表的名称添加前缀会导致 by simp 上的错误,它应该提供证据表明数据库模式实际上是不同的;

open Query in
def example2 :=
  let mountains := table mountainDiary
  let waterfalls := table waterfallDiary
  mountains.product waterfalls (by simp)
    |>.select (.eq (c! "mountain.location") (c! "waterfall.location"))
    |>.project [⟨"mountain.name", .string⟩, ⟨"waterfall.name", .string⟩] (by repeat constructor)

然而,错误信息同样没有帮助:

unsolved goals
mountains : Query peak := table mountainDiary
waterfalls : Query waterfall := table waterfallDiary
⊢ False

Lean 的宏系统不仅可以为查询提供方便的语法,还可以生成的错误信息变得有用。 不幸的是,本书的范围不包括如何使用 Lean 的宏实现语言。 像 Query 这样的索引族可能最适合作为一个有类型的数据库交互库的核心,直接暴露给用户的接口。

练习

日期

定义一个用来表示日期的结构。将其添加到 DBType 宇宙中,并相应地更新其余代码。 定义必要的额外的 DBExpr 构造子。

可空类型

通过以下结构表示数据库类型,为查询语言添加对可空列的支持:

structure NDBType where
  underlying : DBType
  nullable : Bool

abbrev NDBType.asType (t : NDBType) : Type :=
  if t.nullable then
    Option t.underlying.asType
  else
    t.underlying.asType

ColumnDBExpr 中使用这种类型代替 DBType,并查找 SQL 的 NULL 和比较运算符的规则,以确定 DBExpr 构造子的类型。

尝试策术

在 Lean 中使用 by repeat constructor 观察 Lean 为以下类型找到了什么值,并解释每个结果。

  • Nat
  • List Nat
  • Vect Nat 4
  • Row []
  • Row [⟨"price", .int⟩]
  • Row peak
  • HasCol [⟨"price", .int⟩, ⟨"price", .int⟩] "price" .int

索引、参量和宇宙层级

归纳类型的参量和索引的区别不仅仅是这些参数在构造子之间相同还是不同。 当确定宇宙层级之间的关系时,归纳类型参数是参量还是索引也很重要: 归纳类型的宇宙层级可以与参量相同,但必须比其索引更大。 这种限制是为了确保 Lean 除了作为编程语言还可以作为定理证明器——否则,Lean 的逻辑将是不一致的。 我们将通过展示不同例子输出的错误信息来阐释决定以下两者的具体规则:宇宙层级和某个参数应该被视为参量还是索引。

通常来说,在归纳类型定义中出现在冒号之前的被当作参量,出现在冒号之后的被当作索引。 参量像函数参数可以给出类型和名字,而索引只能给出类型。 如 Vect 的定义所示:

inductive Vect (α : Type u) : Nat → Type u where
   | nil : Vect α 0
   | cons : α → Vect α n → Vect α (n + 1)

在这个定义中,α 是一个参量,Nat 是一个索引。 参量可以在整个定义中被使用(例如,Vect.cons 使用 α 作为其第一个参数的类型),但它们必须始终一致。 因为索引可能会不同,所以它们在每个构造子中被分配单独的值,而不是作为参数出现在数据类型的顶部的定义中。

一个非常简单的带有参量的数据类型是 WithParameter

inductive WithParameter (α : Type u) : Type u where
  | test : α → WithParameter α

宇宙层级 u 可以用于参量和归纳类型本身,说明参量不会增加数据类型的宇宙层级。 同样,当有多个参量时,归纳类型的宇宙层级取决于这些参量的宇宙层级中最大的那个:

inductive WithTwoParameters (α : Type u) (β : Type v) : Type (max u v) where
  | test : α → β → WithTwoParameters α β

由于参量不会增加数据类型的宇宙层级,使用它们很方便。 Lean 会尝试识别像索引一样出现在冒号之后,但像参量一样使用的参数,并将它们转换为参量: 以下两个归纳数据类型的参量都出现在冒号之后:

inductive WithParameterAfterColon : Type u → Type u where
  | test : α → WithParameterAfterColon α

inductive WithParameterAfterColon2 : Type u → Type u where
  | test1 : α → WithParameterAfterColon2 α
  | test2 : WithParameterAfterColon2 α

当一个参量在数据类型的声明中没有命名时,可以在每个构造子中使用不同的名称,只要它们的使用是一致的。 以下声明被接受:

inductive WithParameterAfterColonDifferentNames : Type u → Type u where
  | test1 : α → WithParameterAfterColonDifferentNames α
  | test2 : β → WithParameterAfterColonDifferentNames β

然而,当参量的命名被指定时,这种灵活性就不被允许了:

inductive WithParameterBeforeColonDifferentNames (α : Type u) : Type u where
  | test1 : α → WithParameterBeforeColonDifferentNames α
  | test2 : β → WithParameterBeforeColonDifferentNames β
inductive datatype parameter mismatch
  β
expected
  α

类似的,尝试命名一个索引会导致错误:

inductive WithNamedIndex (α : Type u) : Type (u + 1) where
  | test1 : WithNamedIndex α
  | test2 : WithNamedIndex α → WithNamedIndex α → WithNamedIndex (α × α)
inductive datatype parameter mismatch
  α × α
expected
  α

使用适当的宇宙层级并将索引放在冒号之后会导致一个可接受的声明:

inductive WithIndex : Type u → Type (u + 1) where
  | test1 : WithIndex α
  | test2 : WithIndex α → WithIndex α → WithIndex (α × α)

虽然 Lean 有时(即,在确定一个参数在所有构造子中的使用一致时)可以确定冒号后的参数是一个参量,但所有参量仍然需要出现在所有索引之前。 试图在索引之后放置一个参量会导致该参量被视为一个索引,进而导致数据类型的宇宙层级必须增加:

inductive ParamAfterIndex : Nat → Type u → Type u where
  | test1 : ParamAfterIndex 0 γ
  | test2 : ParamAfterIndex n γ → ParamAfterIndex k γ → ParamAfterIndex (n + k) γ
invalid universe level in constructor 'ParamAfterIndex.test1', parameter 'γ' has type
  Type u
at universe level
  u+2
it must be smaller than or equal to the inductive datatype universe level
  u+1

参量不必是类型。这个例子显示了普通数据类型,如 Nat 也可以被用作参量:

inductive NatParam (n : Nat) : Nat → Type u where
  | five : NatParam 4 5
inductive datatype parameter mismatch
  4
expected
  n

按照错误信息的提示将 4 改成 n 会导致声明被接受:

inductive NatParam (n : Nat) : Nat → Type u where
  | five : NatParam n 5

从以上结果中可以总结出什么?

参量和索引的规则如下:

  1. 参量在每个构造子的类型中的使用方式必须相同。
  2. 所有参量必须在所有索引之前。
  3. 正在定义的数据类型的宇宙层级必须至少与最大的参量宇宙层级一样大,并严格大于最大的索引宇宙层级。
  4. 冒号前写的命名参数始终是参量,而冒号后的参数通常是索引。如果 Lean 发现冒号后的参数在所有构造子中使用一致且不在任何索引之后,则可能能够 这个参数是参量。

当不确定时,可以使用 Lean 命令 #print 来检查数据类型参数中的多少是参量。 例如,对于 Vect,它指出参量的数量是 1:

#print Vect
inductive Vect.{u} : Type u → Nat → Type u
number of parameters: 1
constructors:
Vect.nil : {α : Type u} → Vect α 0
Vect.cons : {α : Type u} → {n : Nat} → α → Vect α n → Vect α (n + 1)

在选择数据类型的参数顺序时,应当考虑哪些参数应该是参量,哪些应该是索引。 尽可能多地将参数作为参量有助于保持一个可控的宇宙层级,从而使复杂的程序的类型检查更容易进行。 一种可能方法是确保参数列表中所有参量出现在所有索引之前。

同时,尽管 Lean 有时可以确定冒号后的参数仍然是参量,但最好使用显式命名编写参量。 这使读者清晰的明白意图,并且 Lean 在这个参量在构造子之间有不一致的使用时会报告错误。

使用依值类型编程的陷阱

依值类型的灵活性允许类型检查器接受更多有用的程序,因为类型的语言足够表达那些一般类型系统不够表达的变化。 同时,依值类型表达非常精细的规范的能力允许类型检查器拒绝更多有错误的程序。 这种能力是有代价的。

返回类型的函数(如 Row )的实现与它的类型之间的紧密耦合是下列问题的一个具体案例: 当类型中包含函数时,接口和实现之间的区别开始瓦解。 通常,只要重构不改变函数的类型签名或输入输出行为,它就不会导致问题。 所以一个函数可以方便地进行下列重构而不会破坏客户端代码:使用更高效的算法和数据结构重写,修复错误,提高代码的清晰度。 然而,当函数出现在类型中时,函数的内部实现成为类型的一部分,因此成为另一个程序的接口的一部分。

Nat 上的加法的两个实现为例。 Nat.plusL 对第一个参数进行递归:

def Nat.plusL : Nat → Nat → Nat
  | 0, k => k
  | n + 1, k => plusL n k + 1

Nat.plusR 则对第二个参数进行递归:

def Nat.plusR : Nat → Nat → Nat
  | n, 0 => n
  | n, k + 1 => plusR n k + 1

两种加法的实现都与数学概念一致,因此在给定相同参数时返回相同的结果。

然而,当这两种实现用于类型时,它们呈现出非常不同的接口。 以一个将两个 Vect 连接起来的函数为例。 这个函数应该返回一个长度为两个参数的长度之和的 Vect。 因为 Vect 本质上是一个带有更多信息的List,所以写这个函数类似 List.append,对第一个参数进行模式匹配和递归。

让我们给定一个初始的类型签名然后进行模式匹配。占位符给出两条信息:

def appendL : Vect α n → Vect α k → Vect α (n.plusL k)
  | .nil, ys => _
  | .cons x xs, ys => _

第一个信息:在 nil 的情形下,占位符应该被替换为一个长度为 plusL 0 kVect

don't know how to synthesize placeholder
context:
α : Type u_1
n k : Nat
ys : Vect α k
⊢ Vect α (Nat.plusL 0 k)

第二个信息:在cons的情形下,占位符应该被替换为一个长度为plusL (n✝ + 1) kVect

don't know how to synthesize placeholder
context:
α : Type u_1
n k n✝ : Nat
x : α
xs : Vect α n✝
ys : Vect α k
⊢ Vect α (Nat.plusL (n✝ + 1) k)

n 后面的符号,称为剑标(dagger),用于表示 Lean 内部生成的名称。 对第一个 Vect 的模式匹配隐式导致第一个 Nat 的值也被细化,因为构造子cons的索引是n + 1Vect的尾部长度为n。 在这里,n✝表示比参数 n 小1的 Nat

定义相等性

plusL 的定义中,有一个模式0, k => k。 因此第一个下划线的类型 Vect α (Nat.plusL 0 k) 的另一个写法是 Vect α k。 类似地,plusL 包含另一个模式 n + 1, k => plusN n k + 1。 因此第二个下划线的类型可以等价地写为Vect α (plusL n✝ k + 1)

为了清楚到底发生了什么,第一步是显式地写出 Nat 参数。这一变化同时导致错误信息中的剑标消失了,因为此时程序已经显式给出了这个参数的名字:

def appendL : (n k : Nat) → Vect α n → Vect α k → Vect α (n.plusL k)
  | 0, k, .nil, ys => _
  | n + 1, k, .cons x xs, ys => _
don't know how to synthesize placeholder
context:
α : Type u_1
k : Nat
ys : Vect α k
⊢ Vect α (Nat.plusL 0 k)
don't know how to synthesize placeholder
context:
α : Type u_1
n k : Nat
x : α
xs : Vect α n
ys : Vect α k
⊢ Vect α (Nat.plusL (n + 1) k)

用简化版本的类型注释下划线不会导致类型错误,这意味着程序中写的类型与 Lean 自己找到的类型是等价的:

def appendL : (n k : Nat) → Vect α n → Vect α k → Vect α (n.plusL k)
  | 0, k, .nil, ys => (_ : Vect α k)
  | n + 1, k, .cons x xs, ys => (_ : Vect α (n.plusL k + 1))
don't know how to synthesize placeholder
context:
α : Type u_1
k : Nat
ys : Vect α k
⊢ Vect α k
don't know how to synthesize placeholder
context:
α : Type u_1
n k : Nat
x : α
xs : Vect α n
ys : Vect α k
⊢ Vect α (Nat.plusL n k + 1)

第一个情形要求一个Vect α k,而 ys 有这种类型。 这跟将一个列表附加到一个空列表时直接返回这个列表的情况相似。 用 ys 替代第一个下划线后,只剩下一个下划线需要填充:

def appendL : (n k : Nat) → Vect α n → Vect α k → Vect α (n.plusL k)
  | 0, k, .nil, ys => ys
  | n + 1, k, .cons x xs, ys => (_ : Vect α (n.plusL k + 1))
don't know how to synthesize placeholder
context:
α : Type u_1
n k : Nat
x : α
xs : Vect α n
ys : Vect α k
⊢ Vect α (Nat.plusL n k + 1)

这里发生了非常重要的事情。 在 Lean 期望一个 Vect α (Nat.plusL 0 k) 的上下文中,它接受了一个 Vect α k 。 然而,Nat.plusL不是一个 abbrev,所以似乎它不应该在类型检查期间运行。 还有其他事情发生了。

理解发生了什么的关键在于 Lean 在类型检查期间不止展开所有 abbrev 的定义。 它还可以在检查两个类型是否等价时执行计算,从而允许一个具有类型A的表达式可以在一个期待类型B的上下文中被使用。 这种属性称为定义相等性(definitional equality)。这种相等性很微妙。

当然,完全相同的两个类型被认为是定义相等的,例如NatNatList StringList String。 任何两个由不同数据类型构造的具体类型都不相等,因此List Nat不等于Int。 此外,两个只在内部名称上存在不同的类型(译者注:即α-等价)是相等的,例如(n : Nat) → Vect String n(k : Nat) → Vect String k。 因为类型可以包含普通数据,定义相等还必须描述何时数据是相等的。 使用相同构造子的数据是相等的,因此0等于0[5, 3, 1]等于[5, 3, 1]

然而,类型不仅包含函数类型、数据类型和构造子。 它们还包含变量函数。 变量的定义相等性相对简单:每个变量只等于自己,因此(n k : Nat) → Vect Int n不等于(n k : Nat) → Vect Int k。 函数则复杂得多。数学上对函数相等的定义为两个函数具有相同的输入输出行为。但这种相等性无法被算法检查。 这违背了而定义相等性的目的:通过算法自动检查两个类型是否相等。 因此,Lean 认为函数只有在它们的函数体定义相等时才是定义相等的。 换句话说,两个函数必须使用相同的算法,调用相同的辅助函数,才能被认为是定义相等的。 这通常不是很有用,因此函数的定义相等一般只用于当两个类型中出现完全相同的函数时。

当函数在类型中被调用时,检查定义相等可能涉及规约这些调用。 类型 Vect String (1 + 4) 与类型 Vect String (3 + 2) 是定义相等的,因为 1 + 43 + 2 是定义相等的。 为了检查它们的相等性,两者都被规约为5,然后使用五次“构造子”规则。

检查函数应用于数据的定义相等性可以首先检查它们是否已经相同——例如,检查["a", "b"] ++ ["c"]是否等于["a", "b"] ++ ["c"]时没有必要进行规约。 如果不同,调用函数并继续检查结果的定义相等性。

并非所有函数参数都是具体数据。 例如,类型可能包含不是由 zerosucc 构造子构建的Nat。 在类型(n : Nat) → Vect String n中,变量n是一个Nat,但在调用函数之前不可能知道它哪个Nat。 实际上,函数可能首先用0调用,然后用17调用,然后再用33调用。 如appendL的定义中所见,类型为Nat的变量也可以传递给plusL等函数。 实际上,类型(n : Nat) → Vect String n(n : Nat) → Vect String (Nat.plusL 0 n)定义相等。

nNat.plusL 0 n 是定义相等的原因是 plusL 对的第一个参数进行模式匹配。 这在别的情况下会导致问题:(n : Nat) → Vect String n(n : Nat) → Vect String (Nat.plusL n 0)定义相等,尽管0应该同时是加法的左和右单位元。 这是因为模式匹配在遇到变量时会卡住。 在 n 的实际值变得已知之前,没有办法知道应该选择 Nat.plusL n 0 的哪种情形。

同样的问题出现在查询示例中的 Row 函数中。 类型Row (c :: cs)不会规约到任何数据类型,因为 Row 的定义对单例列表和至少有两个条目的列表的处理方式不同。 换句话说,当尝试将变量cs与具体的List构造子匹配时会卡住。 这就是为什么几乎每个拆分或构造 Row 的函数都需要与 Row 本身对应的三种情形:为了获得模式匹配或构造子可以使用的具体类型。

appendL中缺失的情形需要一个Vect α (Nat.plusL n k + 1)。 索引中的+ 1表明下一步是使用Vect.cons

def appendL : (n k : Nat) → Vect α n → Vect α k → Vect α (n.plusL k)
  | 0, k, .nil, ys => ys
  | n + 1, k, .cons x xs, ys => .cons x (_ : Vect α (n.plusL k))
don't know how to synthesize placeholder
context:
α : Type u_1
n k : Nat
x : α
xs : Vect α n
ys : Vect α k
⊢ Vect α (Nat.plusL n k)

一个对 appendL 的递归调用可以构造一个具有所需长度的 Vect

def appendL : (n k : Nat) → Vect α n → Vect α k → Vect α (n.plusL k)
  | 0, k, .nil, ys => ys
  | n + 1, k, .cons x xs, ys => .cons x (appendL n k xs ys)

既然程序完成了,删除对 nk 的显式匹配使得这个函数更容易阅读和调用:

def appendL : Vect α n → Vect α k → Vect α (n.plusL k)
  | .nil, ys => ys
  | .cons x xs, ys => .cons x (appendL xs ys)

比较类型使用定义相等意味着定义相等中涉及的所有内容,包括函数的内部定义,都成为使用依值类型和索引族的程序的接口的一部分。 在类型中暴露函数的内部实现意味着重构暴露的函数可能导致使用它的程序无法通过类型检查。 特别是,plusLappendL 的类型中使用的事实意味着 plusL 的使用不能被等价的 plusR 替换。

在加法上卡住

如果使用 plusR 定义 append 会发生什么? 让我们从头来过。使用显式长度并用占位符填充每种情形,会显示以下有用的错误消息:

def appendR : (n k : Nat) → Vect α n → Vect α k → Vect α (n.plusR k)
  | 0, k, .nil, ys => _
  | n + 1, k, .cons x xs, ys => _
don't know how to synthesize placeholder
context:
α : Type u_1
k : Nat
ys : Vect α k
⊢ Vect α (Nat.plusR 0 k)
don't know how to synthesize placeholder
context:
α : Type u_1
n k : Nat
x : α
xs : Vect α n
ys : Vect α k
⊢ Vect α (Nat.plusR (n + 1) k)

然而,尝试在第一个占位符上添加一个Vect α k类型注释会导致类型不匹配错误:

def appendR : (n k : Nat) → Vect α n → Vect α k → Vect α (n.plusR k)
  | 0, k, .nil, ys => (_ : Vect α k)
  | n + 1, k, .cons x xs, ys => _
type mismatch
  ?m.3036
has type
  Vect α k : Type ?u.2973
but is expected to have type
  Vect α (Nat.plusR 0 k) : Type ?u.2973

这个错误指出 plusR 0 kk 定义相等。

这是因为 plusR 有以下定义:

def Nat.plusR : Nat → Nat → Nat
  | n, 0 => n
  | n, k + 1 => plusR n k + 1

它的模式匹配发生在第二个参数上,而非第一个,这意味着该位置上的变量 k 阻止了它的规约。 Lean 标准库中的 Nat.add 等价于 plusR ,而不是 plusL ,因此尝试在这个定义中使用它会导致完全相同的问题:

def appendR : (n k : Nat) → Vect α n → Vect α k → Vect α (n + k)
  | 0, k, .nil, ys => (_ : Vect α k)
  | n + 1, k, .cons x xs, ys => _
type mismatch
  ?m.3068
has type
  Vect α k : Type ?u.2973
but is expected to have type
  Vect α (0 + k) : Type ?u.2973

加法在变量上卡住。 解决它需要命题相等

命题相等性

命题相等性是两个表达式相等的数学陈述。 Lean 在需要时会自动检查定义相等性,但命题相等性需要显式证明。 一旦一个相等命题被证明,它就可以在程序中被使用,从而将一个类型替换为等式另一侧的类型,从而解套卡住的类型检查器。

定义相等性只规定了很有限的相等性,所以它可以被算法自动地检查。 命题相等性要丰富得多,但计算机通常无法检查两个表达式是否命题相等,尽管它可以验证所谓的证明是否实际上是一个证明。 定义相等和命题相等之间的分裂代表了人类和机器之间的分工:最无聊的相等性作为定义相等的一部分被自动检查,从而使人类思维可以处理命题相等中可用的有趣问题。 同样,定义相等性由类型检查器自动调用,而命题相等必须明确地被调用。

命题、证明和索引中,一些相等性命题使用 simp 证明。 那里面的相等性命题实际上已经定义相等。 通常,命题相等性的证明是通过首先将它们变成定义相等或接近现有证明的相等性的形式,然后使用像 simp 这样的策术来处理简化后的情形。 simp 策术非常强大:它使用许多快速的自动化工具来构造证明。 一个更简单的策术叫做 rfl ,它专门使用定义相等来证明命题相等。 rfl 的名称来自**反射性(reflexivity)**的缩写,它是相等性的一个属性:一切都等于自己。

解决appendR需要一个证明,即k = Nat.plusR 0 k。它们并不定义相等,因为plusR在第二个参数的变量上卡住了。 为了让它计算,k必须是一个具体的构造子。 这时,我们可以使用模式匹配。

因为 k 可以是任何 Nat ,所以我们需要一个对任何 k 都能返回 k = Nat.plusR 0 k 的证据的函数。 它的类型应该为(k : Nat) → k = Nat.plusR 0 k。 进行模式匹配并输入占位符后得到以下信息:

def plusR_zero_left : (k : Nat) → k = Nat.plusR 0 k
  | 0 => _
  | k + 1 => _
don't know how to synthesize placeholder
context:
⊢ 0 = Nat.plusR 0 0
don't know how to synthesize placeholder
context:
k : Nat
⊢ k + 1 = Nat.plusR 0 (k + 1)

k 通过模式匹配细化为 0 后,第一个占位符需要一个定义相等的命题的证据。 使用 rfl 策术完成它,只留下第二个占位符:

def plusR_zero_left : (k : Nat) → k = Nat.plusR 0 k
  | 0 => by rfl
  | k + 1 => _

第二个占位符有点棘手。 表达式Nat.plusR 0 k + 1定义相等于Nat.plusR 0 (k + 1)。 这意味着目标也可以写成k + 1 = Nat.plusR 0 k + 1

def plusR_zero_left : (k : Nat) → k = Nat.plusR 0 k
  | 0 => by rfl
  | k + 1 => (_ : k + 1 = Nat.plusR 0 k + 1)
don't know how to synthesize placeholder
context:
k : Nat
⊢ k + 1 = Nat.plusR 0 k + 1

在等式命题两侧的 + 1 下面是函数本身返回的另一个实例。 换句话说,对 k 的递归调用将返回 k = Nat.plusR 0 k 的证据。 如果相等性不适用于函数参数,那么它就不是相等性。 换句话说,如果 x = y ,那么 f x = f y 。 标准库包含一个函数congrArg,它接受一个函数和一个相等性证明,并返回一个新的证明,其中函数已经应用于等式的两侧。 在这种情形下,函数是(· + 1)

def plusR_zero_left : (k : Nat) → k = Nat.plusR 0 k
  | 0 => by rfl
  | k + 1 =>
    congrArg (· + 1) (plusR_zero_left k)

命题相等性可以使用右三角运算符在程序中使用。 给定一个相等性证明作为第一个参数,另一个表达式作为第二个参数,这个运算符将第二个参数类型中等式左侧的实例替换为等式的右侧的实例。 换句话说,以下定义不会导致类型错误:

def appendR : (n k : Nat) → Vect α n → Vect α k → Vect α (n.plusR k)
  | 0, k, .nil, ys => plusR_zero_left k ▸ (_ : Vect α k)
  | n + 1, k, .cons x xs, ys => _

第一个占位符有预期的类型:

don't know how to synthesize placeholder
context:
α : Type u_1
k : Nat
ys : Vect α k
⊢ Vect α k

现在可以用ys填充它:

def appendR : (n k : Nat) → Vect α n → Vect α k → Vect α (n.plusR k)
  | 0, k, .nil, ys => plusR_zero_left k ▸ ys
  | n + 1, k, .cons x xs, ys => _

填充剩下的占位符需要解套另一个卡住的加法:

don't know how to synthesize placeholder
context:
α : Type u_1
n k : Nat
x : α
xs : Vect α n
ys : Vect α k
⊢ Vect α (Nat.plusR (n + 1) k)

这里,要证明的命题是 Nat.plusR (n + 1) k = Nat.plusR n k + 1,可以使用+ 1拉到表达式的顶部,使其与cons的索引匹配。

证明是一个递归函数,它对 plusR 的第二个参数 k 进行模式匹配。 这是因为 plusR 自身也是对第二个参数进行模式匹配,所以证明可以相同的模式匹配解套它,将计算行为暴露出来。 证明的框架与plusR_zero_left非常相似:

def plusR_succ_left (n : Nat) : (k : Nat) → Nat.plusR (n + 1) k = Nat.plusR n k + 1
  | 0 => by rfl
  | k + 1 => _

剩下的情形的类型在定义上等于 Nat.plusR (n + 1) k + 1 = Nat.plusR n (k + 1) + 1,因此可以像 plusR_zero_left 一样用 congrArg 解决:

don't know how to synthesize placeholder
context:
n k : Nat
⊢ Nat.plusR (n + 1) (k + 1) = Nat.plusR n (k + 1) + 1

证明就此完成

def plusR_succ_left (n : Nat) : (k : Nat) → Nat.plusR (n + 1) k = Nat.plusR n k + 1
  | 0 => by rfl
  | k + 1 => congrArg (· + 1) (plusR_succ_left n k)

完成的证明可以用来解套appendR中的第二个情形:

def appendR : (n k : Nat) → Vect α n → Vect α k → Vect α (n.plusR k)
  | 0, k, .nil, ys => plusR_zero_left k ▸ ys
  | n + 1, k, .cons x xs, ys => plusR_succ_left n k ▸ .cons x (appendR n k xs ys)

如果再次将 appendR 的长度参数改成隐式参数,它们在证明中也将不具有显示的名字。 然而,Lean 的类型检查器有足够的信息自动填充它们,只有唯一的值可以使类型匹配:

def appendR : Vect α n → Vect α k → Vect α (n.plusR k)
  | .nil, ys => plusR_zero_left _ ▸ ys
  | .cons x xs, ys => plusR_succ_left _ _ ▸ .cons x (appendR xs ys)

优势和劣势

索引族有一个重要的特性:对它们进行模式匹配会影响定义相等性。 例如,在Vect上的match表达式中的nil情形中,长度会直接变成0。 定义相等非常好用,因为它从不需要显式调用。

然而,使用依赖类型和模式匹配的定义相等在软件工程上有严重的缺点。 首先,在类型中使用的函数需要额外编写,同时在类型中方便使用的实现并不一定是一个高效的实现。 一旦一个函数在类型中被使用,它的实现就成为接口的一部分,导致未来重构困难。 其次,检查定义相等性可能会很慢。 当检查两个表达式是否定义相等时,如果相关的函数复杂并且有许多抽象层,Lean 可能需要运行大量代码。 第三,定义相等检查失败而报告的错误信息可能很难理解,因为它们通常包含了函数内部实现相关的信息。 并不总是容易理解错误消息中表达式的来源。 最后,在一组索引族和依赖类型函数中编码非平凡的不变性通常是脆弱的。 当函数的规约行为不能方便地提供需要的定义相等性时,通常需要更改系统中的早期定义。 另一种方法是在程序中的很多地方手动引入相等性的证明,但这样会变得非常麻烦。

在惯用的 Lean 代码中,带有索引的数据类型并不经常使用。 相反,子类型和显式命题通常用于保证重要的不变性。 这种方法涉及许多显式证明,而很少直接使用定义相等。 为了可以被用作一个交互式定理证明器,Lean 的很多设计是为了使显式证明方便。 一般来说,在大多数情况下,应该优先考虑这种方法。

然而,理解索引族是重要的。 诸如 plusR_zero_leftplusR_succ_left 之类的递归函数实际上是使用了数学归纳法的证明。 递归的基情形对应于归纳的基情形,递归调用则表示对归纳假设的使用。 更一般地说,Lean 中的新命题通常被定义为证据的归纳类型,这些归纳类型通常具有索引。 证明定理的过程实际上是在构造具有这些类型的表达式,这个过程与本节中的证明非常相似。 此外,索引数据类型有时确实是最佳选择。熟练掌握它们的使用是知道何时使用它们的一个重要部分。

练习

  • 使用类似于plusR_succ_left的递归函数,证明对于所有的Nat nkn.plusR k = n + k
  • 写一个在 Vect 上的函数,其中 plusRplusL 更自然:plusL 需要在定义中显示使用(命题相等性的)证明。

总结

依值类型

依值类型允许类型包含非类型代码,如函数调用和数据构造子,使类型系统的表达能力大大增强。 从参数的计算类型的能力意味着函数的返回类型可以根据提供的参数而变化。 例如,可以使数据库查询的结果的类型依赖于数据库模式和具体的查询,而无需对查询结果进行任何可能失败的强制类型转换操作。 当查询发生变化时,运行它得到的结果的类型也会发生变化,从而获得即时的编译时反馈。

当函数的返回类型取决于一个值时,使用模式匹配分析值可能导致类型被细化,因为代表值的变量被模式中的构造子替换。 函数的类型签名记录了返回类型如何取依赖于参数的值, 所以模式匹配解释了返回类型如何根据不同的潜在参数变成一个更具体的类型。

出现在类型中的普通代码在类型检查期间运行,其中可能导致无限循环的 partial 函数不会被调用。 大多数情况下,这种计算遵循了本书开头介绍的普通求值规则: 子表达式逐渐被其值替换,直到整个表达式变成了一个值。 类型检查期间的计算与运行时计算有一个重要的区别:类型中的一些值可能是变量,意味着这些值还未知。 在这些情况下,模式匹配会“卡住”,直到确定这个变量对应特定的构造子(例如通过对其进行模式匹配)。 类型级别的计算可以看作是一种部分求值:只求值完全已知的程序部分,剩下的部分则保持不变。

宇宙设计模式

一个在使用依值类型时常见的设计模式是将类型系统的某个子集显示地划分出来。 例如,数据库查询库可能能够返回可变长度的字符串、固定长度的字符串或某些范围内的数字,但它永远不会返回函数、用户定义的数据类型或IO操作。 一个领域特定的类型子集的定义方式如下:首先定义一个具有与所需类型结构匹配的构造子的数据类型,然后定义将这个数据类型的值解释为真实的类型一个函数。 这些构造子被称为所讨论类型的编码(codes),整个设计模式有时被称为 Tarski风格的宇宙设计模式。当上下文清楚地表明此时宇宙不指代Type 3Prop等时,可以简称为宇宙设计模式。

自定义宇宙,相比于类型类,是另一种划定一组感兴趣的类型的方式。 类型类是可扩展的,这种扩展性并非总是好的。 定义自定义宇宙相对于直接使用类型具有许多优点:

  • 可以通过对编码进行递归来实现对宇宙中包含的任意类型的通用操作,例如相等性测试和序列化。
  • 可以精确地表示外部系统接受的类型,并且编码的数据类型的定义相当于一个预期类型的文档。
  • Lean 的模式匹配完整性检查器确保没有遗漏对编码的处理, 而基于类型类的解决方案则在客户端代码的实际调用才能检查某些类型的类型类实例是否遗漏。

索引族

数据类型可以接受两种不同类型的参数:参量(parameter) 在每个构造子都是相同的,而 索引(index) 则可以在不同构造子间不同。 特定的索引意味着只有特定的构造子可用。 例如,Vect.nil 仅在长度索引为 0 时可用,而 Vect.cons 仅在长度索引为 n+1 时可用。 虽然参量通常以命名参数写在数据类型声明中的冒号前,索引写在冒号后(作为某个函数类型的参数),但 Lean 可以推断冒号后的参数何时被用作参量。

索引族允许表达数据之间的复杂关系,所有这些关系都由编译器检查。 数据类型的不变性可以直接通过索引族编码,从而保证这些不变性不会被(哪怕是暂时地)违反。 向编译器提供关于数据类型不变性的信息带来了一个重大好处:编译器现在可以告诉程序员必须做什么才能满足这些不变性。 通过刻意地触发编译期错误(特别是通过下划线占位符触发),可以将 “此时需要注意什么不变性” 的任务交给 Lean 思考 ,从而使程序员可以花更多心思担心其他事情。

使用索引族编码不变性有时会导致困难。 首先,每个不变性都需要自己的数据类型,然后需要自己的支持库。 毕竟,List.appendVect.append 是不能互换的。这都会导致代码重复。 其次,方便使用索引族需要类型中使用的函数的递归结构与被类型检查的程序的递归结构相匹配。 使用索引族编程正是“正确安排这些匹配发生”的艺术。 虽然可以通过手动引入相等性证明解决不匹配的巧合,但这件事并不容易,而且会导致程序中充斥着难懂的代码。 第三,类型检查期间运行过于复杂的代码可能导致很长的编译时间。避免这些复杂程序带来的编译减速可能需要专门的技术。

定义相等性和命题相等性

Lean的类型检查器必须不时检查两个类型是否应该被视为可互换的。 因为类型可以包含任意程序,所以它必须能够检查任意程序的相等性。 然而,没有有效的算法可以检查任意两个程序在数学意义上的相等性。 为了解决这个问题,Lean 引入了两种相等性的概念:

  • 定义相等性(Definitional equality) 是一个对程序相等性的近似:定义相等的程序一定相等,但反之不然。它基本只上检查(在允许计算和绑定变量重命名的意义下)语法表示的相等性。Lean 在需要时会自动检查定义相等。
  • 命题相等性(Propositional equality) 必须由程序员显式证明和显式调用。Lean 会自动检查证明是否正确,并检查对这种相等性的调用是否使得证明目标被完成。

这两种相等性概念代表了程序员和 Lean 之间的分工。 定义相等性简单但自动,命题相等性手动但表达力强。 命题相等性可以用于解套类型中的一些卡住的程序 (比如因为无法对变量求值)。

然而过于频繁地使用命题相等性来解套类型层面的卡住的计算 通常意味着代码是一段臭代码:这意味着没有很好地设计匹配。 这时较好的方案是重新设计类型和索引, 或者使用其他的技术来保证程序不变性。 当命题相等被用来证明程序满足规范, 或作为子类型的一部分时, 则是一种常见的模式。

小插曲:策略,归纳与证明

一个关于证明与用户界面的说明

本书展现了编写证明的过程,仿佛它们是一次就写就并交付给 Lean 运行似的,接着 Lean 会报错,描述剩余任务的错误信息。实际上,与 Lean 互动的过程要愉快得多。Lean 在光标移动时提供有关证明的信息,并且有许多互动功能使证明更容易。请查阅您的 Lean 开发环境的文档以获取更多信息。

本书中的方法侧重于逐步构建证明并显示产生的消息,这展示了 Lean 在编写证明时提供的各种互动反馈,尽管这比专家使用的过程慢得多。同时,看到不完整的证明逐步趋向完整是一种对证明有益的视角。随着您编写证明技能的提高,Lean 的反馈将不再感觉像错误,而更像是对您自己思维过程的支持。学习互动方法非常重要。

递归和归纳

上一章中的函数 plusR_succ_leftplusR_zero_left 可以从两个角度看待。从一方面看,它们是递归函数,构建了命题的证明,就像其他递归函数可能构建列表、字符串或任何其他数据结构一样。从另一方面上看,它们也对应于 数学归纳法 (Mathematical Induction) 的证明。

数学归纳是一种证明技术,通过两个步骤证明一个命题对所有自然数成立:

  1. 证明该命题对 0 成立。这称为 基本情况(Base Case)
  2. 在假设命题对某个任意选择的数 n 成立的前提下,证明它对 n+1 成立。这称为 归纳步骤(Induction Step)。假设命题对 n 成立的假设称为 归纳假设(Induction Hypothesis)

因为我们不可能对每个自然数进行检查,归纳提供了一种手段来编写原则上可以扩展到任何特定自然数的证明。例如,如果需要对数字 3 进行具体证明,那么可以首先使用基本情况,然后归纳步骤三次,分别证明命题对 0、1、2,最后对 3 成立。因此,它证明了该命题对所有自然数成立。

归纳策略

通过递归函数编写归纳证明,使用诸如 congrArg 之类的辅助函数并不总是能很好地表达证明背后的意图。虽然递归函数确实具有归纳的结构,但它们应该被视为一种证明的编码。此外,Lean 的策略系统提供了许多自动构建证明的机会,这是显式编写递归函数时无法实现的。Lean 提供了一种归纳策略,可以在单个策略块中完成整个归纳证明。在幕后,Lean 构建了对应于归纳使用的递归函数。

要使用归纳策略证明 plusR_zero_left,首先编写其签名(使用 定理(Theorem),因为这确实是一个证明)。然后,使用 by induction k 作为定义的主体:

theorem plusR_zero_left (k : Nat) : k = Nat.plusR 0 k := by
  induction k

产生的消息表明有两个目标:

unsolved goals
case zero
⊢ Nat.zero = Nat.plusR 0 Nat.zero

case succ
n✝ : Nat
n_ih✝ : n✝ = Nat.plusR 0 n✝
⊢ Nat.succ n✝ = Nat.plusR 0 (Nat.succ n✝)

策略块是在 Lean 类型检查器处理文件时运行的程序,有点像功能更强大的 C 预处理器宏。策略生成实际的程序。

在策略语言中,可能有多个目标。每个目标由类型和一些假设组成。这些类似于使用下划线作为占位符——目标中的类型表示要证明的内容,假设表示在作用域内且可以使用的内容。在 case zero 的目标中,没有假设,类型是 Nat.zero = Nat.plusR 0 Nat.zero ——这是定理陈述,其中 0 代替 k。在 case succ 的目标中,有两个假设,分别命名为 n✝n_ih✝。在幕后,归纳策略创建了一个依赖模式匹配来优化整体类型,n✝ 表示模式中 Nat.succ 的参数。假设 n_ih✝ 表示递归调用生成的函数在 n✝ 上的结果。其类型是定理的整体类型,只是用 n✝ 代替 kcase succ 目标的类型是定理陈述的整体,用 Nat.succ n✝ 代替 k

使用归纳策略得到的两个目标对应于数学归纳描述中的基本情况和归纳步骤。基本情况是 case zero。在 case succ 中,n_ih✝ 对应于归纳假设,而整个 case succ 是归纳步骤。

编写证明的下一步是依次关注两个目标中的每一个。就像在 do 块中使用 pure ( ) 来表示“什么也不做”一样,策略语言有一个语句 skip 也什么也不做。当 Lean 的语法需要一个策略时,但尚不清楚应该使用哪个策略时,可以使用 skip。将 with 添加到**归纳语句(Induction Statement)**的末尾提供了一种类似于模式匹配的语法:

theorem plusR_zero_left (k : Nat) : k = Nat.plusR 0 k := by
  induction k with
  | zero => skip
  | succ n ih => skip

每个 skip 语句都有一个与之关联的消息。第一个显示了基本情况:

unsolved goals
case zero
⊢ Nat.zero = Nat.plusR 0 Nat.zero

第二个显示了归纳步骤:

unsolved goals
case succ
n : Nat
ih : n = Nat.plusR 0 n
⊢ Nat.succ n = Nat.plusR 0 (Nat.succ n)

在归纳步骤中,不可访问的带匕首的名称已被提供的名称替换,分别为 succ 后的 nih

induction ... with 后的 cases 不是模式:它们由目标的名称和零个或多个名称组成。名称用于在目标中引入的假设;如果提供的名称超过目标引入的名称数,则会出现错误:

theorem plusR_zero_left (k : Nat) : k = Nat.plusR 0 k := by
  induction k with
  | zero => skip
  | succ n ih lots of names => skip

too many variable names provided at alternative 'succ', #5 provided, but #2 expected

关注基本情况,rfl 策略在 归纳策略(Induction Tactics) 中与在递归函数中一样有效:

theorem plusR_zero_left (k : Nat) : k = Nat.plusR 0 k := by
  induction k with
  | zero => rfl
  | succ n ih => skip

在递归函数版本的证明中,类型注释使得预期类型更容易理解。在策略语言中,有许多具体的方法可以转换目标,使其更容易解决。unfold 策略用其定义替换定义的名称:

theorem plusR_zero_left (k : Nat) : k = Nat.plusR 0 k := by
  induction k with
  | zero => rfl
  | succ n ih =>
    unfold Nat.plusR

现在,目标中等式的右侧已变为 Nat.plusR 0 n + 1 而不是 Nat.plusR 0 (Nat.succ n)

unsolved goals
case succ
n : Nat
ih : n = Nat.plusR 0 n
⊢ Nat.succ n = Nat.plusR 0 n + 1

代替使用诸如 congrArg 之类的函数和运算符,存在允许使用等式证明转换证明目标的策略。最重要的策略之一是 rw,它接受等式证明列表,并在目标中用右侧替换左侧。这几乎在 plusR_zero_left 中完成了正确的操作:

theorem plusR_zero_left (k : Nat) : k = Nat.plusR 0 k := by
  induction k with
  | zero => rfl
  | succ n ih =>
    unfold Nat.plusR
    rw [ih]

然而,重写的方向不正确。将 n 替换为 Nat.plusR 0 n 使得目标更复杂而不是更简单:

unsolved goals
case succ
n : Nat
ih : n = Nat.plusR 0 n
⊢ Nat.succ (Nat.plusR 0 n) = Nat.plusR 0 (Nat.plusR 0 n) + 1

通过在 重写(Rewrite) 调用中的 ih 前加一个左箭头,可以解决这个问题,指示它用左侧替换等式的右侧:

theorem plusR_zero_left (k : Nat) : k = Nat.plusR 0 k := by
  induction k with
  | zero => rfl
  | succ n ih =>
    unfold Nat.plusR
    rw [←ih]

这个重写使得等式的两边相同,Lean 会自己处理 rfl。证毕。

策略高尔夫

到目前为止,策略语言尚未显示出其真正的价值。上面的证明并不比递归函数短,只是用特定领域的语言而不是完整的 Lean 语言编写。但是,用策略编写的证明可以更短、更容易、更易维护。就像高尔夫比赛中分数越低越好一样,策略高尔夫比赛中的证明越短越好。

plusR_zero_left 的归纳步骤可以使用简化策略 simp 证明。单独使用 simp 并没有帮助:

theorem plusR_zero_left (k : Nat) : k = Nat.plusR 0 k := by
  induction k with
  | zero => rfl
  | succ n ih =>
    simp

simp made no progress

然而,simp 可以配置为使用一组定义。就像 rw 一样,这些参数在列表中提供。要求 simp 考虑 Nat.plusR 的定义导致一个更简单的目标:

theorem plusR_zero_left (k : Nat) : k = Nat.plusR 0 k := by
  induction k with
  | zero => rfl
  | succ n ih =>
    simp [Nat.plusR]

unsolved goals
case succ
n : Nat
ih : n = Nat.plusR 0 n
⊢ n = Nat.plusR 0 n

特别是,目标现在与归纳假设相同。除了自动证明简单的等式外,简化器还会自动将目标如 Nat.succ A = Nat.succ B 替换为 A = B。由于归纳假设 ih 具有完全正确的类型,exact 策略可以指示它应该被使用:

theorem plusR_zero_left (k : Nat) : k = Nat.plusR 0 k := by
  induction k with
  | zero => rfl
  | succ n ih =>
    simp [Nat.plusR]
    exact ih

然而,使用 exact 有点脆弱。重命名归纳假设(在“打高尔夫”证明时可能会发生)会导致此证明停止工作。假设策略解决了当前目标,如果任何假设与之匹配:

theorem plusR_zero_left (k : Nat) : k = Nat.plusR 0 k := by
  induction k with
  | zero => rfl
  | succ n ih =>
    simp [Nat.plusR]
    assumption

这个证明并不比使用展开和显式重写的先前证明短。然而,一系列变换可以使它更短,利用 simp 可以解决许多类型的目标这一事实。第一步是去掉归纳末尾的 with。对于结构化、可读的证明,with 语法是方便的。如果缺少任何情况,它会抱怨,并且它清楚地显示归纳的结构。但是缩短证明通常需要更宽松的方法。

使用不带 with 的归纳仅会产生两个目标。case 策略可以像在induction...with 策略的分支中一样选择其中一个目标。换句话说,以下证明等同于前一个证明:

theorem plusR_zero_left (k : Nat) : k = Nat.plusR 0 k := by
  induction k
  case zero => rfl
  case succ n ih =>
    simp [Nat.plusR]
    assumption

在具有单个目标的上下文中(即 k = Nat.plusR 0 k),归纳 k 策略产生两个目标。通常,策略要么失败并产生错误,要么接受一个目标并将其转换为零个或多个新目标。每个新目标表示剩下要证明的内容。如果结果是零个目标,则策略成功,该部分证明完成。

<;> 运算符接受两个策略作为参数,生成一个新策略。T1 <;> T2T1 应用于当前目标,然后在 T1 创建的所有目标中应用 T2。换句话说,<;> 允许通用策略一次性用于多个新目标。一个这样的通用策略是 simp

因为 simp 既可以完成基本情况的证明,又可以在归纳步骤的证明中取得进展,所以使用它与归纳和 <;> 缩短了证明:

theorem plusR_zero_left (k : Nat) : k = Nat.plusR 0 k := by
  induction k <;> simp [Nat.plusR]

这仅产生一个目标,即转换后的归纳步骤:

unsolved goals
case succ
n✝ : Nat
n_ih✝ : n✝ = Nat.plusR 0 n✝
⊢ n✝ = Nat.plusR 0 n✝

在这个目标中运行 assumption 完成了证明:

theorem plusR_zero_left (k : Nat) : k = Nat.plusR 0 k := by
  induction k <;> simp [Nat.plusR] <;> assumption

在这里,exact 是不可能的,因为 ih 从未被显式命名。

对于初学者来说,这个证明并不容易阅读。然而,专家用户的常见模式是使用像 simp 这样的强大策略处理一些简单情况,使他们可以将证明的文本集中在有趣的情况下。此外,这些证明在面对函数和数据类型的小变化时往往更稳健。策略高尔夫游戏是培养编写证明时的良好品味和风格的有用部分。

其他数据类型的归纳

数学归纳通过为 Nat.zero 提供基本情况和为 Nat.succ 提供归纳步骤来证明自然数的命题。归纳原则对于其他数据类型也是有效的。没有递归参数的构造函数形成基本情况,而具有递归参数的构造函数形成归纳步骤。进行归纳证明的能力是它们被称为归纳数据类型的原因。

这方面的一个例子是对二叉树的归纳。对二叉树进行归纳是一种证明技术,通过两个步骤证明一个命题对所有二叉树成立:

  1. 证明该命题对 BinTree.leaf 成立。这称为基本情况。
  2. 在假设该命题对某些任意选择的树 lr 成立的前提下,证明它对 BinTree.branch l x r 成立,其中 x 是任意选择的新数据点。这称为归纳步骤。假设该命题对 lr 成立的假设称为归纳假设。

BinTree.count 计算树中分支的数量:

def BinTree.count : BinTree α → Nat
  | .leaf => 0
  | .branch l _ r =>
    1 + l.count + r.count

镜像树不会改变树中的分支数量。可以通过对树进行归纳证明这一点。第一步是声明定理并调用归纳:

theorem BinTree.mirror_count (t : BinTree α) : t.mirror.count = t.count := by
  induction t with
  | leaf => skip
  | branch l x r ihl ihr => skip

基本情况表明,计算镜像叶子的数量与计算叶子相同:

unsolved goals
case leaf
α : Type
⊢ count (mirror leaf) = count leaf

归纳步骤允许假设镜像左右子树不会影响其分支计数,并要求证明镜像具有这些子树的分支也保留整体分支计数:

unsolved goals
case branch
α : Type
l : BinTree α
x : α
r : BinTree α
ihl : count (mirror l) = count l
ihr : count (mirror r) = count r
⊢ count (mirror (branch l x r)) = count (branch l x r)

基本情况成立,因为镜像 leaf 结果为 leaf,因此左右两边定义上相等。这可以通过使用带有展开 BinTree.mirror 指令的 simp 表达:

theorem BinTree.mirror_count (t : BinTree α) : t.mirror.count = t.count := by
  induction t with
  | leaf => simp [BinTree.mirror]
  | branch l x r ihl ihr => skip

在归纳步骤中,目标中没有任何东西与归纳假设立即匹配。使用 BinTree.countBinTree.mirror 的定义简化显示了关系:

theorem BinTree.mirror_count (t : BinTree α) : t.mirror.count = t.count := by
  induction t with
  | leaf => simp [BinTree.mirror]
  | branch l x r ihl ihr =>
    simp [BinTree.mirror, BinTree.count]

unsolved goals
case branch
α : Type
l : BinTree α
x : α
r : BinTree α
ihl : count (mirror l) = count l
ihr : count (mirror r) = count r
⊢ 1 + count (mirror r) + count (mirror l) = 1 + count l + count r

可以使用两个归纳假设重写目标的左侧,使其与右侧几乎相同:

theorem BinTree.mirror_count (t : BinTree α) : t.mirror.count = t.count := by
  induction t with
  | leaf => simp [BinTree.mirror]


  | branch l x r ihl ihr =>
    simp [BinTree.mirror, BinTree.count]
    rw [ihl, ihr]

unsolved goals
case branch
α : Type
l : BinTree α
x : α
r : BinTree α
ihl : count (mirror l) = count l
ihr : count (mirror r) = count r
⊢ 1 + count r + count l = 1 + count l + count r

使用 simp_arith 策略(一个可以使用额外算术等式的 simp 版本)足以证明此目标,从而得到:

theorem BinTree.mirror_count (t : BinTree α) : t.mirror.count = t.count := by
  induction t with
  | leaf => simp [BinTree.mirror]
  | branch l x r ihl ihr =>
    simp [BinTree.mirror, BinTree.count]
    rw [ihl, ihr]
    simp_arith

除了要展开的定义外,简化器还可以传递等式证明的名称以在简化证明目标时用作重写。BinTree.mirror_count 还可以这样写:

theorem BinTree.mirror_count (t : BinTree α) : t.mirror.count = t.count := by
  induction t with
  | leaf => simp [BinTree.mirror]
  | branch l x r ihl ihr =>
    simp_arith [BinTree.mirror, BinTree.count, ihl, ihr]

随着证明变得更加复杂,手动列出假设会变得繁琐。此外,手动编写假设名称可能会使重复使用证明步骤来处理多个子目标变得更加困难。simpsimp_arith 的参数 * 指示它们在简化或解决目标时使用所有假设。换句话说,证明也可以这样写:

theorem BinTree.mirror_count (t : BinTree α) : t.mirror.count = t.count := by
  induction t with
  | leaf => simp [BinTree.mirror]
  | branch l x r ihl ihr =>
    simp_arith [BinTree.mirror, BinTree.count, *]

因为两个分支都在使用简化器,证明可以简化为:

theorem BinTree.mirror_count (t : BinTree α) : t.mirror.count = t.count := by
  induction t <;> simp_arith [BinTree.mirror, BinTree.count, *]

练习

  1. 使用induction...with 策略证明 plusR_succ_left
  2. 重写 plus_succ_left 的证明,使用 <;> 并写成一行。
  3. 使用列表归纳证明列表追加是结合的:theorem List.append_assoc (xs ys zs : List α) : xs ++ (ys ++ zs) = (xs ++ ys) ++ zs

编程、证明与性能

本章是关于编程的。程序不仅需要计算出正确的结果,还需要高效地执行。 为了编写高效的功能程序,了解如何适当地使用数据结构, 以及如何考虑运行程序所需的时间和空间非常重要。

本章也是关于证明的。在 Lean 中进行高效编程最重要的数据结构体之一是数组, 但安全使用数组需要证明数组索引在边界内。此外,大多数有趣的数组算法并不遵循结构化递归模式。 相反,它们会遍历数组。虽然这些算法会停机,但 Lean 不一定能够自动检查这一点。 证明可以用来展示程序为什么会停机。

重写程序使其运行得更快通常会导致代码更难理解。证明还可以表明两个程序始终会计算出相同的答案, 即使它们使用不同的算法或实现技术。通过这种方式,缓慢、直白的程序可以作为快速、复杂版本的规范。

将证明和编程相结合,可以使程序既安全又高效。证明允许省略运行时边界检查, 它们使许多测试变得不必要,并且它们在不引入任何运行时性能开销的情况下为程序提供了极高的置信度。 然而,证明程序的定理可能是耗时且昂贵的,因此其他工具通常更经济。

交互式定理证明是一个深刻的话题。本章仅提供一个示例,面向在 Lean 中编程时出现的证明。 大多数有趣的定理与编程没有密切关系。请参阅 下一步 以获取更多学习资源的列表。 然而,就像学习编程一样,在学习编写证明时,没有什么是可以替代实践经验的——是时候开始了!

尾递归

虽然 Lean 的 do-记法允许使用传统的循环语法,例如 forwhile, 但这些结构在幕后会被翻译为递归函数的调用。在大多数编程语言中, 递归函数相对于循环有一个关键缺点:循环不消耗堆栈空间, 而递归函数消耗与递归调用次数成正比的栈空间。栈空间通常是有限的, 通常有必要将以递归函数自然表达的算法,重写为用显式可变堆来分配栈的循环。

在函数式编程中,情况通常相反。以可变循环自然表达的程序可能会消耗栈空间, 而将它们重写为递归函数可以使它们快速运行。这是函数式编程语言的一个关键方面: 尾调用消除(Tail-call Elimination)。尾调用是从一个函数到另一个函数的调用, 可以编译成一个普通的跳转,替换当前的栈帧而非压入一个新的栈帧, 而尾调用消除就是实现此转换的过程。

尾调用消除不仅仅是一种可选的优化。它的存在是编写高效函数式代码的基础部分。 为了使其有效,它必须是可靠的。程序员必须能够可靠地识别尾调用, 并且他们必须相信编译器会消除它们。

函数 NonTail.sumNat 列表的内容加起来:

def NonTail.sum : List Nat → Nat
  | [] => 0
  | x :: xs => x + sum xs

将此函数应用于列表 [1, 2, 3] 会产生以下求值步骤:

NonTail.sum [1, 2, 3]
===>
1 + (NonTail.sum [2, 3])
===>
1 + (2 + (NonTail.sum [3]))
===>
1 + (2 + (3 + (NonTail.sum [])))
===>
1 + (2 + (3 + 0))
===>
1 + (2 + 3)
===>
1 + 5
===>
6

在求值步骤中,括号表示对 NonTail.sum 的递归调用。换句话说,要加起来这三个数字, 程序必须首先检查列表是否非空。要将列表的头部(1)加到列表尾部的和上, 首先需要计算列表尾部的和:

1 + (NonTail.sum [2, 3])

但是要计算列表尾部的和,程序必须检查它是否为空,然而它不是。 尾部本身是一个列表,头部为 2。结果步骤正在等待 NonTail.sum [3] 的返回:

1 + (2 + (NonTail.sum [3]))

运行时调用栈的重点在于跟踪值 123,以及一个指令将它们加到递归调用的结果上。 随着递归调用的完成,控制权返回到发出调用的栈帧,于是每一步的加法都被执行了。 存储列表的头部和将它们相加的指令并不是免费的;它占用的空间与列表的长度成正比。

函数 Tail.sum 也将 Nat 列表的内容加起来:

def Tail.sumHelper (soFar : Nat) : List Nat → Nat
  | [] => soFar
  | x :: xs => sumHelper (x + soFar) xs

def Tail.sum (xs : List Nat) : Nat :=
  Tail.sumHelper 0 xs

将其应用于列表 [1, 2, 3] 会产生以下求值步骤:

Tail.sum [1, 2, 3]
===>
Tail.sumHelper 0 [1, 2, 3]
===>
Tail.sumHelper (0 + 1) [2, 3]
===>
Tail.sumHelper 1 [2, 3]
===>
Tail.sumHelper (1 + 2) [3]
===>
Tail.sumHelper 3 [3]
===>
Tail.sumHelper (3 + 3) []
===>
Tail.sumHelper 6 []
===>
6

The internal helper function calls itself recursively, but it does so in a way where nothing needs to be remembered in order to compute the final result. When Tail.sumHelper reaches its base case, control can be returned directly to Tail.sum, because the intermediate invocations of Tail.sumHelper simply return the results of their recursive calls unmodified. In other words, a single stack frame can be re-used for each recursive invocation of Tail.sumHelper. Tail-call elimination is exactly this re-use of the stack frame, and Tail.sumHelper is referred to as a tail-recursive function. 内部的辅助函数以递归方式调用自身,但它以一种新的方式执行此操作,无需记住任何内容即可计算最终结果。 当 Tail.sumHelper 达到其基本情况时,控制权可以直接返回到 Tail.sum, 因为 Tail.sumHelper 的中间调用只是返回其递归调用的结果,而不会修改结果。 换句话说,可以为 Tail.sumHelper 的每个递归调用重新使用一个栈帧。 尾调用消除正是这种栈帧的重新使用,而 Tail.sumHelper 被称为 尾递归函数(Tail-Recursive Function)

Tail.sumHelper 的第一个参数包含所有其他需要在调用栈中跟踪的信息,即到目前为止遇到的数字的总和。 在每个递归调用中,此参数都会使用新信息进行更新,而非将新信息添加到调用栈中。 替换调用栈中信息的 soFar 等参数称为 累加器(Accumulator)

在撰写本文时,在作者的计算机上,当传入包含 216,856 个或更多条目的列表时, NonTail.sum 会因栈溢出而崩溃。另一方面,Tail.sum 可以对包含 100,000,000 个元素的列表求和,而不会发生栈溢出。由于在运行 Tail.sum 时不需要压入新的栈帧,因此它完全等同于一个 while 循环,其中一个可变变量保存当前列表。 在每次递归调用中,栈上的函数参数都会被简单地替换为列表的下一个节点。

尾部与非尾部位置

Tail.sumHelper 是尾递归的原因在于递归调用处于 尾部位置。 非正式地说,当调用者不需要以任何方式修改返回值,而是会直接返回它时, 函数调用就处于尾部位置。更正式地说,尾部位置可以显式地为表达式定义。

如果 match-表达式处于尾部位置,那么它的每个分支也处于尾部位置。 一旦 match 选择了一个分支,控制权就会立即传递给它。 与此类似,如果 if 表达式本身处于尾部位置,那么 if 表达式的两个分支都处于尾部位置。 最后,如果 let 表达式处于尾部位置,那么它的主体也是如此。

所有其他位置都不处于尾部位置。函数或构造子的参数不处于尾部位置, 因为求值必须跟踪会应用到参数值的函数或构造子。内部函数的主体不处于尾部位置, 因为控制权甚至可能不会传递给它:函数主体在函数被调用之前不会被求值。 与此类似,函数类型的函数主体不处于尾部位置。要求值 (x : α) → E 中的 E, 就有必要跟踪结果,结果的类型必须有 (x : α) → ... 包裹在其周围。

NonTail.sum 中,递归调用不在尾部位置,因为它是一个 + 的参数。 在 Tail.sumHelper 中,递归调用在尾部位置,因为它紧跟在模式匹配之后, 而模式匹配本身是函数的主体。

在撰写本文时,Lean 仅消除了递归函数中的直接尾部调用。 这意味着在 f 的定义中对 f 的尾部调用将被消除,但对其他函数 g 的尾部调用不会被消除。 当然,消除其他函数的尾部调用以节省栈帧是可行的,但这尚未在 Lean 中实现。

反转列表

函数 NonTail.reverse 通过将每个子列表的头部追加到结果的末尾来反转列表:

def NonTail.reverse : List α → List α
  | [] => []
  | x :: xs => reverse xs ++ [x]

使用它来反转 [1, 2, 3] 会产生以下步骤:

NonTail.reverse [1, 2, 3]
===>
(NonTail.reverse [2, 3]) ++ [1]
===>
((NonTail.reverse [3]) ++ [2]) ++ [1]
===>
(((NonTail.reverse []) ++ [3]) ++ [2]) ++ [1]
===>
(([] ++ [3]) ++ [2]) ++ [1]
===>
([3] ++ [2]) ++ [1]
===>
[3, 2] ++ [1]
===>
[3, 2, 1]

尾递归版本会在每一步的累加器上使用 x :: · 而非 · ++ [x]

def Tail.reverseHelper (soFar : List α) : List α → List α
  | [] => soFar
  | x :: xs => reverseHelper (x :: soFar) xs

def Tail.reverse (xs : List α) : List α :=
  Tail.reverseHelper [] xs

这是因为在使用 NonTail.reverse 计算时保存在每个栈帧中的上下文从基本情况开始应用。 每个「记住的」上下文片段都按照后进先出的顺序执行。 另一方面,累加器传递版本从列表中的第一个条目而非原始基本情况开始修改累加器, 如以下归约步骤所示:

Tail.reverse [1, 2, 3]
===>
Tail.reverseHelper [] [1, 2, 3]
===>
Tail.reverseHelper [1] [2, 3]
===>
Tail.reverseHelper [2, 1] [3]
===>
Tail.reverseHelper [3, 2, 1] []
===>
[3, 2, 1]

换句话说,非尾递归版本从基本情况开始,从右到左通过列表修改递归结果。 列表中的条目以先入先出的顺序影响累加器。带有累加器的尾递归版本从列表的头部开始, 从左到右通过列表修改初始累加器值。

由于加法满足交换律,因此无需在 Tail.sum 中对此进行处理。 列表追加不满足交换律,因此必须注意要找到一个在相反方向运行时具有相同效果的操作。 在 NonTail.reverse 中,在递归结果后追加 [x] 与以逆序构建结果时将 x 添加到列表开头类似。

多个递归调用

BinTree.mirror 的定义中,有两个递归调用:

def BinTree.mirror : BinTree α → BinTree α
  | .leaf => .leaf
  | .branch l x r => .branch (mirror r) x (mirror l)

就像命令式语言通常会对 reversesum 等函数使用 while 循环一样, 它们通常会对这种遍历使用递归函数。此函数无法直接通过累加器传递风格重写为尾递归函数。

通常,如果每个递归步骤需要多个递归调用,那么将很难使用累加器传递样式。 这种困难类似于使用循环和显式数据结构重写递归函数的困难,增加了说服 Lean 函数终止的复杂性。 但是,就像在 BinTree.mirror 中一样,多个递归调用通常表示一个数据结构, 其构造子具有多次递归出现的情况。在这些情况下,结构的深度通常与其整体大小成对数关系, 这使得栈和堆之间的权衡不那么明显。有一些系统化的技术可以使这些函数成为尾递归, 例如使用 续体传递风格(Continuation-Passing Style),但它们超出了本章的范围。

练习

将以下所有非尾递归的函数翻译成累加器传递的尾递归函数:

def NonTail.length : List α → Nat
  | [] => 0
  | _ :: xs => NonTail.length xs + 1
def NonTail.factorial : Nat → Nat
  | 0 => 1
  | n + 1 => factorial n * (n + 1)

NonTail.filter 的翻译应当产生一个程序,它通过尾递归占用常量栈空间, 运行时间与输入列表的长度成线性相关。相对于原始情况来说,常数因子的开销是可以接受的:

def NonTail.filter (p : α → Bool) : List α → List α
  | [] => []
  | x :: xs =>
    if p x then
      x :: filter p xs
    else
      filter p xs

证明等价

重写为使用尾递归和累加器的程序可能看起来与原始程序非常不同。 原始递归函数通常更容易理解,但它有在运行时耗尽栈的风险。 在用示例测试程序的两个版本以排除简单错误后,可以使用证明来一劳永逸地证明二者是等价的。

证明 sum 相等

要证明 sum 的两个版本相等,首先用桩(stub)证明编写定理的陈述:

theorem non_tail_sum_eq_tail_sum : NonTail.sum = Tail.sum := by
  skip

正如预期,Lean 描述了一个未解决的目标:

unsolved goals
⊢ NonTail.sum = Tail.sum

rfl 策略无法在此处应用,因为 NonTail.sumTail.sum 在定义上不相等。 然而,函数除了定义相等外还存在更多相等的方式。还可以通过证明两个函数对相同输入产生相等输出, 来证明它们相等。换句话说,可以通过证明「对于所有可能的输入 \( x \), 都有 \( f(x) = g(x) \)」来证明 \( f = g \)。此原理称为 函数外延性(Function Extensionality)。 函数外延性正是 NonTail.sum 等于 Tail.sum 的原因:它们都对数字列表求和。

在 Lean 的策略语言中,可使用 funext 调用函数外延性,后跟一个用于任意参数的名称。 任意参数会作为假设添加到语境中,目标变为证明应用于此参数的函数相等:

theorem non_tail_sum_eq_tail_sum : NonTail.sum = Tail.sum := by
  funext xs
unsolved goals
case h
xs : List Nat
⊢ NonTail.sum xs = Tail.sum xs

此目标可通过对参数 xs 进行归纳来证明。当应用于空列表时,sum 函数都返回 0,这是基本情况。 在输入列表的开头添加一个数字会让两个函数都将该数字添加到结果中,这是归纳步骤。 调用 induction 策略会产生两个目标:

theorem non_tail_sum_eq_tail_sum : NonTail.sum = Tail.sum := by
  funext xs
  induction xs with
  | nil => skip
  | cons y ys ih => skip
unsolved goals
case h.nil
⊢ NonTail.sum [] = Tail.sum []
unsolved goals
case h.cons
y : Nat
ys : List Nat
ih : NonTail.sum ys = Tail.sum ys
⊢ NonTail.sum (y :: ys) = Tail.sum (y :: ys)

nil 的基本情况可以使用 rfl 解决,因为当传递空列表时,两个函数都返回 0

theorem non_tail_sum_eq_tail_sum : NonTail.sum = Tail.sum := by
  funext xs
  induction xs with
  | nil => rfl
  | cons y ys ih => skip

解决归纳步骤的第一步是简化目标,要求 simp 展开 NonTail.sumTail.sum

theorem non_tail_sum_eq_tail_sum : NonTail.sum = Tail.sum := by
  funext xs
  induction xs with
  | nil => rfl
  | cons y ys ih =>
    simp [NonTail.sum, Tail.sum]
unsolved goals
case h.cons
y : Nat
ys : List Nat
ih : NonTail.sum ys = Tail.sum ys
⊢ y + NonTail.sum ys = Tail.sumHelper 0 (y :: ys)

展开 Tail.sum 会发现它直接委托给了 Tail.sumHelper,它也应该被简化:

theorem non_tail_sum_eq_tail_sum : NonTail.sum = Tail.sum := by
  funext xs
  induction xs with
  | nil => rfl
  | cons y ys ih =>
    simp [NonTail.sum, Tail.sum, Tail.sumHelper]

在结果目标中,sumHelper 执行了一步计算并将 y 加到累加器上:

unsolved goals
case h.cons
y : Nat
ys : List Nat
ih : NonTail.sum ys = Tail.sum ys
⊢ y + NonTail.sum ys = Tail.sumHelper y ys

使用归纳假设重写会从目标中删除所有 NonTail.sum 的引用:

theorem non_tail_sum_eq_tail_sum : NonTail.sum = Tail.sum := by
  funext xs
  induction xs with
  | nil => rfl
  | cons y ys ih =>
    simp [NonTail.sum, Tail.sum, Tail.sumHelper]
    rw [ih]
unsolved goals
case h.cons
y : Nat
ys : List Nat
ih : NonTail.sum ys = Tail.sum ys
⊢ y + Tail.sum ys = Tail.sumHelper y ys

这个新目标表明,将某个数字加到列表的和中与在 sumHelper 中使用该数字作为初始累加器相同。 为了清晰起见,这个新目标可以作为独立的定理来证明:

theorem helper_add_sum_accum (xs : List Nat) (n : Nat) :
    n + Tail.sum xs = Tail.sumHelper n xs := by
  skip
unsolved goals
xs : List Nat
n : Nat
⊢ n + Tail.sum xs = Tail.sumHelper n xs

这又是一次归纳证明,其中基本情况使用 rfl 证明:

theorem helper_add_sum_accum (xs : List Nat) (n : Nat) :
    n + Tail.sum xs = Tail.sumHelper n xs := by
  induction xs with
  | nil => rfl
  | cons y ys ih => skip
unsolved goals
case cons
n y : Nat
ys : List Nat
ih : n + Tail.sum ys = Tail.sumHelper n ys
⊢ n + Tail.sum (y :: ys) = Tail.sumHelper n (y :: ys)

由于这是一个归纳步骤,因此目标应该被简化,直到它与归纳假设 ih 匹配。 简化,然后使用 Tail.sumTail.sumHelper 的定义,得到以下结果:

theorem helper_add_sum_accum (xs : List Nat) (n : Nat) :
    n + Tail.sum xs = Tail.sumHelper n xs := by
  induction xs with
  | nil => rfl
  | cons y ys ih =>
    simp [Tail.sum, Tail.sumHelper]
unsolved goals
case cons
n y : Nat
ys : List Nat
ih : n + Tail.sum ys = Tail.sumHelper n ys
⊢ n + Tail.sumHelper y ys = Tail.sumHelper (y + n) ys

理想情况下,归纳假设可以用来替换 Tail.sumHelper (y + n) ys,但它们不匹配。 归纳假设可用于 Tail.sumHelper n ys,而非 Tail.sumHelper (y + n) ys。 换句话说,这个证明到这里被卡住了。

第二次尝试

与其试图弄清楚证明,不如退一步思考。为什么函数的尾递归版本等于非尾递归版本? 从根本上讲,在列表中的每个条目中,累加器都会增加与递归结果中添加的量相同的值。 这个见解可以用来写一个优雅的证明。 重点在于,归纳证明必须设置成归纳假设可以应用于 任何 累加器值。

放弃之前的尝试,这个见解可以编码为以下陈述:

theorem non_tail_sum_eq_helper_accum (xs : List Nat) :
    (n : Nat) → n + NonTail.sum xs = Tail.sumHelper n xs := by
  skip

在这个陈述中,非常重要的是 n 是冒号后面类型的组成部分。 产生的目标以 ∀ (n : Nat) 开头,这是「对于所有 n」的缩写:

unsolved goals
xs : List Nat
⊢ ∀ (n : Nat), n + NonTail.sum xs = Tail.sumHelper n xs

使用归纳策略会产生包含这个「对于所有(for all)」语句的目标:

theorem non_tail_sum_eq_helper_accum (xs : List Nat) :
    (n : Nat) → n + NonTail.sum xs = Tail.sumHelper n xs := by
  induction xs with
  | nil => skip
  | cons y ys ih => skip

nil 情况下,目标是:

unsolved goals
case nil
⊢ ∀ (n : Nat), n + NonTail.sum [] = Tail.sumHelper n []

对于 cons 的归纳步骤,归纳假设和具体目标都包含「对于所有 n」:

unsolved goals
case cons
y : Nat
ys : List Nat
ih : ∀ (n : Nat), n + NonTail.sum ys = Tail.sumHelper n ys
⊢ ∀ (n : Nat), n + NonTail.sum (y :: ys) = Tail.sumHelper n (y :: ys)

换句话说,目标变得更难证明,但归纳假设相应地更加有用。

对于以「对于所有 \( x \)」开头的陈述的数学证明应该假设存在任意的 \( x \), 并证明该阐述。「任意」意味着不假设 \( x \) 的任何额外性质,因此结果语句将适用于 任何 \( x \)。 在 Lean 中,「对于所有」语句是一个依值函数:无论将其应用于哪个特定值,它都将返回命题的证据。 类似地,选择任意 \( x \) 的过程与使用 fun x => ... 相同。在策略语言中, 选择任意 \( x \) 的过程是使用 intro 策略执行的,当策略脚本完成后,它会在幕后生成函数。 intro 策略应当被提供用于此任意值的名称。

nil 情况下使用 intro 策略会从目标中移除 ∀ (n : Nat),,并添加假设 n : Nat

theorem non_tail_sum_eq_helper_accum (xs : List Nat) :
    (n : Nat) → n + NonTail.sum xs = Tail.sumHelper n xs := by
  induction xs with
  | nil => intro n
  | cons y ys ih => skip
unsolved goals
case nil
n : Nat
⊢ n + NonTail.sum [] = Tail.sumHelper n []

此命题等式的两边在定义上等于 n,因此 rfl 就足够了:

theorem non_tail_sum_eq_helper_accum (xs : List Nat) :
    (n : Nat) → n + NonTail.sum xs = Tail.sumHelper n xs := by
  induction xs with
  | nil =>
    intro n
    rfl
  | cons y ys ih => skip

cons 目标也包含一个「对于所有」:

unsolved goals
case cons
y : Nat
ys : List Nat
ih : ∀ (n : Nat), n + NonTail.sum ys = Tail.sumHelper n ys
⊢ ∀ (n : Nat), n + NonTail.sum (y :: ys) = Tail.sumHelper n (y :: ys)

这这里建议使用 intro

theorem non_tail_sum_eq_helper_accum (xs : List Nat) :
    (n : Nat) → n + NonTail.sum xs = Tail.sumHelper n xs := by
  induction xs with
  | nil =>
    intro n
    rfl
  | cons y ys ih =>
    intro n
unsolved goals
case cons
y : Nat
ys : List Nat
ih : ∀ (n : Nat), n + NonTail.sum ys = Tail.sumHelper n ys
n : Nat
⊢ n + NonTail.sum (y :: ys) = Tail.sumHelper n (y :: ys)

现在,证明目标包含应用于 y :: ysNonTail.sumTail.sumHelper。 简化器可以使下一步更清晰:

theorem non_tail_sum_eq_helper_accum (xs : List Nat) :
    (n : Nat) → n + NonTail.sum xs = Tail.sumHelper n xs := by
  induction xs with
  | nil =>
    intro n
    rfl
  | cons y ys ih =>
    intro n
    simp [NonTail.sum, Tail.sumHelper]
unsolved goals
case cons
y : Nat
ys : List Nat
ih : ∀ (n : Nat), n + NonTail.sum ys = Tail.sumHelper n ys
n : Nat
⊢ n + (y + NonTail.sum ys) = Tail.sumHelper (y + n) ys

此目标非常接近于匹配归纳假设。它不匹配的方面有两个:

  • 等式的左侧是 n + (y + NonTail.sum ys),但归纳假设需要左侧是一个添加到 NonTail.sum ys 的数字。 换句话说,此目标应重写为 (n + y) + NonTail.sum ys,这是有效的,因为自然数加法满足结合律。
  • 当左侧重写为 (y + n) + NonTail.sum ys 时,右侧的累加器参数应为 n + y 而非 y + n 以进行匹配。 此重写是有效的,因为加法也满足交换律。

The associativity and commutativity of addition have already been proved in Lean's standard library. The proof of associativity is named Nat.add_assoc, and its type is (n m k : Nat) → (n + m) + k = n + (m + k), while the proof of commutativity is called Nat.add_comm and has type (n m : Nat) → n + m = m + n. Normally, the rw tactic is provided with an expression whose type is an equality. However, if the argument is instead a dependent function whose return type is an equality, it attempts to find arguments to the function that would allow the equality to match something in the goal. There is only one opportunity to apply associativity, though the direction of the rewrite must be reversed because the right side of the equality in Nat.add_assoc is the one that matches the proof goal:

加法的结合律和交换律已在 Lean 的标准库中得到证明。结合律的证明名为 Nat.add_assoc, 其类型为 (n m k : Nat) → (n + m) + k = n + (m + k), 而交换律的证明称为 Nat.add_comm, 其类型为 (n m : Nat) → n + m = m + n。 通常,rw 策略会提供一个类型为等式的表达式。但是,如果参数是一个返回类型为等式的相关函数, 它会尝试查找函数的参数,以便等式可以匹配目标中的某个内容。 虽然必须反转重写方向,但只有一种机会应用结合律, 因为 Nat.add_assoc 中等式的右侧是与证明目标匹配的:

theorem non_tail_sum_eq_helper_accum (xs : List Nat) :
    (n : Nat) → n + NonTail.sum xs = Tail.sumHelper n xs := by
  induction xs with
  | nil =>
    intro n
    rfl
  | cons y ys ih =>
    intro n
    simp [NonTail.sum, Tail.sumHelper]
    rw [←Nat.add_assoc]
unsolved goals
case cons
y : Nat
ys : List Nat
ih : ∀ (n : Nat), n + NonTail.sum ys = Tail.sumHelper n ys
n : Nat
⊢ n + y + NonTail.sum ys = Tail.sumHelper (y + n) ys

然而,直接使用 Nat.add_comm 重写会导致错误的结果。rw 策略猜测了错误的重写位置,导致了意料之外的目标:

theorem non_tail_sum_eq_helper_accum (xs : List Nat) :
    (n : Nat) → n + NonTail.sum xs = Tail.sumHelper n xs := by
  induction xs with
  | nil =>
    intro n
    rfl
  | cons y ys ih =>
    intro n
    simp [NonTail.sum, Tail.sumHelper]
    rw [←Nat.add_assoc]
    rw [Nat.add_comm]
unsolved goals
case cons
y : Nat
ys : List Nat
ih : ∀ (n : Nat), n + NonTail.sum ys = Tail.sumHelper n ys
n : Nat
⊢ NonTail.sum ys + (n + y) = Tail.sumHelper (y + n) ys

可以通过显式地将 yn 作为参数提供给 Nat.add_comm 来解决此问题:

theorem non_tail_sum_eq_helper_accum (xs : List Nat) :
    (n : Nat) → n + NonTail.sum xs = Tail.sumHelper n xs := by
  induction xs with
  | nil =>
    intro n
    rfl
  | cons y ys ih =>
    intro n
    simp [NonTail.sum, Tail.sumHelper]
    rw [←Nat.add_assoc]
    rw [Nat.add_comm y n]
unsolved goals
case cons
y : Nat
ys : List Nat
ih : ∀ (n : Nat), n + NonTail.sum ys = Tail.sumHelper n ys
n : Nat
⊢ n + y + NonTail.sum ys = Tail.sumHelper (n + y) ys

现在目标与归纳假设相匹配了。特别是,归纳假设的类型是一个依值函数类型。 将 ih 应用于 n + y 会产生刚好期望的类型。如果其参数具有期望的类型, exact 策略会完成证明目标:

theorem non_tail_sum_eq_helper_accum (xs : List Nat) :
    (n : Nat) → n + NonTail.sum xs = Tail.sumHelper n xs := by
  induction xs with
  | nil => intro n; rfl
  | cons y ys ih =>
    intro n
    simp [NonTail.sum, Tail.sumHelper]
    rw [←Nat.add_assoc]
    rw [Nat.add_comm y n]
    exact ih (n + y)

实际的证明只需要一些额外的工作即可使目标与辅助函数的类型相匹配。 第一步仍然是调用函数外延性:

theorem non_tail_sum_eq_tail_sum : NonTail.sum = Tail.sum := by
  funext xs
unsolved goals
case h
xs : List Nat
⊢ NonTail.sum xs = Tail.sum xs

下一步是展开 Tail.sum,暴露出 Tail.sumHelper

theorem non_tail_sum_eq_tail_sum : NonTail.sum = Tail.sum := by
  funext xs
  simp [Tail.sum]
unsolved goals
case h
xs : List Nat
⊢ NonTail.sum xs = Tail.sumHelper 0 xs

完成这一步后,类型已经近乎匹配了。但是,辅助类型在左侧有一个额外的加数。 换句话说,证明目标是 NonTail.sum xs = Tail.sumHelper 0 xs, 但将 non_tail_sum_eq_helper_accum 应用于 xs0 会产生类型 0 + NonTail.sum xs = Tail.sumHelper 0 xs。 另一个标准库证明 Nat.zero_add 的类型为 (n : Nat) → 0 + n = n。 将此函数应用于 NonTail.sum xs 会产生类型为 0 + NonTail.sum xs = NonTail.sum xs 的表达式, 因此从右往左重写会产生期望的目标:

theorem non_tail_sum_eq_tail_sum : NonTail.sum = Tail.sum := by
  funext xs
  simp [Tail.sum]
  rw [←Nat.zero_add (NonTail.sum xs)]
unsolved goals
case h
xs : List Nat
⊢ 0 + NonTail.sum xs = Tail.sumHelper 0 xs

最后,可以使用辅助定理来完成证明:

theorem non_tail_sum_eq_tail_sum : NonTail.sum = Tail.sum := by
  funext xs
  simp [Tail.sum]
  rw [←Nat.zero_add (NonTail.sum xs)]
  exact non_tail_sum_eq_helper_accum xs 0

此证明演示了在证明「累加器传递尾递归函数等于非尾递归版本」时可以使用的通用模式。 第一步是发现起始累加器参数和最终结果之间的关系。 例如,以 n 的累加器开始 Tail.sumHelper 会导致最终的和被添加到 n 中, 而以 ys 的累加器开始 Tail.reverseHelper 会导致最终反转的列表被前置到 ys 中。 第二步是将此关系写成定理陈述,并通过归纳法证明它。虽然在实践中, 累加器总是用一些中性值(Neutral,即幺元,例如 0[])初始化, 但允许起始累加器为任何值的更通用的陈述是获得足够强的归纳假设所需要的。 最后,将此辅助定理与实际的初始累加器值一起使用会产生期望的证明。 例如,在 non_tail_sum_eq_tail_sum 中,累加器指定为 0。 这可能需要重写目标以使中性初始累加器值出现在正确的位置。

练习

热身

使用 induction 策略编写你自己的 Nat.zero_addNat.add_assocNat.add_comm 的证明。

更多累加器证明

反转列表

sum 的证明调整为 NonTail.reverseTail.reverse 的证明。 第一步是思考传递给 Tail.reverseHelper 的累加器值与非尾递归反转之间的关系。 正如在 Tail.sumHelper 中将数字添加到累加器中与将其添加到整体的和中相同, 在 Tail.reverseHelper 中使用 List.cons 将新条目添加到累加器中相当于对整体结果进行了一些更改。 用纸和笔尝试三个或四个不同的累加器值,直到关系变得清晰。 使用此关系来证明一个合适的辅助定理。然后,写下整体定理。 因为 NonTail.reverseTail.reverse 是多态的,所以声明它们的相等性需要使用 @ 来阻止 Lean 尝试找出为 α 使用哪种类型。一旦 α 被视为一个普通参数, funext 应该与 αxs 一起调用:

theorem non_tail_reverse_eq_tail_reverse : @NonTail.reverse = @Tail.reverse := by
  funext α xs

这会产生一个合适的目标:

unsolved goals
case h.h
α : Type u_1
xs : List α
⊢ NonTail.reverse xs = Tail.reverse xs

阶乘

通过找到累加器和结果之间的关系并证明一个合适的辅助定理, 证明上一节练习中的 NonTail.factorial 等于你的尾递归版本的解决方案。

数组与停机性

为了编写高效的代码,选择合适的数据结构非常重要。链表有它的用途:在某些应用程序中, 共享列表的尾部非常重要。但是,大多数可变长有序数据集合的用例都能由数组更好地提供服务, 数组既有较少的内存开销,又有更好的局部性。

然而,数组相对于列表来说有两个缺点:

  1. 数组是通过索引访问的,而非通过模式匹配,这为维护安全性增加了 证明义务
  2. 从左到右处理整个数组的循环是一个尾递归函数,但它没有会在每次调用时递减的参数。

高效地使用数组需要知道如何向 Lean 证明数组索引在范围内, 以及如何证明接近数组大小的数组索引也会使程序停机。这两个都使用不等式命题,而非命题等式表示。

不等式

由于不同的类型有不同的序概念,不等式需要由两个类来控制,分别称为 LELT标准类型类 一节中的表格描述了这些类与语法的关系:

表达式脱糖结果类名
x < yLT.lt x yLT
x ≤ yLE.le x yLE
x > yLT.lt y xLT
x ≥ yLE.le y xLE

换句话说,一个类型可以定制 < 运算符的含义,而 > 可以从 < 中派生它们的含义。LTLE 类具有返回命题而非 Bool 的方法:

class LE (α : Type u) where
  le : α → α → Prop

class LT (α : Type u) where
  lt : α → α → Prop

NatLE 实例会委托给 Nat.le

instance : LE Nat where
  le := Nat.le

定义 Nat.le 需要 Lean 中尚未介绍的一个特性:它是一个归纳定义的关系。

归纳定义的命题、谓词和关系

Nat.le 是一个 归纳定义的关系(Inductively-Defined Relation)。 就像 inductive 可以用来创建新的数据类型一样,它也可以用来创建新的命题。 当一个命题接受一个参数时,它被称为 谓词(Predicate),它可能对某些潜在参数为真, 但并非对所有参数都为真。接受多个参数的命题称为 关系(Relation)。"

每个归纳定义命题的构造子都是证明它的方法。换句话说,命题的声明描述了它为真的不同形式的证据。 一个没有参数且只有一个构造子的命题很容易证明:

inductive EasyToProve : Prop where
  | heresTheProof : EasyToProve

证明包括使用其构造子:

theorem fairlyEasy : EasyToProve := by
  constructor

实际上,命题 True 应该总是很容易证明,它的定义就像 EasyToProve

inductive True : Prop where
  | intro : True

不带参数的归纳定义命题远不如归纳定义的数据类型有趣。 这是因为数据本身很有趣——自然数 3 不同于数字 35,而订购了 3 个披萨的人如果 30 分钟后收到 35 个披萨会很沮丧。命题的构造子描述了命题可以为真的方式, 但一旦命题被证明,就不需要知道它使用了哪些底层构造子。 这就是为什么 Prop 宇宙中最有趣的归纳定义类型带参数的原因。

归纳定义谓词 IsThree 陈述它有三个参数:

inductive IsThree : Nat → Prop where
  | isThree : IsThree 3

这里使用的机制就像索引族,如 HasCol, 只不过结果类型是一个可以被证明的命题,而非可以被使用的数据。

使用此谓词,可以证明三确实等于三:

theorem three_is_three : IsThree 3 := by
  constructor

类似地,IsFive 是一个谓词,它陈述了其参数为 5

inductive IsFive : Nat → Prop where
  | isFive : IsFive 5

如果一个数字是三,那么将它加二的结果应该是五。这可以表示为定理陈述:

theorem three_plus_two_five : IsThree n → IsFive (n + 2) := by
  skip

由此产生的目标具有函数类型:

unsolved goals
n : Nat
⊢ IsThree n → IsFive (n + 2)

因此,intro 策略可用于将参数转换为假设:

theorem three_plus_two_five : IsThree n → IsFive (n + 2) := by
  intro three
unsolved goals
n : Nat
three : IsThree n
⊢ IsFive (n + 2)

假设 n 为三,则应该可以使用 IsFive 的构造子来完成证明:

theorem three_plus_two_five : IsThree n → IsFive (n + 2) := by
  intro three
  constructor

然而,这会产生一个错误:

tactic 'constructor' failed, no applicable constructor found
n : Nat
three : IsThree n
⊢ IsFive (n + 2)

出现此错误是因为 n + 25 在定义上不相等。在普通的函数定义中, 可以对假设 three 使用依值模式匹配来将 n 细化为 3。 依值模式匹配的策略等价为 cases,其语法类似于 induction

theorem three_plus_two_five : IsThree n → IsFive (n + 2) := by
  intro three
  cases three with
  | isThree => skip

在剩余情况下,n 已细化为 3

unsolved goals
case isThree
⊢ IsFive (3 + 2)

由于 3 + 2 在定义上等于 5,因此构造子现在适用了:

theorem three_plus_two_five : IsThree n → IsFive (n + 2) := by
  intro three
  cases three with
  | isThree => constructor

标准假命题 False 没有构造子,因此无法提供直接证据。 为 False 提供证据的唯一方法是假设本身不可能,类似于用 nomatch 来标记类型系统认为无法访问的代码。如 插曲中的证明一节 所述,否定 Not AA → False 的缩写。Not A 也可以写成 ¬A

四不是三:

theorem four_is_not_three : ¬ IsThree 4 := by
  skip

初始证明目标包含 Not

unsolved goals
⊢ ¬IsThree 4

可以使用 simp 显示出它实际上是一个函数类型:

theorem four_is_not_three : ¬ IsThree 4 := by
  simp [Not]
unsolved goals
⊢ IsThree 4 → False

因为目标是一个函数类型,所以 intro 可用于将参数转换为假设。 无需保留 simp,因为 intro 可以展开 Not 本身的定义:

theorem four_is_not_three : ¬ IsThree 4 := by
  intro h
unsolved goals
h : IsThree 4
⊢ False

在此证明中,cases 策略直接解决了目标:

theorem four_is_not_three : ¬ IsThree 4 := by
  intro h
  cases h

就像对 Vect String 2 的模式匹配不需要包含 Vect.nil 的情况一样, 对 IsThree 4 的情况证明不需要包含 isThree 的情况。

自然数不等式

Nat.le 的定义有一个参数和一个索引:

inductive Nat.le (n : Nat) : Nat → Prop
  | refl : Nat.le n n
  | step : Nat.le n m → Nat.le n (m + 1)

参数 n 应该是较小的数字,而索引应该是大于或等于 n 的数字。 当两个数字相等时使用 refl 构造子,而当索引大于 n 时使用 step 构造子。

从证据的视角来看,证明 \( n \leq k \) 需要找到一些数字 \( d \) 使得 \( n + d = m \)。 在 Lean 中,证明由 \( d \) 个 Nat.le.step 实例包裹的 Nat.le.refl 构造子组成。 每个 step 构造子将其索引参数加一,因此 \( d \) 个 step 构造子将 \( d \) 加到较大的数字上。 例如,证明四小于或等于七由 refl 周围的三个 step 组成:

theorem four_le_seven : 4 ≤ 7 :=
  open Nat.le in
  step (step (step refl))

严格小于关系通过在左侧数字上加一来定义:

def Nat.lt (n m : Nat) : Prop :=
  Nat.le (n + 1) m

instance : LT Nat where
  lt := Nat.lt

证明四严格小于七由 refl 周围的两个 step 组成:

theorem four_lt_seven : 4 < 7 :=
  open Nat.le in
  step (step refl)

这是因为 4 < 7 等价于 5 ≤ 7

停机性证明

函数 Array.map 接受一个函数和一个数组,它将接受的函数应用于输入数组的每个元素后,返回产生的新数组。 将其写成尾递归函数遵循通常的累加器模式,即将输入委托给一个函数,该函数将输出的数组传递给累加器。 累加器用空数组初始化。传递累加器的辅助函数还接受一个参数来跟踪数组中的当前索引,该索引从 0 开始:

def Array.map (f : α → β) (arr : Array α) : Array β :=
  arrayMapHelper f arr Array.empty 0

辅助函数应在每次迭代时检查索引是否仍在范围内。如果是,则应再次循环, 将转换后的元素添加到累加器的末尾,并将索引加 1。如果不是,则应终止并返回累加器。 此代码的最初实现会失败,因为 Lean 无法证明数组索引有效:

def arrayMapHelper (f : α → β) (arr : Array α) (soFar : Array β) (i : Nat) : Array β :=
  if i < arr.size then
    arrayMapHelper f arr (soFar.push (f arr[i])) (i + 1)
  else soFar
failed to prove index is valid, possible solutions:
  - Use `have`-expressions to prove the index is valid
  - Use `a[i]!` notation instead, runtime check is perfomed, and 'Panic' error message is produced if index is not valid
  - Use `a[i]?` notation instead, result is an `Option` type
  - Use `a[i]'h` notation instead, where `h` is a proof that index is valid
α : Type ?u.1704
β : Type ?u.1707
f : α → β
arr : Array α
soFar : Array β
i : Nat
⊢ i < Array.size arr

然而,条件表达式已经检查了有效数组索引所要求的精确条件(即 i < arr.size)。 为 if 添加一个名称可以解决此问题,因为它添加了一个前提供数组索引策略使用:

def arrayMapHelper (f : α → β) (arr : Array α) (soFar : Array β) (i : Nat) : Array β :=
  if inBounds : i < arr.size then
    arrayMapHelper f arr (soFar.push (f arr[i])) (i + 1)
  else soFar

然而,Lean 不接受修改后的程序,因为递归调用不是针对输入构造子之一的参数进行的。 实际上,累加器和索引都在增长,而非缩小:

fail to show termination for
  arrayMapHelper
with errors
argument #6 was not used for structural recursion
  failed to eliminate recursive application
    arrayMapHelper f✝ arr (Array.push soFar (f✝ arr[i])) (i + 1)

structural recursion cannot be used

failed to prove termination, use `termination_by` to specify a well-founded relation

尽管如此,此函数仍然会停机,因此简单地将其标记为 partial 非常不妥。

为什么 arrayMapHelper 会停机?每次迭代都会检查索引 i 是否仍在数组 arr 的范围内。 如果是,则 i 将增加并且循环将重复。如果不是,则程序将停机。因为 arr.size 是一个有限数, 所以 i 只可以增加有限次。即使函数的每个参数在每次调用时都不会减少,arr.size - i 也会减小到零。

可以通过在定义的末尾提供 termination_by 子句来指示 Lean 使用另一个表达式判定停机。 termination_by 子句有两个组成部分:函数参数的名称和使用这些名称的表达式, 该表达式应在每次调用时减少。对于 arrayMapHelper,最终定义如下所示:

def arrayMapHelper (f : α → β) (arr : Array α) (soFar : Array β) (i : Nat) : Array β :=
  if inBounds : i < arr.size then
    arrayMapHelper f arr (soFar.push (f arr[i])) (i + 1)
  else soFar
termination_by arrayMapHelper _ arr _ i _ => arr.size - i

类似的停机证明可用于编写 Array.find,这是一个在数组中查找满足布尔函数的第一个元素, 并返回该元素及其索引的函数:

def Array.find (arr : Array α) (p : α → Bool) : Option (Nat × α) :=
  findHelper arr p 0

同样,辅助函数会停机,因为随着 i 的增加,arr.size - i 会减少:

def findHelper (arr : Array α) (p : α → Bool) (i : Nat) : Option (Nat × α) :=
  if h : i < arr.size then
    let x := arr[i]
    if p x then
      some (i, x)
    else findHelper arr p (i + 1)
  else none
termination_by findHelper arr p i => arr.size - i

并非所有停机参数都像这个参数一样简单。但是,在所有停机证明中, 都会出现「基于函数的参数找出在每次调用时都会减少的某个表达式」这种基本结构。 有时,为了弄清楚函数为何停机,可能需要一点创造力,有时 Lean 需要额外的证明才能接受停机参数。

练习

  • 使用尾递归累加器传递函数和 termination_by 子句在数组上实现 ForM (Array α) 实例。
  • 使用 不需要 termination_by 子句的尾递归累加器传递函数实现一个用于反转数组的函数。
  • 使用恒等单子中的 for ... in ... 循环重新实现 Array.mapArray.findForM 实例, 并比较结果代码。
  • 使用恒等单子中的 for ... in ... 循环重新实现数组反转。将其与尾递归函数的版本进行比较。

更多不等式

Lean 的内置证明自动化足以检查 arrayMapHelperfindHelper 是否停机。 所需要做的就是提供一个值随着每次递归调用而减小的表达式。 但是,Lean 的内置自动化不是万能的,它通常需要一些帮助。

归并排序

一个停机证明非平凡的函数示例是 List 上的归并排序。归并排序包含两个阶段: 首先,将列表分成两半。使用归并排序对每一半进行排序, 然后使用一个将两个已排序列表合并为一个更大的已排序列表的函数合并结果。 基本情况是空列表和单元素列表,它们都被认为已经排序。

要合并两个已排序列表,需要考虑两个基本情况:

  1. 如果一个输入列表为空,则结果是另一个列表。
  2. 如果两个列表都不为空,则应比较它们的头部。该函数的结果是两个头部中较小的一个, 后面是合并两个列表的剩余项的结果。

这在任何列表上都不是结构化递归。递归停机是因为在每次递归调用中都会从两个列表中的一个中删除一个项, 但它可能是任何一个列表。termination_by 子句使用两个列表长度的和作为递减值:

def merge [Ord α] (xs : List α) (ys : List α) : List α :=
  match xs, ys with
  | [], _ => ys
  | _, [] => xs
  | x'::xs', y'::ys' =>
    match Ord.compare x' y' with
    | .lt | .eq => x' :: merge xs' (y' :: ys')
    | .gt => y' :: merge (x'::xs') ys'
termination_by merge xs ys => xs.length + ys.length

除了使用列表的长度外,还可以提供一个包含两个列表的偶对:

def merge [Ord α] (xs : List α) (ys : List α) : List α :=
  match xs, ys with
  | [], _ => ys
  | _, [] => xs
  | x'::xs', y'::ys' =>
    match Ord.compare x' y' with
    | .lt | .eq => x' :: merge xs' (y' :: ys')
    | .gt => y' :: merge (x'::xs') ys'
termination_by merge xs ys => (xs, ys)

它有效是因为 Lean 有一个内置的数据大小概念,通过一个称为 WellFoundedRelation 的类型类来表示。如果偶对中的第一个或第二个项缩小,偶对的实例会自动认为它们会变小。

分割列表的一个简单方法是将输入列表中的每个项添加到两个交替的输出列表中:

def splitList (lst : List α) : (List α × List α) :=
  match lst with
  | [] => ([], [])
  | x :: xs =>
    let (a, b) := splitList xs
    (x :: b, a)

归并排序检查是否已达到基本情况。如果是,则返回输入列表。 如果不是,则分割输入,并合并对每一半排序的结果:

def mergeSort [Ord α] (xs : List α) : List α :=
  if h : xs.length < 2 then
    match xs with
    | [] => []
    | [x] => [x]
  else
    let halves := splitList xs
    merge (mergeSort halves.fst) (mergeSort halves.snd)

Lean 的模式匹配编译器能够判断由测试 xs.length < 2if 引入的前提 h 排除了长度超过一个条目的列表,因此没有「缺少情况」的错误。 然而,即使此程序总是停机,它也不是结构化递归的:

fail to show termination for
  mergeSort
with errors
argument #3 was not used for structural recursion
  failed to eliminate recursive application
    mergeSort halves.fst

structural recursion cannot be used

failed to prove termination, use `termination_by` to specify a well-founded relation

它能停机的原因是 splitList 总是返回比其输入更短的列表。 因此,halves.fsthalves.snd 的长度小于 xs 的长度。 这可以使用 termination_by 子句来表示:

def mergeSort [Ord α] (xs : List α) : List α :=
  if h : xs.length < 2 then
    match xs with
    | [] => []
    | [x] => [x]
  else
    let halves := splitList xs
    merge (mergeSort halves.fst) (mergeSort halves.snd)
termination_by mergeSort xs => xs.length

有了这个子句,错误信息就变了。Lean 不会抱怨函数不是结构化递归的, 而是指出它无法自动证明 (splitList xs).fst.length < xs.length

failed to prove termination, possible solutions:
  - Use `have`-expressions to prove the remaining goals
  - Use `termination_by` to specify a different well-founded relation
  - Use `decreasing_by` to specify your own tactic for discharging this kind of goal
α : Type u_1
xs : List α
h : ¬List.length xs < 2
halves : List α × List α := splitList xs
⊢ List.length (splitList xs).fst < List.length xs

分割列表使其变短

还需要证明 (splitList xs).snd.length < xs.length。由于 splitList 在向两个列表添加条目之间交替进行,因此最简单的方法是同时证明这两个语句, 这样证明的结构就可以遵循用于实现 splitList 的算法。换句话说,最简单的方法是证明 ∀(lst : List), (splitList lst).fst.length < lst.length ∧ (splitList lst).snd.length < lst.length

不幸的是,这个陈述是错误的。特别是, splitList []([], [])。 两个输出列表的长度都是 0,这并不小于输入列表的长度 0。类似地, splitList ["basalt"] 求值为 ([\"basalt\"], []),而 ["basalt"] 并不比 ["basalt"] 短。然而, splitList ["basalt", "granite"] 求值为 (["basalt"], ["granite"]), 这两个输出列表都比输入列表短。

输出列表的长度始终小于或等于输入列表的长度,但仅当输入列表至少包含两个条目时, 它们才严格更短。事实证明,最容易证明前一个陈述,然后将其扩展到后一个陈述。 从定理的陈述开始:

theorem splitList_shorter_le (lst : List α) :
    (splitList lst).fst.length ≤ lst.length ∧
      (splitList lst).snd.length ≤ lst.length := by
  skip
unsolved goals
α : Type u_1
lst : List α
⊢ List.length (splitList lst).fst ≤ List.length lst ∧ List.length (splitList lst).snd ≤ List.length lst

由于 splitList 在列表上是结构化递归的,因此证明应使用归纳法。 splitList 中的结构化递归非常适合归纳证明:归纳法的基本情况与递归的基本情况匹配, 归纳步骤与递归调用匹配。induction 策略给出了两个目标:

theorem splitList_shorter_le (lst : List α) :
    (splitList lst).fst.length ≤ lst.length ∧
      (splitList lst).snd.length ≤ lst.length := by
  induction lst with
  | nil => skip
  | cons x xs ih => skip
unsolved goals
case nil
α : Type u_1
⊢ List.length (splitList []).fst ≤ List.length [] ∧ List.length (splitList []).snd ≤ List.length []
unsolved goals
case cons
α : Type u_1
x : α
xs : List α
ih : List.length (splitList xs).fst ≤ List.length xs ∧ List.length (splitList xs).snd ≤ List.length xs
⊢ List.length (splitList (x :: xs)).fst ≤ List.length (x :: xs) ∧
    List.length (splitList (x :: xs)).snd ≤ List.length (x :: xs)

可以通过调用简化器并指示它展开 splitList 的定义来证明 nil 情况的目标, 因为空列表的长度小于或等于空列表的长度。类似地,在 cons 情况下使用 splitList 简化会在目标中的长度周围放置 Nat.succ

theorem splitList_shorter_le (lst : List α) :
    (splitList lst).fst.length ≤ lst.length ∧
      (splitList lst).snd.length ≤ lst.length := by
  induction lst with
  | nil => simp [splitList]
  | cons x xs ih =>
    simp [splitList]
unsolved goals
case cons
α : Type u_1
x : α
xs : List α
ih : List.length (splitList xs).fst ≤ List.length xs ∧ List.length (splitList xs).snd ≤ List.length xs
⊢ Nat.succ (List.length (splitList xs).snd) ≤ Nat.succ (List.length xs) ∧
    List.length (splitList xs).fst ≤ Nat.succ (List.length xs)

这是因为对 List.length 的调用消耗了列表 x :: xs 的头部,将其转换为 Nat.succ, 既在输入列表的长度中,也在第一个输出列表的长度中。

在 Lean 中编写 A ∧ BAnd A B 的缩写。 AndProp 宇宙中的一个结构体类型:

structure And (a b : Prop) : Prop where
  intro ::
  left : a
  right : b

换句话说,A ∧ B 的证明包括应用于 left 字段中 A 的证明和应用于 right 字段中 B 的证明的 And.intro 构造子。

cases 策略允许证明依次考虑数据类型的每个构造子或命题的每个潜在证明。 它对应于没有递归的 match 表达式。对结构体使用 cases 会导致结构体被分解, 并为结构体的每个字段添加一个假设,就像模式匹配表达式提取结构体的字段以用于程序中一样。 由于结构体只有一个构造子,因此对结构体使用 cases 不会产生额外的目标。

由于 ihList.length (splitList xs).fst ≤ List.length xs ∧ List.length (splitList xs).snd ≤ List.length xs 的一个证明,使用 cases ih 会产生一个 List.length (splitList xs).fst ≤ List.length xs 的假设 和一个 List.length (splitList xs).snd ≤ List.length xs 的假设:

theorem splitList_shorter_le (lst : List α) :
    (splitList lst).fst.length ≤ lst.length ∧
      (splitList lst).snd.length ≤ lst.length := by
  induction lst with
  | nil => simp [splitList]
  | cons x xs ih =>
    simp [splitList]
    cases ih
unsolved goals
case cons.intro
α : Type u_1
x : α
xs : List α
left✝ : List.length (splitList xs).fst ≤ List.length xs
right✝ : List.length (splitList xs).snd ≤ List.length xs
⊢ Nat.succ (List.length (splitList xs).snd) ≤ Nat.succ (List.length xs) ∧
    List.length (splitList xs).fst ≤ Nat.succ (List.length xs)

由于证明的目标也是一个 And,因此可以使用 constructor 策略应用 And.intro, 从而为每个参数生成一个目标:

theorem splitList_shorter_le (lst : List α) :
    (splitList lst).fst.length ≤ lst.length ∧
      (splitList lst).snd.length ≤ lst.length := by
  induction lst with
  | nil => simp [splitList]
  | cons x xs ih =>
    simp [splitList]
    cases ih
    constructor
unsolved goals
case cons.intro.left
α : Type u_1
x : α
xs : List α
left✝ : List.length (splitList xs).fst ≤ List.length xs
right✝ : List.length (splitList xs).snd ≤ List.length xs
⊢ Nat.succ (List.length (splitList xs).snd) ≤ Nat.succ (List.length xs)

case cons.intro.right
α : Type u_1
x : α
xs : List α
left✝ : List.length (splitList xs).fst ≤ List.length xs
right✝ : List.length (splitList xs).snd ≤ List.length xs
⊢ List.length (splitList xs).fst ≤ Nat.succ (List.length xs)

left 目标与 left✝ 假设非常相似,除了目标用 Nat.succ 包装不等式的两侧。 同样,right 目标类似于 right✝ 假设,除了目标仅将 Nat.succ 添加到输入列表的长度。 现在是时候证明 Nat.succ 的这些包装保留了陈述的真值了。

两边同时加一

对于 left 目标,要证明的语句是 Nat.succ_le_succ : n ≤ m → Nat.succ n ≤ Nat.succ m。 换句话说,如果 n ≤ m,那么在两边都加一并不会改变这一事实。为什么这是真的? 证明 n ≤ m 是一个 Nat.le.refl 构造子,周围有 m - nNat.le.step 构造子的实例。 在两边都加一只是意味着 refl 应用于比之前大一的数,并且具有相同数量的 step 构造子。

更形式化地说,证明是通过归纳法来证明 n ≤ m 的证据。如果证据是 refl,则 n = m, 因此 Nat.succ n = Nat.succ m,并且可以再次使用 refl。 如果证据是 step,则归纳假设提供了 Nat.succ n ≤ Nat.succ m 的证据, 并且目标是证明 Nat.succ n ≤ Nat.succ (Nat.succ m)。 这可以通过将 step 与归纳假设一起使用来完成。

在 Lean 中,该定理陈述为:

theorem Nat.succ_le_succ : n ≤ m → Nat.succ n ≤ Nat.succ m := by
  skip

错误信息对其进行了概括:

unsolved goals
n m : Nat
⊢ n ≤ m → Nat.succ n ≤ Nat.succ m

第一步是使用 intro 策略,将假设 n ≤ m 引入作用域并为其命名:

theorem Nat.succ_le_succ : n ≤ m → Nat.succ n ≤ Nat.succ m := by
  intro h
unsolved goals
n m : Nat
h : n ≤ m
⊢ Nat.succ n ≤ Nat.succ m

由于证明是通过归纳法对证据 n ≤ m 进行的,因此下一个策略是 induction h

theorem Nat.succ_le_succ : n ≤ m → Nat.succ n ≤ Nat.succ m := by
  intro h
  induction h

这会产生两个目标,每个目标对应于 Nat.le 的一个构造子:

unsolved goals
case refl
n m : Nat
⊢ Nat.succ n ≤ Nat.succ n

case step
n m m✝ : Nat
a✝ : Nat.le n m✝
a_ih✝ : Nat.succ n ≤ Nat.succ m✝
⊢ Nat.succ n ≤ Nat.succ (Nat.succ m✝)

refl 的目标可以使用 refl 本身来解决,constructor 策略会选择它。 step 的目标还需要使用 step 构造子:

theorem Nat.succ_le_succ : n ≤ m → Nat.succ n ≤ Nat.succ m := by
  intro h
  induction h with
  | refl => constructor
  | step h' ih => constructor
unsolved goals
case step.a
n m m✝ : Nat
h' : Nat.le n m✝
ih : Nat.succ n ≤ Nat.succ m✝
⊢ Nat.le (Nat.succ n) (m✝ + 1)

该目标不再使用 运算符显示,但它等价于归纳假设 ihassumption 策略会自动选择一个满足目标的假设,证明完毕:

theorem Nat.succ_le_succ : n ≤ m → Nat.succ n ≤ Nat.succ m := by
  intro h
  induction h with
  | refl => constructor
  | step h' ih =>
    constructor
    assumption

写成递归函数,证明如下:

theorem Nat.succ_le_succ : n ≤ m → Nat.succ n ≤ Nat.succ m
  | .refl => .refl
  | .step h' => .step (Nat.succ_le_succ h')

将基于策略的归纳证明与这个递归函数进行比较是有指导意义的。哪些证明步骤对应于定义的哪些部分?

在较大的一侧加一

证明 splitList_shorter_le 所需的第二个不等式是 ∀(n m : Nat), n ≤ m → n ≤ Nat.succ m。 这个证明几乎与 Nat.succ_le_succ 相同。同样,传入的假设 n ≤ m 基本上跟踪了 nmNat.le.step 构造子数量上的差异。因此,证明应该在基本情况下添加一个额外的 Nat.le.step。 证明可以写成:

theorem Nat.le_succ_of_le : n ≤ m → n ≤ Nat.succ m := by
  intro h
  induction h with
  | refl => constructor; constructor
  | step => constructor; assumption

为了揭示幕后发生的事情,applyexact 策略可用于准确指示正在应用哪个构造子。 apply 策略通过应用一个返回类型匹配的函数或构造子来解决当前目标, 为每个未提供的参数创建新的目标,而如果需要任何新目标,exact 就会失败:

theorem Nat.le_succ_of_le : n ≤ m → n ≤ Nat.succ m := by
  intro h
  induction h with
  | refl => apply Nat.le.step; exact Nat.le.refl
  | step _ ih => apply Nat.le.step; exact ih

证明可以简化:

theorem Nat.le_succ_of_le (h : n ≤ m) : n ≤ Nat.succ m := by
  induction h <;> repeat (first | constructor | assumption)

在这个简短的策略脚本中,由 induction 引入的两个目标都使用 repeat (first | constructor | assumption) 来解决。策略 first | T1 | T2 | ... | Tn 表示按顺序尝试 T1Tn,然后使用第一个成功的策略。 换句话说,repeat (first | constructor | assumption) 会尽可能地应用构造子, 然后尝试使用假设来解决目标。

最后,证明可以写成一个递归函数:

theorem Nat.le_succ_of_le : n ≤ m → n ≤ Nat.succ m
  | .refl => .step .refl
  | .step h => .step (Nat.le_succ_of_le h)

每种证明风格都适用于不同的情况。详细的证明脚本在初学者阅读代码或证明步骤提供某种见解的情况下很有用。 简短、高度自动化的证明脚本通常更容易维护,因为自动化通常在面对定义和数据类型的细微更改时既灵活又健壮。 递归函数通常从数学证明的角度来看更难理解,也更难维护,但对于开始使用交互式定理证明的程序员来说, 它可能是一个有用的桥梁。

完成证明

现在已经证明了两个辅助定理,splitList_shorter_le 的其余部分将很快完成。 当前的证明状态有两个目标,用于 And 的左侧和右侧:

unsolved goals
case cons.intro.left
α : Type u_1
x : α
xs : List α
left✝ : List.length (splitList xs).fst ≤ List.length xs
right✝ : List.length (splitList xs).snd ≤ List.length xs
⊢ Nat.succ (List.length (splitList xs).snd) ≤ Nat.succ (List.length xs)

case cons.intro.right
α : Type u_1
x : α
xs : List α
left✝ : List.length (splitList xs).fst ≤ List.length xs
right✝ : List.length (splitList xs).snd ≤ List.length xs
⊢ List.length (splitList xs).fst ≤ Nat.succ (List.length xs)

目标以 And 结构体的字段命名。这意味着 case 策略(不要与 cases 混淆)可以依次关注于每个目标:

theorem splitList_shorter_le (lst : List α) :
    (splitList lst).fst.length ≤ lst.length ∧ (splitList lst).snd.length ≤ lst.length := by
  induction lst with
  | nil => simp [splitList]
  | cons x xs ih =>
    simp [splitList]
    cases ih
    constructor
    case left => skip
    case right => skip

现在不再是一个错误列出两个未解决的目标,而是有两个错误信息, 每个 skip 上一个。对于left目标,可以使用Nat.succ_le_succ

unsolved goals
α : Type u_1
x : α
xs : List α
left✝ : List.length (splitList xs).fst ≤ List.length xs
right✝ : List.length (splitList xs).snd ≤ List.length xs
⊢ Nat.succ (List.length (splitList xs).snd) ≤ Nat.succ (List.length xs)

在右侧目标中,Nat.le_suc_of_le 适合:

unsolved goals
α : Type u_1
x : α
xs : List α
left✝ : List.length (splitList xs).fst ≤ List.length xs
right✝ : List.length (splitList xs).snd ≤ List.length xs
⊢ List.length (splitList xs).fst ≤ Nat.succ (List.length xs)

这两个定理都包含前提条件 n ≤ m。它们可以作为 left✝right✝ 假设找到, 这意味着 assumption 策略可以处理最终目标:

theorem splitList_shorter_le (lst : List α) :
    (splitList lst).fst.length ≤ lst.length ∧ (splitList lst).snd.length ≤ lst.length := by
  induction lst with
  | nil => simp [splitList]
  | cons x xs ih =>
    simp [splitList]
    cases ih
    constructor
    case left => apply Nat.succ_le_succ; assumption
    case right => apply Nat.le_succ_of_le; assumption

下一步是返回到证明归并排序停机所需的实际定理:只要一个列表至少有两个条目, 则分割它的两个结果都严格短于它。

theorem splitList_shorter (lst : List α) (_ : lst.length ≥ 2) :
    (splitList lst).fst.length < lst.length ∧
      (splitList lst).snd.length < lst.length := by
  skip
unsolved goals
α : Type u_1
lst : List α
x✝ : List.length lst ≥ 2
⊢ List.length (splitList lst).fst < List.length lst ∧ List.length (splitList lst).snd < List.length lst

模式匹配在策略脚本中与在程序中一样有效。因为 lst 至少有两个条目, 所以它们可以用 match 暴露出来,它还通过依值模式匹配来细化类型:

theorem splitList_shorter (lst : List α) (_ : lst.length ≥ 2) :
    (splitList lst).fst.length < lst.length ∧
      (splitList lst).snd.length < lst.length := by
  match lst with
  | x :: y :: xs =>
    skip
unsolved goals
α : Type u_1
lst : List α
x y : α
xs : List α
x✝ : List.length (x :: y :: xs) ≥ 2
⊢ List.length (splitList (x :: y :: xs)).fst < List.length (x :: y :: xs) ∧
    List.length (splitList (x :: y :: xs)).snd < List.length (x :: y :: xs)

使用 splitList 简化会删除 xy,导致列表的计算长度每个都获得 Nat.succ

theorem splitList_shorter (lst : List α) (_ : lst.length ≥ 2) :
    (splitList lst).fst.length < lst.length ∧
      (splitList lst).snd.length < lst.length := by
  match lst with
  | x :: y :: xs =>
    simp [splitList]
unsolved goals
α : Type u_1
lst : List α
x y : α
xs : List α
x✝ : List.length (x :: y :: xs) ≥ 2
⊢ Nat.succ (List.length (splitList xs).fst) < Nat.succ (Nat.succ (List.length xs)) ∧
    Nat.succ (List.length (splitList xs).snd) < Nat.succ (Nat.succ (List.length xs))

simp_arith 替换 simp 会删除这些 Nat.succ 构造子, 因为 simp_arith 利用了 n + 1 < m + 1 意味着 n < m 的事实:

theorem splitList_shorter (lst : List α) (_ : lst.length ≥ 2) :
    (splitList lst).fst.length < lst.length ∧
      (splitList lst).snd.length < lst.length := by
  match lst with
  | x :: y :: xs =>
    simp_arith [splitList]
unsolved goals
α : Type u_1
lst : List α
x y : α
xs : List α
x✝ : List.length (x :: y :: xs) ≥ 2
⊢ List.length (splitList xs).fst ≤ List.length xs ∧ List.length (splitList xs).snd ≤ List.length xs

此目标现在匹配 splitList_shorter_le,可用于结束证明:

theorem splitList_shorter (lst : List α) (_ : lst.length ≥ 2) :
    (splitList lst).fst.length < lst.length ∧
      (splitList lst).snd.length < lst.length := by
  match lst with
  | x :: y :: xs =>
    simp_arith [splitList]
    apply splitList_shorter_le

证明 mergeSort 停机所需的事实可以从结果 And 中提取出来:

theorem splitList_shorter_fst (lst : List α) (h : lst.length ≥ 2) :
    (splitList lst).fst.length < lst.length :=
  splitList_shorter lst h |>.left

theorem splitList_shorter_snd (lst : List α) (h : lst.length ≥ 2) :
    (splitList lst).snd.length < lst.length :=
  splitList_shorter lst h |>.right

归并排序停机证明

归并排序有两个递归调用,一个用于 splitList 返回的每个子列表。 每个递归调用都需要证明传递给它的列表的长度短于输入列表的长度。 通常分两步编写停机证明会更方便:首先,写下允许 Lean 验证停机的命题,然后证明它们。 否则,可能会投入大量精力来证明命题,却发现它们并不是所需的在更小的输入上建立递归调用的内容。

sorry 策略可以证明任何目标,即使是错误的目标。它不适用于生产代码或最终证明, 但它是一种便捷的方法,可以提前「勾勒出」证明或程序。任何使用 sorry 的定义或定理都会附有警告。

使用 sorrymergeSort 停机论证的初始草图可以通过将 Lean 无法证明的目标复制到 have 表达式中来编写。在 Lean 中,have 类似于 let。使用 have 时,名称是可选的。 通常,let 用于定义引用关键值的名称,而 have 用于局部证明命题, 当 Lean 在寻找「数组查找是否在范围内」或「函数是否停机」的证据时,可以找到这些命题。

def mergeSort [Ord α] (xs : List α) : List α :=
  if h : xs.length < 2 then
    match xs with
    | [] => []
    | [x] => [x]
  else
    let halves := splitList xs
    have : halves.fst.length < xs.length := by
      sorry
    have : halves.snd.length < xs.length := by
      sorry
    merge (mergeSort halves.fst) (mergeSort halves.snd)
termination_by mergeSort xs => xs.length

警告位于名称 mergeSort 上:

declaration uses 'sorry'

因为没有错误,所以建议的命题足以建立停机证明。

证明从应用辅助定理开始:

def mergeSort [Ord α] (xs : List α) : List α :=
  if h : xs.length < 2 then
    match xs with
    | [] => []
    | [x] => [x]
  else
    let halves := splitList xs
    have : halves.fst.length < xs.length := by
      apply splitList_shorter_fst
    have : halves.snd.length < xs.length := by
      apply splitList_shorter_snd
    merge (mergeSort halves.fst) (mergeSort halves.snd)
termination_by mergeSort xs => xs.length

两个证明都失败了,因为 splitList_shorter_fstsplitList_shorter_snd 都需要证明 xs.length ≥ 2

unsolved goals
case h
α : Type ?u.37732
inst✝ : Ord α
xs : List α
h : ¬List.length xs < 2
halves : List α × List α := splitList xs
⊢ List.length xs ≥ 2

要检查这是否足以完成证明,请使用 sorry 添加它并检查错误:

def mergeSort [Ord α] (xs : List α) : List α :=
  if h : xs.length < 2 then
    match xs with
    | [] => []
    | [x] => [x]
  else
    let halves := splitList xs
    have : xs.length ≥ 2 := by sorry
    have : halves.fst.length < xs.length := by
      apply splitList_shorter_fst
      assumption
    have : halves.snd.length < xs.length := by
      apply splitList_shorter_snd
      assumption
    merge (mergeSort halves.fst) (mergeSort halves.snd)
termination_by mergeSort xs => xs.length

同样,只会有一个警告。

declaration uses 'sorry'

有一个有希望的假设可用:h : ¬List.length xs < 2,它来自 if。 显然,如果不是 xs.length < 2,那么 xs.length ≥ 2。 Lean 库以 Nat.ge_of_not_lt 的名称提供了此定理。程序现在已完成:

def mergeSort [Ord α] (xs : List α) : List α :=
  if h : xs.length < 2 then
    match xs with
    | [] => []
    | [x] => [x]
  else
    let halves := splitList xs
    have : xs.length ≥ 2 := by
      apply Nat.ge_of_not_lt
      assumption
    have : halves.fst.length < xs.length := by
      apply splitList_shorter_fst
      assumption
    have : halves.snd.length < xs.length := by
      apply splitList_shorter_snd
      assumption
    merge (mergeSort halves.fst) (mergeSort halves.snd)
termination_by mergeSort xs => xs.length

该函数可以在示例上进行测试:

#eval mergeSort ["soapstone", "geode", "mica", "limestone"]
["geode", "limestone", "mica", "soapstone"]
#eval mergeSort [5, 3, 22, 15]
[3, 5, 15, 22]

用减法迭代表示除法

正如乘法是迭代的加法,指数是迭代的乘法,除法可以理解为迭代的减法。 本书中对递归函数的第一个描述 给出了除法的一个版本,当除数不为零时停机,但 Lean 并不接受。证明除法终止需要使用关于不等式的事实。

第一步是细化除法的定义,使其需要证据证明除数不为零:

def div (n k : Nat) (ok : k > 0) : Nat :=
  if n < k then
    0
  else
    1 + div (n - k) k ok

由于增加了参数,错误信息会稍长一些,但它包含基本相同的信息:

fail to show termination for
  div
with errors
argument #1 was not used for structural recursion
  failed to eliminate recursive application
    div (n - k) k ok

argument #2 was not used for structural recursion
  failed to eliminate recursive application
    div (n - k) k ok

argument #3 was not used for structural recursion
  application type mismatch
    @Nat.le.brecOn (Nat.succ 0) fun k ok => Nat → Nat
  argument
    fun k ok => Nat → Nat
  has type
    (k : Nat) → k > 0 → Type : Type 1
  but is expected to have type
    (a : Nat) → Nat.le (Nat.succ 0) a → Prop : Type

structural recursion cannot be used

failed to prove termination, use `termination_by` to specify a well-founded relation

div 的这个定义会停机,因为第一个参数 n 在每次递归调用时都更小。 这可以使用 termination_by 子句来表示:

def div (n k : Nat) (ok : k > 0) : Nat :=
  if h : n < k then
    0
  else
    1 + div (n - k) k ok
termination_by div n k ok => n

现在,错误仅限于递归调用:

failed to prove termination, possible solutions:
  - Use `have`-expressions to prove the remaining goals
  - Use `termination_by` to specify a different well-founded relation
  - Use `decreasing_by` to specify your own tactic for discharging this kind of goal
n k : Nat
ok : k > 0
h : ¬n < k
⊢ n - k < n

This can be proved using a theorem from the standard library, Nat.sub_lt. This theorem states that (the curly braces indicate that n and k are implicit arguments). Using this theorem requires demonstrating that both n and k are greater than zero. Because k > 0 is syntactic sugar for 0 < k, the only necessary goal is to show that 0 < n. There are two possibilities: either n is 0, or it is n' + 1 for some other Nat n'. But n cannot be 0. The fact that the if selected the second branch means that ¬ n < k, but if n = 0 and k > 0 then n must be less than k, which would be a contradiction. This, n = Nat.succ n', and Nat.succ n' is clearly greater than 0. 这可以使用标准库中的定理 Nat.sub_lt 来证明。该定理指出 ∀ {n k : Nat}, 0 < n → 0 < k → n - k < n (花括号表示 nk 是隐式参数)。使用此定理需要证明 nk 都大于零。 因为 k > 00 < k 的语法糖,所以唯一必要的目标是证明 0 < n。 有两种可能性:n0,或它为某个其他 Nat n'n' + 1。 但 n 不能为 0if 选择第二个分支的事实意味着 ¬ n < k, 但如果 n = 0k > 0,则 n 必须小于 k,这将会产生矛盾。 在这里,n = Nat.succ n',而 Nat.succ n' 明显大于 0

div 的完整定义,包括停机证明:

def div (n k : Nat) (ok : k > 0) : Nat :=
  if h : n < k then
    0
  else
    have : 0 < n := by
      cases n with
      | zero => contradiction
      | succ n' => simp_arith
    have : n - k < n := by
      apply Nat.sub_lt <;> assumption
    1 + div (n - k) k ok
termination_by div n k ok => n

练习

证明以下定理:

  • 对于所有的自然数 \( n \),\( 0 < n + 1 \)。
  • 对于所有的自然数 \( n \),\( 0 \leq n \)。
  • 对于所有的自然数 \( n \) 和 \( k \),\( (n + 1) - (k + 1) = n - k \)
  • 对于所有的自然数 \( n \) 和 \( k \), 若 \( k < n \) 则 \( n \neq 0 \)
  • 对于所有的自然数 \( n \),\( n - n = 0 \)
  • 对于所有的自然数 \( n \) 和 \( k \),若 \( n + 1 < k \) 则 \( n < k \)

安全数组索引

ArrayNatGetElem 实例需要证明提供的 Nat 小于数组。 在实践中,这些证明通常最终会连同索引一起传递给函数。 与其分别传递索引和证明,可以使用名为 Fin 的类型将索引和证明捆绑到单个值中。 这可以使代码更易阅。此外,许多对数组的内置操作将其索引参数作为 Fin 而非 Nat, 因此使用这些内置操作需要了解如何使用 Fin

类型 Fin n 表示严格小于 n 的数字。换句话说,Fin 3 描述 012, 而 Fin 0 没有任何值。Fin 的定义类似于 Subtype,因为 Fin n 是一个包含 Nat 和小于 n 的证明的结构体:

structure Fin (n : Nat) where
  val  : Nat
  isLt : LT.lt val n

Lean 包含 ToStringOfNat 的实例,允许将 Fin 值方便地用作数字。 换句话说,#eval (5 : Fin 8) 的输出为 5, 而非类似 {val := 5, isLt := _} 的值。

当提供的数字大于边界时,OfNat 实例对于 Fin 不会失败,而是返回一个对边界取模的值。 这意味着 #eval (45 : Fin 10) 的结果是 5,而非编译时错误。

在返回类型中,将 Fin 作为找到的索引返回,能够让它与其所在的数据结构的连接更加清晰。 上一节中的 Array.find 返回一个索引, 调用者不能立即使用它来执行数组查找,因为有关其有效性的信息已丢失。 更具体类型的值可以直接使用,而不会使程序变得复杂得多:

def findHelper (arr : Array α) (p : α → Bool) (i : Nat) : Option (Fin arr.size × α) :=
  if h : i < arr.size then
    let x := arr[i]
    if p x then
      some (⟨i, h⟩, x)
    else findHelper arr p (i + 1)
  else none
termination_by findHelper arr p i => arr.size - i

def Array.find (arr : Array α) (p : α → Bool) : Option (Fin arr.size × α) :=
  findHelper arr p 0

练习

编写一个函数 Fin.next? : Fin n → Option (Fin n)Fin 在边界内时返回下一个最大的 Fin, 否则返回 none。检查

#eval (3 : Fin 8).next?

会输出

some 4

#eval (7 : Fin 8).next?

会输出

none

插入排序与数组可变性

虽然插入排序的最差时间复杂度并不是最优,但它仍然有一些有用的属性:

  • 它简单明了,易于实现和理解
  • 它是一种原地排序算法,不需要额外的空间来运行
  • 它是一种稳定排序
  • 当输入已经排序得差不多时,它很快

原地算法在 Lean 中特别有用,因为它管理内存的方式。 在某些情况下,会复制数组的操作通常可以优化为直接修改。这包括交换数组中的元素。

大多数语言和具有自动内存管理的运行时系统,包括 JavaScript、JVM 和 .NET,都使用跟踪垃圾回收。 当需要回收内存时,系统从许多 (例如调用栈和全局值)开始, 然后通过递归地追踪指针来确定可以到达哪些值。任何无法到达的值都会被释放,从而释放内存。

引用计数是追踪式垃圾回收的替代方法,它被许多语言使用,包括 Python、Swift 和 Lean。 在引用计数系统中,内存中的每个对象都有一个字段来跟踪对它的引用数。 当建立一个新引用时,计数器会增加。当一个引用不再存在时,计数器会减少。 当计数器达到零时,对象会立即被释放。

与追踪式垃圾回收器相比,引用计数有一个主要的缺点:循环引用会导致内存泄漏。 如果对象 \( A \) 引用对象 \( B \),而对象 \( B \) 引用对象 \( A \), 它们将永远不会被释放,即使程序中没有其他内容引用 \( A \) 或 \( B \)。 循环引用要么是由不受控制的递归引起的,要么是由可变引用引起的。由于 Lean 不支持这两者, 因此不可能构造循环引用。

引用计数意味着 Lean 运行时系统用于分配和释放数据结构的原语可以检查引用计数是否即将降至零, 并重新使用现有对象而非分配一个新对象。当使用大型数组时,这一点尤其重要。

针对 Lean 数组的插入排序的实现应满足以下条件:

  1. Lean 应当接受没有 partial 标注的函数
  2. 若传递了一个没有其他引用的数组,它应原地修改数组,而非分配一个新数组

第一个条件很容易检查:如果 Lean 接受该定义,则满足该条件。 然而,第二个条件需要一种测试方法。Lean 提供了一个名为 dbgTraceIfShared 的内置函数,其签名如下:

#check dbgTraceIfShared
dbgTraceIfShared.{u} {α : Type u} (s : String) (a : α) : α

它以一个字符串和一个值作为参数,如果该值有多个引用,则使用该字符串打印一条消息到标准错误, 并返回该值。严格来说,它不是一个纯函数。 但是,它仅在开发期间用于检查函数实际上能够重用内存而非分配和复制。

在学习使用 dbgTraceIfShared 时,重要的是要知道 #eval 会报告的值比已编译的代码中共享的值更多, 这可能会令人困惑。重要的是使用 lake 构建可执行文件,而非在编辑器中进行实验。

插入排序由两个循环组成。外层循环将指针从左向右移动到要排序的数组中。 每次迭代后,指针左边的数组区域都会被排序,而右边的区域可能尚未被排序。 内层循环获取指针指向的元素,并将其向左移动,直到找到合适的位置并恢复循环不变式。 换句话说,每次迭代都会将数组的下一个元素插入到已排序区域的合适位置。

内层循环

插入排序的内层循环可以实现为一个尾递归函数,该函数将数组和要插入的元素的索引作为参数。 要插入的元素会与它左边的元素反复交换,直到左边的元素更小或到达数组的开头。 内层循环会在用来索引数组的 Fin 中的 Nat 上进行结构化递归:

def insertSorted [Ord α] (arr : Array α) (i : Fin arr.size) : Array α :=
  match i with
  | ⟨0, _⟩ => arr
  | ⟨i' + 1, _⟩ =>
    have : i' < arr.size := by
      simp [Nat.lt_of_succ_lt, *]
    match Ord.compare arr[i'] arr[i] with
    | .lt | .eq => arr
    | .gt =>
      insertSorted (arr.swap ⟨i', by assumption⟩ i) ⟨i', by simp [*]⟩

若索引 i0,则插入到已排序区域的元素已到达该区域的开头,并且是最小的。 若索引为 i' + 1,则应将 i' 处的元素与 i 处的元素进行比较。 请注意,虽然 iFin arr.size,但 i' 只是一个 Nat,因为它是由 ival 字段产生的。 因此,在使用 i'arr 进行索引之前,有必要证明 i' < arr.size

省略带有证明 i' < arr.sizehave 表达式,将显示以下目标:

unsolved goals
α : Type ?u.7
inst✝ : Ord α
arr : Array α
i : Fin (Array.size arr)
i' : Nat
isLt✝ : i' + 1 < Array.size arr
⊢ i' < Array.size arr

提示 Nat.lt_of_succ_lt 是 Lean 标准库中的一个定理。 它的签名可以通过 #check Nat.lt_of_succ_lt 查看:

Nat.lt_of_succ_lt {n m : Nat} (a✝ : Nat.succ n < m) : n < m

换句话说,它指出如果 n + 1 < m,则 n < m。传递给 simp* 会使其将 Nat.lt_of_succ_lti 中的 isLt 字段结合起来以获得最终证明。

在确定 i' 可用于查找要插入元素左侧的元素后,就要查找并比较这两个元素。 若左侧元素小于或等于要插入的元素,则循环结束并且不变量被恢复。 若左侧元素大于要插入的元素,则交换元素并重新开始内层循环。 Array.swap 将其两个索引都作为 Fin,并且利用 have 建立 i' < arr.sizeby assumption。 在内层循环的下一轮中要检查的索引也是 i',但在这种情况下 by assumption 并足够。 这是因为该证明是针对原始数组 arr 编写的,而非交换两个元素的结果。 simp 策略的数据库包含这样一个事实:交换数组的两个元素不会改变其大小, 并且 [*] 参数会指示它额外使用 have 引入的假设。

外层循环

插入排序的外层循环将指针从左向右移动,在每次迭代中调用 insertSorted 将指针处的元素插入到数组中正确的位置。循环的基本形式类似于 Array.map 的实现:

def insertionSortLoop [Ord α] (arr : Array α) (i : Nat) : Array α :=
  if h : i < arr.size then
    insertionSortLoop (insertSorted arr ⟨i, h⟩) (i + 1)
  else
    arr

它产生的错误也与在 Array.map 上没有 termination_by 子句时发生的错误相同, 因为没有在每次递归调用时都会减少的参数:

fail to show termination for
  insertionSortLoop
with errors
argument #4 was not used for structural recursion
  failed to eliminate recursive application
    insertionSortLoop (insertSorted arr { val := i, isLt := h }) (i + 1)

structural recursion cannot be used

failed to prove termination, use `termination_by` to specify a well-founded relation

在构建停机证明之前,可以使用 partial 修饰符测试定义以确保它返回预期的答案:

partial def insertionSortLoop [Ord α] (arr : Array α) (i : Nat) : Array α :=
  if h : i < arr.size then
    insertionSortLoop (insertSorted arr ⟨i, h⟩) (i + 1)
  else
    arr
#eval insertionSortLoop #[5, 17, 3, 8] 0
#[3, 5, 8, 17]
#eval insertionSortLoop #["metamorphic", "igneous", "sedentary"] 0
#["igneous", "metamorphic", "sedentary"]

停机性

同样,该函数会停机是因为正在处理的索引和数组大小之差在每次递归调用时都会减小。 然而,这一次,Lean 不接受 termination_by

def insertionSortLoop [Ord α] (arr : Array α) (i : Nat) : Array α :=
  if h : i < arr.size then
    insertionSortLoop (insertSorted arr ⟨i, h⟩) (i + 1)
  else
    arr
termination_by insertionSortLoop arr i => arr.size - i
failed to prove termination, possible solutions:
  - Use `have`-expressions to prove the remaining goals
  - Use `termination_by` to specify a different well-founded relation
  - Use `decreasing_by` to specify your own tactic for discharging this kind of goal
α : Type u_1
inst✝ : Ord α
arr : Array α
i : Nat
h : i < Array.size arr
⊢ Array.size (insertSorted arr { val := i, isLt := h }) - (i + 1) < Array.size arr - i

问题在于 Lean 无法知道 insertSorted 返回的数组与传递给它的数组大小相同。 为了证明 insertionSortLoop 会停机,首先有必要证明 insertSorted 不会改变数组的大小。 将未经证明的停机条件从错误消息复制到函数中,并使用 sorry「证明」它,可以暂时接受该函数:

def insertionSortLoop [Ord α] (arr : Array α) (i : Nat) : Array α :=
  if h : i < arr.size then
    have : (insertSorted arr ⟨i, h⟩).size - (i + 1) < arr.size - i := by
      sorry
    insertionSortLoop (insertSorted arr ⟨i, h⟩) (i + 1)
  else
    arr
termination_by insertionSortLoop arr i => arr.size - i
declaration uses 'sorry'

由于 insertSorted 在要插入的元素的索引上是结构化递归的,所以应该通过索引归纳进行证明。 在基本情况下,数组返回不变,因此其长度肯定不会改变。对于归纳步骤, 归纳假设是在下一个更小的索引上的递归调用不会改变数组的长度。 这里有两种情况需要考虑:要么元素已完全插入到已排序区域中,并且数组返回不变, 在这种情况下长度也不会改变,要么元素在递归调用之前与下一个元素交换。 然而,在数组中交换两个元素不会改变它的大小, 并且归纳假设指出以下一个索引的递归调用返回的数组与其参数大小相同。因此,大小仍然保持不变。

将自然语言的定理陈述翻译为 Lean,并使用本章中的技术进行操作,足以证明基本情况并在归纳步骤中取得进展:

theorem insert_sorted_size_eq [Ord α] (arr : Array α) (i : Fin arr.size) :
    (insertSorted arr i).size = arr.size := by
  match i with
  | ⟨j, isLt⟩ =>
    induction j with
    | zero => simp [insertSorted]
    | succ j' ih =>
      simp [insertSorted]

在归纳步骤中使用 insertSorted 的简化揭示了 insertSorted 中的模式匹配:

unsolved goals
case succ
α : Type u_1
inst✝ : Ord α
arr : Array α
i : Fin (Array.size arr)
j' : Nat
ih : ∀ (isLt : j' < Array.size arr), Array.size (insertSorted arr { val := j', isLt := isLt }) = Array.size arr
isLt : Nat.succ j' < Array.size arr
⊢ Array.size
      (match compare arr[j'] arr[{ val := Nat.succ j', isLt := isLt }] with
      | Ordering.lt => arr
      | Ordering.eq => arr
      | Ordering.gt =>
        insertSorted
          (Array.swap arr { val := j', isLt := (_ : j' < Array.size arr) } { val := Nat.succ j', isLt := isLt })
          { val := j',
            isLt :=
              (_ :
                j' <
                  Array.size
                    (Array.swap arr { val := j', isLt := (_ : j' < Array.size arr) }
                      { val := Nat.succ j', isLt := isLt })) }) =
    Array.size arr

当面对包含 ifmatch 的目标时,split 策略(不要与归并排序定义中使用的 split 函数混淆) 会用一个新目标替换原目标,用于控制流的每条路径:

theorem insert_sorted_size_eq [Ord α] (arr : Array α) (i : Fin arr.size) :
    (insertSorted arr i).size = arr.size := by
  match i with
  | ⟨j, isLt⟩ =>
    induction j with
    | zero => simp [insertSorted]
    | succ j' ih =>
      simp [insertSorted]
      split

此外,每个新目标都有一个假设,表明哪个分支导致了该目标,在本例中命名为 heq✝

unsolved goals
case succ.h_1
α : Type u_1
inst✝ : Ord α
arr : Array α
i : Fin (Array.size arr)
j' : Nat
ih : ∀ (isLt : j' < Array.size arr), Array.size (insertSorted arr { val := j', isLt := isLt }) = Array.size arr
isLt : Nat.succ j' < Array.size arr
x✝ : Ordering
heq✝ : compare arr[j'] arr[{ val := Nat.succ j', isLt := isLt }] = Ordering.lt
⊢ Array.size arr = Array.size arr

case succ.h_2
α : Type u_1
inst✝ : Ord α
arr : Array α
i : Fin (Array.size arr)
j' : Nat
ih : ∀ (isLt : j' < Array.size arr), Array.size (insertSorted arr { val := j', isLt := isLt }) = Array.size arr
isLt : Nat.succ j' < Array.size arr
x✝ : Ordering
heq✝ : compare arr[j'] arr[{ val := Nat.succ j', isLt := isLt }] = Ordering.eq
⊢ Array.size arr = Array.size arr

case succ.h_3
α : Type u_1
inst✝ : Ord α
arr : Array α
i : Fin (Array.size arr)
j' : Nat
ih : ∀ (isLt : j' < Array.size arr), Array.size (insertSorted arr { val := j', isLt := isLt }) = Array.size arr
isLt : Nat.succ j' < Array.size arr
x✝ : Ordering
heq✝ : compare arr[j'] arr[{ val := Nat.succ j', isLt := isLt }] = Ordering.gt
⊢ Array.size
      (insertSorted
        (Array.swap arr { val := j', isLt := (_ : j' < Array.size arr) } { val := Nat.succ j', isLt := isLt })
        { val := j',
          isLt :=
            (_ :
              j' <
                Array.size
                  (Array.swap arr { val := j', isLt := (_ : j' < Array.size arr) }
                    { val := Nat.succ j', isLt := isLt })) }) =
    Array.size arr

与其为这两个简单情况编写证明,不如在 split 后添加 <;> try rfl, 这样这两个直接的情况会立即消失,只留下一个目标:

theorem insert_sorted_size_eq [Ord α] (arr : Array α) (i : Fin arr.size) :
    (insertSorted arr i).size = arr.size := by
  match i with
  | ⟨j, isLt⟩ =>
    induction j with
    | zero => simp [insertSorted]
    | succ j' ih =>
      simp [insertSorted]
      split <;> try rfl
unsolved goals
case succ.h_3
α : Type u_1
inst✝ : Ord α
arr : Array α
i : Fin (Array.size arr)
j' : Nat
ih : ∀ (isLt : j' < Array.size arr), Array.size (insertSorted arr { val := j', isLt := isLt }) = Array.size arr
isLt : Nat.succ j' < Array.size arr
x✝ : Ordering
heq✝ : compare arr[j'] arr[{ val := Nat.succ j', isLt := isLt }] = Ordering.gt
⊢ Array.size
      (insertSorted
        (Array.swap arr { val := j', isLt := (_ : j' < Array.size arr) } { val := Nat.succ j', isLt := isLt })
        { val := j',
          isLt :=
            (_ :
              j' <
                Array.size
                  (Array.swap arr { val := j', isLt := (_ : j' < Array.size arr) }
                    { val := Nat.succ j', isLt := isLt })) }) =
    Array.size arr

不幸的是,归纳假设不足以证明这个目标。归纳假设指出对 arr 调用 insertSorted 不会改变大小, 但证明目标是要证明用交换的结果来进行递归调用的结果不会改变大小。成功完成证明需要一个归纳假设, 该假设适用于传递给 insertSorted 的任何数组,以及作为参数的更小的索引。

可以使用 induction 策略的 generalizing 选项来获得强归纳假设。 此选项会将语境中的附加假设引入到一个语句中,该语句用于生成基本情况、归纳假设和在归纳步骤中显示的目标。 对 arr 进行推广会产生更强的假设:

theorem insert_sorted_size_eq [Ord α] (arr : Array α) (i : Fin arr.size) :
    (insertSorted arr i).size = arr.size := by
  match i with
  | ⟨j, isLt⟩ =>
    induction j generalizing arr with
    | zero => simp [insertSorted]
    | succ j' ih =>
      simp [insertSorted]
      split <;> try rfl

在生成的证明目标中,arr 现在是归纳假设中「对于所有」语句的一部分:

unsolved goals
case succ.h_3
α : Type u_1
inst✝ : Ord α
j' : Nat
ih :
  ∀ (arr : Array α),
    Fin (Array.size arr) →
      ∀ (isLt : j' < Array.size arr), Array.size (insertSorted arr { val := j', isLt := isLt }) = Array.size arr
arr : Array α
i : Fin (Array.size arr)
isLt : Nat.succ j' < Array.size arr
x✝ : Ordering
heq✝ : compare arr[j'] arr[{ val := Nat.succ j', isLt := isLt }] = Ordering.gt
⊢ Array.size
      (insertSorted
        (Array.swap arr { val := j', isLt := (_ : j' < Array.size arr) } { val := Nat.succ j', isLt := isLt })
        { val := j',
          isLt :=
            (_ :
              j' <
                Array.size
                  (Array.swap arr { val := j', isLt := (_ : j' < Array.size arr) }
                    { val := Nat.succ j', isLt := isLt })) }) =
    Array.size arr

然而,整个证明开始变得难以控制。下一步是引入一个变量表示交换结果的长度, 证明它等于 arr.size,然后证明这个变量也等于递归调用产生的数组的长度。 之后可以将这些相等语句链接在一起来证明目标。 然而,仔细地重新表述定理的陈述要容易得多,这样归纳假设就能自动变得足够强,变量也会被引入。 重新表述的陈述如下:

theorem insert_sorted_size_eq [Ord α] (len : Nat) (i : Nat) :
    (arr : Array α) → (isLt : i < arr.size) → arr.size = len →
    (insertSorted arr ⟨i, isLt⟩).size = len := by
  skip

这个版本的定理陈述更容易证明,原因有以下几个:

  1. 与其将索引及其有效性证明捆绑在 Fin 中,不如将索引放在数组之前。 这使得归纳假设可以自然地推广到整个数组,并证明 i 在范围内。
  2. 引入了一个抽象长度 len 来表示 array.size。证明自动化通常更擅长处理显式相等性陈述。

生成的证明状态显示了将要用于生成归纳假设的语句,以及基本情况和归纳步骤的目标:

unsolved goals
α : Type u_1
inst✝ : Ord α
len i : Nat
⊢ ∀ (arr : Array α) (isLt : i < Array.size arr),
    Array.size arr = len → Array.size (insertSorted arr { val := i, isLt := isLt }) = len

将该语句与 induction 策略产生的目标进行比较:

theorem insert_sorted_size_eq [Ord α] (len : Nat) (i : Nat) :
    (arr : Array α) → (isLt : i < arr.size) → arr.size = len →
    (insertSorted arr ⟨i, isLt⟩).size = len := by
  induction i with
  | zero => skip
  | succ i' ih => skip

在基本情况下,每个 i 的出现都会被替换为 0。使用 intro 引入每个假设, 然后使用 insertSorted 简化就能证明目标,因为在索引 zero 处的 insertSorted 会返回其参数不变:

unsolved goals
case zero
α : Type u_1
inst✝ : Ord α
len : Nat
⊢ ∀ (arr : Array α) (isLt : Nat.zero < Array.size arr),
    Array.size arr = len → Array.size (insertSorted arr { val := Nat.zero, isLt := isLt }) = len

在归纳步骤中,归纳假设具有恰当的强度。它对 任何 数组都适用,只要该数组的长度为 len

unsolved goals
case succ
α : Type u_1
inst✝ : Ord α
len i' : Nat
ih :
  ∀ (arr : Array α) (isLt : i' < Array.size arr),
    Array.size arr = len → Array.size (insertSorted arr { val := i', isLt := isLt }) = len
⊢ ∀ (arr : Array α) (isLt : Nat.succ i' < Array.size arr),
    Array.size arr = len → Array.size (insertSorted arr { val := Nat.succ i', isLt := isLt }) = len

在基本情况下,simp 将目标简化为 arr.size = len

theorem insert_sorted_size_eq [Ord α] (len : Nat) (i : Nat) :
    (arr : Array α) → (isLt : i < arr.size) → arr.size = len →
    (insertSorted arr ⟨i, isLt⟩).size = len := by
  induction i with
  | zero =>
    intro arr isLt hLen
    simp [insertSorted]
  | succ i' ih => skip
unsolved goals
case zero
α : Type u_1
inst✝ : Ord α
len : Nat
arr : Array α
isLt : Nat.zero < Array.size arr
hLen : Array.size arr = len
⊢ Array.size arr = len

这可以使用假设 hLen 来证明。向 simp 添加 * 参数指示它额外使用假设,这解决了目标:

theorem insert_sorted_size_eq [Ord α] (len : Nat) (i : Nat) :
    (arr : Array α) → (isLt : i < arr.size) → arr.size = len →
    (insertSorted arr ⟨i, isLt⟩).size = len := by
  induction i with
  | zero =>
    intro arr isLt hLen
    simp [insertSorted, *]
  | succ i' ih => skip

在归纳步骤中,引入假设并简化目标会再次产生包含模式匹配的目标:

theorem insert_sorted_size_eq [Ord α] (len : Nat) (i : Nat) :
    (arr : Array α) → (isLt : i < arr.size) → (arr.size = len) →
    (insertSorted arr ⟨i, isLt⟩).size = len := by
  induction i with
  | zero =>
    intro arr isLt hLen
    simp [insertSorted, *]
  | succ i' ih =>
    intro arr isLt hLen
    simp [insertSorted]
unsolved goals
case succ
α : Type u_1
inst✝ : Ord α
len i' : Nat
ih :
  ∀ (arr : Array α) (isLt : i' < Array.size arr),
    Array.size arr = len → Array.size (insertSorted arr { val := i', isLt := isLt }) = len
arr : Array α
isLt : Nat.succ i' < Array.size arr
hLen : Array.size arr = len
⊢ Array.size
      (match compare arr[i'] arr[{ val := Nat.succ i', isLt := isLt }] with
      | Ordering.lt => arr
      | Ordering.eq => arr
      | Ordering.gt =>
        insertSorted
          (Array.swap arr { val := i', isLt := (_ : i' < Array.size arr) } { val := Nat.succ i', isLt := isLt })
          { val := i',
            isLt :=
              (_ :
                i' <
                  Array.size
                    (Array.swap arr { val := i', isLt := (_ : i' < Array.size arr) }
                      { val := Nat.succ i', isLt := isLt })) }) =
    len

使用 split 策略会为每个模式生成一个目标。同样,前两个目标来自没有递归调用的分支,因此不需要归纳假设:

theorem insert_sorted_size_eq [Ord α] (len : Nat) (i : Nat) :
    (arr : Array α) → (isLt : i < arr.size) → (arr.size = len) →
    (insertSorted arr ⟨i, isLt⟩).size = len := by
  induction i with
  | zero =>
    intro arr isLt hLen
    simp [insertSorted, *]
  | succ i' ih =>
    intro arr isLt hLen
    simp [insertSorted]
    split
unsolved goals
case succ.h_1
α : Type u_1
inst✝ : Ord α
len i' : Nat
ih :
  ∀ (arr : Array α) (isLt : i' < Array.size arr),
    Array.size arr = len → Array.size (insertSorted arr { val := i', isLt := isLt }) = len
arr : Array α
isLt : Nat.succ i' < Array.size arr
hLen : Array.size arr = len
x✝ : Ordering
heq✝ : compare arr[i'] arr[{ val := Nat.succ i', isLt := isLt }] = Ordering.lt
⊢ Array.size arr = len

case succ.h_2
α : Type u_1
inst✝ : Ord α
len i' : Nat
ih :
  ∀ (arr : Array α) (isLt : i' < Array.size arr),
    Array.size arr = len → Array.size (insertSorted arr { val := i', isLt := isLt }) = len
arr : Array α
isLt : Nat.succ i' < Array.size arr
hLen : Array.size arr = len
x✝ : Ordering
heq✝ : compare arr[i'] arr[{ val := Nat.succ i', isLt := isLt }] = Ordering.eq
⊢ Array.size arr = len

case succ.h_3
α : Type u_1
inst✝ : Ord α
len i' : Nat
ih :
  ∀ (arr : Array α) (isLt : i' < Array.size arr),
    Array.size arr = len → Array.size (insertSorted arr { val := i', isLt := isLt }) = len
arr : Array α
isLt : Nat.succ i' < Array.size arr
hLen : Array.size arr = len
x✝ : Ordering
heq✝ : compare arr[i'] arr[{ val := Nat.succ i', isLt := isLt }] = Ordering.gt
⊢ Array.size
      (insertSorted
        (Array.swap arr { val := i', isLt := (_ : i' < Array.size arr) } { val := Nat.succ i', isLt := isLt })
        { val := i',
          isLt :=
            (_ :
              i' <
                Array.size
                  (Array.swap arr { val := i', isLt := (_ : i' < Array.size arr) }
                    { val := Nat.succ i', isLt := isLt })) }) =
    len

split 产生的每个目标中运行 try assumption 会消除两个非递归目标:

theorem insert_sorted_size_eq [Ord α] (len : Nat) (i : Nat) :
    (arr : Array α) → (isLt : i < arr.size) → (arr.size = len) →
    (insertSorted arr ⟨i, isLt⟩).size = len := by
  induction i with
  | zero =>
    intro arr isLt hLen
    simp [insertSorted, *]
  | succ i' ih =>
    intro arr isLt hLen
    simp [insertSorted]
    split <;> try assumption
unsolved goals
case succ.h_3
α : Type u_1
inst✝ : Ord α
len i' : Nat
ih :
  ∀ (arr : Array α) (isLt : i' < Array.size arr),
    Array.size arr = len → Array.size (insertSorted arr { val := i', isLt := isLt }) = len
arr : Array α
isLt : Nat.succ i' < Array.size arr
hLen : Array.size arr = len
x✝ : Ordering
heq✝ : compare arr[i'] arr[{ val := Nat.succ i', isLt := isLt }] = Ordering.gt
⊢ Array.size
      (insertSorted
        (Array.swap arr { val := i', isLt := (_ : i' < Array.size arr) } { val := Nat.succ i', isLt := isLt })
        { val := i',
          isLt :=
            (_ :
              i' <
                Array.size
                  (Array.swap arr { val := i', isLt := (_ : i' < Array.size arr) }
                    { val := Nat.succ i', isLt := isLt })) }) =
    len

对于证明目标的全新表述,其中常量 len 用于递归函数中涉及的所有数组的长度, 恰好属于 simp 可以解决的问题类型。最终的证明目标可以通过 simp [*] 来解决, 因为将数组的长度与 len 联系起来的假设很重要:

theorem insert_sorted_size_eq [Ord α] (len : Nat) (i : Nat) :
    (arr : Array α) → (isLt : i < arr.size) → (arr.size = len) →
    (insertSorted arr ⟨i, isLt⟩).size = len := by
  induction i with
  | zero =>
    intro arr isLt hLen
    simp [insertSorted, *]
  | succ i' ih =>
    intro arr isLt hLen
    simp [insertSorted]
    split <;> try assumption
    simp [*]

最后,因为 simp [*] 可以使用假设,所以 try assumption 一行可以用 simp [*] 替换来缩短证明:

theorem insert_sorted_size_eq [Ord α] (len : Nat) (i : Nat) :
    (arr : Array α) → (isLt : i < arr.size) → (arr.size = len) →
    (insertSorted arr ⟨i, isLt⟩).size = len := by
  induction i with
  | zero =>
    intro arr isLt hLen
    simp [insertSorted, *]
  | succ i' ih =>
    intro arr isLt hLen
    simp [insertSorted]
    split <;> simp [*]

现在可以使用这个证明来替换 insertionSortLoop 中的 sorry。 将 arr.size 作为定理的 len 参数会导致最终结论为 (insertSorted arr ⟨i, isLt⟩).size = arr.size, 因此重写以一个非常易于管理的证明目标结束:

  def insertionSortLoop [Ord α] (arr : Array α) (i : Nat) : Array α :=
    if h : i < arr.size then
      have : (insertSorted arr ⟨i, h⟩).size - (i + 1) < arr.size - i := by
        rw [insert_sorted_size_eq arr.size i arr h rfl]
      insertionSortLoop (insertSorted arr ⟨i, h⟩) (i + 1)
    else
      arr
termination_by insertionSortLoop arr i => arr.size - i
unsolved goals
α : Type ?u.22173
inst✝ : Ord α
arr : Array α
i : Nat
h : i < Array.size arr
⊢ Array.size arr - (i + 1) < Array.size arr - i

证明 Nat.sub_succ_lt_self 是 Lean 标准库的一部分,其类型为 ∀ (a i : Nat), i < a → a - (i + 1) < a - i 它刚好就是我们所需要的:

def insertionSortLoop [Ord α] (arr : Array α) (i : Nat) : Array α :=
  if h : i < arr.size then
    have : (insertSorted arr ⟨i, h⟩).size - (i + 1) < arr.size - i := by
      rw [insert_sorted_size_eq arr.size i arr h rfl]
      simp [Nat.sub_succ_lt_self, *]
    insertionSortLoop (insertSorted arr ⟨i, h⟩) (i + 1)
  else
    arr
termination_by insertionSortLoop arr i => arr.size - i

驱动函数

插入排序本身会调用 insertionSortLoop,以将数组中已排序区域与未排序区域的分界索引初始化为 0

def insertionSort [Ord α] (arr : Array α) : Array α :=
   insertionSortLoop arr 0

一些快速测试表明该函数至少不是明显错误的:

#eval insertionSort #[3, 1, 7, 4]
#[1, 3, 4, 7]
#eval insertionSort #[ "quartz", "marble", "granite", "hematite"]
#["granite", "hematite", "marble", "quartz"]

它真的是插入排序吗?

插入排序被 定义 为原地排序算法。尽管它具有二次最差运行时间,但它仍然有用, 因为它是一种稳定的排序算法,不会分配额外的空间,并且可以有效处理几乎已排序的数据。 如果内层循环的每次迭代都分配一个新数组,那么该算法就不会真正成为插入排序。

Lean 的数组操作(例如 Array.setArray.swap)会检查所讨论的数组的引用计数是否大于 1。 如果是,则该数组对代码的多个部分可见,这意味着它必须被复制。 否则,Lean 将不再是一种纯函数式语言。但是,当引用计数恰好为 1 时,没有其他潜在的值观察者。 在这种情况下,数组原语会就地改变数组。程序其他不知道的部分不会对它造成破坏。

Lean 的证明逻辑在纯函数式程序的级别上,而非在底层实现上工作。 这意味着发现程序是否不必要地复制了数据的最好方法是测试它。 在需要改变的每个点添加对 dbgTraceIfShared 的调用,当所讨论的值有多个引用时, 它会将提供的消息打印到 stderr

插入排序刚好有一个地方有复制而非改变的风险:调用 Array.swap。将 arr.swap ⟨i', by assumption⟩ i 替换为 ((dbgTraceIfShared "array to swap" arr).swap ⟨i', by assumption⟩ i) 会让程序在无法改变数组时发出 shared RC array to swap。然而,对程序的这一更改也会更改证明, 因为现在调用了一个附加函数。由于 dbgTraceIfShared 直接返回其第二个参数, 因此将其添加到对 simp 的调用中足以修复证明。

插入排序的完整形式化验证代码为:

def insertSorted [Ord α] (arr : Array α) (i : Fin arr.size) : Array α :=
  match i with
  | ⟨0, _⟩ => arr
  | ⟨i' + 1, _⟩ =>
    have : i' < arr.size := by
      simp [Nat.lt_of_succ_lt, *]
    match Ord.compare arr[i'] arr[i] with
    | .lt | .eq => arr
    | .gt =>
      insertSorted
        ((dbgTraceIfShared "array to swap" arr).swap ⟨i', by assumption⟩ i)
        ⟨i', by simp [dbgTraceIfShared, *]⟩

theorem insert_sorted_size_eq [Ord α] (len : Nat) (i : Nat) :
    (arr : Array α) → (isLt : i < arr.size) → (arr.size = len) →
    (insertSorted arr ⟨i, isLt⟩).size = len := by
  induction i with
  | zero =>
    intro arr isLt hLen
    simp [insertSorted, *]
  | succ i' ih =>
    intro arr isLt hLen
    simp [insertSorted, dbgTraceIfShared]
    split <;> simp [*]

def insertionSortLoop [Ord α] (arr : Array α) (i : Nat) : Array α :=
  if h : i < arr.size then
    have : (insertSorted arr ⟨i, h⟩).size - (i + 1) < arr.size - i := by
      rw [insert_sorted_size_eq arr.size i arr h rfl]
      simp [Nat.sub_succ_lt_self, *]
    insertionSortLoop (insertSorted arr ⟨i, h⟩) (i + 1)
  else
    arr
termination_by insertionSortLoop arr i => arr.size - i

def insertionSort [Ord α] (arr : Array α) : Array α :=
  insertionSortLoop arr 0

要检查形式化验证是否实际起作用,需要一点技巧。首先,当所有参数在编译时都已知时, Lean 编译器会积极地优化函数调用。仅仅编写一个将 insertionSort 应用于大数组的程序是不够的, 因为生成的编译代码可能只包含已排序的数组作为常量。确保编译器不会优化排序例程的最简单方法是从 stdin 读取数组。其次,编译器会执行死代码消除。如果从未使用 let 绑定的变量, 则向程序中添加额外的 let 并不一定会导致运行代码中更多的引用。为了确保不会完全消除额外的引用, 重点在于确保以某种方式使用了额外的引用。

测试形式化验证代码的第一步是编写 getLines,它从标准输入读取一行数组:

def getLines : IO (Array String) := do
  let stdin ← IO.getStdin
  let mut lines : Array String := #[]
  let mut currLine ← stdin.getLine
  while !currLine.isEmpty do
     -- Drop trailing newline:
    lines := lines.push (currLine.dropRight 1)
    currLine ← stdin.getLine
  pure lines

IO.FS.Stream.getLine 返回一行完整的文本,包括结尾的换行。当到达文件结尾标记时,它返回空字符串 ""

接下来,需要两个单独的 main 例程。两者都从标准输入读取要排序的数组, 确保在编译时不会用它们的返回值替换对 insertionSort 的调用。然后两者都打印到控制台, 确保对 insertionSort 的调用不会被完全优化掉。其中一个只打印排序后的数组, 而另一个同时打印排序后的数组和原始数组。第二个函数应该触发一个警告, 即 Array.swap 必须分配一个新数组:

def mainUnique : IO Unit := do
  let lines ← getLines
  for line in insertionSort lines do
    IO.println line

def mainShared : IO Unit := do
  let lines ← getLines
  IO.println "--- Sorted lines: ---"
  for line in insertionSort lines do
    IO.println line

  IO.println ""
  IO.println "--- Original data: ---"
  for line in lines do
    IO.println line

实际的 main 只需根据提供的命令行参数选择两个 main 活动二者之一:

def main (args : List String) : IO UInt32 := do
  match args with
  | ["--shared"] => mainShared; pure 0
  | ["--unique"] => mainUnique; pure 0
  | _ =>
    IO.println "Expected single argument, either \"--shared\" or \"--unique\""
    pure 1

在没有参数的情况下运行它会产生预期的用法信息:

$ sort
Expected single argument, either "--shared" or "--unique"

test-data 文件包含以下岩石:

schist
feldspar
diorite
pumice
obsidian
shale
gneiss
marble
flint

对这些岩石使用形式化验证的插入排序,结果按字母顺序打印出来:

$ sort --unique < test-data
diorite
feldspar
flint
gneiss
marble
obsidian
pumice
schist
shale

然而,保留对原始数组的引用的版本会导致对 Array.swap 的第一次调用在 stderr 上发出通知(即 shared RC array to swap):

$ sort --shared < test-data
shared RC array to swap
--- Sorted lines: ---
diorite
feldspar
flint
gneiss
marble
obsidian
pumice
schist
shale

--- Original data: ---
schist
feldspar
diorite
pumice
obsidian
shale
gneiss
marble
flint

仅出现一个 shared RC 通知这一事实意味着数组仅被复制了一次。 这是因为由对 Array.swap 的调用产生的副本本身是唯一的,因此不需要进行进一步的复制。 在命令式语言中,由于忘记在按引用传递数组之前显式复制数组,可能会导致微妙的 Bug。 在运行 sort --shared 时,数组会安需复制,以保持 Lean 程序的纯函数语义,但仅此而已。

其他可变性的机会

当引用唯一时,使用修改而非复制并不仅限于数组更新操作。 Lean 还会尝试「回收」引用计数即将降至零的构造函数,重新使用它们而不是分配新数据。 这意味着,例如,List.map 将原地修改链表,至少在无人能注意到的情况下。 优化 Lean 代码中的热循环最重要的步骤之一是确保被修改的数据不会被从多个位置引用。

练习

  • 编写一个反转数组的函数。测试如果输入数组的引用计数为一,则你的函数不会分配一个新数组。
  • 为数组实现归并排序或快速排序。证明你的实现会停机,并测试它不会分配比预期更多的数组。 这是一个具有挑战性的练习!

特殊类型

理解数据在内存中的表示非常重要。通常,可以从数据类型的定义中理解它的表示。 每个构造子对应于内存中的一个对象,该对象有一个包含标记和引用计数的头。 构造子的参数分别由指向其他对象的指针表示。换句话说,List 实际上是一个链表, 从 structure 中提取一个字段实际上只是跟随一个指针。

然而,这个规则有一些重要的例外。编译器对许多类型进行了特殊处理。 例如,类型 UInt32 被定义为 Fin (2 ^ 32),但在运行时它会被替换为基于机器字的实际原生实现。 类似地,尽管 Nat 的定义暗示了一个类似于 List Unit 的实现, 但实际的运行时表示会对足够小的数字使用立即(immediate)机器字, 对较大的数字则使用高效的任意精度算术库。Lean 编译器会将使用模式匹配的定义转换为与其表示对应的适当操作, 并且对加法和减法等操作的调用会被映射到底层算术库中的快速操作。 毕竟,加法不应该花费与加数大小成线性的时间。

由于某些类型具有特殊表示,因此在使用它们时需要小心。 这些类型中的大多数由编译器特殊处理的 structure 组成。对于这些结构体, 直接使用构造子或字段访问器可能会触发从高效表示到方便证明的低效表示的昂贵转换。 例如,String 被定义为包含字符列表的结构体,但字符串的运行时表示使用了 UTF-8, 而非指向字符的指针链表。将构造子应用于字符列表会创建一个以 UTF-8 编码它们的字节数组, 而访问结构体的字段需要线性时间来解码 UTF-8 的表示并分配一个链表。数组的表示方式类似。 从逻辑角度来看,数组是包含数组元素列表的结构体,但运行时表示则是一个动态大小的数组。 在运行时,构造子会将列表转换为数组,而字段访问器则会在数组中分配一个链表。 编译器用高效的版本替换了各种数组操作,这些版本在可能的情况下会改变数组,而非分配一个新的数组。

类型本身和命题的证明都会从编译后的代码中完全擦除。换句话说,它们不会占用任何空间, 证明过程中可能执行的任何计算也同样会被擦除, 这意味着证明可以利用字符串和数组作为归纳定义列表的简便接口,包括使用归纳法来证明它们, 而不会在程序运行时施加缓慢的转换步骤。对于这些内置类型,数据的简便逻辑表示并不意味着程序一定会很慢。

如果一个结构体类型只有一个非类型,非证明的字段,那么构造子自身会在运行时消失, 并被替换为其单个参数。换句话说,其子类型与其底层类型完全相同,不会带有额外的间接层。 同样,Fin 在内存中只是 Nat,并且可以创建单字段结构体来跟踪 NatString 的不同用法, 而无需支付性能损失。如果一个构造子没有非类型,非证明的参数,那么该构造子也会消失, 并被一个常量值替换,否则指针将用于该常量值。这意味着 truefalsenone 是常量值, 而非指向堆分配对象的指针。

以下类型拥有特殊的表示:

类型逻辑表示运行时表示
Nat一元类型,每个 Nat.succ 都有一个指针高效的任意精度整数
Int和类型,带有表示正负值的构造子,每个包含一个 Nat高效的任意精度整数
UInt8UInt16UInt32UInt64带有合适边界的 Fin固定精度的机器整数
CharUInt32 以及与之配对的它是有效码位的证明一般字符
String在名为 data 的字段中包含 List Char 的结构体UTF-8 编码的字符串
Array α在名为 data 的字段中包含 List α 的结构体指向 α 值的指针打包的数组
Sort u一个类型完全擦除
命题的证明当命题被视为证据类型时,命题所暗示的任何数据完全擦除

练习

Pos 的 定义 并没有利用 Lean 将 Nat 编译成高效类型的优势。 在运行时,它本质上是一个链表。或者,可以定义一个子类型,允许在内部使用 Lean 的快速 Nat 类型, 如 子类型的开头部分 中所述。 在运行时,证明将被擦除。由于结果结构体只有一个数据字段,因此它会表示为该字段, 这意味着 Pos 的这种新表示与 Nat 的表示相同。

在证明定理 ∀ {n k : Nat}, n ≠ 0 → k ≠ 0 → n + k ≠ 0 之后,为 Pos 这种新的表示定义 ToStringAdd 的实例。然后,定义 Mul 的实例,在此过程中证明任何必要的定理。

总结

尾递归

尾递归是一种递归,其中递归调用的结果会立即返回,而非以其他方式使用。 这些递归调用称为「尾调用」。尾调用很有趣,因为它们可以编译成跳转指令而非调用指令, 并且可以重新使用当前栈帧,而非压入新的一帧。换句话说,尾递归函数实际上就是循环。

使递归函数更快的常用方法是使用累加器传递风格对其进行重写。 它不使用调用栈来记住如何处理递归调用的结果,而是使用一个名为「累加器」的附加参数来收集此信息。 例如,用于反转列表的尾递归函数的累加器按相反顺序包含已经处理过的列表项。

在 Lean 中,只有自尾调用(self-tail-call)会被优化为循环。 换句话说,两个以互相尾调用结束的函数不会被优化。

引用计数与原地更新

与 Java、C# 和大多数 JavaScript 实现中那样使用跟踪垃圾收集器不同, Lean 使用引用计数进行内存管理。这意味着内存中的每个值都包含一个字段, 该字段跟踪引用它的其他值的数量,并且运行时系统在引用出现或消失时维护这些计数。 引用计数也用在了 Python、PHP 和 Swift 中。

当要求分配一个新对象时,Lean 的运行时系统能够回收引用计数降为零的现有对象。 此外,如果数组的引用计数为一,则数组操作(如 Array.setArray.swap)将修改原数组, 而非分配一个修改后的副本。如果 Array.swap 持有对数组的唯一引用, 那么程序的其他部分就无法分辨它是被改变了还是被复制了。

在 Lean 中编写高效的代码需要使用尾递归,并小心确保大数组被唯一使用。 虽然可以通过检查函数的定义来识别尾调用,但了解一个值是否被唯一引用可能需要阅读整个程序。 调试辅助函数 dbgTraceIfShared 可以用在程序的关键位置来检查一个值是否被共享。

证明程序的正确性

以累加器传递样式重写程序,或进行其他使程序运行更快的转换,也可能会让程序更难理解。 保留程序的原始版本(正确性更加明显)是有用的,然后将其用作优化版本的可执行规范。 虽然单元测试等技术在 Lean 中与在任何其他语言中一样有效, 但 Lean 还允许使用数学证明来完全确保函数的两个版本对 所有 可能的输入返回相同的结果。

通常,证明两个函数相等是使用函数外延性(funext 策略)完成的, 即如果两个函数对每个输入返回相同的值,则它们相等。如果函数是递归的, 那么归纳法通常是证明其输出相同的好方法。通常,函数的递归定义将对一个特定参数进行递归调用; 这个参数是归纳的一个好选择。在某些情况下,归纳假设不够充分。 解决这个问题通常需要考虑如何构建定理陈述的更通用版本,以提供足够充分的归纳假设。 特别是,为了证明一个函数等价于一个累加器传递版本, 需要一个将任意初始累加器值与原始函数的最终结果联系起来的定理陈述。

安全的数组索引

类型 Fin n 表示严格小于 n 的自然数。Fin 是「有限」的缩写。 与子类型一样,Fin n 是一个包含 Nat 和证明这个 Nat 小于 n 的结构体。 不存在类型为 Fin 0 的值。

如果 arr 是一个 Array α,那么 Fin arr.size 总是包含一个适合作为 arr 索引的数字。 许多内置数组运算符(例如 Array.swap)会将 Fin 值作为参数,而非独立的证明对象。

Lean 为 Fin 提供了大多数有用的数字类型类的实例。FinOfNat 实例会执行模运算, 而非在提供的数字大于 Fin 可接受的数字时在编译时失败。

临时性证明

有时,当某个陈述实际上尚未被证明,而假装它已被证明是有用的。 这在确保一个陈述的证明是否适用于某些任务时很有用,例如在另一个证明中重写、 确定数组访问是否安全、或显示递归调用是在比原始参数更小的值上进行的。 花时间证明某件事,却发现其他一些证明更有用,这是非常令人沮丧的。

sorry 策略使 Lean 临时接受一个陈述,就好像它是真正的证明一样。 它可以看作类似于在 C# 中抛出 NotImplementedException 的桩(stub)方法。 任何依赖于 sorry 的证明都会在 Lean 中包含一个警告。

小心!sorry 策略可以证明 任何 陈述,甚至是错误的陈述。 证明 3 < 2 可能会导致数组越界访问持续到运行时,意外地使程序崩溃。 在开发过程中使用 sorry 很方便,但将其保留在代码中很危险。

停机证明

当一个递归函数不使用结构体递归时,Lean 无法自动确定它是否停机。 在这些情况下,该函数可以用 partial 标记为偏函数。但是,也可以提供证明函数停机的证明。

偏函数有一个关键的缺点:它们不能在类型检查或证明中展开。 这意味着 Lean 作为交互式定理证明器的价值不能应用于它们。 此外,证明一个预期停机的函数实际上总是停机,可以消除更多潜在的 bug 来源。

递归函数末尾允许的 termination_by 子句可用于指定递归函数停机的原因。 该子句将函数的参数映射到一个表达式,该表达式预期在每次递归调用时都会变小。 可能减小的表达式的示例包括不断增长的数组索引与数组大小之间的差、 每次递归调用时减半的列表长度,或一对列表,其中恰好一个在每次递归调用时都会缩小。

Lean 包含的证明自动化可以自动确定某些表达式在每次调用时都会缩小,但许多有趣的程序需要手动证明。 这些证明可以使用 have 提供,havelet 的一个版本,旨在局部提供证明而非值。

编写递归函数的一个好方法是从声明它们为 partial 开始,并通过测试调试它们, 直到它们返回正确的答案。然后,可以删除 partial 并用 termination_by 子句替换它。 Lean 会在需要证明的每个递归调用上放置错误高亮,其中包含需要证明的语句。 每个这样的语句都可以放在 have 中,证明为 sorry。 如果 Lean 接受该程序并且它仍然通过测试,最后一步就是实际证明使 Lean 接受它的定理。 这种方法可以防止浪费时间来证明一个有缺陷的程序的停机性。

下一步

本书介绍了 Lean 中函数式编程的基本知识,包括一些互动定理证明的内容。 使用依值类型的函数式语言(如 Lean)是一个深奥的主题,内容丰富。 根据您的兴趣,以下资源可能对学习 Lean 4 有用。

学习 Lean

Lean 4 本身在以下资源中有详细描述:

Lean 4 定理证明》 是一本关于使用 Lean 编写证明的教程。 《Lean 4 手册》 提供了 Lean 语言及其功能的参考资料。虽然在撰写本文时它仍未完成, 但它比本书更详细地描述了 Lean 的许多方面。 《怎样用 Lean 证明数学题》是著名教材 《怎样证明数学题》的 Lean 版伴随读物, 介绍了如何编写纸笔数学证明。 《Lean 4 元编程》概述了 Lean 的扩展机制, 从中缀运算符和符号到宏、自定义策略和完整的自定义嵌入式语言。 《Lean 函数式编程》可能对喜欢递归笑话的读者来说很有趣。 然而,继续学习 Lean 的最佳方式是开始阅读和编写代码,在遇到困难时查阅文档。 此外,Lean Zulip 是结识其他 Lean 用户、 寻求帮助和帮助他人的好地方。

标准库

Lean 自带的库相对较小。Lean 是自托管的,所包含的代码仅够实现 Lean 本身。 对于许多应用来说,需要更大的标准库。

std4 是一个正在开发中的标准库, 包含许多数据结构、策略、类型类实例和函数,这些都超出了 Lean 编译器本身的范围。 要使用 std4,第一步是找到与您使用的 Lean 4 版本兼容的提交记录(即其中的 lean-toolchain 文件与您的项目匹配)。然后,将以下内容添加到您的 lakefile.lean 顶层, 其中 COMMIT_HASH 是适当的版本:

require std from git
  "https://github.com/leanprover/std4/" @ "COMMIT_HASH"

Lean 形式化数学

大多数数学资源是为 Lean 3 编写的。 在社区网站上可以找到许多这样的资源。 要开始在 Lean 4 中进行数学研究,最简单的方法可能是参与将数学库 mathlib 从 Lean 3 迁移到 Lean 4 的过程。 有关更多信息,请参阅 mathlib4 的 README.

在计算机科学中使用依值类型

Coq 是一种与 Lean 有许多共同点的语言。对于计算机科学家来说, 《软件基础》系列教材提供了一个很好的介绍, 介绍了 Coq 在计算机科学中的应用。Lean 和 Coq 的基本思想非常相似, 编程技巧在两个语言之间是可以相互转换的。

使用依值类型编程

对于有兴趣学习使用索引族和依值类型来构建程序的程序员来说,Edwin Brady 的 《Idris 类型驱动开发》 提供了一个很好的介绍。和 Coq 一样,Idris 是 Lean 的近亲语言,但是它缺乏策略。

理解依值类型

The Little Typer》是一本为没有正式学习过逻辑或编程语言理论, 但希望理解依值类型论核心思想的程序员准备的书。虽然上述所有资源都旨在实现尽可能的实用, 但这本书通过从头开始构建基础,使用仅来自编程的概念来呈现依值类型理论的方法。

免责声明:《Functional Programming in Lean》的作者也是《The Little Typer》的作者之一。