Lean 函数式编程

4.2. Monad 类型类🔗

不必为每个是单子的类型分别导入像 okandThen 这样的运算符,Lean 标准库包含一个类型类,允许对它们进行重载,从而同一组运算符可用于任意单子。 单子有两个操作,它们等价于 okandThen

class Monad (m : Type Type) where pure : α m α bind : m α (α m β) m β

这个定义略作了简化。 Lean 库中的实际定义稍微更复杂一些,并将在后文给出。

可以通过调整它们各自的 andThen 操作的定义,来创建 OptionExcept εMonad 实例:

instance : Monad Option where pure x := some x bind opt next := match opt with | none => none | some x => next x instance : Monad (Except ε) where pure x := Except.ok x bind attempt next := match attempt with | Except.error e => Except.error e | Except.ok x => next x

例如,firstThirdFifthSeventh 曾分别针对 Option αExcept String α 返回类型来定义。 现在,它可以针对任何单子以多态方式定义。 不过,它确实需要一个查找函数作为参数,因为不同的单子可能会以不同方式无法找到结果。 bind 的中缀版本是 >>=,它在示例中扮演与 ~~> 相同的角色。

def firstThirdFifthSeventh [Monad m] (lookup : List α Nat m α) (xs : List α) : m (α × α × α × α) := lookup xs 0 >>= fun first => lookup xs 2 >>= fun third => lookup xs 4 >>= fun fifth => lookup xs 6 >>= fun seventh => pure (first, third, fifth, seventh)

给定慢速哺乳动物和快速鸟类的示例列表,firstThirdFifthSeventh 的这一实现可以与 Option 一起使用:

def slowMammals : List String := ["Three-toed sloth", "Slow loris"] def fastBirds : List String := [ "Peregrine falcon", "Saker falcon", "Golden eagle", "Gray-headed albatross", "Spur-winged goose", "Swift", "Anna's hummingbird" ]none#eval firstThirdFifthSeventh (fun xs i => xs[i]?) slowMammals
none
some ("Peregrine falcon", "Golden eagle", "Spur-winged goose", "Anna's hummingbird")#eval firstThirdFifthSeventh (fun xs i => xs[i]?) fastBirds
some ("Peregrine falcon", "Golden eagle", "Spur-winged goose", "Anna's hummingbird")

在将 Except 的查找函数 get 重命名为更具体的名称之后,完全相同的 firstThirdFifthSeventh 实现也可以与 Except 一起使用:

def getOrExcept (xs : List α) (i : Nat) : Except String α := match xs[i]? with | none => Except.error s!"Index {i} not found (maximum is {xs.length - 1})" | some x => Except.ok xExcept.error "Index 2 not found (maximum is 1)"#eval firstThirdFifthSeventh getOrExcept slowMammals
Except.error "Index 2 not found (maximum is 1)"
Except.ok ("Peregrine falcon", "Golden eagle", "Spur-winged goose", "Anna's hummingbird")#eval firstThirdFifthSeventh getOrExcept fastBirds
Except.ok ("Peregrine falcon", "Golden eagle", "Spur-winged goose", "Anna's hummingbird")

m 必须具有一个 Monad 实例这一事实意味着 >>=pure 操作是可用的。

4.2.1. 一般的单子操作🔗

由于许多不同类型都是单子,对任意单子具有多态性的函数非常强大。 例如,函数 mapMmap 的一个版本,它使用 Monad 来顺序执行并组合函数应用所得的结果:

def mapM [Monad m] (f : α m β) : List α m (List β) | [] => pure [] | x :: xs => f x >>= fun hd => mapM f xs >>= fun tl => pure (hd :: tl)

函数参数 f 的返回类型决定将使用哪个 Monad 实例。 换言之,mapM 可用于产生日志的函数、可能失败的函数,或使用可变状态的函数。 由于 f 的类型决定了可用的效果,API 设计者可以对它们进行严格控制。

本章引言所述,State σ α 表示使用类型为 σ 的可变变量并返回类型为 α 的值的程序。 这些程序实际上是从初始状态到一个由值和最终状态组成的二元组的函数。 Monad 类要求其参数期望一个单一类型参数;也就是说,它应当是一个 Type Type。 这意味着 State 的实例应提及状态类型 σ,而该状态类型会成为该实例的一个参数:

instance : Monad (State σ) where pure x := fun s => (s, x) bind first next := fun s => let (s', x) := first s next x s'

这意味着,在使用 bind 排序的对 getset 的调用之间,状态的类型不能改变;对于有状态计算而言,这是一个合理的规则。 运算符 increment 将保存的状态增加给定的量,并返回旧值:

def increment (howMuch : Int) : State Int Int := get >>= fun i => set (i + howMuch) >>= fun () => pure i

mapMincrement 一起使用,会得到一个计算列表中各项之和的程序。 更具体地说,可变变量包含当前为止的和,而结果列表包含逐步累计的和。 换言之,mapM increment 的类型为 List Int State Int (List Int),展开 State 的定义会得到 List Int Int (Int × List Int)。 它以初始和作为参数,该参数应为 0

(15, [0, 1, 3, 6, 10])#eval mapM increment [1, 2, 3, 4, 5] 0
(15, [0, 1, 3, 6, 10])

一个 日志效应 可以使用 WithLog 表示。 就像 State 一样,它的 Monad 实例关于所记录数据的类型是多态的:

instance : Monad (WithLog logged) where pure x := {log := [], val := x} bind result next := let {log := thisOut, val := thisRes} := result let {log := nextOut, val := nextRes} := next thisRes {log := thisOut ++ nextOut, val := nextRes}

saveIfEven 是一个记录偶数日志、但原样返回其参数的函数:

def saveIfEven (i : Int) : WithLog Int Int := (if isEven i then save i else pure ()) >>= fun () => pure i

将此函数与 mapM 一起使用,会得到一个日志,其中包含与未改变的输入列表配对的偶数:

{ log := [2, 4], val := [1, 2, 3, 4, 5] }#eval mapM saveIfEven [1, 2, 3, 4, 5]
{ log := [2, 4], val := [1, 2, 3, 4, 5] }

4.2.2. 恒等单子🔗

单子将带有效果的程序,例如失败、异常或日志记录,编码为由数据和函数构成的显式表示。 然而,有时 API 会为了灵活性而写成使用单子,但该 API 的客户端可能并不需要任何被编码的效果。 恒等单子 是一种没有效果的单子。 它允许纯代码与单子式 API 一起使用:

def Id (t : Type) : Type := t instance : Monad Id where pure x := x bind x f := f x

pure 的类型应为 α Id α,但 Id α 会约化为 α。 类似地,bind 的类型应为 α (α Id β) Id β。 因为这会约化为 α (α β) β,所以可以将第二个参数应用于第一个参数以得到结果。

使用恒等单子时,mapM 变得等价于 map 然而,若要以这种方式调用它,Lean 需要一个提示,说明预期的单子是 Id

def numbers := mapM (m := Id) (do return · + 1) [1, 2, 3, 4, 5]

在类型没有提供任何关于应使用哪个单子的具体提示的上下文中使用 mapM,会产生一条 “instance problem is stuck” 消息:

def numbers := mapM (do typeclass instance problem is stuck Pure ?m.6 Note: Lean will not try to resolve this typeclass instance problem because the type argument to `Pure` is a metavariable. This argument must be fully determined before Lean will try to resolve the typeclass. Hint: Adding type annotations and supplying implicit arguments to functions can give Lean more information for typeclass resolution. For example, if you have a variable `x` that you intend to be a `Nat`, but Lean reports it as having an unresolved type like `?m`, replacing `x` with `(x : Nat)` can get typeclass resolution un-stuck.return · + 1) [1, 2, 3, 4, 5]
typeclass instance problem is stuck
  Pure ?m.6

Note: Lean will not try to resolve this typeclass instance problem because the type argument to `Pure` is a metavariable. This argument must be fully determined before Lean will try to resolve the typeclass.

Hint: Adding type annotations and supplying implicit arguments to functions can give Lean more information for typeclass resolution. For example, if you have a variable `x` that you intend to be a `Nat`, but Lean reports it as having an unresolved type like `?m`, replacing `x` with `(x : Nat)` can get typeclass resolution un-stuck.

4.2.3. Monad 约定🔗

正如 BEqHashable 的每一对实例都应保证任意两个相等的值具有相同的散列值一样,Monad 的每个实例也应遵守一个约定。 首先,pure 应是 bind 的左恒等元。 也就是说,bind (pure v) f 应与 f v 相同。 其次,pure 应是 bind 的右恒等元,因此 bind v purev 相同。 最后,bind 应满足结合律,因此 bind (bind v f) gbind v (fun x => bind (f x) g) 相同。

这一约定更一般地规定了带有效果的程序所应满足的性质。 由于 pure 没有效果,将它的效果与 bind 排序不应改变结果。 bind 的结合律基本上说明,只要保持事情发生的顺序,排序本身的簿记方式并不重要。

4.2.4. 练习🔗

4.2.4.1. 在树上进行映射

定义一个函数 BinTree.mapM。 类比于列表上的 mapM,该函数应以前序遍历的方式,将一个单子函数应用到树中的每个数据项。 类型签名应为:

def BinTree.mapM [Monad m] (f : α m β) : BinTree α m (BinTree β)

4.2.4.2. Option 单子的约定

首先,写出一个令人信服的论证,说明 OptionMonad 实例满足单子约定。 然后,考虑以下实例:

instance : Monad Option where pure x := some x bind opt next := none

这两个方法都具有正确的类型。 为什么这个实例违反了单子契约?