Lean 函数式编程

7.3. 实例详解:带类型的查询🔗

当构建一个旨在类似某种其他语言的 API 时,带索引的族非常有用。 它们可用于编写 HTML 构造子库,使其不允许生成无效 HTML;也可用于编码某种配置文件格式的特定规则;还可用于建模复杂的业务约束。 本节描述如何使用带索引的族在 Lean 中编码关系代数的一个子集,作为较简单的技术示范;这些技术可用于构建更强大的数据库查询语言。

该子集使用类型系统来强制字段名互不相交等要求,并使用类型层面的计算将模式反映到查询返回值的类型中。 不过,它并不是一个现实的系统——数据库被表示为链表的链表,类型系统远比 SQL 的类型系统简单,并且关系代数的算子与 SQL 的算子并不真正匹配。 然而,它已经足够大,能够展示有用的原则和技术。

7.3.1. 数据的一个宇宙🔗

在此关系代数中,列中可保存的基础数据可以具有类型 IntStringBool,并由宇宙 DBType 描述:

inductive DBType where | int | string | bool abbrev DBType.asType : DBType Type | .int => Int | .string => String | .bool => Bool

使用 DBType.asType 可使这些码被用作类型。 例如:

"Mount Hood"#eval ("Mount Hood" : DBType.string.asType)
"Mount Hood"

可以比较由这三种数据库类型中的任意一种所描述的值是否相等。 然而,要向 Lean 说明这一点需要一些工作。 直接使用 BEq 会失败:

def DBType.beq (t : DBType) (x y : t.asType) : Bool := failed to synthesize BEq t.asType Hint: Additional diagnostic information may be available using the `set_option diagnostics true` command.x == y
failed to synthesize
  BEq t.asType

Hint: Additional diagnostic information may be available using the `set_option diagnostics true` command.

正如在嵌套有序对宇宙中一样,类型类搜索不会自动检查 t 的值的每一种可能性。 解决方案是使用模式匹配来细化 xy 的类型:

def DBType.beq (t : DBType) (x y : t.asType) : Bool := match t with | .int => x == y | .string => x == y | .bool => x == y

在此函数版本中,xy 在三个相应情形中具有类型 IntStringBool,而这些类型全都有 BEq 实例。 可以使用 DBType.beq 的定义,为由 DBType 编码的类型定义一个 BEq 实例:

instance {t : DBType} : BEq t.asType where beq := t.beq

这不同于码的实例:

instance : BEq DBType where beq | .int, .int => true | .string, .string => true | .bool, .bool => true | _, _ => false

前一个实例允许比较取自这些码所描述类型的值,而后一个实例允许比较这些码本身。

可以使用相同技术编写一个 Repr 实例。 Repr 类的方法称为 reprPrec,因为它被设计为在显示值时考虑诸如运算符优先级之类的因素。 通过依值模式匹配细化类型,可以使用 IntStringBoolRepr 实例中的 reprPrec 方法:

instance {t : DBType} : Repr t.asType where reprPrec := match t with | .int => reprPrec | .string => reprPrec | .bool => reprPrec

7.3.2. 模式与表🔗

模式描述数据库中每一列的名称和类型:

structure Column where name : String contains : DBType abbrev Schema := List Column

事实上,模式可以被看作描述表中行的一个宇宙。 空模式描述单元类型;只有一列的模式单独描述该值;而至少有两列的模式则由一个元组表示:

abbrev Row : Schema Type | [] => Unit | [col] => col.contains.asType | col1 :: col2 :: cols => col1.contains.asType × Row (col2::cols)

关于乘积类型的开头一节 中所述,Lean 的乘积类型和元组是右结合的。 这意味着嵌套有序对等价于普通的扁平元组。

表是共享同一模式的行的列表:

abbrev Table (s : Schema) := List (Row s)

例如,登临山峰的日记可以用模式 peak 表示:

abbrev peak : Schema := [ "name", .string, "location", .string, "elevation", .int, "lastVisited", .int ]

本书作者曾登临的一组选定山峰表现为一个普通的元组列表:

def mountainDiary : Table peak := [ ("Mount Nebo", "USA", 3637, 2013), ("Moscow Mountain", "USA", 1519, 2015), ("Himmelbjerget", "Denmark", 147, 2004), ("Mount St. Helens", "USA", 2549, 2010) ]

另一个例子由瀑布以及到访它们的日记构成:

abbrev waterfall : Schema := [ "name", .string, "location", .string, "lastVisited", .int ]def waterfallDiary : Table waterfall := [ ("Multnomah Falls", "USA", 2018), ("Shoshone Falls", "USA", 2014) ]

7.3.2.1. 递归与宇宙再探🔗

将行方便地组织为元组是有代价的:Row 分别处理其两个基本情形这一事实意味着,在类型中使用 Row 且通过对码(也就是模式)递归来定义的函数,也需要作出相同区分。 一个会受此影响的例子是相等性检查,它通过对模式递归来定义一个检查行是否相等的函数。 此例不能通过 Lean 的类型检查器:

def Row.bEq (r1 r2 : Row s) : Bool := match s with | [] => true | col::cols => match r1, r2 with | Type mismatch (v1, r1') has type ?m.10 × ?m.11 but is expected to have type Row (col :: cols)(v1, r1'), (v2, r2') => v1 == v2 && bEq r1' r2'
Type mismatch
  (v1, r1')
has type
  ?m.10 × ?m.11
but is expected to have type
  Row (col :: cols)

问题在于模式 col :: cols 没有充分细化这些行的类型。 这是因为 Lean 此时尚无法判断匹配到的是 Row 定义中的单元素模式 [col],还是 col1 :: col2 :: cols 模式,因此对 Row 的调用不会计算归约到有序对类型。 解决方案是在 Row.bEq 的定义中镜像 Row 的结构:

def Row.bEq (r1 r2 : Row s) : Bool := match s with | [] => true | [_] => r1 == r2 | _::_::_ => match r1, r2 with | (v1, r1'), (v2, r2') => v1 == v2 && bEq r1' r2' instance : BEq (Row s) where beq := Row.bEq

不同于其他语境,出现在类型中的函数不能仅按其输入/输出行为来理解。 使用这些类型的程序会发现自己被迫镜像类型层面函数所使用的算法,使其结构与该类型的模式匹配和递归行为相一致。 使用依值类型编程的一项重要技能,就是选择具有恰当计算行为的适当类型层面函数。

7.3.2.2. 列指针🔗

有些查询只有在某个模式包含特定列时才有意义。 例如,返回海拔高于 1000 米的山峰的查询,只有在具有一个包含整数的 "elevation" 列的模式语境中才有意义。 表明某列包含在某个模式中的一种方式,是直接提供指向它的指针;而将该指针定义为带索引的族,则可以排除无效指针。

一列可以通过两种方式出现在模式中:要么它位于模式的开头,要么它位于模式中较后的某处。 最终,如果一列位于某个模式中较后的地方,那么它将成为该模式某个尾部的开头。

带索引的族 HasCol 是将该规约翻译为 Lean 代码的结果:

inductive HasCol : Schema String DBType Type where | here : HasCol (name, t :: _) name t | there : HasCol s name t HasCol (_ :: s) name t

该族的三个参数分别是模式、列名以及列的类型。 这三者都是指标,但若将参数重新排序,把模式放在列名和类型之后,就可以使名称和类型成为参数。 当模式以列 name, t 开头时,可以使用构造子 here;因此,它是指向模式中第一列的指针,并且只能在第一列具有所需名称和类型时使用。 构造子 there 将指向较小模式的指针转换为指向在其上多了一列的模式的指针。

因为 "elevation"peak 中的第三列,所以可以用 there 跳过前两列来找到它,此后它就是第一列。 换言之,要满足类型 HasCol peak "elevation" .int,可使用表达式 .there (.there .here)。 理解 HasCol 的一种方式是将其看作某种带有装饰的 Natzero 对应于 here,而 succ 对应于 there。 额外的类型信息使得出现差一错误成为不可能。

指向模式中特定列的指针可用于从行中提取该列的值:

def Row.get (row : Row s) (col : HasCol s n t) : t.asType := match s, col, row with | [_], .here, v => v | _::_::_, .here, (v, _) => v | _::_::_, .there next, (_, r) => get r next

第一步是对模式进行模式匹配,因为这决定了行是元组还是单个值。 不需要为空模式提供情形,因为有一个 HasCol 可用,并且 HasCol 的两个构造子都指定了非空模式。 如果模式只有一列,那么指针必须指向它,因此只需匹配 HasColhere 构造子。 如果模式有两列或更多列,那么必须有一个对应 here 的情形,此时值就是行中的第一个值;还要有一个对应 there 的情形,此时使用递归调用。 因为 HasCol 类型保证该列存在于行中,所以 Row.get 不需要返回 Option

HasCol 扮演两个角色:

  1. 它充当某个具有特定名称和类型的列存在于模式中的证据

  2. 它充当可用于在行中找到与该列关联的值的数据

第一个角色,即证据的角色,类似于命题的使用方式。 指标族 HasCol 的定义可以被解读为:什么算作给定列存在的证据这一规范。 然而,与命题不同,使用了 HasCol 的哪一个构造子是重要的。 在第二个角色中,构造子像 Nat 一样用于在集合中查找数据。 使用指标族编程通常要求能够熟练地在这两种视角之间切换。

7.3.2.3. 子模式🔗

关系代数中的一个重要操作是将表或行投影到一个较小的模式。 所有不存在于较小模式中的列都会被遗忘。 为了使投影有意义,较小的模式必须是较大模式的子模式,这意味着较小模式中的每一列都必须存在于较大模式中。 正如 HasCol 使得可以在行中编写不会失败的单列查找一样,将子模式关系表示为指标族,也使得可以编写不会失败的投影函数。

一个模式作为另一个模式的子模式的各种方式,可以定义为一个指标族。 基本思想是:若较小模式中的每一列都出现在较大模式中,则较小模式就是较大模式的子模式。 如果较小模式为空,那么它当然是较大模式的子模式,这由构造子 nil 表示。 如果较小模式有一列,那么该列必须在较大模式中,并且该子模式中的所有其余列也必须构成较大模式的子模式。 这由构造子 cons 表示。

inductive Subschema : Schema Schema Type where | nil : Subschema [] bigger | cons : HasCol bigger n t Subschema smaller bigger Subschema (n, t :: smaller) bigger

换言之,Subschema 为较小模式的每一列分配一个 HasCol,该 HasCol 指向它在较大模式中的位置。

模式 travelDiary 表示 peakwaterfall 共有的字段:

abbrev travelDiary : Schema := ["name", .string, "location", .string, "lastVisited", .int]

它当然是 peak 的子模式,如下例所示:

example : Subschema travelDiary peak := .cons .here (.cons (.there .here) (.cons (.there (.there (.there .here))) .nil))

然而,像这样的代码难以阅读,也难以维护。 一种改进方式是指示 Lean 自动写出 SubschemaHasCol 构造子。 这可以使用在 关于命题与证明的插曲中介绍的策略功能来完成。 该插曲使用 by decideby simp 为各种命题提供证据。

在此语境中,有两个策略是有用的:

  • constructor 策略指示 Lean 使用某个数据类型的构造子来解决问题。

  • repeat 策略指示 Lean 反复重复某个策略,直到该策略失败或证明完成为止。

在下一个例子中,by constructor 的效果与直接写 .nil 相同:

example : Subschema [] peak := Subschema [] peak All goals completed! 🐙

然而,对一个稍微复杂一些的类型尝试同样的策略会失败:

example : Subschema ["location", .string] peak := unsolved goals HasCol peak "location" DBType.string Subschema [] peakSubschema [{ name := "location", contains := DBType.string }] peak HasCol peak "location" DBType.stringSubschema [] peak
unsolved goals
HasCol peak "location" DBType.string

Subschema [] peak

unsolved goals 开头的错误描述的是未能完全构造出其本应构造的表达式的策略。 在 Lean 的策略语言中,目标是一个类型,策略要在幕后构造适当的表达式来满足它。 在此情形中,constructor 导致 Subschema.cons 被应用,而这两个目标表示 cons 所期望的两个参数。 再添加一个 constructor 实例会使第一个目标(HasCol peak "location" DBType.string)由 HasCol.there 来处理,因为 peak 的第一列不是 "location"

example : Subschema ["location", .string] peak := unsolved goals HasCol [{ name := "location", contains := DBType.string }, { name := "elevation", contains := DBType.int }, { name := "lastVisited", contains := DBType.int }] "location" DBType.string Subschema [] peakSubschema [{ name := "location", contains := DBType.string }] peak HasCol peak "location" DBType.stringSubschema [] peak HasCol [{ name := "location", contains := DBType.string }, { name := "elevation", contains := DBType.int }, { name := "lastVisited", contains := DBType.int }] "location" DBType.stringSubschema [] peak
unsolved goals
HasCol
  [{ name := "location", contains := DBType.string }, { name := "elevation", contains := DBType.int },
    { name := "lastVisited", contains := DBType.int }]
  "location" DBType.string

Subschema [] peak

然而,添加第三个 constructor 会使第一个目标得到解决,因为 HasCol.here 是适用的:

example : Subschema ["location", .string] peak := unsolved goals Subschema [] peakSubschema [{ name := "location", contains := DBType.string }] peak HasCol peak "location" DBType.stringSubschema [] peak HasCol [{ name := "location", contains := DBType.string }, { name := "elevation", contains := DBType.int }, { name := "lastVisited", contains := DBType.int }] "location" DBType.stringSubschema [] peak Subschema [] peak
unsolved goals
Subschema [] peak

第四个 constructor 实例解决了 Subschema peak [] 目标:

example : Subschema ["location", .string] peak := Subschema [{ name := "location", contains := DBType.string }] peak HasCol peak "location" DBType.stringSubschema [] peak HasCol [{ name := "location", contains := DBType.string }, { name := "elevation", contains := DBType.int }, { name := "lastVisited", contains := DBType.int }] "location" DBType.stringSubschema [] peak Subschema [] peak All goals completed! 🐙

事实上,不使用策略写出的版本有四个构造子:

example : Subschema ["location", .string] peak := .cons (.there .here) .nil

与其通过试验来找出应该写多少次 constructor,不如使用 repeat 策略来要求 Lean 只要 constructor 持续取得进展就不断尝试它:

example : Subschema ["location", .string] peak := Subschema [{ name := "location", contains := DBType.string }] peak repeat All goals completed! 🐙

这个更灵活的版本也适用于更有意思的 Subschema 问题:

example : Subschema travelDiary peak := Subschema travelDiary peak repeat All goals completed! 🐙 example : Subschema travelDiary waterfall := Subschema travelDiary waterfall repeat All goals completed! 🐙

盲目尝试构造子直到某个构造子奏效的方法,对于 NatList Bool 这样的类型并不十分有用。 毕竟,一个表达式具有类型 Nat 并不意味着它就是正确的 Nat。 但是像 HasColSubschema 这样的类型受到其指标的充分约束,以至于永远只有一个构造子适用;这意味着程序本身的内容不那么有意思,而计算机可以选出正确的那个。

如果一个模式是另一个模式的子模式,那么它也是在该较大模式上扩展一个额外列之后所得模式的子模式。 这一事实可以用函数定义来刻画。 Subschema.addColumn 接受 smallerbigger 的子模式这一证据,然后返回 smallerc :: bigger 的子模式这一证据,也就是说,返回 bigger 加上一个额外列后的证据:

def Subschema.addColumn : Subschema smaller bigger Subschema smaller (c :: bigger) | .nil => .nil | .cons col sub' => .cons (.there col) sub'.addColumn

子模式描述了在较大模式中何处找到较小模式中的每一列。 Subschema.addColumn 必须将这些描述从原来的较大模式转换到扩展后的较大模式。 在 nil 情形中,较小模式是 [],而 nil 也是 []c :: bigger 的子模式的证据。 在 cons 情形中,它描述了如何把 smaller 中的一列放入 bigger;该列的位置需要用 there 调整,以考虑新增列 c,并且递归调用会调整其余列。

理解 Subschema 的另一种方式是:它定义了两个模式之间的一个关系——存在类型为 Subschema smaller bigger 的表达式,意味着 (smaller, bigger) 处于该关系中。 该关系是自反的,意思是每个模式都是其自身的子模式:

def Subschema.reflexive : (s : Schema) Subschema s s | [] => .nil | _ :: cs => .cons .here (reflexive cs).addColumn

7.3.2.4. 投影行🔗

给定 s's 的子模式这一证据,s 中的一行可以被投影为 s' 中的一行。 这是通过使用 s's 的子模式这一证据完成的,该证据说明了 s' 的每一列在 s 中何处找到。 s' 中的新行通过从旧行中的适当位置取回值,逐列构造出来。

执行此投影的函数 Row.project 有三个情形,对应于 Row 本身的每个情形。 它将 Row.getSubschema 参数中的每个 HasCol 一起使用,以构造投影后的行:

def Row.project (row : Row s) : (s' : Schema) Subschema s' s Row s' | [], .nil => () | [_], .cons c .nil => row.get c | _::_::_, .cons c cs => (row.get c, row.project _ cs)

7.3.3. 条件与选择🔗

投影会从表中移除不需要的列,但查询还必须能够移除不需要的行。 这一操作称为选择。 选择依赖于具备某种表达哪些行是所需行的手段。

示例查询语言包含表达式,它们类似于 SQL 的 WHERE 子句中可以写出的内容。 表达式由指标族 DBExpr 表示。 因为表达式可以引用数据库中的列,但不同的子表达式全都具有相同的模式,所以 DBExpr 将数据库模式作为参数。 此外,每个表达式都有一个类型,而这些类型会变化,因此它是一个指标:

inductive DBExpr (s : Schema) : DBType Type where | col (n : String) (loc : HasCol s n t) : DBExpr s t | eq (e1 e2 : DBExpr s t) : DBExpr s .bool | lt (e1 e2 : DBExpr s .int) : DBExpr s .bool | and (e1 e2 : DBExpr s .bool) : DBExpr s .bool | const : t.asType DBExpr s t

col 构造子表示对数据库中某列的引用。 eq 构造子比较两个表达式是否相等,lt 检查一个表达式是否小于另一个表达式,and 是布尔合取,而 const 是某个类型的常量值。

例如,peak 中的一个表达式若要检查 elevation 列是否大于 1000 且位置是否为 "Denmark",可以写作:

def tallInDenmark : DBExpr peak .bool := .and (.lt (.const 1000) (.col "elevation" (HasCol peak "elevation" DBType.int repeat All goals completed! 🐙))) (.eq (.col "location" (HasCol peak "location" ?m.16 repeat All goals completed! 🐙)) (.const "Denmark"))

这有些冗长。 特别是,对列的引用包含对 by repeat constructor 的样板调用。 Lean 中一种称为的功能可以通过消除这些样板代码来帮助提高表达式的可读性:

macro "c!" n:term : term => `(DBExpr.col $n (by repeat constructor))

此声明向 Lean 添加了 c! 关键字,并指示 Lean 将任何后接表达式的 c! 实例替换为相应的 DBExpr.col 构造。 这里,term 表示 Lean 表达式,而不是命令、策略或语言的其他部分。 Lean 宏有点类似于 C 预处理器宏,但它们更好地集成到语言中,并且会自动避免 CPP 的一些陷阱。 事实上,它们与 Scheme 和 Racket 中的宏关系非常密切。

借助此宏,该表达式可以易读得多:

def tallInDenmark : DBExpr peak .bool := .and (.lt (.const 1000) (c! "elevation")) (.eq (c! "location") (.const "Denmark"))

在给定行的上下文中求表达式的值时,使用 Row.get 提取列引用,并将所有其他表达式委托给 Lean 对值的操作来处理:

def DBExpr.evaluate (row : Row s) : DBExpr s t t.asType | .col _ loc => row.get loc | .eq e1 e2 => evaluate row e1 == evaluate row e2 | .lt e1 e2 => evaluate row e1 < evaluate row e2 | .and e1 e2 => evaluate row e1 && evaluate row e2 | .const v => v

对哥本哈根地区最高的山丘 Valby Bakke 求该表达式的值,得到 false,因为 Valby Bakke 的海拔远低于 1 千米:

false#eval tallInDenmark.evaluate ("Valby Bakke", "Denmark", 31, 2023)
false

对一座虚构的海拔为 1230m 的山求该表达式的值,得到 true

true#eval tallInDenmark.evaluate ("Fictional mountain", "Denmark", 1230, 2023)
true

对美国爱达荷州最高峰求该表达式的值,得到 false,因为爱达荷州不属于丹麦:

false#eval tallInDenmark.evaluate ("Mount Borah", "USA", 3859, 1996)
false

7.3.4. 查询🔗

该查询语言基于关系代数。 除表之外,它还包含以下运算符:

  1. 两个具有相同模式的表达式的并集,将两个查询所得的行合并起来

  2. 两个具有相同模式的表达式的差集,从第一个结果中的行移除第二个结果中出现的行

  3. 按某个准则进行选择,即根据一个表达式过滤查询结果

  4. 投影到一个子模式,从查询结果中移除若干列

  5. 笛卡儿积,将一个查询中的每一行与另一个查询中的每一行组合起来

  6. 重命名查询结果中的一列,这会修改其模式

  7. 给查询中的所有列名加上一个前缀

最后一个运算符并非严格必要,但它使该语言使用起来更加方便。

同样,查询由一个索引族表示:

inductive Query : Schema Type where | table : Table s Query s | union : Query s Query s Query s | diff : Query s Query s Query s | select : Query s DBExpr s .bool Query s | project : Query s (s' : Schema) Subschema s' s Query s' | product : Query s1 Query s2 disjoint (s1.map Column.name) (s2.map Column.name) Query (s1 ++ s2) | renameColumn : Query s (c : HasCol s n t) (n' : String) !((s.map Column.name).contains n') Query (s.renameColumn c n') | prefixWith : (n : String) Query s Query (s.map fun c => {c with name := n ++ "." ++ c.name})

select 构造子要求用于选择的表达式返回一个布尔值。 product 构造子的类型包含一次对 disjoint 的调用,这确保两个模式不共享任何名称:

def disjoint [BEq α] (xs ys : List α) : Bool := not (xs.any ys.contains || ys.any xs.contains)

在期望类型的位置使用类型为 Bool 的表达式,会触发从 BoolProp 的强制类型转换。 正如可判定命题可以被视为布尔值,其中该命题的证据被强制转换为 true,而该命题的反驳被强制转换为 false,布尔值也会被强制转换为一个命题,该命题陈述此表达式等于 true。 由于该库的所有使用都预期发生在模式已预先给定的上下文中,此命题可以用 by simp 证明。 类似地,renameColumn 构造子会检查新名称在模式中尚不存在。 它使用辅助函数 Schema.renameColumn 来改变 HasCol 所指向的列的名称:

def Schema.renameColumn : (s : Schema) HasCol s n t String Schema | c :: cs, .here, n' => {c with name := n'} :: cs | c :: cs, .there next, n' => c :: renameColumn cs next n'

7.3.5. 执行查询🔗

执行查询需要若干辅助函数。 查询的结果是一张表;这意味着查询语言中的每个操作都需要一个作用于表的相应实现。

7.3.5.1. 笛卡儿积🔗

取两张表的笛卡儿积,是通过将第一张表中的每一行追加到第二张表中的每一行来完成的。 首先,由于 Row 的结构,向一行添加单列需要对其模式进行模式匹配,以确定结果将是一个裸值还是一个元组。 因为这是常见操作,将该模式匹配分解到一个辅助函数中会很方便:

def addVal (v : c.contains.asType) (row : Row s) : Row (c :: s) := match s, row with | [], () => v | c' :: cs, v' => (v, v')

追加两行同时按第一个模式和第一行的结构递归,因为行的结构与模式的结构同步推进。 当第一行为空时,追加返回第二行。 当第一行是单元素行时,将该值添加到第二行。 当第一行包含多列时,将第一列的值添加到对该行其余部分递归所得的结果中。

def Row.append (r1 : Row s1) (r2 : Row s2) : Row (s1 ++ s2) := match s1, r1 with | [], () => r2 | [_], v => addVal v r2 | _::_::_, (v, r') => (v, r'.append r2)

标准库中的 List.flatMap 会把一个自身返回列表的函数应用于输入列表中的每个条目,并返回按顺序追加所得列表后的结果:

def List.flatMap (f : α List β) : (xs : List α) List β | [] => [] | x :: xs => f x ++ xs.flatMap f

其类型签名表明 List.flatMap 可用于实现一个 Monad List 实例。 事实上,List.flatMappure x := [x] 一起确实实现了一个单子。 然而,它并不是一个很有用的 Monad 实例。 List 单子基本上是 Many 的一个版本,它会在用户有机会请求某个数量的值之前,预先探索搜索空间中的每一条可能路径。 由于这个性能陷阱,通常不宜为 List 定义 Monad 实例。 然而在这里,查询语言没有用于限制返回结果数量的运算符,因此组合所有可能性正是所期望的行为:

def Table.cartesianProduct (table1 : Table s1) (table2 : Table s2) : Table (s1 ++ s2) := table1.flatMap fun r1 => table2.map r1.append

正如 List.product 一样,身份单子中带有可变状态的循环也可用作另一种实现技术:

def Table.cartesianProduct (table1 : Table s1) (table2 : Table s2) : Table (s1 ++ s2) := Id.run do let mut out : Table (s1 ++ s2) := [] for r1 in table1 do for r2 in table2 do out := (r1.append r2) :: out pure out.reverse

7.3.5.2. 差集🔗

从表中移除不需要的行可以使用 List.filter 完成,它接受一个列表和一个返回 Bool 的函数。 返回的新列表只包含使该函数返回 true 的条目。 例如,

["Willamette", "Columbia", "Sandy", "Deschutes"].filter (·.length > 8)

求值得到

["Willamette", "Deschutes"]

因为 "Columbia""Sandy" 的长度小于或等于 8。 可以使用辅助函数 List.without 来移除表中的条目:

def List.without [BEq α] (source banned : List α) : List α := source.filter fun r => !(banned.contains r)

在解释查询时,这将与 RowBEq 实例一起使用。

7.3.5.3. 重命名列🔗

重命名一行中的列,是用一个递归函数遍历该行,直到找到所讨论的列;此时,具有新名称的列获得与具有旧名称的列相同的值:

def Row.rename (c : HasCol s n t) (row : Row s) : Row (s.renameColumn c n') := match s, row, c with | [_], v, .here => v | _::_::_, (v, r), .here => (v, r) | _::_::_, (v, r), .there next => addVal v (r.rename next)

虽然此函数改变了其参数的类型,但实际返回值所包含的数据与原参数完全相同。 从运行时角度看,Row.rename 不过是一个缓慢的恒等函数。 使用索引族编程的一个困难在于,当性能很重要时,这类操作可能会造成妨碍。 要消除这些“重新索引”函数,需要非常谨慎且常常较为脆弱的设计。

7.3.5.4. 给列名加前缀🔗

给列名添加前缀与重命名列非常相似。 不同的是,prefixRow 不能前进到某个目标列后就返回,而必须处理所有列:

def prefixRow (row : Row s) : Row (s.map fun c => {c with name := n ++ "." ++ c.name}) := match s, row with | [], _ => () | [_], v => v | _::_::_, (v, r) => (v, prefixRow r)

这可以与 List.map 一起使用,以便给表中的所有行添加前缀。 同样,此函数的存在只是为了改变一个值的类型。

7.3.5.5. 组合各个部分🔗

定义完所有这些辅助函数后,执行查询只需要一个简短的递归函数:

def Query.exec : Query s Table s | .table t => t | .union q1 q2 => exec q1 ++ exec q2 | .diff q1 q2 => exec q1 |>.without (exec q2) | .select q e => exec q |>.filter e.evaluate | .project q _ sub => exec q |>.map (·.project _ sub) | .product q1 q2 _ => exec q1 |>.cartesianProduct (exec q2) | .renameColumn q c _ _ => exec q |>.map (·.rename c) | .prefixWith _ q => exec q |>.map prefixRow

构造子的某些参数在执行期间不会使用。 特别是,构造子 project 和函数 Row.project 都将较小的模式作为显式参数,但该模式是较大模式的子模式这一证据的类型包含了足够信息,使 Lean 能够自动填充该参数。 类似地,product 构造子所要求的两张表具有互不相交的列名这一事实,对于 Table.cartesianProduct 并不需要。 一般而言,依值类型提供了许多机会,使 Lean 能够代表程序员填充参数。

对查询结果使用点记法,可以调用在 TableList 命名空间中定义的函数,例如 List.mapList.filterTable.cartesianProduct。 这之所以可行,是因为 Table 是使用 abbrev 定义的。 就像类型类搜索一样,点记法可以穿透由 abbrev 创建的定义。

select 的实现也相当简洁。 执行查询 q 后,使用 List.filter 移除不满足该表达式的行。 List.filter 期望一个从 Row sBool 的函数,但 DBExpr.evaluate 的类型是 Row s DBExpr s t t.asType。 由于 select 构造子的类型要求该表达式具有类型 DBExpr s .bool,在此上下文中 t.asType 实际上就是 Bool

一个查找所有海拔超过 500 米的山峰高度的查询可以写为:

open Query in def example1 := table mountainDiary |>.select (.lt (.const 500) (c! "elevation")) |>.project ["elevation", .int] (Subschema [{ name := "elevation", contains := DBType.int }] peak repeat All goals completed! 🐙)

执行它会返回预期的整数列表:

[3637, 1519, 2549]#eval example1.exec
[3637, 1519, 2549]

为了规划观光游览,将同一地点的所有山峰和瀑布配对可能是有意义的。 这可以通过取两张表的笛卡儿积、只选择其中位置相等的行,然后投影出名称来完成:

open Query in def example2 := let mountain := table mountainDiary |>.prefixWith "mountain" let waterfall := table waterfallDiary |>.prefixWith "waterfall" mountain.product waterfall (mountain:Query (List.map (fun c => { name := "mountain" ++ "." ++ c.name, contains := c.contains }) peak) := prefixWith "mountain" (table mountainDiary)waterfall:Query (List.map (fun c => { name := "waterfall" ++ "." ++ c.name, contains := c.contains }) _root_.waterfall) := prefixWith "waterfall" (table waterfallDiary)disjoint (List.map Column.name (List.map (fun c => { name := "mountain" ++ "." ++ c.name, contains := c.contains }) peak)) (List.map Column.name (List.map (fun c => { name := "waterfall" ++ "." ++ c.name, contains := c.contains }) _root_.waterfall)) = true All goals completed! 🐙) |>.select (.eq (c! "mountain.location") (c! "waterfall.location")) |>.project ["mountain.name", .string, "waterfall.name", .string] (mountain:Query (List.map (fun c => { name := "mountain" ++ "." ++ c.name, contains := c.contains }) peak) := prefixWith "mountain" (table mountainDiary)waterfall:Query (List.map (fun c => { name := "waterfall" ++ "." ++ c.name, contains := c.contains }) _root_.waterfall) := prefixWith "waterfall" (table waterfallDiary)Subschema [{ name := "mountain.name", contains := DBType.string }, { name := "waterfall.name", contains := DBType.string }] (List.map (fun c => { name := "mountain" ++ "." ++ c.name, contains := c.contains }) peak ++ List.map (fun c => { name := "waterfall" ++ "." ++ c.name, contains := c.contains }) _root_.waterfall) repeat All goals completed! 🐙)

由于示例数据只包含美国的瀑布,执行该查询会返回美国境内的山峰和瀑布配对:

[("Mount Nebo", "Multnomah Falls"), ("Mount Nebo", "Shoshone Falls"), ("Moscow Mountain", "Multnomah Falls"), ("Moscow Mountain", "Shoshone Falls"), ("Mount St. Helens", "Multnomah Falls"), ("Mount St. Helens", "Shoshone Falls")]#eval example2.exec
[("Mount Nebo", "Multnomah Falls"), ("Mount Nebo", "Shoshone Falls"), ("Moscow Mountain", "Multnomah Falls"),
  ("Moscow Mountain", "Shoshone Falls"), ("Mount St. Helens", "Multnomah Falls"),
  ("Mount St. Helens", "Shoshone Falls")]

7.3.5.6. 你可能遇到的错误🔗

许多潜在错误都被 Query 的定义排除了。 例如,若忘记 "mountain.location" 中添加的限定符,会产生一个编译时错误,并高亮显示列引用 c! "location"

open Query in def example2 := let mountains := table mountainDiary |>.prefixWith "mountain" let waterfalls := table waterfallDiary |>.prefixWith "waterfall" mountains.product waterfalls (unsolved goals mountains:Query (List.map (fun c => { name := "mountain" ++ "." ++ c.name, contains := c.contains }) peak) := prefixWith "mountain" (table mountainDiary)waterfalls:Query (List.map (fun c => { name := "waterfall" ++ "." ++ c.name, contains := c.contains }) waterfall) := prefixWith "waterfall" (table waterfallDiary)disjoint ["mountain.name", "mountain.location", "mountain.elevation", "mountain.lastVisited"] ["waterfall.name", "waterfall.location", "waterfall.lastVisited"] = truemountains:Query (List.map (fun c => { name := "mountain" ++ "." ++ c.name, contains := c.contains }) peak) := prefixWith "mountain" (table mountainDiary)waterfalls:Query (List.map (fun c => { name := "waterfall" ++ "." ++ c.name, contains := c.contains }) waterfall) := prefixWith "waterfall" (table waterfallDiary)disjoint (List.map Column.name (List.map (fun c => { name := "mountain" ++ "." ++ c.name, contains := c.contains }) peak)) (List.map Column.name (List.map (fun c => { name := "waterfall" ++ "." ++ c.name, contains := c.contains }) waterfall)) = true mountains:Query (List.map (fun c => { name := "mountain" ++ "." ++ c.name, contains := c.contains }) peak) := prefixWith "mountain" (table mountainDiary)waterfalls:Query (List.map (fun c => { name := "waterfall" ++ "." ++ c.name, contains := c.contains }) waterfall) := prefixWith "waterfall" (table waterfallDiary)disjoint ["mountain.name", "mountain.location", "mountain.elevation", "mountain.lastVisited"] ["waterfall.name", "waterfall.location", "waterfall.lastVisited"] = true) |>.select (.eq (unsolved goals mountains:Query (List.map (fun c => { name := "mountain" ++ "." ++ c.name, contains := c.contains }) peak) := waterfalls:Query (List.map (fun c => { name := "waterfall" ++ "." ++ c.name, contains := c.contains }) waterfall) := HasCol (List.map (fun c => { name := "waterfall" ++ "." ++ c.name, contains := c.contains }) []) "location" ?m.31c! "location") (c! "waterfall.location")) |>.project ["mountain.name", .string, "waterfall.name", .string] (mountains:Query (List.map (fun c => { name := "mountain" ++ "." ++ c.name, contains := c.contains }) peak) := prefixWith "mountain" (table mountainDiary)waterfalls:Query (List.map (fun c => { name := "waterfall" ++ "." ++ c.name, contains := c.contains }) waterfall) := prefixWith "waterfall" (table waterfallDiary)Subschema [{ name := "mountain.name", contains := DBType.string }, { name := "waterfall.name", contains := DBType.string }] (List.map (fun c => { name := "mountain" ++ "." ++ c.name, contains := c.contains }) peak ++ List.map (fun c => { name := "waterfall" ++ "." ++ c.name, contains := c.contains }) waterfall) repeat All goals completed! 🐙)

这是极好的反馈! 另一方面,错误消息的文本却很难据此采取行动:

unsolved goals
mountains:Query (List.map (fun c => { name := "mountain" ++ "." ++ c.name, contains := c.contains }) peak) := waterfalls:Query (List.map (fun c => { name := "waterfall" ++ "." ++ c.name, contains := c.contains }) waterfall) := HasCol (List.map (fun c => { name := "waterfall" ++ "." ++ c.name, contains := c.contains }) []) "location" ?m.31

类似地,若忘记给两张表的名称添加前缀,会在 by decide 处产生错误;这里本应提供证据,表明这些模式事实上互不相交:

open Query in def example2 := let mountains := table mountainDiary let waterfalls := table waterfallDiary mountains.product waterfalls (mountains:Query peak := table mountainDiarywaterfalls:Query waterfall := table waterfallDiarydisjoint (List.map Column.name peak) (List.map Column.name waterfall) = true Tactic `decide` proved that the proposition disjoint (List.map Column.name peak) (List.map Column.name waterfall) = true is falsemountains:Query peak := table mountainDiarywaterfalls:Query waterfall := table waterfallDiarydisjoint (List.map Column.name peak) (List.map Column.name waterfall) = true) |>.select (.eq (unsolved goals mountains:Query peak := waterfalls:Query waterfall := HasCol [] "mountain.location" ?m.29c! "mountain.location") (unsolved goals mountains:Query peak := waterfalls:Query waterfall := HasCol [] "waterfall.location" ?m.29c! "waterfall.location")) |>.project ["mountain.name", .string, "waterfall.name", .string] (unsolved goals mountains:Query peak := waterfalls:Query waterfall := HasCol [] "mountain.name" DBType.string mountains:Query peak := waterfalls:Query waterfall := Subschema [{ name := "waterfall.name", contains := DBType.string }] (peak ++ waterfall)mountains:Query peak := table mountainDiarywaterfalls:Query waterfall := table waterfallDiarySubschema [{ name := "mountain.name", contains := DBType.string }, { name := "waterfall.name", contains := DBType.string }] (peak ++ waterfall) repeat mountains:Query peak := table mountainDiarywaterfalls:Query waterfall := table waterfallDiaryHasCol [] "mountain.name" DBType.stringmountains:Query peak := table mountainDiarywaterfalls:Query waterfall := table waterfallDiarySubschema [{ name := "waterfall.name", contains := DBType.string }] (peak ++ waterfall))

这条错误消息更有帮助:

Tactic `decide` proved that the proposition
  disjoint (List.map Column.name peak) (List.map Column.name waterfall) = true
is false

Lean 的宏系统包含了所需的一切,不仅能为查询提供方便的语法,还能安排生成有帮助的错误消息。 遗憾的是,描述如何用 Lean 宏实现语言超出了本书范围。 像 Query 这样的索引族,作为有类型数据库交互库的核心或许最合适,而不是作为其用户界面。

7.3.6. 练习🔗

7.3.6.1. 日期

定义一个表示日期的结构。将其加入 DBType 宇宙,并相应地更新其余代码。提供看起来必要的额外 DBExpr 构造子。

7.3.6.2. 可空类型

通过用以下结构表示数据库类型,为查询语言添加对可空列的支持:

structure NDBType where underlying : DBType nullable : Bool abbrev NDBType.asType (t : NDBType) : Type := if t.nullable then Option t.underlying.asType else t.underlying.asType

ColumnDBExpr 中用此类型替代 DBType,并查阅 SQL 关于 NULL 和比较运算符的规则,以确定 DBExpr 的构造子的类型。

7.3.6.3. 试验策略

要求 Lean 使用 by repeat constructor 查找以下类型的值,其结果是什么?解释为什么每个都会得到相应的结果。