强制转换

在数学中,用同一个符号来在不同的语境中代表数学对象的不同方面是很常见的。 例如,如果在一个需要集合的语境中给出了一个环,那么理解为该环对应的集合也是很有道理的。 在编程语言中,有一些规则自动地将一种类型转换为另一种类型也是很常见的。 例如,Java 允许 byte 自动转换为一个 int,Kotlin 也允许非空类型在可为空的语境中使用。

在 Lean 中,这两个目的都是用一个叫做 强制转换(coercions) 的机制实现的。 当 Lean 遇到了在某语境中某表达式的类型与期望类型不一致时,Lean 在报错前会尝试进行强制转换。 不像 Java,C,和 Kotlin,强制转换是通过定义类型类实例实现的,并且是可扩展的。

正数

例如,每个正数都对应一个自然数。 之前定义的函数 Pos.toNat 可以将一个 Pos 转换成对应的 Nat

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

函数 List.drop,的类型是 {α : Type} → Nat → List α → List α,它将列表的前缀移除。 将 List.drop 应用到 Pos 会产生一个类型错误:

[1, 2, 3, 4].drop (2 : Pos)
application type mismatch
  List.drop 2
argument
  2
has type
  Pos : Type
but is expected to have type
  Nat : Type

因为 List.drop 的作者没有让它成为一个类型类的方法,所以它没有办法通过定义新实例的方式来重写。

Coe 类型类描述了类型间强制转换的重载方法。

class Coe (α : Type) (β : Type) where
  coe : α → β

一个 Coe Pos Nat 的实例就足够让先前的代码正常工作了。

instance : Coe Pos Nat where
  coe x := x.toNat

#eval [1, 2, 3, 4].drop (2 : Pos)
[3, 4]

#check 来看隐藏在幕后的实例搜索。

#check [1, 2, 3, 4].drop (2 : Pos)
List.drop (Pos.toNat 2) [1, 2, 3, 4] : List Nat

链式强制转换

在寻找强制转换时,Lean 会尝试通过一系列较小的强制转换来组成一个完整的强制转换。 例如,已经存在一个从 NatInt 的强制转换实例。 由于这个实例结合了 Coe Pos Nat 实例,我们就可以写出下面的代码:

def oneInt : Int := Pos.one

这个定义用到了两个强制转换:从 PosNat,再从 NatInt

Lean 编译器在存在循环强制转换的情况下不会陷入无限循环。 例如,即使两个类型 AB 可以互相强制转换,在转换中 Lean 也可以找到一个路径。

inductive A where
  | a

inductive B where
  | b

instance : Coe A B where
  coe _ := B.b

instance : Coe B A where
  coe _ := A.a

instance : Coe Unit A where
  coe _ := A.a

def coercedToB : B := ()

提示:双括号 () 是构造子 Unit.unit 的简写。 在派生 Repr B 实例后,

#eval coercedToB

结果为:

B.b

Option 类型类似于 C# 和 Kotlin 中可为空的类型:none 构造子就代表了一个不存在的值。 Lean 标准库定义了一个从任意类型 αOption α 的强制转换,效果是会将值包裹在 some 中。 这使得 option 类型用起来更像是其他语言中可为空的类型,因为 some 是可以忽略的。 例如,可以找到列表中最后一个元素的函数 List.getLast?,就可以直接返回值 x 而无需加上 some

def List.last? : List α → Option α
  | [] => none
  | [x] => x
  | _ :: x :: xs => last? (x :: xs)

实例搜索找到强制转换,并插入对 coe 的调用,该调用会将参数包装在 some 中。这些强制转换可以是链式的,这样嵌套使用 Option 时就不需要嵌套的 some 构造子:

def perhapsPerhapsPerhaps : Option (Option (Option String)) :=
  "Please don't tell me"

仅当 Lean 遇到推断出的类型和剩下的程序需要的类型不匹配时,才会自动使用强制转换。 在遇到其它错误时,强制转换不会被使用。 例如,如果遇到的错误是实例缺失,强制类型转换不会被使用:

def perhapsPerhapsPerhapsNat : Option (Option (Option Nat)) :=
  392
failed to synthesize instance
  OfNat (Option (Option (Option Nat))) 392

这可以通过手动指定 OfNat 所需的类型来解决:

def perhapsPerhapsPerhapsNat : Option (Option (Option Nat)) :=
  (392 : Nat)

此外,强制转换用一个上箭头手动调用。

def perhapsPerhapsPerhapsNat : Option (Option (Option Nat)) :=
  ↑(392 : Nat)

在一些情况下,这可以保证 Lean 找到了正确的实例。 这也会让程序员的意图更加清晰。

非空列表与依值强制转换

β 类型中的值可以对应每一个 α 类型中的值时,Coe α β 实例才是合理的。 将 Nat 强制转换为 Int 是合理的,因为 Int 类型中包含了全部的自然数。 类似地,一个从非空列表到常规列表的强制转换也是合理的,因为 List 类型可以表示每一个非空列表:

instance : Coe (NonEmptyList α) (List α) where
  coe
    | { head := x, tail := xs } => x :: xs

这使得非空列表可以使用全部的 List API。

另一方面,我们不可能写出一个 Coe (List α) (NonEmptyList α) 的实例,因为没有任何一个非空列表可以表示一个空列表。 这个限制可以通过其他方式的强制转换来解决,该强制转换被称为 依值强制转换(dependent coercions) 。 当是否能将一种类型强制转换到另一种类型依赖于具体的值时,依值强制转换就派上用场了。 就像 OfNat 类型类需要具体的 Nat 来作为参数,依值强制转换也接受要被强制转换的值作为参数:

class CoeDep (α : Type) (x : α) (β : Type) where
  coe : β

这可以使得只选取特定的值,通过加上进一步的类型类约束或者直接写出特定的构造子。 例如,任意非空的 List 都可以被强制转换为一个 NonEmptyList

instance : CoeDep (List α) (x :: xs) (NonEmptyList α) where
  coe := { head := x, tail := xs }

强制转换为类型(本节中 sort 的翻译待讨论

在数学中,一个建立在集合上,但是比集合具有额外的结构的概念是很常见的。 例如,一个幺半群就是一些集合 S,一个 S 中的元素 s,以及一个 S 上结合的二元运算,使得 s 在运算的左侧和右侧都是中性的。 S 是这个幺半群的“载体集”。 自然数集上的零和加法构成一个幺半群,因为加法是满足结合律的,并且为任何一个数字加零都是恒等的。 类似地,自然数上的一和乘法也构成一个幺半群。 幺半群在函数式编程中的应用也很广泛:列表,空列表,和连接运算符构成一个幺半群。 字符串,空字符串,和连接运算符也构成一个幺半群:

structure Monoid where
  Carrier : Type
  neutral : Carrier
  op : Carrier → Carrier → Carrier

def natMulMonoid : Monoid :=
  { Carrier := Nat, neutral := 1, op := (· * ·) }

def natAddMonoid : Monoid :=
  { Carrier := Nat, neutral := 0, op := (· + ·) }

def stringMonoid : Monoid :=
  { Carrier := String, neutral := "", op := String.append }

def listMonoid (α : Type) : Monoid :=
  { Carrier := List α, neutral := [], op := List.append }

给定一个幺半群,我们就可以写出一个 foldMap 函数,该函数在一次遍历中将整个列表中的元素映射到载体集中,然后使用幺半群的运算符将它们组合起来。 由于幺半群有单位元,所以当列表为空时我们就可以返回这个值。 又因为运算符是满足结合律的,这个函数的用户不需要关心函数结合元素的顺序到底是从左到右的还是从右到左的。

def foldMap (M : Monoid) (f : α → M.Carrier) (xs : List α) : M.Carrier :=
  let rec go (soFar : M.Carrier) : List α → M.Carrier
    | [] => soFar
    | y :: ys => go (M.op soFar (f y)) ys
  go M.neutral xs

尽管一个幺半群是由三部分信息组成的,但在提及它的载体集时使用幺半群的名字也是很常见的。 说“令 A 为一个幺半群,并令 xyA 中的元素”是很常见的,而不是说“令 A 为一个幺半群,并令 xy 为载体集中的元素”。 这种方式可以通过定义一种新的强制转换来在 Lean 中实现,该转换从幺半群到它的载体集。

CoeSort 类型类和 Coe 大同小异,只是要求强制转换的目标一定要是一个 sort,即 TypeProp。 词语 sort 指的是这些分类其他类型的类型——Type 分类那些本身分类数据的类型,而 Prop 分类那些本身分类其真实性证据的命题。 正如在类型不匹配时会检查 Coe 一样,当在预期为 sort 的上下文中提供了其他东西时,会使用 CoeSort

从一个幺半群到它的载体集的强制转换会返回该载体集:

instance : CoeSort Monoid Type where
  coe m := m.Carrier

有了这个强制转换,类型签名变得不那么繁琐了:

def foldMap (M : Monoid) (f : α → M) (xs : List α) : M :=
  let rec go (soFar : M) : List α → M
    | [] => soFar
    | y :: ys => go (M.op soFar (f y)) ys
  go M.neutral xs

另一个有用的 CoeSort 使用场景是它可以让 BoolProp 建立联系。 就像在有序性和等价性那一节我们提到的,Lean 的 if 表达式需要条件为一个可判定的命题而不是一个 Bool。 然而,程序通常需要能够根据布尔值进行分支。 Lean 标准库并没有定义两种 if 表达式,而是定义了一种从 Bool 到命题的强制转换,即该 Bool 值等于 true

instance : CoeSort Bool Prop where
  coe b := b = true

如此,这个 sort 将是一个 Prop 而不是 Bool

强制转换为函数 (本节翻译需要润色

许多在编程中常见的数据类型都会有一个函数和一些额外的信息组成。 例如,一个函数可能附带一个名称以在日志中显示,或附带一些配置数据。 此外,将一个类型放在结构体的字段中(类似于 Monoid 的例子)在某些上下文中是有意义的,这些上下文中存在多种实现操作的方法,并且需要比类型类允许的更手动的控制。 例如,JSON 序列化器生成的值的具体细节可能很重要,因为另一个应用程序期望特定的格式。 有时,仅从配置数据就可以推导出函数本身。

CoeFun 类型类可以将非函数类型的值转换为函数类型的值。 CoeFun 有两个参数:第一个是需要被转变为函数的值的类型,第二个是一个输出参数,决定了到底应该转换为哪个函数类型。

class CoeFun (α : Type) (makeFunctionType : outParam (α → Type)) where
  coe : (x : α) → makeFunctionType x

第二个参数本身是一个可以计算类型的函数。 在 Lean 中,类型是一等公民,可以作为函数参数被传递,也可以作为返回值,就像其他东西一样。

例如,一个将常量加到其参数的函数可以表示为围绕要添加的量的包装,而不是通过定义一个实际的函数:

structure Adder where
  howMuch : Nat

一个为参数加上5的函数的 howMuch 字段为 5

def add5 : Adder := ⟨5⟩

这个 Adder 类型并不是一个函数,将它应用到一个参数会报错:

#eval add5 3
function expected at
  add5
term has type
  Adder

定义一个 CoeFun 实例让 Lean 来将 adder 转换为一个 Nat → Nat 的函数:

instance : CoeFun Adder (fun _ => Nat → Nat) where
  coe a := (· + a.howMuch)

#eval add5 3
8

因为所有的 Adder 都应该被转换为 Nat → Nat 的函数,CoeFun 的第二个参数就被省略了。

当我们需要这个值来决定正确的函数类型时,CoeFun 的第二个参数就派上用场了。 例如,给定下面的 JSON 值表示:

inductive JSON where
  | true : JSON
  | false : JSON
  | null : JSON
  | string : String → JSON
  | number : Float → JSON
  | object : List (String × JSON) → JSON
  | array : List JSON → JSON
deriving Repr

一个 JSON 序列化器是一个结构体,它不仅包含它知道如何序列化的类型,还包含序列化代码本身:

structure Serializer where
  Contents : Type
  serialize : Contents → JSON

对字符串的序列化器只需要将所给的字符串包装在 JSON.string 构造子中即可:

def Str : Serializer :=
  { Contents := String,
    serialize := JSON.string
  }

将 JSON 序列化器视为序列化其参数的函数需要提取可序列化数据的内部类型:

instance : CoeFun Serializer (fun s => s.Contents → JSON) where
  coe s := s.serialize

有了这个实例,一个序列化器就能直接应用在参数上。

def buildResponse (title : String) (R : Serializer) (record : R.Contents) : JSON :=
  JSON.object [
    ("title", JSON.string title),
    ("status", JSON.number 200),
    ("record", R record)
  ]

这个序列化器可以直接传入 buildResponse

#eval buildResponse "Functional Programming in Lean" Str "Programming is fun!"
JSON.object
  [("title", JSON.string "Functional Programming in Lean"),
   ("status", JSON.number 200.000000),
   ("record", JSON.string "Programming is fun!")]

附注:将 JSON 表示为字符串

当 JSON 被编码为 Lean 对象时可能有点难以理解。 为了帮助保证序列化的响应是我们所期望的,写一个简单的从 JSONString 的转换器可能会很方便。 第一步是简化数字的显示。 JSON 不区分整数和浮点数,Float 类型即可用来代表二者。 在 Lean 中,Float.toString 包括数字的后继零。

#eval (5 : Float).toString
"5.000000"

解决方案是写一个小函数,这个函数可以清理掉所有的后继零,和后继的小数点:

def dropDecimals (numString : String) : String :=
  if numString.contains '.' then
    let noTrailingZeros := numString.dropRightWhile (· == '0')
    noTrailingZeros.dropRightWhile (· == '.')
  else numString

有了这个定义,#eval dropDecimals (5 : Float).toString 结果为 "5"#eval dropDecimals (5.2 : Float).toString 结果为 "5.2"

下一步是定义一个辅助函数来连接字符串列表,并在中间添加分隔符:

def String.separate (sep : String) (strings : List String) : String :=
  match strings with
  | [] => ""
  | x :: xs => String.join (x :: xs.map (sep ++ ·))

这个函数用于处理 JSON 数组和对象中的逗号分隔元素。 #eval ", ".separate ["1", "2"] 结果为 "1, 2"#eval ", ".separate ["1"] 结果为 "1"#eval ", ".separate [] 结果为 ""

最后,需要一个字符串转义程序来处理 JSON 字符串,以便包含 "Hello!" 的 Lean 字符串可以输出为 ""Hello!"”。 幸运的是,Lean 编译器已经包含了一个用于转义 JSON 字符串的内部函数,叫做 Lean.Json.escape。 要使用这个函数,可以在文件开头添加 import Lean

JSON 值转换为字符串的函数被声明了 partial,因为 Lean 并不知道它是否停机。 这是因为出现在函数中的 asString 的递归调用被应用到了 List.map,这种模式的递归已经复杂到 Lean 无法知道递归过程中值的规模是否是减小的。 在一个只需要产生 JSON 字符串而不需要让过程在数学上是合理的的应用中,让函数是 partial 的不太可能造成麻烦。

partial def JSON.asString (val : JSON) : String :=
  match val with
  | true => "true"
  | false => "false"
  | null => "null"
  | string s => "\"" ++ Lean.Json.escape s ++ "\""
  | number n => dropDecimals n.toString
  | object members =>
    let memberToString mem :=
      "\"" ++ Lean.Json.escape mem.fst ++ "\": " ++ asString mem.snd
    "{" ++ ", ".separate (members.map memberToString) ++ "}"
  | array elements =>
    "[" ++ ", ".separate (elements.map asString) ++ "]"

有了这个定义,序列化的结果更加易读了:

#eval (buildResponse "Functional Programming in Lean" Str "Programming is fun!").asString
"{\\"title\\": \\"Functional Programming in Lean\\", \\"status\\": 200, \\"record\\": \\"Programming is fun!\\"}"

可能会遇到的问题

自然数字面量是通过 OfNat 类型类重载的。 因为在类型不匹配时才会触发强制转换,而不是在找不到实例时,所以当对于某类型的 OfNat 实例缺失时,并不会触发强制转换:

def perhapsPerhapsPerhapsNat : Option (Option (Option Nat)) :=
  392
failed to synthesize instance
  OfNat (Option (Option (Option Nat))) 392

设计原则

强制转换是一个强大的工具,请负责任地使用它。 一方面,它可以使 API 设计得更贴近领域内使用习惯。 这是繁琐的手动转换函数和一个清晰的程序间的差别。 正如 Abelson 和 Sussman 在《计算机程序的构造和解释》( Structure and Interpretation of Computer Programs )(麻省理工学院出版社,1996年)前言中所写的那样:

写程序须以让人读明白为主,让计算机执行为辅。

明智地使用强制转换,可以使得代码更加易读——这是与领域内专家的交流的基础。 然而,严重依赖强制转换的 API 会有许多限制。 在你自己的代码中使用强制转换前,认真思考这些限制。

首先,强制转换只应该出现在类型信息充足,Lean 能够知道所有参与的类型的语境中。 因为强制转换类型类中并没有输出参数这么一说。 这意味着在函数上添加返回类型注释可以决定是类型错误还是成功应用强制转换。 例如,从非空列表到列表的强制转换使以下程序得以运行:

def lastSpider : Option String :=
  List.getLast? idahoSpiders

另一方面,如果类型注释被省略了,那么结果的类型就是未知的,那么 Lean 就无法找到对应的强制转换。

def lastSpider :=
  List.getLast? idahoSpiders
application type mismatch
  List.getLast? idahoSpiders
argument
  idahoSpiders
has type
  NonEmptyList String : Type
but is expected to have type
  List ?m.34258 : Type

通常来讲,如果一个强制转换因为一些原因失败了,用户会收到原始的类型错误,这会使在强制转换链上定位错误变得十分困难。

最后,强制转换不会在字段访问符号的上下文中应用。 这意味着需要强制转换的表达式与不需要强制转换的表达式之间仍然存在重要区别,而这个区别对用户来说是肉眼可见的。