错误处理#

错误处理一直是我们语言设计的核心。接下来我们将解释 MoonBit 中的错误处理。我们假设您对 MoonBit 有一些了解,如果没有,请查看 MoonBit 之旅

错误类型#

MoonBit 中使用的错误值必须具有错误类型。错误类型可以用以下形式定义:

type! E1 Int  // 错误类型 E1 具有一个构造器 E1,并带有一个 Int 负载
type! E2      // 错误类型 E2 具有一个没有负载的构造器 E2
type! E3 {    // 错误类型 E3 类似于普通的枚举类型,有三个构造器
  A
  B(Int, x~ : String)
  C(mut x~ : String, Char, y~ : Bool)
}

函数的返回类型可以包含错误类型,以表明函数可能返回一个错误。例如,以下函数 div 可能返回一个类型为 DivError 的错误:

type! DivError String

fn div(x : Int, y : Int) -> Int!DivError {
  if y == 0 {
    raise DivError("division by zero")
  }
  x / y
}

这里使用了关键字 raise 来中断函数执行并返回一个错误。

默认错误类型#

MoonBit 提供了一个默认错误类型 Error,当具体的错误类型不重要时可以使用它。为了方便起见,您可以在函数名或返回类型后面加上后缀 !,以表示使用了 Error 类型。例如,以下函数签名是等价的:

fn f() -> Unit! {
  ...
}

fn g!() -> Unit {
  ...
}

fn h() -> Unit!Error {
  ...
}

对于匿名函数和矩阵函数,您可以在关键字 fn 后面加上 ! 后缀来实现这一点。例如:

type! IntError Int

fn h(f : (Int) -> Int!, x : Int) -> Unit {
  ...
}

fn g() -> Unit {
  let _ = h(fn! { x => raise IntError(x) }, 0)
  let _ = h(fn!(x) { raise IntError(x) }, 0)

}

如上例所示,type! 定义的错误类型可以在引发错误时作为 Error 类型的值使用。

请注意,只有错误类型或 Error 类型可以用作错误。对于在错误类型上是泛型的函数,您可以使用 Error 约束来实现。例如:

// Result::unwrap_or_error
fn unwrap_or_error[T, E : Error](result : Result[T, E]) -> T!E {
  match result {
    Ok(x) => x
    Err(e) => raise e
  }
}

由于 Error 类型可以包含多个错误类型,对 Error 类型进行模式匹配必须使用通配符 _ 来匹配所有错误类型。例如:

type! E4

type! E5

fn f(e : Error) -> Unit {
  match e {
    E4 => println("E1")
    E5 => println("E2")
    _ => println("unknown error")
  }
}

处理错误#

有三种方式可以处理错误:

  • 在函数应用中的函数名后面附加 !,以便在出现错误时直接重新抛出错误,例如:

fn div_reraise(x : Int, y : Int) -> Int!DivError {
  div!(x, y) // 如果 `div` 引发错误,则重新抛出错误
}
  • 在函数名后面附加 ?,将结果转换为 Result 类型的值,例如:

test {
  let res = div?(6, 3)
  inspect!(res, content="Ok(2)")
  let res = div?(6, 0)
  inspect!(
    res,
    content=
      #|Err("division by zero")
    ,
  )
}
  • Use try and catch to catch and handle errors, for example:

fn main {
try {
  div!(42, 0)
} catch {
  DivError(s) => println(s)
} else {
  v => println(v)
}
}
输出#
除零

这里,try 用于调用可能引发错误的函数,catch 用于匹配和处理捕获的错误。如果没有捕获到错误,catch 块将不会执行,而是执行 else 块。

如果不需要在没有捕获到错误时执行任何操作,则可以省略 else 块。例如:

try {
  println(div!(42, 0))
} catch {
  _ => println("Error")
}

catch 关键字是可选的,当 try 的主体是一个简单表达式时,大括号可以省略。例如:

let a = try {
  div!(42, 0)
} catch {
  _ => 0
}
println(a)

!? 符号也可以用于方法调用和管道运算符。例如:

type T Int

type! E Int derive(Show)

fn k(self : T) -> Unit!E {
  ...
}

fn l() -> Unit!E {
  let x = T(42)
  k!(x)
  x.k!()
  x |> k!()
}

然而对于可能引发错误的中缀运算符,如 + *,必须使用原始形式,例如 x.op_add!(y)x.op_mul!(y)

此外,如果函数的返回类型包含错误类型,则函数调用必须使用 !? 进行错误处理,否则编译器将报告错误。

错误推导#

try 块中,可能引发多种不同类型的错误。当发生这种情况时,编译器将使用 Error 类型作为通用错误类型。因此,处理程序必须使用通配符 _ 来确保捕获所有错误。例如:

fn f1() -> Unit!E1 {
  ...
}

fn f2() -> Unit!E2 {
  ...
}

try {
  f1!()
  f2!()
} catch {
  E1(_) => ...
  E2 => ...
  _ => ...
}

您还可以使用 catch! 来重新抛出未捕获的错误,以方便处理。当您只想处理特定错误并重新抛出其他错误时,这很有用。例如:

try {
  f1!()
  f2!()
} catch! {
  E1(_) => ...
}

示例:除零#

我们将编写一个小例子来演示 MoonBit 错误处理系统的基础知识。考虑以下 div 函数,它将在除零时引发错误:

type! DivisionByZeroError String
fn div(x : Int, y : Int) -> Int!DivisionByZeroError {
  if y == 0 {
    raise DivisionByZeroError("division by zero")
  }
  x / y
}

在以前,我们通常使用 type 来定义一个包装器类型,该类型包装了某些现有的外部类型。然而,在这里,我们使用 !type 附加到 DivisionByZeroError,以定义一个错误类型,该类型包装了 String

type! E SS 构造一个错误类型 E

就像 type 一样,type! 可能有一个像上面的 DivisionByZeroError 那样的数据,也可能没有,甚至可能像普通的 enum 一样有多个构造器:

type! ConnectionError {
  BrokenPipe(Int,String)
  ConnectionReset
  ConnectionAbort
  ConnectionRefused
}

要使用 DivisionByZeroError 类型,我们通常会定义一个函数,该函数通过在签名中定义返回类型为 T ! E 来表示它会引发错误,其中 T 是实际的返回类型,E 是错误类型。在这个例子中,它是 Int!DivisionByZeroError。错误可以使用 raise e 抛出,其中 eE 的实例,可以使用 S 的默认构造器构造。

任何错误的实例都是一个二等公民对象。这意味着它只能出现在返回值中。如果返回值包含错误,函数签名必须调整以匹配返回类型。

MoonBit 中的 test 块也可以看作是一个函数,返回类型为 Unit!Error。

调用一个可出错的函数#

一个可出错的函数通常有两种调用方式:f!(...)f?(...)

直接调用#

f!(...) 直接调用函数。可能的错误必须在调用 f 的函数中处理。我们可以重新抛出它,而不实际处理错误:

// We have to match the error type of `div2` with `div`
fn div2(x : Int, y : Int) -> Int!DivisionByZeroError {
  div!(x,y)
}

或者像其他许多语言一样使用 try...catch 块:

fn div3(x : Int, y : Int) -> Unit {
  try {
    div!(x, y)
  } catch { // `catch` and `except` works the same.
    DivisionByZeroError(e) => println("inf: \{e}")
  } else {
    v => println(v)
  }
}

catch... 子句的语义类似于模式匹配。我们可以解包错误以检索底层的 String 并打印它。此外,还有一个 else 子句来处理 try... 块的值。

fn test_try() -> Result[Int, Error] {
  // compiler can figure out the type of a local error-able function.
  fn f() -> _!_ {
    raise Failure("err")
  }

  try Ok(f!()) { err => Err(err) }
}

如果 try 的主体是一行代码(表达式),则大括号可以省略。catch 关键字也可以省略。在 try 主体可能引发不同错误的情况下,可以使用特殊的 catch! 来捕获一些错误,同时重新抛出其他未捕获的错误:

type! E1
type! E2
fn f1() -> Unit!E1 { raise E1 }
fn f2() -> Unit!E2 { raise E2 }
fn f() -> Unit! {
  try {
    f1!()
    f2!()
  } catch! {
    E1 => println("E1")
    // E2 gets re-raised.
  }
}

转换为 Result#

提取值#

Result 类型的对象是 MoonBit 中的一等公民。Result 有 2 个构造器:Ok(...)Err(...),前者接受一个一等公民对象,后者接受一个错误对象。

使用 f?(...),返回类型 T!E 被转换为 Result[T,E]。我们可以使用模式匹配从中提取值:

let res = div?(10, 0)
match res {
  Ok(x) => println(x)
  Err(DivisionByZeroError(e)) => println(e)
}

f?() 基本上是一个语法糖,等价于

let res = try {
  Ok(div!(10, 0))
} catch {
  s => Err(s)
}

注意 T?f?(...) 之间的区别:T 是一个类型,T? 等价于 Option[T],而 f?(...) 是对可出错函数 f 的调用。

除了模式匹配,Result 还提供了一些有用的方法来处理可能的错误:

let res1: Result[Int, String] = Err("error")
let value = res1.or(0) // 0

let res2: Result[Int, String] = Ok(42)
let value = res2.unwrap() // 42
  • or 如果结果是 Ok,则返回值,如果是 Err,则返回默认值

  • unwrap 如果结果是 Err,则会崩溃,如果是 Ok,则返回值

映射值#

let res1: Result[Int, String] = Ok(42)
let new_result = res1.map(fn(x) { x + 1 }) // Ok(43)

let res2: Result[Int, String] = Err("error")
let new_result = res2.map_err(fn(x) { x + "!" }) // Err("error!")
  • map 将函数应用于内部的值;如果结果是 Err,则不执行任何操作。

  • map_error 则相反。

与一些语言不同,MoonBit 对可出错值和可空值进行了区分。尽管有些人可能将它们类比对待,因为一个不包含值的 Err 对象就像 null。MoonBit 知道这一点。

  • to_optionResult 转换为 Option

let res1: Result[Int, String] = Ok(42)
let option = res1.to_option() // Some(42)

let res2: Result[Int, String] = Err("error")
let option1 = res2.to_option() // None

内置错误类型和相关函数#

在 MoonBit 中,Error 是一个通用的错误类型:

// These signatures are equivalent. They all raise Error.
fn f() -> Unit! { .. }
fn f!() -> Unit { .. }
fn f() -> Unit!Error { .. }

fn test_error() -> Result[Int, Error] {
  fn f() -> _!_ {
    raise DivisionByZeroError("err")
  }

  try {
    Ok(f!())
  } catch {
    err => Err(err)
  }
}

尽管构造器 Err 期望一个 Error 类型,我们仍然可以将 DivisionByZeroError 类型的错误传递给它。

但是 Error 不能直接构造。它是用来传递的,而不是直接使用:

type! ArithmeticError

fn what_error_is_this(e : Error) -> Unit {
  match e {
    DivisionByZeroError(_) => println("DivisionByZeroError")
    ArithmeticError => println("ArithmeticError")
    ... => println("...")
    _ => println("Error")
  }
}

Error 通常用于不需要具体错误类型的情况,或者简单地用来捕获所有的子错误。

由于 Error 包含多种错误类型,这里不允许部分匹配。我们必须通过提供一个通配符 _ 来进行兜底匹配。

我们通常使用内置的 Failure 错误类型来表示通用错误:通用意味着它用于不值得单独定义类型的错误。

fn div_trivial(x : Int, y : Int) -> Int!Failure {
  if y == 0 {
    raise Failure("division by zero")
  }
  x / y
}

除了直接使用构造器,函数 fail! 提供了一个快捷方式来构造 Failure。如果我们查看源代码:

pub fn fail[T](msg : String, ~loc : SourceLoc = _) -> T!Failure {
  raise Failure("FAILED: \{loc} \{msg}")
}

我们可以看到 fail 只是一个带有预定义输出模板的构造函数,用于显示错误和源位置。在实践中,fail! 总是比 Failure更常用。

其他用于打破控制流的函数有 abortpanic。它们是等效的。在任何地方的 panic 都会手动在那个地方崩溃程序,并打印出堆栈跟踪。