Newshot
Account

Lean 中的 Monads

AI summary

原文仅给出标题「Monads in Lean」,未提供正文内容,无法提取更多事实信息。

Translation(model translation, not the source's own text)

这篇文章演示如何在 Lean 编程语言(具体来说是 Lean 4)中使用 monad。如果你不熟悉 Lean,这门语言的这一部分与 Haskell 或 OCaml 等其他函数式编程语言非常相似。

动机示例——表达式求值

假设我们要编写一个简单的数值表达式求值器,支持数字的加法和除法:


        inductive Expr where
  | num : Int → Expr
  | add : Expr → Expr → Expr
  | div : Expr → Expr → Expr
      

当我们遇到除以零时会怎样?我们希望求值器将此报告为失败,并用 Option 类型显式表示:


        def safeDiv (x y : Int) : Option Int :=
  if y == 0 then none else some (x / y)
      

有了这个,eval 函数可以写成如下形式:


        def eval : Expr → Option Int
  | .num n => some n
  | .add a b =>
    match eval a with
    | none => none
    | some x =>
      match eval b with
      | none => none
      | some y => some (x + y)
  | .div a b =>
    match eval a with
    | none => none
    | some x =>
      match eval b with
      | none => none
      | some y => safeDiv x y
      

safeDiv 返回一个 Option;为了将这种可能的失败传播给调用者,eval 也返回一个 Option,而且——此外——还必须对可能出现的 none 值进行大量繁琐的检查。有没有更好的办法?

注意这里重复出现的一个常见代码模式:

  • 检查 Option 的值
  • 如果它是 none,则返回 none 而不继续执行
  • 否则如果它是 some result,则用 result 继续计算

我们可以把它提取成一个辅助函数:


        def withSome (result : Option α) (onSuccess : α → Option β) : Option β :=
  match result with
  | none => none
  | some x => onSuccess x
      

然后用这个辅助函数重写我们的 eval:


        def evalWithSome : Expr → Option Int
  | .num n => some n
  | .add a b =>
      withSome (evalWithSome a) (fun x =>
        withSome (evalWithSome b) (fun y =>
          some (x + y)))
  | .div a b =>
      withSome (evalWithSome a) (fun x =>
        withSome (evalWithSome b) (fun y =>
          safeDiv x y))
      

这已经好多了;我们还可以用运算符重载让它更简洁:


        infixl:55 " >>? " => withSome

def evalOp : Expr → Option Int
  | .num n => some n
  | .add a b =>
    evalOp a >>? fun x =>
    evalOp b >>? fun y =>
    some (x + y)
  | .div a b =>
    evalOp a >>? fun x =>
    evalOp b >>? fun y =>
    safeDiv x y
      

恭喜——我们基本上已经发明了 monad!

表达式求值中作为 Monad 的 Option

简化形式下,Lean 中的 Monad 类型类定义如下:


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

在我们的代码中,pure 等价于 some,bind 等价于 withSome;事实上,Option 的 Monad 实例大致如下:


        instance : Monad Option where
  pure x := some x
  bind opt next :=
    match opt with
    | none => none
    | some x => next x
      

Lean 中已经为 monad 定义了一个类似于 >>? 的运算符;它叫做 >>=,会采用 Monad 类型类的 bind 方法。因此我们可以使用标准的 Monad 实例重写求值器,而无需定义 withSome 或 >>? 运算符 [1]:


        def evalBind : Expr → Option Int
  | .num n => pure n
  | .add a b =>
    evalBind a >>= fun x =>
    evalBind b >>= fun y =>
    some (x + y)
  | .div a b =>
    evalBind a >>= fun x =>
    evalBind b >>= fun y =>
    safeDiv x y
      

我们还可以更进一步使用 do 记法,它是围绕 >>= 的语法糖,让我们可以把求值器重写为:


        def evalDo : Expr → Option Int
  | .num n => pure n
  | .add a b => do
    let x ← evalDo a
    let y ← evalDo b
    some (x + y)
  | .div a b => do
    let x ← evalDo a
    let y ← evalDo b
    safeDiv x y
      

如果你见过处理 I/O 的 Lean 代码,这种记法应该很熟悉;我们将在本文后面再次讨论 I/O。

用于建模副作用的一元(Monad)

函数式编程语言非常强调无副作用的纯函数。毫无疑问,这类函数更容易推理、调试,甚至更容易优化(通过记忆化)。话虽如此,现实世界是混乱的,有时副作用不可避免;monad 有助于在函数式程序中封装副作用。

核心思想是:副作用被封装在 Monad 内部,Monad 包裹了计算返回的值。bind 方法(或等价的 >>= 运算符)让我们可以取出一个被包裹的值,并将其推入序列中的下一个计算。这在延续传递风格(CPS)中可能看起来很熟悉。延续本身也可以用 monad 来建模 [2]。

bind 的定义是:


        bind : m α → (α → m β) → m β
      

以下是一个帮助理解的心智模型:

  • 取出一个被潜在副作用包裹的值
  • 取出一个接受该值并可能产生额外副作用的计算
  • 将结果合并为一个最终的被包裹的值

将多个 bind 链接在一起,本质上就是一系列计算,同时跟踪副作用。monad 将计算做什么与它们如何被排序解耦。程序员描述计算的序列及其依赖关系,而 monad 的 bind 实现则处理合并其副作用所需的簿记工作。

我们的求值器案例研究展示了副作用的一个例子:从计算中提前返回一个 none 值。

让我们看看另一种惯用的做法,使用 Except 类型,它包含一个成功结果或一个错误值(与 Rust 中的 Result 类型非常相似)。

我们首先编写一个返回异常的 safeDiv 版本:


        def safeDivExc (x y : Int) : Except String Int :=
  if y == 0 then .error "division by zero" else .ok (x / y)
      

与其为 Except 编写一个全新的 eval 变体,我们将创建一个适用于任何 Monad 的通用版本 [3]:


        def evalM {m : Type → Type} [Monad m]
    (div : Int → Int → m Int) : Expr → m Int
  | .num n => pure n
  | .add a b =>
    do
      let x ← evalM div a
      let y ← evalM div b
      pure (x + y)
  | .div a b =>
    do
      let x ← evalM div a
      let y ← evalM div b
      div x y
      

现在 evalM 可以与 safeDivExc 一起使用,实现一个除法返回 Except 的求值器;与 safeDiv 一起使用时,它将等价于我们之前编写的针对 Option 的 evalDo。


        #eval evalM safeDiv (.add (.num 4) (.div (.num 6) (.num 0)))
#eval evalM safeDivExc (.add (.num 4) (.div (.num 6) (.num 0)))
      

记录计算——writer monad 的一个例子

让我们看一个更高级的例子:一个求值器,它还将执行的计算记录在一个线性的字符串日志中。这里的挑战在于,我们仍然必须将其与 Option 结合以支持除零的情况,因此我们首先创建一个新的结构来携带计算的状态:


        structure LoggedOption (α : Type) where
  value : Option α
  log : List String
      

这将是一次 eval 调用的结果;它产生一个可选值,以及一个字符串列表(日志)。以下是该类型的 Monad 实现:


        instance : Monad LoggedOption where
  pure x := ⟨some x, []⟩
  bind v next :=
    match v.value with
    | none => ⟨none, v.log⟩
    | some x =>
      let nextResult := next x
      ⟨nextResult.value, v.log ++ nextResult.log⟩
      

主要思想是通过追加的方式将存储的日志与下一个计算的日志合并(从而累积一个线性日志)。以下是我们的求值器:


        def evalLogged : Expr → LoggedOption Int
  | .num n => pure n
  | .add a b => do
    let x ← evalLogged a
    let y ← evalLogged b
    ⟨some (x + y), [s!"adding {x} and {y}"]⟩
  | .div a b => do
    let x ← evalLogged a
    let y ← evalLogged b
    match y with
    | 0 => ⟨none, [s!"dividing {x} by zero"]⟩
    | _ => ⟨some (x / y), [s!"dividing {x} by {y}"]⟩
      

注意 evalLogged 的代码不必过多处理日志记录的具体机制(除了添加新的日志条目之外)。所有这些都隐藏在 monad 实例内部。LoggedOption 是 writer monad 的一个例子,它是 state monad 的只写版本。

State monad

monad 在函数式编程语言中最重要的用例之一,是通过计算来建模可变状态。

有状态的计算被建模为一个函数:它接收初始状态,并返回一个元组,其中包含结果值和一个新的、更新后的状态。用 Lean 的记法表示:


        def State (σ : Type) (α : Type) : Type :=
  σ → (α × σ)
      

为了演示它的实际用法,我们来增强求值器,使其支持变量赋值和查找。我们的状态将是一个由名称和值组成的关联列表:


        abbrev Vars := List (String × Int)
      

并且我们会为表达式枚举增加额外的操作:


        inductive ExprV where
  | num : Int → ExprV
  | add : ExprV → ExprV → ExprV
  | div : ExprV → ExprV → ExprV
  | store : String → ExprV → ExprV
  | load : String → ExprV
      

这将是我们的 state monad:


        structure EvalM (α : Type) where
  run : Vars → Option (α × Vars)
      

注意 run 函数与上面 State 签名之间的对应关系:

  • 它接收当前状态,在我们的用例中,这个状态是一个关联列表,保存所有已定义变量的值。
  • 它返回一个可选结果,该结果是某个值(在我们的例子中,是计算的结果)和一个更新后的状态组成的对。

这是它的 Monad 类实现:


        instance : Monad EvalM where
  pure x := ⟨fun vars => some (x, vars)⟩
  bind action next := ⟨fun vars =>
    match action.run vars with
    | none => none
    | some (x, vars') => (next x).run vars'⟩
      

最后是求值器:


        def evalState : ExprV → EvalM Int
  | .num n => pure n
  | .add a b => do
    let x ← evalState a
    let y ← evalState b
    pure (x + y)
  | .div a b => do
    let x ← evalState a
    let y ← evalState b
    ⟨fun vars => (safeDiv x y).map (fun value => (value, vars))⟩
  | .load name =>
    ⟨fun vars => do
      let value ← vars.lookup name
      pure (value, vars)⟩
  | .store name rhs => do
    let value ← evalState rhs
    ⟨fun vars =>
      let vars' := (name, value) :: vars.filter (fun p => p.1 != name)
      some (value, vars')⟩
      

不直接处理变量的操作(如 .num 和 .add)像之前一样完成自己的工作。.div 只需处理实际的 monad 类型,以确保在除零时传播 none。还要注意,这个求值器支持将 store 作为表达式,因为它们分别返回一个值和更新后的状态;因此,像下面这样的计算可以正常工作:


        -- (x := 12) + (x / 3): the right operand sees the left operand's assignment.
#eval (evalState
  (.add (.store "x" (.num 12))
        (.div (.load "x") (.num 3)))).run []
      

产生结果 some (16, [("x", 12)])。

在具有可变状态的语言中,建模副作用很容易——只需赋值某个状态(例如结构体字段或类成员)。然而,在像 Lean 这样的函数式语言中,我们必须显式地在计算之间携带这个状态;monad 帮助我们优雅地做到这一点——通过隐藏实际携带状态的大部分机制,大部分代码得以免于簿记工作。

IO monad

文章标题:Lean 中的 Monad

正文: 在 Lean 和 Haskell 这类函数式编程语言中,最广为人知的 monad 例子大概就是 IO。任何写过与外部世界交互的“真正”程序的人,对 IO 都非常熟悉。下面是一个定制的“Hello”程序,通常是学习一门新编程语言前 15 分钟就会教的内容:


        def greet : IO Unit := do
  let stdin ← IO.getStdin
  IO.println "What is your name?"
  let name ← stdin.getLine
  IO.println s!"Hello, {name}"
      

注意这里的 do 记法,它暗示了 monad 的存在。如前所述,do 是 monad 的 bind 方法(即 >>= 运算符)的语法糖;我们可以把这段代码更显式地重写为:


        def greetBind : IO Unit :=
  IO.getStdin >>= fun stdin =>
  IO.println "What is your name?" >>= fun _ =>
  stdin.getLine >>= fun name =>
  IO.println s!"Hello, {name}"
      

本质上,IO 是一个 state monad,其中被携带的状态代表“外部世界”。像 println 这样的 IO 操作作用于这个世界,而它的状态通过 bind 传递下去,和其他 state monad 一样。

下面是我们表达式求值器的一个版本,它直接把日志输出到标准输出,而不是把日志收集为字符串列表:


        def evalIO : Expr → OptionT IO Int
  | .num n => pure n
  | .add a b => do
    let x ← evalIO a
    let y ← evalIO b
    IO.println s!"adding {x} and {y}"
    pure (x + y)
  | .div a b => do
    let x ← evalIO a
    let y ← evalIO b
    IO.println s!"dividing {x} by {y}"
    OptionT.mk (pure (safeDiv x y))
      

这里唯一不寻常的构造是 OptionT,它是一个 monad transformer,帮助我们把 Option(用于传播安全除法产生的失败)与 IO Monad 组合起来 [4]。

IO monad 也支持 throw,以实现类似 Python 的异常行为,但在本例中我们显式使用 Option;异常是编程偏好问题,在像 eval 这样的库函数内部使用异常,我觉得并不合适。

Lean 中的 Monad 定律

和 Haskell 中一样,Lean 中的 monad 应当遵守某些定律。它们是:

左单位元:把一个值包装进 pure 并立即通过 bind 传给 f,应当等价于直接对值调用 f:


        pure v >>= f       =    f v
      

右单位元:从一个计算中取出值并立即用 pure 重新包装,不应改变该计算:


        m >>= pure         =    m
      

结合律:运算的分组方式无关紧要,只要它们的顺序保持不变:


        (m >>= f) >>= g    =    m >>= (fun v => f v >>= g)
      

我们来看看 Option 如何遵守这些定律。

对于左单位元,pure v 是 some v,而 bind 的实现把 some x 匹配到 next x,这等价于 f v。

对于右单位元,m >>= pure 只是把 some 值重新包装进 pure,对 none 则保持不变——这等价于保持原样。

对于结合律,如果 m 是 none,那么两边都是 none。如果 m 是 some v,左边是 f v >>= g,右边也归约为同样的结果,因此它们同样等价。

虽然 Lean 并不强制要求 Monad 实例遵守这些定律,但它有一个单独的类型类 LawfulMonad,它确实要求提供 Lean 证明;这对 Lean 的主要用途很有用——通过其强大的类型系统表达并证明关于程序的定理。但这是另一个话题了。

如需评论,请给我发邮件。

The translation is machine-generated and individual terms may be off. Nothing is rewritten or cut; the original is authoritative.

Published by Lobsters
This site only summarises and aggregates; copyright stays with the original author. Headlines and summaries follow the source — where they differ, the original wins.
Read the original ↗