Lean 函数式编程

1.5. 数据类型与模式🔗

结构使得多个相互独立的数据片段能够组合成一个连贯的整体,并由一个全新的类型来表示。 像结构这样把一组值组合在一起的类型称为积类型。 然而,许多领域概念无法自然地表示为结构。 例如,一个应用程序可能需要跟踪用户权限,其中一些用户是文档所有者,一些用户可以编辑文档,而另一些用户只能读取文档。 计算器具有若干二元运算符,例如加法、减法和乘法。 结构并不提供一种简便方式来编码多种选择。

类似地,虽然结构体是跟踪固定字段集合的绝佳方式,但许多应用需要可能包含任意数量元素的数据。 多数经典数据结构,如树和列表,都具有递归结构:列表的尾部本身是一个列表,或者二叉树的左右分支本身也是二叉树。 在前述计算器中,表达式自身的结构是递归的。 例如,加法表达式中的各个加数本身可能是乘法表达式。

允许选择的数据类型称为和类型,而能够包含其自身实例的数据类型称为递归数据类型。 递归和类型称为归纳数据类型,因为可以使用数学归纳法来证明关于它们的陈述。 在编程时,归纳数据类型通过模式匹配和递归函数来使用。

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

inductive Bool where | false : Bool | true : Bool

此定义有两个主要部分。 第一行给出了新类型的名称(Bool),而其余各行分别描述一个构造子。 与结构的构造子一样,归纳数据类型的构造子只是惰性的接收者和其他数据的容器,而不是插入任意初始化和验证代码的位置。 不同于结构,归纳数据类型可以有多个构造子。 这里有两个构造子,truefalse,且二者都不接受任何实参。 正如结构声明会把其名称放入一个以所声明类型命名的命名空间中,归纳数据类型也会把其构造子的名称放入一个命名空间中。 在 Lean 标准库中,truefalse 从此命名空间重新导出,因此它们可以单独书写,而不必分别写作 Bool.trueBool.false

从数据建模的角度看,归纳数据类型用于许多与其他语言中密封抽象类类似的场景。 在 C# 或 Java 这样的语言中,可以写出类似的 Bool 定义:

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

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

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

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

这里,zero 表示 0,而 succ 表示某个其他数的后继。 在 succ 的声明中提到的 Nat 正是正在被定义的类型 Nat 本身。 后继的意思是“比……大一”,因此五的后继是六,32,185 的后继是 32,186。 使用这个定义,4 表示为 Nat.succ (Nat.succ (Nat.succ (Nat.succ Nat.zero)))。 这个定义几乎就像 Bool 的定义,只是名称略有不同。 唯一真正的区别是 succ 后面跟着 (n : Nat),它指定构造子 succ 接受一个类型为 Nat 的参数,而该参数恰好命名为 n。 名称 zerosucc 位于一个以其类型命名的命名空间中,因此必须分别以 Nat.zeroNat.succ 引用它们。

参数名称(例如 n)可能出现在 Lean 的错误消息中,也可能出现在编写数学证明时提供的反馈中。 Lean 还提供了一种可选语法,用于按名称提供参数。 不过,一般而言,参数名称的选择不如结构字段名称的选择重要,因为它并不构成 API 的那么大一部分。

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

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

正如上面的 Bool 示例一样,这定义的类型比 Lean 中的对应物更多。 此外,这个示例凸显出:Lean 数据类型的构造子更像抽象类的子类,而不像 C# 或 Java 中的构造函数,因为这里展示的构造函数包含要执行的初始化代码。

和类型也类似于在 TypeScript 中使用字符串标签来编码可辨识联合。 在 TypeScript 中,Nat 可以如下定义:

interface Zero {
    tag: "zero";
}

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

type Nat = Zero | Succ;

就像 C# 和 Java 一样,这种编码最终得到的类型比 Lean 中更多,因为 ZeroSucc 各自都是独立的类型。 它还说明,Lean 的构造子对应于 JavaScript 或 TypeScript 中包含标签的对象,该标签用于标识其内容。

1.5.1. 模式匹配🔗

在许多语言中,使用这类数据时,首先用 instance-of 运算符检查收到的是哪个子类,然后读取该给定子类中可用字段的值。 instance-of 检查决定运行哪段代码,从而确保该代码所需的数据可用,而字段本身则提供这些数据。 在 Lean 中,这两个目的由模式匹配同时实现。

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

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

match 表达式获得函数的参数 n 以进行解构。 如果 n 是由 Nat.zero 构造的,那么将采用模式匹配的第一个分支,结果为 true。 如果 n 是由 Nat.succ 构造的,那么将采用第二个分支,结果为 false

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

isZero 5 的求值过程类似:

isZero 中模式第二个分支里的 k 并非装饰性的。 它使作为 Nat.succ 的参数的 Nat 以给定名称变得可见。 随后可以使用这个较小的数来计算表达式的最终结果。

正如某个数 n 的后继比 n 大一(即 n + 1)一样,一个数的前驱比它小一。 如果 pred 是求 Nat 的前驱的函数,那么下面的示例应当得到预期结果:

4#eval pred 5
4
838#eval pred 839
838

因为 Nat 不能表示负数,Nat.zero 有些令人困惑。 通常,在使用 Nat 时,原本会产生负数的运算符会被重新定义为产生 zero 本身:

0#eval pred 0
0

要寻找 Nat 的前驱,第一步是检查用哪个构造子创建了它。 如果它是 Nat.zero,那么结果是 Nat.zero。 如果它是 Nat.succ,那么名称 k 用来指称其下方的 Nat。 而这个 Nat 正是所需的前驱,因此 Nat.succ 分支的结果是 k

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

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

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

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

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

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

1.5.2. 递归函数🔗

引用正在被定义的名称的定义称为递归定义。 归纳数据类型允许递归;事实上,Nat 就是这种数据类型的一个例子,因为 succ 要求另一个 Nat。 递归数据类型可以表示任意大的数据,只受可用内存等技术因素限制。 正如不可能在数据类型定义中为每个自然数写出一个构造子,也不可能为每一种可能性写出一个模式匹配分支。

递归数据类型与递归函数相辅相成。 一个关于 Nat 的简单递归函数会检查其参数是否为偶数。 在此情形中,Nat.zero 是偶数。 像这样的非递归代码分支称为基本情形。 奇数的后继是偶数,偶数的后继是奇数。 这意味着,用 Nat.succ 构造出的数为偶数,当且仅当它的参数不是偶数。

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

这种思考模式是为 Nat 编写递归函数时的典型方式。 首先,确定对 Nat.zero 应当做什么。 然后,确定如何把任意 Nat 的结果转换为其后继的结果,并将这个转换应用于递归调用的结果。 这种模式称为结构递归

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

def fail to show termination for evenLoops with errors failed to infer structural recursion: Not considering parameter n of evenLoops: it is unchanged in the recursive calls no parameters suitable for structural recursion well-founded recursion cannot be used, `evenLoops` does not take any (non-fixed) argumentsevenLoops (n : Nat) : Bool := match n with | Nat.zero => true | Nat.succ k => not (evenLoops n)

该错误消息的重要部分在于,Lean 无法判定这个递归函数总是会到达一个基本情形(因为它并不会)。

fail to show termination for
  evenLoops
with errors
failed to infer structural recursion:
Not considering parameter n of evenLoops:
  it is unchanged in the recursive calls
no parameters suitable for structural recursion

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

尽管加法接受两个参数,但只需要检查其中一个。 要把零加到一个数 n 上,只需返回 n。 要把 k 的后继加到 n 上,则取将 k 加到 n 的结果的后继。

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

plus 的定义中,选择名称 k' 是为了表明它与实参 k 有关联,但并不相同。 例如,逐步考察 plus 3 2 的求值过程,会得到以下步骤:

理解加法的一种方式是:n + kNat.succ 作用 k 次于 n。 类似地,乘法 n × kn 与自身相加 k 次,而减法 n - kn 的前驱 k 次。

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

并非每个函数都能容易地用结构递归来编写。 把加法理解为反复进行 Nat.succ,把乘法理解为反复进行加法,把减法理解为反复进行前驱,这些理解提示我们可将除法实现为反复进行减法。 在这种情况下,如果被除数小于除数,则结果为零。 否则,结果就是用被除数减去除数后再除以除数所得结果的后继。

def fail to show termination for div with errors failed to infer structural recursion: Not considering parameter k of div: it is unchanged in the recursive calls Cannot use parameter k: failed to eliminate recursive application div (n - k) k failed to prove termination, possible solutions: - Use `have`-expressions to prove the remaining goals - Use `termination_by` to specify a different well-founded relation - Use `decreasing_by` to specify your own tactic for discharging this kind of goal k n:Nath✝:¬n < kn - k < ndiv (n : Nat) (k : Nat) : Nat := if n < k then 0 else Nat.succ (div (n - k) k)

只要第二个参数不是 0,该程序就会终止,因为它总是朝着基本情形取得进展。 然而,它并不是结构递归的,因为它并不遵循先为零寻找结果、再将较小 Nat 的结果转换为其后继的结果这一模式。 特别地,该函数的递归调用被应用于另一个函数调用的结果,而不是应用于输入构造子的参数。 因此,Lean 用以下消息拒绝它:

fail to show termination for
  div
with errors
failed to infer structural recursion:
Not considering parameter k of div:
  it is unchanged in the recursive calls
Cannot use parameter k:
  failed to eliminate recursive application
    div (n - k) k


failed to prove termination, possible solutions:
  - Use `have`-expressions to prove the remaining goals
  - Use `termination_by` to specify a different well-founded relation
  - Use `decreasing_by` to specify your own tactic for discharging this kind of goal
k n:Nath✝:¬n < kn - k < n

这条消息意味着 div 需要一个手工的终止性证明。 这一主题将在 最后一章中探讨。