通过范畴论认识单子
我们都知道编程基于数学,但或许不太清楚的是,掌握更多数学知识将有助于我们成为更优秀的开发者。虽然数学在所有类型的编程中都至关重要,但在函数式编程中更是如此。
最近你肯定听说过一个非常流行的概念,那就是单子(monad)。它是范畴论中的一个数学概念,后来被应用于函数式编程中。
我第一次听到“monad”这个术语是在我开始学习Scala编程的时候。在网上搜索了很久之后,我发现相关的文章要么是数学家写的,要么是面向数学家的,要么是讨论编程而非数学的文章。另一方面,人们也经常滥用这个术语,用它来指代一些并非真正意义上的monad。因此,我们将尝试在保持数学严谨性的前提下,探讨monad的本质,以及它们如何帮助我们成为更优秀的开发者。尽管monad是非常抽象和复杂的概念,但我们会尽量用简单易懂的方式来解释。
为了理解单子,我们首先必须了解两个概念:范畴和函子。
类别
我们先来看一个例子:
想象一下,我们获得了《指环王》中土世界的这样一张地图。这张地图是精灵绘制的。
我们在这张地图上看到了什么?
首先映入眼帘的是几个城镇。此外,还有连接各个城镇的道路,以及城镇之间的道路(象征着身份认同)。这些道路都是单行道。而且,正如地图所示,并非所有城镇都能到达(谁会想去魔多呢?哦,对了……霍比特人……)。另一方面,很明显,如果弗罗多想从夏尔前往孤山,他可以先经过瑞文戴尔,也可以直接前往孤山。
这是一个范畴。从数学角度来说,它包含两个组成部分:
-
第一组是一组集合(中土世界的城镇集合)
-
第二种是称为态射(连接城镇的道路)的函数/箭头。此外,我们可以复合态射(如之前的弗罗多示例所示),这种复合使得任何与恒等态射复合的态射都等于态射¹,并且复合满足结合律²。
函子:
想象一下,现在我们找到了另一张地图,这次是《权力的游戏》中七大王国的地图。
此外,我们还有城镇以及连接这些城镇的道路。也就是说,我们还有另一类情况。
看看连接各个城镇的道路。你能找到一些共同点吗?例如,有没有哪个城镇是没人想去的?当然,谁会想去弥林呢?没人!
另一个例子是,存在一个可以从所有其他城镇到达的城镇。看看中土世界的地图!
事实上,它与托尔金的作品有如此多的相似之处,以至于我们可以说,如果我们更改名称和距离,它就是同一张地图(在数学中,它被称为具有相同的结构;在现实世界中,我们说乔治·R·R·马丁“深受”托尔金作品的启发)³。
由此可见,我们应该能够改变七大王国的中土世界地图,但是……该如何改变呢?
首先我们需要考虑的是城镇的改变,具体来说:
我们最后要做的就是改造道路:
这种转变非常重要的一点是它保留了结构,包括构成:弗罗多从夏尔到孤山(可以选择经过瑞文戴尔)的旅程类似于琼恩·雪诺从长城到君临城(也可以选择经过鹰巢城)的旅程。
我们可以说,这种变换……就是一个函子!通过这种方式,我们能够变换“完全不同”的世界,这正是数学范畴论的创建目的:在截然不同的领域之间搭建桥梁。
单子:
现在我们又发现了一张中土世界的地图,但这次是由一位矮人绘制的。
当然,中土世界是一样的,所以它一定(也确实)拥有相同的人口和相同的道路。当然,有了这么多地图,迷路应该不难……
由于本质上是同一张地图,显然我们也可以构造一个函子或中土世界本身的变换,其结果也相同。这种特殊的函子被称为自函子。
但还有更多。如果一个自函子还满足它有两个自然变换,例如恒等函数和另一个结合函数,我们可以说我们有一个单子(我们称之为单子定律)⁴。
函数式编程中的 Monad
历史:
菲利普·瓦德勒是函数式编程理论和单子应用的最大贡献者,他受到尤金尼奥·莫吉的单子启发,运用单子为编程语言赋予语义。单子将序列的概念引入函数式编程范式,但由于函数的组合,并没有与函数式编程范式相矛盾。⁵
许多常见的编程概念都可以用单子结构来描述,包括副作用、I/O、异常处理……
这使得我们可以用纯函数式的方式定义这些概念,而无需向语言语义中添加新术语,也无需采用命令式编程。像 Haskell 这样的语言在标准内核中提供了 monad,而 Scala 虽然内核中没有提供 monad,但您应该熟悉Cats和Scalaz库,它们可以帮助我们使用 monad 进行开发(当然,我们也可以自己实现 monad)。
函数式编程中的函子和单子:
解释数学中的范畴论是如何运作的固然很好,但作为开发者,我们已经迫不及待地想看到它应用于函数式编程的例子了:
这张图⁶表示一个函子,在本例中,它由列表和映射组成。想必你已经将这个例子与《指环王》和《权力的游戏》中的函子联系起来了。
你可能在网上看到过,List 本身就是一个函子,就像 Scala 的 Option 类是一个单子一样。它们都是类型构造器(相当于城镇的转换器),但是……道路/箭头的转换器在哪里呢?这是一种非常常见的语言滥用,你以后应该不会再用同样的眼光看待它了。
如果在之前的图中,我们用的是 flatMap 而不是 map(flatMap 的输出属于同一类别),那么我们画的就会是一个单子(虽然我们需要验证单子定律),而不是一个函子。
在程序中使用 monad 的理由有很多,但我将列出(依我拙见)使用 monad 的三个主要原因:
-
归根结底,我们只想使用函数(记住:函数式编程)。
-
假设你有一个程序,它执行以下两个功能:
f(x) = x+3
g(x,y) = List(x, y)
我们希望这两个函数按顺序执行,并且我们希望只使用函数来实现。那么,我们该如何实现呢?使用函数的组合。如果我们想先执行 g,然后对结果执行 f,我们可以按如下方式组合函数:f ∘ g (x, y)。
-
如果我们用g(x, y) = x / y代替之前的 g,则会遇到两个问题:
-
如果接受 f 的类型与返回 g 的类型不同(因为 g 是除法运算,所以可能返回浮点数,而 f 接受整数),那么显然就会出现问题。
-
如果函数 g 传入值 2 和 0 会发生什么?实际上,函数会报错,因为它不能被 0 整除。在函数式编程中,我们不希望出现异常,因为异常不是函数。
我的意思是,函数组合还需要一个要素才能达到100%的功能性(这个要素也能解决上面提到的两个问题)。显然(从帖子标题就能看出来),这个要素就是单子(monad)的使用。
其理念是允许函数返回两种类型的结果,因此 g 将不再是:g: Int, Int → Float,而是g: Int, Int → Some | None和f(x): Some | None → Some | None。
例如,我们将撤销上一个例子中的函数:
def g(x: Int, y: Int): Option[Float] = {
y match {
case 0 => return None
case y => return Some(x.toFloat/y)
}
}
f(x) 也一样:
def f(x: Option[Float]): Option[Int] = {
x match {
case None => None
case Some(x) => Some(x.asInstanceOf[Int]+3)
}
}
因此,我们的代码将是g(x, y).flatMap(x => f(Option(x)))。这样,我们就组合了两个函数并避免了异常,因为如果 y 等于 0,结果将为 None。
总结与结论
简而言之,我们可以说单子是一种遵循单子定律的自函子。我们还可以说,正是由于单子的存在,我们才能组合函数,解决类型问题和异常情况。
现在我们对范畴论有了一些了解,也知道了其中一些概念在函数式编程中的应用,我们可以使用程序中提供的工具来编写更好的代码。
去吧,你就可以告诉你的朋友们“我见过你们人类无法相信的事情”。
笔记
1.也就是说,如果弗罗多从夏尔前往埃瑞博(f),然后留在埃瑞博(身份),就相当于他直接前往埃瑞博。
2.也就是说,如果我们取五条路径,例如从夏尔到埃瑞博的路径(f),从瑞文戴尔到夏尔的路径(g),从魔多到瑞文戴尔的路径(h),从瑞文戴尔到埃瑞博的路径(j),以及从魔多到夏尔的路径(k)。那么,(f ∘ g) ∘ h = f ∘ (g ∘ h),因为 f ∘ g = j 且 g ∘ h = k。
3.显然,这些地图的路径都是精心设计的,以便一切都完美契合。
4. 具有复合运算的单子定律:
-
F ∘ id = F(左恒等式)
-
Id ∘ F = F(右恒等式)
-
(F ∘ G) ∘ H = F ∘ (G ∘ H) (结合律)
5. De Euclides a Java — Ricardo Peña Mari
资源和链接
-
维基百科上关于范畴论和单子的文章
*本文最初发表于Datio 的博客,日期为 2017 年 6 月 26 日。
文章来源:https://dev.to/juaneto/knowing-monads-through-the-category-theory-1mea





