什么是 monad?
monad(单子) 是函数式编程中的一种抽象,本文旨在对 monad 的粗浅介绍,所以跳过其数学上的定义和结构性证明(其实是目前笔者也不太懂🤫),通过一些具体的例子说明它的概念和作用。
定义
尽管没有太复杂的数学概念,但还是需要一个定义说明什么样的东西才能称之为 monad。在接下来的说明中,除了列举出数学定义以外,还有其在 go 语言中的具体表现形式。在 wiki 的定义中:
一个 monad 包含三个部分:
-
类型构造子
M
。- 在 go 中可以理解为一种名为
M
包裹着T
的泛型结构体M<T>{ val: T }
- 在 go 中可以理解为一种名为
-
类型转换子
Unit :: T -> M T
。- 在 go 中可以理解为由值
T
构造M
的函数func Unit[T any] (val T) -> M<T>
- 在 go 中可以理解为由值
-
组合子
>>= or FlatMap :: M T -> ( T -> M U) -> M U
。- 在 go 中可以理解为
M<T>{ val: T }
这个结构体具有一个成员方法func flatMap[T, U any] (func(T) -> M<U>) -> M<U>
,能够接受一个函数参数实现从M<T>
到M<U>
的变换。
- 在 go 中可以理解为
那么我们可以称这个具有 FlatMap
方法的 M 为一个 Monad (请注意不是 M<\T> )。
更严格的定义
一个 monad 还必须含有以下三个约束:
-
转换子
Unit
是组合子>>=
的左单位元:Unit >>= f <-> f
。- 在 go 中可以理解为:如果有一个函数为
F[ T, U any] (T) M U
, 那么Unit(x).FlatMap(f)
的执行结果和执行f(x)
结果相同
- 在 go 中可以理解为:如果有一个函数为
-
转换子
Unit
是组合子>>=
的右单位元:f >>= Unit <-> f
- 在 go 中可以理解为:如果有一个函数为
F[ T, U any] (T) M U
,F(x).FlatMap(Unit)
的执行结果等于F(x)
- 在 go 中可以理解为:如果有一个函数为
-
组合子
>>=
满足结合律:ma >>= λx -> (f(x) >>= λy -> g(y)) <-> (ma >>= λx -> f(x)) >>= λy -> g(y)
- 在 go 中可以理解为以下两个过程执行结果相等
1 | func F[T, U any](x T) M<U> { f(x) } // f(x) 是对 x 的一些行为 |
1 | func F[T, U any](x T) M<U> { f(x) } // f(x) 是对 x 的一些行为 |
monad 有什么用?
在列举完 monad 的定义后,为了避免陷在抽象的世界里无法自拔,笔者在接下来会具体列举一些例子说明 monad 的作用 。以笔者的观点来说,monad 的作用就是提供了一种隐藏副作用的形式,使得在编写处理函数的时候只用考虑预期的输入,将副作用延续到最后处理。
另一个宇宙的 go error monad
引言
在 go 编程中,可能常见如下代码:
1 | // 获取要查询的ID |
可以看到 go 的灵魂出现了🤗
当然笔者并不反对 go 这种严格处理每个函数返回的错误值的思想,不过本文既然是有关 monad 的介绍,自然是想着怎么将 monad 套用到 go 的错误处理中。
go 版 monad 式错误处理
回顾 monad 的定义:
- 首先 monad 是一个结构体:
1 | type ErrMonad[T any] struct { |
上面的结构体包含了返回值和错误。
- 然后需要一个由
T
构造成M T
的函数:
1 | func Unit[T any] (result T) ErrMonad[T] { |
- 有组合子
FlatMap
成员方法:
1 | func (h ErrMonad[T]) FlatMap[U] (mapFunc func(T) ErrMonad[U] ) ErrMonad[U] { |
有了上述实现后,之前的流程就可以改写为:
1 | // 获取要查询的ID |
可以看出相较于之前的版本,代码更简洁了一些 (至少少了 if err != nil { return err }
)。
然而理想是美好的,看着 monad 实现这么简单,为啥群友总说 go 不支持 monad 呢。回看本节标题 “另一个宇宙的 go error monad”,非常遗憾的是,目前的 go 不支持泛型方法参数 Type Parameters Proposal,为啥 go 不支持泛型方法。具体来说就是不支持入参是一个带泛型的方法,即以下函数都是无法实现的:
1 | func goIsBest( func[T any] () ) bool { return false } |
摆个 issue 做参考(希望未来会有解决方法吧):
proposal: spec: allow type parameters in methods · Issue #49085 · golang/go · GitHub
这就导致了 FlatMap
方法是不可行的。至此,go 的 monad 之旅到此结束。
附一篇经典的错误处理方法 blog ( 感觉就像一种青春版的 monad,在所举的例子中存在类型只有 io.Writer,所以只用在单个类型里打转,省略了由 T 类型到 U 类型的转换,所以这种形式可以在 go 中实现:
Errors are values - Thttps://go.dev/blog/errors-are-valueshe Go Programming Language
if err != nil 太烦?Go 创始人教你如何对错误进行编程! - 知乎 (评论区)
monad 如何解决回调地狱
现在让我们来看看一点老(不新又不老)的东西。
引言
各位即使没写过 javascript,也可能听说过回调地狱这个概念,具体来讲这是一种 javascript 异步编程中出现的一种现象。拿Callback Hell中的例子举例吧:
1 | fs.readdir(source, function (err, files) { |
上面的代码具体作用就是异步执行如下操作: 通过传入的 srouce
读取指定目录下的文件列表,然后使用 gm
函数进行图像处理,保存处理后的图像到目标目录。
可以看到代码的嵌套层级非常深,这就是早期 javascript 异步编程的问题。对于异步函数,需要传入一个回调函数表明在当前状态结束后 (如读取文件结束后) 应该继续执行的动作。可以想象一旦异步处理过程多了,如果没有合适的方法,必然会导致深层次的函数嵌套。一般的做法就是将嵌套的函数抽出来,将异步调用拆解到每个函数中:
1 | main() { |
但上述做法带来的一个小问题是如果需要了解整个运行的流程,需要不断跳转函数才能知道整个运行逻辑,而不能直接在一个 main 函数中知晓。
异步和 monad
上述问题的根本原因在于异步过
promise 介绍
在 2015 年后,promise 的出现缓解了 javascript 在异步编程中的问题,首先介绍一下什么是 promise:
- promise 是 javascript 中的一个对象,通过
Promise.resolve
方法可以构造出一个 promise 对象。
1 | let x = Promise.resolve(123) |
-
promise 内部有三种状态
pending
、fulfilled
和rejected
。他们的作用在这里不深究,只要粗略地了解:fulfilled
可以认为是方法执行成功的状态,rejected
可以认为是方法返回 error 的状态。 -
promise 有三个成员方法
then
,catch
和finally
。这里只介绍then
和catch
方法。
then
方法接受两个类型为函数的参数,一个是当状态为 fulfilled
的时候调用,另一个为 rejected
的时候调用。一般来说,笔者喜欢只传前一个参数,第二个参数使用缺省值,即只有在状态为成功的时候才执行传入的函数。具体代码例子如下:
1 | let x = Promise.resolve("now") |
和 then
类似,catch
方法接受一个类型为函数的参数,当状态为 rejected
会调用,具体代码例子如下:
1 | let x = Promise.reject("now") |
promise 和 monad
在了解了 promise 的概念后,可以看出 promise 非常像一个 monad。下面来点证明:
-
类型构造子:Promise < T >
-
类型转换子:Promise.resolve
-
组合子:Promise< T >.then( (value: T) => U )
1 | let x = Promise.resolve("now") |