Lean 4 定理证明

作者:Jeremy Avigad, Leonardo de Moura, Soonho Kong and Sebastian Ullrich, 以及来自 Lean 社区的贡献者

Lean-zh 项目组

本书假定你使用 Lean 4。安装方式参见 Lean 4 手册 中的 快速开始 一节。 本书的第一版为 Lean 2 编写,Lean 3 版请访问 此处

简介

计算机和定理证明

形式验证(Formal Verification) 是指使用逻辑和计算方法来验证用精确的数学术语表达的命题。 这包括普通的数学定理,以及硬件或软件、网络协议、机械和混合系统中的形式命题。 在实践中,验证数学命题和验证系统的正确性之间很类似:形式验证用数学术语描述硬件和软件系统, 在此基础上验证其命题的正确性,这就像定理证明的过程。相反,一个数学定理的证明可能需要冗长的计算, 在这种情况下,验证定理的真实性需要验证计算过程是否出错。

二十世纪的逻辑学发展表明,绝大多数传统证明方法可以化为若干基础系统中的一小套公理和规则。 有了这种简化,计算机能以两种方式帮助建立命题:1)它可以帮助寻找一个证明, 2)可以帮助验证一个所谓的证明是正确的。

自动定理证明(Automated theorem proving) 着眼于「寻找」证明。 归结原理(Resolution) 定理证明器、表格法(tableau) 定理证明器、 快速可满足性求解器(Fast satisfiability solvers) 等提供了在命题逻辑和一阶逻辑中验证公式有效性的方法; 还有些系统为特定的语言和问题提供搜索和决策程序, 例如整数或实数上的线性或非线性表达式; 像 SMT(Satisfiability modulo theories,可满足性模理论) 这样的架构将通用的搜索方法与特定领域的程序相结合; 计算机代数系统和专门的数学软件包提供了进行数学计算或寻找数学对象的手段, 这些系统中的计算也可以被看作是一种证明,而这些系统也可以帮助建立数学命题。

自动推理系统努力追求能力和效率,但往往牺牲稳定性。这样的系统可能会有 bug, 而且很难保证它们所提供的结果是正确的。相比之下,交互式定理证明器(Interactive theorem proving) 侧重于「验证」证明,要求每个命题都有合适的公理基础的证明来支持。 这就设定了非常高的标准:每一条推理规则和每一步计算都必须通过求助于先前的定义和定理来证明, 一直到基本公理和规则。事实上,大多数这样的系统提供了精心设计的「证明对象」, 可以传给其他系统并独立检查。构建这样的证明通常需要用户更多的输入和交互, 但它可以让你获得更深入、更复杂的证明。

Lean 定理证明器 旨在融合交互式和自动定理证明, 它将自动化工具和方法置于一个支持用户交互和构建完整公理化证明的框架中。 它的目标是支持数学推理和复杂系统的推理,并验证这两个领域的命题。

Lean 的底层逻辑有一个计算的解释,与此同时 Lean 可以被视为一种编程语言。 更重要的是,它可以被看作是一个编写具有精确语义的程序的系统, 以及对程序所表示的计算功能进行推理。Lean 中也有机制使它能够作为它自己的 元编程语言, 这意味着你可以使用 Lean 本身实现自动化和扩展 Lean 的功能。 Lean 的这些方面将在本教程的配套教程 Lean 4函数式编程中进行探讨,本书只介绍计算方面。

关于 Lean

Lean 项目由微软 Redmond 研究院的 Leonardo de Moura 在 2013 年发起,这是个长期项目, 自动化方法的潜力会在未来逐步被挖掘。Lean 是在 Apache 2.0 许可协议 下发布的, 这是一个允许他人自由使用和扩展代码和数学库的许可性开源许可证。

通常有两种办法来运行Lean。第一个是网页版本: 由 JavaScript 编写,包含标准定义和定理库,编辑器会下载到你的浏览器上运行。 这是个方便快捷的办法。

第二种是本地版本:本地版本远快于网页版本,并且非常灵活。Visual Studio Code 和 Emacs 扩展模块给编写和调试证明提供了有力支撑,因此更适合正式使用。 源代码和安装方法见https://github.com/leanprover/lean4/.

本教程介绍的是 Lean 的当前版本:Lean 4。

关于本书

本书的目的是教你在 Lean 中编写和验证证明,并且不太需要针对 Lean 的基础知识。首先,你将学习 Lean 所基于的逻辑系统,它是 依值类型论(Dependent type theory) 的一个版本,足以证明几乎所有传统的数学定理,并且有足够的表达能力自然地表示数学定理。更具体地说,Lean 是基于具有归纳类型(Inductive type)的构造演算(Calculus of Construction)系统的类型论版本。Lean 不仅可以定义数学对象和表达依值类型论的数学断言,而且还可以作为一种语言来编写证明。

由于完全深入细节的公理证明十分复杂,定理证明的难点在于让计算机尽可能多地填补证明细节。 你将通过依值类型论语言来学习各种方法实现自动证明,例如项重写, 以及 Lean 中的项和表达式的自动简化方法。同样,繁饰(Elaboration)类型推断(Type inference) 的方法,可以用来支持灵活的代数推理。

最后,你会学到 Lean 的一些特性,包括与系统交流的语言,和 Lean 提供的对复杂理论和数据的管理机制。

在本书中你会见到类似下面这样的代码:

theorem and_commutative (p q : Prop) : p ∧ q → q ∧ p :=
  fun hpq : p ∧ q =>
  have hp : p := And.left hpq
  have hq : q := And.right hpq
  show q ∧ p from And.intro hq hp

如果你在 VS Code 中阅读本书,你会看到一个按钮, 上面写着「try it!」按下按钮将示例复制到编辑器中,并带有足够的周围上下文,以使代码正确编译。 您可以在编辑器中输入内容并修改示例,Lean 将在您输入时检查结果并不断提供反馈。 我们建议您在阅读后面的章节时,自己运行示例并试验代码。你可以通过使用 「Lean 4: Open Documentation View」命令在 VS Code 中打开本书。

致谢

本教程是一项开放源代码项目,在 Github 上维护。许多人为此做出了贡献,提供了 更正、建议、示例和文本。我们要感谢 Ulrik Buchholz、Kevin Buzzard、Mario Carneiro、 Nathan Carter、Eduardo Cavazos、Amine Chaieb、Joe Corneli、William DeMeo、 Marcus Klaas de Vries、Ben Dyer、Gabriel Ebner、Anthony Hart、Simon Hudon、Sean Leather、 Assia Mahboubi、Gihan Marasingha、Patrick Massot、Christopher John Mazey、 Sebastian Ullrich、Floris van Doorn、Daniel Velleman、Théo Zimmerman、Paul Chisholm、Chris Lovett 以及 Siddhartha Gadgil 对本文做出的贡献。有关我们杰出的贡献者的最新名单, 请参见 Lean 证明器Lean 社区

依值类型论

依值类型论(Dependent type theory)是一种强大而富有表达力的语言,允许你表达复杂的数学断言,编写复杂的硬件和软件规范,并以自然和统一的方式对这两者进行推理。Lean 是基于一个被称为构造演算(Calculus of Constructions)的依值类型论的版本,它拥有一个可数的非累积性宇宙(non-cumulative universe)的层次结构以及归纳类型(Inductive type)。在本章结束时,你将学会一大部分。

简单类型论

「类型论」得名于其中每个表达式都有一个类型。举例:在一个给定的语境中,x + 0 可能表示一个自然数,f 可能表示一个定义在自然数上的函数。Lean 中的自然数是任意精度的无符号整数。

这里的一些例子展示了如何声明对象以及检查其类型。

/- 定义一些常数 -/

def m : Nat := 1       -- m 是自然数
def n : Nat := 0
def b1 : Bool := true  -- b1 是布尔型
def b2 : Bool := false

/- 检查类型 -/

#check m            -- 输出: Nat
#check n
#check n + 0        -- Nat
#check m * (n + 0)  -- Nat
#check b1           -- Bool
#check b1 && b2     -- "&&" is the Boolean and
#check b1 || b2     -- Boolean or
#check true         -- Boolean "true"

/- 求值(Evaluate) -/

#eval 5 * 4         -- 20
#eval m + 2         -- 3
#eval b1 && b2      -- false

位于 /--/ 之间的文本组成了一个注释块,会被 Lean 的编译器忽略。类似地,两条横线--后面也是注释。注释块可以嵌套,这样就可以「注释掉」一整块代码,这和任何程序语言都是一样的。

def 关键字声明工作环境中的新常量符号。在上面的例子中,def m : Nat := 1定义了一个 Nat 类型的新常量 m,其值为 1#check 命令要求 Lean 给出它的类型,用于向系统询问信息的辅助命令都以井号(#)开头。#eval命令让 Lean 计算给出的表达式。你应该试试自己声明一些常量和检查一些表达式的类型。

简单类型论的强大之处在于,你可以从其他类型中构建新的类型。例如,如果 ab 是类型,a -> b 表示从 ab 的函数类型,a × b 表示由 a 元素与 b 元素配对构成的类型,也称为笛卡尔积。注意×是一个 Unicode 符号,可以使用 \times 或简写 \tim 来输入。合理使用 Unicode 提高了易读性,所有现代编辑器都支持它。在 Lean 标准库中,你经常看到希腊字母表示类型,Unicode符号->的更紧凑版本。

#check Nat → Nat      -- 用"\to" or "\r"来打出这个箭头
#check Nat -> Nat     -- 也可以用 ASCII 符号

#check Nat × Nat      -- 用"\times"打出乘号
#check Prod Nat Nat   -- 换成ASCII 符号

#check Nat → Nat → Nat
#check Nat → (Nat → Nat)  --  结果和上一个一样

#check Nat × Nat → Nat
#check (Nat → Nat) → Nat -- 一个「泛函」

#check Nat.succ     -- Nat → Nat
#check (0, 1)       -- Nat × Nat
#check Nat.add      -- Nat → Nat → Nat

#check Nat.succ 2   -- Nat
#check Nat.add 3    -- Nat → Nat
#check Nat.add 5 2  -- Nat
#check (5, 9).1     -- Nat
#check (5, 9).2     -- Nat

#eval Nat.succ 2   -- 3
#eval Nat.add 5 2  -- 7
#eval (5, 9).1     -- 5
#eval (5, 9).2     -- 9

同样,你应该自己尝试一些例子。

让我们看一些基本语法。你可以通过输入 \to 或者 \r 或者 \-> 来输入 。你也可以就用 ASCII 码 ->,所以表达式 Nat -> NatNat → Nat 意思是一样的,都表示以一个自然数作为输入并返回一个自然数作为输出的函数类型。Unicode符号 × 是笛卡尔积,用 \times 输入。小写的希腊字母 αβ,和 γ 等等常用来表示类型变量,可以用 \a\b,和 \g 来输入。

这里还有一些需要注意的事情。第一,函数 f 应用到值 x 上写为 f x(例:Nat.succ 2)。第二,当写类型表达式时,箭头是右结合的;例如,Nat.add 的类型是 Nat → Nat → Nat,等价于 Nat → (Nat → Nat)

因此你可以把 Nat.add 看作一个函数,它接受一个自然数并返回另一个函数,该函数接受一个自然数并返回一个自然数。在类型论中,把 Nat.add 函数看作接受一对自然数作为输入并返回一个自然数作为输出的函数通常会更方便。系统允许你「部分应用」函数 Nat.add,比如 Nat.add 3 具有类型 Nat → Nat,即 Nat.add 3 返回一个「等待」第二个参数 n 的函数,然后可以继续写 Nat.add 3 n

注:取一个类型为 Nat × Nat → Nat 的函数,然后「重定义」它,让它变成 Nat → Nat → Nat 类型,这个过程被称作柯里化(currying)。

如果你有 m : Natn : Nat,那么 (m, n) 表示 mn 组成的有序对,其类型为 Nat × Nat。这个方法可以制造自然数对。反过来,如果你有 p : Nat × Nat,之后你可以写 p.1 : Natp.2 : Nat。这个方法用于提取它的两个组件。

类型作为对象

Lean 所依据的依值类型论对简单类型论的其中一项升级是,类型本身(如 NatBool 这些东西)也是对象,因此也具有类型。

#check Nat               -- Type
#check Bool              -- Type
#check Nat → Bool        -- Type
#check Nat × Bool        -- Type
#check Nat → Nat         -- ...
#check Nat × Nat → Nat
#check Nat → Nat → Nat
#check Nat → (Nat → Nat)
#check Nat → Nat → Bool
#check (Nat → Nat) → Nat

上面的每个表达式都是类型为 Type 的对象。你也可以为类型声明新的常量:

def α : Type := Nat
def β : Type := Bool
def F : Type → Type := List
def G : Type → Type → Type := Prod

#check α        -- Type
#check F α      -- Type
#check F Nat    -- Type
#check G α      -- Type → Type
#check G α β    -- Type
#check G α Nat  -- Type

正如上面所示,你已经看到了一个类型为 Type → Type → Type 的函数例子,即笛卡尔积 Prod

def α : Type := Nat
def β : Type := Bool

#check Prod α β       -- Type
#check α × β          -- Type

#check Prod Nat Nat   -- Type
#check Nat × Nat      -- Type

这里有另一个例子:给出任意类型 α,那么类型 List α 是类型为 α 的元素的列表的类型。

def α : Type := Nat

#check List α    -- Type
#check List Nat  -- Type

看起来 Lean 中任何表达式都有一个类型,因此你可能会想到:Type 自己的类型是什么?

#check Type      -- Type 1

实际上,这是 Lean 系统的一个最微妙的方面:Lean 的底层基础有无限的类型层次:

#check Type     -- Type 1
#check Type 1   -- Type 2
#check Type 2   -- Type 3
#check Type 3   -- Type 4
#check Type 4   -- Type 5

可以将 Type 0 看作是一个由「小」或「普通」类型组成的宇宙。然后,Type 1 是一个更大的类型范围,其中包含 Type 0 作为一个元素,而 Type 2 是一个更大的类型范围,其中包含 Type 1 作为一个元素。这个列表是无限的,所以对于每个自然数 n 都有一个 Type nTypeType 0 的缩写:

#check Type
#check Type 0

下表可能有助于具体说明所讨论的关系。行方向代表宇宙的变化,列方向代表有时被称为「度」的变化。

sortProp (Sort 0)Type (Sort 1)Type 1 (Sort 2)Type 2 (Sort 3)...
typeTrueBoolNat -> TypeType -> Type 1...
termtrivialtruefun n => Fin nfun (_ : Type) => Type...

然而,有些操作需要在类型宇宙上具有 多态(Polymorphic) 。例如,List α 应该对任何类型的 α 都有意义,无论 α 存在于哪种类型的宇宙中。所以函数 List 有如下的类型:

#check List    -- List.{u} (α : Type u) : Type u

这里 u 是一个遍取类型级别的变量。#check 命令的输出意味着当 α 有类型 Type n 时,List α 也有类型 Type n。函数 Prod 具有类似的多态性:

#check Prod    -- Prod.{u, v} (α : Type u) (β : Type v) : Type (max u v)

你可以使用 universe 命令来声明宇宙变量,这样就可以定义多态常量:

universe u

def F (α : Type u) : Type u := Prod α α

#check F    -- Type u → Type u

可以通过在定义 F 时提供 universe 参数来避免使用 universe 命令:

def F.{u} (α : Type u) : Type u := Prod α α

#check F    -- Type u → Type u

函数抽象和求值

Lean 提供 fun (或 λ)关键字用于从给定表达式创建函数,如下所示:

#check fun (x : Nat) => x + 5   -- Nat → Nat
#check λ (x : Nat) => x + 5     -- λ 和 fun 是同义词
#check fun x : Nat => x + 5     -- 同上
#check λ x : Nat => x + 5       -- 同上

你可以通过传递所需的参数来计算 lambda 函数:

#eval (λ x : Nat => x + 5) 10    -- 15

从另一个表达式创建函数的过程称为 lambda 抽象 。假设你有一个变量 x : α 和一个表达式 t : β,那么表达式 fun (x : α) => t 或者 λ (x : α) => t 是一个类型为 α → β 的对象。这个从 αβ 的函数把任意 x 映射到 t

这有些例子:

#check fun x : Nat => fun y : Bool => if not y then x + 1 else x + 2
#check fun (x : Nat) (y : Bool) => if not y then x + 1 else x + 2
#check fun x y => if not y then x + 1 else x + 2   -- Nat → Bool → Nat

Lean 将这三个例子解释为相同的表达式;在最后一个表达式中,Lean 可以从表达式if not y then x + 1 else x + 2推断 xy 的类型。

一些数学上常见的函数运算的例子可以用 lambda 抽象的项来描述:

def f (n : Nat) : String := toString n
def g (s : String) : Bool := s.length > 0

#check fun x : Nat => x        -- Nat → Nat
#check fun x : Nat => true     -- Nat → Bool
#check fun x : Nat => g (f x)  -- Nat → Bool
#check fun x => g (f x)        -- Nat → Bool

看看这些表达式的意思。表达式 fun x : Nat => x 代表 Nat 上的恒等函数,表达式 fun x : Nat => true 表示一个永远输出 true 的常值函数,表达式 fun x : Nat => g (f x) 表示 fg 的复合。一般来说,你可以省略类型注释,让 Lean 自己推断它。因此你可以写 fun x => g (f x) 来代替 fun x : Nat => g (f x)

你可以以函数作为参数,通过给它们命名 fg,你可以在实现中使用这些函数:

#check fun (g : String → Bool) (f : Nat → String) (x : Nat) => g (f x)
-- (String → Bool) → (Nat → String) → Nat → Bool

你还可以以类型作为参数:

#check fun (α β γ : Type) (g : β → γ) (f : α → β) (x : α) => g (f x)

这个表达式表示一个接受三个类型 αβγ 和两个函数 g : β → γf : α → β,并返回的 gf 的复合的函数。(理解这个函数的类型需要理解依值积类型,下面将对此进行解释。)

lambda表达式的一般形式是 fun x : α => t,其中变量 x 是一个 绑定变量(Bound Variable) :它实际上是一个占位符,其「作用域」没有扩展到表达式 t 之外。例如,表达式 fun (b : β) (x : α) => b 中的变量 b 与前面声明的常量 b 没有任何关系。事实上,这个表达式表示的函数与 fun (u : β) (z : α) => u 是一样的。形式上,可以通过给绑定变量重命名来使形式相同的表达式被看作是 alpha等价 的,也就是被认为是「一样的」。Lean 认识这种等价性。

注意到项 t : α → β 应用到项 s : α 上导出了表达式 t s : β。回到前面的例子,为清晰起见给绑定变量重命名,注意以下表达式的类型:

#check (fun x : Nat => x) 1     -- Nat
#check (fun x : Nat => true) 1  -- Bool

def f (n : Nat) : String := toString n
def g (s : String) : Bool := s.length > 0

#check
  (fun (α β γ : Type) (u : β → γ) (v : α → β) (x : α) => u (v x)) Nat String Bool g f 0
  -- Bool

表达式 (fun x : Nat => x) 1 的类型是 Nat。实际上,应用 (fun x : Nat => x)1 上返回的值是 1

#eval (fun x : Nat => x) 1     -- 1
#eval (fun x : Nat => true) 1  -- true

稍后你将看到这些项是如何计算的。现在,请注意这是依值类型论的一个重要特征:每个项都有一个计算行为,并支持「标准化」的概念。从原则上讲,两个可以化约为相同形式的项被称为「定义等价」。它们被 Lean 的类型检查器认为是「相同的」,并且 Lean 尽其所能地识别和支持这些识别结果。

Lean 是个完备的编程语言。它有一个生成二进制可执行文件的编译器,和一个交互式解释器。你可以用#eval命令执行表达式,这也是测试你的函数的好办法。

注意到#eval#reduce不是等价的。#eval命令首先把 Lean 表达式编译成中间表示(intermediate representation, IR)然后用一个解释器来执行这个IR。某些内建类型(例如,NatStringArray)在 IR 中有更有效率的表示。IR支持使用对 Lean 不透明的外部函数。 #reduce 命令建立在一个规约引擎上,类似于在 Lean 的可信内核中使用的那个,它是负责检查和验证表达式和证明正确性的那一部分。它的效率不如 #eval,且将所有外部函数视为不透明的常量。之后你将了解到这两个命令之间还有其他一些差异。

定义

def 关键字提供了一个声明新对象的重要方式。

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

这很类似其他编程语言中的函数。名字 double 被定义为一个函数,它接受一个类型为 Nat 的输入参数 x,其结果是x + x,因此它返回类型 Nat。然后你可以调用这个函数:

def double (x : Nat) : Nat :=
 x + x
#eval double 3    -- 6

在这种情况下你可以把 def 想成一种 lambda。下面给出了相同的结果:

def double : Nat → Nat :=
  fun x => x + x

#eval double 3    -- 6

当 Lean 有足够的信息来推断时,你可以省略类型声明。类型推断是 Lean 的重要组成部分:

def double :=
  fun (x : Nat) => x + x

定义的一般形式是 def foo : α := bar,其中 α 是表达式 bar 返回的类型。Lean 通常可以推断类型 α,但是精确写出它可以澄清你的意图,并且如果定义的右侧没有匹配你的类型,Lean 将标记一个错误。

bar 可以是任何一个表达式,不只是一个 lambda 表达式。因此 def 也可以用于给一些值命名,例如:

def pi := 3.141592654

def 可以接受多个输入参数。比如定义两自然数之和:

def add (x y : Nat) :=
  x + y

#eval add 3 2               -- 5

参数列表可以像这样分开写:

def double (x : Nat) : Nat :=
 x + x
def add (x : Nat) (y : Nat) :=
  x + y

#eval add (double 3) (7 + 9)  -- 22

注意到这里我们使用了 double 函数来创建 add 函数的第一个参数。

你还可以在 def 中写一些更有趣的表达式:

def greater (x y : Nat) :=
  if x > y then x
  else y

猜猜这个可以做什么。

还可以定义一个函数,该函数接受另一个函数作为输入。下面调用一个给定函数两次,将第一次调用的输出传递给第二次:

def double (x : Nat) : Nat :=
 x + x
def doTwice (f : Nat → Nat) (x : Nat) : Nat :=
  f (f x)

#eval doTwice double 2   -- 8

现在为了更抽象一点,你也可以指定类型参数等:

def compose (α β γ : Type) (g : β → γ) (f : α → β) (x : α) : γ :=
  g (f x)

这句代码的意思是:函数 compose 首先接受任何两个函数作为参数,这其中两个函数各自接受一个输入。类型β → γα → β意思是要求第二个函数的输出类型必须与第一个函数的输入类型匹配,否则这两个函数将无法复合。

compose 再接受一个类型为compose 再参数作为第二个函数(这里叫做 f)的输入,通过这个函数之后的返回结果类型为β,再作为第一个函数(这里叫做 g)的输入。第一个函数返回类型为γ,这就是 compose 函数最终返回的类型。

compose 可以在任意的类型α β γ上使用,它可以复合任意两个函数,只要前一个的输出类型是后一个的输入类型。举例:

def compose (α β γ : Type) (g : β → γ) (f : α → β) (x : α) : γ :=
 g (f x)
def double (x : Nat) : Nat :=
 x + x
def square (x : Nat) : Nat :=
  x * x

#eval compose Nat Nat Nat double square 3  -- 18

局部定义

Lean 还允许你使用 let 关键字来引入「局部定义」。表达式 let a := t1; t2 定义等价于把 t2 中所有的 a 替换成 t1 的结果。

#check let y := 2 + 2; y * y   -- Nat
#eval  let y := 2 + 2; y * y   -- 16

def twice_double (x : Nat) : Nat :=
  let y := x + x; y * y

#eval twice_double 2   -- 16

这里 twice_double x 定义等价于 (x + x) * (x + x)

你可以连续使用多个 let 命令来进行多次替换:

#check let y := 2 + 2; let z := y + y; z * z   -- Nat
#eval  let y := 2 + 2; let z := y + y; z * z   -- 64

换行可以省略分号 ;

def t (x : Nat) : Nat :=
  let y := x + x
  y * y

表达式 let a := t1; t2 的意思很类似 (fun a => t2) t1,但是这两者并不一样。前者中你要把 t2 中每一个 a 的实例考虑成 t1 的一个缩写。后者中 a 是一个变量,表达式 fun a => t2 不依赖于 a 的取值而可以单独具有意义。作为一个对照,考虑为什么下面的 foo 定义是合法的,但 bar 不行(因为在确定了 x 所属的 a 是什么之前,是无法让它 + 2 的)。

def foo := let a := Nat; fun x : a => x + 2
/-
  def bar := (fun a => fun x : a => x + 2) Nat
-/

变量和小节

考虑下面这三个函数定义:

def compose (α β γ : Type) (g : β → γ) (f : α → β) (x : α) : γ :=
  g (f x)

def doTwice (α : Type) (h : α → α) (x : α) : α :=
  h (h x)

def doThrice (α : Type) (h : α → α) (x : α) : α :=
  h (h (h x))

Lean 提供 variable 指令来让这些声明变得紧凑:

variable (α β γ : Type)

def compose (g : β → γ) (f : α → β) (x : α) : γ :=
  g (f x)

def doTwice (h : α → α) (x : α) : α :=
  h (h x)

def doThrice (h : α → α) (x : α) : α :=
  h (h (h x))

你可以声明任意类型的变量,不只是 Type 类型:

variable (α β γ : Type)
variable (g : β → γ) (f : α → β) (h : α → α)
variable (x : α)

def compose := g (f x)
def doTwice := h (h x)
def doThrice := h (h (h x))

#print compose
#print doTwice
#print doThrice

输出结果表明所有三组定义具有完全相同的效果。

variable 命令指示 Lean 将声明的变量作为绑定变量插入定义中,这些定义通过名称引用它们。Lean 足够聪明,它能找出定义中显式或隐式使用哪些变量。因此在写定义时,你可以将 αβγgfhx 视为固定的对象,并让 Lean 自动抽象这些定义。

当以这种方式声明时,变量将一直保持存在,直到所处理的文件结束。然而,有时需要限制变量的作用范围。Lean 提供了小节标记 section 来实现这个目的:

section useful
  variable (α β γ : Type)
  variable (g : β → γ) (f : α → β) (h : α → α)
  variable (x : α)

  def compose := g (f x)
  def doTwice := h (h x)
  def doThrice := h (h (h x))
end useful

当一个小节结束后,变量不再发挥作用。

你不需要缩进一个小节中的行。你也不需要命名一个小节,也就是说,你可以使用一个匿名的 section /end 对。但是,如果你确实命名了一个小节,你必须使用相同的名字关闭它。小节也可以嵌套,这允许你逐步增加声明新变量。

命名空间

Lean 可以让你把定义放进一个「命名空间」(namespace)里,并且命名空间也是层次化的:

namespace Foo
  def a : Nat := 5
  def f (x : Nat) : Nat := x + 7

  def fa : Nat := f a
  def ffa : Nat := f (f a)

  #check a
  #check f
  #check fa
  #check ffa
  #check Foo.fa
end Foo

-- #check a  -- error
-- #check f  -- error
#check Foo.a
#check Foo.f
#check Foo.fa
#check Foo.ffa

open Foo

#check a
#check f
#check fa
#check Foo.fa

当你声明你在命名空间 Foo 中工作时,你声明的每个标识符都有一个前缀 Foo.。在打开的命名空间中,可以通过较短的名称引用标识符,但是关闭命名空间后,必须使用较长的名称。与 section 不同,命名空间需要一个名称。只有一个匿名命名空间在根级别上。

open 命令使你可以在当前使用较短的名称。通常,当你导入一个模块时,你会想打开它包含的一个或多个命名空间,以访问短标识符。但是,有时你希望将这些信息保留在一个完全限定的名称中,例如,当它们与你想要使用的另一个命名空间中的标识符冲突时。因此,命名空间为你提供了一种在工作环境中管理名称的方法。

例如,Lean 把和列表相关的定义和定理都放在了命名空间 List 之中。

#check List.nil
#check List.cons
#check List.map

open List 命令允许你使用短一点的名字:

open List

#check nil
#check cons
#check map

像小节一样,命名空间也是可以嵌套的:

namespace Foo
  def a : Nat := 5
  def f (x : Nat) : Nat := x + 7

  def fa : Nat := f a

  namespace Bar
    def ffa : Nat := f (f a)

    #check fa
    #check ffa
  end Bar

  #check fa
  #check Bar.ffa
end Foo

#check Foo.fa
#check Foo.Bar.ffa

open Foo

#check fa
#check Bar.ffa

关闭的命名空间可以之后重新打开,甚至是在另一个文件里:

namespace Foo
  def a : Nat := 5
  def f (x : Nat) : Nat := x + 7

  def fa : Nat := f a
end Foo

#check Foo.a
#check Foo.f

namespace Foo
  def ffa : Nat := f (f a)
end Foo

与小节一样,嵌套的名称空间必须按照打开的顺序关闭。命名空间和小节有不同的用途:命名空间组织数据,小节声明变量,以便在定义中插入。小节对于分隔 set_optionopen 等命令的范围也很有用。

然而,在许多方面,namespace ... end 结构块和 section ... end 表现出来的特征是一样的。尤其是你在命名空间中使用 variable 命令时,它的作用范围被限制在命名空间里。类似地,如果你在命名空间中使用 open 命令,它的效果在命名空间关闭后消失。

依值类型论「依赖」着什么?

简单地说,类型可以依赖于参数。你已经看到了一个很好的例子:类型 List α 依赖于参数 α,而这种依赖性是区分 List NatList Bool 的关键。另一个例子,考虑类型 Vector α n,即长度为 nα 元素的向量类型。这个类型取决于两个参数:向量中元素的类型 α : Type 和向量的长度 n : Nat

假设你希望编写一个函数 cons,它在列表的开头插入一个新元素。cons 应该有什么类型?这样的函数是多态的(polymorphic):你期望 NatBool 或任意类型 αcons 函数表现相同的方式。因此,将类型作为 cons 的第一个参数是有意义的,因此对于任何类型 αcons α 是类型 α 列表的插入函数。换句话说,对于每个 αcons α 是将元素 a : α 插入列表 as : List α 的函数,并返回一个新的列表,因此有 cons α a as : List α

很明显,cons α 具有类型 α → List α → List α,但是 cons 具有什么类型?如果我们假设是 Type → α → list α → list α,那么问题在于,这个类型表达式没有意义:α 没有任何的所指,但它实际上指的是某个类型 Type。换句话说,假设α : Type 是函数的首个参数,之后的两个参数的类型是 αList α,它们依赖于首个参数 α

def cons (α : Type) (a : α) (as : List α) : List α :=
  List.cons a as

#check cons Nat        -- Nat → List Nat → List Nat
#check cons Bool       -- Bool → List Bool → List Bool
#check cons            -- (α : Type) → α → List α → List α

这就是依值函数类型,或者依值箭头类型的一个例子。给定 α : Typeβ : α → Type,把 β 考虑成 α 之上的类型类,也就是说,对于每个 a : α 都有类型 β a。在这种情况下,类型 (a : α) → β a 表示的是具有如下性质的函数 f 的类型:对于每个 a : αf aβ a 的一个元素。换句话说,f 返回值的类型取决于其输入。

注意到 (a : α) → β 对于任意表达式 β : Type 都有意义。当 β 的值依赖于 a(例如,在前一段的表达式 β a),(a : α) → β 表示一个依值函数类型。当 β 的值不依赖于 a(a : α) → β 与类型 α → β 无异。实际上,在依值类型论(以及Lean)中,α → β 表达的意思就是当 β 的值不依赖于 a 时的 (a : α) → β。【注】

译者注:在依值类型论的数学符号体系中,依值类型是用 Π 符号来表达的,在Lean 3中还使用这种表达,例如 Π x : α, β x。Lean 4抛弃了这种不友好的写法。(x : α) → β x 这种写法在引入「构造子」之后意义会更明朗一些(见下一个注释),对于来自数学背景的读者可以把它类比于 forall x : α, β x 这种写法(这也是合法的 Lean 语句),关于前一种符号在量词与等价一章中有更详细的说明。同时,依值类型有着更丰富的引入动机,推荐读者寻找一些拓展读物。

回到列表的例子,你可以使用#check命令来检查下列的 List 函数。@ 符号以及圆括号和花括号之间的区别将在后面解释。

#check @List.cons    -- {α : Type u_1} → α → List α → List α
#check @List.nil     -- {α : Type u_1} → List α
#check @List.length  -- {α : Type u_1} → List α → Nat
#check @List.append  -- {α : Type u_1} → List α → List α → List α

就像依值函数类型 (a : α) → β a 通过允许 β 依赖 α 从而推广了函数类型 α → β,依值笛卡尔积类型 (a : α) × β a 同样推广了笛卡尔积 α × β。依值积类型又称为sigma类型,可写成Σ a : α, β a。你可以用⟨a, b⟩或者Sigma.mk a b来创建依值对。 符号可以用\langle\rangle或者\<\>来输入.

universe u v

def f (α : Type u) (β : α → Type v) (a : α) (b : β a) : (a : α) × β a :=
  ⟨a, b⟩

def g (α : Type u) (β : α → Type v) (a : α) (b : β a) : Σ a : α, β a :=
  Sigma.mk a b

def h1 (x : Nat) : Nat :=
  (f Type (fun α => α) Nat x).2

#eval h1 5 -- 5

def h2 (x : Nat) : Nat :=
  (g Type (fun α => α) Nat x).2

#eval h2 5 -- 5

函数 fg 表达的是同样的函数。

隐参数

假设我们有一个列表的实现如下:

universe u
def Lst (α : Type u) : Type u := List α
def Lst.cons (α : Type u) (a : α) (as : Lst α) : Lst α := List.cons a as
def Lst.nil (α : Type u) : Lst α := List.nil
def Lst.append (α : Type u) (as bs : Lst α) : Lst α := List.append as bs
#check Lst          -- Lst.{u} (α : Type u) : Type u
#check Lst.cons     -- Lst.cons.{u} (α : Type u) (a : α) (as : Lst α) : Lst α
#check Lst.nil      -- Lst.nil.{u} (α : Type u) : Lst α
#check Lst.append   -- Lst.append.{u} (α : Type u) (as bs : Lst α) : Lst α

然后,你可以建立一个自然数列表如下:

universe u
def Lst (α : Type u) : Type u := List α
def Lst.cons (α : Type u) (a : α) (as : Lst α) : Lst α := List.cons a as
def Lst.nil (α : Type u) : Lst α := List.nil
def Lst.append (α : Type u) (as bs : Lst α) : Lst α := List.append as bs
#check Lst          -- Type u_1 → Type u_1
#check Lst.cons     -- (α : Type u_1) → α → Lst α → Lst α
#check Lst.nil      -- (α : Type u_1) → Lst α
#check Lst.append   -- (α : Type u_1) → Lst α → Lst α → Lst α
#check Lst.cons Nat 0 (Lst.nil Nat)

def as : Lst Nat := Lst.nil Nat
def bs : Lst Nat := Lst.cons Nat 5 (Lst.nil Nat)

#check Lst.append Nat as bs

由于构造子对类型是多态的【注】,我们需要重复插入类型 Nat 作为一个参数。但是这个信息是多余的:我们可以推断表达式 Lst.cons Nat 5 (Lst.nil Nat) 中参数 α 的类型,这是通过第二个参数 5 的类型是 Nat 来实现的。类似地,我们可以推断 Lst.nil Nat 中参数的类型,这是通过它作为函数 Lst.cons 的一个参数,且这个函数在这个位置需要接收的是一个具有 Lst α 类型的参数来实现的。

译者注:「构造子」(Constructor)的概念前文未加解释,对类型论不熟悉的读者可能会困惑。它指的是一种「依值类型的类型」,也可以看作「类型的构造子」,例如 λ α : α -> α 甚至可看成 ⋆ -> ⋆。当给 α 或者 赋予一个具体的类型时,这个表达式就成为了一个类型。前文中 (x : α) → β x 中的 β 就可以看成一个构造子,(x : α) 就是传进的类型参数。原句「构造子对类型是多态的」意为给构造子中放入不同类型时它会变成不同类型。

这是依值类型论的一个主要特征:项包含大量信息,而且通常可以从上下文推断出一些信息。在 Lean 中,我们使用下划线 _ 来指定系统应该自动填写信息。这就是所谓的「隐参数」。

universe u
def Lst (α : Type u) : Type u := List α
def Lst.cons (α : Type u) (a : α) (as : Lst α) : Lst α := List.cons a as
def Lst.nil (α : Type u) : Lst α := List.nil
def Lst.append (α : Type u) (as bs : Lst α) : Lst α := List.append as bs
#check Lst          -- Type u_1 → Type u_1
#check Lst.cons     -- (α : Type u_1) → α → Lst α → Lst α
#check Lst.nil      -- (α : Type u_1) → Lst α
#check Lst.append   -- (α : Type u_1) → Lst α → Lst α → Lst α
#check Lst.cons _ 0 (Lst.nil _)

def as : Lst Nat := Lst.nil _
def bs : Lst Nat := Lst.cons _ 5 (Lst.nil _)

#check Lst.append _ as bs

然而,敲这么多下划线仍然很无聊。当一个函数接受一个通常可以从上下文推断出来的参数时,Lean 允许你指定该参数在默认情况下应该保持隐式。这是通过将参数放入花括号来实现的,如下所示:

universe u
def Lst (α : Type u) : Type u := List α

def Lst.cons {α : Type u} (a : α) (as : Lst α) : Lst α := List.cons a as
def Lst.nil {α : Type u} : Lst α := List.nil
def Lst.append {α : Type u} (as bs : Lst α) : Lst α := List.append as bs

#check Lst.cons 0 Lst.nil

def as : Lst Nat := Lst.nil
def bs : Lst Nat := Lst.cons 5 Lst.nil

#check Lst.append as bs

唯一改变的是变量声明中 α : Type u 周围的花括号。我们也可以在函数定义中使用这个技巧:

universe u
def ident {α : Type u} (x : α) := x

#check ident         -- ?m → ?m
#check ident 1       -- Nat
#check ident "hello" -- String
#check @ident        -- {α : Type u_1} → α → α

这使得 ident 的第一个参数是隐式的。从符号上讲,这隐藏了类型的说明,使它看起来好像 ident 只是接受任何类型的参数。事实上,函数 id 在标准库中就是以这种方式定义的。我们在这里选择一个非传统的名字只是为了避免名字的冲突。

variable 命令也可以用这种技巧来来把变量变成隐式的:

universe u

section
  variable {α : Type u}
  variable (x : α)
  def ident := x
end

#check ident
#check ident 4
#check ident "hello"

此处定义的 ident 和上面那个效果是一样的。

Lean 有非常复杂的机制来实例化隐参数,我们将看到它们可以用来推断函数类型、谓词,甚至证明。实例化这些「洞」或「占位符」的过程通常被称为 繁饰(Elaboration) 。隐参数的存在意味着有时可能没有足够的信息来精确地确定表达式的含义。像 idList.nil 这样的表达式被认为是「多态的」,因为它可以在不同的上下文中具有不同的含义。

可以通过写 (e : T) 来指定表达式 e 的类型 T。这就指导 Lean 的繁饰器在试图解决隐式参数时使用值 T 作为 e 的类型。在下面的第二个例子中,这种机制用于指定表达式 idList.nil 所需的类型:

#check List.nil               -- List ?m
#check id                     -- ?m → ?m

#check (List.nil : List Nat)  -- List Nat
#check (id : Nat → Nat)       -- Nat → Nat

Lean 中数字是重载的,但是当数字的类型不能被推断时,Lean 默认假设它是一个自然数。因此,下面的前两个 #check 命令中的表达式以同样的方式进行了繁饰,而第三个 #check 命令将 2 解释为整数。

#check 2            -- Nat
#check (2 : Nat)    -- Nat
#check (2 : Int)    -- Int

然而,有时我们可能会发现自己处于这样一种情况:我们已经声明了函数的参数是隐式的,但现在想显式地提供参数。如果 foo 是有隐参数的函数,符号 @foo 表示所有参数都是显式的该函数。

#check @id        -- {α : Sort u_1} → α → α
#check @id Nat    -- Nat → Nat
#check @id Bool   -- Bool → Bool

#check @id Nat 1     -- Nat
#check @id Bool true -- Bool

第一个 #check 命令给出了标识符的类型 id,没有插入任何占位符。而且,输出表明第一个参数是隐式的。

命题和证明

前一章你已经看到了在 Lean 中定义对象和函数的一些方法。在本章中,我们还将开始解释如何用依值类型论的语言来编写数学命题和证明。

命题即类型

证明在依值类型论语言中定义的对象的断言(assertion)的一种策略是在定义语言之上分层断言语言和证明语言。但是,没有理由以这种方式重复使用多种语言:依值类型论是灵活和富有表现力的,我们也没有理由不能在同一个总框架中表示断言和证明。

例如,我们可引入一种新类型 Prop,来表示命题,然后引入用其他命题构造新命题的构造子。

def Implies (p q : Prop) : Prop := p → q
#check And     -- Prop → Prop → Prop
#check Or      -- Prop → Prop → Prop
#check Not     -- Prop → Prop
#check Implies -- Prop → Prop → Prop

variable (p q r : Prop)
#check And p q                      -- Prop
#check Or (And p q) r               -- Prop
#check Implies (And p q) (And q p)  -- Prop

对每个元素 p : Prop,可以引入另一个类型 Proof p,作为 p 的证明的类型。「公理」是这个类型中的常值。

def Implies (p q : Prop) : Prop := p → q
structure Proof (p : Prop) : Type where
  proof : p
#check Proof   -- Proof : Prop → Type

axiom and_comm (p q : Prop) : Proof (Implies (And p q) (And q p))

variable (p q : Prop)
#check and_comm p q     -- Proof (Implies (And p q) (And q p))

然而,除了公理之外,我们还需要从旧证明中建立新证明的规则。例如,在许多命题逻辑的证明系统中,我们有肯定前件式(modus ponens)推理规则:

如果能证明 Implies p qp,则能证明 q

我们可以如下地表示它:

def Implies (p q : Prop) : Prop := p → q
structure Proof (p : Prop) : Type where
  proof : p
axiom modus_ponens : (p q : Prop) → Proof (Implies p q) → Proof p → Proof q

命题逻辑的自然演绎系统通常也依赖于以下规则:

当假设 p 成立时,如果我们能证明 q. 则我们能证明 Implies p q.

我们可以如下地表示它:

def Implies (p q : Prop) : Prop := p → q
structure Proof (p : Prop) : Type where
  proof : p
axiom implies_intro : (p q : Prop) → (Proof p → Proof q) → Proof (Implies p q)

这个功能让我们可以合理地搭建断言和证明。确定表达式 tp 的证明,只需检查 t 具有类型 Proof p

可以做一些简化。首先,我们可以通过将 Proof pp 本身合并来避免重复地写 Proof 这个词。换句话说,只要我们有 p : Prop,我们就可以把 p 解释为一种类型,也就是它的证明类型。然后我们可以把 t : p 读作 tp 的证明。

此外,我们可以在 Implies p qp → q 之间来回切换。换句话说,命题 pq 之间的含义对应于一个函数,它将 p 的任何元素接受为 q 的一个元素。因此,引入连接词 Implies 是完全多余的:我们可以使用依值类型论中常见的函数空间构造子 p → q 作为我们的蕴含概念。

这是在构造演算(Calculus of Constructions)中遵循的方法,因此在 Lean 中也是如此。自然演绎证明系统中的蕴含规则与控制函数抽象(abstraction)和应用(application)的规则完全一致,这是Curry-Howard同构的一个实例,有时也被称为命题即类型。事实上,类型 Prop 是上一章描述的类型层次结构的最底部 Sort 0 的语法糖。此外,Type u 也只是 Sort (u+1) 的语法糖。Prop 有一些特殊的特性,但像其他类型宇宙一样,它在箭头构造子下是封闭的:如果我们有 p q : Prop,那么 p → q : Prop

至少有两种将命题作为类型来思考的方法。对于那些对逻辑和数学中的构造主义者来说,这是对命题含义的忠实诠释:命题 p 代表了一种数据类型,即构成证明的数据类型的说明。p 的证明就是正确类型的对象 t : p

非构造主义者可以把它看作是一种简单的编码技巧。对于每个命题 p,我们关联一个类型,如果 p 为假,则该类型为空,如果 p 为真,则有且只有一个元素,比如 *。在后一种情况中,让我们说(与之相关的类型)p占据(inhabited)。恰好,函数应用和抽象的规则可以方便地帮助我们跟踪 Prop 的哪些元素是被占据的。所以构造一个元素 t : p 告诉我们 p 确实是正确的。你可以把 p 的占据者想象成「p 为真」的事实。对 p → q 的证明使用「p 是真的」这个事实来得到「q 是真的」这个事实。

事实上,如果 p : Prop 是任何命题,那么 Lean 的内核将任意两个元素 t1 t2 : p 看作定义相等,就像它把 (fun x => t) st[s/x] 看作定义等价。这就是所谓的「证明无关性」(proof irrelevance)。这意味着,即使我们可以把证明 t : p 当作依值类型论语言中的普通对象,它们除了 p 是真的这一事实之外,没有其他信息。

我们所建议的思考「命题即类型」范式的两种方式在一个根本性的方面有所不同。从构造的角度看,证明是抽象的数学对象,它被依值类型论中的合适表达式所表示。相反,如果我们从上述编码技巧的角度考虑,那么表达式本身并不表示任何有趣的东西。相反,是我们可以写下它们并检查它们是否有良好的类型这一事实确保了有关命题是真的。换句话说,表达式本身就是证明。

在下面的论述中,我们将在这两种说话方式之间来回切换,有时说一个表达式「构造」或「产生」或「返回」一个命题的证明,有时则简单地说它「是」这样一个证明。这类似于计算机科学家偶尔会模糊语法和语义之间的区别,有时说一个程序「计算」某个函数,有时又说该程序「是」该函数。

为了用依值类型论的语言正式表达一个数学断言,我们需要展示一个项 p : Prop。为了证明该断言,我们需要展示一个项 t : p。Lean 的任务,作为一个证明助手,是帮助我们构造这样一个项 t,并验证它的格式是否良好,类型是否正确。

以「命题即类型」的方式工作

在「命题即类型」范式中,只涉及 的定理可以通过 lambda 抽象和应用来证明。在 Lean 中,theorem 命令引入了一个新的定理:

variable {p : Prop}
variable {q : Prop}

theorem t1 : p → q → p := fun hp : p => fun hq : q => hp

这与上一章中常量函数的定义完全相同,唯一的区别是参数是 Prop 的元素,而不是 Type 的元素。直观地说,我们对 p → q → p 的证明假设 pq 为真,并使用第一个假设(平凡地)建立结论 p 为真。

请注意,theorem 命令实际上是 def 命令的一个翻版:在命题和类型对应下,证明定理 p → q → p 实际上与定义关联类型的元素是一样的。对于内核类型检查器,这两者之间没有区别。

然而,定义和定理之间有一些实用的区别。正常情况下,永远没有必要展开一个定理的「定义」;通过证明无关性,该定理的任何两个证明在定义上都是相等的。一旦一个定理的证明完成,通常我们只需要知道该证明的存在;证明是什么并不重要。鉴于这一事实,Lean 将证明标记为不可还原(irreducible),作为对解析器(更确切地说,是 繁饰器 )的提示,在处理文件时一般不需要展开它。事实上,Lean 通常能够并行地处理和检查证明,因为评估一个证明的正确性不需要知道另一个证明的细节。

和定义一样,#print 命令可以展示一个定理的证明。

variable {p : Prop}
variable {q : Prop}
theorem t1 : p → q → p := fun hp : p => fun hq : q => hp

#print t1

注意,lambda抽象 hp : phq : q 可以被视为 t1 的证明中的临时假设。Lean 还允许我们通过 show 语句明确指定最后一个项 hp 的类型。

variable {p : Prop}
variable {q : Prop}
theorem t1 : p → q → p :=
  fun hp : p =>
  fun hq : q =>
  show p from hp

添加这些额外的信息可以提高证明的清晰度,并有助于在编写证明时发现错误。show 命令只是注释类型,而且在内部,我们看到的所有关于 t1 的表示都产生了相同的项。

与普通定义一样,我们可以将 lambda 抽象的变量移到冒号的左边:

variable {p : Prop}
variable {q : Prop}
theorem t1 (hp : p) (hq : q) : p := hp

#print t1    -- p → q → p

现在我们可以把定理 t1 作为一个函数应用。

variable {p : Prop}
variable {q : Prop}
theorem t1 (hp : p) (hq : q) : p := hp

axiom hp : p

theorem t2 : q → p := t1 hp

这里,axiom 声明假定存在给定类型的元素,因此可能会破坏逻辑一致性。例如,我们可以使用它来假设空类型 False 有一个元素:

axiom unsound : False
-- false可导出一切
theorem ex : 1 = 0 :=
False.elim unsound

声明「公理」hp : p 等同于声明 p 为真,正如 hp 所证明的那样。应用定理 t1 : p → q → p 到事实 hp : p(也就是 p 为真)得到定理 t1 hp : q → p

回想一下,我们也可以这样写定理 t1:

theorem t1 {p q : Prop} (hp : p) (hq : q) : p := hp

#print t1

t1 的类型现在是 ∀ {p q : Prop}, p → q → p。我们可以把它理解为「对于每一对命题 p q,我们都有 p → q → p」。例如,我们可以将所有参数移到冒号的右边:

theorem t1 : ∀ {p q : Prop}, p → q → p :=
  fun {p q : Prop} (hp : p) (hq : q) => hp

如果 pq 被声明为变量,Lean 会自动为我们推广它们:

variable {p q : Prop}

theorem t1 : p → q → p := fun (hp : p) (hq : q) => hp

事实上,通过命题即类型的对应关系,我们可以声明假设 hpp,作为另一个变量:

variable {p q : Prop}
variable (hp : p)

theorem t1 : q → p := fun (hq : q) => hp

Lean 检测到证明使用 hp,并自动添加 hp : p 作为前提。在所有情况下,命令 #print t1 仍然会产生 ∀ p q : Prop, p → q → p。这个类型也可以写成 ∀ (p q : Prop) (hp : p) (hq :q), p,因为箭头仅仅表示一个箭头类型,其中目标不依赖于约束变量。

当我们以这种方式推广 t1 时,我们就可以将它应用于不同的命题对,从而得到一般定理的不同实例。

theorem t1 (p q : Prop) (hp : p) (hq : q) : p := hp

variable (p q r s : Prop)

#check t1 p q                -- p → q → p
#check t1 r s                -- r → s → r
#check t1 (r → s) (s → r)    -- (r → s) → (s → r) → r → s

variable (h : r → s)
#check t1 (r → s) (s → r) h  -- (s → r) → r → s

同样,使用命题即类型对应,类型为 r → s 的变量 h 可以看作是 r → s 存在的假设或前提。

作为另一个例子,让我们考虑上一章讨论的组合函数,现在用命题代替类型。

variable (p q r s : Prop)

theorem t2 (h₁ : q → r) (h₂ : p → q) : p → r :=
  fun h₃ : p =>
  show r from h₁ (h₂ h₃)

作为一个命题逻辑定理,t2 是什么意思?

注意,数字 unicode 下标输入方式为 \0\1\2,...。

命题逻辑

Lean 定义了所有标准的逻辑连接词和符号。命题连接词有以下表示法:

AsciiUnicode编辑器缩写定义
TrueTrue
FalseFalse
Not¬\not, \negNot
/\\andAnd
\/\orOr
->\to, \r, \imp
<->\iff, \lrIff

它们都接收 Prop 值。

variable (p q : Prop)

#check p → q → p ∧ q
#check ¬p → p ↔ False
#check p ∨ q → q ∨ p

操作符的优先级如下:¬ > ∧ > ∨ > → > ↔。举例:a ∧ b → c ∨ d ∧ e 意为 (a ∧ b) → (c ∨ (d ∧ e)) 等二元关系是右结合的。所以如果我们有 p q r : Prop,表达式 p → q → r 读作「如果 p,那么如果 q,那么 r」。这是 p ∧ q → r 的柯里化形式。

在上一章中,我们观察到 lambda 抽象可以被看作是 的「引入规则」,展示了如何「引入」或建立一个蕴含。应用可以看作是一个「消去规则」,展示了如何在证明中「消去」或使用一个蕴含。其他的命题连接词在 Lean 的库 Prelude.core 文件中定义。(参见导入文件以获得关于库层次结构的更多信息),并且每个连接都带有其规范引入和消去规则。

合取

表达式 And.intro h1 h2p ∧ q 的证明,它使用了 h1 : ph2 : q 的证明。通常把 And.intro 称为合取引入规则。下面的例子我们使用 And.intro 来创建 p → q → p ∧ q 的证明。

variable (p q : Prop)

example (hp : p) (hq : q) : p ∧ q := And.intro hp hq

#check fun (hp : p) (hq : q) => And.intro hp hq

example 命令声明了一个没有名字也不会永久保存的定理。本质上,它只是检查给定项是否具有指定的类型。它便于说明,我们将经常使用它。

表达式 And.left hh : p ∧ q 建立了一个 p 的证明。类似地,And.right hq 的证明。它们常被称为左或右合取消去规则。

variable (p q : Prop)

example (h : p ∧ q) : p := And.left h
example (h : p ∧ q) : q := And.right h

我们现在可以证明 p ∧ q → q ∧ p

variable (p q : Prop)

example (h : p ∧ q) : q ∧ p :=
  And.intro (And.right h) (And.left h)

请注意,引入和消去与笛卡尔积的配对和投影操作类似。区别在于,给定 hp : phq : qAnd.intro hp hq 具有类型 p ∧ q : Prop,而 Prod hp hq 具有类型 p × q : Type× 之间的相似性是Curry-Howard同构的另一个例子,但与蕴涵和函数空间构造子不同,在 Lean 中 × 是分开处理的。然而,通过类比,我们刚刚构造的证明类似于交换一对中的元素的函数。

我们将在结构体和记录一章中看到 Lean 中的某些类型是Structures,也就是说,该类型是用单个规范的构造子定义的,该构造子从一系列合适的参数构建该类型的一个元素。对于每一组 p q : Propp ∧ q 就是一个例子:构造一个元素的规范方法是将 And.intro 应用于合适的参数 hp : phq : q。Lean 允许我们使用匿名构造子表示法 ⟨arg1, arg2, ...⟩ 在此类情况下,当相关类型是归纳类型并可以从上下文推断时。特别地,我们经常可以写入 ⟨hp, hq⟩,而不是 And.intro hp hq:

variable (p q : Prop)
variable (hp : p) (hq : q)

#check (⟨hp, hq⟩ : p ∧ q)

尖括号可以用 \<\> 打出来。

Lean 提供了另一个有用的语法小工具。给定一个归纳类型 Foo 的表达式 e(可能应用于一些参数),符号 e.barFoo.bar e 的缩写。这为访问函数提供了一种方便的方式,而无需打开名称空间。例如,下面两个表达的意思是相同的:

variable (xs : List Nat)

#check List.length xs
#check xs.length

给定 h : p ∧ q,我们可以写 h.left 来表示 And.left h 以及 h.right 来表示 And.right h。因此我们可以简写上面的证明如下:

variable (p q : Prop)

example (h : p ∧ q) : q ∧ p :=
  ⟨h.right, h.left⟩

在简洁和含混不清之间有一条微妙的界限,以这种方式省略信息有时会使证明更难阅读。但对于像上面这样简单的结构,当 h 的类型和结构的目标很突出时,符号是干净和有效的。

And. 这样的迭代结构是很常见的。Lean 还允许你将嵌套的构造函数向右结合,这样这两个证明是等价的:

variable (p q : Prop)

example (h : p ∧ q) : q ∧ p ∧ q :=
  ⟨h.right, ⟨h.left, h.right⟩⟩

example (h : p ∧ q) : q ∧ p ∧ q :=
  ⟨h.right, h.left, h.right⟩

这一点也很常用。

析取

表达式 Or.intro_left q hp 从证明 hp : p 建立了 p ∨ q 的证明。类似地,Or.intro_right p hq 从证明 hq : q 建立了 p ∨ q 的证明。这是左右析取(「或」)引入规则。

variable (p q : Prop)
example (hp : p) : p ∨ q := Or.intro_left q hp
example (hq : q) : p ∨ q := Or.intro_right p hq

析取消去规则稍微复杂一点。这个想法是,如果我们想要从 p ∨ q 证明 r,只需要展示 p 可以证明 r,且 q 也可以证明 r。换句话说,它是一种逐情况证明。在表达式 Or.elim hpq hpr hqr 中,Or.elim 接受三个论证,hpq : p ∨ qhpr : p → rhqr : q → r,生成 r 的证明。在下面的例子中,我们使用 Or.elim 证明 p ∨ q → q ∨ p

variable (p q r : Prop)

example (h : p ∨ q) : q ∨ p :=
  Or.elim h
    (fun hp : p =>
      show q ∨ p from Or.intro_right q hp)
    (fun hq : q =>
      show q ∨ p from Or.intro_left p hq)

在大多数情况下,Or.intro_rightOr.intro_left 的第一个参数可以由 Lean 自动推断出来。因此,Lean 提供了 Or.inrOr.inl 作为 Or.intro_right _Or.intro_left _ 的缩写。因此,上面的证明项可以写得更简洁:

variable (p q r : Prop)

example (h : p ∨ q) : q ∨ p :=
  Or.elim h (fun hp => Or.inr hp) (fun hq => Or.inl hq)

Lean 的完整表达式中有足够的信息来推断 hphq 的类型。但是在较长的版本中使用类型注释可以使证明更具可读性,并有助于捕获和调试错误。

因为 Or 有两个构造子,所以不能使用匿名构造子表示法。但我们仍然可以写 h.elim 而不是 Or.elim h,不过你需要注意这些缩写是增强还是降低了可读性:

variable (p q r : Prop)

example (h : p ∨ q) : q ∨ p :=
  h.elim (fun hp => Or.inr hp) (fun hq => Or.inl hq)

否定和假言

否定 ¬p 真正的定义是 p → False,所以我们通过从 p 导出一个矛盾来获得 ¬p。类似地,表达式 hnp hphp : phnp : ¬p 产生一个 False 的证明。下一个例子用所有这些规则来证明 (p → q) → ¬q → ¬p。(¬ 符号可以由 \not 或者 \neg 来写出。)

variable (p q : Prop)

example (hpq : p → q) (hnq : ¬q) : ¬p :=
  fun hp : p =>
  show False from hnq (hpq hp)

连接词 False 只有一个消去规则 False.elim,它表达了一个事实,即矛盾能导出一切。这个规则有时被称为ex falsoex falso sequitur quodlibet(无稽之谈)的缩写】,或爆炸原理

variable (p q : Prop)

example (hp : p) (hnp : ¬p) : q := False.elim (hnp hp)

假命题导出任意的事实 q,是 False.elim 的一个隐参数,而且是自动推断出来的。这种从相互矛盾的假设中推导出任意事实的模式很常见,用 absurd 来表示。

variable (p q : Prop)

example (hp : p) (hnp : ¬p) : q := absurd hp hnp

证明 ¬p → q → (q → p) → r

variable (p q r : Prop)

example (hnp : ¬p) (hq : q) (hqp : q → p) : r :=
  absurd (hqp hq) hnp

顺便说一句,就像 False 只有一个消去规则,True 只有一个引入规则 True.intro : true。换句话说,True 就是真,并且有一个标准证明 True.intro

逻辑等价

表达式 Iff.intro h1 h2h1 : p → qh2 : q → p 生成了 p ↔ q 的证明。表达式 Iff.mp hh : p ↔ q 生成了 p → q 的证明。表达式 Iff.mpr hh : p ↔ q 生成了 q → p 的证明。下面是 p ∧ q ↔ q ∧ p 的证明:

variable (p q : Prop)

theorem and_swap : p ∧ q ↔ q ∧ p :=
  Iff.intro
    (fun h : p ∧ q =>
     show q ∧ p from And.intro (And.right h) (And.left h))
    (fun h : q ∧ p =>
     show p ∧ q from And.intro (And.right h) (And.left h))

#check and_swap p q    -- p ∧ q ↔ q ∧ p

variable (h : p ∧ q)
example : q ∧ p := Iff.mp (and_swap p q) h

我们可以用匿名构造子表示法来,从正反两个方向的证明,来构建 p ↔ q 的证明。我们也可以使用 . 符号连接 mpmpr。因此,前面的例子可以简写如下:

variable (p q : Prop)

theorem and_swap : p ∧ q ↔ q ∧ p :=
  ⟨ fun h => ⟨h.right, h.left⟩, fun h => ⟨h.right, h.left⟩ ⟩

example (h : p ∧ q) : q ∧ p := (and_swap p q).mp h

引入辅助子目标

这里介绍 Lean 提供的另一种帮助构造长证明的方法,即 have 结构,它在证明中引入了一个辅助的子目标。下面是一个小例子,改编自上一节:

variable (p q : Prop)

example (h : p ∧ q) : q ∧ p :=
  have hp : p := h.left
  have hq : q := h.right
  show q ∧ p from And.intro hq hp

在内部,表达式 have h : p := s; t 产生项 (fun (h : p) => t) s。换句话说,sp 的证明,t 是假设 h : p 的期望结论的证明,并且这两个是由 lambda 抽象和应用组合在一起的。这个简单的方法在构建长证明时非常有用,因为我们可以使用中间的 have 作为通向最终目标的垫脚石。

Lean 还支持从目标向后推理的结构化方法,它模仿了普通数学文献中「足以说明某某」(suffices to show)的构造。下一个例子简单地排列了前面证明中的最后两行。

variable (p q : Prop)

example (h : p ∧ q) : q ∧ p :=
  have hp : p := h.left
  suffices hq : q from And.intro hq hp
  show q from And.right h

suffices hq : q 给出了两条目标。第一,我们需要证明,通过利用附加假设 hq : q 证明原目标 q ∧ p,这样足以证明 q,第二,我们需要证明 q

经典逻辑

到目前为止,我们看到的引入和消去规则都是构造主义的,也就是说,它们反映了基于命题即类型对应的逻辑连接词的计算理解。普通经典逻辑在此基础上加上了排中律 p ∨ ¬p(excluded middle, em)。要使用这个原则,你必须打开经典逻辑命名空间。

open Classical

variable (p : Prop)
#check em p

从直觉上看,构造主义的「或」非常强:断言 p ∨ q 等于知道哪个是真实情况。如果 RH 代表黎曼猜想,经典数学家愿意断言 RH ∨ ¬RH,即使我们还不能断言析取式的任何一端。

排中律的一个结果是双重否定消去规则(double-negation elimination, dne):

open Classical

theorem dne {p : Prop} (h : ¬¬p) : p :=
  Or.elim (em p)
    (fun hp : p => hp)
    (fun hnp : ¬p => absurd hnp h)

双重否定消去规则给出了一种证明任何命题 p 的方法:通过假设 ¬p 来推导出 false,相当于证明了 p。换句话说,双重否定消除允许反证法,这在构造主义逻辑中通常是不可能的。作为练习,你可以试着证明相反的情况,也就是说,证明 em 可以由 dne 证明。

经典公理还可以通过使用 em 让你获得额外的证明模式。例如,我们可以逐情况进行证明:

open Classical
variable (p : Prop)

example (h : ¬¬p) : p :=
  byCases
    (fun h1 : p => h1)
    (fun h1 : ¬p => absurd h1 h)

或者你可以用反证法来证明:

open Classical
variable (p : Prop)

example (h : ¬¬p) : p :=
  byContradiction
    (fun h1 : ¬p =>
     show False from h h1)

如果你不习惯构造主义,你可能需要一些时间来了解经典推理在哪里使用。在下面的例子中,它是必要的,因为从一个构造主义的观点来看,知道 pq 不同时真并不一定告诉你哪一个是假的:

open Classical
variable (p q : Prop)
example (h : ¬(p ∧ q)) : ¬p ∨ ¬q :=
  Or.elim (em p)
    (fun hp : p =>
      Or.inr
        (show ¬q from
          fun hq : q =>
          h ⟨hp, hq⟩))
    (fun hp : ¬p =>
      Or.inl hp)

稍后我们将看到,构造逻辑中 某些情况允许「排中律」和「双重否定消除律」等,而 Lean 支持在这种情况下使用经典推理,而不依赖于排中律。

Lean 中使用的公理的完整列表见公理与计算

逻辑命题的例子

Lean 的标准库包含了许多命题逻辑的有效语句的证明,你可以自由地在自己的证明中使用这些证明。下面的列表包括一些常见的逻辑等价式。

交换律:

  1. p ∧ q ↔ q ∧ p
  2. p ∨ q ↔ q ∨ p

结合律:

  1. (p ∧ q) ∧ r ↔ p ∧ (q ∧ r)
  2. (p ∨ q) ∨ r ↔ p ∨ (q ∨ r)

分配律:

  1. p ∧ (q ∨ r) ↔ (p ∧ q) ∨ (p ∧ r)
  2. p ∨ (q ∧ r) ↔ (p ∨ q) ∧ (p ∨ r)

其他性质:

  1. (p → (q → r)) ↔ (p ∧ q → r)
  2. ((p ∨ q) → r) ↔ (p → r) ∧ (q → r)
  3. ¬(p ∨ q) ↔ ¬p ∧ ¬q
  4. ¬p ∨ ¬q → ¬(p ∧ q)
  5. ¬(p ∧ ¬p)
  6. p ∧ ¬q → ¬(p → q)
  7. ¬p → (p → q)
  8. (¬p ∨ q) → (p → q)
  9. p ∨ False ↔ p
  10. p ∧ False ↔ False
  11. ¬(p ↔ ¬p)
  12. (p → q) → (¬q → ¬p)

经典推理:

  1. (p → r ∨ s) → ((p → r) ∨ (p → s))
  2. ¬(p ∧ q) → ¬p ∨ ¬q
  3. ¬(p → q) → p ∧ ¬q
  4. (p → q) → (¬p ∨ q)
  5. (¬q → ¬p) → (p → q)
  6. p ∨ ¬p
  7. (((p → q) → p) → p)

sorry 标识符神奇地生成任何东西的证明,或者提供任何数据类型的对象。当然,作为一种证明方法,它是不可靠的——例如,你可以使用它来证明 False——并且当文件使用或导入依赖于它的定理时,Lean 会产生严重的警告。但它对于增量地构建长证明非常有用。从上到下写证明,用 sorry 来填子证明。确保 Lean 接受所有的 sorry;如果不是,则有一些错误需要纠正。然后返回,用实际的证据替换每个 sorry,直到做完。

有另一个有用的技巧。你可以使用下划线 _ 作为占位符,而不是 sorry。回想一下,这告诉 Lean 该参数是隐式的,应该自动填充。如果 Lean 尝试这样做并失败了,它将返回一条错误消息「不知道如何合成占位符」(Don't know how to synthesize placeholder),然后是它所期望的项的类型,以及上下文中可用的所有对象和假设。换句话说,对于每个未解决的占位符,Lean 报告在那一点上需要填充的子目标。然后,你可以通过递增填充这些占位符来构造一个证明。

这里有两个简单的证明例子作为参考。

open Classical

-- 分配律
example (p q r : Prop) : p ∧ (q ∨ r) ↔ (p ∧ q) ∨ (p ∧ r) :=
  Iff.intro
    (fun h : p ∧ (q ∨ r) =>
      have hp : p := h.left
      Or.elim (h.right)
        (fun hq : q =>
          show (p ∧ q) ∨ (p ∧ r) from Or.inl ⟨hp, hq⟩)
        (fun hr : r =>
          show (p ∧ q) ∨ (p ∧ r) from Or.inr ⟨hp, hr⟩))
    (fun h : (p ∧ q) ∨ (p ∧ r) =>
      Or.elim h
        (fun hpq : p ∧ q =>
          have hp : p := hpq.left
          have hq : q := hpq.right
          show p ∧ (q ∨ r) from ⟨hp, Or.inl hq⟩)
        (fun hpr : p ∧ r =>
          have hp : p := hpr.left
          have hr : r := hpr.right
          show p ∧ (q ∨ r) from ⟨hp, Or.inr hr⟩))

-- 需要一点经典推理的例子
example (p q : Prop) : ¬(p ∧ ¬q) → (p → q) :=
  fun h : ¬(p ∧ ¬q) =>
  fun hp : p =>
  show q from
    Or.elim (em q)
      (fun hq : q => hq)
      (fun hnq : ¬q => absurd (And.intro hp hnq) h)

练习

证明以下等式,用真实证明取代「sorry」占位符。

variable (p q r : Prop)

--  ∧ 和 ∨ 的交换律
example : p ∧ q ↔ q ∧ p := sorry
example : p ∨ q ↔ q ∨ p := sorry

-- ∧ 和 ∨ 的结合律
example : (p ∧ q) ∧ r ↔ p ∧ (q ∧ r) := sorry
example : (p ∨ q) ∨ r ↔ p ∨ (q ∨ r) := sorry

-- 分配律
example : p ∧ (q ∨ r) ↔ (p ∧ q) ∨ (p ∧ r) := sorry
example : p ∨ (q ∧ r) ↔ (p ∨ q) ∧ (p ∨ r) := sorry

-- 其他性质
example : (p → (q → r)) ↔ (p ∧ q → r) := sorry
example : ((p ∨ q) → r) ↔ (p → r) ∧ (q → r) := sorry
example : ¬(p ∨ q) ↔ ¬p ∧ ¬q := sorry
example : ¬p ∨ ¬q → ¬(p ∧ q) := sorry
example : ¬(p ∧ ¬p) := sorry
example : p ∧ ¬q → ¬(p → q) := sorry
example : ¬p → (p → q) := sorry
example : (¬p ∨ q) → (p → q) := sorry
example : p ∨ False ↔ p := sorry
example : p ∧ False ↔ False := sorry
example : (p → q) → (¬q → ¬p) := sorry

下面这些需要一点经典逻辑。

open Classical

variable (p q r : Prop)

example : (p → q ∨ r) → ((p → q) ∨ (p → r)) := sorry
example : ¬(p ∧ q) → ¬p ∨ ¬q := sorry
example : ¬(p → q) → p ∧ ¬q := sorry
example : (p → q) → (¬p ∨ q) := sorry
example : (¬q → ¬p) → (p → q) := sorry
example : p ∨ ¬p := sorry
example : (((p → q) → p) → p) := sorry

最后,证明 ¬(p ↔ ¬p) 且不使用经典逻辑。

量词与等价

上一章介绍了构造包含命题连接词的证明方法。在本章中,我们扩展逻辑结构,包括全称量词和存在量词,以及等价关系。

全称量词

如果 α 是任何类型,我们可以将 α 上的一元谓词 p 作为 α → Prop 类型的对象。在这种情况下,给定 x : αp x 表示断言 px 上成立。类似地,一个对象 r : α → α → Prop 表示 α 上的二元关系:给定 x y : αr x y 表示断言 xy 相关。

全称量词 ∀ x : α, p x 表示,对于每一个 x : αp x 成立。与命题连接词一样,在自然演绎系统中,「forall」有引入和消去规则。非正式地,引入规则是:

给定 p x 的证明,在 x : α 是任意的情况下,我们得到 ∀ x : α, p x 的证明。

消去规则是:

给定 ∀ x : α, p x 的证明和任何项 t : α,我们得到 p t 的证明。

与蕴含的情况一样,命题即类型。回想依值箭头类型的引入规则:

给定类型为 β x 的项 t,在 x : α 是任意的情况下,我们有 (fun x : α => t) : (x : α) → β x

消去规则:

给定项 s : (x : α) → β x 和任何项 t : α,我们有 s t : β t

p x 具有 Prop 类型的情况下,如果我们用 ∀ x : α, p x 替换 (x : α) → β x,就得到构建涉及全称量词的证明的规则。

因此,构造演算用全称表达式来识别依值箭头类型。如果 p 是任何表达式,∀ x : α, p 不过是 (x : α) → p 的替代符号,在 p 是命题的情况下,前者比后者更自然。通常,表达式 p 取决于 x : α。回想一下,在普通函数空间中,我们可以将 α → β 解释为 (x : α) → β 的特殊情况,其中 β 不依赖于 x。类似地,我们可以把命题之间的蕴涵 p → q 看作是 ∀ x : p, q 的特殊情况,其中 q 不依赖于 x

下面是一个例子,说明了如何运用命题即类型对应规则。 可以用 \forall 输入,也可以用前两个字母简写 \fo

example (α : Type) (p q : α → Prop) : (∀ x : α, p x ∧ q x) → ∀ y : α, p y :=
  fun h : ∀ x : α, p x ∧ q x =>
  fun y : α =>
  show p y from (h y).left

作为一种符号约定,我们给予全称量词尽可能最宽的优先级范围,因此上面例子中的假设中,需要用括号将 x 上的量词限制起来。证明 ∀ y : α, p y 的标准方法是取任意的 y,然后证明 p y。这是引入规则。现在,给定 h 有类型 ∀ x : α, p x ∧ q x,表达式 h y 有类型 p y ∧ q y。这是消去规则。取合取的左侧得到想要的结论 p y

只有约束变量名称不同的表达式被认为是等价的。因此,例如,我们可以在假设和结论中使用相同的变量 x,并在证明中用不同的变量 z 实例化它:

example (α : Type) (p q : α → Prop) : (∀ x : α, p x ∧ q x) → ∀ x : α, p x :=
  fun h : ∀ x : α, p x ∧ q x =>
  fun z : α =>
  show p z from And.left (h z)

再举一个例子,下面是关系 r 的传递性:

variable (α : Type) (r : α → α → Prop)
variable (trans_r : ∀ x y z, r x y → r y z → r x z)

variable (a b c : α)
variable (hab : r a b) (hbc : r b c)

#check trans_r    -- ∀ (x y z : α), r x y → r y z → r x z
#check trans_r a b c -- r a b → r b c → r a c
#check trans_r a b c hab -- r b c → r a c
#check trans_r a b c hab hbc -- r a c

当我们在值 a b c 上实例化 trans_r 时,我们最终得到 r a b → r b c → r a c 的证明。将此应用于「假设」hab : r a b,我们得到了 r b c → r a c 的一个证明。最后将它应用到假设 hbc 中,得到结论 r a c 的证明。

variable (α : Type) (r : α → α → Prop)
variable (trans_r : ∀ {x y z}, r x y → r y z → r x z)

variable (a b c : α)
variable (hab : r a b) (hbc : r b c)

#check trans_r
#check trans_r hab
#check trans_r hab hbc

优点是我们可以简单地写 trans_r hab hbc 作为 r a c 的证明。一个缺点是 Lean 没有足够的信息来推断表达式 trans_rtrans_r hab 中的参数类型。第一个 #check 命令的输出是 r ?m.1 ?m.2 → r ?m.2 ?m.3 → r ?m.1 ?m.3,表示在本例中隐式参数未指定。

下面是一个用等价关系进行基本推理的例子:

variable (α : Type) (r : α → α → Prop)

variable (refl_r : ∀ x, r x x)
variable (symm_r : ∀ {x y}, r x y → r y x)
variable (trans_r : ∀ {x y z}, r x y → r y z → r x z)

example (a b c d : α) (hab : r a b) (hcb : r c b) (hcd : r c d) : r a d :=
  trans_r (trans_r hab (symm_r hcb)) hcd

为了习惯使用全称量词,你应该尝试本节末尾的一些练习。

依值箭头类型的类型规则,特别是全称量词,体现了 Prop 命题类型与其他对象的类型的不同。假设我们有 α : Sort iβ : Sort j,其中表达式 β 可能依赖于变量 x : α。那么 (x : α) → βSort (imax i j) 的一个元素,其中 imax i jijj 不为0时的最大值,否则为0。

其想法如下。如果 j 不是 0,然后 (x : α) → βSort (max i j) 类型的一个元素。换句话说,从 αβ 的一类依值函数存在于指数为 ij 最大值的宇宙中。然而,假设 β 属于 Sort 0,即 Prop 的一个元素。在这种情况下,(x : α) → β 也是 Sort 0 的一个元素,无论 α 生活在哪种类型的宇宙中。换句话说,如果 β 是一个依赖于 α 的命题,那么 ∀ x : α, β 又是一个命题。这反映出 Prop 作为一种命题类型而不是数据类型,这也使得 Prop 具有「非直谓性」(impredicative)。

「直谓性」一词起源于20世纪初的数学基础发展,当时逻辑学家如庞加莱和罗素将集合论的悖论归咎于「恶性循环」:当我们通过量化一个集合来定义一个属性时,这个集合包含了被定义的属性。注意,如果 α 是任何类型,我们可以在 α 上形成所有谓词的类型 α → Prop(α 的「幂」类型)。Prop的非直谓性意味着我们可以通过 α → Prop 形成量化命题。特别是,我们可以通过量化所有关于 α 的谓词来定义 α 上的谓词,这正是曾经被认为有问题的循环类型。

等价

现在让我们来看看在 Lean 库中定义的最基本的关系之一,即等价关系。在归纳类型一章中,我们将解释如何从 Lean 的逻辑框架中定义等价。在这里我们解释如何使用它。

等价关系的基本性质:反身性、对称性、传递性。

#check Eq.refl    -- Eq.refl.{u_1} {α : Sort u_1} (a : α) : a = a
#check Eq.symm    -- Eq.symm.{u} {α : Sort u} {a b : α} (h : a = b) : b = a
#check Eq.trans   -- Eq.trans.{u} {α : Sort u} {a b c : α} (h₁ : a = b) (h₂ : b = c) : a = c

通过告诉 Lean 不要插入隐参数(在这里显示为元变量)可以使输出更容易阅读。

universe u

#check @Eq.refl.{u}   -- @Eq.refl : ∀ {α : Sort u} (a : α), a = a
#check @Eq.symm.{u}   -- @Eq.symm : ∀ {α : Sort u} {a b : α}, a = b → b = a
#check @Eq.trans.{u}  -- @Eq.trans : ∀ {α : Sort u} {a b c : α}, a = b → b = c → a = c

.{u} 告诉 Lean 实例化宇宙 u 上的常量。

因此,我们可以将上一节中的示例具体化为等价关系:

variable (α : Type) (a b c d : α)
variable (hab : a = b) (hcb : c = b) (hcd : c = d)

example : a = d :=
  Eq.trans (Eq.trans hab (Eq.symm hcb)) hcd

我们也可以使用投影记号:

variable (α : Type) (a b c d : α)
variable (hab : a = b) (hcb : c = b) (hcd : c = d)
example : a = d := (hab.trans hcb.symm).trans hcd

反身性比它看上去更强大。回想一下,在构造演算中,项有一个计算解释,可规约为相同形式的项会被逻辑框架视为相同的。因此,一些非平凡的恒等式可以通过自反性来证明:

variable (α β : Type)

example (f : α → β) (a : α) : (fun x => f x) a = f a := Eq.refl _
example (a : α) (b : β) : (a, b).1 = a := Eq.refl _
example : 2 + 3 = 5 := Eq.refl _

这个特性非常重要,以至于库中为 Eq.refl _ 专门定义了一个符号 rfl

variable (α β : Type)
example (f : α → β) (a : α) : (fun x => f x) a = f a := rfl
example (a : α) (b : β) : (a, b).1 = a := rfl
example : 2 + 3 = 5 := rfl

然而,等价不仅仅是一种关系。它有一个重要的性质,即每个断言都遵从等价性,即我们可以在不改变真值的情况下对表达式做等价代换。也就是说,给定 h1 : a = bh2 : p a,我们可以构造一个证明 p b,只需要使用代换 Eq.subst h1 h2

example (α : Type) (a b : α) (p : α → Prop)
        (h1 : a = b) (h2 : p a) : p b :=
  Eq.subst h1 h2

example (α : Type) (a b : α) (p : α → Prop)
    (h1 : a = b) (h2 : p a) : p b :=
  h1 ▸ h2

第二个例子中的三角形是建立在 Eq.substEq.symm 之上的宏,它可以通过 \t 来输入。

规则 Eq.subst 定义了一些辅助规则,用来执行更显式的替换。它们是为处理应用型项,即形式为 s t 的项而设计的。这些辅助规则是,使用 congrArg 来替换参数,使用 congrFun 来替换正在应用的项,并且可以同时使用 congr 来替换两者。

variable (α : Type)
variable (a b : α)
variable (f g : α → Nat)
variable (h₁ : a = b)
variable (h₂ : f = g)

example : f a = f b := congrArg f h₁
example : f a = g a := congrFun h₂ a
example : f a = g b := congr h₂ h₁

Lean 的库包含大量通用的等式,例如:

variable (a b c : Nat)

example : a + 0 = a := Nat.add_zero a
example : 0 + a = a := Nat.zero_add a
example : a * 1 = a := Nat.mul_one a
example : 1 * a = a := Nat.one_mul a
example : a + b = b + a := Nat.add_comm a b
example : a + b + c = a + (b + c) := Nat.add_assoc a b c
example : a * b = b * a := Nat.mul_comm a b
example : a * b * c = a * (b * c) := Nat.mul_assoc a b c
example : a * (b + c) = a * b + a * c := Nat.mul_add a b c
example : a * (b + c) = a * b + a * c := Nat.left_distrib a b c
example : (a + b) * c = a * c + b * c := Nat.add_mul a b c
example : (a + b) * c = a * c + b * c := Nat.right_distrib a b c

Nat.mul_addNat.add_mulNat.left_distribNat.right_distrib 的代称。上面的属性是为自然数类型 Nat 声明的。

这是一个使用代换以及结合律、交换律和分配律的自然数计算的例子。

example (x y : Nat) : (x + y) * (x + y) = x * x + y * x + x * y + y * y :=
  have h1 : (x + y) * (x + y) = (x + y) * x + (x + y) * y :=
    Nat.mul_add (x + y) x y
  have h2 : (x + y) * (x + y) = x * x + y * x + (x * y + y * y) :=
    (Nat.add_mul x y x) ▸ (Nat.add_mul x y y) ▸ h1
  h2.trans (Nat.add_assoc (x * x + y * x) (x * y) (y * y)).symm

注意,Eq.subst 的第二个隐式参数提供了将要发生代换的表达式上下文,其类型为 α → Prop。因此,推断这个谓词需要一个高阶合一(higher-order unification)的实例。一般来说,确定高阶合一器是否存在的问题是无法确定的,而 Lean 充其量只能提供不完美的和近似的解决方案。因此,Eq.subst 并不总是做你想要它做的事。宏 h ▸ e 使用了更有效的启发式方法来计算这个隐参数,并且在不能应用 Eq.subst 的情况下通常会成功。

因为等式推理是如此普遍和重要,Lean 提供了许多机制来更有效地执行它。下一节将提供允许你以更自然和清晰的方式编写计算式证明的语法。但更重要的是,等式推理是由项重写器、简化器和其他种类的自动化方法支持的。术语重写器和简化器将在下一节中简要描述,然后在下一章中更详细地描述。

计算式证明

一个计算式证明是指一串使用诸如等式的传递性等基本规则得到的中间结果。在 Lean 中,计算式证明从关键字 calc 开始,语法如下:

calc
  <expr>_0  'op_1'  <expr>_1  ':='  <proof>_1
  '_'       'op_2'  <expr>_2  ':='  <proof>_2
  ...
  '_'       'op_n'  <expr>_n  ':='  <proof>_n

calc 下的每一行使用相同的缩进。每个 <proof>_i<expr>_{i-1} op_i <expr>_i 的证明。

我们也可以在第一个关系中使用 _(就在 <expr>_0 之后),这对于对齐关系/证明对的序列很有用:

calc <expr>_0
    '_' 'op_1' <expr>_1 ':=' <proof>_1
    '_' 'op_2' <expr>_2 ':=' <proof>_2
    ...
    '_' 'op_n' <expr>_n ':=' <proof>_n

例子:

variable (a b c d e : Nat)
variable (h1 : a = b)
variable (h2 : b = c + 1)
variable (h3 : c = d)
variable (h4 : e = 1 + d)

theorem T : a = e :=
  calc
    a = b      := h1
    _ = c + 1  := h2
    _ = d + 1  := congrArg Nat.succ h3
    _ = 1 + d  := Nat.add_comm d 1
    _ = e      := Eq.symm h4

这种写证明的风格在与 simprewrite 策略(Tactic)结合使用时最为有效,这些策略将在下一章详细讨论。例如,用缩写 rw 表示重写,上面的证明可以写成如下。

variable (a b c d e : Nat)
variable (h1 : a = b)
variable (h2 : b = c + 1)
variable (h3 : c = d)
variable (h4 : e = 1 + d)
theorem T : a = e :=
  calc
    a = b      := by rw [h1]
    _ = c + 1  := by rw [h2]
    _ = d + 1  := by rw [h3]
    _ = 1 + d  := by rw [Nat.add_comm]
    _ = e      := by rw [h4]

本质上,rw 策略使用一个给定的等式(它可以是一个假设、一个定理名称或一个复杂的项)来「重写」目标。如果这样做将目标简化为一种等式 t = t,那么该策略将使用反身性来证明这一点。

重写可以一次应用一系列,因此上面的证明可以缩写为:

variable (a b c d e : Nat)
variable (h1 : a = b)
variable (h2 : b = c + 1)
variable (h3 : c = d)
variable (h4 : e = 1 + d)
theorem T : a = e :=
  calc
    a = d + 1  := by rw [h1, h2, h3]
    _ = 1 + d  := by rw [Nat.add_comm]
    _ = e      := by rw [h4]

甚至更简单:

variable (a b c d e : Nat)
variable (h1 : a = b)
variable (h2 : b = c + 1)
variable (h3 : c = d)
variable (h4 : e = 1 + d)
theorem T : a = e :=
  by rw [h1, h2, h3, Nat.add_comm, h4]

相反,simp 策略通过在项中以任意顺序在任何适用的地方重复应用给定的等式来重写目标。它还使用了之前声明给系统的其他规则,并明智地应用交换性以避免循环。因此,我们也可以如下证明定理:

variable (a b c d e : Nat)
variable (h1 : a = b)
variable (h2 : b = c + 1)
variable (h3 : c = d)
variable (h4 : e = 1 + d)
theorem T : a = e :=
  by simp [h1, h2, h3, Nat.add_comm, h4]

我们将在下一章讨论 rwsimp 的变体。

calc 命令可以配置为任何支持某种形式的传递性的关系式。它甚至可以结合不同的关系式。

example (a b c d : Nat) (h1 : a = b) (h2 : b ≤ c) (h3 : c + 1 < d) : a < d :=
  calc
    a = b     := h1
    _ < b + 1 := Nat.lt_succ_self b
    _ ≤ c + 1 := Nat.succ_le_succ h2
    _ < d     := h3

你可以通过添加 Trans 类型类(Type class)的新实例来「教给」calc 新的传递性定理。稍后将介绍类型类,但下面的小示例将演示如何使用新的 Trans 实例扩展 calc 表示法。

def divides (x y : Nat) : Prop :=
  ∃ k, k*x = y

def divides_trans (h₁ : divides x y) (h₂ : divides y z) : divides x z :=
  let ⟨k₁, d₁⟩ := h₁
  let ⟨k₂, d₂⟩ := h₂
  ⟨k₁ * k₂, by rw [Nat.mul_comm k₁ k₂, Nat.mul_assoc, d₁, d₂]⟩

def divides_mul (x : Nat) (k : Nat) : divides x (k*x) :=
  ⟨k, rfl⟩

instance : Trans divides divides divides where
  trans := divides_trans

example (h₁ : divides x y) (h₂ : y = z) : divides x (2*z) :=
  calc
    divides x y     := h₁
    _ = z           := h₂
    divides _ (2*z) := divides_mul ..

infix:50 " ∣ " => divides

example (h₁ : divides x y) (h₂ : y = z) : divides x (2*z) :=
  calc
    x ∣ y   := h₁
    _ = z   := h₂
    _ ∣ 2*z := divides_mul ..

上面的例子也清楚地表明,即使关系式没有中缀符号,也可以使用 calc。最后,我们注意到上面例子中的竖线是unicode。我们使用 unicode 来确保我们不会重载在match .. with表达式中使用的ASCII|

使用 calc,我们可以以一种更自然、更清晰的方式写出上一节的证明。

example (x y : Nat) : (x + y) * (x + y) = x * x + y * x + x * y + y * y :=
  calc
    (x + y) * (x + y) = (x + y) * x + (x + y) * y  := by rw [Nat.mul_add]
    _ = x * x + y * x + (x + y) * y                := by rw [Nat.add_mul]
    _ = x * x + y * x + (x * y + y * y)            := by rw [Nat.add_mul]
    _ = x * x + y * x + x * y + y * y              := by rw [←Nat.add_assoc]

这里值得考虑另一种 calc 表示法。当第一个表达式占用这么多空间时,在第一个关系中使用 _ 自然会对齐所有关系式:

example (x y : Nat) : (x + y) * (x + y) = x * x + y * x + x * y + y * y :=
  calc (x + y) * (x + y)
    _ = (x + y) * x + (x + y) * y       := by rw [Nat.mul_add]
    _ = x * x + y * x + (x + y) * y     := by rw [Nat.add_mul]
    _ = x * x + y * x + (x * y + y * y) := by rw [Nat.add_mul]
    _ = x * x + y * x + x * y + y * y   := by rw [←Nat.add_assoc]

Nat.add_assoc 之前的左箭头指挥重写以相反的方向使用等式。(你可以输入 \l 或 ascii 码 <-。)如果追求简洁,rwsimp 可以一次性完成这项工作:

example (x y : Nat) : (x + y) * (x + y) = x * x + y * x + x * y + y * y :=
  by rw [Nat.mul_add, Nat.add_mul, Nat.add_mul, ←Nat.add_assoc]

example (x y : Nat) : (x + y) * (x + y) = x * x + y * x + x * y + y * y :=
  by simp [Nat.mul_add, Nat.add_mul, Nat.add_assoc]

存在量词

存在量词可以写成 exists x : α, p x∃ x : α, p x。这两个写法实际上在 Lean 的库中的定义为一个更冗长的表达式,Exists (fun x : α => p x)

存在量词也有一个引入规则和一个消去规则。引入规则很简单:要证明 ∃ x : α, p x,只需提供一个合适的项 t 和对 p t 的证明即可。\exists 或简写 \ex 输入,下面是一些例子:

example : ∃ x : Nat, x > 0 :=
  have h : 1 > 0 := Nat.zero_lt_succ 0
  Exists.intro 1 h

example (x : Nat) (h : x > 0) : ∃ y, y < x :=
  Exists.intro 0 h

example (x y z : Nat) (hxy : x < y) (hyz : y < z) : ∃ w, x < w ∧ w < z :=
  Exists.intro y (And.intro hxy hyz)

#check @Exists.intro -- ∀ {α : Sort u_1} {p : α → Prop} (w : α), p w → Exists p

当类型可从上下文中推断时,我们可以使用匿名构造子表示法 ⟨t, h⟩ 替换 Exists.intro t h

example : ∃ x : Nat, x > 0 :=
  have h : 1 > 0 := Nat.zero_lt_succ 0
  ⟨1, h⟩

example (x : Nat) (h : x > 0) : ∃ y, y < x :=
  ⟨0, h⟩

example (x y z : Nat) (hxy : x < y) (hyz : y < z) : ∃ w, x < w ∧ w < z :=
  ⟨y, hxy, hyz⟩

注意 Exists.intro 有隐参数:Lean 必须在结论 ∃ x, p x 中推断谓词 p : α → Prop。这不是一件小事。例如,如果我们有 hg : g 0 0 = 0Exists.intro 0 hg,有许多可能的值的谓词 p,对应定理 ∃ x, g x x = x∃ x, g x x = 0∃ x, g x 0 = x,等等。Lean 使用上下文来推断哪个是合适的。下面的例子说明了这一点,在这个例子中,我们设置了选项 pp.explicit 为true,要求 Lean 打印隐参数。

variable (g : Nat → Nat → Nat)
variable (hg : g 0 0 = 0)

theorem gex1 : ∃ x, g x x = x := ⟨0, hg⟩
theorem gex2 : ∃ x, g x 0 = x := ⟨0, hg⟩
theorem gex3 : ∃ x, g 0 0 = x := ⟨0, hg⟩
theorem gex4 : ∃ x, g x x = 0 := ⟨0, hg⟩

set_option pp.explicit true  -- 打印隐参数
#print gex1
#print gex2
#print gex3
#print gex4

我们可以将 Exists.intro 视为信息隐藏操作,因为它将断言的具体实例隐藏起来变成了存在量词。存在消去规则 Exists.elim 执行相反的操作。它允许我们从 ∃ x : α, p x 证明一个命题 q,通过证明对于任意值 wp w 都能推出 q。粗略地说,既然我们知道有一个 x 满足 p x,我们可以给它起个名字,比如 w。如果 q 没有提到 w,那么表明 p w 能推出 q 就等同于表明 q 从任何这样的 x 的存在而推得。下面是一个例子:

variable (α : Type) (p q : α → Prop)

example (h : ∃ x, p x ∧ q x) : ∃ x, q x ∧ p x :=
  Exists.elim h
    (fun w =>
     fun hw : p w ∧ q w =>
     show ∃ x, q x ∧ p x from ⟨w, hw.right, hw.left⟩)

把存在消去规则和析取消去规则作个比较可能会带来一些启发。命题 ∃ x : α, p x 可以看成一个对所有 α 中的元素 a 所组成的命题 p a 的大型析取。注意到匿名构造子 ⟨w, hw.right, hw.left⟩ 是嵌套的构造子 ⟨w, ⟨hw.right, hw.left⟩⟩ 的缩写。

存在式命题类型很像依值类型一节所述的 sigma 类型。给定 a : αh : p a 时,项 Exists.intro a h 具有类型 (∃ x : α, p x) : Prop,而 Sigma.mk a h 具有类型 (Σ x : α, p x) : TypeΣ 之间的相似性是Curry-Howard同构的另一例子。

Lean 提供一个更加方便的消去存在量词的途径,那就是通过 match 表达式。

variable (α : Type) (p q : α → Prop)

example (h : ∃ x, p x ∧ q x) : ∃ x, q x ∧ p x :=
  match h with
  | ⟨w, hw⟩ => ⟨w, hw.right, hw.left⟩

match 表达式是 Lean 功能定义系统的一部分,它提供了复杂功能的方便且丰富的表达方式。再一次,正是Curry-Howard同构让我们能够采用这种机制来编写证明。match 语句将存在断言「析构」到组件 whw 中,然后可以在语句体中使用它们来证明命题。我们可以对 match 中使用的类型进行注释,以提高清晰度:

variable (α : Type) (p q : α → Prop)
example (h : ∃ x, p x ∧ q x) : ∃ x, q x ∧ p x :=
  match h with
  | ⟨(w : α), (hw : p w ∧ q w)⟩ => ⟨w, hw.right, hw.left⟩

我们甚至可以同时使用 match 语句来分解合取:

variable (α : Type) (p q : α → Prop)
example (h : ∃ x, p x ∧ q x) : ∃ x, q x ∧ p x :=
  match h with
  | ⟨w, hpw, hqw⟩ => ⟨w, hqw, hpw⟩

Lean 还提供了一个模式匹配的 let 表达式:

variable (α : Type) (p q : α → Prop)
example (h : ∃ x, p x ∧ q x) : ∃ x, q x ∧ p x :=
  let ⟨w, hpw, hqw⟩ := h
  ⟨w, hqw, hpw⟩

这实际上是上面的 match 结构的替代表示法。Lean 甚至允许我们在 fun 表达式中使用隐含的 match

variable (α : Type) (p q : α → Prop)
example : (∃ x, p x ∧ q x) → ∃ x, q x ∧ p x :=
  fun ⟨w, hpw, hqw⟩ => ⟨w, hqw, hpw⟩

我们将在归纳和递归一章看到所有这些变体都是更一般的模式匹配构造的实例。

在下面的例子中,我们将 even a 定义为 ∃ b, a = 2 * b,然后我们证明两个偶数的和是偶数。

def is_even (a : Nat) := ∃ b, a = 2 * b

theorem even_plus_even (h1 : is_even a) (h2 : is_even b) : is_even (a + b) :=
  Exists.elim h1 (fun w1 (hw1 : a = 2 * w1) =>
  Exists.elim h2 (fun w2 (hw2 : b = 2 * w2) =>
    Exists.intro (w1 + w2)
      (calc a + b
        _ = 2 * w1 + 2 * w2 := by rw [hw1, hw2]
        _ = 2 * (w1 + w2)   := by rw [Nat.mul_add])))

使用本章描述的各种小工具——match 语句、匿名构造子和 rewrite 策略,我们可以简洁地写出如下证明:

def is_even (a : Nat) := ∃ b, a = 2 * b
theorem even_plus_even (h1 : is_even a) (h2 : is_even b) : is_even (a + b) :=
  match h1, h2 with
  | ⟨w1, hw1⟩, ⟨w2, hw2⟩ => ⟨w1 + w2, by rw [hw1, hw2, Nat.mul_add]⟩

就像构造主义的「或」比古典的「或」强,同样,构造的「存在」也比古典的「存在」强。例如,下面的推论需要经典推理,因为从构造的角度来看,知道并不是每一个 x 都满足 ¬ p,并不等于有一个特定的 x 满足 p

open Classical
variable (p : α → Prop)

example (h : ¬ ∀ x, ¬ p x) : ∃ x, p x :=
  byContradiction
    (fun h1 : ¬ ∃ x, p x =>
      have h2 : ∀ x, ¬ p x :=
        fun x =>
        fun h3 : p x =>
        have h4 : ∃ x, p x := ⟨x, h3⟩
        show False from h1 h4
      show False from h h2)

下面是一些涉及存在量词的常见等式。在下面的练习中,我们鼓励你尽可能多写出证明。你需要判断哪些是非构造主义的,因此需要一些经典推理。

open Classical

variable (α : Type) (p q : α → Prop)
variable (r : Prop)

example : (∃ x : α, r) → r := sorry
example (a : α) : r → (∃ x : α, r) := sorry
example : (∃ x, p x ∧ r) ↔ (∃ x, p x) ∧ r := sorry
example : (∃ x, p x ∨ q x) ↔ (∃ x, p x) ∨ (∃ x, q x) := sorry

example : (∀ x, p x) ↔ ¬ (∃ x, ¬ p x) := sorry
example : (∃ x, p x) ↔ ¬ (∀ x, ¬ p x) := sorry
example : (¬ ∃ x, p x) ↔ (∀ x, ¬ p x) := sorry
example : (¬ ∀ x, p x) ↔ (∃ x, ¬ p x) := sorry

example : (∀ x, p x → r) ↔ (∃ x, p x) → r := sorry
example (a : α) : (∃ x, p x → r) ↔ (∀ x, p x) → r := sorry
example (a : α) : (∃ x, r → p x) ↔ (r → ∃ x, p x) := sorry

注意,第二个例子和最后两个例子要求假设至少有一个类型为 α 的元素 a

以下是两个比较困难的问题的解:

open Classical

variable (α : Type) (p q : α → Prop)
variable (a : α)
variable (r : Prop)

example : (∃ x, p x ∨ q x) ↔ (∃ x, p x) ∨ (∃ x, q x) :=
  Iff.intro
    (fun ⟨a, (h1 : p a ∨ q a)⟩ =>
      Or.elim h1
        (fun hpa : p a => Or.inl ⟨a, hpa⟩)
        (fun hqa : q a => Or.inr ⟨a, hqa⟩))
    (fun h : (∃ x, p x) ∨ (∃ x, q x) =>
      Or.elim h
        (fun ⟨a, hpa⟩ => ⟨a, (Or.inl hpa)⟩)
        (fun ⟨a, hqa⟩ => ⟨a, (Or.inr hqa)⟩))

example : (∃ x, p x → r) ↔ (∀ x, p x) → r :=
  Iff.intro
    (fun ⟨b, (hb : p b → r)⟩ =>
     fun h2 : ∀ x, p x =>
     show r from hb (h2 b))
    (fun h1 : (∀ x, p x) → r =>
     show ∃ x, p x → r from
       byCases
         (fun hap : ∀ x, p x => ⟨a, λ h' => h1 hap⟩)
         (fun hnap : ¬ ∀ x, p x =>
          byContradiction
            (fun hnex : ¬ ∃ x, p x → r =>
              have hap : ∀ x, p x :=
                fun x =>
                byContradiction
                  (fun hnp : ¬ p x =>
                    have hex : ∃ x, p x → r := ⟨x, (fun hp => absurd hp hnp)⟩
                    show False from hnex hex)
              show False from hnap hap)))

多来点儿证明语法

我们已经看到像 funhaveshow 这样的关键字使得写出反映非正式数学证明结构的正式证明项成为可能。在本节中,我们将讨论证明语言的一些通常很方便的附加特性。

首先,我们可以使用匿名的 have 表达式来引入一个辅助目标,而不需要标注它。我们可以使用关键字 this 来引用最后一个以这种方式引入的表达式:

variable (f : Nat → Nat)
variable (h : ∀ x : Nat, f x ≤ f (x + 1))

example : f 0 ≤ f 3 :=
  have : f 0 ≤ f 1 := h 0
  have : f 0 ≤ f 2 := Nat.le_trans this (h 1)
  show f 0 ≤ f 3 from Nat.le_trans this (h 2)

通常证明从一个事实转移到另一个事实,所以这可以有效地消除杂乱的大量标签。

当目标可以推断出来时,我们也可以让 Lean 写 by assumption 来填写证明:

variable (f : Nat → Nat)
variable (h : ∀ x : Nat, f x ≤ f (x + 1))
example : f 0 ≤ f 3 :=
  have : f 0 ≤ f 1 := h 0
  have : f 0 ≤ f 2 := Nat.le_trans (by assumption) (h 1)
  show f 0 ≤ f 3 from Nat.le_trans (by assumption) (h 2)

这告诉 Lean 使用 assumption 策略,反过来,通过在局部上下文中找到合适的假设来证明目标。我们将在下一章学习更多关于 assumption 策略的内容。

我们也可以通过写 ‹p› 的形式要求 Lean 填写证明,其中 p 是我们希望 Lean 在上下文中找到的证明命题。你可以分别使用 \f<\f> 输入这些角引号。字母「f」表示「French」,因为 unicode 符号也可以用作法语引号。事实上,这个符号在 Lean 中定义如下:

notation "‹" p "›" => show p by assumption

这种方法比使用 by assumption 更稳健,因为需要推断的假设类型是显式给出的。它还使证明更具可读性。这里有一个更详细的例子:

variable (f : Nat → Nat)
variable (h : ∀ x : Nat, f x ≤ f (x + 1))

example : f 0 ≥ f 1 → f 1 ≥ f 2 → f 0 = f 2 :=
  fun _ : f 0 ≥ f 1 =>
  fun _ : f 1 ≥ f 2 =>
  have : f 0 ≥ f 2 := Nat.le_trans ‹f 1 ≥ f 2› ‹f 0 ≥ f 1›
  have : f 0 ≤ f 2 := Nat.le_trans (h 0) (h 1)
  show f 0 = f 2 from Nat.le_antisymm this ‹f 0 ≥ f 2›

你可以这样使用法语引号来指代上下文中的「任何东西」,而不仅仅是匿名引入的东西。它的使用也不局限于命题,尽管将它用于数据有点奇怪:

example (n : Nat) : Nat := ‹Nat›

稍后,我们将展示如何使用 Lean 中的宏系统扩展证明语言。

练习

  1. 证明以下等式:
variable (α : Type) (p q : α → Prop)

example : (∀ x, p x ∧ q x) ↔ (∀ x, p x) ∧ (∀ x, q x) := sorry
example : (∀ x, p x → q x) → (∀ x, p x) → (∀ x, q x) := sorry
example : (∀ x, p x) ∨ (∀ x, q x) → ∀ x, p x ∨ q x := sorry

你还应该想想为什么在最后一个例子中反过来是不能证明的。

  1. 当一个公式的组成部分不依赖于被全称的变量时,通常可以把它提取出一个全称量词的范围。尝试证明这些(第二个命题中的一个方向需要经典逻辑):
variable (α : Type) (p q : α → Prop)
variable (r : Prop)

example : α → ((∀ x : α, r) ↔ r) := sorry
example : (∀ x, p x ∨ r) ↔ (∀ x, p x) ∨ r := sorry
example : (∀ x, r → p x) ↔ (r → ∀ x, p x) := sorry
  1. 考虑「理发师悖论」:在一个小镇里,这里有一个(男性)理发师给所有不为自己刮胡子的人刮胡子。证明这里存在矛盾:
variable (men : Type) (barber : men)
variable (shaves : men → men → Prop)

example (h : ∀ x : men, shaves barber x ↔ ¬ shaves x x) : False := sorry
  1. 如果没有任何参数,类型 Prop 的表达式只是一个断言。填入下面 primeFermat_prime 的定义,并构造每个给定的断言。例如,通过断言每个自然数 n 都有一个大于 n 的质数,你可以说有无限多个质数。哥德巴赫弱猜想指出,每一个大于5的奇数都是三个素数的和。如果有必要,请查阅费马素数的定义或其他任何资料。
def even (n : Nat) : Prop := sorry

def prime (n : Nat) : Prop := sorry

def infinitely_many_primes : Prop := sorry

def Fermat_prime (n : Nat) : Prop := sorry

def infinitely_many_Fermat_primes : Prop := sorry

def goldbach_conjecture : Prop := sorry

def Goldbach's_weak_conjecture : Prop := sorry

def Fermat's_last_theorem : Prop := sorry
  1. 尽可能多地证明存在量词一节列出的等式。

证明策略

在本章中,我们描述了另一种构建证明的方法,即使用 策略(Tactic) 。 一个证明项代表一个数学证明;策略是描述如何建立这样一个证明的命令或指令。你可以在数学证明开始时非正式地说:「为了证明条件的必要性,展开定义,应用前面的定理,并进行简化。」就像这些指令告诉读者如何构建证明一样,策略告诉 Lean 如何构建证明。它们自然而然地支持增量式的证明书写,在这种写作方式中,你将分解一个证明,并一步步地实现目标。

译者注:tactic 和 strategy 都有策略的意思,其中 tactic 侧重细节,如排兵布阵, strategy 面向整体,如大规模战略。试译 strategy 为「要略」,与 tactic 相区分。

我们将把由策略序列组成的证明描述为「策略式」证明,前几章的证明我们称为「项式」证明。每种风格都有自己的优点和缺点。例如,策略式证明可能更难读,因为它们要求读者预测或猜测每条指令的结果。但它们一般更短,更容易写。此外,策略提供了一个使用 Lean 自动化的途径,因为自动化程序本身就是策略。

进入策略模式

从概念上讲,陈述一个定理或引入一个 have 的声明会产生一个目标,即构造一个具有预期类型的项的目标。例如, 下面创建的目标是构建一个类型为 p ∧ q ∧ p 的项,条件有常量 p q : Prophp : phq : q

theorem test (p q : Prop) (hp : p) (hq : q) : p ∧ q ∧ p :=
  sorry

写成目标如下:

    p : Prop, q : Prop, hp : p, hq : q ⊢ p ∧ q ∧ p

事实上,如果你把上面的例子中的「sorry」换成下划线,Lean 会报告说,正是这个目标没有得到解决。

通常情况下,你会通过写一个明确的项来满足这样的目标。但在任何需要项的地方,Lean 允许我们插入一个 by <tactics> 块,其中 <tactics> 是一串命令,用分号或换行符分开。你可以用下面这种方式来证明上面的定理:

theorem test (p q : Prop) (hp : p) (hq : q) : p ∧ q ∧ p :=
  by apply And.intro
     exact hp
     apply And.intro
     exact hq
     exact hp

我们经常将 by 关键字放在前一行,并将上面的例子写为

theorem test (p q : Prop) (hp : p) (hq : q) : p ∧ q ∧ p := by
  apply And.intro
  exact hp
  apply And.intro
  exact hq
  exact hp

apply 策略应用于一个表达式,被视为表示一个有零或多个参数的函数。它将结论与当前目标中的表达式统一起来,并为剩余的参数创建新的目标,只要后面的参数不依赖于它们。在上面的例子中,命令 apply And.intro 产生了两个子目标:

    case left
    p q : Prop
    hp : p
    hq : q
    ⊢ p

    case right
    p q : Prop
    hp : p
    hq : q
    ⊢ q ∧ p

第一个目标是通过 exact hp 命令来实现的。exact 命令只是 apply 的一个变体,它表示所给的表达式应该准确地填充目标。在策略证明中使用它很有益,因为它如果失败那么表明出了问题。它也比 apply 更稳健,因为繁饰器在处理被应用的表达式时,会考虑到目标所预期的类型。然而,在这种情况下,apply 也可以很好地工作。

你可以用#print命令查看所产生的证明项。

theorem test (p q : Prop) (hp : p) (hq : q) : p ∧ q ∧ p := by
 apply And.intro
 exact hp
 apply And.intro
 exact hq
 exact hp
#print test

你可以循序渐进地写一个策略脚本。在VS Code中,你可以通过按Ctrl-Shift-Enter打开一个窗口来显示信息,然后只要光标在策略块中,该窗口就会显示当前的目标。在 Emacs 中,你可以通过按C-c C-g看到任何一行末尾的目标,或者通过把光标放在最后一个策略的第一个字符之后,看到一个不完整的证明中的剩余目标。如果证明是不完整的,标记 by 会被装饰成一条红色的斜线,错误信息中包含剩余的目标。

策略命令可以接受复合表达式,而不仅仅是单一标识符。下面是前面证明的一个简短版本。

theorem test (p q : Prop) (hp : p) (hq : q) : p ∧ q ∧ p := by
  apply And.intro hp
  exact And.intro hq hp

它产生了相同的证明项。

theorem test (p q : Prop) (hp : p) (hq : q) : p ∧ q ∧ p := by
 apply And.intro hp
 exact And.intro hq hp
#print test

应用多个策略可以通过用分号连接写在一行中。

theorem test (p q : Prop) (hp : p) (hq : q) : p ∧ q ∧ p := by
  apply And.intro hp; exact And.intro hq hp

可能产生多个子目标的策略通常对子目标进行标记。例如,apply And.intro 策略将第一个目标标记为 left,将第二个目标标记为 right。在 apply 策略的情况下,标签是从 And.intro 声明中使用的参数名称推断出来的。你可以使用符号 case <tag> => <tactics> 来结构化你的策略。下面是本章中第一个策略证明的结构化版本。

theorem test (p q : Prop) (hp : p) (hq : q) : p ∧ q ∧ p := by
  apply And.intro
  case left => exact hp
  case right =>
    apply And.intro
    case left => exact hq
    case right => exact hp

使用 case 标记,你也可以在 left 之前先解决子目标 right

theorem test (p q : Prop) (hp : p) (hq : q) : p ∧ q ∧ p := by
  apply And.intro
  case right =>
    apply And.intro
    case left => exact hq
    case right => exact hp
  case left => exact hp

注意,Lean 将其他目标隐藏在 case 块内。我们说它「专注」于选定的目标。 此外,如果所选目标在 case 块的末尾没有完全解决,Lean 会标记一个错误。

对于简单的子目标,可能不值得使用其标签来选择一个子目标,但你可能仍然想要结构化证明。Lean 还提供了「子弹」符号 . <tactics>· <tactics>

theorem test (p q : Prop) (hp : p) (hq : q) : p ∧ q ∧ p := by
  apply And.intro
  . exact hp
  . apply And.intro
    . exact hq
    . exact hp

基本策略

除了 applyexact 之外,另一个有用的策略是 intro,它引入了一个假设。下面是我们在前一章中证明的命题逻辑中的一个等价性的例子,现在用策略来证明。

example (p q r : Prop) : p ∧ (q ∨ r) ↔ (p ∧ q) ∨ (p ∧ r) := by
  apply Iff.intro
  . intro h
    apply Or.elim (And.right h)
    . intro hq
      apply Or.inl
      apply And.intro
      . exact And.left h
      . exact hq
    . intro hr
      apply Or.inr
      apply And.intro
      . exact And.left h
      . exact hr
  . intro h
    apply Or.elim h
    . intro hpq
      apply And.intro
      . exact And.left hpq
      . apply Or.inl
        exact And.right hpq
    . intro hpr
      apply And.intro
      . exact And.left hpr
      . apply Or.inr
        exact And.right hpr

intro 命令可以更普遍地用于引入任何类型的变量。

example (α : Type) : α → α := by
  intro a
  exact a

example (α : Type) : ∀ x : α, x = x := by
  intro x
  exact Eq.refl x

你可以同时引入好几个变量:

example : ∀ a b c : Nat, a = b → a = c → c = b := by
  intro a b c h₁ h₂
  exact Eq.trans (Eq.symm h₂) h₁

由于 apply 策略是一个用于交互式构造函数应用的命令,intro 策略是一个用于交互式构造函数抽象的命令(即 fun x => e 形式的项)。 与 lambda 抽象符号一样,intro 策略允许我们使用隐式的 match

example (α : Type) (p q : α → Prop) : (∃ x, p x ∧ q x) → ∃ x, q x ∧ p x := by
  intro ⟨w, hpw, hqw⟩
  exact ⟨w, hqw, hpw⟩

就像 match 表达式一样,你也可以提供多个选项。

example (α : Type) (p q : α → Prop) : (∃ x, p x ∨ q x) → ∃ x, q x ∨ p x := by
  intro
  | ⟨w, Or.inl h⟩ => exact ⟨w, Or.inr h⟩
  | ⟨w, Or.inr h⟩ => exact ⟨w, Or.inl h⟩

intros 策略可以在没有任何参数的情况下使用,在这种情况下,它选择名字并尽可能多地引入变量。稍后你会看到一个例子。

assumption 策略在当前目标的背景下查看假设,如果有一个与结论相匹配的假设,它就会应用这个假设。

example (x y z w : Nat) (h₁ : x = y) (h₂ : y = z) (h₃ : z = w) : x = w := by
  apply Eq.trans h₁
  apply Eq.trans h₂
  assumption   -- 应用h₃

若有必要,它会在结论中统一元变量。

example (x y z w : Nat) (h₁ : x = y) (h₂ : y = z) (h₃ : z = w) : x = w := by
  apply Eq.trans
  assumption      -- 求解了 x = ?b with h₁
  apply Eq.trans
  assumption      -- 求解了 y = ?h₂.b with h₂
  assumption      -- 求解了 z = w with h₃

下面的例子使用 intros 命令来自动引入三个变量和两个假设:

example : ∀ a b c : Nat, a = b → a = c → c = b := by
  intros
  apply Eq.trans
  apply Eq.symm
  assumption
  assumption

请注意,由 Lean 自动生成的名称在默认情况下是不可访问的。其动机是为了确保你的策略证明不依赖于自动生成的名字,并因此而更加强大。然而,你可以使用组合器 unhygienic 来禁用这一限制。

example : ∀ a b c : Nat, a = b → a = c → c = b := by unhygienic
  intros
  apply Eq.trans
  apply Eq.symm
  exact a_2
  exact a_1

你也可以使用 rename_i 策略来重命名你的上下文中最近的不能访问的名字。在下面的例子中,策略 rename_i h1 _ h2 在你的上下文中重命名了三个假设中的两个。

example : ∀ a b c d : Nat, a = b → a = d → a = c → c = b := by
  intros
  rename_i h1 _ h2
  apply Eq.trans
  apply Eq.symm
  exact h2
  exact h1

rfl 策略是 exact rfl 的语法糖。

example (y : Nat) : (fun x : Nat => 0) y = 0 :=
  by rfl

repeat 组合器可以多次使用一个策略。

example : ∀ a b c : Nat, a = b → a = c → c = b := by
  intros
  apply Eq.trans
  apply Eq.symm
  repeat assumption

另一个有时很有用的策略是还原 revert 策略,从某种意义上说,它是对 intro 的逆。

example (x : Nat) : x = x := by
  revert x
  -- goal is ⊢ ∀ (x : Nat), x = x
  intro y
  -- goal is y : Nat ⊢ y = y
  rfl

将一个假设还原到目标中会产生一个蕴含。

example (x y : Nat) (h : x = y) : y = x := by
  revert h
  -- goal is x y : Nat ⊢ x = y → y = x
  intro h₁
  -- goal is x y : Nat, h₁ : x = y ⊢ y = x
  apply Eq.symm
  assumption

但是 revert 更聪明,因为它不仅会还原上下文中的一个元素,还会还原上下文中所有依赖它的后续元素。例如,在上面的例子中,还原 x 会带来 h

example (x y : Nat) (h : x = y) : y = x := by
  revert x
  -- goal is y : Nat ⊢ ∀ (x : Nat), x = y → y = x
  intros
  apply Eq.symm
  assumption

你还可以一次性还原多个元素:

example (x y : Nat) (h : x = y) : y = x := by
  revert x y
  -- goal is ⊢ ∀ (x y : Nat), x = y → y = x
  intros
  apply Eq.symm
  assumption

你只能 revert 局部环境中的一个元素,也就是一个局部变量或假设。但是你可以使用泛化 generalize 策略将目标中的任意表达式替换为新的变量。

example : 3 = 3 := by
  generalize 3 = x
  -- goal is x : Nat ⊢ x = x
  revert x
  -- goal is ⊢ ∀ (x : Nat), x = x
  intro y
  -- goal is y : Nat ⊢ y = y
  rfl

上述符号的记忆法是,你通过将 3 设定为任意变量 x 来泛化目标。要注意:不是每一个泛化都能保留目标的有效性。这里,generalize 用一个无法证明的目标取代了一个可以用 rfl 证明的目标。

example : 2 + 3 = 5 := by
  generalize 3 = x
  -- goal is x : Nat ⊢ 2 + x = 5
  admit

在这个例子中,admit 策略是 sorry 证明项的类似物。它关闭了当前的目标,产生了通常的警告:使用了 sorry。为了保持之前目标的有效性,generalize 策略允许我们记录 3 已经被 x 所取代的事实。你所需要做的就是提供一个标签,generalize 使用它来存储局部上下文中的赋值。

example : 2 + 3 = 5 := by
  generalize h : 3 = x
  -- goal is x : Nat, h : 3 = x ⊢ 2 + x = 5
  rw [← h]

这里 rewrite 策略,缩写为 rw,用 hx3 换了回来。rewrite 策略下文将继续讨论。

更多策略

一些额外的策略对于建构和析构命题以及数据很有用。例如,当应用于形式为 p ∨ q 的目标时,你可以使用 apply Or.inlapply Or.inr 等策略。 反之,cases 策略可以用来分解一个析取。

example (p q : Prop) : p ∨ q → q ∨ p := by
  intro h
  cases h with
  | inl hp => apply Or.inr; exact hp
  | inr hq => apply Or.inl; exact hq

注意,该语法与 match 表达式中使用的语法相似。新的子目标可以按任何顺序解决。

example (p q : Prop) : p ∨ q → q ∨ p := by
  intro h
  cases h with
  | inr hq => apply Or.inl; exact hq
  | inl hp => apply Or.inr; exact hp

你也可以使用一个(非结构化的)cases,而不使用 with,并为每个备选情况制定一个策略。

example (p q : Prop) : p ∨ q → q ∨ p := by
  intro h
  cases h
  apply Or.inr
  assumption
  apply Or.inl
  assumption

(非结构化的)cases 在你可以用同一个策略来解决子任务时格外有用。

example (p : Prop) : p ∨ p → p := by
  intro h
  cases h
  repeat assumption

你也可以使用组合器 tac1 <;> tac2,将 tac2 应用于策略 tac1 产生的每个子目标。

example (p : Prop) : p ∨ p → p := by
  intro h
  cases h <;> assumption

你可以与 . 符号相结合使用非结构化的 cases 策略。

example (p q : Prop) : p ∨ q → q ∨ p := by
  intro h
  cases h
  . apply Or.inr
    assumption
  . apply Or.inl
    assumption

example (p q : Prop) : p ∨ q → q ∨ p := by
  intro h
  cases h
  case inr h =>
    apply Or.inl
    assumption
  case inl h =>
    apply Or.inr
    assumption

example (p q : Prop) : p ∨ q → q ∨ p := by
  intro h
  cases h
  case inr h =>
    apply Or.inl
    assumption
  . apply Or.inr
    assumption

cases 策略也被用来分解一个析取。

example (p q : Prop) : p ∧ q → q ∧ p := by
  intro h
  cases h with
  | intro hp hq => constructor; exact hq; exact hp

在这个例子中,应用 cases 策略后只有一个目标,h : p ∧ q 被一对假设取代,hp : phq : qconstructor 策略应用了唯一一个合取构造子 And.intro。有了这些策略,上一节的一个例子可以改写如下。

example (p q r : Prop) : p ∧ (q ∨ r) ↔ (p ∧ q) ∨ (p ∧ r) := by
  apply Iff.intro
  . intro h
    cases h with
    | intro hp hqr =>
      cases hqr
      . apply Or.inl; constructor <;> assumption
      . apply Or.inr; constructor <;> assumption
  . intro h
    cases h with
    | inl hpq =>
      cases hpq with
      | intro hp hq => constructor; exact hp; apply Or.inl; exact hq
    | inr hpr =>
      cases hpr with
      | intro hp hr => constructor; exact hp; apply Or.inr; exact hr

你将在归纳类型一章中看到,这些策略是相当通用的。cases 策略可以用来分解递归定义类型的任何元素;constructor 总是应用递归定义类型的第一个适用构造子。例如,你可以使用 casesconstructor 与一个存在量词:

example (p q : Nat → Prop) : (∃ x, p x) → ∃ x, p x ∨ q x := by
  intro h
  cases h with
  | intro x px => constructor; apply Or.inl; exact px

在这里,constructor 策略将存在性断言的第一个组成部分,即 x 的值,保留为隐式的。它是由一个元变量表示的,这个元变量以后应该被实例化。在前面的例子中,元变量的正确值是由策略 exact px 决定的,因为 px 的类型是 p x。如果你想明确指定存在量词的存在者,你可以使用 exists 策略来代替。

example (p q : Nat → Prop) : (∃ x, p x) → ∃ x, p x ∨ q x := by
  intro h
  cases h with
  | intro x px => exists x; apply Or.inl; exact px

另一个例子:

example (p q : Nat → Prop) : (∃ x, p x ∧ q x) → ∃ x, q x ∧ p x := by
  intro h
  cases h with
  | intro x hpq =>
    cases hpq with
    | intro hp hq =>
      exists x

这些策略既可以用在命题上,也可以用在数上。在下面的两个例子中,它们被用来定义交换乘法和加法类型组件的函数:

def swap_pair : α × β → β × α := by
  intro p
  cases p
  constructor <;> assumption

def swap_sum : Sum α β → Sum β α := by
  intro p
  cases p
  . apply Sum.inr; assumption
  . apply Sum.inl; assumption

在我们为变量选择的名称之前,它们的定义与有关合取和析取的类似命题的证明是相同的。cases 策略也会对自然数进行逐情况区分:

open Nat
example (P : Nat → Prop) (h₀ : P 0) (h₁ : ∀ n, P (succ n)) (m : Nat) : P m := by
  cases m with
  | zero    => exact h₀
  | succ m' => exact h₁ m'

cases 策略伙同 induction 策略将在归纳类型的策略一节中详述。

contradiction 策略搜索当前目标的假设中的矛盾:

example (p q : Prop) : p ∧ ¬ p → q := by
  intro h
  cases h
  contradiction

你也可以在策略块中使用 match

example (p q r : Prop) : p ∧ (q ∨ r) ↔ (p ∧ q) ∨ (p ∧ r) := by
  apply Iff.intro
  . intro h
    match h with
    | ⟨_, Or.inl _⟩ => apply Or.inl; constructor <;> assumption
    | ⟨_, Or.inr _⟩ => apply Or.inr; constructor <;> assumption
  . intro h
    match h with
    | Or.inl ⟨hp, hq⟩ => constructor; exact hp; apply Or.inl; exact hq
    | Or.inr ⟨hp, hr⟩ => constructor; exact hp; apply Or.inr; exact hr

你可以将 intro hmatch h ... 结合起来,然后上例就可以如下地写出:

example (p q r : Prop) : p ∧ (q ∨ r) ↔ (p ∧ q) ∨ (p ∧ r) := by
  apply Iff.intro
  . intro
    | ⟨hp, Or.inl hq⟩ => apply Or.inl; constructor <;> assumption
    | ⟨hp, Or.inr hr⟩ => apply Or.inr; constructor <;> assumption
  . intro
    | Or.inl ⟨hp, hq⟩ => constructor; assumption; apply Or.inl; assumption
    | Or.inr ⟨hp, hr⟩ => constructor; assumption; apply Or.inr; assumption

结构化策略证明

策略通常提供了建立证明的有效方法,但一长串指令会掩盖论证的结构。在这一节中,我们将描述一些有助于为策略式证明提供结构的方法,使这种证明更易读,更稳健。

Lean 的证明写作语法的一个优点是,它可以混合项式和策略式证明,并在两者之间自由转换。例如,策略 applyexact 可以传入任意的项,你可以用 haveshow 等等来写这些项。反之,当写一个任意的 Lean 项时,你总是可以通过插入一个 by 块来调用策略模式。下面是一个简易例子:

example (p q r : Prop) : p ∧ (q ∨ r) → (p ∧ q) ∨ (p ∧ r) := by
  intro h
  exact
    have hp : p := h.left
    have hqr : q ∨ r := h.right
    show (p ∧ q) ∨ (p ∧ r) by
      cases hqr with
      | inl hq => exact Or.inl ⟨hp, hq⟩
      | inr hr => exact Or.inr ⟨hp, hr⟩

更自然一点:

example (p q r : Prop) : p ∧ (q ∨ r) ↔ (p ∧ q) ∨ (p ∧ r) := by
  apply Iff.intro
  . intro h
    cases h.right with
    | inl hq => exact Or.inl ⟨h.left, hq⟩
    | inr hr => exact Or.inr ⟨h.left, hr⟩
  . intro h
    cases h with
    | inl hpq => exact ⟨hpq.left, Or.inl hpq.right⟩
    | inr hpr => exact ⟨hpr.left, Or.inr hpr.right⟩

事实上,有一个 show 策略,它类似于证明项中的 show 表达式。它只是简单地声明即将被解决的目标的类型,同时保持策略模式。

example (p q r : Prop) : p ∧ (q ∨ r) ↔ (p ∧ q) ∨ (p ∧ r) := by
  apply Iff.intro
  . intro h
    cases h.right with
    | inl hq =>
      show (p ∧ q) ∨ (p ∧ r)
      exact Or.inl ⟨h.left, hq⟩
    | inr hr =>
      show (p ∧ q) ∨ (p ∧ r)
      exact Or.inr ⟨h.left, hr⟩
  . intro h
    cases h with
    | inl hpq =>
      show p ∧ (q ∨ r)
      exact ⟨hpq.left, Or.inl hpq.right⟩
    | inr hpr =>
      show p ∧ (q ∨ r)
      exact ⟨hpr.left, Or.inr hpr.right⟩

show 策略其实可以被用来重写一些定义等价的目标:

example (n : Nat) : n + 1 = Nat.succ n := by
  show Nat.succ n = Nat.succ n
  rfl

还有一个 have 策略,它引入了一个新的子目标,就像写证明项时一样。

example (p q r : Prop) : p ∧ (q ∨ r) → (p ∧ q) ∨ (p ∧ r) := by
  intro ⟨hp, hqr⟩
  show (p ∧ q) ∨ (p ∧ r)
  cases hqr with
  | inl hq =>
    have hpq : p ∧ q := And.intro hp hq
    apply Or.inl
    exact hpq
  | inr hr =>
    have hpr : p ∧ r := And.intro hp hr
    apply Or.inr
    exact hpr

与证明项一样,你可以省略 have 策略中的标签,在这种情况下,将使用默认标签 this

example (p q r : Prop) : p ∧ (q ∨ r) → (p ∧ q) ∨ (p ∧ r) := by
  intro ⟨hp, hqr⟩
  show (p ∧ q) ∨ (p ∧ r)
  cases hqr with
  | inl hq =>
    have : p ∧ q := And.intro hp hq
    apply Or.inl
    exact this
  | inr hr =>
    have : p ∧ r := And.intro hp hr
    apply Or.inr
    exact this

have 策略中的类型可以省略,所以你可以写 have hp := h.lefthave hqr := h.right。 事实上,使用这种符号,你甚至可以省略类型和标签,在这种情况下,新的事实是用标签 this 引入的。

example (p q r : Prop) : p ∧ (q ∨ r) → (p ∧ q) ∨ (p ∧ r) := by
  intro ⟨hp, hqr⟩
  cases hqr with
  | inl hq =>
    have := And.intro hp hq
    apply Or.inl; exact this
  | inr hr =>
    have := And.intro hp hr
    apply Or.inr; exact this

Lean 还有一个 let 策略,与 have 策略类似,但用于引入局部定义而不是辅助事实。它是证明项中 let 的策略版。

example : ∃ x, x + 2 = 8 := by
  let a : Nat := 3 * 2
  exists a

have 一样,你可以通过写 let a := 3 * 2 来保留类型为隐式。lethave 的区别在于,let 在上下文中引入了一个局部定义,因此局部声明的定义可以在证明中展开。

我们使用了.来创建嵌套的策略块。 在一个嵌套块中,Lean 专注于第一个目标,如果在该块结束时还没有完全解决,就会产生一个错误。这对于表明一个策略所引入的多个子目标的单独证明是有帮助的。符号 . 是对空格敏感的,并且依靠缩进来检测策略块是否结束。另外,你也可以用大括号和分号来定义策略块。

example (p q r : Prop) : p ∧ (q ∨ r) ↔ (p ∧ q) ∨ (p ∧ r) := by
  apply Iff.intro
  { intro h;
    cases h.right;
    { show (p ∧ q) ∨ (p ∧ r);
      exact Or.inl ⟨h.left, ‹q›⟩ }
    { show (p ∧ q) ∨ (p ∧ r);
      exact Or.inr ⟨h.left, ‹r›⟩ } }
  { intro h;
    cases h;
    { show p ∧ (q ∨ r);
      rename_i hpq;
      exact ⟨hpq.left, Or.inl hpq.right⟩ }
    { show p ∧ (q ∨ r);
      rename_i hpr;
      exact ⟨hpr.left, Or.inr hpr.right⟩ } }

使用缩进来构造证明很有用:每次一个策略留下一个以上的子目标时,我们通过将它们封装在块中并缩进来分隔剩下的子目标。因此,如果将定理 foo 应用于一个目标产生了四个子目标,那么我们就可以期待这样的证明:

  apply foo
  . <proof of first goal>
  . <proof of second goal>
  . <proof of third goal>
  . <proof of final goal>

  apply foo
  case <tag of first goal>  => <proof of first goal>
  case <tag of second goal> => <proof of second goal>
  case <tag of third goal>  => <proof of third goal>
  case <tag of final goal>  => <proof of final goal>

  apply foo
  { <proof of first goal>  }
  { <proof of second goal> }
  { <proof of third goal>  }
  { <proof of final goal>  }

策略组合器

策略组合器 是由旧策略形成新策略的操作。by 隐含了一个序列组合器:

example (p q : Prop) (hp : p) : p ∨ q :=
  by apply Or.inl; assumption

这里,apply Or.inl; assumption在功能上等同于一个单独的策略,它首先应用apply Or.inl,然后应用 assumption

t₁ <;> t₂中,<;>操作符提供了一个并行的序列操作。t₁被应用于当前目标,然后t₂被应用于所有产生的子目标:

example (p q : Prop) (hp : p) (hq : q) : p ∧ q :=
  by constructor <;> assumption

当所产生的目标能够以统一的方式完成时,或者,至少,当有可能以统一的方式在所有的目标上取得进展时,这就特别有用。

first | t₁ | t₂ | ... | tₙ 应用每$1 $2,直到其中一个成功,否则就失败:

example (p q : Prop) (hp : p) : p ∨ q := by
  first | apply Or.inl; assumption | apply Or.inr; assumption

example (p q : Prop) (hq : q) : p ∨ q := by
  first | apply Or.inl; assumption | apply Or.inr; assumption

在第一个例子中,左分支成功了,而在第二个例子中,右分支成功了。在接下来的三个例子中,同样的复合策略在每种情况下都能成功。

example (p q r : Prop) (hp : p) : p ∨ q ∨ r :=
  by repeat (first | apply Or.inl; assumption | apply Or.inr | assumption)

example (p q r : Prop) (hq : q) : p ∨ q ∨ r :=
  by repeat (first | apply Or.inl; assumption | apply Or.inr | assumption)

example (p q r : Prop) (hr : r) : p ∨ q ∨ r :=
  by repeat (first | apply Or.inl; assumption | apply Or.inr | assumption)

该策略试图通过假设立即解决左边的析取项;如果失败,它就试图关注右边的析取项;如果不成功,它就调用假设策略。

毫无疑问,策略可能会失败。事实上,正是这种「失败」状态导致 first 组合器回溯并尝试下一个策略。try 组合器建立了一个总是成功的策略,尽管可能是以一种平凡的方式:try t 执行 t 并报告成功,即使 t 失败。它等同于 first | t | skip,其中 skip 是一个什么都不做的策略(并且成功地做到了「什么都不做」)。在下一个例子中,第二个 constructor 在右边的合取项 q ∧ r 上成功了(注意,合取和析取是右结合的),但在第一个合取项上失败。try 策略保证了序列组合的成功。

example (p q r : Prop) (hp : p) (hq : q) (hr : r) : p ∧ q ∧ r := by
  constructor <;> (try constructor) <;> assumption

小心:repeat (try t) 将永远循环,因为内部策略永远不会失败。

在一个证明中,往往有多个目标未完成。并行序列是一种布置方式,以便将一个策略应用于多个目标,但也有其他的方式可以做到这一点。例如,all_goals tt 应用于所有未完成的目标:

example (p q r : Prop) (hp : p) (hq : q) (hr : r) : p ∧ q ∧ r := by
  constructor
  all_goals (try constructor)
  all_goals assumption

在这种情况下,any_goals 策略提供了一个更稳健的解决方案。它与 all_goals 类似,只是除非它的参数至少在一个目标上成功,否则就会失败。

example (p q r : Prop) (hp : p) (hq : q) (hr : r) : p ∧ q ∧ r := by
  constructor
  any_goals constructor
  any_goals assumption

下面 by 块中的第一个策略是反复拆分合取:

example (p q r : Prop) (hp : p) (hq : q) (hr : r) :
      p ∧ ((p ∧ q) ∧ r) ∧ (q ∧ r ∧ p) := by
  repeat (any_goals constructor)
  all_goals assumption

其实可以将整个策略压缩成一行:

example (p q r : Prop) (hp : p) (hq : q) (hr : r) :
      p ∧ ((p ∧ q) ∧ r) ∧ (q ∧ r ∧ p) := by
  repeat (any_goals (first | constructor | assumption))

组合器 focus t 确保 t 只影响当前的目标,暂时将其他目标从作用范围中隐藏。因此,如果 t 通常只影响当前目标,focus (all_goals t)t 具有相同的效果。

重写

计算式证明一节中简要介绍了 rewrite 策略(简称 rw)和 simp 策略。在本节和下一节中,我们将更详细地讨论它们。

rewrite 策略提供了一种基本的机制,可以将替换应用于目标和假设,在处理等式时非常方便。该策略的最基本形式是 rewrite [t],其中 t 是一个类型断定为等式的项。例如,t 可以是上下文中的一个假设h : x = y;可以是一个一般的法则,如add_comm : ∀ x y, x + y = y + x,在这个法则中,重写策略试图找到 xy 的合适实例;或者可以是任何断言具体或一般等式的复合项。在下面的例子中,我们使用这种基本形式,用一个假设重写目标。

example (f : Nat → Nat) (k : Nat) (h₁ : f 0 = 0) (h₂ : k = 0) : f k = 0 := by
  rw [h₂] -- 用 0 换掉 k
  rw [h₁] -- 用 0 换掉 f 0

在上面的例子中,第一次使用 rw 将目标 f k = 0 中的 k 替换成 0。然后,第二次用 0 替换 f 0。该策略自动关闭任何形式的目标t = t。下面是一个使用复合表达式进行重写的例子。

example (x y : Nat) (p : Nat → Prop) (q : Prop) (h : q → x = y)
        (h' : p y) (hq : q) : p x := by
  rw [h hq]; assumption

这里,h hq 建立了等式 x = yh hq 周围的括号是不必要的,但为了清楚起见,还是加上了括号。

多个重写可以使用符号rw [t_1, ..., t_n]来组合,这只是rw t_1; ...; rw t_n的缩写。前面的例子可以写成如下:

example (f : Nat → Nat) (k : Nat) (h₁ : f 0 = 0) (h₂ : k = 0) : f k = 0 := by
  rw [h₂, h₁]

默认情况下,rw 正向使用一个等式,用一个表达式匹配左边的等式,然后用右边的等式替换它。符号 ←t 可以用来指示策略在反方向上使用等式 t

example (f : Nat → Nat) (a b : Nat) (h₁ : a = b) (h₂ : f a = 0) : f b = 0 := by
  rw [←h₁, h₂]

在这个例子中,项 ←h₁ 指示重写器用 a 替换 b。在编辑器中,你可以用 \l 输入反箭头。你也可以使用 ascii 替代品 <-

有时一个等式的左侧可以匹配模式中的多个子项,在这种情况下,rw 策略会在遍历项时选择它发现的第一个匹配。如果这不是你想要的,你可以使用附加参数来指定适当的子项。

example (a b c : Nat) : a + b + c = a + c + b := by
  rw [Nat.add_assoc, Nat.add_comm b, ← Nat.add_assoc]

example (a b c : Nat) : a + b + c = a + c + b := by
  rw [Nat.add_assoc, Nat.add_assoc, Nat.add_comm b]

example (a b c : Nat) : a + b + c = a + c + b := by
  rw [Nat.add_assoc, Nat.add_assoc, Nat.add_comm _ b]

在上面的第一个例子中,第一步将 a + b + c 重写为 a + (b + c)。然后,接下来对项 b + c 使用交换律;如果不指定参数,该策略将把 a + (b + c) 重写为 (b + c) + a。最后一步以相反的方向应用结合律,将a + (c + b)改写为 a + c + b。接下来的两个例子则是应用结合律将两边的小括号移到右边,然后将 bc 调换。注意最后一个例子通过指定 Nat.add_comm 的第二个参数来指定重写应该在右侧进行。

默认情况下,rewrite 策略只影响目标。符号 rw [t] at h 在假设 h 处应用重写 t

example (f : Nat → Nat) (a : Nat) (h : a + 0 = 0) : f a = f 0 := by
  rw [Nat.add_zero] at h
  rw [h]

第一步,rw [Nat.add_zero] at h 将假设 a + 0 = 0 改写为 a = 0。然后,新的假设a = 0被用来把目标重写为f 0 = f 0

rewrite 策略不限于命题。在下面的例子中,我们用rw [h] at t来重写假设t : Tuple α nt : Tuple α 0

def Tuple (α : Type) (n : Nat) :=
  { as : List α // as.length = n }

example (n : Nat) (h : n = 0) (t : Tuple α n) : Tuple α 0 := by
  rw [h] at t
  exact t

简化

rewrite 被设计为操纵目标的手术刀,而简化器提供了一种更强大的自动化形式。Lean 库中的一些特性已经被标记为[simp]属性,simp 策略使用它们来反复重写表达式中的子项。

example (x y z : Nat) : (x + 0) * (0 + y * 1 + z * 0) = x * y := by
  simp

example (x y z : Nat) (p : Nat → Prop) (h : p (x * y))
        : p ((x + 0) * (0 + y * 1 + z * 0)) := by
  simp; assumption

在第一个例子中,目标中等式的左侧被简化,使用涉及0和1的通常的同义词,将目标简化为x * y = x * y'。此时simp'应用反身性(rfl)来完成它。在第二个例子中,simp 将目标化简为p (x * y),这时假设 h 完成了它。下面是一些关于列表的例子。

open List

example (xs : List Nat)
        : reverse (xs ++ [1, 2, 3]) = [3, 2, 1] ++ reverse xs := by
  simp

example (xs ys : List α)
        : length (reverse (xs ++ ys)) = length xs + length ys := by
  simp [Nat.add_comm]

就像 rw,你也可以用关键字 at 来简化一个假设:

example (x y z : Nat) (p : Nat → Prop)
        (h : p ((x + 0) * (0 + y * 1 + z * 0))) : p (x * y) := by
  simp at h; assumption

此外,你可以使用一个「通配符」星号来简化所有的假设和目标:

attribute [local simp] Nat.mul_comm Nat.mul_assoc Nat.mul_left_comm
attribute [local simp] Nat.add_assoc Nat.add_comm Nat.add_left_comm

example (w x y z : Nat) (p : Nat → Prop)
        (h : p (x * y + z * w * x)) : p (x * w * z + y * x) := by
  simp at *; assumption

example (x y z : Nat) (p : Nat → Prop)
        (h₁ : p (1 * x + y)) (h₂ : p (x * z * 1))
        : p (y + 0 + x) ∧ p (z * x) := by
  simp at * <;> constructor <;> assumption

上例中前两行的意思是,对于具有交换律和结合律的运算(如自然数的加法和乘法),简化器使用这两个定律来重写表达式,同时还使用左交换律。在乘法的情况下,左交换律表达如下:x * (y * z) = y * (x * z)local 修饰符告诉简化器在当前文件(或小节或命名空间,视情况而定)中使用这些规则。交换律和左交换律是有一个问题是,重复应用其中一个会导致循环。但是简化器检测到了对其参数进行置换的特性,并使用了一种被称为有序重写的技术。这意味着系统保持着项的内部次序,只有在这样做会降低次序的情况下才会应用等式。对于上面提到的三个等式,其效果是表达式中的所有小括号都被结合到右边,并且表达式以一种规范的(尽管有些随意)方式排序。两个在交换律和结合律上等价的表达式然后被改写成相同的规范形式。

attribute [local simp] Nat.mul_comm Nat.mul_assoc Nat.mul_left_comm
attribute [local simp] Nat.add_assoc Nat.add_comm Nat.add_left_comm
example (w x y z : Nat) (p : Nat → Prop)
        : x * y + z * w * x = x * w * z + y * x := by
  simp

example (w x y z : Nat) (p : Nat → Prop)
        (h : p (x * y + z * w * x)) : p (x * w * z + y * x) := by
  simp; simp at h; assumption

rewrite 一样,你可以向 simp 提供一个要使用的事实列表,包括一般引理、局部假设、要展开的定义和复合表达式。simp 策略也能识别 rewrite←t语法。在任何情况下,额外的规则都会被添加到用于简化项的等式集合中。

def f (m n : Nat) : Nat :=
  m + n + m

example {m n : Nat} (h : n = 1) (h' : 0 = m) : (f m n) = n := by
  simp [h, ←h', f]

一个常见的习惯是用局部假设来简化一个目标:

example (f : Nat → Nat) (k : Nat) (h₁ : f 0 = 0) (h₂ : k = 0) : f k = 0 := by
  simp [h₁, h₂]

为了在简化时使用局部环境中存在的所有假设,我们可以使用通配符 *

example (f : Nat → Nat) (k : Nat) (h₁ : f 0 = 0) (h₂ : k = 0) : f k = 0 := by
  simp [*]

另一例:

example (u w x y z : Nat) (h₁ : x = y + z) (h₂ : w = u + x)
        : w = z + y + u := by
  simp [*, Nat.add_assoc, Nat.add_comm, Nat.add_left_comm]

简化器也会进行命题重写。例如,使用假设 p,它把 p ∧ q 改写为 q,把 p ∨ q 改写为 true,然后以普通方式证明。迭代这样的重写,会生成非平凡的命题推理。

example (p q : Prop) (hp : p) : p ∧ q ↔ q := by
  simp [*]

example (p q : Prop) (hp : p) : p ∨ q := by
  simp [*]

example (p q r : Prop) (hp : p) (hq : q) : p ∧ (q ∨ r) := by
  simp [*]

下一个例子简化了所有的假设,然后用这些假设来证明目标。

example (u w x x' y y' z : Nat) (p : Nat → Prop)
        (h₁ : x + 0 = x') (h₂ : y + 0 = y')
        : x + y + 0 = x' + y' := by
  simp at *
  simp [*]

使得简化器特别有用的一点是,它的能力可以随着规则库的发展而增强。例如,假设我们定义了一个列表操作,该操作通过拼接其反转来对称其输入:

def mk_symm (xs : List α) :=
  xs ++ xs.reverse

那么对于任何列表 xsreverse (mk_symm xs) 等于 mk_symm xs,这可以通过展开定义轻松证明:

def mk_symm (xs : List α) :=
 xs ++ xs.reverse
theorem reverse_mk_symm (xs : List α)
        : (mk_symm xs).reverse = mk_symm xs := by
  simp [mk_symm]

你可以使用这个定理来证明一些新结果:

def mk_symm (xs : List α) :=
 xs ++ xs.reverse
theorem reverse_mk_symm (xs : List α)
       : (mk_symm xs).reverse = mk_symm xs := by
 simp [mk_symm]
example (xs ys : List Nat)
        : (xs ++ mk_symm ys).reverse = mk_symm ys ++ xs.reverse := by
  simp [reverse_mk_symm]

example (xs ys : List Nat) (p : List Nat → Prop)
        (h : p (xs ++ mk_symm ys).reverse)
        : p (mk_symm ys ++ xs.reverse) := by
  simp [reverse_mk_symm] at h; assumption

但是使用 reverse_mk_symm 通常是正确的,如果用户不需要明确地调用它,那就更好了。你可以通过在定义该定理时将其标记为简化规则来实现这一点:

def mk_symm (xs : List α) :=
 xs ++ xs.reverse
@[simp] theorem reverse_mk_symm (xs : List α)
        : (mk_symm xs).reverse = mk_symm xs := by
  simp [mk_symm]

example (xs ys : List Nat)
        : (xs ++ mk_symm ys).reverse = mk_symm ys ++ xs.reverse := by
  simp

example (xs ys : List Nat) (p : List Nat → Prop)
        (h : p (xs ++ mk_symm ys).reverse)
        : p (mk_symm ys ++ xs.reverse) := by
  simp at h; assumption

符号 @[simp] 声明 reverse_mk_symm 具有 [simp] 属性,可以更明确地说明:

def mk_symm (xs : List α) :=
 xs ++ xs.reverse
theorem reverse_mk_symm (xs : List α)
        : (mk_symm xs).reverse = mk_symm xs := by
  simp [mk_symm]

attribute [simp] reverse_mk_symm

example (xs ys : List Nat)
        : (xs ++ mk_symm ys).reverse = mk_symm ys ++ xs.reverse := by
  simp

example (xs ys : List Nat) (p : List Nat → Prop)
        (h : p (xs ++ mk_symm ys).reverse)
        : p (mk_symm ys ++ xs.reverse) := by
  simp at h; assumption

该属性也可以在定理声明后的任何时候应用:

def mk_symm (xs : List α) :=
 xs ++ xs.reverse
theorem reverse_mk_symm (xs : List α)
        : (mk_symm xs).reverse = mk_symm xs := by
  simp [mk_symm]

example (xs ys : List Nat)
        : (xs ++ mk_symm ys).reverse = mk_symm ys ++ xs.reverse := by
  simp [reverse_mk_symm]

attribute [simp] reverse_mk_symm

example (xs ys : List Nat) (p : List Nat → Prop)
        (h : p (xs ++ mk_symm ys).reverse)
        : p (mk_symm ys ++ xs.reverse) := by
  simp at h; assumption

然而,一旦属性被应用,就没有办法永久地删除它;它将在任何导入该属性的文件中持续存在。正如我们将在属性一章中进一步讨论的那样,我们可以使用 local 修饰符将属性的范围限制在当前文件或章节中:

def mk_symm (xs : List α) :=
 xs ++ xs.reverse
theorem reverse_mk_symm (xs : List α)
        : (mk_symm xs).reverse = mk_symm xs := by
  simp [mk_symm]

section
attribute [local simp] reverse_mk_symm

example (xs ys : List Nat)
        : (xs ++ mk_symm ys).reverse = mk_symm ys ++ xs.reverse := by
  simp

example (xs ys : List Nat) (p : List Nat → Prop)
        (h : p (xs ++ mk_symm ys).reverse)
        : p (mk_symm ys ++ xs.reverse) := by
  simp at h; assumption
end

在该部分之外,简化器将不再默认使用 reverse_mk_symm

请注意,我们讨论过的各种 simp 选项----给出一个明确的规则列表,并使用 at 指定位置----可以合并,但它们的排列顺序是严格的。你可以在编辑器中看到正确的顺序,把光标放在 simp 标识符上,查看与之相关的文档。

有两个额外的修饰符是有用的。默认情况下,simp 包括所有被标记为 [simp] 属性的定理。写simp only排除了这些默认值,允许你使用一个更明确的规则列表。在下面的例子中,减号和 only 被用来阻止 reverse_mk_symm 的应用:

def mk_symm (xs : List α) :=
  xs ++ xs.reverse
@[simp] theorem reverse_mk_symm (xs : List α)
        : (mk_symm xs).reverse = mk_symm xs := by
  simp [mk_symm]

example (xs ys : List Nat) (p : List Nat → Prop)
        (h : p (xs ++ mk_symm ys).reverse)
        : p (mk_symm ys ++ xs.reverse) := by
  simp at h; assumption

example (xs ys : List Nat) (p : List Nat → Prop)
        (h : p (xs ++ mk_symm ys).reverse)
        : p ((mk_symm ys).reverse ++ xs.reverse) := by
  simp [-reverse_mk_symm] at h; assumption

example (xs ys : List Nat) (p : List Nat → Prop)
        (h : p (xs ++ mk_symm ys).reverse)
        : p ((mk_symm ys).reverse ++ xs.reverse) := by
  simp only [List.reverse_append] at h; assumption

simp 策略有很多配置选项,例如,我们可以启用语境简化:

example : if x = 0 then y + x = y else x ≠ 0 := by
  simp (config := { contextual := true })

contextual := truesimp 简化y + x = y时会使用x = 0,同时会用x ≠ 0来简化另一侧。另一个例子:

example : ∀ (x : Nat) (h : x = 0), y + x = y := by
  simp (config := { contextual := true })

另一个有用的配置是arith := true,它会简化算术表达式。因为这太常用了所以用 simp_arith 作为simp (config := { arith := true })的缩写。

example : 0 < 1 + x ∧ x + y + 2 ≥ y + 1 := by
  simp_arith

拆分

split 策略对于在多情形分支结构中打破嵌套的if-then-elsematch 表达式很有用。 对于包含 n 种情形的 match 表达式,split 策略最多生成 n 个子目标。例子:

def f (x y z : Nat) : Nat :=
  match x, y, z with
  | 5, _, _ => y
  | _, 5, _ => y
  | _, _, 5 => y
  | _, _, _ => 1

example (x y z : Nat) : x ≠ 5 → y ≠ 5 → z ≠ 5 → z = w → f x y w = 1 := by
  intros
  simp [f]
  split
  . contradiction
  . contradiction
  . contradiction
  . rfl

可以压缩成一行:

def f (x y z : Nat) : Nat :=
 match x, y, z with
 | 5, _, _ => y
 | _, 5, _ => y
 | _, _, 5 => y
 | _, _, _ => 1
example (x y z : Nat) : x ≠ 5 → y ≠ 5 → z ≠ 5 → z = w → f x y w = 1 := by
  intros; simp [f]; split <;> first | contradiction | rfl

策略split <;> first | contradiction | rfl首先应用 split 策略,接着对每个生成出的目标尝试 contradiction,如果失败那么最后 rfl

类似 simp,我们对一个特定的假设也可以使用 split

def g (xs ys : List Nat) : Nat :=
  match xs, ys with
  | [a, b], _ => a+b+1
  | _, [b, c] => b+1
  | _, _      => 1

example (xs ys : List Nat) (h : g xs ys = 0) : False := by
  simp [g] at h; split at h <;> simp_arith at h

扩展策略

在下面的例子中,我们使用 syntax 命令定义符号 triv。然后,我们使用 macro_rules 命令来指定使用 triv 时应该做什么。你可以提供不同的扩展,策略解释器将尝试所有的扩展,直到有一个成功。

-- 定义一个新策略符号
syntax "triv" : tactic

macro_rules
  | `(tactic| triv) => `(tactic| assumption)

example (h : p) : p := by
  triv

-- 你不能用 `triv` 解决下面的定理:
-- example (x : α) : x = x := by
--  triv

-- 扩展 `triv`。策略解释器会尝试所有可能的扩展宏,直到有一个成功。
macro_rules
  | `(tactic| triv) => `(tactic| rfl)

example (x : α) : x = x := by
  triv

example (x : α) (h : p) : x = x ∧ p := by
  apply And.intro <;> triv

-- 加一个递归扩展
macro_rules | `(tactic| triv) => `(tactic| apply And.intro <;> triv)

example (x : α) (h : p) : x = x ∧ p := by
  triv

练习

  1. 用策略式证明重做命题与证明量词与等价两章的练习。适当使用 rwsimp

  2. 用策略组合器给下面的例子用一行写一个证明:

example (p q r : Prop) (hp : p)
        : (p ∨ q ∨ r) ∧ (q ∨ p ∨ r) ∧ (q ∨ r ∨ p) := by
  admit

与 Lean 交互

你现在已经熟悉了依值类型论的基本原理,它既是一种定义数学对象的语言,也是一种构造证明的语言。现在你缺少一个定义新数据类型的机制。下一章介绍归纳数据类型的概念来帮你完成这个目标。但首先,在这一章中,我们从类型论的机制中抽身出来,探索与 Lean 交互的一些实用性问题。

并非所有的知识都能马上对你有用。可以略过这一节,然后在必要时再回到这一节以了解 Lean 的特性。

导入文件

Lean 的前端的目标是解释用户的输入,构建形式化的表达式,并检查它们是否形式良好和类型正确。Lean 还支持使用各种编辑器,这些编辑器提供持续的检查和反馈。更多信息可以在Lean文档上找到。

Lean 的标准库中的定义和定理分布在多个文件中。用户也可能希望使用额外的库,或在多个文件中开发自己的项目。当 Lean 启动时,它会自动导入库中 Init 文件夹的内容,其中包括一些基本定义和结构。因此,我们在这里介绍的大多数例子都是「开箱即用」的。

然而,如果你想使用其他文件,需要通过文件开头的`import'语句手动导入。命令

import Bar.Baz.Blah

导入文件 Bar/Baz/Blah.olean,其中的描述是相对于 Lean 搜索路径 解释的。关于如何确定搜索路径的信息可以在文档中找到。默认情况下,它包括标准库目录,以及(在某些情况下)用户的本地项目的根目录。

导入是传递性的。换句话说,如果你导入了 Foo,并且 Foo 导入了 Bar,那么你也可以访问 Bar 的内容,而不需要明确导入它。

小节(续)

Lean 提供了各种分段机制来帮助构造理论。你在变量和小节中看到,section 命令不仅可以将理论中的元素组合在一起,还可以在必要时声明变量,作为定理和定义的参数插入。请记住,variable 命令的意义在于声明变量,以便在定理中使用,如下面的例子:

section
variable (x y : Nat)

def double := x + x

#check double y
#check double (2 * x)

attribute [local simp] Nat.add_assoc Nat.add_comm Nat.add_left_comm

theorem t1 : double (x + y) = double x + double y := by
  simp [double]

#check t1 y
#check t1 (2 * x)

theorem t2 : double (x * y) = double x * y := by
  simp [double, Nat.add_mul]

end

double 的定义不需要声明 x 作为参数;Lean 检测到这种依赖关系并自动插入。同样,Lean 检测到 xt1t2 中的出现,也会自动插入。注意,double 没有 y 作为参数。变量只包括在实际使用的声明中。

命名空间(续)

在 Lean 中,标识符是由层次化的名称给出的,如 Foo.Bar.baz。我们在命名空间一节中看到,Lean 提供了处理分层名称的机制。命令 namespace foo 导致 foo 被添加到每个定义和定理的名称中,直到遇到 end foo。命令 open foo 然后为以 foo 开头的定义和定理创建临时的 别名

namespace Foo
def bar : Nat := 1
end Foo

open Foo

#check bar
#check Foo.bar

下面的定义

def Foo.bar : Nat := 1

被看成一个宏;展开成

namespace Foo
def bar : Nat := 1
end Foo

尽管定理和定义的名称必须是唯一的,但标识它们的别名却不是。当我们打开一个命名空间时,一个标识符可能是模糊的。Lean 试图使用类型信息来消除上下文中的含义,但你总是可以通过给出全名来消除歧义。为此,字符串 _root_ 是对空前缀的明确表述。

def String.add (a b : String) : String :=
  a ++ b

def Bool.add (a b : Bool) : Bool :=
  a != b

def add (α β : Type) : Type := Sum α β

open Bool
open String
-- #check add -- ambiguous
#check String.add           -- String → String → String
#check Bool.add             -- Bool → Bool → Bool
#check _root_.add           -- Type → Type → Type

#check add "hello" "world"  -- String
#check add true false       -- Bool
#check add Nat Nat          -- Type

我们可以通过使用 protected 关键字来阻止创建较短的别名:

protected def Foo.bar : Nat := 1

open Foo

-- #check bar -- error
#check Foo.bar

这通常用于像Nat.recNat.recOn这样的名称,以防止普通名称的重载。

open 命令允许变量。命令

open Nat (succ zero gcd)
#check zero     -- Nat
#eval gcd 15 6  -- 3

仅对列出的标识符创建了别名。命令

open Nat hiding succ gcd
#check zero     -- Nat
-- #eval gcd 15 6  -- error
#eval Nat.gcd 15 6  -- 3

Nat 命名空间中 除了 被列出的标识符都创建了别名。命令

open Nat renaming mul → times, add → plus
#eval plus (times 2 2) 3  -- 7

Nat.mul更名为 timesNat.add更名为 plus

有时,将别名从一个命名空间导出到另一个命名空间,或者导出到上一层是很有用的。命令

export Nat (succ add sub)

在当前命名空间中为 succaddsub 创建别名,这样无论何时命名空间被打开,这些别名都可以使用。如果这个命令在名字空间之外使用,那么这些别名会被输出到上一层。

属性

Lean 的主要功能是把用户的输入翻译成形式化的表达式,由内核检查其正确性,然后存储在环境中供以后使用。但是有些命令对环境有其他的影响,或者给环境中的对象分配属性,定义符号,或者声明类型类的实例,如类型类一章所述。这些命令大多具有全局效应,也就是说,它们不仅在当前文件中保持有效,而且在任何导入它的文件中也保持有效。然而,这类命令通常支持 local 修饰符,这表明它们只在当前 sectionnamespace 关闭前或当前文件结束前有效。

简化一节中,我们看到可以用[simp]属性来注释定理,这使它们可以被简化器使用。下面的例子定义了列表的前缀关系,证明了这种关系是自反的,并为该定理分配了[simp]属性。

def isPrefix (l₁ : List α) (l₂ : List α) : Prop :=
  ∃ t, l₁ ++ t = l₂

@[simp] theorem List.isPrefix_self (as : List α) : isPrefix as as :=
  ⟨[], by simp⟩

example : isPrefix [1, 2, 3] [1, 2, 3] := by
  simp

然后简化器通过将其改写为 True 来证明 isPrefix [1, 2, 3] [1, 2, 3]

你也可以在做出定义后的任何时候分配属性:

def isPrefix (l₁ : List α) (l₂ : List α) : Prop :=
 ∃ t, l₁ ++ t = l₂
theorem List.isPrefix_self (as : List α) : isPrefix as as :=
  ⟨[], by simp⟩

attribute [simp] List.isPrefix_self

在所有这些情况下,该属性在任何导入该声明的文件中仍然有效。添加 local 修饰符可以限制范围:

def isPrefix (l₁ : List α) (l₂ : List α) : Prop :=
 ∃ t, l₁ ++ t = l₂
section

theorem List.isPrefix_self (as : List α) : isPrefix as as :=
  ⟨[], by simp⟩

attribute [local simp] List.isPrefix_self

example : isPrefix [1, 2, 3] [1, 2, 3] := by
  simp

end

-- Error:
-- example : isPrefix [1, 2, 3] [1, 2, 3] := by
--  simp

另一个例子,我们可以使用 instance 命令来给 isPrefix 关系分配符号。该命令将在类型类中解释,它的工作原理是给相关定义分配一个[instance]属性。

def isPrefix (l₁ : List α) (l₂ : List α) : Prop :=
  ∃ t, l₁ ++ t = l₂

instance : LE (List α) where
  le := isPrefix

theorem List.isPrefix_self (as : List α) : as ≤ as :=
  ⟨[], by simp⟩

这个分配也可以是局部的:

def isPrefix (l₁ : List α) (l₂ : List α) : Prop :=
  ∃ t, l₁ ++ t = l₂
def instLe : LE (List α) :=
  { le := isPrefix }

section
attribute [local instance] instLe

example (as : List α) : as ≤ as :=
  ⟨[], by simp⟩

end

-- Error:
-- example (as : List α) : as ≤ as :=
--  ⟨[], by simp⟩

在下面的符号一节中,我们将讨论 Lean 定义符号的机制,并看到它们也支持 local 修饰符。然而,在设置选项一节中,我们将讨论 Lean 设置选项的机制,它并 遵循这种模式:选项 只能 在局部设置,也就是说,它们的范围总是限制在当前小节或当前文件中。

隐参数(续)

隐参数一节中,我们看到,如果 Lean 将术语 t 的类型显示为 {x : α} → β x,那么大括号表示 x 被标记为 t隐参数。这意味着每当你写 t 时,就会插入一个占位符,或者说「洞」,这样 t 就会被 @t _ 取代。如果你不希望这种情况发生,你必须写 @t 来代替。

请注意,隐参数是急于插入的。假设我们定义一个函数 f (x : Nat) {y : Nat} (z : Nat)。那么,当我们写表达式f 7时,没有进一步的参数,它会被解析为f 7 _。Lean 提供了一个较弱的注释,{{y : ℕ}},它指定了一个占位符只应在后一个显式参数之前添加。这个注释也可以写成 ⦃y : Nat⦄,其中的 unicode 括号输入方式为 \{{\}}。有了这个注释,表达式f 7将被解析为原样,而f 7 3将被解析为 f 7 _ 3,就像使用强注释一样。

为了说明这种区别,请看下面的例子,它表明一个自反的欧几里得关系既是对称的又是传递的。

def reflexive {α : Type u} (r : α → α → Prop) : Prop :=
  ∀ (a : α), r a a

def symmetric {α : Type u} (r : α → α → Prop) : Prop :=
  ∀ {a b : α}, r a b → r b a

def transitive {α : Type u} (r : α → α → Prop) : Prop :=
  ∀ {a b c : α}, r a b → r b c → r a c

def euclidean {α : Type u} (r : α → α → Prop) : Prop :=
  ∀ {a b c : α}, r a b → r a c → r b c

theorem th1 {α : Type u} {r : α → α → Prop}
            (reflr : reflexive r) (euclr : euclidean r)
            : symmetric r :=
  fun {a b : α} =>
  fun (h : r a b) =>
  show r b a from euclr h (reflr _)

theorem th2 {α : Type u} {r : α → α → Prop}
            (symmr : symmetric r) (euclr : euclidean r)
            : transitive r :=
  fun {a b c : α} =>
  fun (rab : r a b) (rbc : r b c) =>
  euclr (symmr rab) rbc

theorem th3 {α : Type u} {r : α → α → Prop}
            (reflr : reflexive r) (euclr : euclidean r)
            : transitive r :=
 th2 (th1 reflr @euclr) @euclr

variable (r : α → α → Prop)
variable (euclr : euclidean r)

#check euclr  -- r ?m1 ?m2 → r ?m1 ?m3 → r ?m2 ?m3

这些结果被分解成几个小步骤。th1 表明自反和欧氏的关系是对称的,th2 表明对称和欧氏的关系是传递的。然后 th3 结合这两个结果。但是请注意,我们必须手动禁用 th1th2euclr 中的隐参数,否则会插入太多的隐参数。如果我们使用弱隐式参数,这个问题就会消失:

def reflexive {α : Type u} (r : α → α → Prop) : Prop :=
  ∀ (a : α), r a a

def symmetric {α : Type u} (r : α → α → Prop) : Prop :=
  ∀ {{a b : α}}, r a b → r b a

def transitive {α : Type u} (r : α → α → Prop) : Prop :=
  ∀ {{a b c : α}}, r a b → r b c → r a c

def euclidean {α : Type u} (r : α → α → Prop) : Prop :=
  ∀ {{a b c : α}}, r a b → r a c → r b c

theorem th1 {α : Type u} {r : α → α → Prop}
            (reflr : reflexive r) (euclr : euclidean r)
            : symmetric r :=
  fun {a b : α} =>
  fun (h : r a b) =>
  show r b a from euclr h (reflr _)

theorem th2 {α : Type u} {r : α → α → Prop}
            (symmr : symmetric r) (euclr : euclidean r)
            : transitive r :=
  fun {a b c : α} =>
  fun (rab : r a b) (rbc : r b c) =>
  euclr (symmr rab) rbc

theorem th3 {α : Type u} {r : α → α → Prop}
            (reflr : reflexive r) (euclr : euclidean r)
            : transitive r :=
  th2 (th1 reflr euclr) euclr

variable (r : α → α → Prop)
variable (euclr : euclidean r)

#check euclr  -- euclidean r

还有第三种隐式参数,用方括号表示,[]。这些是用于类型类的,在类型类中解释。

符号

Lean 中的标识符可以包括任何字母数字字符,包括希腊字母(除了∀、Σ和λ,它们在依值类型论中有特殊的含义)。它们还可以包括下标,可以通过输入 \_,然后再输入所需的下标字符。

Lean 的解析器是可扩展的,也就是说,我们可以定义新的符号。

Lean 的语法可以由用户在各个层面进行扩展和定制,从基本的「mixfix」符号到自定义的繁饰器。事实上,所有内置的语法都是使用对用户开放的相同机制和 API 进行解析和处理的。 在本节中,我们将描述和解释各种扩展点。

虽然在编程语言中引入新的符号是一个相对罕见的功能,有时甚至因为它有可能使代码变得模糊不清而被人诟病,但它是形式化的一个宝贵工具,可以在代码中简洁地表达各自领域的既定惯例和符号。 除了基本的符号之外,Lean 的能力是将普通的样板代码分解成(良好的)宏,并嵌入整个定制的特定领域语言(DSL,domain specific language),对子问题进行高效和可读的文本编码,这对程序员和证明工程师都有很大的好处。

符号和优先级

最基本的语法扩展命令允许引入新的(或重载现有的)前缀、下缀和后缀运算符:

infixl:65   " + " => HAdd.hAdd  -- 左结合
infix:50    " = " => Eq         -- 非结合
infixr:80   " ^ " => HPow.hPow  -- 右结合
prefix:100  "-"   => Neg.neg
set_option quotPrecheck false
postfix:max "⁻¹"  => Inv.inv

语法:

运算符种类(其「结合方式」) : 解析优先级 " 新的或现有的符号 " => 这个符号应该翻译成的函数

优先级是一个自然数,描述一个运算符与它的参数结合的「紧密程度」,编码操作的顺序。我们可以通过查看上述展开的命令来使之更加精确:

notation:65 lhs:65 " + " rhs:66 => HAdd.hAdd lhs rhs
notation:50 lhs:51 " = " rhs:51 => Eq lhs rhs
notation:80 lhs:81 " ^ " rhs:80 => HPow.hPow lhs rhs
notation:100 "-" arg:100 => Neg.neg arg
set_option quotPrecheck false
notation:1024 arg:1024 "⁻¹" => Inv.inv arg  -- `max` is a shorthand for precedence 1024

事实证明,第一个代码块中的所有命令实际上都是命令 ,翻译成更通用的 notation 命令。我们将在下面学习如何编写这种宏。 notation 命令不接受单一的记号,而是接受一个混合的记号序列和有优先级的命名项占位符,这些占位符可以在=>的右侧被引用,并将被在该位置解析的相应项所取代。 优先级为 p 的占位符在该处只接受优先级至少为 p 的记号。因此,字符串a + b + c不能被解析为等同于a + (b + c),因为 infixl 符号的右侧操作数的优先级比该符号本身大。 相反,infixr 重用了符号右侧操作数的优先级,所以a ^ b ^ c 可以被解析为a ^ (b ^ c)。 注意,如果我们直接使用 notation 来引入一个 infix 符号,如

set_option quotPrecheck false
notation:65 lhs:65 " ~ " rhs:65 => wobble lhs rhs

在上文没有充分确定结合规则的情况下,Lean 的解析器将默认为右结合。 更确切地说,Lean 的解析器在存在模糊语法的情况下遵循一个局部的最长解析规则:当解析a ~a ~ b ~ c的右侧时,它将继续尽可能长的解析(在当前的上下文允许的情况下),不在 b 之后停止,而是同时解析~ c。因此该术语等同于a ~ (b ~ c)

如上所述,notation 命令允许我们定义任意的mixfix语法,自由地混合记号和占位符。

set_option quotPrecheck false
notation:max "(" e ")" => e
notation:10 Γ " ⊢ " e " : " τ => Typing Γ e τ

没有优先级的占位符默认为 0,也就是说,它们接受任何优先级的符号来代替它们。如果两个记号重叠,我们再次应用最长解析规则:

notation:65 a " + " b:66 " + " c:66 => a + b - c
#eval 1 + 2 + 3  -- 0

新的符号比二进制符号要好,因为后者在连锁之前,会在1 + 2之后停止解析。 如果有多个符号接受同一个最长的解析,选择将被推迟到阐述,这将失败,除非正好有一个重载是类型正确的。

强制转换

在 Lean 中,自然数的类型 Nat,与整数的类型 Int 不同。但是有一个函数 Int.ofNat 将自然数嵌入整数中,这意味着我们可以在需要时将任何自然数视为整数。Lean 有机制来检测和插入这种 强制转换

variable (m n : Nat)
variable (i j : Int)

#check i + m      -- i + Int.ofNat m : Int
#check i + m + j  -- i + Int.ofNat m + j : Int
#check i + m + n  -- i + Int.ofNat m + Int.ofNat n : Int

显示信息

有很多方法可以让你查询 Lean 的当前状态以及当前上下文中可用的对象和定理的信息。你已经看到了两个最常见的方法,#check#eval。请记住,#check经常与@操作符一起使用,它使定理或定义的所有参数显式化。此外,你可以使用#print命令来获得任何标识符的信息。如果标识符表示一个定义或定理,Lean 会打印出符号的类型,以及它的定义。如果它是一个常数或公理,Lean 会指出它并显示其类型。

-- 等式
#check Eq
#check @Eq
#check Eq.symm
#check @Eq.symm

#print Eq.symm

-- 与
#check And
#check And.intro
#check @And.intro

-- 自定义函数
def foo {α : Type u} (x : α) : α := x

#check foo
#check @foo
#print foo

设置选项

Lean 维护着一些内部变量,用户可以通过设置这些变量来控制其行为。语法如下:

set_option <name> <value>

有一系列非常有用的选项可以控制 Lean 的 美观打印 显示项的方式。下列选项的输入值为真或假:

pp.explicit  : display implicit arguments
pp.universes : display hidden universe parameters
pp.notation  : display output using defined notations

例如,以下设置会产生更长的输出:

set_option pp.explicit true
set_option pp.universes true
set_option pp.notation false

#check 2 + 2 = 4
#reduce (fun x => x + 2) = (fun x => x + 3)
#check (fun x => x + 1) 1

命令 set_option pp.all true 一次性执行这些设置,而 set_option pp.all false 则恢复到之前的值。当你调试一个证明,或试图理解一个神秘的错误信息时,漂亮地打印额外的信息往往是非常有用的。不过太多的信息可能会让人不知所措,Lean 的默认值一般来说对普通的交互是足够的。

使用库

为了有效地使用Lean,你将不可避免地需要使用库中的定义和定理。回想一下,文件开头的 import 命令会从其他文件中导入之前编译的结果,而且导入是传递的;如果你导入了 FooFoo 又导入了 Bar,那么 Bar 的定义和定理也可以被你利用。但是打开一个命名空间的行为,提供了更短的名字,并没有延续下去。在每个文件中,你需要打开你想使用的命名空间。

一般来说,你必须熟悉库和它的内容,这样你就知道有哪些定理、定义、符号和资源可供你使用。下面我们将看到 Lean 的编辑器模式也可以帮助你找到你需要的东西,但直接研究库的内容往往是不可避免的。Lean 的标准库在 GitHub 上。

你可以使用 GitHub 的浏览器界面查看这些目录和文件的内容。如果你在自己的电脑上安装了Lean,你可以在 lean 文件夹中找到这个库,用你的文件管理器探索它。每个文件顶部的注释标题提供了额外的信息。

Lean 库的开发者遵循一般的命名准则,以便于猜测你所需要的定理的名称,或者在支持 Lean 模式的编辑器中用 Tab 自动补全来找到它,这将在下一节讨论。标识符一般是 camelCase,而类型是 CamelCase。对于定理的名称,我们依靠描述性的名称,其中不同的组成部分用 _ 分开。通常情况下,定理的名称只是描述结论。

#check Nat.succ_ne_zero
#check Nat.zero_add
#check Nat.mul_one
#check Nat.le_of_succ_le_succ

Lean 中的标识符可以被组织到分层的命名空间中。例如,命名空间 Nat 中名为 le_of_succ_le_succ 的定理有全称 Nat.le_of_succ_le_succ,但较短的名称可由命令 open Nat 提供(对于未标记为 protected 的名称)。我们将在归纳类型结构体和记录中看到,在 Lean 中定义结构体和归纳数据类型会产生相关操作,这些操作存储在与被定义类型同名的命名空间。例如,乘积类型带有以下操作:

#check @Prod.mk
#check @Prod.fst
#check @Prod.snd
#check @Prod.rec

第一个用于构建一个对,而接下来的两个,Prod.fstProd.snd,投影两个元素。最后一个,Prod.rec,提供了另一种机制,用两个元素的函数来定义乘积上的函数。像 Prod.rec 这样的名字是受保护的,这意味着即使 Prod 名字空间是打开的,也必须使用全名。

由于命题即类型的对应原则,逻辑连接词也是归纳类型的实例,因此我们也倾向于对它们使用点符号:

#check @And.intro
#check @And.casesOn
#check @And.left
#check @And.right
#check @Or.inl
#check @Or.inr
#check @Or.elim
#check @Exists.intro
#check @Exists.elim
#check @Eq.refl
#check @Eq.subst

自动约束隐参数

在上一节中,我们已经展示了隐参数是如何使函数更方便使用的。然而,像 compose 这样的函数在定义时仍然相当冗长。宇宙多态的 compose 比之前定义的函数还要啰嗦。

universe u v w
def compose {α : Type u} {β : Type v} {γ : Type w}
            (g : β → γ) (f : α → β) (x : α) : γ :=
  g (f x)

你可以通过在定义 compose 时提供宇宙参数来避免使用 universe 命令。

def compose.{u, v, w}
            {α : Type u} {β : Type v} {γ : Type w}
            (g : β → γ) (f : α → β) (x : α) : γ :=
  g (f x)

Lean 4支持一个名为 自动约束隐参数 的新特性。它使诸如 compose 这样的函数在编写时更加方便。当 Lean 处理一个声明的头时, 如果 它是一个小写字母或希腊字母,任何未约束的标识符都会被自动添加为隐式参数。有了这个特性,我们可以把 compose 写成

def compose (g : β → γ) (f : α → β) (x : α) : γ :=
  g (f x)

#check @compose
-- {β : Sort u_1} → {γ : Sort u_2} → {α : Sort u_3} → (β → γ) → (α → β) → α → γ

请注意,Lean 使用 Sort 而不是 Type 推断出了一个更通用的类型。

虽然我们很喜欢这个功能,并且在实现 Lean 时广泛使用,但我们意识到有些用户可能会对它感到不舒服。因此,你可以使用set_option autoBoundImplicitLocal false命令将其禁用。

set_option autoImplicit false
/- 这个定义会报错:`unknown identifier` -/
-- def compose (g : β → γ) (f : α → β) (x : α) : γ :=
--   g (f x)

隐式Lambda

在Lean 3 stdlib中,我们发现了许多例子包含丑陋的@+_ 惯用法。当我们的预期类型是一个带有隐参数的函数类型,而我们有一个常量(例子中的reader_t.pure)也需要隐参数时,就会经常使用这个惯用法。在Lean 4中,繁饰器自动引入了 lambda 来消除隐参数。我们仍在探索这一功能并分析其影响,但到目前为止的结果是非常积极的。下面是上面链接中使用Lean 4隐式 lambda 的例子。

variable (ρ : Type) (m : Type → Type) [Monad m]
instance : Monad (ReaderT ρ m) where
  pure := ReaderT.pure
  bind := ReaderT.bind

用户可以通过使用@或用包含{}[]的约束标记编写的 lambda 表达式来禁用隐式 lambda 功能。下面是几个例子

namespace ex2
def id1 : {α : Type} → α → α :=
  fun x => x

def listId : List ({α : Type} → α → α) :=
  (fun x => x) :: []

-- 这个例子中,隐式 lambda 引入被禁用了,因为在 `fun` 前使用了`@`
def id2 : {α : Type} → α → α :=
  @fun α (x : α) => id1 x

def id3 : {α : Type} → α → α :=
  @fun α x => id1 x

def id4 : {α : Type} → α → α :=
  fun x => id1 x

-- 这个例子中,隐式 lambda 引入被禁用了,因为使用了绑定记号`{...}`
def id5 : {α : Type} → α → α :=
  fun {α} x => id1 x
end ex2

简单函数语法糖

在Lean 3中,我们可以通过使用小括号从 infix 运算符中创建简单的函数。例如,(+1)fun x, x + 1的语法糖。在Lean 4中,我们用·作为占位符来扩展这个符号。这里有几个例子:

namespace ex3
#check (· + 1)
-- fun a => a + 1
#check (2 - ·)
-- fun a => 2 - a
#eval [1, 2, 3, 4, 5].foldl (·*·) 1
-- 120

def f (x y z : Nat) :=
  x + y + z

#check (f · 1 ·)
-- fun a b => f a 1 b

#eval [(1, 2), (3, 4), (5, 6)].map (·.1)
-- [1, 3, 5]
end ex3

如同在Lean 3中,符号是用圆括号激活的,lambda抽象是通过收集嵌套的·创建的。这个集合被嵌套的小括号打断。在下面的例子中创建了两个不同的 lambda 表达式。

#check (Prod.mk · (· + 1))
-- fun a => (a, fun b => b + 1)

命名参数

被命名参数使你可以通过用参数的名称而不是参数列表中的位置来指定参数。 如果你不记得参数的顺序但知道它们的名字,你可以以任何顺序传入参数。当 Lean 未能推断出一个隐参数时,你也可以提供该参数的值。被命名参数还可以通过识别每个参数所代表的内容来提高你的代码的可读性。

def sum (xs : List Nat) :=
  xs.foldl (init := 0) (·+·)

#eval sum [1, 2, 3, 4]
-- 10

example {a b : Nat} {p : Nat → Nat → Nat → Prop} (h₁ : p a b b) (h₂ : b = a)
    : p a a b :=
  Eq.subst (motive := fun x => p a x b) h₂ h₁

在下面的例子中,我们说明了被命名参数和默认参数之间的交互。

def f (x : Nat) (y : Nat := 1) (w : Nat := 2) (z : Nat) :=
  x + y + w - z

example (x z : Nat) : f (z := z) x = x + 1 + 2 - z := rfl

example (x z : Nat) : f x (z := z) = x + 1 + 2 - z := rfl

example (x y : Nat) : f x y = fun z => x + y + 2 - z := rfl

example : f = (fun x z => x + 1 + 2 - z) := rfl

example (x : Nat) : f x = fun z => x + 1 + 2 - z := rfl

example (y : Nat) : f (y := 5) = fun x z => x + 5 + 2 - z := rfl

def g {α} [Add α] (a : α) (b? : Option α := none) (c : α) : α :=
  match b? with
  | none   => a + c
  | some b => a + b + c

variable {α} [Add α]

example : g = fun (a c : α) => a + c := rfl

example (x : α) : g (c := x) = fun (a : α) => a + x := rfl

example (x : α) : g (b? := some x) = fun (a c : α) => a + x + c := rfl

example (x : α) : g x = fun (c : α) => x + c := rfl

example (x y : α) : g x y = fun (c : α) => x + y + c := rfl

你可以使用..来提供缺少的显式参数作为 _。这个功能与被命名参数相结合,对编写模式很有用。下面是一个例子:

inductive Term where
  | var    (name : String)
  | num    (val : Nat)
  | app    (fn : Term) (arg : Term)
  | lambda (name : String) (type : Term) (body : Term)

def getBinderName : Term → Option String
  | Term.lambda (name := n) .. => some n
  | _ => none

def getBinderType : Term → Option Term
  | Term.lambda (type := t) .. => some t
  | _ => none

当显式参数可以由 Lean 自动推断时,省略号也很有用,而我们想避免一连串的 _

example (f : Nat → Nat) (a b c : Nat) : f (a + b + c) = f (a + (b + c)) :=
  congrArg f (Nat.add_assoc ..)

归纳类型

我们已经看到Lean 的形式基础包括基本类型,Prop, Type 0, Type 1, Type 2, ...,并允许形成依值函数类型,(x : α) → β。在例子中,我们还使用了额外的类型,如 BoolNatInt,以及类型构造子,如 List 和乘积 ×。事实上,在Lean 的库中,除了宇宙之外的每一个具体类型和除了依值箭头之外的每一个类型构造子都是一个被称为归纳类型的类型构造的一般类别的实例。值得注意的是,仅用类型宇宙、依值箭头类型和归纳类型就可以构建一个内涵丰富的数学大厦;其他一切都源于这些。

直观地说,一个归纳类型是由一个指定的构造子列表建立起来的。在Lean 中,指定这种类型的语法如下:

inductive Foo where
  | constructor₁ : ... → Foo
  | constructor₂ : ... → Foo
  ...
  | constructorₙ : ... → Foo

我们的直觉是,每个构造子都指定了一种建立新的对象 Foo 的方法,可能是由以前构造的值构成。Foo 类型只不过是由以这种方式构建的对象组成。归纳式声明中的第一个字符也可以用逗号而不是 | 来分隔构造子。

我们将在下面看到,构造子的参数可以包括 Foo 类型的对象,但要遵守一定的「正向性」约束,即保证 Foo 的元素是自下而上构建的。粗略地说,每个...可以是由 Foo 和以前定义的类型构建的任何箭头类型,其中 Foo 如果出现,也只是作为依值箭头类型的「目标」。

我们将提供一些归纳类型的例子。我们还把上述方案稍微扩展,即相互定义的归纳类型,以及所谓的归纳族

就像逻辑连接词一样,每个归纳类型都有引入规则,说明如何构造该类型的一个元素;还有消去规则,说明如何在另一个构造中「使用」该类型的一个元素。其实逻辑连接词也是归纳类型结构的例子。你已经看到了归纳类型的引入规则:它们只是在类型的定义中指定的构造子。消去规则提供了类型上的递归原则,其中也包括作为一种特殊情况的归纳原则。

在下一章中,我们将介绍Lean 的函数定义包,它提供了更方便的方法来定义归纳类型上的函数并进行归纳证明。但是由于归纳类型的概念是如此的基本,我们觉得从低级的、实践的理解开始是很重要的。我们将从归纳类型的一些基本例子开始,然后逐步上升到更详细和复杂的例子。

枚举式类型

最简单的归纳类型是一个具有有限的、可枚举的元素列表的类型。

inductive Weekday where
  | sunday : Weekday
  | monday : Weekday
  | tuesday : Weekday
  | wednesday : Weekday
  | thursday : Weekday
  | friday : Weekday
  | saturday : Weekday

inductive 命令创建了一个新类型 Weekday。构造子都在 Weekday 命名空间中。

inductive Weekday where
 | sunday : Weekday
 | monday : Weekday
 | tuesday : Weekday
 | wednesday : Weekday
 | thursday : Weekday
 | friday : Weekday
 | saturday : Weekday
#check Weekday.sunday
#check Weekday.monday

open Weekday

#check sunday
#check monday

在声明Weekday的归纳类型时,可以省略: Weekday

inductive Weekday where
  | sunday
  | monday
  | tuesday
  | wednesday
  | thursday
  | friday
  | saturday

sundaymonday、... 、saturday 看作是 Weekday 的不同元素,没有其他有区别的属性。消去规则 Weekday.rec,与 Weekday 类型及其构造子一起定义。它也被称为 递归器(Recursor) ,它是使该类型「归纳」的原因:它允许我们通过给每个构造子分配相应的值来定义Weekday的函数。直观的说,归纳类型是由构造子详尽地生成的,除了它们构造的元素外,没有其他元素。

我们将使用match表达式来定义一个从 Weekday 到自然数的函数:

inductive Weekday where
 | sunday : Weekday
 | monday : Weekday
 | tuesday : Weekday
 | wednesday : Weekday
 | thursday : Weekday
 | friday : Weekday
 | saturday : Weekday
open Weekday

def numberOfDay (d : Weekday) : Nat :=
  match d with
  | sunday    => 1
  | monday    => 2
  | tuesday   => 3
  | wednesday => 4
  | thursday  => 5
  | friday    => 6
  | saturday  => 7

#eval numberOfDay Weekday.sunday  -- 1
#eval numberOfDay Weekday.monday  -- 2
#eval numberOfDay Weekday.tuesday -- 3

注意,match表达式是使用你声明归纳类型时生成的递归器Weekday.rec来编译的。

inductive Weekday where
 | sunday : Weekday
 | monday : Weekday
 | tuesday : Weekday
 | wednesday : Weekday
 | thursday : Weekday
 | friday : Weekday
 | saturday : Weekday
open Weekday

def numberOfDay (d : Weekday) : Nat :=
  match d with
  | sunday    => 1
  | monday    => 2
  | tuesday   => 3
  | wednesday => 4
  | thursday  => 5
  | friday    => 6
  | saturday  => 7

set_option pp.all true
#print numberOfDay
-- ... numberOfDay.match_1
#print numberOfDay.match_1
-- ... Weekday.casesOn ...
#print Weekday.casesOn
-- ... Weekday.rec ...
#check @Weekday.rec
/-
@Weekday.rec.{u}
 : {motive : Weekday → Sort u} →
    motive Weekday.sunday →
    motive Weekday.monday →
    motive Weekday.tuesday →
    motive Weekday.wednesday →
    motive Weekday.thursday →
    motive Weekday.friday →
    motive Weekday.saturday →
    (t : Weekday) → motive t
-/

译者注:此处详细解释一下递归器rec。递归器作为归纳类型的消去规则,用于构造归纳类型到其他类型的函数。从最朴素的集合论直觉上讲,枚举类型的函数只需要规定每个元素的对应,也就是match的方式,但是要注意,match并不像其他Lean 关键字那样是一种简单的语法声明,它实际上是一种功能,而这并不是类型论自带的功能。因此match需要一个类型论实现,也就是递归器。现在我们通过#check @Weekday.rec命令的输出来看递归器是如何工作的。首先回忆@是显式参数的意思。递归器是一个复杂的函数,输入的信息有1)motive:一个「目的」函数,表明想要拿当前类型构造什么类型。这个输出类型足够一般所以在u上;2)motive函数对所有枚举元素的输出值(这里就显得它非常「递归」)。这两点是准备工作,下面是这个函数的实际工作:输入一个具体的属于这个枚举类型的项t,输出结果motive t。下文在非枚举类型中,会直接用到这些递归器,届时可以更清晰地看到它们如何被使用。

当声明一个归纳数据类型时,你可以使用deriving Repr来指示Lean 生成一个函数,将Weekday对象转换为文本。这个函数被#eval命令用来显示Weekday对象。

inductive Weekday where
  | sunday
  | monday
  | tuesday
  | wednesday
  | thursday
  | friday
  | saturday
  deriving Repr

open Weekday

#eval tuesday   -- Weekday.tuesday

将与某一结构相关的定义和定理归入同名的命名空间通常很有用。例如,我们可以将 numberOfDay 函数放在 Weekday 命名空间中。然后当我们打开命名空间时,我们就可以使用较短的名称。

我们可以定义从 WeekdayWeekday 的函数:

inductive Weekday where
 | sunday : Weekday
 | monday : Weekday
 | tuesday : Weekday
 | wednesday : Weekday
 | thursday : Weekday
 | friday : Weekday
 | saturday : Weekday
 deriving Repr
namespace Weekday
def next (d : Weekday) : Weekday :=
  match d with
  | sunday    => monday
  | monday    => tuesday
  | tuesday   => wednesday
  | wednesday => thursday
  | thursday  => friday
  | friday    => saturday
  | saturday  => sunday

def previous (d : Weekday) : Weekday :=
  match d with
  | sunday    => saturday
  | monday    => sunday
  | tuesday   => monday
  | wednesday => tuesday
  | thursday  => wednesday
  | friday    => thursday
  | saturday  => friday

#eval next (next tuesday)      -- Weekday.thursday
#eval next (previous tuesday)  -- Weekday.tuesday

example : next (previous tuesday) = tuesday :=
  rfl

end Weekday

我们如何证明 next (previous d) = d 对于任何Weekdayd 的一般定理?你可以使用match来为每个构造子提供一个证明:

inductive Weekday where
 | sunday : Weekday
 | monday : Weekday
 | tuesday : Weekday
 | wednesday : Weekday
 | thursday : Weekday
 | friday : Weekday
 | saturday : Weekday
 deriving Repr
namespace Weekday
def next (d : Weekday) : Weekday :=
 match d with
 | sunday    => monday
 | monday    => tuesday
 | tuesday   => wednesday
 | wednesday => thursday
 | thursday  => friday
 | friday    => saturday
 | saturday  => sunday
def previous (d : Weekday) : Weekday :=
 match d with
 | sunday    => saturday
 | monday    => sunday
 | tuesday   => monday
 | wednesday => tuesday
 | thursday  => wednesday
 | friday    => thursday
 | saturday  => friday
def next_previous (d : Weekday) : next (previous d) = d :=
  match d with
  | sunday    => rfl
  | monday    => rfl
  | tuesday   => rfl
  | wednesday => rfl
  | thursday  => rfl
  | friday    => rfl
  | saturday  => rfl

用策略证明更简洁:(复习:组合器tac1 <;> tac2,意为将tac2应用于策略tac1产生的每个子目标。)

inductive Weekday where
 | sunday : Weekday
 | monday : Weekday
 | tuesday : Weekday
 | wednesday : Weekday
 | thursday : Weekday
 | friday : Weekday
 | saturday : Weekday
 deriving Repr
namespace Weekday
def next (d : Weekday) : Weekday :=
 match d with
 | sunday    => monday
 | monday    => tuesday
 | tuesday   => wednesday
 | wednesday => thursday
 | thursday  => friday
 | friday    => saturday
 | saturday  => sunday
def previous (d : Weekday) : Weekday :=
 match d with
 | sunday    => saturday
 | monday    => sunday
 | tuesday   => monday
 | wednesday => tuesday
 | thursday  => wednesday
 | friday    => thursday
 | saturday  => friday
def next_previous (d : Weekday) : next (previous d) = d := by
  cases d <;> rfl

下面的归纳类型的策略一节将介绍额外的策略,这些策略是专门为利用归纳类型而设计。

命题即类型的对应原则下,我们可以使用 match 来证明定理和定义函数。换句话说,逐情况证明是一种逐情况定义的另一表现形式,其中被「定义」的是一个证明而不是一段数据。

Lean 库中的Bool类型是一个枚举类型的实例。

namespace Hidden
inductive Bool where
  | false : Bool
  | true  : Bool
end Hidden

(为了运行这个例子,我们把它们放在一个叫做 Hidden 的命名空间中,这样像 Bool 这样的名字就不会和标准库中的 Bool 冲突。这是必要的,因为这些类型是Lean「启动工作」的一部分,在系统启动时被自动导入)。

作为一个练习,你应该思考这些类型的引入和消去规则的作用。作为进一步的练习,我们建议在 Bool 类型上定义布尔运算 andornot,并验证其共性。提示,你可以使用match来定义像and这样的二元运算:

namespace Hidden
def and (a b : Bool) : Bool :=
  match a with
  | true  => b
  | false => false
end Hidden

同样地,大多数等式可以通过引入合适的match,然后使用 rfl 来证明。

带参数的构造子

枚举类型是归纳类型的一种非常特殊的情况,其中构造子根本不需要参数。一般来说,「构造」可以依赖于数据,然后在构造参数中表示。考虑一下库中的乘积类型和求和类型的定义:

namespace Hidden
inductive Prod (α : Type u) (β : Type v)
  | mk : α → β → Prod α β

inductive Sum (α : Type u) (β : Type v) where
  | inl : α → Sum α β
  | inr : β → Sum α β
end Hidden

思考一下这些例子中发生了什么。乘积类型有一个构造子 Prod.mk,它需要两个参数。要在 Prod α β 上定义一个函数,我们可以假设输入的形式是 Prod.mk a b,而我们必须指定输出,用 ab 来表示。我们可以用它来定义 Prod 的两个投影。标准库定义的符号 α × β 表示 Prod α β(a, b) 表示 Prod.mk a b

namespace Hidden
inductive Prod (α : Type u) (β : Type v)
  | mk : α → β → Prod α β
def fst {α : Type u} {β : Type v} (p : Prod α β) : α :=
  match p with
  | Prod.mk a b => a

def snd {α : Type u} {β : Type v} (p : Prod α β) : β :=
  match p with
  | Prod.mk a b => b
end Hidden

函数 fst 接收一个对 pmatchp解释为一个对 Prod.mk a b。还记得在依值类型论中,为了给这些定义以最大的通用性,我们允许类型 αβ 属于任何宇宙。

下面是另一个例子,我们用递归器Prod.casesOn代替match

def prod_example (p : Bool × Nat) : Nat :=
  Prod.casesOn (motive := fun _ => Nat) p (fun b n => cond b (2 * n) (2 * n + 1))

#eval prod_example (true, 3)
#eval prod_example (false, 3)

参数motive是用来指定你要构造的对象的类型,它是一个依值的函数,_是被自动推断出的类型,此处即Bool × Nat。函数cond是一个布尔条件:cond b t1 t2,如果b为真,返回t1,否则返回t2。函数prod_example接收一个由布尔值b和一个数字n组成的对,并根据b为真或假返回2 * n2 * n + 1

相比之下,求和类型有两个构造子inlinr(表示「从左引入」和「从右引入」),每个都需要 一个 (显式的)参数。要在 Sum α β 上定义一个函数,我们必须处理两种情况:要么输入的形式是 inl a,由此必须依据 a 指定一个输出值;要么输入的形式是 inr b,由此必须依据 b 指定一个输出值。

def sum_example (s : Sum Nat Nat) : Nat :=
  Sum.casesOn (motive := fun _ => Nat) s
    (fun n => 2 * n)
    (fun n => 2 * n + 1)

#eval sum_example (Sum.inl 3)
#eval sum_example (Sum.inr 3)

这个例子与前面的例子类似,但现在输入到sum_example的内容隐含了inl ninr n的形式。在第一种情况下,函数返回 2 * n,第二种情况下,它返回 2 * n + 1

注意,乘积类型取决于参数α β : Type,这些参数是构造子和Prod的参数。Lean 会检测这些参数何时可以从构造子的参数或返回类型中推断出来,并在这种情况下使其隐式。

定义自然数一节中,我们将看到当归纳类型的构造子从归纳类型本身获取参数时会发生什么。本节考虑的例子暂时不是这样:每个构造子只依赖于先前指定的类型。

一个有多个构造子的类型是析取的:Sum α β 的一个元素要么是 inl a 的形式,要么是 inl b 的形式。一个有多个参数的构造子引入了合取信息:从 Prod.mk a b 的元素 Prod α β 中我们可以提取 ab。一个任意的归纳类型可以包括这两个特征:拥有任意数量的构造子,每个构造子都需要任意数量的参数。

和函数定义一样,Lean 的归纳定义语法可以让你把构造子的命名参数放在冒号之前:

namespace Hidden
inductive Prod (α : Type u) (β : Type v) where
  | mk (fst : α) (snd : β) : Prod α β

inductive Sum (α : Type u) (β : Type v) where
  | inl (a : α) : Sum α β
  | inr (b : β) : Sum α β
end Hidden

这些定义的结果与本节前面给出的定义基本相同。

Prod 这样只有一个构造子的类型是纯粹的合取型:构造子只是将参数列表打包成一块数据,基本上是一个元组,后续参数的类型可以取决于初始参数的类型。我们也可以把这样的类型看作是一个「记录」或「结构体」。在Lean 中,关键词 structure 可以用来同时定义这样一个归纳类型以及它的投影。

namespace Hidden
structure Prod (α : Type u) (β : Type v) where
  mk :: (fst : α) (snd : β)
end Hidden

这个例子同时引入了归纳类型 Prod,它的构造子 mk,通常的消去器(recrecOn),以及投影 fstsnd

如果你没有命名构造子,Lean 使用mk作为默认值。例如,下面定义了一个记录,将一个颜色存储为RGB值的三元组:

structure Color where
  (red : Nat) (green : Nat) (blue : Nat)
  deriving Repr

def yellow := Color.mk 255 255 0

#eval Color.red yellow

yellow 的定义形成了有三个值的记录,而投影 Color.red 则返回红色成分。

如果你在每个字段之间加一个换行符,就可以不用括号。

structure Color where
  red : Nat
  green : Nat
  blue : Nat
  deriving Repr

structure 命令对于定义代数结构特别有用,Lean 提供了大量的基础设施来支持对它们的处理。例如,这里是一个半群的定义:

structure Semigroup where
  carrier : Type u
  mul : carrier → carrier → carrier
  mul_assoc : ∀ a b c, mul (mul a b) c = mul a (mul b c)

更多例子见结构体和记录

我们已经讨论了依值乘积类型Sigma

namespace Hidden
inductive Sigma {α : Type u} (β : α → Type v) where
  | mk : (a : α) → β a → Sigma β
end Hidden

库中另两个归纳类型的例子:

namespace Hidden
inductive Option (α : Type u) where
  | none : Option α
  | some : α → Option α

inductive Inhabited (α : Type u) where
  | mk : α → Inhabited α
end Hidden

在依值类型论的语义中,没有内置的部分函数的概念。一个函数类型 α → β 或一个依值函数类型 (a : α) → β 的每个元素都被假定为在每个输入端有一个值。Option 类型提供了一种表示部分函数的方法。Option β的一个元素要么是none,要么是some b的形式,用于某个值b : β。因此我们可以认为α → Option β类型的元素f是一个从αβ的部分函数:对于每一个a : αf a要么返回none,表示f a是「未定义」,要么返回some b

Inhabited α的一个元素只是证明了α有一个元素的事实。稍后,我们将看到 Inhabited 是Lean 中类型类的一个例子:Lean 可以被告知合适的基础类型是含有元素的,并且可以在此基础上自动推断出其他构造类型是含有元素的。

作为练习,我们鼓励你建立一个从 αββγ 的部分函数的组合概念,并证明其行为符合预期。我们也鼓励你证明 BoolNat 是含有元素的,两个含有元素的类型的乘积是含有元素的,以及到一个含有元素的类型的函数类型是含有元素的。

归纳定义的命题

归纳定义的类型可以存在于任何类型宇宙中,包括最底层的类型,Prop。事实上,这正是逻辑连接词的定义方式。

namespace Hidden
inductive False : Prop

inductive True : Prop where
  | intro : True

inductive And (a b : Prop) : Prop where
  | intro : a → b → And a b

inductive Or (a b : Prop) : Prop where
  | inl : a → Or a b
  | inr : b → Or a b
end Hidden

你应该想一想这些是如何产生你已经看到的引入和消去规则的。有一些规则规定了归纳类型的消去器可以去消去什么,或者说,哪些类型可以成为递归器的目标。粗略地说,Prop 中的归纳类型的特点是,只能消去成 Prop 中的其他类型。这与以下理解是一致的:如果 p : Prop,一个元素 hp : p 不携带任何数据。然而,这个规则有一个小的例外,我们将在归纳族一节中讨论。

甚至存在量词也是归纳式定义的:

namespace Hidden
inductive Exists {α : Sort u} (p : α → Prop) : Prop where
  | intro (w : α) (h : p w) : Exists p
end Hidden

请记住,符号 ∃ x : α, pExists (fun x : α => p) 的语法糖。

False, True, AndOr的定义与Empty, Unit, ProdSum的定义完全类似。不同的是,第一组产生的是Prop的元素,第二组产生的是Type u的元素,适用于某些u。类似地,∃ x : α, pΣ x : α, pProp 值的变体。

这里可以提到另一个归纳类型,表示为{x : α // p},它有点像∃ x : α, PΣ x : α, P之间的混合。

namespace Hidden
inductive Subtype {α : Type u} (p : α → Prop) where
  | mk : (x : α) → p x → Subtype p
end Hidden

事实上,在Lean 中,Subtype 是用结构体命令定义的。

namespace Hidden
structure Subtype {α : Sort u} (p : α → Prop) where
  val : α
  property : p val
end Hidden

符号{x : α // p x}Subtype (fun x : α => p x) 的语法糖。它仿照集合理论中的子集表示法:{x : α // p x}表示具有属性pα元素的集合。

定义自然数

到目前为止,我们所看到的归纳定义的类型都是「无趣的」:构造子打包数据并将其插入到一个类型中,而相应的递归器则解压数据并对其进行操作。当构造子作用于被定义的类型中的元素时,事情就变得更加有趣了。一个典型的例子是自然数 Nat 类型:

namespace Hidden
inductive Nat where
  | zero : Nat
  | succ : Nat → Nat
end Hidden

有两个构造子,我们从 zero : Nat 开始;它不需要参数,所以我们一开始就有了它。相反,构造子succ只能应用于先前构造的Nat。将其应用于 zero,得到 succ zero : Nat。再次应用它可以得到succ (succ zero) : Nat,依此类推。直观地说,Nat是具有这些构造子的「最小」类型,这意味着它是通过从zero开始并重复应用succ这样无穷无尽地(并且自由地)生成的。

和以前一样,Nat 的递归器被设计用来定义一个从 Nat 到任何域的依值函数f,也就是一个(n : nat) → motive n的元素f,其中motive : Nat → Sort u。它必须处理两种情况:一种是输入为 zero 的情况,另一种是输入为 succ n 的情况,其中 n : Nat。在第一种情况下,我们只需指定一个适当类型的目标值,就像以前一样。但是在第二种情况下,递归器可以假设在n处的f的值已经被计算过了。因此,递归器的下一个参数是以nf n来指定f (succ n)的值。

如果我们检查递归器的类型:

namespace Hidden
inductive Nat where
 | zero : Nat
 | succ : Nat → Nat
#check @Nat.rec
end Hidden

你会得到:

  {motive : Nat → Sort u}
  → motive Nat.zero
  → ((n : Nat) → motive n → motive (Nat.succ n))
  → (t : Nat) → motive t

隐参数 motive,是被定义的函数的陪域。在类型论中,通常说 motive 是消去/递归的 目的 ,因为它描述了我们希望构建的对象类型。接下来的两个参数指定了如何计算零和后继的情况,如上所述。它们也被称为小前提 minor premises。最后,t : Nat,是函数的输入。它也被称为大前提 major premise

Nat.recOnNat.rec类似,但大前提发生在小前提之前。

@Nat.recOn :
  {motive : Nat → Sort u}
  → (t : Nat)
  → motive Nat.zero
  → ((n : Nat) → motive n → motive (Nat.succ n))
  → motive t

例如,考虑自然数上的加法函数 add m n。固定m,我们可以通过递归来定义n的加法。在基本情况下,我们将add m zero设为m。在后续步骤中,假设add m n的值已经确定,我们将add m (succ n)定义为succ (add m n)

namespace Hidden
inductive Nat where
  | zero : Nat
  | succ : Nat → Nat
  deriving Repr

def add (m n : Nat) : Nat :=
  match n with
  | Nat.zero   => m
  | Nat.succ n => Nat.succ (add m n)

open Nat

#eval add (succ (succ zero)) (succ zero)
end Hidden

将这些定义放入一个命名空间 Nat 是很有用的。然后我们可以继续在这个命名空间中定义熟悉的符号。现在加法的两个定义方程是成立的:

namespace Hidden
inductive Nat where
 | zero : Nat
 | succ : Nat → Nat
 deriving Repr
namespace Nat

def add (m n : Nat) : Nat :=
  match n with
  | Nat.zero   => m
  | Nat.succ n => Nat.succ (add m n)

instance : Add Nat where
  add := add

theorem add_zero (m : Nat) : m + zero = m := rfl
theorem add_succ (m n : Nat) : m + succ n = succ (m + n) := rfl

end Nat
end Hidden

我们将在类型类一章中解释 instance 命令如何工作。我们以后的例子将使用Lean 自己的自然数版本。

然而,证明像 zero + m = m 这样的事实,需要用归纳法证明。如上所述,归纳原则只是递归原则的一个特例,其中陪域 motive nProp 的一个元素。它代表了熟悉的归纳证明模式:要证明 ∀ n, motive n,首先要证明 motive 0,然后对于任意的 n,假设 ih : motive n 并证明 motive (succ n)

namespace Hidden
open Nat

theorem zero_add (n : Nat) : 0 + n = n :=
  Nat.recOn (motive := fun x => 0 + x = x)
   n
   (show 0 + 0 = 0 from rfl)
   (fun (n : Nat) (ih : 0 + n = n) =>
    show 0 + succ n = succ n from
    calc 0 + succ n
      _ = succ (0 + n) := rfl
      _ = succ n       := by rw [ih])
end Hidden

请注意,当 Nat.recOn 在证明中使用时,它实际上是变相的归纳原则。rewritesimp 策略在这样的证明中往往非常有效。在这种情况下,证明可以化简成:

namespace Hidden
open Nat

theorem zero_add (n : Nat) : 0 + n = n :=
  Nat.recOn (motive := fun x => 0 + x = x) n
    rfl
    (fun n ih => by simp [add_succ, ih])
end Hidden

另一个例子,让我们证明加法结合律,∀ m n k, m + n + k = m + (n + k)。(我们定义的符号+是向左结合的,所以m + n + k实际上是(m + n) + k。) 最难的部分是确定在哪个变量上做归纳。由于加法是由第二个参数的递归定义的,k 是一个很好的猜测,一旦我们做出这个选择,证明几乎是顺理成章的:

namespace Hidden
open Nat
theorem add_assoc (m n k : Nat) : m + n + k = m + (n + k) :=
  Nat.recOn (motive := fun k => m + n + k = m + (n + k)) k
    (show m + n + 0 = m + (n + 0) from rfl)
    (fun k (ih : m + n + k = m + (n + k)) =>
      show m + n + succ k = m + (n + succ k) from
      calc m + n + succ k
        _ = succ (m + n + k)   := rfl
        _ = succ (m + (n + k)) := by rw [ih]
        _ = m + succ (n + k)   := rfl
        _ = m + (n + succ k)   := rfl)
end Hidden

你又可以化简证明:

open Nat
theorem add_assoc (m n k : Nat) : m + n + k = m + (n + k) :=
  Nat.recOn (motive := fun k => m + n + k = m + (n + k)) k
    rfl
    (fun k ih => by simp [Nat.add_succ, ih])

假设我们试图证明加法交换律。选择第二个参数做归纳法,我们可以这样开始:

open Nat
theorem add_comm (m n : Nat) : m + n = n + m :=
  Nat.recOn (motive := fun x => m + x = x + m) n
   (show m + 0 = 0 + m by rw [Nat.zero_add, Nat.add_zero])
   (fun (n : Nat) (ih : m + n = n + m) =>
    show m + succ n = succ n + m from
    calc m + succ n
      _ = succ (m + n) := rfl
      _ = succ (n + m) := by rw [ih]
      _ = succ n + m   := sorry)

在这一点上,我们看到我们需要另一个事实,即 succ (n + m) = succ n + m。你可以通过对 m 的归纳来证明这一点:

open Nat

theorem succ_add (n m : Nat) : succ n + m = succ (n + m) :=
  Nat.recOn (motive := fun x => succ n + x = succ (n + x)) m
    (show succ n + 0 = succ (n + 0) from rfl)
    (fun (m : Nat) (ih : succ n + m = succ (n + m)) =>
     show succ n + succ m = succ (n + succ m) from
     calc succ n + succ m
       _ = succ (succ n + m)   := rfl
       _ = succ (succ (n + m)) := by rw [ih]
       _ = succ (n + succ m)   := rfl)

然后你可以用 succ_add 代替前面证明中的 sorry。然而,证明可以再次压缩:

namespace Hidden
open Nat
theorem succ_add (n m : Nat) : succ n + m = succ (n + m) :=
  Nat.recOn (motive := fun x => succ n + x = succ (n + x)) m
    rfl
    (fun m ih => by simp only [add_succ, ih])

theorem add_comm (m n : Nat) : m + n = n + m :=
  Nat.recOn (motive := fun x => m + x = x + m) n
    (by simp)
    (fun m ih => by simp [add_succ, succ_add, ih])
end Hidden

其他递归数据类型

让我们再考虑一些归纳定义类型的例子。对于任何类型 α,在库中定义了 α 的元素列表 List α 类型。

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

namespace List

def append (as bs : List α) : List α :=
  match as with
  | nil       => bs
  | cons a as => cons a (append as bs)

theorem nil_append (as : List α) : append nil as = as :=
  rfl

theorem cons_append (a : α) (as bs : List α)
                    : append (cons a as) bs = cons a (append as bs) :=
  rfl

end List
end Hidden

一个 α 类型的元素列表,要么是空列表 nil,要么是一个元素 h : α,后面是一个列表 t : List α。第一个元素h,通常被称为列表的「头」,最后一个t,被称为「尾」。

作为一个练习,请证明以下内容:

namespace Hidden
inductive List (α : Type u) where
| nil  : List α
| cons : α → List α → List α
namespace List
def append (as bs : List α) : List α :=
 match as with
 | nil       => bs
 | cons a as => cons a (append as bs)
theorem nil_append (as : List α) : append nil as = as :=
 rfl
theorem cons_append (a : α) (as bs : List α)
                    : append (cons a as) bs = cons a (append as bs) :=
 rfl
theorem append_nil (as : List α) : append as nil = as :=
  sorry

theorem append_assoc (as bs cs : List α)
        : append (append as bs) cs = append as (append bs cs) :=
  sorry
end List
end Hidden

还可以尝试定义函数 length : {α : Type u} → List α → Nat,返回一个列表的长度,并证明它的行为符合我们的期望(例如,length (append as bs) = length as + length bs)。

另一个例子,我们可以定义二叉树的类型:

inductive BinaryTree where
  | leaf : BinaryTree
  | node : BinaryTree → BinaryTree → BinaryTree

事实上,我们甚至可以定义可数多叉树的类型:

inductive CBTree where
  | leaf : CBTree
  | sup : (Nat → CBTree) → CBTree

namespace CBTree

def succ (t : CBTree) : CBTree :=
  sup (fun _ => t)

def toCBTree : Nat → CBTree
  | 0 => leaf
  | n+1 => succ (toCBTree n)

def omega : CBTree :=
  sup toCBTree

end CBTree

归纳类型的策略

归纳类型在Lean 中有最根本的重要性,因此设计了一些方便使用的策略,这里讲几种。

cases 策略适用于归纳定义类型的元素,正如其名称所示:它根据每个可能的构造子分解元素。在其最基本的形式中,它被应用于局部环境中的元素x。然后,它将目标还原为 x 被每个构成体所取代的情况。

example (p : Nat → Prop) (hz : p 0) (hs : ∀ n, p (Nat.succ n)) : ∀ n, p n := by
  intro n
  cases n
  . exact hz  -- goal is p 0
  . apply hs  -- goal is a : Nat ⊢ p (succ a)

还有一些额外的修饰功能。首先,cases 允许你使用 with 子句来选择每个选项的名称。例如在下一个例子中,我们为 succ 的参数选择m这个名字,这样第二个情况就指的是succ m。更重要的是,cases策略将检测局部环境中任何依赖于目标变量的项目。它将这些元素还原,进行拆分,并重新引入它们。在下面的例子中,注意假设h : n ≠ 0在第一个分支中变成h : 0 ≠ 0,在第二个分支中变成h : succ m ≠ 0

open Nat

example (n : Nat) (h : n ≠ 0) : succ (pred n) = n := by
  cases n with
  | zero =>
    -- goal: h : 0 ≠ 0 ⊢ succ (pred 0) = 0
    apply absurd rfl h
  | succ m =>
    -- second goal: h : succ m ≠ 0 ⊢ succ (pred (succ m)) = succ m
    rfl

cases 可以用来产生数据,也可以用来证明命题。

def f (n : Nat) : Nat := by
  cases n; exact 3; exact 7

example : f 0 = 3 := rfl
example : f 5 = 7 := rfl

再一次,cases将被还原,分隔,然后在背景中重新引入依赖。

def Tuple (α : Type) (n : Nat) :=
  { as : List α // as.length = n }

def f {n : Nat} (t : Tuple α n) : Nat := by
  cases n; exact 3; exact 7

def myTuple : Tuple Nat 3 :=
  ⟨[0, 1, 2], rfl⟩

example : f myTuple = 7 :=
  rfl

下面是一个带有参数的多个构造子的例子。

inductive Foo where
  | bar1 : Nat → Nat → Foo
  | bar2 : Nat → Nat → Nat → Foo

def silly (x : Foo) : Nat := by
  cases x with
  | bar1 a b => exact b
  | bar2 c d e => exact e

每个构造子的备选项不需要按照构造子的声明顺序来求解。

inductive Foo where
  | bar1 : Nat → Nat → Foo
  | bar2 : Nat → Nat → Nat → Foo
def silly (x : Foo) : Nat := by
  cases x with
  | bar2 c d e => exact e
  | bar1 a b => exact b

with的语法对于编写结构化证明很方便。Lean 还提供了一个补充的case策略,它允许你专注于目标分配变量名。

inductive Foo where
  | bar1 : Nat → Nat → Foo
  | bar2 : Nat → Nat → Nat → Foo
def silly (x : Foo) : Nat := by
  cases x
  case bar1 a b => exact b
  case bar2 c d e => exact e

case 策略很聪明,它将把构造子与适当的目标相匹配。例如,我们可以按照相反的顺序填充上面的目标:

inductive Foo where
  | bar1 : Nat → Nat → Foo
  | bar2 : Nat → Nat → Nat → Foo
def silly (x : Foo) : Nat := by
  cases x
  case bar2 c d e => exact e
  case bar1 a b => exact b

你也可以使用 cases 伴随一个任意的表达式。假设该表达式出现在目标中,cases策略将概括该表达式,引入由此产生的全称变量,并对其进行处理。

open Nat

example (p : Nat → Prop) (hz : p 0) (hs : ∀ n, p (succ n)) (m k : Nat)
        : p (m + 3 * k) := by
  cases m + 3 * k
  exact hz   -- goal is p 0
  apply hs   -- goal is a : Nat ⊢ p (succ a)

可以认为这是在说「把 m + 3 * k 是零或者某个数字的后继的情况拆开」。其结果在功能上等同于以下:

open Nat

example (p : Nat → Prop) (hz : p 0) (hs : ∀ n, p (succ n)) (m k : Nat)
        : p (m + 3 * k) := by
  generalize m + 3 * k = n
  cases n
  exact hz   -- goal is p 0
  apply hs   -- goal is a : Nat ⊢ p (succ a)

注意,表达式 m + 3 * kgeneralize 删除了;重要的只是它的形式是 0 还是 succ a。这种形式的 cases不会恢复任何同时提到方程中的表达式的假设(在本例中是m + 3 * k)。如果这样的术语出现在一个假设中,而你也想对其进行概括,你需要明确地恢复 revert 它。

如果你所涉及的表达式没有出现在目标中,cases 策略使用 have 来把表达式的类型放到上下文中。下面是一个例子:

example (p : Prop) (m n : Nat)
        (h₁ : m < n → p) (h₂ : m ≥ n → p) : p := by
  cases Nat.lt_or_ge m n
  case inl hlt => exact h₁ hlt
  case inr hge => exact h₂ hge

定理 Nat.lt_or_ge m nm < n ∨ m ≥ n,很自然地认为上面的证明是在这两种情况下的分割。在第一个分支中,我们有假设 h₁ : m < n,在第二个分支中,我们有假设 h₂ : m ≥ n。上面的证明在功能上等同于以下:

example (p : Prop) (m n : Nat)
        (h₁ : m < n → p) (h₂ : m ≥ n → p) : p := by
  have h : m < n ∨ m ≥ n := Nat.lt_or_ge m n
  cases h
  case inl hlt => exact h₁ hlt
  case inr hge => exact h₂ hge

在前两行之后,我们有 h : m < n ∨ m ≥ n 作为假设,我们只需在此基础上做cases。

下面是另一个例子,我们利用自然数相等的可判定性,对m = nm ≠ n的情况进行拆分。

#check Nat.sub_self

example (m n : Nat) : m - n = 0 ∨ m ≠ n := by
  cases Decidable.em (m = n) with
  | inl heq => rw [heq]; apply Or.inl; exact Nat.sub_self n
  | inr hne => apply Or.inr; exact hne

如果你 open Classical,你可以对任何命题使用排中律。但是使用类型类推理,Lean 实际上可以找到相关的决策程序,这意味着你可以在可计算函数中使用情况拆分。

正如 cases 项可以用来进行分情况证明,induction 项可以用来进行归纳证明。其语法与cases相似,只是参数只能是局部上下文中的一个项。下面是一个例子:

namespace Hidden
theorem zero_add (n : Nat) : 0 + n = n := by
  induction n with
  | zero => rfl
  | succ n ih => rw [Nat.add_succ, ih]
end Hidden

cases 一样,我们可以使用 case 代替with

namespace Hidden
theorem zero_add (n : Nat) : 0 + n = n := by
  induction n
  case zero => rfl
  case succ n ih => rw [Nat.add_succ, ih]
end Hidden

更多例子:

namespace Hidden
theorem add_zero (n : Nat) : n + 0 = n := Nat.add_zero n
open Nat

theorem zero_add (n : Nat) : 0 + n = n := by
  induction n <;> simp [*, add_zero, add_succ]

theorem succ_add (m n : Nat) : succ m + n = succ (m + n) := by
  induction n <;> simp [*, add_zero, add_succ]

theorem add_comm (m n : Nat) : m + n = n + m := by
  induction n <;> simp [*, add_zero, add_succ, succ_add, zero_add]

theorem add_assoc (m n k : Nat) : m + n + k = m + (n + k) := by
  induction k <;> simp [*, add_zero, add_succ]
end Hidden

induction策略也支持用户定义的具有多个目标(又称主前提)的归纳原则。

/-
theorem Nat.mod.inductionOn
      {motive : Nat → Nat → Sort u}
      (x y  : Nat)
      (ind  : ∀ x y, 0 < y ∧ y ≤ x → motive (x - y) y → motive x y)
      (base : ∀ x y, ¬(0 < y ∧ y ≤ x) → motive x y)
      : motive x y :=
-/

example (x : Nat) {y : Nat} (h : y > 0) : x % y < y := by
  induction x, y using Nat.mod.inductionOn with
  | ind x y h₁ ih =>
    rw [Nat.mod_eq_sub_mod h₁.2]
    exact ih h
  | base x y h₁ =>
    have : ¬ 0 < y ∨ ¬ y ≤ x := Iff.mp (Decidable.not_and_iff_or_not ..) h₁
    match this with
    | Or.inl h₁ => exact absurd h h₁
    | Or.inr h₁ =>
      have hgt : y > x := Nat.gt_of_not_le h₁
      rw [← Nat.mod_eq_of_lt hgt] at hgt
      assumption

你也可以在策略中使用match符号:

example : p ∨ q → q ∨ p := by
  intro h
  match h with
  | Or.inl _  => apply Or.inr; assumption
  | Or.inr h2 => apply Or.inl; exact h2

为了方便起见,模式匹配已经被整合到诸如introfunext等策略中。

example : s ∧ q ∧ r → p ∧ r → q ∧ p := by
  intro ⟨_, ⟨hq, _⟩⟩ ⟨hp, _⟩
  exact ⟨hq, hp⟩

example :
    (fun (x : Nat × Nat) (y : Nat × Nat) => x.1 + y.2)
    =
    (fun (x : Nat × Nat) (z : Nat × Nat) => z.2 + x.1) := by
  funext (a, b) (c, d)
  show a + d = d + a
  rw [Nat.add_comm]

我们用最后一个策略来结束本节,这个策略旨在促进归纳类型的工作,即 injection 注入策略。归纳类型的元素是自由生成的,也就是说,构造子是注入式的,并且有不相交的作用范围。injection 策略是为了利用这一事实:

open Nat

example (m n k : Nat) (h : succ (succ m) = succ (succ n))
        : n + k = m + k := by
  injection h with h'
  injection h' with h''
  rw [h'']

该策略的第一个实例在上下文中加入了 h' : succ m = succ n,第二个实例加入了 h'' : m = n

injection 策略还可以检测不同构造子被设置为相等时产生的矛盾,并使用它们来关闭目标。

open Nat

example (m n : Nat) (h : succ m = 0) : n = n + 7 := by
  injection h

example (m n : Nat) (h : succ m = 0) : n = n + 7 := by
  contradiction

example (h : 7 = 4) : False := by
  contradiction

如第二个例子所示,contradiction 策略也能检测出这种形式的矛盾。

归纳族

我们几乎已经完成了对Lean 所接受的全部归纳定义的描述。到目前为止,你已经看到Lean 允许你用任何数量的递归构造子引入归纳类型。事实上,一个归纳定义可以引入一个有索引的归纳类型的 族(Family)

归纳族是一个由以下形式的同时归纳定义的有索引的家族:

inductive foo : ... → Sort u where
  | constructor₁ : ... → foo ...
  | constructor₂ : ... → foo ...
  ...
  | constructorₙ : ... → foo ...

与普通的归纳定义不同,它构造了某个 Sort u 的元素,更一般的版本构造了一个函数 ... → Sort u,其中 ... 表示一串参数类型,也称为 索引 。然后,每个构造子都会构造一个家族中某个成员的元素。一个例子是 Vector α n 的定义,它是长度为 nα 元素的向量的类型:

namespace Hidden
inductive Vector (α : Type u) : Nat → Type u where
  | nil  : Vector α 0
  | cons : α → {n : Nat} → Vector α n → Vector α (n+1)
end Hidden

注意,cons 构造子接收 Vector α n 的一个元素,并返回 Vector α (n+1) 的一个元素,从而使用家族中的一个成员的元素来构建另一个成员的元素。

一个更奇特的例子是由Lean 中相等类型的定义:

namespace Hidden
inductive Eq {α : Sort u} (a : α) : α → Prop where
  | refl : Eq a a
end Hidden

对于每个固定的 α : Sort ua : α,这个定义构造了一个 Eq a x 的类型类,由 x : α 索引。然而,只有一个构造子refl,它是Eq a a的一个元素,构造子后面的大括号告诉Lean 要把refl的参数明确化。直观地说,在xa的情况下,构建Eq a x证明的唯一方法是使用自反性。请注意,Eq a aEq a x这个类型家族中唯一的类型。由Lean 产生的消去规则如下:

universe u v

#check (@Eq.rec : {α : Sort u} → {a : α} → {motive : (x : α) → a = x → Sort v}
                  → motive a rfl → {b : α} → (h : a = b) → motive b h)

一个显著的事实是,所有关于相等的基本公理都来自构造子refl和消去器Eq.rec。然而,相等的定义是不典型的,见公理化细节一节的讨论。

递归器Eq.rec也被用来定义代换:

namespace Hidden
theorem subst {α : Type u} {a b : α} {p : α → Prop} (h₁ : Eq a b) (h₂ : p a) : p b :=
  Eq.rec (motive := fun x _ => p x) h₂ h₁
end Hidden

可以使用match定义subst

namespace Hidden
theorem subst {α : Type u} {a b : α} {p : α → Prop} (h₁ : Eq a b) (h₂ : p a) : p b :=
  match h₁ with
  | rfl => h₂
end Hidden

实际上,Lean 使用基于Eq.rec的定义来编译match表达式。

namespace Hidden
theorem subst {α : Type u} {a b : α} {p : α → Prop} (h₁ : Eq a b) (h₂ : p a) : p b :=
  match h₁ with
  | rfl => h₂

set_option pp.all true
#print subst
  -- ... subst.match_1 ...
#print subst.match_1
  -- ... Eq.casesOn ...
#print Eq.casesOn
  -- ... Eq.rec ...
end Hidden

使用递归器或matchh₁ : a = b,我们可以假设ab相同,在这种情况下,p bp a相同。

证明 Eq 的对称和传递性并不难。在下面的例子中,我们证明symm,并留下transcongr (congruence)定理作为练习。

namespace Hidden
theorem symm {α : Type u} {a b : α} (h : Eq a b) : Eq b a :=
  match h with
  | rfl => rfl

theorem trans {α : Type u} {a b c : α} (h₁ : Eq a b) (h₂ : Eq b c) : Eq a c :=
  sorry

theorem congr {α β : Type u} {a b : α} (f : α → β) (h : Eq a b) : Eq (f a) (f b) :=
  sorry
end Hidden

在类型论文献中,有对归纳定义的进一步推广,例如,「归纳-递归」和「归纳-归纳」的原则。这些东西Lean 暂不支持。

公理化细节

我们已经通过例子描述了归纳类型和它们的语法。本节为那些对公理基础感兴趣的人提供额外的信息。

我们已经看到,归纳类型的构造子需要 参量 (parameter,与argument都有「参数」译义,为区别此处译为参量)——即在整个归纳构造过程中保持固定的参数——和 索引 ,即同时在构造中的类型类的参数。每个构造子都应该有一个类型,其中的参数类型是由先前定义的类型、参量和索引类型以及当前正在定义的归纳族建立起来的。要求是,如果后者存在,它只 严格正向 出现。这意味着它所出现的构造子的任何参数都是一个依值箭头类型,其中定义中的归纳类型只作为结果类型出现,其中的索引是以常量和先前的参数来给出。

既然一个归纳类型对于某些 u 来说存在于在 Sort u 中,那么我们有理由问 哪些 宇宙层次的 u 可以被实例化。归纳类型类 C 的定义中的每个构造子 c 的形式为

  c : (a : α) → (b : β[a]) → C a p[a,b]

其中a是一列数据类型的参量,b是一列构造子的参数,p[a, b]是索引,用于确定构造所处的归纳族的元素。(请注意,这种描述有些误导,因为构造子的参数可以以任何顺序出现,只要依赖关系是合理的)。对 C 的宇宙层级的约束分为两种情况,取决于归纳类型是否被指定落在 Prop(即 Sort 0)。

我们首先考虑归纳类型指定落在 Prop 的情况。那么宇宙等级u'被限制为满足以下条件:

对于上面的每个构造子c,以及序列β[a]中的每个βk[a],如果βk[a] : Sort v,我们有uv

换句话说,宇宙等级 u 被要求至少与代表构造子参数的每个类型的宇宙等级一样大。

当归纳类型被指定落在 Prop 中时,对构造子参数的宇宙等级没有任何限制。但是这些宇宙等级对消去规则有影响。一般来说,对于 Prop 中的归纳类型,消去规则的motive被要求在 Prop 中。

这最后一条规则有一个例外:当只有一个构造子,并且每个构造子参数都在Prop中或者是一个索引时,我们可以从一个归纳定义的Prop中消除到一个任意的Sort。直观的说,在这种情况下,消除并不利用任何信息,而这些信息并不是由参数类型被栖息这一简单的事实所提供的。这种特殊情况被称为单子消除(singleton elimination)。

我们已经在Eq.rec的应用中看到了单子消除的作用,这是归纳定义的相等类型的消去器。我们可以使用一个元素 h : Eq a b 来将一个元素 t' : p a 转换为 p b,即使 p ap b 是任意类型,因为转换并不产生新的数据;它只是重新解释了我们已经有的数据。单子消除法也用于异质等价和良基的递归,这将在归纳和递归一章中讨论。

相互和嵌套的归纳类型

我们现在考虑两个经常有用的归纳类型的推广,Lean 通过「编译」它们来支持上述更原始的归纳类型种类。换句话说,Lean 解析了更一般的定义,在此基础上定义了辅助的归纳类型,然后使用辅助类型来定义我们真正想要的类型。下一章将介绍Lean 的方程编译器,它需要有效地利用这些类型。尽管如此,在这里描述这些声明还是有意义的,因为它们是普通归纳定义的直接变形。

首先,Lean 支持 相互定义 的归纳类型。这个想法是,我们可以同时定义两个(或更多)归纳类型,其中每个类型都指代其他类型。

mutual
  inductive Even : Nat → Prop where
    | even_zero : Even 0
    | even_succ : (n : Nat) → Odd n → Even (n + 1)

  inductive Odd : Nat → Prop where
    | odd_succ : (n : Nat) → Even n → Odd (n + 1)
end

在这个例子中,同时定义了两种类型:一个自然数n如果是0或比Even多一个,就是Odd;如果是比Odd多一个,就是Even。在下面的练习中,要求你写出细节。

相互的归纳定义也可以用来定义有限树的符号,节点由α的元素标记:

mutual
    inductive Tree (α : Type u) where
      | node : α → TreeList α → Tree α

    inductive TreeList (α : Type u) where
      | nil  : TreeList α
      | cons : Tree α → TreeList α → TreeList α
end

有了这个定义,我们可以通过给出一个 α 的元素和一个子树列表(可能是空的)来构造 Tree α 的元素。子树列表由TreeList α类型表示,它被定义为空列表nil,或者是一棵树的consTreeList α的一个元素。

然而,这个定义在工作中是不方便的。如果子树的列表是由 List (Tree α) 类型给出的,那就更好了,尤其是Lean 的库中包含了一些处理列表的函数和定理。我们可以证明 TreeList α 类型与 List (Tree α)同构的,但是沿着这个同构关系来回翻译结果是很乏味的。

事实上,Lean 允许我们定义我们真正想要的归纳类型:

inductive Tree (α : Type u) where
  | mk : α → List (Tree α) → Tree α

这就是所谓的 嵌套 归纳类型。它不属于上一节给出的归纳类型的严格规范,因为Tree不是严格意义上出现在mk的参数中,而是嵌套在List类型构造子中。然后Lean 在其内核中自动建立了 TreeList αList (Tree α) 之间的同构关系,并根据同构关系定义了 Tree 的构造子。

练习

  1. 尝试定义自然数的其他运算,如乘法、前继函数(定义pred 0 = 0)、截断减法(当m大于或等于n时,n - m = 0)和乘方。然后在我们已经证明的定理的基础上,尝试证明它们的一些基本属性。

由于其中许多已经在Lean 的核心库中定义,你应该在一个名为 Hidden 或类似的命名空间中工作,以避免名称冲突。

  1. 定义一些对列表的操作,如 length 函数或 reverse 函数。证明一些属性,比如下面这些。

a. length (s ++ t) = length s + length t

b. length (reverse t) = length t

c. reverse (reverse t) = t

  1. 定义一个归纳数据类型,由以下构造子建立的项组成。
  • const n,一个表示自然数n的常数
  • var n,一个变量,编号为n
  • plus s t,表示st的总和
  • times s t,表示st的乘积

递归地定义一个函数,根据变量的赋值来计算任何这样的项。

  1. 同样,定义命题公式的类型,以及关于这类公式类型的函数:求值函数、衡量公式复杂性的函数,以及用另一个公式替代给定变量的函数。

归纳和递归

在上一章中,我们看到归纳定义提供了在 Lean 中引入新类型的强大手段。此外,构造子和递归器提供了在这些类型上定义函数的唯一手段。命题即类型的对应关系,意味着归纳法是证明的基本方法。

Lean 提供了定义递归函数、执行模式匹配和编写归纳证明的自然方法。它允许你通过指定它应该满足的方程来定义一个函数,它允许你通过指定如何处理可能出现的各种情况来证明一个定理。在它内部,这些描述被「方程编译器」程序「编译」成原始递归器。方程编译器不是可信代码库的一部分;它的输出包括由内核独立检查的项。

模式匹配

对示意图模式的解释是编译过程的第一步。我们已经看到,casesOn 递归器可以通过分情况讨论来定义函数和证明定理,根据归纳定义类型所涉及的构造子。但是复杂的定义可能会使用几个嵌套的 casesOn 应用,而且可能很难阅读和理解。模式匹配提供了一种更方便的方法,并且为函数式编程语言的用户所熟悉。

考虑一下自然数的归纳定义类型。每个自然数要么是 zero,要么是 succ x,因此你可以通过在每个情况下指定一个值来定义一个从自然数到任意类型的函数:

open Nat

def sub1 : Nat → Nat
  | zero   => zero
  | succ x => x

def isZero : Nat → Bool
  | zero   => true
  | succ x => false

用来定义这些函数的方程在定义上是成立的:

open Nat
def sub1 : Nat → Nat
  | zero   => zero
  | succ x => x
def isZero : Nat → Bool
  | zero   => true
  | succ x => false
example : sub1 0 = 0 := rfl
example (x : Nat) : sub1 (succ x) = x := rfl

example : isZero 0 = true := rfl
example (x : Nat) : isZero (succ x) = false := rfl

example : sub1 7 = 6 := rfl
example (x : Nat) : isZero (x + 3) = false := rfl

我们可以用一些更耳熟能详的符号,而不是 zerosucc

def sub1 : Nat → Nat
  | 0   => 0
  | x+1 => x

def isZero : Nat → Bool
  | 0   => true
  | x+1 => false

因为加法和零符号已经被赋予 [matchPattern] 属性,它们可以被用于模式匹配。Lean 简单地将这些表达式规范化,直到显示构造子 zerosucc

模式匹配适用于任何归纳类型,如乘积和 Option 类型:

def swap : α × β → β × α
  | (a, b) => (b, a)

def foo : Nat × Nat → Nat
  | (m, n) => m + n

def bar : Option Nat → Nat
  | some n => n + 1
  | none   => 0

在这里,我们不仅用它来定义一个函数,而且还用它来进行逐情况证明:

namespace Hidden
def not : Bool → Bool
  | true  => false
  | false => true

theorem not_not : ∀ (b : Bool), not (not b) = b
  | true  => rfl  -- proof that not (not true) = true
  | false => rfl  -- proof that not (not false) = false
end Hidden

模式匹配也可以用来解构归纳定义的命题:

example (p q : Prop) : p ∧ q → q ∧ p
  | And.intro h₁ h₂ => And.intro h₂ h₁

example (p q : Prop) : p ∨ q → q ∨ p
  | Or.inl hp => Or.inr hp
  | Or.inr hq => Or.inl hq

这样解决带逻辑连接词的命题就很紧凑。

在所有这些例子中,模式匹配被用来进行单一情况的区分。更有趣的是,模式可以涉及嵌套的构造子,如下面的例子。

def sub2 : Nat → Nat
  | 0   => 0
  | 1   => 0
  | x+2 => x

方程编译器首先对输入是 zero 还是 succ x 的形式进行分类讨论,然后对 xzero 还是 succ x 的形式进行分类讨论。它从提交给它的模式中确定必要的情况拆分,如果模式不能穷尽情况,则会引发错误。同时,我们可以使用算术符号,如下面的版本。在任何一种情况下,定义方程都是成立的。

def sub2 : Nat → Nat
  | 0   => 0
  | 1   => 0
  | x+2 => x
example : sub2 0 = 0 := rfl
example : sub2 1 = 0 := rfl
example : sub2 (x+2) = x := rfl

example : sub2 5 = 3 := rfl

你可以写 #print sub2 来看看这个函数是如何被编译成递归器的。(Lean 会告诉你 sub2 已经被定义为内部辅助函数 sub2.match_1,但你也可以把它打印出来)。Lean 使用这些辅助函数来编译 match 表达式。实际上,上面的定义被扩展为

def sub2 : Nat → Nat :=
  fun x =>
    match x with
    | 0   => 0
    | 1   => 0
    | x+2 => x

下面是一些嵌套模式匹配的例子:

example (p q : α → Prop)
        : (∃ x, p x ∨ q x) → (∃ x, p x) ∨ (∃ x, q x)
  | Exists.intro x (Or.inl px) => Or.inl (Exists.intro x px)
  | Exists.intro x (Or.inr qx) => Or.inr (Exists.intro x qx)

def foo : Nat × Nat → Nat
  | (0, n)     => 0
  | (m+1, 0)   => 1
  | (m+1, n+1) => 2

方程编译器可以按顺序处理多个参数。例如,将前面的例子定义为两个参数的函数会更自然:

def foo : Nat → Nat → Nat
  | 0,   n   => 0
  | m+1, 0   => 1
  | m+1, n+1 => 2

另一例:

def bar : List Nat → List Nat → Nat
  | [],      []      => 0
  | a :: as, []      => a
  | [],      b :: bs => b
  | a :: as, b :: bs => a + b

这些模式是由逗号分隔的。

在下面的每个例子中,尽管其他参数包括在模式列表中,但只对第一个参数进行了分割。

namespace Hidden
def and : Bool → Bool → Bool
  | true,  a => a
  | false, _ => false

def or : Bool → Bool → Bool
  | true,  _ => true
  | false, a => a

def cond : Bool → α → α → α
  | true,  x, y => x
  | false, x, y => y
end Hidden

还要注意的是,当定义中不需要一个参数的值时,你可以用下划线来代替。这个下划线被称为 通配符模式 ,或 匿名变量 。与方程编译器之外的用法不同,这里的下划线 并不 表示一个隐参数。使用下划线表示通配符在函数式编程语言中是很常见的,所以 Lean 采用了这种符号。通配符和重叠模式一节阐述了通配符的概念,而不可访问模式一节解释了你如何在模式中使用隐参数。

正如归纳类型一章中所描述的,归纳数据类型可以依赖于参数。下面的例子使用模式匹配定义了 tail 函数。参数 α : Type 是一个参数,出现在冒号之前,表示它不参与模式匹配。Lean 也允许参数出现在 : 之后,但它不能对其进行模式匹配。

def tail1 {α : Type u} : List α → List α
  | []      => []
  | a :: as => as

def tail2 : {α : Type u} → List α → List α
  | α, []      => []
  | α, a :: as => as

尽管参数 α 在这两个例子中的位置不同,但在这两种情况下,它的处理方式是一样的,即它不参与情况分割。

Lean 也可以处理更复杂的模式匹配形式,其中从属类型的参数对各种情况构成了额外的约束。这种 依值模式匹配 的例子在依值模式匹配一节中考虑。

通配符和重叠模式

考虑上节的一个例子:

def foo : Nat → Nat → Nat
  | 0,   n   => 0
  | m+1, 0   => 1
  | m+1, n+1 => 2

也可以表述成

def foo : Nat → Nat → Nat
  | 0, n => 0
  | m, 0 => 1
  | m, n => 2

在第二种表述中,模式是重叠的;例如,一对参数 0 0 符合所有三种情况。但是Lean 通过使用第一个适用的方程来处理这种模糊性,所以在这个例子中,最终结果是一样的。特别是,以下方程在定义上是成立的:

def foo : Nat → Nat → Nat
  | 0, n => 0
  | m, 0 => 1
  | m, n => 2
example : foo 0     0     = 0 := rfl
example : foo 0     (n+1) = 0 := rfl
example : foo (m+1) 0     = 1 := rfl
example : foo (m+1) (n+1) = 2 := rfl

由于不需要 mn 的值,我们也可以用通配符模式代替。

def foo : Nat → Nat → Nat
  | 0, _ => 0
  | _, 0 => 1
  | _, _ => 2

你可以检查这个 foo 的定义是否满足与之前相同的定义特性。

一些函数式编程语言支持 不完整的模式 。在这些语言中,解释器对不完整的情况产生一个异常或返回一个任意的值。我们可以使用 Inhabited (含元素的)类型类来模拟任意值的方法。粗略的说,Inhabited α 的一个元素是对 α 拥有一个元素的见证;在类型类中,我们将看到 Lean 可以被告知合适的基础类型是含元素的,并且可以自动推断出其他构造类型是含元素的。在此基础上,标准库提供了一个任意元素 arbitrary,任何含元素的类型。

我们还可以使用类型Option α来模拟不完整的模式。我们的想法是对所提供的模式返回some a,而对不完整的情况使用none。下面的例子演示了这两种方法。

def f1 : Nat → Nat → Nat
  | 0, _  => 1
  | _, 0  => 2
  | _, _  => default  -- 不完整的模式

example : f1 0     0     = 1       := rfl
example : f1 0     (a+1) = 1       := rfl
example : f1 (a+1) 0     = 2       := rfl
example : f1 (a+1) (b+1) = default := rfl

def f2 : Nat → Nat → Option Nat
  | 0, _  => some 1
  | _, 0  => some 2
  | _, _  => none     -- 不完整的模式

example : f2 0     0     = some 1 := rfl
example : f2 0     (a+1) = some 1 := rfl
example : f2 (a+1) 0     = some 2 := rfl
example : f2 (a+1) (b+1) = none   := rfl

方程编译器是很智能的。如果你遗漏了以下定义中的任何一种情况,错误信息会告诉你遗漏了哪个。

def bar : Nat → List Nat → Bool → Nat
  | 0,   _,      false => 0
  | 0,   b :: _, _     => b
  | 0,   [],     true  => 7
  | a+1, [],     false => a
  | a+1, [],     true  => a + 1
  | a+1, b :: _, _     => a + b

某些情况也可以用「if ... then ... else」代替 casesOn

def foo : Char → Nat
  | 'A' => 1
  | 'B' => 2
  | _   => 3

#print foo.match_1

结构化递归和归纳

方程编译器的强大之处在于,它还支持递归定义。在接下来的三节中,我们将分别介绍。

  • 结构性递归定义
  • 良基的递归定义
  • 相互递归的定义

一般来说,方程编译器处理以下形式的输入。

def foo (a : α) : (b : β) → γ
  | [patterns₁] => t₁
  ...
  | [patternsₙ] => tₙ

这里 (a : α) 是一个参数序列,(b : β) 是进行模式匹配的参数序列,γ 是任何类型,它可以取决于 ab 。每一行应该包含相同数量的模式,对应于 β 的每个元素。正如我们所看到的,模式要么是一个变量,要么是应用于其他模式的构造子,要么是一个正规化为该形式的表达式(其中非构造子用 [matchPattern] 属性标记)。构造子的出现会提示情况拆分,构造子的参数由给定的变量表示。在依值模式匹配一节中,我们将看到有时有必要在模式中包含明确的项,这些项需要进行表达式类型检查,尽管它们在模式匹配中没有起到作用。由于这个原因,这些被称为「不可访问的模式」。但是在依值模式匹配一节之前,我们将不需要使用这种不可访问的模式。

正如我们在上一节所看到的,项 t₁,...,tₙ 可以利用任何一个参数 a,以及在相应模式中引入的任何一个变量。使得递归和归纳成为可能的是,它们也可以涉及对 foo 的递归调用。在本节中,我们将处理 结构性递归 ,其中 foo 的参数出现在 := 的右侧,是左侧模式的子项。我们的想法是,它们在结构上更小,因此在归纳类型中出现在更早的阶段。下面是上一章的一些结构递归的例子,现在用方程编译器来定义。

open Nat
def add : Nat → Nat → Nat
  | m, zero   => m
  | m, succ n => succ (add m n)

theorem add_zero (m : Nat)   : add m zero = m := rfl
theorem add_succ (m n : Nat) : add m (succ n) = succ (add m n) := rfl

theorem zero_add : ∀ n, add zero n = n
  | zero   => rfl
  | succ n => congrArg succ (zero_add n)

def mul : Nat → Nat → Nat
  | n, zero   => zero
  | n, succ m => add (mul n m) n

zero_add 的证明清楚地表明,归纳证明实际上是 Lean 中的一种递归形式。

上面的例子表明,add 的定义方程具有定义意义, mul 也是如此。方程编译器试图确保在任何可能的情况下都是这样,就像直接的结构归纳法一样。然而,在其他情况下,约简只在命题上成立,也就是说,它们是必须明确应用的方程定理。方程编译器在内部生成这样的定理。用户不能直接使用它们;相反,simp 策略被配置为在必要时使用它们。因此,对zero_add的以下两种证明都成立:

open Nat
def add : Nat → Nat → Nat
  | m, zero   => m
  | m, succ n => succ (add m n)
theorem zero_add : ∀ n, add zero n = n
  | zero   => by simp [add]
  | succ n => by simp [add, zero_add]

与模式匹配定义一样,结构递归或归纳的参数可能出现在冒号之前。在处理定义之前,简单地将这些参数添加到本地上下文中。例如,加法的定义也可以写成这样:

open Nat
def add (m : Nat) : Nat → Nat
  | zero   => m
  | succ n => succ (add m n)

你也可以用 match 来写上面的例子。

open Nat
def add (m n : Nat) : Nat :=
  match n with
  | zero   => m
  | succ n => succ (add m n)

一个更有趣的结构递归的例子是斐波那契函数 fib

def fib : Nat → Nat
  | 0   => 1
  | 1   => 1
  | n+2 => fib (n+1) + fib n

example : fib 0 = 1 := rfl
example : fib 1 = 1 := rfl
example : fib (n + 2) = fib (n + 1) + fib n := rfl

example : fib 7 = 21 := rfl

这里,fib 函数在 n + 2 (定义上等于 succ (succ n) )处的值是根据 n + 1 (定义上等价于 succ n )和 n 处的值定义的。然而,这是一种众所周知的计算斐波那契函数的低效方法,其执行时间是 n 的指数级。这里有一个更好的方法:

def fibFast (n : Nat) : Nat :=
  (loop n).2
where
  loop : Nat → Nat × Nat
    | 0   => (0, 1)
    | n+1 => let p := loop n; (p.2, p.1 + p.2)

#eval fibFast 100

下面是相同的定义,使用 let rec 代替 where

def fibFast (n : Nat) : Nat :=
  let rec loop : Nat → Nat × Nat
    | 0   => (0, 1)
    | n+1 => let p := loop n; (p.2, p.1 + p.2)
  (loop n).2

在这两种情况下,Lean 都会生成辅助函数 fibFast.loop

为了处理结构递归,方程编译器使用 值过程 (course-of-values)递归,使用由每个归纳定义类型自动生成的常量 belowbrecOn。你可以通过查看 Nat.belowNat.brecOn 的类型来了解它是如何工作的。

variable (C : Nat → Type u)

#check (@Nat.below C : Nat → Type u)

#reduce @Nat.below C (3 : Nat)

#check (@Nat.brecOn C : (n : Nat) → ((n : Nat) → @Nat.below C n → C n) → C n)

类型 @Nat.below C (3 : nat) 是一个存储着 C 0C 1,和 C 2 中元素的数据结构。值过程递归由 Nat.brecOn 实现。它根据该函数之前的所有值,定义类型为 (n : Nat) → C n 的依值函数在特定输入 n 时的值,表示为 @Nat.below C n 的一个元素。

值过程递归是方程编译器用来向 Lean 内核证明函数终止的技术之一。它不会像其他函数式编程语言编译器一样影响编译递归函数的代码生成器。回想一下,#eval fib <n><n> 的指数。另一方面,#reduce fib <n> 是有效的,因为它使用了发送到内核的基于 brecOn 结构的定义。

def fib : Nat → Nat
  | 0   => 1
  | 1   => 1
  | n+2 => fib (n+1) + fib n

-- #eval fib 50 -- 这个很慢
#reduce fib 50  -- 用这个,这个快

#print fib

另一个递归定义的好例子是列表的 append 函数。

def append : List α → List α → List α
  | [],    bs => bs
  | a::as, bs => a :: append as bs

example : append [1, 2, 3] [4, 5] = [1, 2, 3, 4, 5] := rfl

这里是另一个:它将第一个列表中的元素和第二个列表中的元素分别相加,直到两个列表中的一个用尽。

def listAdd [Add α] : List α → List α → List α
  | [],      _       => []
  | _,       []      => []
  | a :: as, b :: bs => (a + b) :: listAdd as bs

#eval listAdd [1, 2, 3] [4, 5, 6, 6, 9, 10]
-- [5, 7, 9]

你可以在章末练习中尝试类似的例子。

局域递归声明

可以使用 let rec 关键字定义局域递归声明。

def replicate (n : Nat) (a : α) : List α :=
  let rec loop : Nat → List α → List α
    | 0,   as => as
    | n+1, as => loop n (a::as)
  loop n []

#check @replicate.loop
-- {α : Type} → α → Nat → List α → List α

Lean 为每个 let rec 创建一个辅助声明。在上面的例子中,它对于出现在 replicatelet rec loop 创建了声明 replication.loop。请注意,Lean 通过添加 let rec 声明中出现的任何局部变量作为附加参数来「关闭」声明。例如,局部变量 a 出现在 let rec 循环中。

你也可以在策略证明模式中使用 let rec,并通过归纳来创建证明。

def replicate (n : Nat) (a : α) : List α :=
 let rec loop : Nat → List α → List α
   | 0,   as => as
   | n+1, as => loop n (a::as)
 loop n []
theorem length_replicate (n : Nat) (a : α) : (replicate n a).length = n := by
  let rec aux (n : Nat) (as : List α)
              : (replicate.loop a n as).length = n + as.length := by
    match n with
    | 0   => simp [replicate.loop]
    | n+1 => simp [replicate.loop, aux n, Nat.add_succ, Nat.succ_add]
  exact aux n []

还可以在定义后使用 where 子句引入辅助递归声明。Lean 将它们转换为 let rec

def replicate (n : Nat) (a : α) : List α :=
  loop n []
where
  loop : Nat → List α → List α
    | 0,   as => as
    | n+1, as => loop n (a::as)

theorem length_replicate (n : Nat) (a : α) : (replicate n a).length = n := by
  exact aux n []
where
  aux (n : Nat) (as : List α)
      : (replicate.loop a n as).length = n + as.length := by
    match n with
    | 0   => simp [replicate.loop]
    | n+1 => simp [replicate.loop, aux n, Nat.add_succ, Nat.succ_add]

良基递归和归纳

当不能使用结构递归时,我们可以使用良基递归(well-founded recursion)来证明终止性。我们需要一个良基关系和一个证明每个递归调用相对于该关系都是递减的证明。依值类型理论具有足够的表达能力来编码和证明良基递归。让我们从理解其工作原理所需的逻辑背景开始。

Lean 的标准库定义了两个谓词,Acc r aWellFounded r,其中 r 是一个在类型 α 上的二元关系,而 a 是类型 α 的一个元素。

variable (α : Sort u)
variable (r : α → α → Prop)

#check (Acc r : α → Prop)
#check (WellFounded r : Prop)

首先 Acc 是一个归纳定义的谓词。根据定义,Acc r x 等价于 ∀ y, r y x → Acc r y。如果你把 r y x 考虑成一种序关系 y ≺ x,那么 Acc r x 说明 x 在下文中可访问, 从某种意义上说,它的所有前继都是可访问的。特别地,如果 x 没有前继,它是可访问的。给定任何类型 α,我们应该能够通过首先为 α 的所有前继元素赋值,递归地为 α 的每个可访问元素赋值。

使用 WellFounded r 来声明 r 是良基的,即说明该类型的每个元素都是可访问的。根据上述考虑,如果 r 是类型 α 上的一个成立良好的关系,那么对于关系 r,我们应该有一个关于 α 的成立良好的递归原则。确实,我们这样做了:标准库定义了 WellFounded.fix,它正好满足这个目的。

noncomputable def f {α : Sort u}
      (r : α → α → Prop)
      (h : WellFounded r)
      (C : α → Sort v)
      (F : (x : α) → ((y : α) → r y x → C y) → C x)
      : (x : α) → C x := WellFounded.fix h F

这里有一大堆字,但我们熟悉第一块:类型 α,关系 r 和假设 h,即 r 是有良基的。变量' C 代表递归定义的动机:对于每个元素 x : α,我们想构造一个 C x 的元素。函数 F 提供了这样做的归纳方法:它告诉我们如何构造一个元素 C x,给定 C y 的元素对于 x 的每个 y

注意 WellFounded.fix 和归纳法原理一样有效。它说如果 是良基的,而你想要证明 ∀ x, C x,那么只要证明对于任意的 x,如果我们有 ∀ y ≺ x, C y,那么我们就有 C x 就足够了。

在上面的例子中,我们使用了修饰符 noncomputable,因为代码生成器目前不支持 WellFounded.fix。函数 WellFounded.fix 是 Lean 用来证明函数终止的另一个工具。

Lean 知道自然数上通常的序 < 是良基的。它还知道许多从其他东西中构造新的良基的序的方法,例如字典序。

下面是标准库中自然数除法的定义。

open Nat

theorem div_lemma {x y : Nat} : 0 < y ∧ y ≤ x → x - y < x :=
  fun h => sub_lt (Nat.lt_of_lt_of_le h.left h.right) h.left

def div.F (x : Nat) (f : (x₁ : Nat) → x₁ < x → Nat → Nat) (y : Nat) : Nat :=
  if h : 0 < y ∧ y ≤ x then
    f (x - y) (div_lemma h) y + 1
  else
    zero

noncomputable def div := WellFounded.fix (measure id).wf div.F

#reduce div 8 2 -- 4

这个定义有点难以理解。这里递归在 x 上, div.F x f : Nat → Nat 为固定的 x 返回「除以 y」函数。你要记住 div.F 的第二个参数 f 是递归的具体实现,这个函数对所有小于 x 的自然数 x₁ 返回「除以 y」函数。

繁饰器(Elaborator)可以使这样的定义更加方便。它接受下列内容:

def div (x y : Nat) : Nat :=
  if h : 0 < y ∧ y ≤ x then
    have : x - y < x := Nat.sub_lt (Nat.lt_of_lt_of_le h.1 h.2) h.1
    div (x - y) y + 1
  else
    0

当 Lean 遇到递归定义时,它首先尝试结构递归,失败时才返回到良基递归。Lean 使用 decreasing_tactic 来显示递归应用会越来越小。上面例子中的辅助命题 x - y < x 应该被视为这种策略的提示。

div 的定义公式不具有定义性,但我们可以使用 unfold 策略展开 div。我们使用 conv 来选择要展开的 div 应用。

def div (x y : Nat) : Nat :=
 if h : 0 < y ∧ y ≤ x then
   have : x - y < x := Nat.sub_lt (Nat.lt_of_lt_of_le h.1 h.2) h.1
   div (x - y) y + 1
 else
   0
example (x y : Nat) : div x y = if 0 < y ∧ y ≤ x then div (x - y) y + 1 else 0 := by
  conv => lhs; unfold div -- 展开方程左侧的div

example (x y : Nat) (h : 0 < y ∧ y ≤ x) : div x y = div (x - y) y + 1 := by
  conv => lhs; unfold div
  simp [h]

下面的示例与此类似:它将任何自然数转换为二进制表达式,表示为0和1的列表。我们必须提供递归调用正在递减的证据,我们在这里用 sorry 来做。sorry 并不会阻止解释器成功地对函数求值。

def natToBin : Nat → List Nat
  | 0     => [0]
  | 1     => [1]
  | n + 2 =>
    have : (n + 2) / 2 < n + 2 := sorry
    natToBin ((n + 2) / 2) ++ [n % 2]

#eval natToBin 1234567

最后一个例子,我们观察到Ackermann函数可以直接定义,因为它可以被自然数上字典序的良基性证明。termination_by 子句指示 Lean 使用字典序。这个子句实际上是将函数参数映射到类型为 Nat × Nat 的元素。然后,Lean 使用类型类解析来合成类型为 WellFoundedRelation (Nat × Nat) 的元素。

def ack : Nat → Nat → Nat
  | 0,   y   => y+1
  | x+1, 0   => ack x 1
  | x+1, y+1 => ack x (ack (x+1) y)
termination_by x y => (x, y)

注意,在上面的例子中使用了字典序,因为实例 WellFoundedRelation (α × β) 使用了字典序。Lean 还定义了实例

instance (priority := low) [SizeOf α] : WellFoundedRelation α :=
  sizeOfWFRel

在下面的例子中,我们通过显示 as.size - i 在递归应用中是递减的来证明它会终止。

def takeWhile (p : α → Bool) (as : Array α) : Array α :=
  go 0 #[]
where
  go (i : Nat) (r : Array α) : Array α :=
    if h : i < as.size then
      let a := as.get ⟨i, h⟩
      if p a then
        go (i+1) (r.push a)
      else
        r
    else
      r
  termination_by as.size - i

注意,辅助函数 go 在这个例子中是递归的,但 takeWhile 不是。

默认情况下,Lean 使用 decreasing_tactic 来证明递归应用正在递减。修饰词 decreasing_by 允许我们提供自己的策略。这里有一个例子。

theorem div_lemma {x y : Nat} : 0 < y ∧ y ≤ x → x - y < x :=
  fun ⟨ypos, ylex⟩ => Nat.sub_lt (Nat.lt_of_lt_of_le ypos ylex) ypos

def div (x y : Nat) : Nat :=
  if h : 0 < y ∧ y ≤ x then
    div (x - y) y + 1
  else
    0
decreasing_by apply div_lemma; assumption

注意 decreasing_by 不是 termination_by 的替代,它们是互补的。 termination_by 用于指定一个良基关系, decreasing_by 用于提供我们自己的策略来显示递归应用正在递减。在下面的示例中,我们将同时使用它们。

def ack : Nat → Nat → Nat
  | 0,   y   => y+1
  | x+1, 0   => ack x 1
  | x+1, y+1 => ack x (ack (x+1) y)
termination_by x y => (x, y)
decreasing_by
  all_goals simp_wf -- 展开良基的递归辅助定义
  · apply Prod.Lex.left; simp_arith
  · apply Prod.Lex.right; simp_arith
  · apply Prod.Lex.left; simp_arith

我们可以使用 decreasing_by sorry 来指示 Lean 「相信」函数可以终止。

def natToBin : Nat → List Nat
  | 0     => [0]
  | 1     => [1]
  | n + 2 => natToBin ((n + 2) / 2) ++ [n % 2]
decreasing_by sorry

#eval natToBin 1234567

回想一下,使用 sorry 相当于使用一个新的公理,应该避免使用。在下面的例子中,我们用 sorry 来证明 False。命令 #print axioms 显示,unsound 依赖于用于实现 sorry 的不健全公理 sorryAx

def unsound (x : Nat) : False :=
  unsound (x + 1)
decreasing_by sorry

#check unsound 0
-- `unsound 0` 是 `False` 的一个证明

#print axioms unsound
-- 'unsound' 依赖于公理:[sorryAx]

总结:

  • 如果没有 termination_by,良基关系(可能)可以这样被导出:选择一个参数,然后使用类型类解析为该参数的类型合成一个良基关系。

  • 如果指定了 termination_by,它将函数的参数映射为类型 α,并再次使用类型类解析。 回想一下,β × γ 的默认实例是基于 βγ的良基关系的字典序。

  • Nat 的默认良基关系实例是 <

  • 默认情况下,策略 decreasing_tactic 用于显示递归应用小于选择的良基关系。如果 decreasing_tactic 失败,错误信息包括剩余目标 ... |- G。注意,decreasing_tactic 使用 assumption。所以,你可以用 have 表达式来证明目标 G。你也可以使用 decreasing_by 来提供你自己的策略。

相互递归

Lean 还提供相互递归定义,语法类似相互归纳类型。例子:

mutual
  def even : Nat → Bool
    | 0   => true
    | n+1 => odd n

  def odd : Nat → Bool
    | 0   => false
    | n+1 => even n
end

example : even (a + 1) = odd a := by
  simp [even]

example : odd (a + 1) = even a := by
  simp [odd]

theorem even_eq_not_odd : ∀ a, even a = not (odd a) := by
  intro a; induction a
  . simp [even, odd]
  . simp [even, odd, *]

这是一个相互的定义,因为 even 是用 odd 递归定义的,而 odd 是用 even 递归定义的。在底层,它被编译为单个递归定义。内部定义的函数接受sum类型的元素作为参数,可以是 even 的输入,也可以是 odd 的输入。然后,它返回与输入相适应的输出。为了定义这个功能,Lean 使用了一个合适的、良基的度量。内部是对用户隐藏的;使用这些定义的规范方法是使用 simp (或 unfold),正如我们上面所做的那样。

相互递归定义还提供了处理相互和嵌套归纳类型的自然方法。回想一下前面提到的 EvenOdd 作为相互归纳谓词的定义。

mutual
  inductive Even : Nat → Prop where
    | even_zero : Even 0
    | even_succ : ∀ n, Odd n → Even (n + 1)

  inductive Odd : Nat → Prop where
    | odd_succ : ∀ n, Even n → Odd (n + 1)
end

构造子 even_zeroeven_succodd_succ 提供了显示数字是偶数还是奇数的积极方法。我们需要利用归纳类型是由这些构造子生成的这一事实来知道零不是奇数,并且后两个含义是相反的。像往常一样,构造子保存在以定义的类型命名的命名空间中,并且命令 open Even Odd 允许我们更方便地访问它们。

mutual
 inductive Even : Nat → Prop where
   | even_zero : Even 0
   | even_succ : ∀ n, Odd n → Even (n + 1)
 inductive Odd : Nat → Prop where
   | odd_succ : ∀ n, Even n → Odd (n + 1)
end
open Even Odd

theorem not_odd_zero : ¬ Odd 0 :=
  fun h => nomatch h

theorem even_of_odd_succ : ∀ n, Odd (n + 1) → Even n
  | _, odd_succ n h => h

theorem odd_of_even_succ : ∀ n, Even (n + 1) → Odd n
  | _, even_succ n h => h

另一个例子,假设我们使用嵌套归纳类型来归纳定义一组项,这样,项要么是常量(由字符串给出名称),要么是将常量应用于常量列表的结果。

inductive Term where
  | const : String → Term
  | app   : String → List Term → Term

然后,我们可以使用一个相互递归的定义来计算在一个项中出现的常量的数量,以及在一个项列表中出现的常量的数量。

inductive Term where
 | const : String → Term
 | app   : String → List Term → Term
namespace Term

mutual
  def numConsts : Term → Nat
    | const _ => 1
    | app _ cs => numConstsLst cs

  def numConstsLst : List Term → Nat
    | [] => 0
    | c :: cs => numConsts c + numConstsLst cs
end

def sample := app "f" [app "g" [const "x"], const "y"]

#eval numConsts sample

end Term

作为最后一个例子,我们定义了一个函数 replaceConst a b e,它将项 e 中的常数 a 替换为 b,然后证明常数的数量是相同的。注意,我们的证明使用了相互递归(即归纳法)。

inductive Term where
 | const : String → Term
 | app   : String → List Term → Term
namespace Term
mutual
 def numConsts : Term → Nat
   | const _ => 1
   | app _ cs => numConstsLst cs
  def numConstsLst : List Term → Nat
   | [] => 0
   | c :: cs => numConsts c + numConstsLst cs
end
mutual
  def replaceConst (a b : String) : Term → Term
    | const c => if a == c then const b else const c
    | app f cs => app f (replaceConstLst a b cs)

  def replaceConstLst (a b : String) : List Term → List Term
    | [] => []
    | c :: cs => replaceConst a b c :: replaceConstLst a b cs
end

mutual
  theorem numConsts_replaceConst (a b : String) (e : Term)
            : numConsts (replaceConst a b e) = numConsts e := by
    match e with
    | const c => simp [replaceConst]; split <;> simp [numConsts]
    | app f cs => simp [replaceConst, numConsts, numConsts_replaceConstLst a b cs]

  theorem numConsts_replaceConstLst (a b : String) (es : List Term)
            : numConstsLst (replaceConstLst a b es) = numConstsLst es := by
    match es with
    | [] => simp [replaceConstLst, numConstsLst]
    | c :: cs =>
      simp [replaceConstLst, numConstsLst, numConsts_replaceConst a b c,
            numConsts_replaceConstLst a b cs]
end

依值模式匹配

我们在模式匹配一节中考虑的所有模式匹配示例都可以很容易地使用 casesOnrecOn 来编写。然而,对于索引归纳族,如 Vector α n,通常不是这种情况,因为区分情况要对索引的值施加约束。如果没有方程编译器,我们将需要大量的样板代码来定义非常简单的函数,例如使用递归定义 mapzipunzip。为了理解其中的困难,考虑一下如何定义一个函数 tail,它接受一个向量 v : Vector α (succ n) 并删除第一个元素。首先想到的可能是使用 casesOn 函数:

inductive Vector (α : Type u) : Nat → Type u
  | nil  : Vector α 0
  | cons : α → {n : Nat} → Vector α n → Vector α (n+1)

namespace Vector

#check @Vector.casesOn
/-
  {α : Type u}
  → {motive : (a : Nat) → Vector α a → Sort v} →
  → {a : Nat} → (t : Vector α a)
  → motive 0 nil
  → ((a : α) → {n : Nat} → (a_1 : Vector α n) → motive (n + 1) (cons a a_1))
  → motive a t
-/

end Vector

但是在 nil 的情况下我们应该返回什么值呢?有趣的事情来了:如果 v 具有 Vector α (succ n) 类型,它「不能」为nil,但很难告诉 casesOn

一种解决方案是定义一个辅助函数:

inductive Vector (α : Type u) : Nat → Type u
  | nil  : Vector α 0
  | cons : α → {n : Nat} → Vector α n → Vector α (n+1)
namespace Vector
def tailAux (v : Vector α m) : m = n + 1 → Vector α n :=
  Vector.casesOn (motive := fun x _ => x = n + 1 → Vector α n) v
    (fun h : 0 = n + 1 => Nat.noConfusion h)
    (fun (a : α) (m : Nat) (as : Vector α m) =>
     fun (h : m + 1 = n + 1) =>
       Nat.noConfusion h (fun h1 : m = n => h1 ▸ as))

def tail (v : Vector α (n+1)) : Vector α n :=
  tailAux v rfl
end Vector

nil 的情况下,m 被实例化为 0noConfusion 利用了 0 = succ n 不能出现的事实。否则,v 的形式为 a :: w,我们可以简单地将 w 从长度 m 的向量转换为长度 n 的向量后返回 w

定义 tail 的困难在于维持索引之间的关系。 tailAux 中的假设 e : m = n + 1 用于传达 n 与与小前提相关的索引之间的关系。此外,zero = n + 1 的情况是不可达的,而放弃这种情况的规范方法是使用 noConfusion

然而,tail 函数很容易使用递归方程来定义,并且方程编译器会自动为我们生成所有样板代码。下面是一些类似的例子:

inductive Vector (α : Type u) : Nat → Type u
  | nil  : Vector α 0
  | cons : α → {n : Nat} → Vector α n → Vector α (n+1)
namespace Vector
def head : {n : Nat} → Vector α (n+1) → α
  | n, cons a as => a

def tail : {n : Nat} → Vector α (n+1) → Vector α n
  | n, cons a as => as

theorem eta : ∀ {n : Nat} (v : Vector α (n+1)), cons (head v) (tail v) = v
  | n, cons a as => rfl

def map (f : α → β → γ) : {n : Nat} → Vector α n → Vector β n → Vector γ n
  | 0,   nil,       nil       => nil
  | n+1, cons a as, cons b bs => cons (f a b) (map f as bs)

def zip : {n : Nat} → Vector α n → Vector β n → Vector (α × β) n
  | 0,   nil,       nil       => nil
  | n+1, cons a as, cons b bs => cons (a, b) (zip as bs)
end Vector

注意,对于「不可达」的情况,例如 head nil,我们可以省略递归方程。为索引族自动生成的定义远非直截了当。例如:

inductive Vector (α : Type u) : Nat → Type u
  | nil  : Vector α 0
  | cons : α → {n : Nat} → Vector α n → Vector α (n+1)
namespace Vector
def map (f : α → β → γ) : {n : Nat} → Vector α n → Vector β n → Vector γ n
  | 0,   nil,       nil       => nil
  | n+1, cons a as, cons b bs => cons (f a b) (map f as bs)

#print map
#print map.match_1
end Vector

tail 函数相比,map 函数手工定义更加繁琐。我们鼓励您尝试使用 recOncasesOnnoConfusion

不可访问模式

有时候,依值匹配模式中的参数对定义来说并不是必需的,但是必须包含它来适当地确定表达式的类型。Lean 允许用户将这些子项标记为「不可访问」以进行模式匹配。例如,当左侧出现的项既不是变量也不是构造子应用时,这些注解是必不可少的,因为它们不适合用于模式匹配的目标。我们可以将这种不可访问的模式视为模式的「不关心」组件。你可以通过写 .(t) 来声明子项不可访问。如果不可访问的模式可以被推断出来,你也可以写 _

下面的例子中,我们声明了一个归纳类型,它定义了「在 f 的像中」的属性。您可以将 ImageOf f b 类型的元素视为 b 位于 f 的像中的证据,构造子 imf 用于构建此类证据。然后,我们可以定义任何函数 f 的「逆」,逆函数将 f 的像中的任何元素赋给映射到它的元素。类型规则迫使我们为第一个参数写 f a,但是这个项既不是变量也不是构造子应用,并且在模式匹配定义中没有作用。为了定义下面的函数 inverse,我们必须将 f a 标记为不可访问。

inductive ImageOf {α β : Type u} (f : α → β) : β → Type u where
  | imf : (a : α) → ImageOf f (f a)

open ImageOf

def inverse {f : α → β} : (b : β) → ImageOf f b → α
  | .(f a), imf a => a

def inverse' {f : α → β} : (b : β) → ImageOf f b → α
  | _, imf a => a

在上面的例子中,不可访问记号清楚地表明 f 不是一个模式匹配变量。

不可访问模式可用于澄清和控制使用依值模式匹配的定义。考虑函数 Vector.add 的以下定义,假设该类型有满足结合律的加法函数,它将一个类型的两个元素向量相加:

inductive Vector (α : Type u) : Nat → Type u
  | nil  : Vector α 0
  | cons : α → {n : Nat} → Vector α n → Vector α (n+1)

namespace Vector

def add [Add α] : {n : Nat} → Vector α n → Vector α n → Vector α n
  | 0,   nil,       nil       => nil
  | n+1, cons a as, cons b bs => cons (a + b) (add as bs)

end Vector

参数 {n : Nat} 出现在冒号之后,因为它不能在整个定义中保持固定。在实现这个定义时,方程编译器首先区分第一个参数是 0 还是 n+1。对接下来的两个参数嵌套地区分情况,在每种情况下,方程编译器都会排除与第一个模式不兼容的情况。

但事实上,在第一个参数上不需要区分情况;当我们对第二个参数区分情况时,VectorcasesOn 消去器会自动抽象该参数,并将其替换为 0n + 1。使用不可访问的模式,我们可以提示方程编译器不要在 n 上区分情况。

inductive Vector (α : Type u) : Nat → Type u
  | nil  : Vector α 0
  | cons : α → {n : Nat} → Vector α n → Vector α (n+1)
namespace Vector
def add [Add α] : {n : Nat} → Vector α n → Vector α n → Vector α n
  | .(_), nil,       nil       => nil
  | .(_), cons a as, cons b bs => cons (a + b) (add as bs)
end Vector

将位置标记为不可访问模式首先告诉方程编译器,参数的形式应该从其他参数所构成的约束中推断出来,其次,第一个参数不应该参与模式匹配。

为简便起见,不可访问的模式 .(_) 可以写成 _

inductive Vector (α : Type u) : Nat → Type u
  | nil  : Vector α 0
  | cons : α → {n : Nat} → Vector α n → Vector α (n+1)
namespace Vector
def add [Add α] : {n : Nat} → Vector α n → Vector α n → Vector α n
  | _, nil,       nil       => nil
  | _, cons a as, cons b bs => cons (a + b) (add as bs)
end Vector

如前所述,参数 {n : Nat} 是模式匹配的一部分,因为它不能在整个定义中保持固定。在以前的 Lean 版本中,用户经常发现必须包含这些额外的判别符是很麻烦的。因此,Lean 4 实现了一个新特性, 判别精炼(discriminant refinement) ,它自动为我们包含了这些额外的判别。

inductive Vector (α : Type u) : Nat → Type u
  | nil  : Vector α 0
  | cons : α → {n : Nat} → Vector α n → Vector α (n+1)
namespace Vector
def add [Add α] {n : Nat} : Vector α n → Vector α n → Vector α n
  | nil,       nil       => nil
  | cons a as, cons b bs => cons (a + b) (add as bs)
end Vector

当与「自动绑定隐式」特性结合使用时,你可以进一步简化声明并这样写:

inductive Vector (α : Type u) : Nat → Type u
  | nil  : Vector α 0
  | cons : α → {n : Nat} → Vector α n → Vector α (n+1)
namespace Vector
def add [Add α] : Vector α n → Vector α n → Vector α n
  | nil,       nil       => nil
  | cons a as, cons b bs => cons (a + b) (add as bs)
end Vector

使用这些新特性,您可以更紧凑地编写在前几节中定义的其他向量函数,如下所示:

inductive Vector (α : Type u) : Nat → Type u
  | nil  : Vector α 0
  | cons : α → {n : Nat} → Vector α n → Vector α (n+1)
namespace Vector
def head : Vector α (n+1) → α
  | cons a as => a

def tail : Vector α (n+1) → Vector α n
  | cons a as => as

theorem eta : (v : Vector α (n+1)) → cons (head v) (tail v) = v
  | cons a as => rfl

def map (f : α → β → γ) : Vector α n → Vector β n → Vector γ n
  | nil,       nil       => nil
  | cons a as, cons b bs => cons (f a b) (map f as bs)

def zip : Vector α n → Vector β n → Vector (α × β) n
  | nil,       nil       => nil
  | cons a as, cons b bs => cons (a, b) (zip as bs)
end Vector

Match 表达式

Lean 还提供「match-with」表达式,它在很多函数式语言中都能找到。

def isNotZero (m : Nat) : Bool :=
  match m with
  | 0   => false
  | n+1 => true

这看起来与普通的模式匹配定义没有太大的不同,但关键是 match 可以在表达式中的任何地方使用,并带有任意参数。

def isNotZero (m : Nat) : Bool :=
  match m with
  | 0   => false
  | n+1 => true

def filter (p : α → Bool) : List α → List α
  | []      => []
  | a :: as =>
    match p a with
    | true => a :: filter p as
    | false => filter p as

example : filter isNotZero [1, 0, 0, 3, 0] = [1, 3] := rfl

另一例:

def foo (n : Nat) (b c : Bool) :=
  5 + match n - 5, b && c with
      | 0,   true  => 0
      | m+1, true  => m + 7
      | 0,   false => 5
      | m+1, false => m + 3

#eval foo 7 true false

example : foo 7 true false = 9 := rfl

Lean 使用内建的 match 来实现系统所有地方的模式匹配。因此,这四种定义具有相同的净效果。

def bar₁ : Nat × Nat → Nat
  | (m, n) => m + n

def bar₂ (p : Nat × Nat) : Nat :=
  match p with
  | (m, n) => m + n

def bar₃ : Nat × Nat → Nat :=
  fun (m, n) => m + n

def bar₄ (p : Nat × Nat) : Nat :=
  let (m, n) := p; m + n

这些变体在解构命题中也是同样有用的:

variable (p q : Nat → Prop)

example : (∃ x, p x) → (∃ y, q y) → ∃ x y, p x ∧ q y
  | ⟨x, px⟩, ⟨y, qy⟩ => ⟨x, y, px, qy⟩

example (h₀ : ∃ x, p x) (h₁ : ∃ y, q y)
        : ∃ x y, p x ∧ q y :=
  match h₀, h₁ with
  | ⟨x, px⟩, ⟨y, qy⟩ => ⟨x, y, px, qy⟩

example : (∃ x, p x) → (∃ y, q y) → ∃ x y, p x ∧ q y :=
  fun ⟨x, px⟩ ⟨y, qy⟩ => ⟨x, y, px, qy⟩

example (h₀ : ∃ x, p x) (h₁ : ∃ y, q y)
        : ∃ x y, p x ∧ q y :=
  let ⟨x, px⟩ := h₀
  let ⟨y, qy⟩ := h₁
  ⟨x, y, px, qy⟩

局域递归声明

可以通过 let rec 关键字定义局域递归声明。

def replicate (n : Nat) (a : α) : List α :=
  let rec loop : Nat → List α → List α
    | 0,   as => as
    | n+1, as => loop n (a::as)
  loop n []

#check @replicate.loop
-- {α : Type} → α → Nat → List α → List α

Lean 对每个 let rec创建一个辅助声明。上例中,它为出现在 replicate 中的 let rec loop 创建了一个声明 replicate.loop。注意到,Lean 通过添加任意的出现在 let rec 声明中的局域变量作为附加参数来「关闭」声明。例如,局域变量 a 出现在 let rec loop 当中。

也在策略模式中可使用 let rec 来建立归纳证明。

def replicate (n : Nat) (a : α) : List α :=
 let rec loop : Nat → List α → List α
   | 0,   as => as
   | n+1, as => loop n (a::as)
 loop n []
theorem length_replicate (n : Nat) (a : α) : (replicate n a).length = n := by
  let rec aux (n : Nat) (as : List α)
              : (replicate.loop a n as).length = n + as.length := by
    match n with
    | 0   => simp [replicate.loop]
    | n+1 => simp [replicate.loop, aux n, Nat.add_succ, Nat.succ_add]
  exact aux n []

也可以用 where 语句在定义后面引入辅助递归声明,Lean 自动把它们转译成 let rec

def replicate (n : Nat) (a : α) : List α :=
  loop n []
where
  loop : Nat → List α → List α
    | 0,   as => as
    | n+1, as => loop n (a::as)

theorem length_replicate (n : Nat) (a : α) : (replicate n a).length = n := by
  exact aux n []
where
  aux (n : Nat) (as : List α)
      : (replicate.loop a n as).length = n + as.length := by
    match n with
    | 0   => simp [replicate.loop]
    | n+1 => simp [replicate.loop, aux n, Nat.add_succ, Nat.succ_add]

练习

  1. 打开命名空间 Hidden 以避免命名冲突,并使用方程编译器定义自然数的加法、乘法和幂运算。然后用方程编译器派生出它们的一些基本属性。

  2. 类似地,使用方程编译器定义列表上的一些基本操作(如 reverse 函数),并通过归纳法证明关于列表的定理(例如对于任何列表 xsreverse (reverse xs) = xs )。

  3. 定义您自己的函数来对自然数执行值的过程递归。同样,看看你是否能弄清楚如何定义 WellFounded.fix

  4. 按照依值模式匹配中的例子,定义一个追加(append)两个向量的函数。提示:你必须定义一个辅助函数。

  5. 考虑以下类型的算术表达式。这个想法是,var n 是一个变量 vₙconst n 是一个常量,它的值是 n

inductive Expr where
  | const : Nat → Expr
  | var : Nat → Expr
  | plus : Expr → Expr → Expr
  | times : Expr → Expr → Expr
  deriving Repr

open Expr

def sampleExpr : Expr :=
  plus (times (var 0) (const 7)) (times (const 2) (var 1))

此处 sampleExpr 表示 (v₀ * 7) + (2 * v₁)

写一个函数来计算这些表达式,对每个 var n 赋值 v n.

inductive Expr where
  | const : Nat → Expr
  | var : Nat → Expr
  | plus : Expr → Expr → Expr
  | times : Expr → Expr → Expr
  deriving Repr
open Expr
def sampleExpr : Expr :=
  plus (times (var 0) (const 7)) (times (const 2) (var 1))
def eval (v : Nat → Nat) : Expr → Nat
  | const n     => sorry
  | var n       => v n
  | plus e₁ e₂  => sorry
  | times e₁ e₂ => sorry

def sampleVal : Nat → Nat
  | 0 => 5
  | 1 => 6
  | _ => 0

-- 如果答案是47说明你写的对。
-- #eval eval sampleVal sampleExpr

实现「常数融合」,这是一个将 5 + 7 等子术语化简为 12 的过程。使用辅助函数 simpConst,定义一个函数「fuse」:为了化简加号或乘号,首先递归地化简参数,然后应用 simpConst 尝试化简结果。

inductive Expr where
  | const : Nat → Expr
  | var : Nat → Expr
  | plus : Expr → Expr → Expr
  | times : Expr → Expr → Expr
  deriving Repr
open Expr
def eval (v : Nat → Nat) : Expr → Nat
  | const n     => sorry
  | var n       => v n
  | plus e₁ e₂  => sorry
  | times e₁ e₂ => sorry
def simpConst : Expr → Expr
  | plus (const n₁) (const n₂)  => const (n₁ + n₂)
  | times (const n₁) (const n₂) => const (n₁ * n₂)
  | e                           => e

def fuse : Expr → Expr := sorry

theorem simpConst_eq (v : Nat → Nat)
        : ∀ e : Expr, eval v (simpConst e) = eval v e :=
  sorry

theorem fuse_eq (v : Nat → Nat)
        : ∀ e : Expr, eval v (fuse e) = eval v e :=
  sorry

最后两个定理表明,定义保持值不变。

结构体和记录

我们已经看到Lean 的基本系统包括归纳类型。此外,显然仅基于类型宇宙、依赖箭头类型和归纳类型,就有可能构建一个坚实的数学大厦;其他的一切都是由此而来。Lean 标准库包含许多归纳类型的实例(例如,NatProdList),甚至逻辑连接词也是使用归纳类型定义的。

回忆一下,只包含一个构造子的非递归归纳类型被称为 结构体(structure)记录(record) 。乘积类型是一种结构体,依值乘积(Sigma)类型也是如此。一般来说,每当我们定义一个结构体 S 时,我们通常定义投影(projection)函数来「析构」(destruct)S 的每个实例并检索存储在其字段中的值。prod.pr1prod.pr2,分别返回乘积对中的第一个和第二个元素的函数,就是这种投影的例子。

在编写程序或形式化数学时,定义包含许多字段的结构体是很常见的。Lean 中可用 structure 命令实现此过程。当我们使用这个命令定义一个结构体时,Lean 会自动生成所有的投影函数。structure 命令还允许我们根据以前定义的结构体定义新的结构体。此外,Lean 为定义给定结构体的实例提供了方便的符号。

声明结构体

结构体命令本质上是定义归纳数据类型的「前端」。每个 structure 声明都会引入一个同名的命名空间。一般形式如下:

    structure <name> <parameters> <parent-structures> where
      <constructor> :: <fields>

大多数部分不是必要的。例子:

structure Point (α : Type u) where
  mk :: (x : α) (y : α)

类型 Point 的值是使用 Point.mk a b 创建的,并且点 p 的字段可以使用 Point.x pPoint.y p。结构体命令还生成有用的递归器和定理。下面是为上述声明生成的一些结构体方法。

structure Point (α : Type u) where
 mk :: (x : α) (y : α)
#check Point       -- 类型
#check @Point.rec  -- 消去器(eliminator)
#check @Point.mk   -- 构造子
#check @Point.x    -- 投影
#check @Point.y    -- 投影

如果没有提供构造子名称,则默认的构造函数名为 mk。如果在每个字段之间添加换行符,也可以避免字段名周围的括号。

structure Point (α : Type u) where
  x : α
  y : α

下面是一些使用生成的结构的简单定理和表达式。像往常一样,您可以通过使用命令 open Point 来避免前缀 Point

structure Point (α : Type u) where
 x : α
 y : α
#eval Point.x (Point.mk 10 20)
#eval Point.y (Point.mk 10 20)

open Point

example (a b : α) : x (mk a b) = a :=
  rfl

example (a b : α) : y (mk a b) = b :=
  rfl

给定 p : Point Nat,符号 p.xPoint.x p 的缩写。这提供了一种方便的方式来访问结构体的字段。

structure Point (α : Type u) where
 x : α
 y : α
def p := Point.mk 10 20

#check p.x  -- Nat
#eval p.x   -- 10
#eval p.y   -- 20

点记号不仅方便于访问记录的投影,而且也方便于应用同名命名空间中定义的函数。回想一下合取一节,如果 p 具有 Point 类型,那么表达式 p.foo 被解释为 Point.foo p,假设 foo 的第一个非隐式参数具有类型 Point,表达式 p.add q 因此是 Point.add p q 的缩写。可见下面的例子。

structure Point (α : Type u) where
  x : α
  y : α
  deriving Repr

def Point.add (p q : Point Nat) :=
  mk (p.x + q.x) (p.y + q.y)

def p : Point Nat := Point.mk 1 2
def q : Point Nat := Point.mk 3 4

#eval p.add q  -- {x := 4, y := 6}

在下一章中,您将学习如何定义一个像 add 这样的函数,这样它就可以通用地为 Point α 的元素工作,而不仅仅是 Point Nat,只要假设 α 有一个关联的加法操作。

更一般地,给定一个表达式 p.foo x y z 其中p : Point,Lean 会把 pPoint 为类型插入到 Point.foo 的第一个参数。例如,下面是标量乘法的定义,p.smul 3 被解释为 Point.smul 3 p

structure Point (α : Type u) where
 x : α
 y : α
 deriving Repr
def Point.smul (n : Nat) (p : Point Nat) :=
  Point.mk (n * p.x) (n * p.y)

def p : Point Nat := Point.mk 1 2

#eval p.smul 3  -- {x := 3, y := 6}

List.map 函数使用类似的技巧很常用。它接受一个列表作为它的第二个非隐式参数:

#check @List.map

def xs : List Nat := [1, 2, 3]
def f : Nat → Nat := fun x => x * x

#eval xs.map f  -- [1, 4, 9]

此处 xs.map f 被解释为 List.map f xs

对象

我们一直在使用构造子创建结构体类型的元素。对于包含许多字段的结构,这通常是不方便的,因为我们必须记住字段定义的顺序。因此,Lean 为定义结构体类型的元素提供了以下替代符号。

    { (<field-name> := <expr>)* : structure-type }
    or
    { (<field-name> := <expr>)* }

只要可以从期望的类型推断出结构体的名称,后缀 : structure-type 就可以省略。例如,我们使用这种表示法来定义「Point」。字段的指定顺序无关紧要,因此下面的所有表达式定义相同的Point。

structure Point (α : Type u) where
  x : α
  y : α

#check { x := 10, y := 20 : Point Nat }  -- Point ℕ
#check { y := 20, x := 10 : Point _ }
#check ({ x := 10, y := 20 } : Point Nat)

example : Point Nat :=
  { y := 20, x := 10 }

如果一个字段的值没有指定,Lean 会尝试推断它。如果不能推断出未指定的字段,Lean 会标记一个错误,表明相应的占位符无法合成。

structure MyStruct where
    {α : Type u}
    {β : Type v}
    a : α
    b : β

#check { a := 10, b := true : MyStruct }

记录更新(Record update) 是另一个常见的操作,相当于通过修改旧记录中的一个或多个字段的值来创建一个新的记录对象。通过在字段赋值之前添加注释 s with,Lean 允许您指定记录规范中未赋值的字段,该字段应从之前定义的结构对象 s 中获取。如果提供了多个记录对象,那么将按顺序访问它们,直到Lean 找到一个包含未指定字段的记录对象。如果在访问了所有对象之后仍未指定任何字段名,Lean 将引发错误。

structure Point (α : Type u) where
  x : α
  y : α
  deriving Repr

def p : Point Nat :=
  { x := 1, y := 2 }

#eval { p with y := 3 }  -- { x := 1, y := 3 }
#eval { p with x := 4 }  -- { x := 4, y := 2 }

structure Point3 (α : Type u) where
  x : α
  y : α
  z : α

def q : Point3 Nat :=
  { x := 5, y := 5, z := 5 }

def r : Point3 Nat :=
  { p, q with x := 6 }

example : r.x = 6 := rfl
example : r.y = 2 := rfl
example : r.z = 5 := rfl

继承

我们可以通过添加新的字段来 扩展 现有的结构体。这个特性允许我们模拟一种形式的 继承

structure Point (α : Type u) where
  x : α
  y : α

inductive Color where
  | red | green | blue

structure ColorPoint (α : Type u) extends Point α where
  c : Color

在下一个例子中,我们使用多重继承定义一个结构体,然后使用父结构的对象定义一个对象。

structure Point (α : Type u) where
  x : α
  y : α
  z : α

structure RGBValue where
  red : Nat
  green : Nat
  blue : Nat

structure RedGreenPoint (α : Type u) extends Point α, RGBValue where
  no_blue : blue = 0

def p : Point Nat :=
  { x := 10, y := 10, z := 20 }

def rgp : RedGreenPoint Nat :=
  { p with red := 200, green := 40, blue := 0, no_blue := rfl }

example : rgp.x   = 10 := rfl
example : rgp.red = 200 := rfl

类型类

类型类(Type Class) 作为一种原则性方法引入, 是为了在函数式编程语言中支持 特设多态(Ad-hoc Polymorphism) 。 我们首先观察到,如果函数简单地接受特定类型的实现作为参数, 然后在其余参数上调用该实现,则很容易实现特设多态函数(如加法)。 例如,假设我们在 Lean 中声明一个结构体来保存加法的实现:

namespace Ex
structure Add (a : Type) where
  add : a → a → a

#check @Add.add
-- Add.add : {a : Type} → Add a → a → a → a
end Ex

在上面 Lean 代码中,字段 add 的类型为 Add.add : {a : Type} → Add a → a → a → a 其中类型 a 周围的大括号表示它是一个隐式参数。我们可以通过以下方式实现 double

namespace Ex
structure Add (a : Type) where
 add : a → a → a
def double (s : Add a) (x : a) : a :=
  s.add x x

#eval double { add := Nat.add } 10
-- 20

#eval double { add := Nat.mul } 10
-- 100

#eval double { add := Int.add } 10
-- 20
end Ex

注意,你可以用 double { add := Nat.add } n 使一个自然数 n 翻倍。 当然,以这种方式让用户手动四处传递实现会非常繁琐。 实际上,这会消除掉特设多态的大部分潜在好处。

类型类的主要思想是使诸如 Add a 之类的参数变为隐含的, 并使用用户定义实例的数据库通过称为类型类解析的过程自动合成所需的实例。 在 Lean 中,通过在以上示例中将 structure 更改为 classAdd.add 的类型会变为:

namespace Ex
class Add (a : Type) where
  add : a → a → a

#check @Add.add
-- Add.add : {a : Type} → [self : Add a] → a → a → a
end Ex

其中方括号表示类型为 Add a 的参数是 实例隐式的 , 即,它应该使用类型类解析合成。这个版本的 add 是 Haskell 项 add :: Add a => a -> a -> a 的 Lean 类比。 同样,我们可以通过以下方式注册实例:

namespace Ex
class Add (a : Type) where
 add : a → a → a
instance : Add Nat where
  add := Nat.add

instance : Add Int where
  add := Int.add

instance : Add Float where
  add := Float.add
end Ex

接着对于 n : Natm : Nat,项 Add.add n m 触发了类型类解析, 目标为 Add Nat,且类型类解析将综合上面 Nat 的实例。 现在,我们可以通过隐式的实例重新实现 double 了:

namespace Ex
class Add (a : Type) where
  add : a → a → a
instance : Add Nat where
 add := Nat.add
instance : Add Int where
 add := Int.add
instance : Add Float where
 add := Float.add
def double [Add a] (x : a) : a :=
  Add.add x x

#check @double
-- @double : {a : Type} → [inst : Add a] → a → a

#eval double 10
-- 20

#eval double (10 : Int)
-- 100

#eval double (7 : Float)
-- 14.000000

#eval double (239.0 + 2)
-- 482.000000

end Ex

一般情况下,实例可能以复杂的方式依赖于其他实例。例如,你可以声明一个(匿名)实例, 说明如果 a 存在加法,那么 Array a 也存在加法:

instance [Add a] : Add (Array a) where
  add x y := Array.zipWith x y (· + ·)

#eval Add.add #[1, 2] #[3, 4]
-- #[4, 6]

#eval #[1, 2] + #[3, 4]
-- #[4, 6]

请注意,(· + ·) 是 Lean 中 fun x y => x + y 的记法。

上述示例演示了类型类如何用于重载符号。现在,我们探索另一个应用程序。 我们经常需要给定类型的任意元素。回想一下类型在 Lean 中可能没有任何元素。 我们经常希望在一个「边界情况」下定义返回一个任意元素。 例如,我们可能希望当 xsList a 类型时 head xs 表达式的类型为 a。 类似地,许多定理在类型不为空的附加假设下成立。例如,如果 a 是一个类型, 则 exists x : a, x = x 仅在 a 不为空时为真。标准库定义了一个类型类 Inhabited,它能够让类型类推理来推断 可居(Inhabited) 类型类的「默认」元素。 让我们从上述程序的第一步开始,声明一个适当的类:

namespace Ex
class Inhabited (a : Type u) where
  default : a

#check @Inhabited.default
-- Inhabited.default : {a : Type u} → [self : Inhabited a] → a
end Ex

注意 Inhabited.default 没有任何显式参数。

Inhabited a 的某个元素只是形式为 Inhabited.mk x 的表达式, 其中 x : a 为某个元素。投影 Inhabited.default 可让我们从 Inhabited a 的某个元素中「提取」出 a 的某个元素。现在我们用一些实例填充该类:

namespace Ex
class Inhabited (a : Type _) where
 default : a
instance : Inhabited Bool where
  default := true

instance : Inhabited Nat where
  default := 0

instance : Inhabited Unit where
  default := ()

instance : Inhabited Prop where
  default := True

#eval (Inhabited.default : Nat)
-- 0

#eval (Inhabited.default : Bool)
-- true
end Ex

你可以用 export 命令来为 Inhabited.default 创建别名 default

namespace Ex
class Inhabited (a : Type _) where
 default : a
instance : Inhabited Bool where
 default := true
instance : Inhabited Nat where
 default := 0
instance : Inhabited Unit where
 default := ()
instance : Inhabited Prop where
 default := True
export Inhabited (default)

#eval (default : Nat)
-- 0

#eval (default : Bool)
-- true
end Ex

链接实例

以类型类推断的层面来看,它并不那么令人印象深刻; 它不过是一种为精细器存储实例列表的机制,用于在查询表中查找。 类型类推断变得强大的原因在于,它能够「链接(Chain)」实例。也就是说, 实例声明本身可以依赖类型类的隐式实例。 这导致类推断递归地通过实例进行链接,并在必要时回溯,就像 Prolog 中的搜索一样。

-->

例如,以下定义展示了若两个类型 ab 包含元素,则二者的积也包含元素:

instance [Inhabited a] [Inhabited b] : Inhabited (a × b) where
  default := (default, default)

将它添加到先前的实例声明后,类型类实例就能推导了,例如 Nat × Bool 的默认元素为:

namespace Ex
class Inhabited (a : Type u) where
 default : a
instance : Inhabited Bool where
 default := true
instance : Inhabited Nat where
 default := 0
opaque default [Inhabited a] : a :=
 Inhabited.default
instance [Inhabited a] [Inhabited b] : Inhabited (a × b) where
  default := (default, default)

#eval (default : Nat × Bool)
-- (0, true)
end Ex

与此类似,我们可以使用合适的常量函数使其居留到类型函数中:

instance [Inhabited b] : Inhabited (a → b) where
  default := fun _ => default

作为练习,请尝试为其他类型定义默认实例,例如 ListSum 类型。

Lean 标准库包含了定义 inferInstance,它的类型为 {α : Sort u} → [i : α] → α, 它在期望的类型是一个实例时触发类型类解析过程十分有用。

#check (inferInstance : Inhabited Nat) -- Inhabited Nat

def foo : Inhabited (Nat × Nat) :=
  inferInstance

theorem ex : foo.default = (default, default) :=
  rfl

你可以使用命令 #print 来检查 inferInstance 有多简单。

#print inferInstance

ToString 方法

多态方法 toString 类型为 {α : Type u} → [ToString α] → α → String。 你可以为自己的类型实现实例并使用链接将复杂的值转换为字符串。 Lean 为大多数内置类型都提供了 ToString 实例。

structure Person where
  name : String
  age  : Nat

instance : ToString Person where
  toString p := p.name ++ "@" ++ toString p.age

#eval toString { name := "Leo", age := 542 : Person }
#eval toString ({ name := "Daniel", age := 18 : Person }, "hello")

数值

数值在 Lean 中是多态的。你可以用一个数值(例如 2)来表示任何实现了类型类 OfNat 的类型中的一个元素。

structure Rational where
  num : Int
  den : Nat
  inv : den ≠ 0

instance : OfNat Rational n where
  ofNat := { num := n, den := 1, inv := by decide }

instance : ToString Rational where
  toString r := s!"{r.num}/{r.den}"

#eval (2 : Rational) -- 2/1

#check (2 : Rational) -- Rational
#check (2 : Nat)      -- Nat

Lean 会将项 (2 : Nat)(2 : Rational) 分别繁饰(Elaborate)为: OfNat.ofNat Nat 2 (instOfNatNat 2)OfNat.ofNat Rational 2 (instOfNatRational 2)。 我们将繁饰的项中出现的数字 2 称为 原始 自然数。 你可以使用宏 nat_lit 2 来输入原始自然数 2

#check nat_lit 2  -- Nat

原始自然数 不是 多态的。

OfNat 实例对数值进行了参数化,因此你可以定义特定数字的实例。 第二个参数通常是变量,如上例所示,或者是一个 原始 自然数。

class Monoid (α : Type u) where
  unit : α
  op   : α → α → α

instance [s : Monoid α] : OfNat α (nat_lit 1) where
  ofNat := s.unit

def getUnit [Monoid α] : α :=
  1

输出参数

默认情况下,Lean 仅当项 T 已知时且不包含缺失部分时,会尝试合成实例 Inhabited T。 以下命令会产生错「typeclass instance problem is stuck, it is often due to metavariables ?m.7 (类型类实例问题卡住了,通常是由于元变量 ?m.7 引起的)」因为该类型有缺失的部分(即 _)。

#check_failure (inferInstance : Inhabited (Nat × _))

你可以将类型类 Inhabited 的参数视为类型类合成器的 输入 值。 当类型类有多个参数时,可以将其中一些标记为输出参数。 即使这些参数有缺失部分,Lean 也会开始类型类合成。 在下面的示例中,我们使用输出参数定义一个 异质(Heterogeneous) 的多态乘法。

namespace Ex
class HMul (α : Type u) (β : Type v) (γ : outParam (Type w)) where
  hMul : α → β → γ

export HMul (hMul)

instance : HMul Nat Nat Nat where
  hMul := Nat.mul

instance : HMul Nat (Array Nat) (Array Nat) where
  hMul a bs := bs.map (fun b => hMul a b)

#eval hMul 4 3           -- 12
#eval hMul 4 #[2, 3, 4]  -- #[8, 12, 16]
end Ex

参数 αβ 会被视为输入参数,γ 被视为输出参数。 如果给定一个应用 hMul a b,那么在知道 ab 的类型后, 将调用类型类合成器,并且可以从输出参数 γ 中获得最终的类型。 在上文中的示例中,我们定义了两个实例。第一个实例是针对自然数的同质乘法。 第二个实例是针对数组的标量乘法。请注意,你可以链接实例,并推广第二个实例。

namespace Ex
class HMul (α : Type u) (β : Type v) (γ : outParam (Type w)) where
  hMul : α → β → γ

export HMul (hMul)

instance : HMul Nat Nat Nat where
  hMul := Nat.mul

instance : HMul Int Int Int where
  hMul := Int.mul

instance [HMul α β γ] : HMul α (Array β) (Array γ) where
  hMul a bs := bs.map (fun b => hMul a b)

#eval hMul 4 3                    -- 12
#eval hMul 4 #[2, 3, 4]           -- #[8, 12, 16]
#eval hMul (-2) #[3, -1, 4]       -- #[-6, 2, -8]
#eval hMul 2 #[#[2, 3], #[0, 4]]  -- #[#[4, 6], #[0, 8]]
end Ex

当你拥有 HMul α β γ 的实例时,可以在类型为 Array β 的数组上将其使用标量类型 α 的新标量数组乘法实例。在最后的 #eval 中,请注意该实例曾在数组数组中使用了两次。

Default Instances

在类 HMul 中,参数 αβ 被当做输入值。 因此,类型类合成仅在已知这两种类型时才开始。这通常可能过于严格。

namespace Ex
class HMul (α : Type u) (β : Type v) (γ : outParam (Type w)) where
  hMul : α → β → γ

export HMul (hMul)

instance : HMul Int Int Int where
  hMul := Int.mul

def xs : List Int := [1, 2, 3]

-- Error "typeclass instance problem is stuck, it is often due to metavariables HMul ?m.89 ?m.90 ?m.91"
#check_failure fun y => xs.map (fun x => hMul x y)
end Ex

实例 HMul 没有被 Lean 合成,因为没有提供 y 的类型。 然而,在这种情况下,自然应该认为 yx 的类型应该相同。 我们可以使用 默认实例 来实现这一点。

namespace Ex
class HMul (α : Type u) (β : Type v) (γ : outParam (Type w)) where
  hMul : α → β → γ

export HMul (hMul)

@[default_instance]
instance : HMul Int Int Int where
  hMul := Int.mul

def xs : List Int := [1, 2, 3]

#check fun y => xs.map (fun x => hMul x y)  -- Int → List Int
end Ex

通过给上述实例添加 default_instance 属性,我们指示 Lean 在挂起的类型类合成问题中使用此实例。 实际的 Lean 实现为算术运算符定义了同质和异质类。此外,a+ba*ba-ba/ba%b 是异质版本的记法。实例 OfNat Nat nOfNat 类的默认实例(优先级 100)。 这就是当预期类型未知时,数字 2 具有类型 Nat 的原因。 你可以定义具有更高优先级的默认实例来覆盖内置实例。

structure Rational where
  num : Int
  den : Nat
  inv : den ≠ 0

@[default_instance 200]
instance : OfNat Rational n where
  ofNat := { num := n, den := 1, inv := by decide }

instance : ToString Rational where
  toString r := s!"{r.num}/{r.den}"

#check 2 -- Rational

优先级也适用于控制不同默认实例之间的交互。例如,假设 xs 有类型 List α。 在繁饰 xs.map (fun x => 2 * x) 时,我们希望乘法的同质实例比 OfNat 的默认实例具有更高的优先级。当我们仅实现了实例 HMul α α α,而未实现 HMul Nat α α 时, 这一点尤为重要。现在,我们展示了 a*b 记法在 Lean 中是如何定义的。

namespace Ex
class OfNat (α : Type u) (n : Nat) where
  ofNat : α

@[default_instance]
instance (n : Nat) : OfNat Nat n where
  ofNat := n

class HMul (α : Type u) (β : Type v) (γ : outParam (Type w)) where
  hMul : α → β → γ

class Mul (α : Type u) where
  mul : α → α → α

@[default_instance 10]
instance [Mul α] : HMul α α α where
  hMul a b := Mul.mul a b

infixl:70 " * " => HMul.hMul
end Ex

Mul 类是仅实现了同质乘法的类型的简便记法。

局部实例

类型类是使用 Lean 中的属性(Attribute)来实现的。因此,你可以使用 local 修饰符表明它们只对当前 sectionnamespace 关闭之前或当前文件结束之前有效。

structure Point where
  x : Nat
  y : Nat

section

local instance : Add Point where
  add a b := { x := a.x + b.x, y := a.y + b.y }

def double (p : Point) :=
  p + p

end -- instance `Add Point` is not active anymore

-- def triple (p : Point) :=
--  p + p + p  -- Error: failed to synthesize instance

你也可使用 attribute 命令暂时禁用一个实例,直至当前的 sectionnamespace 关闭,或直到当前文件的结尾。

structure Point where
  x : Nat
  y : Nat

instance addPoint : Add Point where
  add a b := { x := a.x + b.x, y := a.y + b.y }

def double (p : Point) :=
  p + p

attribute [-instance] addPoint

-- def triple (p : Point) :=
--  p + p + p  -- Error: failed to synthesize instance

我们建议你只使用此命令来诊断问题。

作用于实例

你可以在命名空间中声明作用域实例。这种类型的实例只在你进入命名空间或打开命名空间时激活。

structure Point where
  x : Nat
  y : Nat

namespace Point

scoped instance : Add Point where
  add a b := { x := a.x + b.x, y := a.y + b.y }

def double (p : Point) :=
  p + p

end Point
-- instance `Add Point` is not active anymore

-- #check fun (p : Point) => p + p + p  -- Error

namespace Point
-- instance `Add Point` is active again
#check fun (p : Point) => p + p + p

end Point

open Point -- activates instance `Add Point`
#check fun (p : Point) => p + p + p

你可以使用 open scoped <namespace> 命令来激活作用于内的属性,但不会「打开」名称空间中的名称。

structure Point where
  x : Nat
  y : Nat

namespace Point

scoped instance : Add Point where
  add a b := { x := a.x + b.x, y := a.y + b.y }

def double (p : Point) :=
  p + p

end Point

open scoped Point -- activates instance `Add Point`
#check fun (p : Point) => p + p + p

-- #check fun (p : Point) => double p -- Error: unknown identifier 'double'

可判定的命题

让我们考虑标准库中定义的另一个类型类,名为 Decidable 类型类。 粗略地讲,对于 Prop 的一个元素,如果我们可以判定它是真或假,它就被称为可判定的。 这种区别只有在构造性数学中才有用;在经典数学中,每个命题都是可判定的。 但如果我们使用经典原则,比如通过情况来定义一个函数,那么这个函数将不可计算。 从算法上来讲,Decidable 类型类可以用来推导出一个过程,它能有效判定命题是否为真。 因此,该类型类支持这样的计算性定义(如果它们是可能的), 同时还允许平滑地过渡到经典定义和经典推理的使用。

在标准库中,Decidable 的形式化定义如下:

namespace Hidden
class inductive Decidable (p : Prop) where
  | isFalse (h : ¬p) : Decidable p
  | isTrue  (h : p)  : Decidable p
end Hidden

从逻辑上讲,拥有一个元素 t : Decidable p 比拥有一个元素 t : p ∨ ¬p 更强; 它允许我们定义一个任意类型的的值,这些值取决于 p 的真值。 例如,为了使表达式 if p then a else b 有意义,我们需要知道 p 是可判定的。该表达式是 ite p a b 的语法糖,其中 ite 的定义如下:

namespace Hidden
def ite {α : Sort u} (c : Prop) [h : Decidable c] (t e : α) : α :=
  Decidable.casesOn (motive := fun _ => α) h (fun _ => e) (fun _ => t)
end Hidden

标准库中还包含 ite 的一种变体,称为 dite, 即依赖 if-then-else 表达式。它的定义如下:

namespace Hidden
def dite {α : Sort u} (c : Prop) [h : Decidable c] (t : c → α) (e : Not c → α) : α :=
  Decidable.casesOn (motive := fun _ => α) h e t
end Hidden

即在 dite c t e 表达式中,我们可以在 then 分支假定 hc : c,在 else 分支假定 hnc : ¬ c。为了方便 dite 的使用, Lean 允许我们将 if h : c then t else e 写作 dite c (λ h : c => t) (λ h : ¬ c => e)

如果没有经典逻辑,我们就不能证明每个命题都是可判定的。 但我们可以证明 某些 命题是可判定的。 例如,我们可以证明基本运算(比如自然数和整数上的等式和比较)的可判定性。 此外,命题连词下的可判定性被保留了下来:

#check @instDecidableAnd
  -- {p q : Prop} → [Decidable p] → [Decidable q] → Decidable (And p q)

#check @instDecidableOr
#check @instDecidableNot

因此我们可以按照自然数上的可判定谓词的情况给出定义:

def step (a b x : Nat) : Nat :=
  if x < a ∨ x > b then 0 else 1

set_option pp.explicit true
#print step

打开隐式参数显示,繁饰器已经推断出了命题 x < a ∨ x > b 的可判定性, 只需应用适当的实例即可。

使用经典公理,我们可以证明每个命题都是可判定的。 你可以导入经典公理,并通过打开 Classical 命名空间来提供可判定的通用实例。

open Classical

之后 Decidable p 就会拥有任何 p 的实例。 因此,当你想进行经典推理时,库中的所有依赖于可判定假设的定理都会免费提供。 在公理和计算一章中, 我们将看到,使用排中律来定义函数会阻止它们被计算性地使用。 因此,标准库将 propDecidable 实例的优先级设为低。

namespace Hidden
open Classical
noncomputable scoped
instance (priority := low) propDecidable (a : Prop) : Decidable a :=
  choice <| match em a with
    | Or.inl h => ⟨isTrue h⟩
    | Or.inr h => ⟨isFalse h⟩
end Hidden

这能保证 Lean 会优先采用其他实例,只有在推断可判定性失败后才退回到 propDecidable

Decidable 类型类还为定理证明提供了一点小规模的自动化。 标准库引入了使用 Decidable 实例解决简单目标的策略 decide

example : 10 < 5 ∨ 1 > 0 := by
  decide

example : ¬ (True ∧ False) := by
  decide

example : 10 * 20 = 200 := by
  decide

theorem ex : True ∧ 2 = 1+1 := by
  decide

#print ex
-- theorem ex : True ∧ 2 = 1 + 1 :=
-- of_decide_eq_true (Eq.refl true)

#check @of_decide_eq_true
-- ∀ {p : Prop} [Decidable p], decide p = true → p

#check @decide
-- (p : Prop) → [Decidable p] → Bool

它们的工作方式如下:表达式 decide p 尝试推断 p 的决策过程,如果成功, 则会求值为 truefalse。特别是,如果 p 是一个为真的封闭表达式, decide p 将根据定义化简未为布尔值 true。在假设 decide p = true 成立的情况下,of_decide_eq_true 会生成 p 的证明。 策略 decide 将所有这些组合在一起以证明目标 p。根据前面的观察, 只要推断出的决策过程拥有足够的信息,可以根据定义将 c 求值为 isTrue 的情况, 那么 decide 就会成功。

类型类推断的管理

如果你需要使用类型类推断来提供一个 Lean 可以推断的表达式, 那么你可以使用 inferInstance 让 Lean 执行推断:

def foo : Add Nat := inferInstance
def bar : Inhabited (Nat → Nat) := inferInstance

#check @inferInstance
-- {α : Sort u} → [α] → α

你可以使用 Lean 中的 (t : T) 语法指定你正在寻找的类的实例, 这是一种很简洁的方式:

#check (inferInstance : Add Nat)

你也可以使用辅助定义 inferInstanceAs

#check inferInstanceAs (Add Nat)

#check @inferInstanceAs
-- (α : Sort u) → [α] → α

有时 Lean 会找不到一个实例,因为该类被定义所掩盖。例如,Lean 无法 找到 Inhabited (Set α) 的一个实例。我们可以显式地声明一个:

def Set (α : Type u) := α → Prop

-- fails
-- example : Inhabited (Set α) :=
--  inferInstance

instance : Inhabited (Set α) :=
  inferInstanceAs (Inhabited (α → Prop))

有时,你可能会发现类型类推断未找到预期的实例,或者更糟的是,陷入无限循环并超时。 为了在这些情况下帮助调试,Lean 可以让你请求搜索的跟踪:

set_option trace.Meta.synthInstance true

如果你使用的是 VS Code,可以通过将鼠标悬停在相关的定理或定义上, 或按 Ctrl-Shift-Enter 打开消息窗口来阅读结果。在 Emacs 中, 你可以使用 C-c C-x 在你的文件中运行一个独立的 Lean 进程, 并且在每次触发类型类解析过程时,输出缓冲区都会显示一个跟踪。

使用以下选项,你还可以限制搜索:

set_option synthInstance.maxHeartbeats 10000
set_option synthInstance.maxSize 400

选项 synthInstance.maxHeartbeats 指定每个类型类解析问题可能出现的心跳(Heartbeat)次数上限。 心跳是(小)内存分配的次数(以千为单位),0 表示没有上限。 选项 synthInstance.maxSize 是用于构建类型类实例合成过程中解的实例个数。

另外请记住,在 VS Code 和 Emacs 编辑器模式中,制表符补全也可用于 set_option,它可以帮助你查找合适的选项。

如上所述,给定语境中的类型类实例代表一个类似 Prolog 的程序,它会进行回溯搜索。 同时程序的效率和找到的解都取决于系统尝试实例的顺序。最后声明的实例首先尝试。 此外,如果在其它模块中声明了实例,它们尝试的顺序取决于打开名称空间的顺序。 在后面打开的名称空间中声明的实例,会更早尝试。

你可以按对类型类实例进行尝试的顺序来更改这些实例, 方法是为它们分配一个 优先级 。在声明实例时, 它将被分配一个默认优先级值。在定义实例时,你可以分配其他的优先级。 以下示例说明了如何执行此操作:

class Foo where
  a : Nat
  b : Nat

instance (priority := default+1) i1 : Foo where
  a := 1
  b := 1

instance i2 : Foo where
  a := 2
  b := 2

example : Foo.a = 1 :=
  rfl

instance (priority := default+2) i3 : Foo where
  a := 3
  b := 3

example : Foo.a = 3 :=
  rfl

使用类型泛型进行强制转换

最基本的强制转换将一种类型的元素映射到另一种类型。 例如,从 NatInt 的强制转换允许我们将任何元素 n : Nat 视作元素 Int。 但一些强制转换依赖于参数;例如,对于任何类型 α,我们可以将任何元素 as : List α 视为 Set α 的元素,即,列表中出现的元素组成的集合。 相应的强制转换被定义在 List α 的「类型族(Type Family)」上,由 α 参数化。

Lean 允许我们声明三类强制转换:

  • 从一个类型族到另一个类型族
  • 从一个类型族到种类(Sort)的类
  • 从一个类型族到函数类型的类

第一种强制转换允许我们将源类型族任何成员的元素视为目标类型族中对应成员的元素。 第二种强制转换允许我们将源类型族任何成员的元素视为类型。 第三种强制转换允许我们将源类型族任何成员的元素视为函数。 让我们逐一考虑这些。

在 Lean 中,强制转换在类型类解析框架的基础上实现。我们通过声明 Coe α β 的实例, 定义从 αβ 的强制转换。例如,以下内容可以定义从 BoolProp 的强制转换:

instance : Coe Bool Prop where
  coe b := b = true

这使得我们可以在 if-then-else 表达式中使用布尔项:

#eval if true then 5 else 3
#eval if false then 5 else 3

我们可以定义一个从 List αSet α 的强制转换,如下所示:

def Set (α : Type u) := α → Prop
def Set.empty {α : Type u} : Set α := fun _ => False
def Set.mem (a : α) (s : Set α) : Prop := s a
def Set.singleton (a : α) : Set α := fun x => x = a
def Set.union (a b : Set α) : Set α := fun x => a x ∨ b x
notation "{ " a " }" => Set.singleton a
infix:55 " ∪ " => Set.union
def List.toSet : List α → Set α
  | []    => Set.empty
  | a::as => {a} ∪ as.toSet

instance : Coe (List α) (Set α) where
  coe a := a.toSet

def s : Set Nat := {1}
#check s ∪ [2, 3]
-- s ∪ List.toSet [2, 3] : Set Nat

我们可以使用符号 在特定位置强制引入强制转换。 这也有助于明确我们的意图,并解决强制转换解析系统中的限制。

def Set (α : Type u) := α → Prop
def Set.empty {α : Type u} : Set α := fun _ => False
def Set.mem (a : α) (s : Set α) : Prop := s a
def Set.singleton (a : α) : Set α := fun x => x = a
def Set.union (a b : Set α) : Set α := fun x => a x ∨ b x
notation "{ " a " }" => Set.singleton a
infix:55 " ∪ " => Set.union
def List.toSet : List α → Set α
  | []    => Set.empty
  | a::as => {a} ∪ as.toSet
instance : Coe (List α) (Set α) where
  coe a := a.toSet
def s : Set Nat := {1}

#check let x := ↑[2, 3]; s ∪ x
-- let x := List.toSet [2, 3]; s ∪ x : Set Nat
#check let x := [2, 3]; s ∪ x
-- let x := [2, 3]; s ∪ List.toSet x : Set Nat

Lean 还使用类型类 CoeDep 支持依值类型强制转换。 例如,我们无法将任意命题强制转换到 Bool,只能转换实现了 Decidable 类型类的命题。

instance (p : Prop) [Decidable p] : CoeDep Prop p Bool where
  coe := decide p

Lean 也会在有需要的时候构造链式(非依赖的)强制转换。事实上,类型类 CoeTCoe 的传递闭包。

现在我们来考查第二种强制转换。 种类类(Class of Sort) 是指宇宙 Type u 的集合。 第二种强制转换的形式如下:

    c : (x1 : A1) → ... → (xn : An) → F x1 ... xn → Type u

其中 F 是如上所示的一族类型。这允许我们当 t 的类型为 F a1 ... an 时编写 s : t 。 换言之,类型转换允许我们将 F a1 ... an 的元素视为类型。 这在定义代数结构时非常有用,其中一个组成部分(即结构的载体)为 Type。 例如,我们可以按以下方式定义一个半群:

structure Semigroup where
  carrier : Type u
  mul : carrier → carrier → carrier
  mul_assoc (a b c : carrier) : mul (mul a b) c = mul a (mul b c)

instance (S : Semigroup) : Mul S.carrier where
  mul a b := S.mul a b

换句话说,一个半群包括一个类型「载体(carrier)」和一个乘法 mul,乘法满足结合性。 instance 命令允许我们用 a * b 代替 Semigroup.mul S a b 只要我们有 a b : S.carrier; 注意,Lean 可以根据 ab 的类型推断出参数 S。函数 Semigroup.carrier 将类 Semigroup 映射到种类 Type u

structure Semigroup where
  carrier : Type u
  mul : carrier → carrier → carrier
  mul_assoc (a b c : carrier) : mul (mul a b) c = mul a (mul b c)
instance (S : Semigroup) : Mul S.carrier where
  mul a b := S.mul a b
#check Semigroup.carrier

如果我们声明该函数是一个强制转换函数,那么无论何时我们都有半群 S : Semigroup, 我们可以写 a : S 而非 a : S.carrier

structure Semigroup where
  carrier : Type u
  mul : carrier → carrier → carrier
  mul_assoc (a b c : carrier) : mul (mul a b) c = mul a (mul b c)
instance (S : Semigroup) : Mul S.carrier where
  mul a b := S.mul a b
instance : CoeSort Semigroup (Type u) where
  coe s := s.carrier

example (S : Semigroup) (a b c : S) : (a * b) * c = a * (b * c) :=
  Semigroup.mul_assoc _ a b c

由于强制转换,我们可以写 (a b c : S)。 注意,我们定义了一个 CoeSort Semigroup (Type u) 的实例, 而非 Coe Semigroup (Type u)

函数类型的类 ,是指 Π 类型集合 (z : B) → C。第三种强制转换形式为:

    c : (x1 : A1) → ... → (xn : An) → (y : F x1 ... xn) → (z : B) → C

其中 F 仍然是一个类型族,而 BC 可以取决于 x1, ..., xn, y。 这使得可以写 t s,只要 tF a1 ... an 的元素。 换句话说,转换使我们可以将 F a1 ... an 的元素视为函数。 继续上面的示例,我们可以定义半群 S1S2 之间的态射的概念。 即,从 S1 的载体到 S2 的载体(注意隐式转换)关于乘法的一个函数。 投影 morphism.mor 将一个态射转化为底层函数。

structure Semigroup where
  carrier : Type u
  mul : carrier → carrier → carrier
  mul_assoc (a b c : carrier) : mul (mul a b) c = mul a (mul b c)
instance (S : Semigroup) : Mul S.carrier where
  mul a b := S.mul a b
instance : CoeSort Semigroup (Type u) where
  coe s := s.carrier
structure Morphism (S1 S2 : Semigroup) where
  mor : S1 → S2
  resp_mul : ∀ a b : S1, mor (a * b) = (mor a) * (mor b)

#check @Morphism.mor

因此,它成为第三种强制转换的主要候选。

structure Semigroup where
  carrier : Type u
  mul : carrier → carrier → carrier
  mul_assoc (a b c : carrier) : mul (mul a b) c = mul a (mul b c)
instance (S : Semigroup) : Mul S.carrier where
  mul a b := S.mul a b
instance : CoeSort Semigroup (Type u) where
  coe s := s.carrier
structure Morphism (S1 S2 : Semigroup) where
  mor : S1 → S2
  resp_mul : ∀ a b : S1, mor (a * b) = (mor a) * (mor b)
instance (S1 S2 : Semigroup) : CoeFun (Morphism S1 S2) (fun _ => S1 → S2) where
  coe m := m.mor

theorem resp_mul {S1 S2 : Semigroup} (f : Morphism S1 S2) (a b : S1)
        : f (a * b) = f a * f b :=
  f.resp_mul a b

example (S1 S2 : Semigroup) (f : Morphism S1 S2) (a : S1) :
      f (a * a * a) = f a * f a * f a :=
  calc f (a * a * a)
    _ = f (a * a) * f a := by rw [resp_mul f]
    _ = f a * f a * f a := by rw [resp_mul f]

有了强制类型转换,我们可以直接写 f (a * a * a) 而不必写 f.mor (a * a * a)。 当 Morphism(态射)f 被用于原本期望函数的位置时, Lean 会自动插入强制转换。类似于 CoeSort,我们还有另一个类 CoeFun 用于这一类的强制转换。域 F 用于指定我们强制类型转换的目标函数类型。 此类型可能依赖于我们强制转换的原类型。

转换策略模式

在策略块中,可以使用关键字conv进入转换模式(conversion mode)。这种模式允许在假设和目标内部,甚至在函数抽象和依赖箭头内部移动,以应用重写或简化步骤。

基本导航和重写

作为第一个例子,让我们证明(a b c : Nat) : a * (b * c) = a * (c * b)(本段中的例子有些刻意设计,因为其他策略可以立即完成它们)。首次简单的尝试是尝试rw [Nat.mul_comm],但这将目标转化为b * c * a = a * (c * b),因为它作用于项中出现的第一个乘法。有几种方法可以解决这个问题,其中一个方法是

example (a b c : Nat) : a * (b * c) = a * (c * b) := by
    rw [Nat.mul_comm b c]

不过本节介绍一个更精确的工具:转换模式。下面的代码块显示了每行之后的当前目标。

example (a b c : Nat) : a * (b * c) = a * (c * b) := by
  conv =>
    -- ⊢ a * (b * c) = a * (c * b)
    lhs
    -- ⊢ a * (b * c)
    congr
    -- 2 goals: ⊢ a, ⊢ b * c
    rfl
    -- ⊢ b * c
    rw [Nat.mul_comm]

上面这段涉及三个导航指令:

  • lhs(left hand side)导航到关系(此处是等式)左边。同理rhs导航到右边。
  • congr创建与当前头函数的(非依赖的和显式的)参数数量一样多的目标(此处的头函数是乘法)。
  • skip走到下一个目标。

一旦到达相关目标,我们就可以像在普通策略模式中一样使用rw

使用转换模式的第二个主要原因是在约束器下重写。假设我们想证明(fun x : Nat => 0 + x) = (fun x => x)。首次简单的尝试rw [zero_add]是失败的。报错:

error: tactic 'rewrite' failed, did not find instance of the pattern
       in the target expression
  0 + ?n
⊢ (fun x => 0 + x) = fun x => x

(错误:'rewrite'策略失败了,没有找到目标表达式中的模式0 + ?n)

解决方案为:

example : (fun x : Nat => 0 + x) = (fun x => x) := by
  conv =>
    lhs
    intro x
    rw [Nat.zero_add]

其中intro x是导航命令,它进入了fun约束器。这个例子有点刻意,你也可以这样做:

example : (fun x : Nat => 0 + x) = (fun x => x) := by
  funext x; rw [Nat.zero_add]

或者这样:

example : (fun x : Nat => 0 + x) = (fun x => x) := by
  simp

所有这些也可以用conv at h从局部上下文重写一个假设h

模式匹配

使用上面的命令进行导航可能很无聊。使用下面的模式匹配来简化它:

example (a b c : Nat) : a * (b * c) = a * (c * b) := by
  conv in b * c => rw [Nat.mul_comm]

这是下面代码的语法糖:

example (a b c : Nat) : a * (b * c) = a * (c * b) := by
  conv =>
    pattern b * c
    rw [Nat.mul_comm]

当然也可以用通配符:

example (a b c : Nat) : a * (b * c) = a * (c * b) := by
  conv in _ * c => rw [Nat.mul_comm]

结构化转换策略

大括号和.也可以在conv模式下用于结构化策略。

example (a b c : Nat) : (0 + a) * (b * c) = a * (c * b) := by
  conv =>
    lhs
    congr
    . rw [Nat.zero_add]
    . rw [Nat.mul_comm]

转换模式中的其他策略

  • arg i进入一个应用的第i个非独立显式参数。
example (a b c : Nat) : a * (b * c) = a * (c * b) := by
  conv =>
    -- ⊢ a * (b * c) = a * (c * b)
    lhs
    -- ⊢ a * (b * c)
    arg 2
    -- ⊢ b * c
    rw [Nat.mul_comm]
  • argscongr的替代品。

  • simp将简化器应用于当前目标。它支持常规策略模式中的相同选项。

def f (x : Nat) :=
  if x > 0 then x + 1 else x + 2

example (g : Nat → Nat) (h₁ : g x = x + 1) (h₂ : x > 0) : g x = f x := by
  conv =>
    rhs
    simp [f, h₂]
  exact h₁
  • enter [1, x, 2, y]argintro使用给定参数的宏。
syntax enterArg := ident <|> group("@"? num)
syntax "enter " "[" (colGt enterArg),+ "]": conv
macro_rules
  | `(conv| enter [$i:num]) => `(conv| arg $i)
  | `(conv| enter [@$i:num]) => `(conv| arg @$i)
  | `(conv| enter [$id:ident]) => `(conv| ext $id)
  | `(conv| enter [$arg:enterArg, $args,*]) => `(conv| (enter [$arg]; enter [$args,*]))
  • done会失败如果有未解决的目标。

  • traceState显示当前策略状态。

  • whnf put term in weak head normal form.

  • tactic => <tactic sequence>回到常规策略模式。这对于退出conv模式不支持的目标,以及应用自定义的一致性和扩展性引理很有用。

example (g : Nat → Nat → Nat)
        (h₁ : ∀ x, x ≠ 0 → g x x = 1)
        (h₂ : x ≠ 0)
        : g x x + x = 1 + x := by
  conv =>
    lhs
    -- ⊢ g x x + x
    arg 1
    -- ⊢ g x x
    rw [h₁]
    -- 2 goals: ⊢ 1, ⊢ x ≠ 0
    . skip
    . tactic => exact h₂
  • apply <term>tactic => apply <term>的语法糖。
example (g : Nat → Nat → Nat)
        (h₁ : ∀ x, x ≠ 0 → g x x = 1)
        (h₂ : x ≠ 0)
        : g x x + x = 1 + x := by
  conv =>
    lhs
    arg 1
    rw [h₁]
    . skip
    . apply h₂

公理与计算

我们已经看到 Lean 中实现的构造演算的版本包括有:依值函数类型、 归纳类型以及一个以非直谓的与证明无关(Proof-Irrelevant)的 Prop 为底层的宇宙层级。 在本章中,我们要探讨使用附加公理和规则扩展 CIC 的方法。 用这种方式扩展一个基础系统通常很方便;它可以使得证明更多的定理成为可能, 并使得证明原本可以被证明的定理变得更容易。但是,添加附加公理可能会产生负面后果, 这些后果可能超出了人们对它们的正确性的担忧。 特别是,公理的使用会以我们将在本文中探究的方式,对定义和定理的计算内容产生影响。

Lean 被设计为支持计算推理和经典推理。有此需求的用户可坚持使用「计算上纯粹」的片段, 它可以确保系统中的封闭表达式会求值为标准范式。具体说来,任何类型为 Nat 的封闭计算上纯粹表达式最终都将归约为一个数值。

Lean 的标准库定义了一个公理: 命题外延性(Propositional Extensionality) 。 以及一个 商(Qoutient) 结构,它蕴含了函数外延性的公理。 这些扩展被用来发展如集合与有限集这些理论。我们在后面会看到, 这些定理的使用会阻碍 Lean 内核中的求值,因此 Nat 类型的封闭项不再求值为数值。 但是 Lean 在对其虚拟机器求值器进行字节码编译时会擦除类型和命题信息, 并且由于这些公理只增加了新的命题,它们与这种计算解释是相容的。 即使是倾向于可计算性的用户也可能希望使用排中律来推理计算。 这也会阻碍内核中的求值,但它与字节码编译是兼容的。

标准函数库还定义了一个选择公理(Choice Principle),该公理与计算诠释完全相反, 因为它神奇地根据断言自身存在的命题产生「数据」。 它对于一些经典结构来说是必不可少的,用户可以在需要时导入它。 但使用此构造来产生数据的表达式将不存在计算内容, 在 Lean 中我们需要将此类定义标记为 noncomputable(不可计算的)以表明该事实。

使用一个巧妙的技巧(称为狄阿科涅斯库定理),人们可以使用命题外延性、 函数外延性和选择公理来导出排中律。然而,如上所述,使用排中律仍然兼容字节码编译和代码提取, 就像其他经典公理一样,只要它们不被用来制造数据。

总而言之,在我们的宇宙类型,依值函数类型和归纳类型的底层框架之上, 标准库增加了三个附加元素:

  • 命题外延性公理
  • 蕴含了函数外延性的的商构造
  • 选择公理,它从存在命题中产生数据。

前两项在 Lean 中对这些块标准化,但与字节码求值兼容, 而第三项不适合可计算性解释。我们将在下面更精确地说明这些细节。

历史与哲学背景

历史上大部分时候,数学主要是计算性的:几何处理涉及几何对象的构造, 代数涉及方程组的算法解,分析提供了计算系统随时间演变的未来行为的方法。 从定理的证明到「对于每个 x,都有一个 y 使得 ...」这一效果, 通常可以提取一种算法来根据给定的 x 计算这样的的 y

然而在 19 世纪,数学论证复杂性的提升推动了数学家发展新的推理风格, 抑制算法信息并调用数学对象,从而抽象掉了对象被表征的细节。 目标是在不陷入繁重的计算细节的情况下获得强大的「概念」理解, 但这可能导致数学定理在直接计算的解读上干脆就是 错误 的。

今天数学界仍在相当普遍地同意计算对于数学很重要。 但对于如何以最佳方式解决计算问题有不同的看法。 从 构造性 的角度来看,将数学与其计算根源分开是一个错误; 每条有意义的数学定理都应具有直接的计算解释。 从 经典的 角度来看,保持关注点的分离更有成效: 我们可以使用一种语言和方法体系编写计算机程序, 同时保持使用非构造性理论和方法对其进行推理的自由。 Lean 旨在支持这两种方法。库的核心部分以构造性方式开发, 但该系统还提供了支持进行经典数学推理的支持。

从计算的角度来看,依值类型论中最纯粹的部分完全避免使用 Prop。 归纳类型和依值函数类型可以看作是数据类型,这些类型的项可以通过应用归约规则进行「求值」, 直到不能再应用任何规则为止。原则上,类型为 Nat 的任何封闭项(即没有自由变量的项) 都应求值为一个数值:succ(... (succ zero)...)

引入一个与证明无关的 Prop 并标记定理不可约表示了分离关注点的第一步。 目的是类型为 p : Prop 的元素在计算中不应发挥任何作用,因此从这个意义上说, 项 t : p 的特定构造是「无关的」。人们仍然可以定义包含类型为 Prop 的元素的计算对象;关键是这些元素可以帮助我们推理计算的影响, 但在我们从项中提取「代码」时可以忽略。但是,Prop 类型的元素并非完全无害。 它们包括任何类型 α 的方程 s = t : α,并且此类方程可以作为强制转换使用, 以对项进行类型检查。在后面,我们将看到此类强制转换是如何阻碍系统中的计算的示例。 但是,在擦除命题内容、忽略中间定型约束并归约项,直到它们达到正规形式的求值方案下, 它们仍然可以进行计算。这正是 Lean 的虚拟机所做的。

在通过了证明无关的 Prop 之后,可以认为使用排中律 p ∨ ¬p 是合法的, 其中 p 是任何命题。当然,这也可能根据 CIC 的规则阻止计算, 但它不会阻止字节码求值,如上所述。仅在 :numref:choice 中讨论过的选择原则才能完全消除理论中与证明无关的部分和与数据相关部分之间的区别。

命题外延性

命题外延性公理如下:

namespace Hidden
axiom propext {a b : Prop} : (a ↔ b) → a = b
end Hidden

它断言当两个命题互相蕴含时,二者实质相等。这与集合论的解释一致, 即对于某个特定的元素 *,其中任何元素 a : Prop 要么为空集, 要么是单元素集 {*}。此公理具有这样的效果,即等价的命题可以在任何语境中彼此替换:

theorem thm₁ (a b c d e : Prop) (h : a ↔ b) : (c ∧ a ∧ d → e) ↔ (c ∧ b ∧ d → e) :=
  propext h ▸ Iff.refl _

theorem thm₂ (a b : Prop) (p : Prop → Prop) (h : a ↔ b) (h₁ : p a) : p b :=
  propext h ▸ h₁

函数外延性

Similar to propositional extensionality, function extensionality asserts that any two functions of type (x : α) → β x that agree on all their inputs are equal.

universe u v
#check (@funext :
           {α : Type u}
         → {β : α → Type u}
         → {f g : (x : α) → β x}
         → (∀ (x : α), f x = g x)
         → f = g)

#print funext

从经典的集合论角度来看,这正是两个函数相等的确切含义。 它被称作函数的「外延性(Extensional)」视角。然而,从构造主义的角度来看, 有时把函数看作算法,或者以某种明确的方式给出的计算机程序要更加自然。 肯定存在这样的情况:两个计算机程序对每个输入都计算出相同的答案, 尽管它们在语法上非常不同。与此类似,你可能想要维护一种函数的视角, 它不会强迫你将具有相同输入/输出行为的两个函数认定为同样的。 这被称为函数的「内涵(Intensional)」视角。

实际上,函数外延性来自于商的存在,我们将在下一节中进行描述。 因此,在 Lean 标准库中,funext 通过商的构造来证明

假设对于 α : Type,我们定义 Set α := α → Prop 来表达 α 子集的类型, 本质上是用谓词来表示子集。通过组合 funextpropext, 我们得到了一个这样的集合的外延性理论:

def Set (α : Type u) := α → Prop

namespace Set

def mem (x : α) (a : Set α) := a x

infix:50 (priority := high) "∈" => mem

theorem setext {a b : Set α} (h : ∀ x, x ∈ a ↔ x ∈ b) : a = b :=
  funext (fun x => propext (h x))

end Set

我们可以继续定义例如空集和交集,并证明的集合恒等性:

def Set (α : Type u) := α → Prop
namespace Set
def mem (x : α) (a : Set α) := a x
infix:50 (priority := high) "∈" => mem
theorem setext {a b : Set α} (h : ∀ x, x ∈ a ↔ x ∈ b) : a = b :=
  funext (fun x => propext (h x))
def empty : Set α := fun x => False

notation (priority := high) "∅" => empty

def inter (a b : Set α) : Set α :=
  fun x => x ∈ a ∧ x ∈ b

infix:70 " ∩ " => inter

theorem inter_self (a : Set α) : a ∩ a = a :=
  setext fun x => Iff.intro
    (fun ⟨h, _⟩ => h)
    (fun h => ⟨h, h⟩)

theorem inter_empty (a : Set α) : a ∩ ∅ = ∅ :=
  setext fun x => Iff.intro
    (fun ⟨_, h⟩ => h)
    (fun h => False.elim h)

theorem empty_inter (a : Set α) : ∅ ∩ a = ∅ :=
  setext fun x => Iff.intro
    (fun ⟨h, _⟩ => h)
    (fun h => False.elim h)

theorem inter.comm (a b : Set α) : a ∩ b = b ∩ a :=
  setext fun x => Iff.intro
    (fun ⟨h₁, h₂⟩ => ⟨h₂, h₁⟩)
    (fun ⟨h₁, h₂⟩ => ⟨h₂, h₁⟩)
end Set

以下是一个函数外延性阻碍了 Lean 核心中计算的示例:

def f (x : Nat) := x
def g (x : Nat) := 0 + x

theorem f_eq_g : f = g :=
  funext fun x => (Nat.zero_add x).symm

def val : Nat :=
  Eq.recOn (motive := fun _ _ => Nat) f_eq_g 0

-- 无法归约为 0
#reduce val

-- 求值为 0
#eval val

首先,我们使用函数外延性来证明两个函数 fg 相等, 然后用 g 替换类型为 Natf,从而转换该类型。 当然,转换是无意义的,因为 Nat 不依赖于 f。 但这已经足够了:在系统的计算规则之下,我们现在有了 Nat 的一个封闭项, 它不会归约为一个数值。在这种情况下,我们可能倾向于将该表达式归约为 0。 但是,在非平凡的例子里,消除转换会改变该项的类型,这可能会导致周围的表达式类型不正确。 然而,虚拟机将表达式求值为 0 则不会遇到问题。下面是一个类似的例子, 展示了 propext 如何造成阻碍。

theorem tteq : (True ∧ True) = True :=
  propext (Iff.intro (fun ⟨h, _⟩ => h) (fun h => ⟨h, h⟩))

def val : Nat :=
  Eq.recOn (motive := fun _ _ => Nat) tteq 0

-- does not reduce to 0
#reduce val

-- evaluates to 0
#eval val

当前的研究计划包括关于 观测类型论(Observational Type Theory)立方类型论(Cubical Type Theory) 的研究,旨在扩展类型理论, 以便允许对涉及函数外延、商,等等的强制转换进行归约。 但解决方案并不明朗,而 Lean 的底层演算法则对此类归约也不支持。

从某种意义上来说,一个强制转换不会改变一个表达式的含义。 相反,它是一种关于表达式类型的推理机制。给定一个适当的语义, 那么忽略掉归约为正确类型所需的中间记录操作,以不改变其含义的方式归约项是有意义的。 在这种情况下,在 Prop 中添加新公理并不重要;通过证明无关性, Prop 中的表达式不会承载任何信息,可以被归约过程安全地忽略。

α 为任意类型,且 rα 上的等价关系。在数学中, 常见的做法是形成「商(Quotient)」α / r,即 α 中元素的类型「模(modulo)」r。 从集合论的角度,可以将 α / r 视为 αr 的等价类的集合。 若 f : α → β 是任意满足等价关系的函数,即对于任意 x y : α, r x y 蕴含 f x = f y, 则 f「提升(lift)」到函数 f' : α / r → β, 其在每个等价类 ⟦x⟧ 上由 f' ⟦x⟧ = f x 定义。 Lean 的标准库通过执行这些构造的附加常量来扩展构造演算,并将该最后的方程作为定义归约规则。

在最基本的表述形式中,商构造甚至不需要 r 成为一个等价关系。 下列常量被内置在 Lean 中:

namespace Hidden
universe u v

axiom Quot : {α : Sort u} → (α → α → Prop) → Sort u

axiom Quot.mk : {α : Sort u} → (r : α → α → Prop) → α → Quot r

axiom Quot.ind :
    ∀ {α : Sort u} {r : α → α → Prop} {β : Quot r → Prop},
      (∀ a, β (Quot.mk r a)) → (q : Quot r) → β q

axiom Quot.lift :
    {α : Sort u} → {r : α → α → Prop} → {β : Sort u} → (f : α → β)
    → (∀ a b, r a b → f a = f b) → Quot r → β
end Hidden

第一条公理根据任何二元关系 r 的类型 α 形成类型 Quot r。 第二条公理将 α 映射到 Quot α,因此若 r : α → α → Propa : α, 则 Quot.mk r aQuot r 的一个元素。 第三条公理 Quot.ind 是说 Quot.mk r a 的每个元素都属于此形式。 至于 Quot.lift,给定函数 f : α → β,若 h 是一个「f 遵循关系 r」的证明,则 Quot.lift f hQuot r 上的对应函数。 其思想是对于 α 中的每个元素 a,函数 Quot.lift f hQuot.mk r a(包含 ar-类)映射到 f a, 其中 h 表明此函数是良定义的。事实上,计算公理被声明为一个归约规则, 如下方的证明所示。

def mod7Rel (x y : Nat) : Prop :=
  x % 7 = y % 7

-- the quotient type
#check (Quot mod7Rel : Type)

-- the class of a
#check (Quot.mk mod7Rel 4 : Quot mod7Rel)

def f (x : Nat) : Bool :=
  x % 7 = 0

theorem f_respects (a b : Nat) (h : mod7Rel a b) : f a = f b := by
  simp [mod7Rel, f] at *
  rw [h]

#check (Quot.lift f f_respects : Quot mod7Rel → Bool)

-- the computation principle
example (a : Nat) : Quot.lift f f_respects (Quot.mk mod7Rel a) = f a :=
  rfl

四个常量 QuotQuot.mkQuot.indQuot.lift 在它们本身上并不强。 你可以检查如果我们把 Quot r 简单地取为 α,并取 Quot.lift 为恒等函数 (忽略 h),那么 Quot.ind 将得到满足。 由于这个原因,这四个常量并没有被看作附加公理。

和归纳定义的类型以及相关的构造子和递归器一样,它们也被视为逻辑框架的一部分。

使 Quot 构造成为真正商的是以下一个附加公理:

namespace Hidden
universe u v
axiom Quot.sound :
      ∀ {α : Type u} {r : α → α → Prop} {a b : α},
        r a b → Quot.mk r a = Quot.mk r b
end Hidden

这条公理断言 α 的任何两个元素,只要满足关系 r,就能在商中被识别的。 如果定理或定义使用了 Quot.sound,它将会在 #print axioms 命令中显示。

当然,当 r 是等价关系时,商集的结构是最常用的。给定上面的 r, 如果我们根据法则 r' a b 当且仅当 Quot.mk r a = Quot.mk r b 定义 r', 那么显然 r' 就是一个等价关系。事实上,r' 是函数 a ↦ quot.mk r a核(Kernel) 。公理 Quot.sound 表明 r a b 蕴含 r' a b。 使用 Quot.liftQuot.ind,我们可以证明 r' 是包含 r 的最小的等价关系, 意思就是,如果 r'' 是包含 r 的任意等价关系,则 r' a b 蕴含 r'' a b。 特别地,如果 r 开始就是一个等价关系,那么对任意 ab,我们都有 r a b 等价于 r' a b

为支持这种通用使用案例,标准库定义了 广集(Setoid) 的概念, 它只是一个带有与之关联的等价关系的类型:

namespace Hidden
class Setoid (α : Sort u) where
  r : α → α → Prop
  iseqv : Equivalence r

instance {α : Sort u} [Setoid α] : HasEquiv α :=
  ⟨Setoid.r⟩

namespace Setoid

variable {α : Sort u} [Setoid α]

theorem refl (a : α) : a ≈ a :=
  iseqv.refl a

theorem symm {a b : α} (hab : a ≈ b) : b ≈ a :=
  iseqv.symm hab

theorem trans {a b c : α} (hab : a ≈ b) (hbc : b ≈ c) : a ≈ c :=
  iseqv.trans hab hbc

end Setoid
end Hidden

给定一个类型 α 和其上的关系 r,以及一个证明 p 证明 r 是一个等价关系, 我们可以定义 Setoid.mk r p 为广集类的一个实例。

namespace Hidden
def Quotient {α : Sort u} (s : Setoid α) :=
  @Quot α Setoid.r
end Hidden

常量 Quotient.mkQuotient.indQuotient.lift 以及 Quotient.sound 仅为 Quot 对应元素的特化形式。 类型类推断能找到与类型 α 关联的广集,这带来了大量好处。 首先,我们可以对 Setoid.r a b 使用符号 a ≈ b(用 \approx 输入), 其中 Setoid 的实例在符号 Setoid.r 中是内隐的。 我们可以使用通用定理 Setoid.reflSetoid.symmSetoid.trans 来推断关系。具体来说,在商中,我们可以对 Quot.mk Setoid.r 使用通用符号 ⟦a⟧, 其中 Setoid 的实例在符号 Setoid.r 中是内隐的,以及定理 Quotient.exact

universe u
#check (@Quotient.exact :
         ∀ {α : Sort u} {s : Setoid α} {a b : α},
           Quotient.mk s a = Quotient.mk s b → a ≈ b)

结合 Quotient.sound,这意味着商的各个元素精确对应于 α 中各元素的等价类。

回顾一下标准库中的 α × β 代表类型 αβ 的笛卡尔积。 为了说明商的用法,让我们将类型为 α 的元素构成的 无序对(Unordered Pair) 的类型定义为 α × α 类型的商。首先,我们定义相关的等价关系:

private def eqv (p₁ p₂ : α × α) : Prop :=
  (p₁.1 = p₂.1 ∧ p₁.2 = p₂.2) ∨ (p₁.1 = p₂.2 ∧ p₁.2 = p₂.1)

infix:50 " ~ " => eqv

下一步是证明 eqv 实际上是一个等价关系,即满足自反性、对称性和传递性。 我们可以使用依值模式匹配进行情况分析,将假设分解然后重新组合以得出结论, 从而以一种简便易读的方式证明这三个事实。

private def eqv (p₁ p₂ : α × α) : Prop :=
  (p₁.1 = p₂.1 ∧ p₁.2 = p₂.2) ∨ (p₁.1 = p₂.2 ∧ p₁.2 = p₂.1)
infix:50 " ~ " => eqv
private theorem eqv.refl (p : α × α) : p ~ p :=
  Or.inl ⟨rfl, rfl⟩

private theorem eqv.symm : ∀ {p₁ p₂ : α × α}, p₁ ~ p₂ → p₂ ~ p₁
  | (a₁, a₂), (b₁, b₂), (Or.inl ⟨a₁b₁, a₂b₂⟩) =>
    Or.inl (by simp_all)
  | (a₁, a₂), (b₁, b₂), (Or.inr ⟨a₁b₂, a₂b₁⟩) =>
    Or.inr (by simp_all)

private theorem eqv.trans : ∀ {p₁ p₂ p₃ : α × α}, p₁ ~ p₂ → p₂ ~ p₃ → p₁ ~ p₃
  | (a₁, a₂), (b₁, b₂), (c₁, c₂), Or.inl ⟨a₁b₁, a₂b₂⟩, Or.inl ⟨b₁c₁, b₂c₂⟩ =>
    Or.inl (by simp_all)
  | (a₁, a₂), (b₁, b₂), (c₁, c₂), Or.inl ⟨a₁b₁, a₂b₂⟩, Or.inr ⟨b₁c₂, b₂c₁⟩ =>
    Or.inr (by simp_all)
  | (a₁, a₂), (b₁, b₂), (c₁, c₂), Or.inr ⟨a₁b₂, a₂b₁⟩, Or.inl ⟨b₁c₁, b₂c₂⟩ =>
    Or.inr (by simp_all)
  | (a₁, a₂), (b₁, b₂), (c₁, c₂), Or.inr ⟨a₁b₂, a₂b₁⟩, Or.inr ⟨b₁c₂, b₂c₁⟩ =>
    Or.inl (by simp_all)

private theorem is_equivalence : Equivalence (@eqv α) :=
  { refl := eqv.refl, symm := eqv.symm, trans := eqv.trans }

现在我们已经证明了 eqv 是一个等价关系,我们可以构造一个 Setoid (α × α), 并使用它来定义无序对的类型 UProd α

private def eqv (p₁ p₂ : α × α) : Prop :=
  (p₁.1 = p₂.1 ∧ p₁.2 = p₂.2) ∨ (p₁.1 = p₂.2 ∧ p₁.2 = p₂.1)
infix:50 " ~ " => eqv
private theorem eqv.refl (p : α × α) : p ~ p :=
  Or.inl ⟨rfl, rfl⟩
private theorem eqv.symm : ∀ {p₁ p₂ : α × α}, p₁ ~ p₂ → p₂ ~ p₁
  | (a₁, a₂), (b₁, b₂), (Or.inl ⟨a₁b₁, a₂b₂⟩) =>
    Or.inl (by simp_all)
  | (a₁, a₂), (b₁, b₂), (Or.inr ⟨a₁b₂, a₂b₁⟩) =>
    Or.inr (by simp_all)
private theorem eqv.trans : ∀ {p₁ p₂ p₃ : α × α}, p₁ ~ p₂ → p₂ ~ p₃ → p₁ ~ p₃
  | (a₁, a₂), (b₁, b₂), (c₁, c₂), Or.inl ⟨a₁b₁, a₂b₂⟩, Or.inl ⟨b₁c₁, b₂c₂⟩ =>
    Or.inl (by simp_all)
  | (a₁, a₂), (b₁, b₂), (c₁, c₂), Or.inl ⟨a₁b₁, a₂b₂⟩, Or.inr ⟨b₁c₂, b₂c₁⟩ =>
    Or.inr (by simp_all)
  | (a₁, a₂), (b₁, b₂), (c₁, c₂), Or.inr ⟨a₁b₂, a₂b₁⟩, Or.inl ⟨b₁c₁, b₂c₂⟩ =>
    Or.inr (by simp_all)
  | (a₁, a₂), (b₁, b₂), (c₁, c₂), Or.inr ⟨a₁b₂, a₂b₁⟩, Or.inr ⟨b₁c₂, b₂c₁⟩ =>
    Or.inl (by simp_all)
private theorem is_equivalence : Equivalence (@eqv α) :=
  { refl := eqv.refl, symm := eqv.symm, trans := eqv.trans }
instance uprodSetoid (α : Type u) : Setoid (α × α) where
  r     := eqv
  iseqv := is_equivalence

def UProd (α : Type u) : Type u :=
  Quotient (uprodSetoid α)

namespace UProd

def mk {α : Type} (a₁ a₂ : α) : UProd α :=
  Quotient.mk' (a₁, a₂)

notation "{ " a₁ ", " a₂ " }" => mk a₁ a₂

end UProd

请注意,我们将 {a₁, a₂} 无序对的记法局部定义为 Quotient.mk (a₁, a₂)。 这对展示来说很有用,但一般来说这不是一个好主意,因为该记法将会与花括号的其它用法冲突, 例如记录和集合。

我们可以很容易地使用 Quot.sound 证明 {a₁, a₂} = {a₂, a₁}, 因为我们有 (a₁, a₂) ~ (a₂, a₁)

private def eqv (p₁ p₂ : α × α) : Prop :=
  (p₁.1 = p₂.1 ∧ p₁.2 = p₂.2) ∨ (p₁.1 = p₂.2 ∧ p₁.2 = p₂.1)
infix:50 " ~ " => eqv
private theorem eqv.refl (p : α × α) : p ~ p :=
  Or.inl ⟨rfl, rfl⟩
private theorem eqv.symm : ∀ {p₁ p₂ : α × α}, p₁ ~ p₂ → p₂ ~ p₁
  | (a₁, a₂), (b₁, b₂), (Or.inl ⟨a₁b₁, a₂b₂⟩) =>
    Or.inl (by simp_all)
  | (a₁, a₂), (b₁, b₂), (Or.inr ⟨a₁b₂, a₂b₁⟩) =>
    Or.inr (by simp_all)
private theorem eqv.trans : ∀ {p₁ p₂ p₃ : α × α}, p₁ ~ p₂ → p₂ ~ p₃ → p₁ ~ p₃
  | (a₁, a₂), (b₁, b₂), (c₁, c₂), Or.inl ⟨a₁b₁, a₂b₂⟩, Or.inl ⟨b₁c₁, b₂c₂⟩ =>
    Or.inl (by simp_all)
  | (a₁, a₂), (b₁, b₂), (c₁, c₂), Or.inl ⟨a₁b₁, a₂b₂⟩, Or.inr ⟨b₁c₂, b₂c₁⟩ =>
    Or.inr (by simp_all)
  | (a₁, a₂), (b₁, b₂), (c₁, c₂), Or.inr ⟨a₁b₂, a₂b₁⟩, Or.inl ⟨b₁c₁, b₂c₂⟩ =>
    Or.inr (by simp_all)
  | (a₁, a₂), (b₁, b₂), (c₁, c₂), Or.inr ⟨a₁b₂, a₂b₁⟩, Or.inr ⟨b₁c₂, b₂c₁⟩ =>
    Or.inl (by simp_all)
private theorem is_equivalence : Equivalence (@eqv α) :=
  { refl := eqv.refl, symm := eqv.symm, trans := eqv.trans }
instance uprodSetoid (α : Type u) : Setoid (α × α) where
  r     := eqv
  iseqv := is_equivalence
def UProd (α : Type u) : Type u :=
  Quotient (uprodSetoid α)
namespace UProd
def mk {α : Type} (a₁ a₂ : α) : UProd α :=
  Quotient.mk' (a₁, a₂)
notation "{ " a₁ ", " a₂ " }" => mk a₁ a₂
theorem mk_eq_mk (a₁ a₂ : α) : {a₁, a₂} = {a₂, a₁} :=
  Quot.sound (Or.inr ⟨rfl, rfl⟩)
end UProd

为了完成此示例,给定 a : αu : uprod α,我们定义命题 a ∈ u, 若 a 是无序对 u 的元素之一,则该命题应成立。 首先,我们在(有序)对上定义一个类似的命题 mem_fn a u; 然后用引理 mem_respects 证明 mem_fn 关于等价关系 eqv 成立。 这是一个在 Lean 标准库中广泛使用的惯用法。

private def eqv (p₁ p₂ : α × α) : Prop :=
  (p₁.1 = p₂.1 ∧ p₁.2 = p₂.2) ∨ (p₁.1 = p₂.2 ∧ p₁.2 = p₂.1)
infix:50 " ~ " => eqv
private theorem eqv.refl (p : α × α) : p ~ p :=
  Or.inl ⟨rfl, rfl⟩
private theorem eqv.symm : ∀ {p₁ p₂ : α × α}, p₁ ~ p₂ → p₂ ~ p₁
  | (a₁, a₂), (b₁, b₂), (Or.inl ⟨a₁b₁, a₂b₂⟩) =>
    Or.inl (by simp_all)
  | (a₁, a₂), (b₁, b₂), (Or.inr ⟨a₁b₂, a₂b₁⟩) =>
    Or.inr (by simp_all)
private theorem eqv.trans : ∀ {p₁ p₂ p₃ : α × α}, p₁ ~ p₂ → p₂ ~ p₃ → p₁ ~ p₃
  | (a₁, a₂), (b₁, b₂), (c₁, c₂), Or.inl ⟨a₁b₁, a₂b₂⟩, Or.inl ⟨b₁c₁, b₂c₂⟩ =>
    Or.inl (by simp_all)
  | (a₁, a₂), (b₁, b₂), (c₁, c₂), Or.inl ⟨a₁b₁, a₂b₂⟩, Or.inr ⟨b₁c₂, b₂c₁⟩ =>
    Or.inr (by simp_all)
  | (a₁, a₂), (b₁, b₂), (c₁, c₂), Or.inr ⟨a₁b₂, a₂b₁⟩, Or.inl ⟨b₁c₁, b₂c₂⟩ =>
    Or.inr (by simp_all)
  | (a₁, a₂), (b₁, b₂), (c₁, c₂), Or.inr ⟨a₁b₂, a₂b₁⟩, Or.inr ⟨b₁c₂, b₂c₁⟩ =>
    Or.inl (by simp_all)
private theorem is_equivalence : Equivalence (@eqv α) :=
  { refl := eqv.refl, symm := eqv.symm, trans := eqv.trans }
instance uprodSetoid (α : Type u) : Setoid (α × α) where
  r     := eqv
  iseqv := is_equivalence
def UProd (α : Type u) : Type u :=
  Quotient (uprodSetoid α)
namespace UProd
def mk {α : Type} (a₁ a₂ : α) : UProd α :=
  Quotient.mk' (a₁, a₂)
notation "{ " a₁ ", " a₂ " }" => mk a₁ a₂
theorem mk_eq_mk (a₁ a₂ : α) : {a₁, a₂} = {a₂, a₁} :=
  Quot.sound (Or.inr ⟨rfl, rfl⟩)
private def mem_fn (a : α) : α × α → Prop
  | (a₁, a₂) => a = a₁ ∨ a = a₂

-- auxiliary lemma for proving mem_respects
-- 用于证明 mem_respects 的辅助引理
private theorem mem_swap {a : α} :
      ∀ {p : α × α}, mem_fn a p = mem_fn a (⟨p.2, p.1⟩)
  | (a₁, a₂) => by
    apply propext
    apply Iff.intro
    . intro
      | Or.inl h => exact Or.inr h
      | Or.inr h => exact Or.inl h
    . intro
      | Or.inl h => exact Or.inr h
      | Or.inr h => exact Or.inl h


private theorem mem_respects
      : {p₁ p₂ : α × α} → (a : α) → p₁ ~ p₂ → mem_fn a p₁ = mem_fn a p₂
  | (a₁, a₂), (b₁, b₂), a, Or.inl ⟨a₁b₁, a₂b₂⟩ => by simp_all
  | (a₁, a₂), (b₁, b₂), a, Or.inr ⟨a₁b₂, a₂b₁⟩ => by simp_all; apply mem_swap

def mem (a : α) (u : UProd α) : Prop :=
  Quot.liftOn u (fun p => mem_fn a p) (fun p₁ p₂ e => mem_respects a e)

infix:50 (priority := high) " ∈ " => mem

theorem mem_mk_left (a b : α) : a ∈ {a, b} :=
  Or.inl rfl

theorem mem_mk_right (a b : α) : b ∈ {a, b} :=
  Or.inr rfl

theorem mem_or_mem_of_mem_mk {a b c : α} : c ∈ {a, b} → c = a ∨ c = b :=
  fun h => h
end UProd

为方便起见,标准库还定义了二元函数的 Quotient.lift₂, 以及对于两个变量的归纳的 Quotient.ind₂

我们在本节的末尾解释为什么商构造蕴含了函数的外延性。不难证明在 (x : α) → β x 上的外延相等性是一种等价关系,因此我们可以将类型 extfun α β 视为「保持等价」的函数。 当然,函数应用遵循这种等价,即若 f₁ 等价于 f₂,则 f₁ a 等于 f₂ a。 因此,应用产生了一个函数 extfun_app : extfun α β → (x : α) → β x。 但是对于每个 f 而言,extfun_app ⟦f⟧ 在定义上等于 fun x => f x, 这在定义上又等于 f。所以,当 f₁f₂ 外延相等时,我们有以下等式链:

    f₁ = extfun_app ⟦f₁⟧ = extfun_app ⟦f₂⟧ = f₂

因此,f₁ 等于 f₂

选择公理

为了陈述标准库中定义的最后一个公理,我们需要 Nonempty 类型,它的定义如下:

namespace Hidden
class inductive Nonempty (α : Sort u) : Prop where
  | intro (val : α) : Nonempty α
end Hidden

由于 Nonempty α 的类型为 Prop,其构造子包含数据,所以只能消去到 Prop。 事实上,Nonempty α 等价于 ∃ x : α, True

example (α : Type u) : Nonempty α ↔ ∃ x : α, True :=
  Iff.intro (fun ⟨a⟩ => ⟨a, trivial⟩) (fun ⟨a, h⟩ => ⟨a⟩)

我们的选择公理现在可以简单地表示如下:

namespace Hidden
universe u
axiom choice {α : Sort u} : Nonempty α → α
end Hidden

给定唯一断言 h,即 α 非空,choice h 神奇地产生了一个 α 的元素。 当然,这阻碍了任何有意义的计算:根据 Prop 的解释,h 根本不包含任何信息, 因而无法找到这样的元素。

这可以在 Classical 命名空间中找到,所以定理的全名是 Classical.choice。 选择公理等价于 非限定摹状词(Indefinite Description) 原理,可通过子类型表示如下:

namespace Hidden
universe u
axiom choice {α : Sort u} : Nonempty α → α
noncomputable def indefiniteDescription {α : Sort u} (p : α → Prop)
                                        (h : ∃ x, p x) : {x // p x} :=
  choice <| let ⟨x, px⟩ := h; ⟨⟨x, px⟩⟩
end Hidden

因为依赖于 choice,Lean 不能为 indefiniteDescription 生成字节码, 所以要求我们将定义标记为 noncomputable。同样在 Classical 命名空间中, 函数 choose 和属性 choose_spec 分离了 indefiniteDescription 输出的两个部分:

open Classical
namespace Hidden
noncomputable def choose {α : Sort u} {p : α → Prop} (h : ∃ x, p x) : α :=
  (indefiniteDescription p h).val

theorem choose_spec {α : Sort u} {p : α → Prop} (h : ∃ x, p x) : p (choose h) :=
  (indefiniteDescription p h).property
end Hidden

choice 选择公理也消除了 Nonempty 特性与更加具有构造性的 Inhabited 特性之间的区别。

open Classical
theorem inhabited_of_nonempty : Nonempty α → Inhabited α :=
  fun h => choice (let ⟨a⟩ := h; ⟨⟨a⟩⟩)

在下一节中,我们将会看到 propextfunextchoice, 合起来就构成了排中律以及所有命题的可判定性。使用它们,我们可以加强如下非限定摹状词原理:

open Classical
universe u
#check (@strongIndefiniteDescription :
         {α : Sort u} → (p : α → Prop)
         → Nonempty α → {x // (∃ (y : α), p y) → p x})

假设环境类型 α 非空,strongIndefiniteDescription p 产生一个满足 p 的元素 α(如果存在的话)。该定义的数据部分通常被称为 希尔伯特 ε 函数

open Classical
universe u
#check (@epsilon :
         {α : Sort u} → [Nonempty α]
         → (α → Prop) → α)

#check (@epsilon_spec :
         ∀ {α : Sort u} {p : α → Prop} (hex : ∃ (y : α), p y),
           p (@epsilon _ (nonempty_of_exists hex) p))

排中律

排中律如下所示:

open Classical

#check (@em : ∀ (p : Prop), p ∨ ¬p)

迪亚科内斯库定理 表明 选择公理足以导出排中律。更确切地说,它表明排中律源自于 Classical.choicepropextfunext。我们概述了标准库中的证明。

首先,我们导入必要的公理,并定义两个谓词 UV

namespace Hidden
open Classical
theorem em (p : Prop) : p ∨ ¬p :=
  let U (x : Prop) : Prop := x = True ∨ p
  let V (x : Prop) : Prop := x = False ∨ p

  have exU : ∃ x, U x := ⟨True, Or.inl rfl⟩
  have exV : ∃ x, V x := ⟨False, Or.inl rfl⟩
  sorry
end Hidden

p 为真时,Prop 的所有元素既在 U 中又在 V 中。 当 p 为假时,U 是单元素的 trueV 是单元素的 false

接下来,我们使用 someUV 中各选取一个元素:

namespace Hidden
open Classical
theorem em (p : Prop) : p ∨ ¬p :=
  let U (x : Prop) : Prop := x = True ∨ p
  let V (x : Prop) : Prop := x = False ∨ p
  have exU : ∃ x, U x := ⟨True, Or.inl rfl⟩
  have exV : ∃ x, V x := ⟨False, Or.inl rfl⟩
  let u : Prop := choose exU
  let v : Prop := choose exV

  have u_def : U u := choose_spec exU
  have v_def : V v := choose_spec exV
  sorry
end Hidden

UV 都是析取,所以 u_defv_def 表示四种情况。 在其中一种情况下,u = Truev = False,在所有其他情况下, p 为真。因此我们有:

namespace Hidden
open Classical
theorem em (p : Prop) : p ∨ ¬p :=
  let U (x : Prop) : Prop := x = True ∨ p
  let V (x : Prop) : Prop := x = False ∨ p
  have exU : ∃ x, U x := ⟨True, Or.inl rfl⟩
  have exV : ∃ x, V x := ⟨False, Or.inl rfl⟩
  let u : Prop := choose exU
  let v : Prop := choose exV
  have u_def : U u := choose_spec exU
  have v_def : V v := choose_spec exV
  have not_uv_or_p : u ≠ v ∨ p :=
    match u_def, v_def with
    | Or.inr h, _ => Or.inr h
    | _, Or.inr h => Or.inr h
    | Or.inl hut, Or.inl hvf =>
      have hne : u ≠ v := by simp [hvf, hut, true_ne_false]
      Or.inl hne
  sorry
end Hidden

另一方面,若 p 为真,则由函数的外延性和命题的外延性,可得 UV 相等。 根据 uv 的定义,这蕴含了它们也相等。

namespace Hidden
open Classical
theorem em (p : Prop) : p ∨ ¬p :=
  let U (x : Prop) : Prop := x = True ∨ p
  let V (x : Prop) : Prop := x = False ∨ p
  have exU : ∃ x, U x := ⟨True, Or.inl rfl⟩
  have exV : ∃ x, V x := ⟨False, Or.inl rfl⟩
  let u : Prop := choose exU
  let v : Prop := choose exV
  have u_def : U u := choose_spec exU
  have v_def : V v := choose_spec exV
  have not_uv_or_p : u ≠ v ∨ p :=
    match u_def, v_def with
    | Or.inr h, _ => Or.inr h
    | _, Or.inr h => Or.inr h
    | Or.inl hut, Or.inl hvf =>
      have hne : u ≠ v := by simp [hvf, hut, true_ne_false]
      Or.inl hne
  have p_implies_uv : p → u = v :=
    fun hp =>
    have hpred : U = V :=
      funext fun x =>
        have hl : (x = True ∨ p) → (x = False ∨ p) :=
          fun _ => Or.inr hp
        have hr : (x = False ∨ p) → (x = True ∨ p) :=
          fun _ => Or.inr hp
        show (x = True ∨ p) = (x = False ∨ p) from
          propext (Iff.intro hl hr)
    have h₀ : ∀ exU exV, @choose _ U exU = @choose _ V exV := by
      rw [hpred]; intros; rfl
    show u = v from h₀ _ _
  sorry
end Hidden

将最后两个事实放在一起可以得出期望的结论:

namespace Hidden
open Classical
theorem em (p : Prop) : p ∨ ¬p :=
  let U (x : Prop) : Prop := x = True ∨ p
  let V (x : Prop) : Prop := x = False ∨ p
  have exU : ∃ x, U x := ⟨True, Or.inl rfl⟩
  have exV : ∃ x, V x := ⟨False, Or.inl rfl⟩
  let u : Prop := choose exU
  let v : Prop := choose exV
  have u_def : U u := choose_spec exU
  have v_def : V v := choose_spec exV
  have not_uv_or_p : u ≠ v ∨ p :=
    match u_def, v_def with
    | Or.inr h, _ => Or.inr h
    | _, Or.inr h => Or.inr h
    | Or.inl hut, Or.inl hvf =>
      have hne : u ≠ v := by simp [hvf, hut, true_ne_false]
      Or.inl hne
  have p_implies_uv : p → u = v :=
    fun hp =>
    have hpred : U = V :=
      funext fun x =>
        have hl : (x = True ∨ p) → (x = False ∨ p) :=
          fun _ => Or.inr hp
        have hr : (x = False ∨ p) → (x = True ∨ p) :=
          fun _ => Or.inr hp
        show (x = True ∨ p) = (x = False ∨ p) from
          propext (Iff.intro hl hr)
    have h₀ : ∀ exU exV, @choose _ U exU = @choose _ V exV := by
      rw [hpred]; intros; rfl
    show u = v from h₀ _ _
  match not_uv_or_p with
  | Or.inl hne => Or.inr (mt p_implies_uv hne)
  | Or.inr h   => Or.inl h
end Hidden

排除中律的推论包括双重否定消除、分情况证明和反证法, 所有这些都在 经典逻辑一节 中描述。排除中律和命题外延性律蕴含了命题完备性:

namespace Hidden
open Classical
theorem propComplete (a : Prop) : a = True ∨ a = False :=
  match em a with
  | Or.inl ha => Or.inl (propext (Iff.intro (fun _ => ⟨⟩) (fun _ => ha)))
  | Or.inr hn => Or.inr (propext (Iff.intro (fun h => hn h) (fun h => False.elim h)))
end Hidden

有了选择公理,我们也能得到一个更强的原则,即每个命题都是可判定的。 回想一下 Decidable 可判定性命题集定义如下:

namespace Hidden
class inductive Decidable (p : Prop) where
  | isFalse (h : ¬p) : Decidable p
  | isTrue  (h : p)  : Decidable p
end Hidden

p ∨ ¬ p 不同,它只能消去到 Prop,类型 Decidable p 等效于和类型 Sum p (¬ p),它可以消除至任何类型。 这就是编写「if-then-else(若-则-否则)」表达式所需的数据。

作为经典推理的一个示例,我们使用 choose 来证明,若 f : α → β 是单射的, 且 α 是可居的,则 f 是左逆的。为了定义左逆 linv,我们使用一个依值的 if-then-else 表达式。回忆一下,if h : c then t else edite c (fun h : c => t) (fun h : ¬ c => e) 的记法。在 linv 的定义中, 选择公理使用了两次:首先,为了证明 (∃ a : A, f a = b) 是「可判定的」, 需要选择一个 a,使得 f a = b。请注意,propDecidable 是一个作用域实例, 它通过 open Classical 命令激活。我们使用此实例来证明 if-then-else 表达式。 (还可以参阅 可判命题一节 中的讨论)。

open Classical

noncomputable def linv [Inhabited α] (f : α → β) : β → α :=
  fun b : β => if ex : (∃ a : α, f a = b) then choose ex else default

theorem linv_comp_self {f : α → β} [Inhabited α]
                       (inj : ∀ {a b}, f a = f b → a = b)
                       : linv f ∘ f = id :=
  funext fun a =>
    have ex  : ∃ a₁ : α, f a₁ = f a := ⟨a, rfl⟩
    have feq : f (choose ex) = f a  := choose_spec ex
    calc linv f (f a)
      _ = choose ex := dif_pos ex
      _ = a         := inj feq

从经典逻辑的视角来看,linv 是一个函数。而从构造性视角来看, 它是不可接受的;由于没有实现这样一种函数的通用方法,因此该构造不具备信息量。