写高阶函数时,我们经常撞到一个很烦的坑:
这个函数,到底会不会抛错?
比如 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 这个方案干净
| 场景 | Rust | Swift | MoonBit |
|---|---|---|---|
| 不抛错的映射 | iter().map() | map() | map() |
| 会抛错的映射 | map() + .collect() 聚合成 Result | map(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?让一个高阶函数,根据传入回调是否会抛错,自动决定自己要不要抛错——一份实现,两种场景全都能用。
它有几点值得记住:
raise声明”可能抛错”,noraise声明”不抛错”,raise?声明”看参数而定”。raise?解决的是高阶函数的复用难题——map这类函数不该因为回调抛不抛错而写两份。- 这不是语法糖,MoonBit 标准库的
Array::map就是用它声明的。 - 它比 Rust 的两套 API、Swift 的限制重重的
rethrows更干净——把”会不会抛错”变成可推导的类型信息。
如果你以后写一个接收函数参数的 API,会纠结”万一用户的回调抛错呢”,那 raise? 就是为你准备的答案。
