首页/MoonBit/错误多态raise-高阶函数不用写两份

写高阶函数时,我们经常撞到一个很烦的坑:

这个函数,到底会不会抛错?

比如 map,它接收一个回调 f。问题是——f 可能抛错,也可能不抛错。

在很多语言里,这不是一个能简单回答的问题,它往往逼着你写两份几乎一样的代码。

MoonBit 用一个 ? 解决了这件事。

这篇文章讲清楚:raise? 到底是什么,它怎么让一个高阶函数不用写两份。


先看问题:为什么 map 会这么纠结

我们先写一个最朴素的 map,把数组里的每个元素都套一遍 f:

fn[T] map(array : Array[T], f : (T) -> T) -> Array[T] {
  let res : Array[T] = []
  for x in array {
    res.push(f(x))
  }
  res
}

这看起来没问题。

问题出在:如果 f 会抛错怎么办?

在 MoonBit 里,一个函数会不会抛错,是写在类型签名里的。

比如这个会抛错的除函数:

suberror DivError { DivError(String) } derive(Debug)

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

它的签名带着 raise DivError,意思是:这个函数可能抛出一个 DivError。

而一个不抛错的函数,签名里带着 noraise:

fn sqr(x : Int) -> Int noraise {
  x * x
}

现在矛盾来了。

map 到底该怎么声明它的回调 f?

  • 如果 f 声明成 raise,那么只要用了 map,就意味着它一定可能抛错。
  • 如果 f 声明成 noraise,那么你就没法把一个会抛错的函数传进去。

无论选哪个,都有一半的场景用不了。


笨办法:写两份

最直接的办法,就是写两个 map。

一份给不抛错的回调,一份给会抛错的回调:

// 给不抛错回调的 map
fn[T] map_no_raise(array : Array[T], f : (T) -> T noraise) -> Array[T] noraise {
  let res : Array[T] = []
  for x in array {
    res.push(f(x))
  }
  res
}

// 给会抛错回调的 map
fn[T] map_with_raise(array : Array[T], f : (T) -> T raise) -> Array[T] raise {
  let res : Array[T] = []
  for x in array {
    res.push(f(x))
  }
  res
}

这两份代码,除了函数名和签名不一样,函数体一模一样。

这就是那个让人抓狂的地方:同样的逻辑,写了两次,后面每次改动都要同步改两份。

在 Rust 里,Iterator 没有独立的 map 和 try_map 两套方法。它只提供一个 map,接受 FnMut(Item) -> B。如果闭包会失败,你不能指望 map,而是得让闭包返回 Result,再靠 collect 把结果聚合成一个 Result<Vec<_>, E>:

let results: Result<Vec<_>, _> = values
  .iter()
  .map(|x| fallible(x))   // 闭包返回 Result(示意,fallible 是一个会失败的函数)
  .collect();             // 聚合成一个 Result

(上面的 fallible 是为了说明情况用的占位示意。)这没问题,但代价是:类型从源头就变成 Result,一路携带。你写的不是”普通映射 + 偶尔可能失败”,而是”全程都在和 Result 打交道”。你想拿到干净的 Vec,最后还得 results? 解开一次。而且一旦映射中途真出了错,collect 会停在第一个错误上。

在 Swift 里,有个 rethrows,理论上能解决,但它有一个硬性要求:这个函数必须通过参数接收一个抛错函数,而且它自身抛出的错误必须来自这些参数函数。一旦函数内部自己抛错、或错误不是直接来自传入的闭包,就不能用 rethrows,得老老实实标 throws。这个限制在回调多了、嵌套深了的时候很容易踩坑。


MoonBit 的 raise?:一份实现,两种场景

MoonBit 的解法很直接:既然 f 可能抛错、也可能不抛错,那就在签名里这样写——

raise?

带着问号,意思是:可能抛错,也可能不抛错,取决于调用时给的回调。

于是 map 只需要写一次:

fn[T] map(array : Array[T], f : (T) -> T raise?) -> Array[T] raise? {
  let res : Array[T] = []
  for x in array {
    res.push(f(x))
  }
  res
}

注意看 f : (T) -> T raise? 和返回 Array[T] 后面没有写死 noraise 也没写 raise——它俩都被这个 raise? 给”接过来了”。

这个签名意味着:这个函数的「抛错行为」,会由你传进去的回调来决定。

现在,同一个 map,两种用法的效果立竿见影。

场景一:传一个不抛错的回调(sqr 是 noraise):

fn main {
  let a = [1, 2, 3].map(sqr)
  println("no raise: \{a}")
}
// 输出:no raise: [1, 4, 9]

场景二:传一个会抛错的回调(div 是 raise):

fn main {
  let b = try [6, 8, 10].map(x => div(x, 2)) catch {
    _ => abort("unexpected error")
  }
  println("with raise: \{b}")
}
// 输出:with raise: [3, 4, 5]

看到没有?同一个 map,没有第二份实现,两种回调都能用。

而且,如果你在回调里真的触发了错误,错误能一路穿透 map 传到外层,被 catch 捕获:

test "error propagates through the same map" {
  let res = try Ok([1, 2].map(x => div(x, 0))) catch {
    DivError(msg) => Err(msg)
    _ => Err("other")
  }
  assert_eq(res, Err("division by zero"))
}

这个测试能通过,说明一件事:map 本身没有把错误吞掉,而是如实把回调的错误往上抛。


关键是”行为跟着参数走”

raise? 最妙的地方,是它的推导机制:

map 自己到底会不会抛错,不是写死在那里的,而是——看调用时你给的回调是什么。

  • 你给的是 noraise 回调,那这次 map 调用就是 noraise 的,结果类型干净。
  • 你给的是 raise 回调,那这次 map 调用就是 raise 的,你需要用 try 或 catch 处理。

签名由实际参数推导而来。

这就是为什么它叫错误多态(error polymorphism)。

它跟普通的多态很像:普通多态是”同一个函数,根据参数类型表现出不同行为”;错误多态是”同一个函数,根据参数是否会抛错,表现出不同的错误行为”。


这不是”语法糖”,因为标准库自己就在用

很多新手会觉得 raise? 是个小众的语法糖。

但事实是:MoonBit 标准库里最常用、最高频的函数,就是用 raise? 声明的。

比如 Array::map 在标准库里的真实定义:

pub fn[T, U] Array::map(
  self : Array[T],
  f : (T) -> U raise?,
) -> Array[U] raise? {
  let arr = Array::make_uninit(self.length())
  for i, v in self {
    arr.unsafe_set(i, f(v))
  }
  arr
}

看到没,f : (T) -> U raise? 和 -> Array[U] raise?。

标准库自己都用 raise?,说明它不是边缘功能,而是一等公民的设计。

为什么标准库要这么写?因为 map 太常用了,它必须同时服务”会抛错的场景”和”不抛错的场景”。如果不用 raise?,标准库就得像 Rust 那样搞出 map 和 try_map 两个方法,用户还得想”这次该用哪个”。


和其他语言对比:为什么 MoonBit 这个方案干净

场景RustSwiftMoonBit
不抛错的映射iter().map()map()map()
会抛错的映射map() + .collect() 聚合成 Resultmap(throws:) + rethrows还是同一个 map()
一份实现两用做不到rethrows 限制多做到

Rust 的痛点是:即使 map 本身能映射,失败也得靠闭包返回 Result 然后 collect 聚合,类型从头到尾都要携带 Result,拿不到干净的 Vec。

Swift 的 rethrows 理论上很美,但限制很苛刻:它要求函数抛出的错误必须直接来自传入的抛错函数,内部自己抛错或错误来源不明确就得退回 throws,回调一多、一嵌套容易踩坑。

MoonBit 的 raise? 直接绕开了这些绕路:把”会不会抛错”写进函数类型签名,然后让这个函数根据参数自动决定。它既不是两套 API,也没有 Swift 那些限制。


呼应一下:从 Result 到 raise?

如果你看过我写 MoonBit 的第一篇文章(为什么 println 和 Json 能直接用),你会记得里面提到过:

let result : Result[Int, String] = Ok(42)

Result 把”可能出错”变成了一种值的类型。

而 raise? 是另一条路:它把”可能出错”写进了函数的类型签名。

这两者并不冲突,反而互补:

  • Result 是”我把错误当值显式传给你”,适合错误也是数据的场景。
  • raise / raise? 是”我在函数类型里声明我会不会抛错”,适合错误是控制流的场景。

MoonBit 让你在两者之间自由选择,而不是只给一条路。

而 raise? 这个”随参数变化”的设计,正是 MoonBit 一贯的品味:尽量让类型系统替你记住那些容易被忽略的细节,而不是靠人脑记。


总结

raise? 让一个高阶函数,根据传入回调是否会抛错,自动决定自己要不要抛错——一份实现,两种场景全都能用。

它有几点值得记住:

  1. raise 声明”可能抛错”,noraise 声明”不抛错”,raise? 声明”看参数而定”。
  2. raise? 解决的是高阶函数的复用难题——map 这类函数不该因为回调抛不抛错而写两份。
  3. 这不是语法糖,MoonBit 标准库的 Array::map 就是用它声明的。
  4. 它比 Rust 的两套 API、Swift 的限制重重的 rethrows 更干净——把”会不会抛错”变成可推导的类型信息。

如果你以后写一个接收函数参数的 API,会纠结”万一用户的回调抛错呢”,那 raise? 就是为你准备的答案。