Monad - A functional design pattern

今年学校课程里有一门课,大概要求选一门技术来介绍一下。考虑到我的编程偏好,我觉得介绍一下函数式编程的概念比一些很具体的厂商产品更有趣吧。所以就选了Monad。

想想也许适合水一篇文章,毕竟一般来说,接触函数式编程的人最终都要写一篇自己的Monad介绍文章(雾

这篇文章不是用来手把手教怎么入门Monad的,毕竟课程要求也不是这个,所以就是简单的「引子 - 正式介绍 - 用处和优势 - 案例 - 小结」的模式了。

那么正文如下,稍微翻译了一下(原稿是英文):

引子

现代的计算机程序需要满足各种标准,以适应用户或开发者的需求以及计算载体的变化。尽管传统的OOP或过程式方法仍然在业界占据主导地位,但在许多场合,函数式编程开始显露其不可替代的优点,尤其是处理以下的情景:

  • 需要应对并发和并行性的场合,并且与此同时还需要保持良好的代码属性,
  • 增强类型安全性并消除bug,
  • 编写可预测甚至可证明的代码,
  • 进行高阶抽象以响应复杂的业务逻辑等…

没有FP经验的开发人员可能很难采用函数式编程。一个典型的问题是嵌套的switch(或模式匹配,又称作Pattern Matching)形成的难以维护的代码。有些开发人员知道另一种形式:处理异步时的回调地狱。

Monad(或者称作单子),就是函数式编程中的一种设计模式,可以用于消除这种嵌套结构。此外,它允许更高阶的抽象,并为在严格的函数上下文中建模IO或可变变量等副作用打开了大门。

定义

Monad的想法最初来自范畴论。那个著名的「一个单子(Monad)说白了不过就是自函子范畴上的一个幺半群而已」的梗,还有从这推广出来的「M开头的词」,也都是从这里衍生的。

在实际工程中,任何符合以下规则的数据类型都可以被视为符合单子的要求:

  • 由一个类型变量所参数化,在OOP语言里,这一般叫做泛型,例如,一个接收一个T类型的List就是这样的一种类型,
  • 具有一个方法,一般命名为pure或return,可以将类型为T的数据提升到这个单子中,
  • 具有一个函数签名类型为M T -> (T -> M U) -> M U的方法,一般称为bind或>>=,可以转换单子内部的值和类型而不破坏单子的结构。

一般的,一个单子自然也应该遵循单子的三个法则::

  • 左恒等律:pure x >>= ff x
  • 右恒等律:m >>= purem
  • 结合律:(m >>= f) >>= gm >>=(λ. x -> f x >>= g)

从Subtyping(更准确地说是Subtypeclassing)的角度来看,Monad可以被视为一个Typeclass(也可以说是interface/trait/protocol…)。这样的Typeclass定义了一种数据类型必须符合的约束,来决定一个数据结构是monadic的。在Haskell语言里,Monad是建构在Functor - Applicative - Monad的Subtypeclassing链条上的。不过在例如OCaml等其他语言里,Monad也可以被直接定义出来而不需要单独分出Applicative这个trait。

需要注意的是,这种协议是用普通OOP语言不支持的Higher Kinded类型(也就是建立在类型上的函数)定义的。大部分函数式语言,例如Scala,天然地支持这一特性。不过在Java里面的话,光是模拟HKT就需要一些dirty hack,同时类型安全的优势也会消失。

优点和用法

单子最明显的优势是在一个更高层次的抽象中提取各种数据类型的许多共同属性或方法的能力,这意味着只要实现了pure>>=这两个要求,我们就可以在这些数据类型上免费地获得了大量有用的方法。

其次,结合律给任意monadic的类型在开启了类似Linq/Stream这种声明式的代码风格。在C#中,只要让一个数据类型成为IEnumerable的实例,就可以在它上面启用Linq,这就是Monad的一种典型应用。有了单子之后,进一步在语言本身或标准库提供的类型上使用声明式代码风格也就成了可能。

通常来说,函数式语言会进一步提供方便的语法糖,如do-notation或list/monad comprehension来进一步简化代码量。这从另一个角度提供了声明式的语法,例如,以类似EBNF语法定义的风格来编写解析器。

Monad也为进一步的复杂组合或处理嵌套结构提供了可能性,例如Monad Transformer或者lens。

在函数式编程中,单子就像是一把方便的瑞士军刀,用来「把抽象推向极限」并进行组合,甚至为元编程提供可能。一些例子有:依赖注入(Reader)或对抽象语法树进行操作(Free)。

在工程上,Monad的一个经典例子就是Parser Combinator。

并且以上所有的领域里,都是类型安全的,也就意味着形式化验证成为了可能。

例子

许多常见的数据类型本质上都是单子:

  • Maybe/Optional/Nullable,表示一个值可能存在或不存在。通过这种方式,FP语言消除了“十亿美元级别的错误”。
  • List/Array,并且可以扩展到任何可以增加成员的线性集合。除此之外,像BinaryTree这样的非线性集合在理论上可以是monadic的,但在这上面进行monad操作是意义可疑的。
  • Either/Result表示两个分支的并集,通常一个「正常的分支」用于保存值,一个「有问题的分支」用于保存错误消息。从这点上说,Java中的Checked Exception可以被认为是monadic的。
  • Promise/Future,代表一个持续一段时间的,延迟的运算。实际上,JavaScript或者C#里的async/await语法糖,表达的就是用do-notation写法表示的连续的Promise链。使用这种方式就不需要显式地获取Future的结果了。一般来说,异步风格在编写非阻塞和无锁的并发程序方面有优势。
  • IO/Effects。这种概念在命令式语言中可能没啥用,但函数式语言可以仅仅利用IO monad来拥有与它们相同的表达能力,哪怕语言本身不支持副作用。

除此之外,还有很多其他的单子类型:LazyStateContinuation,Concurrent (STIORefMVar、…),Free,…

可以发现,这里所有的类型都是「接受一个类型参数的容器」,这也回应了我们前面提到的定义。另外一件有趣的事情是,有些数据类型在命令式语言中难以构建出来,以至于需要作为语言的语法结构来提供,但是在函数式编程语言里,这些类型可能就是简单的抽象代数类型。

结论

简单概括一下的话,我们回顾了嵌套模式匹配带来的可维护性问题,以及monad作为一种可能的解决方案。我们展示了它的正式定义,也简要介绍了它在不同语言里的应用情况,并进一步讨论了它擅长解决的问题、一般用途以及一些常用实例。

作为结论,单子当然可以被定义为一种设计模式,因为它可以解决很具体的编码和设计问题。和传统OOP设计模式不同的地方,大概在于Monad根植于函数式编程风格,而它的理论基础可以追溯到范畴论。

这也是「编程语言的『组合性』」的一个很好的例子。

就这么多了。

代码略过。

如果想要看比较正经的Monad入门介绍可以Google一下,比如这一篇:https://www.blog-blockchain.xyz/pl/monad/index.html。

那么这次就先到这里了。


Monad - A functional design pattern
http://inori.moe/2023/02/20/monad-a-functional-design-pattern/
作者
inori
发布于
2023年2月20日
许可协议