用 A
来代表任意一个数据类,还有一个一元类型构造器 (Unary Type Constructor) F
,我们可以构建出一个类型 F[A]
。这个类型构造器 F
能代表什么?我们可以凭借经验列举出很多合理的答案。F
可以表示赋予给 A
的某项能力,比如 Ordering[A]
指定 A
是可排序的数据;也可以代表以 A
为元素的数据结构,比如 IndexedSeq[A]
,List[A]
,Set[A]
;它还可以表示返回 A
类型的异步计算,比如 Future[A]
,等等。
总而言之,类型构造器 F
代表了一种用于修饰 A
元素的 上下文 ,容器 ,更专业的术语是 作用 effect。举出的例子越多,就越有助于我们将 F
从具体的形式中抽象出来。是否可以仅基于这个抽象的容器 F
构造一套 API,然后将其形式化为一个接口 (或特质) 呢?事实上,有 Scala 强大的类型系统作支撑,这在技术上完全可行。但如果不去指明 F
应该表示什么,那为它编写出的接口及其 API 有什么意义呢?
这正是本文要讨论的,完全基于法则 (law) 的纯代数式接口,本文将用这类接口描述 Functional programming 中一些通用的结构模式。重点是 F
(以及 A
)遵循的法则,因为法则为函数式程序赋予可预测性和可推导性。比如在分治算法中,可预测性使我们大胆假设数据的结合顺序不会影响计算的结果;可推导性意味着基于法则推导并衍生出的组合子都是可靠的,不需要编写额外的单元测试代码验证它们的功能。我们所期望的这些良好性质显然都与 F
的形式无关。
这些通用的函数式模式主要围绕三种基本的操作展开:组合,折叠,以及映射。关于组合,本文首先会介绍 Monoid 和 Monad,它们的概念来自于群论和范畴论,其命名来源于希腊语的 monos (单元) 和 monas (单一)。通俗地说,**Monoid 组合数据 A
,而 Monad 组合 F[A]
**。不需深究它们的数学背景,在 Scala 语境中,只需要将其理解成是满足法则的特质即可。我们也很快地理解折叠是组合的另一种形式,前者针对 A
,后者则针对 F[A]
。这些模式的本质都是映射,称可映射的类型为 Functor (函子)。本文还会基于 Functor 延申出 Applicative 和 Traversable 等其它通用的模式,它们也是通过特质定义的。
在掌握了 FP 的通用模式之后,再将它们推广到具体的问题域就游刃有余了。比如,遍历任意复杂嵌套结构的集合,但不再需要编写大段的 for 循环代码;或者是并行地聚合数据,由结合律来保证其结果和串行计算相同,但效率更高。
这也仅仅是众多有趣尝试中的一部分案例,重点是尝试使用 组合 和 推导 来解决工作中的实际编程问题。通常的做法是,构建一个描述法则的最小原语集,然后不断发现并拓展新的组合子,最终在指定的问题域中编写出一套通用且功能强大的 domain specific language。
本文的内容提炼自《Functional Programming in Scala》第三部分。关于高阶类型的前置知识,可以移步到:使用 Scala 实现基于泛型的抽象 - 掘金 (juejin.cn)
Monoid 幺半群
我们先用一些简单而熟悉的例子来快速地了解 Monoid 是什么,然后再去讨论它的意义。字符串存在这样的二元运算:"foo" + "bar" = "foobar"
,且连续的二元运算满足结合律:("aa" + "bb") + "cc"
等价于 "aa" + ("bb" + "cc")
。还有一个特殊的空字符串 ""
,任何一个字符串 s1
和它计算的结果都是 s1
本身,无论空字符串出现在运算符的左侧还是右侧。
数值类型也存在着类似的运算规则,比如加法和乘法。加法显然满足 (x + y) + z == x + (y + z)
。其中加法运算的特殊值是 0
,任何数与 0
的加和都是其自身,乘法运算也同理,只不过它的特殊值是 1
而非 0
。
可以很容易地在这些简单的例子当中发现通用的模式。首先是一个满足结合律的二元运算 a op b
,其中,a
和 b
源自同一个类型 A
,且这个二元运算的结果也是 A
,称 A
在 op
运算下是 闭合 的。
数值类型的减法和除法不满足结合律,比如
1 - (2 - 3)
不等于1 - 2 - 3
。此外,除法的计算也不是闭合的,比如任何一个数除以0
都会得到一个NaN
,这不是一个有意义的数值。除此之外,还要区分 ""结合律" 和 "交换律" 这两个概念。比如,字符串拼接操作满足结合律但不满足交换律:"aa" + "bb"
并不等价于"bb" + "aa"
。
我们还提到了 ""
和 0
,1
这些在不同运算下具备特殊意义的 "零值",它们称之为单位元 (幺元)。单位元应当满足这样的性质:
scss
op(zero, a) == op(a, zero) == a
这条性质称 单位元律 。单位元与任何其他元素结合都不会改变其结果,因此在一串连续的二元运算中,单位元可以充当计算的起点。比如以迭代方式将一系列元素值累积给 sum
变量时,我们总是会为其赋 0
作为初始值,而不是 -1
或是其他的值。
scala
val xs1 = List(1, 2, 3, 4)
var sum = 0
for(x <- xs1) sum = sum + x
概念上,Monoid 就是包含了 一个满足结合律的二元运算和一个单位元 的抽象。将它声明为一个 trait:
scala
trait Monoid[A]:
// 可结合的二元操作,(x op y) op z <=> x op (y op z)
def op(a1: A, a2: A): A
// 零值,或者单位元。
def zero: A
我们需要了解的 Monoid 定义就这么多。当然,只是定义一个 Monoid 还并不是那么引人入胜,重点是对 Monoid 的应用。
Foldable 可折叠数据结构
Monoid 和可折叠数据结构 Foldable 关系密切。比如,我们熟悉的 List 就是 Foldable,它提供了一系列折叠方法供用户聚合数据。
先看 List 所提供的 fold
方法。它需要两个参数,一个是 A
类型的元素零值,一个是二元函数 (A, A) => A
。是不是很熟悉?如果这个二元函数满足结合律,那么我们传入的就是一个 Monoid 实例。
scala
val sum = xs1.fold(0)(_ + _) // xs1.sum
val min_val = xs1.fold(Int.MaxValue)((a1, a2) => if a1 < a2 then a1 else a2) // xs1.max
val max_val = xs1.fold(Int.MinValue)((a1, a2) => if a1 > a2 then a1 else a2) // xs1.min
foldLeft
和 foldRight
则更加泛化一些,它以另一个 B
类型作为零值,且累积的结果也是 B
类型。比如在经典的 wordcount 任务当中,一个自然地想法是将 B
类型界定为 Map[String, Int]
,这样我们就可以 (word -> frequency
) 的形式累积统计结果了。
scala
val wordBag = List("hello", "world", "hello", "scala")
// 使用 `foldLeft` 还是 `foldRight` 实现并不重要,因为统计的次序并不会影响结果。
val wordFreq = wordBag.foldRight(Map[String, Int]())((str, map) => map.updated(str, map.getOrElse(str, 0) + 1))
// Map(scala -> 1, hello -> 2, world -> 1)
通常期望左右折叠是等价的,这样就不必纠结折叠次序是否会影响计算结果了,Monoid 的单位元和结合律法则保证了这一点。我们之前提过,数值的加法满足结合律,但减法不是。下面可以做个简单的测试:
scala
val xs1 = List(1, 2, 3, 4)
// 减法不满足结合律,因此我们传入的不是 Monoid。
val res1 = xs1.foldRight(0)(_ - _)
val res2 = xs1.foldLeft(0)(_ - _)
println(res1 == res2) // false
// 乘法满足结合律,因此我们传入的是 Monoid,进而 foldRight 和 foldLeft 等价。
val res3 = xs1.foldRight(1)(_ * _)
val res4 = xs1.foldLeft(1)(_ * _)
println(res3 == res4) // true
有时需要先将原始数据 List[A]
切换到 List[B]
,再应用 Monoid[B]
实现折叠,见 foldMap
函数的实现:
scala
def foldMap[A, B](item: List[A], m: Monoid[B])(f: A => B) : B =
// item.map(f).foldLeft(m.zero)(m.op)
item.foldLeft(m.zero)((b, a) => m.op(f(a), b))
val xs1 = List("Hello", "World", "Scala")
val intMonoid = new Monoid[Int]{
override def zero: Int = 0
override def op(a1: Int, a2: Int): Int = a1 + a2
}
// 将 List[String] 转换成 List[Int] 并应用 Monoid[Int]。
// 等价于求 xs1 列表中所有字符串的长度之和。
foldMap(xs1, intMonoid)(_.length)
特别地,当传入的 f
是 identity 函数时,foldMap
和 fold
函数等价。identity 函数指 x => x
这类直接返回输入的函数 ,又称之为 恒等映射,或者称自函数 ,显然存在 identify(x) == x
。
只探讨对 List 的折叠还是太狭隘了。我们不妨抽象出一个 Foldable[F[_]]
:
scala
def dual[A](monoid: Monoid[A]): Monoid[A] = new Monoid[A]:
override def zero: A = monoid.zero
override def op(x: A, y : A) : A = monoid.op(y, x)
def endoMonoid[A]: Monoid[A => A] = new Monoid[A => A]:
override def zero: A => A = a => a
override def op(a1: A => A, a2: A => A): A => A = a1.andThen(a2)
trait Foldable[F[_]]:
def foldRight[A, B](fa: F[A])(zero: B)(f: (A, B) => B): B =
foldMap(fa)(f.curried)(dual(endoMonoid[B]))(zero)
def foldLeft[A, B](fa: F[A])(zero: B)(f: (B, A) => B): B =
foldMap(fa)(a => b => f(b, a))(endoMonoid[B])(zero)
def foldMap[A, B](fa: F[A])(f: A => B)(monoid: Monoid[B]): B = ???
def fold[A](fa: F[A])(zero: A)(f: (A, A) => A) : A = ???
其中,F
可以代表包含了一个类型参数的类型构造器,它可以是 List,也可以是 Map (需要类型 lambda),Set,Tree 乃至 Future。
这里只需将 foldMap
作为原语,其他 fold 方法都可以基于它衍生出来。
结合律与并行化
我们特别强调 Monoid 定义的二元运算需要满足结合律,因为这保证了运算的结果与元素的结合方式无关。进而,可以引入 平衡折叠 来代替深度嵌套的左右折叠,从而令一些操作更有效率,或者是更易被并行化。
假如有一个顺序集 (a, b, c, d)
,其左折叠的计算路径是这样的:
css
op(op(op(a, b), c), d)
而平衡折叠的计算路径是这样的:
scss
op(op(a, b), op(c, d))
不妨将这两条计算路径看作是两棵树,显然前者是非平衡的 (接近于链表形状),而后者是平衡的。在串行环境中,树的形状并不会带来性能上的区别,因为需要遍历的叶子节点数量是相等的,但在并行环境中就完全不一样了。很容易证明,此时高度更低的树所需要的时间复杂度更低。
我们这就为 IndexedSeq
实现一个平衡折叠的 foldMapV
方法:
scala
def foldMapV[A, B](seq: IndexedSeq[A], m: Monoid[B])(f: A => B) : B =
if seq.isEmpty then return m.zero
if seq.length == 1 then return f(seq.head)
val (l, r) = seq.splitAt(seq.length / 2)
m.op(foldMapV(l, m)(f), foldMapV(r, m)(f))
显然当 seq
的长度小于等于 1 时就没有必要再派生新的递归调用了。根据 Monoid 的单位元律, (m.zero op x) == x
这个性质恒成立,此时直接返回 seq
剩下的那一个元素或者单位元即可。
然后,我们应用 Future 库并行化每个分支(见笔记:Scala 纯函数式库设计:并行编程 - 掘金 (juejin.cn)),或者是将其集成到我们之前设计好的函数式并行库中(见笔记:Scala 纯函数式库设计:并行编程 - 掘金 (juejin.cn)),这里仅给出简易实现:
scala
given ExecutionContextExecutor = ExecutionContext.fromExecutor(ForkJoinPool())
def foldMapVParallel[A, B](seq: IndexedSeq[A], m: Monoid[B])(f: A => B) : Future[B] =
if seq.isEmpty then return Future.successful(m.zero)
if seq.length == 1 then return Future.successful {f(seq.head)}
val (l, r) = seq.splitAt(seq.length / 2)
val f1 = foldMapVParallel(l, m)(f)
val f2 = foldMapVParallel(r, m)(f)
// 我们将折叠计算,而非数据结构。
Future.foldLeft(Array(f1, f2))(m.zero)(m.op)
下面来测试并行版本和非并行版本的 foldMapV
方法的性能:
scala
val xs1 = (1 to 1024).toArray
val m: Monoid[Int] = new Monoid[Int]:
override def zero: Int = 0
override def op(a1: Int, a2: Int): Int = {
Thread.sleep(3)
a1 + a2
}
end m
val t1 = System.currentTimeMillis()
val y = foldMapVParallel(xs1, m)(identity)
y.onComplete {
case Success(value) =>
println(value)
println(s"parallel foldMap latency = ${System.currentTimeMillis() - t1}")
}
Await.result(y, Duration.Inf)
val t2 = System.currentTimeMillis()
val x = foldMapV(xs1, m)(identity)
println(x)
println(s"non-parallel foldMap latency = ${System.currentTimeMillis() - t2}")
理论上,串行版本的 foldMap 的时间复杂度是 O(N) ,并行化版本的时间复杂度是 O(logN) ,且二元操作的时延越显著(这里是使用 Thread.sleep
模拟的),并行版本的性能加速比越高。
对于简单的折叠计算,引入并行化并不会带来多少好处,反而会因频繁切换线程而导致性能下降。
Monoid 同态
Monoid[A]
可以通过某个函数 f
转换为另一个 Monoid[B]
,f
是一个 A => B
的概念映射。这样,我们就可以将对 A
的二元运算映射为对 B
的二元运算了。下面用具体的 Monoid[List[Int]]
和 Monoid[Int]
来举例:
scala
def listMonoid = new Monoid[List[Int]]:
def zero = List[Int]()
def op(a1: List[Int], a2: List[Int]) = a1.concat(a2)
def accum(list: List[Int]): Int = list.sum
def intMonoid = new Monoid[Int]{
def zero = 0
def op(a1: Int, a2: Int): Int = a1 + a2
}
现在正好有两个 List[Int]
列表,我们期望求这两个列表所有元素的 Int
值总和。这有两种方式:
- 首先将两个列表合并成一个,然后求这个大的列表的元素和。
- 分别求每一个小列表的元素和,然后将这两个元素和相加。
即,下方的两种调用是等价的:
scala
accum(listMonoid.op(xs1, xs2)) == intMonoid.op(accum(xs1), accum(xs2))
我们可以提取出更一般的模式。存在 M: Monoid[A]
和 N: Monoid[B]
两个 Monoid,如果有 f: A => B
,我们称 f
构建了一个由 M 到 N 的 Monoid 同态。它满足:
scala
M.op(f(x), f(y)) == f(N.op(x, y))
f(M.zero) = N.zero
上述的性质表明:无论将映射 f
操作放到 M 的二元结合之前,还是放到 N 的二元结合之后,其结果不会改变 。这使我们可以灵活选择对 Monoid 进行转换的时机。比如要计算一个包含海量元素的列表元素之和,更明智的方法是每次读取一小批数据再进行累积求和,因为这样可以有效避免 OOM 的问题,而在有些情况却相反。比如现在 f
函数是一个远程 RPC 调用,则此时等待集齐完所有的数据之后再发送或许才是更好的策略,因为这样可以显著减少网络开销。
特别地,M 的单位元可以通过 f
直接映射为 N 的单位元。比如可以将 List.empty
转换为 0
,前者是 Monoid[List]
的单位元,而后者是 Monoid[Int]
的单位元,映射的概念 f
则表示 "列表的长度"。
如果同时还存在
g: B => A
,则称f
和g
构建了 M 与 N 之间的双向 Monoid 同态。
前文的 foldMapVParallel
函数实际上就是 Monoid 同态的一种实践。用户无需关注何时会衍生一个新的异步计算,列表将如何被分割,子结果又会如何聚合,这符合我们在之前的文章中曾提到的将 "声明与计算相分离" 的设计思想。
组合复杂 Monoid
Monoid 真正有趣的地方在于它们的组合。给定两个 Map[String, Map[String, Int]]
类型的 Map:
scala
val v1 = Map("o1" -> Map("i1" -> 1, "i2" -> 2))
val v2 = Map("o1" -> Map("i2" -> 3))
现在要求实现一个函数,根据嵌套的 key 来将上述的两个 Map 进行合并,且最终得到的结果应该是:Map("o1" -> Map("i1" -> 1, "i2" -> 5))
。首先思考 "完全基于直觉" 的实现过程应该是什么样的:先获取到 map1 和 map2 的第一层 keySet 并集的每一个 key1,再用这个 key1 获取到第二层 keySet 并集,最后再进行合并操作。
scala
type EmbeddedMap = Map[String, Map[String, Int]]
def mergeEmbeddedMapTest(map1: EmbeddedMap, map2: EmbeddedMap) : EmbeddedMap =
val keySet_1 = map1.keySet ++ map2.keySet
val acc_1 : EmbeddedMap = Map()
keySet_1.foldLeft(acc_1)((`acc01`, key_1) => `acc01`.updated(
key_1, {
val m1 = map1.getOrElse(key_1, Map())
val m2 = map2.getOrElse(key_1, Map())
val keySet_2 = m1.keySet ++ m2.keySet
val acc_2: Map[String, Int] = Map()
keySet_2.foldLeft(acc_2)((`acc02`, key_2) => `acc02`.updated(
key_2, m1.getOrElse(key_2, 0) + m2.getOrElse(key_2, 0)
))
}
))
这样可以解决当前的问题,但它并不是一个可拓展的实现。假如要用类似的逻辑合并 Map[String, Map[String, Map[String, Int]]]
,甚至是更复杂的嵌套,这下又该如何应对呢?
不难发现,笔者事实上手动地嵌套了重复的代码。为了更容易观察,这里将内嵌的代码块提取出一个 mergeEmbeddedMapTest1
方法:
scala
// 类型太长了,我们在这里用一个类型别名作替代。
type EmbeddedMap = Map[String, Map[String, Int]]
def mergeEmbeddedMapTest(map1: EmbeddedMap, map2: EmbeddedMap) : EmbeddedMap =
val keySet_1 = map1.keySet ++ map2.keySet
val acc_1 : EmbeddedMap = Map()
keySet_1.foldLeft(acc_1)((`acc01`, key_1) => `acc01`.updated(
key_1, mergeEmbeddedMapTest1(map1.getOrElse(key_1, Map()), map2.getOrElse(key_1,Map()))
))
def mergeEmbeddedMapTest1(map1: Map[String, Int], map2: Map[String, Int]) : Map[String, Int] =
val keySet_1 = map1.keySet ++ map2.keySet
val acc_1 : Map[String, Int] = Map()
keySet_1.foldLeft(acc_1)((`acc01`, key_1) => `acc01`.updated(
key_1, map1.getOrElse(key_1, 0) + map2.getOrElse(key_1, 0)
))
这样看起来就清晰一些了。mergeEmbeddedMapTest1
和 mergeEmbeddedMapTest
本质上就是一套代码模板,只不过前者操作的是两个 Map[String, Map[String, Int]]
,后者操作的是两个 Map[String, Int]
。只要将 Map 的值类型抽象为 V
,这样就可以将它们统一成 Map[String, V]
类型。
除了类型之外,这两个代码模板仅在一处存在不同,那就是 map1.getOrElse(key_1, 0) + map2.getOrElse(key_1, 0)
和 mergeEmbeddedMapTest1(map1.getOrElse(key_1, Map()), map2.getOrElse(key_1,Map()))
。要是能把这两处代码也统一起来,我们就能进一步精简代码了。
这两处调用事实上都是对 map1.get(key_1)
和 map2.get(key_1)
的二元运算,这取决于 V
的实际类型:如果拿到的是两个 Int
,则进行数值加法,默认提供的零值是 0
;如果是 Map
类型,则继续调用 mergeEmbeddedMapTest1
实现二元折叠,默认零值为 Map()
。
如何保证类型参数 V
能够提供 零值和二元运算 呢?显而易见,那就是为类型 V
构建一个 Monoid[V]
。
更进一步,如果 mergeEmbeddedMapTest
本身也是一个 Monoid 就好了,这样就可以将原问题完全转换成一个 "如何组合 Monoid" 的问题。我们基于上述的想法梳理成代码,并给出最终的实现:
scala
trait Monoid[A]:
def zero: A
def op(a1: A, a2 :A): A
def intMonoid = new Monoid[Int]{
def zero = 0
def op(a1: Int, a2: Int): Int = a1 + a2
}
def mapMonoid[K, V](v: Monoid[V]) = new Monoid[Map[K,V]]{
def zero: Map[K, V] = Map[K, V]()
def op(a1: Map[K, V], a2: Map[K, V]): Map[K, V] = (a1.keySet ++ a2.keySet).foldLeft(zero) {
(acc, k) => acc.updated(k,
v.op(a1.getOrElse(k, v.zero), a2.getOrElse(k, v.zero))
)
}
}
def mergeEmbeddedMap: Monoid[Map[String, Map[String, Int]]] = mapMonoid(mapMonoid(intMonoid))
模块化代码的好处显而易见。不仅是 Map[String, Map[String, Int]
,我们现在可以通过编写并组合 Monoid 的方式实现对任意嵌套的 Map 的折叠操作,而无需编写深度嵌套的 for 循环代码。
可证明,如果类型 A
和类型 B
都具备 Monoid 法则,那么二元组 [(A, B)]
同样满足 Monoid 法则。
scala
def productMonoid[A, B](A: Monoid[A], B: Monoid[B]) : Monoid[(A, B)] = new Monoid[(A, B)]:
override def zero: (A, B) = (A.zero, B.zero)
override def op(a1: (A, B), a2: (A, B)): (A, B) = (A.op(a1._1, a2._1), B.op(a1._2, a2._2))
可以利用这个性质在一次遍历中同时进行多个计算。比如求一个 List[Int]
的平均值,这需要计算出两个统计量:元素和以及元素个数。现在,我们可以通过组合 Monoid 的方式在一次折叠中同时计算这些信息。
scala
def foldMap[A, B](item: List[A], m: Monoid[B])(f: A => B) : B =
item.foldLeft(m.zero)((b, a) => m.op(f(a), b))
val combineOp: Monoid[(Int, Int)] = productMonoid(intAdditionMonoid, intAdditionMonoid)
val xs: List[Int] = List(1, 2, 3, 4)
val (sum, nums) = foldMap(xs, combineOp)(x => (x, 1))
val avg: Double = sum / nums
Monad 单子
Monoid 是一个通过 法则 而非 具体行为 来定义自身的纯代数式接口,后文会介绍其他类似的模式。回顾之前各种版本的组合子实践:
- 自动化测试框架:用 Scala 编写 Property-based Testing API - 掘金 (juejin.cn)
- 并行计算库:Scala 纯函数式库设计:并行编程 - 掘金 (juejin.cn)
- JSON 语法解析器:基于代数式设计构建 JSON 语法解析器 - 掘金 (juejin.cn)
在这些实践中,我们首先构建了精简的原语,然后基于这套原语衍生出更加具体且实用的组合子。然而,有些特殊的组合子总是出现在原语集当中,包括 flatMap
,map
,unit
。这并不是巧合,本章会介绍它们构成了哪些通用模式 (Functor, Monad),以及这些模式背后的约束法则。
Functor 函子:泛化 map
map
是平时开发中最常用也最熟悉的组合子。它接受一个 f: A => B
,然后将 F[A]
映射为 F[B]
。任何包含 map
操作的类型在可以被称之为 Functor。诸如 Option,List,Future 都可以属于 Functor。
scala
trait Functor[F[_]]:
def map[A, B](fa: F[A])(f: A => B) : F[B]
Functor 需要满足函子法则:
scala
map(x)(identify) == x
这个法则十分自然,不需要我们费力地理解。它约束了 map
只能对 F[A]
内部元素进行纯粹映射,但是不能变换 F[A]
本身的结构 。这个 "保持结构" 在不同语境下有不同的语义,例如,对于 List[A]
,无论如何映射它的元素,列表的长度和元素的顺序都不会改变;对于 Some[A]
,无论如何映射它的元素,它也不会转化为 None
,这个事实不会改变。
第二条法则是我们曾在 Scala 纯函数式库设计:并行编程 - 掘金 (juejin.cn) 发现的映射法则 (又称结合律),用于将多个嵌套的 map
映射融合为一次 map
操作。
scala
map(map(v)(g))(f) == map(v)(f compose g)
Monad :泛化 unit 与 flatMap
Functor 是众多抽象当中最基础的那一个,但只用 map
函数定义不出太多可用的操作。下面引申出 Monad (单子) 的概念。
暂且先认为 Monad 是一套提供了 flatMap
和 unit
这两个最小原语集的 trait。选择这两个组合子的原因是,我们在之前的实践中验证过 大部分其他组合子都可以基于这套最小原语集衍生出来,包括 map
。因此,Monad 相当于实现了 Functor:
scala
trait Monad[F[_]] extends Functor[F]:
override def map[A, B](fa: F[A])(f: A => B): F[B] = flatMap(fa)(a => unit(f(a)))
def map2[A, B, C](fa: F[A], fb: F[B])(f: (A, B) => C): F[C] =
flatMap(fa)(a => flatMap(fb)(b => unit(f(a, b))))
// flatMap 和 unit 构成了 Monad 的最小原语集(之一)。
def flatMap[A, B](fa: F[A])(f: A => F[B]) : F[B] = ???
def unit[A](a: A) : F[A] = ???
从签名上看,map
和 flatMap
最终的返回值都是 F[B]
。map
总是维持原有 F[A]
的结构,但 flatMap
不太一样,它可以用函数 f
构建一个全新结构的 F[B]
并返回。比如,一个 Some[A]
在 map
映射后只会转换成另一个 Some[B]
,但被 flatMap
映射后却可能返回一个 None
。
flatMap
的其中一个语义是 "展平" flatten,比如将一个F[F[A]]
转换为F[A]
。见后文的 flatten 法则。
Monad 的可拓展性比 Functor 强得多。在介绍 Future 章节时,我们曾提到了一对实用的 Monadic 组合子: sequence
和 traverse
。其中 Future 的 traverse
负责将 List[A]
提升为 Future[List[A]]
,而 sequence
则可以将 List[Future[A]]
翻转成 List[Future[A]]
类型。不仅如此,我们还归纳出了这两个组合子的转换关系:
scss
traverse(xs)(f) == sequence(xs map f)
当 f
为恒等映射时,结合 Functor 的 map 法则,我们进一步可以推导出:
scss
traverse(x)(identify) == sequence(xs)
现在将它们从具体的 Future 类型中抽离出来,然后定义在 Monad 内部。
scala
trait Monad[F[_]] extends Functor[F]:
//...
def sequence[A](xs: List[F[A]]) : F[List[A]] = traverse(xs)(identity)
def traverse[A, B](xs: List[A])(f: A => F[B]) : F[List[B]] =
xs.foldRight(unit(List.empty[B]))((x, acc) => map2(f(x), acc)((e, list) => e :: list))
end Monad
还有一个用于复制内部元素 N 次的 replicateM
组合子,它出现在自动测试框架和 JSON 解析器章节,比如用于创建一个值阈在 [0, 1000) 之间的长度为 N 的列表的生成器,或者是解析重复字符串如 "abcabcabc"
这种模式的语法解析器。
scala
def replicateM[A](n: Int, fa: F[A]): F[List[A]] = sequence((1 to n).toList.map(_ => fa))
可衍生出的组合子还有很多,这些只是简单的热身。我们在 Monad 中付出的创造性工作将是一劳永逸的。只需为某个具体类型 F[A]
编写一个 Monad 实例并提供最小原语集的实现,F[A]
便可 "免费地" 获取到其他衍生的组合子。
scala
case class Id[A](a: A)
val m = new Monad[Id]:
override def flatMap[A, B](fa: Id[A])(f: A => Id[B]): Id[B] = f(fa.a)
override def unit[A](a: A): Id[A] = Id(a)
// Id(6)
m.map(Id(3))(_ * 2)
// Id(7)
m.map2(Id(3), Id(4))(_ + _)
// Id(List(10, 10))
m.replicateM(2, Id(10))
// Id(List(42, 24))
m.sequence(List(Id(42), Id(24)))
Monad 法则
Monad 显然也得像 Monoid 那样遵守一些规范。我们需要如何约束 flatMap
和 unit
呢?
结合律
Monad 的最小实现包含了 flatMap
组合子,它应当满足结合律:
scala
x.flatMap(f).flatMap(g) == x.flatMap(a => f(a).flatMap(g))
这条结合律看起来并不是那么直观。如果将 flatMap
视作中缀运算符,则其左侧是 F[_]
值,右侧是 A => F[B]
函数。为了对齐操作符两侧的形式,我们现在需要发挥一点想象力,将 F[A]
看作某个匿名函数 _ => fa
的传值调用。这样,两个函数之间的结合律就更容易观察了。
scala
infix def flatMap[A, B](fa: F[A], f: A => F[B]): F[B] // fa flatMap f
我们基于 flatMap
创建出了更加泛化的组合子 compose
,它也称之为 Kleisli 箭头:
scala
def compose[A, B, C](f: A => F[B], g: B => F[C]) : A => F[C] = a => flatMap(f(a))(g)
compose
能更加直观地表达出我们熟悉的结合律形式:
scala
compose(f, compose(g, h)) == compose(compose(f, g), h)
这里借助 Kleisli 箭头的结合律推导上述 flatMap
的结合律成立。首先,使用 compose
实现 flatMap
:
scala
trait Monad[F[_]] extends Functor[F]:
def unit[A](a: A): F[A]
def compose[A, B, C](f: A => F[B], g: B => F[C]) : A => F[C]
def flatMap[A, B](fa: F[A])(f: A => F[B]): F[B] = compose(_ => fa, f)(None)
end Monad_2
传入 None
只是为了触发 compose
的计算,实际上传入 ()
, "Any"
还是 42
都没有关系,因为表达式 _ => fa
总是返回常量 fa
。下面是推理过程:
scala
// 左式:
x.flatMap(f).flatMap(g)
↔️ compose(_ => compose(_ => x, f)(None), g)(None)
↔️ compose(compose(_ => x, f), g)(None)
↔️ compose(compose(h, f), g)(None)
↔️ compose(compose(h, f), g)
// 右式:
x.flatMap(a => f(a).flatMap(g))
↔️ compose(_ => x, a => compose(_ => f(a), g)(None))(None)
↔️ compose(_ => x, compose(a => f(a), g))(None)
↔️ compose(h, compose(f, g))(None)
↔️ compose(h, compose(f, g))
一是去掉多余地嵌套,把 x => F(x)
这样的表达式直接简化为 F
;二是去掉 F(x)
的参数列表 (x)
来将值还原成函数 F
。最后,将 _ => x
表达式看作是一个整体 h
,就能够得到 Kleisli 箭头所阐述的结合律了。
这里挖掘出了 Monad 的第二个最小原语集:
compose
和unit
。
左单位元律 / 右单位元律
不难发现,最小原语集中的 unit
函数就是单位元。在几乎所有 Monad 的实现中,unit
充当着 F
的构造器。它的目的只有一个,那就是将 A
提升为 F[A]
并返回。Monad 满足左单位元律和右单位元律:
scala
f == compose(f, unit) == compose(unit, f)
函数 f
以任意次序结合 unit
都会返回自身。同样的,我们也可以使用 flatMap
表达单位元法则。
scala
flatMap(x)(unit) = x
flatMap(unit(y))(f) = f(y)
Monad 可以用一个精简而准确的定义描述:它是一个满足结合律和单位元律的最小 Monadic 组合子原语集的实现 。显然,至于这个最小原语集是 {flatMap, unit}
还是 {compose, unit}
,对于 Monad 来说其实并不重要,重要的是性质。
种种迹象表明,虽然 Monad 和 Monoid 组合的对象不一样,但它们在深层次中存在着关联。这也是它们的命名如此相像的原因。
flatten 法则
如果向 flatMap
组合子传递恒等映射,则 flatMap
会特化为展平操作 flatten
。它表示直接取出 F[F[A]]
内嵌的 F[A]
并返回。
scala
trait Monad[F[_]] extends Applicative[F]:
// ...
def flatten[A](ffa: F[F[A]]) : F[A] = flatMap(ffa)(identity)
end Monad
flatten
是一个实用的操作。比如,我们可以通过嵌套调用 i
次 flatten
将一个 N
维的 List 展平为 N - i
维:
scala
val listMonad: Monad[List] = new Monad[List]:
override def unit[A](a: A): List[A] = List[A]()
override def flatMap[A, B](fa: List[A])(f: A => List[B]): List[B] = fa.flatMap(f)
val xxs = List(List(1, 2), List(3, 4))
// List(1, 2, 3, 4)
listMonad.flatten(xxs)
val yyys = List(List(List(3)))
// List(3)
listMonad.flatten(listMonad.flatten(yyys))
flatten
的实现还可以被替换为以下形式。在不改变 F[F[A]]
结构的前提下,"对内部 F[A]
进行恒等映射" 和 "对更内部的 A
进行恒等映射" 等价。
scala
def flatten2[A](ffa: F[F[A]]) : F[A] = flatMap(ffa)(fa => map(fa)(identity))
将思路再打开一些,让我们把恒等映射替换成更一般的函数 f
:
scala
def flatMapMap[A, B](a: F[F[A]])(f: A => B): F[B] = flatMap(a)(fa => map(fa)(f))
flatMapMap
本身并不是一个通用的组合子,因为没有必要强绑定 flatMap
和 map
。重点是,通过嵌套组合 flatMap
和 map
同样实现像 flatten
那样展平的效果。
如 Haskell 这类纯函数式语言更倾向于将刚才的 flatten
函数命名为 join
,但功能不变。join
, map
, unit
可以构成 Monad 的第三个最小原语集,而 flatMap
则可以通过组合 join
和 map
实现。
scala
trait Monad[F[_]] extends Functor[F]:
def join[A](ffa: F[F[A]]): F[A]
def unit[A](a: A): F[A]
def map[A, B](fa: F[A])(f: A => B) : F[B]
def flatMap[A, B](fa: F[A])(f: A => F[B]): F[B] = join(map(fa)(f))
在该原语集中,flatMap
是这样被解释的:fa
的内部元素 A
被 f
映射之后返回一个 F[F[A]]
类型。之后,由 join
( 或者称 flatten
) 函数将重复嵌套的同一上下文 F
展平为一个,flatMap
的命名正是由此得来的。
应用 Monad
我们注意到, Monoid 修饰普通的元素类 A
,而 Monad 修饰类型构造器 F
。如果 Monoid 的意义在于提供了元素 A
的结合方式,那么经过类比后是否也能推断出 Monad 的作用呢?为了对 Monad 有一个更直观的理解,下面举两个具体的例子。
Identity Monad
之前构造了一个简单的容器类 Id
,现在来为它完善 Monad 定义。这次将 flatMap
和 map
组合子作为方法定义在其内部。
scala
case class Id[A](a : A):
def flatMap[B](f: A => Id[B]): Id[B] = f(a)
def map[B](f: A => B) : Id[B] = flatMap(a => Id.unit(f(a)))
object Id:
def unit[A](a : A) : Id[A] = Id(a) // apply
我们把两个字符串装入到 Id[A]
里,然后进行简单的测试:
scala
val id1 = Id("Hello")
val id2 = Id("World")
// Id(HelloWorld)
id1.flatMap(str1 => id2.map(str2 => str1 + str2))
在 Scala 中,如果一个类定义了 flatMap
和 map
这两个方法,那么它可以被应用 for 推导式(见:Scala +:类型推断,列表操作与 for loop - 掘金 (juejin.cn)。下面的写法更清晰一点:
scala
for{
str1 <- id1
str2 <- id2
} yield {
str1 + str2
}
看起来我们好像从 id1
和 id2
两个上下文中提取出了元素值,然后在 yield
语法块中操作它们。当然,我们完全可以不使用 Id
然后做相同的事情:
scala
val str1 = "Hello"
val str2 = "World"
str1 + str2
Monad 事实上是将 str1 + str2
这个计算放到了一个语义更加丰富的上下文组合中执行。这还只是个简单的演示,毕竟 Id[A]
内部除了被包装的 A
之外什么都没有。下面看一个复杂的例子:State Monad。
State Monad
回顾在 Scala:在纯函数中优雅地转移状态 - 掘金 (juejin.cn) 中的实践:使用 (S, A) => S
函数来保存并转移状态,S
表示状态类型,A
代表计算值类型。相关的 State 类型定义如下:
scala
case class State[S, +A](run: S => (A, S)):
def map[B](from: A => B): State[S, B] = flatMap { a => unit(from(a)) }
def map2[B, C](otherState: State[S, B])(zip: (A, B) => C): State[S, C] =
flatMap { a => { otherState.map { b => zip(a, b) }}}
def flatMap[B](f: A => State[S, B]): State[S, B] = State { s => val (a, nxt) = run(s);f(a).run(nxt) }
object State:
def unit[S, A](a: A): State[S, A] = State { s => (a, s) }
def sequence[S, A](ss: List[State[S, A]]): State[S, List[A]] =
ss.foldRight(unit[S, List[A]](List[A]()))((statement,acc) => { statement.map2(acc)( _ :: _) })
def getState[S]: State[S, S] = State{s => (s, s)}
def setState[S](new_state: => S): State[S, Unit] = State {_ => ((), new_state)}
我们在当时已经给出了所有必要的实现:map
,map2
,flatMap
,unit
,所以 State 类型自身就是一个 Monad。这里通过类型 Lambda 指派 Int
作为状态类型,代码如下。
scala
type IntState[V] = State[Int, V]
// 表达式不支持 v 传名调用, 这里使用 def 定义一个局部函数。
def stepAutoIncrement[V](v: => V) : IntState[V] = State {s => (v, s + 1)}
import State.{getState, setState, unit}
val process = for {
_ : Unit <- setState(1)
operand1: String <- stepAutoIncrement("hello") // 2
x : Int <- getState
operand2: String <- stepAutoIncrement("world") // 3
N : Int <- stepAutoIncrement(3)
M : Int <- stepAutoIncrement(N * 2)
y : Int <- getState
_ : Unit <- setState(10)
z : Int <- getState
} yield {
println(x)
println(y)
println(z)
(operand1 + operand2).repeat(M)
}
val (result, s) = process.run(0)
// state = 10, result = helloworldhelloworld
println(s"state = ${s}, result = ${result}")
当前的 for 推导式,或者说一连串的 flatMap
调用链更像是一段命令式程序。其中,每一行命令都写作 x <- expr
,表示将 expr
的计算结果提取给 x
,不断往复,并在最后执行 yield
语句块作为收尾。
比如,从 stepAutoIncrement(3)
提取出一个 N
,然后紧接着传递给 stepAutoIncrement(N * 2)
,它的返回值又传递给 getState
... 以此类推。底层的调用链是这样表示的:stepAutoIncrement(3).flatMap(N => stepAutoIncrement(N * 2).flatMap(...))...
。显然,后面的命令可以捕获并使用之前命令的提取值。
我们先是初始状态量 0
并启动计算,随后状态量在命令链中以副作用形式传递,且在任何一个环节都可以调用 getState
获取当前状态的快照。
Monad 意味着什么
Monad 组合上下文。可以在前文中提取有用的信息供后文使用,正如 State Monad 的例子,flatMap
可以将一连串 上下文相关的调用 链接起来,然后表达成精简的 for 推导式。由 flatMap
组合成的调用链是边生成结果边解释执行的。
因此在使用
flatMap
组合异步计算 Future 时,要将 Future 声明在 for 表达式之前,而不是在 for 推导式内,否则程序就会串行地创建异步计算并依次执行,这显然是没有意义的。这一点在之前的文章:基于组合 Future 的并行任务流 - 掘金 (juejin.cn) 提到过。
Monad 仅仅指定了要发生的事情需要满足结合律和单位元律法则,它没有限制 for 表达式的每一行命令要发生什么,甚至是执行的次数。我们创建一个关于 IndexedSeq 的 Monad,观察有何不同。
scala
val xxs = Array(Array(1, 2, 3, 4, 5), Array(6, 7, 8, 9, 10))
val flattened_seq = for {
xs <- xxs
x <- xs
} yield x
// List(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
println(flattened_seq.toList)
在上述的 for 推导式中,每一条语句会返回多个结果,因为 xxs
及其提取出的 xs
都包含多个元素。这会导致 yield
代码块被执行多次,取决于 xxs
完全展平后的元素数量。
现在应该对 Monad 的用途有更清晰的理解了。除了 Monad 之外,我们之前提到的 Foldable,还有之后的 Applicative,Traversable 都在做类似的事情。下面来看看其他的 Functor 拓展。
Applicative 可应用函子
在 Monad 章节,我们用 flatMap
和 unit
作为原语集构造出了更多的 monadic 组合子,比如:
scala
def sequence[A](xs: List[F[A]]) : F[List[A]] = traverse(xs)(identity)
def traverse[A, B](xs: List[A])(f: A => F[B]) : F[List[B]] =
xs.foldRight(unit(List.empty[B]))((x, acc) => map2(f(x), acc)((e, list) => e :: list))
traverse
是通过 map2
和 unit
来实现的,它没有直接调用 flatMap
。考虑另一种抽象,使用 map2
和 unit
这两个组合子作为最小原语集,这个抽象称之为 Applicative(可应用函子)。它的形式如下:
scala
trait Applicative[F[_]] extends Functor[F]:
override def map[A, B](fa: F[A])(f: A => B): F[B] = map2(unit(None), fa)((_, a) => f(a))
// 最小原语集
def map2[A, B, C](fa: F[A], fb: F[B])(f: (A, B) => C) : F[C]
def unit[A](a: A) : F[A]
def sequence[A](xs: List[F[A]]): F[List[A]] = traverse(xs)(identity)
def traverse[A, B](xs: List[A])(f: A => F[B]): F[List[B]] =
xs.foldRight(unit(List.empty[B]))((x, acc) => map2(f(x), acc)((e, list) => e :: list))
// derived
def replicateM[A](n: Int, fa: F[A]) : F[List[A]] = sequence((1 to n).toList.map(_ => fa))
def product[A, B](fa: F[A], fb: F[B]) : F[(A, B)] = map2(fa, fb)((_, _))
end Applicative
所有的 Applicative 都是 Functor ,因为 map
可以被 map2
和 unit
这两个原语级别的组合子来实现。实际上,Applicative 这个名词源于这样的事实,它使用 apply
组合子和 unit
构成最小原语集,而非 map2
。不过,apply
和 map2
是可以相互实现的:
scala
// 如果选择 map2 和 unit 作为最小原语集
// 则 apply 可以通过以下方式来实现:
def apply[A, B](fab: F[A => B])(fa: F[A]) : F[B] = map2(fab, fa)(_(_))
如果选择 apply
作为原语,则 map2
以及 map
可以如此实现:
scala
// 假设现在 apply 和 unit 是最小原语集
override def map[A, B](fa: F[A])(f: A => B): F[B] = apply(unit(f))(fa)
def map2[A, B, C](fa: F[A], fb: F[B])(f: (A, B) => C) : F[C] =
apply(apply(unit(f.curried))(fa))(fb)
map2
通过柯里化将 (A, B) => C
转化成 A => B => C
(等价于 A => (B => C)
),然后通过两次 apply
调用分别确定了输入 fa
和 fb
,最终得到 F[C]
。
Monad vs Applicative
所有的 Monad 都是 Applicative ,因为我们之前已经使用 flatMap
实现过 map2
了。但反过来,Applicative 可以只使用 map2
(或者 apply
) 和 unit
作为原语集,然后去实现 flatMap
方法吗?
scala
def map2[A, B, C](fa: F[A], fb: F[B])(f: (A, B) => C) : F[C]
def unit[A](a: A) : F[A]
def flatMap[A, B](fa: F[A])(f: A => F[B]): F[B]
答案是否定的。如果 flatMap
不作为原语,Applicative 就得借助其他组合子将 A
从 F[A]
中提取出来。显然 unit
和 map2
都不行,它们总是返回一个 F[_]
,因此只能将 flatMap
的拆箱逻辑交给 F[_]
自身给出原语级别的实现。这样来看,Monad 的表达力似乎要更强,那具体是如何体现的呢?这里构建 Option Applicative 和 Option Monad 作为对比:
scala
val App = new Applicative[Option]:
override def map2[A, B, C](fa: Option[A], fb: Option[B])(f: (A, B) => C): Option[C] = Some(f(fa.get, fb.get))
override def unit[A](a: A): Option[A] = Some(a)
val M = new Monad[Option]:
override def flatMap[A, B](fa: Option[A])(f: A => Option[B]): Option[B] = fa.flatMap(f(_))
override def unit[A](a: A): Option[A] = Some(a)
首先是一个简单的需求。有两张 Map 表 depts
和 salaries
,现给定 name
信息,要求同时从这两个表中查询某一个人的 depts
以及 salaries
信息。
scala
val depts = Map[String, String]("ZhangSan"-> "W1", "LiSi" -> "W2")
val salaries = Map[String, String]("ZhangSan" -> "10,000", "LiSi" -> "15,000")
val name = "Someone"
val r = App.map2(
depts.get(name).orElse(Some("None")), salaries.get(name).orElse(Some("NaN"))){
(dept, salary) => s"name: ${name}, dept: $dept, salary: $salary"
}
println(r)
当前对 depts
和 salaries
的两个查找是独立的,使用 map2
可以将这两个查询组合起来。下面是另一个例子:首先在 nameById
当中查询出 id
对应的 name
,然后再根据 name
查询出对应的 salary
信息。
scala
val id = "1"
val nameById = Map[String, String]("1" -> "ZhangSan", "2" -> "LiSi")
val salaries = Map[String, String]("ZhangSan"->"10,100", "LiSi" -> "15,000")
val r =M.flatMap(nameById.get(id).orElse(Some("None")))(`name` => {
val salary = salaries.get(`name`).orElse(Some("NaN")).get
Some(s"name: ${`name`}, salary: $salary")
})
我们会发现无法使用 map2
实现这样的关联查询。map2
需要两个已知的Option
作为输入,但是现在我们需要先找到 id
对应的 name
,然后才能再进一步搜索对应的 salary
信息。这个例子说明:Applicative 只能用于上下文无关的计算,而 Monad 允许进行上下文相关的计算。
什么时候需要 Applicative?
既然 Monad 的功能更加强大,那么处处构建 Monad 不好吗?在哪些场合下只需要用到 Applicative 呢?
再回顾一下之前的语法解析器章节。现在有两个语法解析器组合子 p1
和 p2
,如果 p1 flatMap p2
,那么 p2
只会在 p1
解析成功之后才执行,否则直接返回解析失败,这样可以短路掉后续的无效尝试。
但有时,系统也会允许一部分调用部分失败,只使用成功的调用来返回结果,浏览器渲染 Web 页面就是这样的逻辑。一个 HTML body 内部通常包含多个静态资源,但一小部分资源加载失败(比如某个图片挂掉)不应导致整个页面加载失败。
在这里举一个更简单的例子:表单验证。程序需要负责检验用户提交的表单中,user 和 age 这两项是否为合法的值。
参考之前的文章 Scala:函数式编程下的异常处理 - 掘金 (juejin.cn) ,我们在此使用 Either[+E, +R]
包装解析成功或者失败这两个结果。首先为 Either 编写一个 Monad,将其中的 E
指派为 String
类型,以文本形式反馈错误。另一方面,我们构造了 validUser
和 validAge
两个检查器,每个检查器仅负责检查一项内容(满足最小职责原则)。
scala
// Scala 2 的类型 Lambda 写法:
// object StringEitherMonad extends Monad[({type either[V] = Either[String, V]})#either]:
object EitherMonad extends Monad[[V] =>> Either[String, V]]:
override def flatMap[A, B](fa: Either[String, A])(f: A => Either[String, B]): Either[String, B] = fa.flatMap(f)
override def unit[A](a: A): Either[String, A] = Right(a)
def validUser (user: String) : Either[String, String] = user match
case "admin" | "user" => Right(user)
case _ => Left("Not a valid user")
def validAge(age : Int) : Either[String, Int] = age match
case x if x < 18 || x > 128 => Left("Not a valid age")
case _ => Right(age)
val m = EitherMonad // for short
使用 flatMap
原语会将它们组合为一个链式检查器:一旦在某个环节检查出错误,那么检查器就会立刻停止并反馈报告。
scala
val result: Either[String, String] = m.flatMap(validAge(19)){
age => m.flatMap(validUser("root")) {
user => m.unit(s"user = ${user}, age = ${age}")
}
}
result match
case Right(info) => println(info)
case Left(err)=> println(err)
这个检查逻辑对用户不那么友好。比如用户填写的表单存在多处错误,链式检查器会反复将表单打回给用户修改,直到完全正确为止。更好的交互方式是,检查器一次性返回当前检查到的所有错误,等用户全部修改完之后再一并提交。直接使用 EitherMonad
的 map2
函数做替代可以吗?在这里 不行 。因为 EitherMonad
中的 map2
底层仍然是 flatMap
组合子。
看来我们得另外构建 map2
的原语了,参考 Either 并声明一个代数结构 Validation[+E, +I]
。如果整个表单没有问题,那么就返回 OK
,否则返回 Err
,同时以 List 形式累积错误信息。为了避免让用户通过 Err(List(".."))
这样别扭的写法声明一个错误,这里借助 String => List[String]
的隐式转换实现自动装箱。
scala
// 我们希望自动为用户处理对错误信息的包装过程。
given Conversion[String, List[String]] = (_: String) :: Nil
// 使用 Scala 3 的枚举类, 或者 Scala 2 中基于 trait 来表示 ADT 类型均可。
//trait Validation[+E, +I]
//case class OK[I](info: I) extends Validation[Nothing, I]
//case class Err[E](err: E, tail: Vector[E] = Vector.empty) extends Validation[E, Nothing]
enum Validation[+E, +I]: // (E)rror & (I)nfo.
case OK[I1](info : I1) extends Validation[Nothing, I1]
case Err[E1](frame: List[E1]) extends Validation[E1, Nothing]
end Validation
object ValidationApplicative extends Applicative[[I] =>> Validation[String, I]]:
import Validation.{OK, Err}
override def map2[A, B, C](fa: Validation[String, A], fb: Validation[String, B])(f: (A, B) => C): Validation[String, C] = {
(fa, fb) match {
case (OK(info1), OK(info2)) => unit(f(info1, info2))
case (OK(_), e @ Err(_)) => e // 如果 fb 为 Err 类型则绑定给 e
case (e @ Err(_), OK(_)) => e
case (Err(f1), Err(f2)) => Err(f1 ::: f2)
}
}
override def unit[A](a: A): Validation[String, A] = OK(a)
end ValidationApplicative
使用 ValidationApplicative
重新实现表单验证功能。现在,假如部分验证不通过,那么基于 map2
的表单解析器将一次性返回所有错误信息。
scala
import Validation.{OK, Err}
def validUser(user: String): Validation[String, String] = user match
case "admin" | "user" => OK(user)
case _ => Err("Not a valid user")
def validAge(age: Int): Validation[String, Int] = age match
case x if x < 18 || x > 128 => Err("Not a valid age")
case _ => OK(age)
val app = ValidationApplicative
val result: Validation[String, String] = app.map2(
validUser("anonymous"),
validAge(-1),
)((x, y) => s"ok, user = $x, age = $y")
result match
case Validation.OK(info) => println(info)
case Validation.Err(stack) => println(s"$stack")
并行性
由 Monad 的 flatMap
组合子链接起来的计算只能 串行执行,因为每一步都依赖前一步动态生成的结果。Applicative 要比 Monad 更 "弱" 一些,但也更加灵活。在表单验证的例子中,我们可以轻易地将多个表单验证器提交到到多线程环境中执行,因为每个验证器负责的内容都是相互独立的。
组合性
Applicative 之间总是可以组合的。假设 F
和 G
都是 Applicative,那么 F[G[_]]
也是 Applicative。
scala
def composeApplicative[F[_], G[_]](F: Applicative[F], G: Applicative[G]):
Applicative[[X] =>> F[G[X]]] = new Applicative[[X] =>> F[G[X]]]:
override def map2[A, B, C](fga: F[G[A]], fgb: F[G[B]])(f: (A, B) => C): F[G[C]] =
F.map2(fga, fgb) {G.map2(_, _)(f)}
override def unit[A](a: A): F[G[A]] = F.unit(G.unit(a))
那 Monad 之间也可以任意地组合吗?思考一下,如果将 F[G[A]]
内的元素 A
映射为 F[G[B]]
会得到什么中间类型,以及如何对它进行变换才能返回 F[G[B]]
类型。我们会在文末继续讨论这个问题。
scala
def composeMonad[F[_], G[_]](F: Monad[F], G: Monad[G]): Monad[[X] =>> F[G[X]]] =
new Monad[[X] =>> F[G[X]]]:
override def unit[A](a: A): F[G[A]] = F.unit(G.unit(a))
override def flatMap[A, B](fga: F[G[A]])(f: A => F[G[B]]): F[G[B]] = ???
Applicative 法则
下面介绍和 Applicative 有关的法则:
左单位元律和右单位元律
已知 Applicative 继承 Functor,这里先从 Functor 的函子法则出发:
scss
map(v)(identify) == v
map(map(v)(g))(f) == map(v)(f compose g)
函子法则隐含了 Applicative 的其他法则。想想在 Applicative 中 map
组合子是如何实现的:
scala
override def map[A, B](fa: F[A])(f: A => B): F[B] = map2(unit(None), fa)((_, a) => f(a))
一个自然的想法是,即使将 unit(None)
放到 map2
的右侧,map2
的语义应仍然保持不变:
scala
override def map[A, B](fa: F[A])(f: A => B): F[B] = map2(fa, unit(None))((a, _) => f(a))
上式中的 None
是多余的,我们使用 _
作为代替。整理可得 Applicative 的左单位元律和右单位元律:
scss
map2(unit(_), fa)((_, a) => a) == fa
map2(fa, unit(_))(a, _) => a) == fa
结合律
考虑基于 map2
和 unit
衍生出一个 map3
组合子:
scala
def map3[A, B, C, D](fa: F[A], fb: F[B], fc: F[C])(f: (A, B, C) => D) : F[D]
= map2(map2(map(fa)(f.curried), fb)(_(_)), fc)(_(_))
可以先将 fa
和 fb
绑定,最后再和 fc
绑定;也可以先选择将 fb
和 fc
绑定,然后再和 fa
绑定。我们会自然地假设这两种结合方式等价,而这是靠 Applicative 的结合律来保证的。
直接使用 map2
组合子表达 Applicative 结合律也不是很清晰,这里替换为 product
组合子的实现。assoc
函数假设右侧嵌入的元组总是可以等价转化成左侧嵌入的元组:
scala
def product[A, B](fa: F[A], fb: F[B]) : F[(A, B)] = map2(fa, fb)((_, _))
def assoc[A, B, C](p: (A, (B, C))) : ((A, B), C) = p match {case (a, (b, c)) => ((a, b), c)}
这样,Applicative 的结合律表达起来就和 Monad 或者 Monoid 差不多了:
scala
product(product(fa, fb), fc) == product(fa, product(fb, fc))(assoc)
product
和 unit
也可以构成 Applicative 的最小原语集。就像前文的 flatMap
和 compose
,map2
和 apply
那样,product
和 map2
也可以相互实现,它们构成的最小原语集在深层次上仍然是等价的。
Naturality 法则
Naturality 法则和前文提到的 Monoid 同态有些许相似之处,它们都强调一点:无论是将映射操作放到结合之前或者之后进行,原先 Applicative 或者 Monoid 的结构总是不会发生变化。我们举例说明:
scala
case class Employee(name: String, id: Int)
case class Pay(rate: Double, hoursPerYear: Double)
object OptionApplicative extends Applicative[Option]:
override def map2[A, B, C](fa: Option[A], fb: Option[B])(f: (A, B) => C): Option[C] =
(fa, fb) match {
case (Some(v1), Some(v2)) => Some(f(v1, v2))
case (None, None) => None
case (None, _) => None
case (_, None) => None
}
override def unit[A](a: A): Option[A] = Some(a)
val optionApp = OptionApplicative
def format(e: Option[Employee], pay: Option[Pay]): Option[String] =
optionApp.map2(e, pay) {(`e`, `pay`) => s"${`e`.name} makes ${`pay`.rate * `pay`.hoursPerYear}"}
val o = format(Some(Employee("Wang Fang", 100)), Some(Pay(150.0, 2500)))
format
方法首先从 Employee
和 Pay
当中提取出字符串信息,然后再输出格式化后的内容模板。可以把这个提取信息的过程放到外部,这样 format
自身就不需要关注 Employee
类和 Pay
类的内部细节了:
scala
def format(s1: Option[String], s2: Option[String]) : Option[String] =
optionApp.map2(s1, s2) {(`s1`, `s2`) => s"${`s1`} makes ${`s2`}"}
def f(employee: Employee): String = employee.name
def g(pay: Pay): String = (pay.rate * pay.hoursPerYear).toString
val o = format(
Some(Employee("Wang Fang", 100)).map(f),
Some(Pay(150.0, 2500)).map(g)
)
println(o.get)
很显然,把转化 Employee
和 Pay
的过程放到 map2
执行之前和之后并不会影响到最终的结果,这就是 Naturality 法则要阐述的内容。它的正式表达如下:
scss
map2(a, b)(productF(f, g)) == product(map(a)(f), map(b)(g))
其中,productF
将两个函数联合为一个函数,并返回这些函数调用的返回值构成的元组:
scala
def productF[I1, O1, I2, O2](f: I1 => O1, g: I2 => O2) : (I1, I2) => (O1, O2) =
(i1, i2) => (f(i1), g(i2))
Traversable 可遍历函子
注,Traversable 的名字在 Scala 库中的某个 trait 撞名了。我们之后提及的 Traversable 均指代可遍历函子。
再次审视 Applicative 当中 sequence
和 traverse
这两个组合子:
scala
trait Applicative[F[_]] extends Functor[F]:
// ...
def traverse[A, B](xs: List[A])(f: A => F[B]): F[List[B]] =
xs.foldRight(unit(List.empty[B]))((x, acc) => map2(f(x), acc)((e, list) => e :: list))
def sequence[A](xs: List[F[A]]): F[List[A]] = traverse(xs)(identity)
end Applicative
一定要把 A
放到 List 容器遍历吗?我们当然有很多的选择。放到 Array 也可以,甚至 Tree 也没问题。容器的形式并不重要,我们可以将它抽象成一个 G
。现在把 traverse
和 sequence
单独提取出来,以 G
为视角构建一个 Traversable[G]
。
scala
trait Traverse[G[_]]:
def traverse[F[_] : Applicative, A, B](ga: G[A])(f: A => F[B]) : F[G[B]]
def sequence[F[_] : Applicative, A](gfa: G[F[A]]) : F[G[A]] = traverse(gfa)(identity)
可以称 G
是个可 "遍历" 的容器 ( G is traversable ),但得做一些补充。对于像 List
,Map
,Tree
这样的集合类型,"遍历" 是一个直观的概念,因为我们可以逐个访问集合中的每个元素。然而,对于像 Option
,Future
这样的容器类型,"遍历" 可能听起来有些奇怪,因为它们只包含零个或一个元素,此时称 "访问" 应该要更加恰当。笔者要强调的是,traverse 行为在不同的容器中的语义可能是不同的。
可以参考泛化之前的例子推测 traverse
大概做了什么。如果 G
是某种集合容器,则 traverse
为每个元素提供新的上下文 F
,然后将这些 "小" F
合并成一个 "大" F
。显然,合并的逻辑是 Applicative[F]
提供的 map2
方法,因此 traverse
为 F
引入了一个上下文界定。特别地,如果 G
只修饰一个元素,那么可以将 traverse
理解为仅仅是将 G[A]
放到 F
容器内部。
sequence
则是 traverse
的一种特殊情况,但功能通俗易懂。从函数签名上分析,它只是将 G[F[A]]
翻转为 F[G[A]]
。翻转是一个实用操作,在不同场景下有不同的语义。比如:
- 给定
List[Option[Int]] => Option[List[Int]]
,用于检测外部输入的列表 List 是否存在至少一个 None 值。如果是,则 f 直接返回 None,否则,返回一个由 Some 包裹的列表。 - 给定
List[Future[Int]] => Future[List[Int]]
,用于将外部的一系列异步计算整合为返回一组值的一个异步计算。
Traversable 总是维持容器原有的结构 。比如,无论是遍历 List(1, 2)
并生成 Option(List(1, 2))
,还是将 Option(List(1, 2))
翻转成 List(Some(1), Some(2))
,List 内部总是只有两个元素。
Traversable 是组合上下文的另一种形式。Monad 将多个 F
串接然后 "展平" 成一个,这主要是依赖 flatMap
组合子来实现的。而Traversable 组合两个不同的上下文 G
和 F
,并返回一个嵌入的 G[F[_]]
,或者是翻转后得到 F[G[_]]
,但数据原始的结构不会改变。
Traversable 是对 Functor 的拓展
将某个类 A
包装为新的类型 ID[A]
,ID
命名自 "identity",表明该类型构造器总是指向 A
自身。
scala
// ID: Identify
type ID[A] = A
这种手法有助于我们发现一些新的模式,或者是将一些看似独立的概念给联系起来。比如, 所有的 Traversable 都是 Functor ,可以通过引入 ID
证明 traverse
能够实现 map
组合子。
scala
given idMonad: Monad[ID] with
override def unit[A](a: A): ID[A] = a
override def flatMap[A, B](fa: ID[A])(f: A => ID[B]): ID[B] = f(fa)
// Traversable 的名字被占用了。
trait Traverse[G[_]] extends Functor[G]:
def traverse[F[_] : Applicative, A, B](ga: G[A])(f: A => F[B]) : G[F[B]]
def sequence[F[_] : Applicative, A](gfa: G[F[A]]) : F[G[A]] = traverse(gfa)(identity)
// 传入 Monad 没有问题,因为 Monad 继承于 Applicative。
override def map[A, B](ga: G[A])(f: A => B): G[B] = traverse[ID, A, B](ga)(f)(using idMonad)
Traversable & Foldable, Monoid & Applicative
还可以继续挖掘。在一个深的层次,Traversable 可以被认为是泛化的 Foldable 。下面就来验证使用 traverse
实现 foldMap
是可行的,并且在此过程中,我们还会有其他的意外收获。
首先构建一个类型别名 ConstInt[_] = Int
,这个 "常量" 类型总会指向 Int
,我们的目的是令 ConstInt
充当类型构造器 F
。其对应的 traverse
函数可以被简化为如下:
scala
// 使用 ConstInt[_] 代替 F 类型,而 ConstInt[_] 又是 Int 的类型别名。
// def traverse[A, B](fa: G[A])(f: A => ConstInt[B]) : ConstInt[G[B]]
def traverse[A, B](ga: G[A])(f: A => Int): Int
这样看起来就很像 Foldable 的 foldMap
了。只需要指定 G
为 List,则该签名表示将 List[A]
里的每一个元素映射为 Int
数值,然后进行累计求和返回,同时默认使用 List.empty
作为零值。我们只需要传入一个 Monoid[Int]
,而实现它是很容易的。
但是,原始 traverse
的函数签名却要求我们构建一个 Applicative[ConstInt]
,而非 Monoid[Int]
。看来解决问题的关键是将 Applicative[ConstInt]
和 Monoid[Int]
这两个原本独立的概念给统一起来。从一个高级的层次上讲,Monoid 和 Applicative 事实上有很多相似之处:
- Monoid 要求提供
A
类型的单位元zero
,Applicative 则通过unit
将A
提升为F[A]
。 - Monoid 的
op
函数定义了两个A
如何结合,Applicative 的map2
函数则规定了两个F
如何结合。
两者的根本区别是:Monoid 修饰元素 A
,而 Applicative 修饰 F[A]
。要是有一种类型能够调和 A
与 F[A]
就好了,想想我们之前提到的 ID[A]
是怎么做的。受此启发,进一步将 ConstInt
泛化为指向任何 V
的常量类型,即Const[V, _] = V
。可以借助 Const
类型编写一个 Monoid 到 Applicative 的转换器,见下文 monoidApplicative
的实现:
scala
type Const[V, _] = V
given monoidApplicative[V]: Conversion[Monoid[V], Applicative[[_] =>> Const[V, _]]] = (monoid: Monoid[V]) =>
new Applicative[[_] =>> Const[V, _]]:
override def unit[A](a: A): Const[V, _] = monoid.zero
override def map2[A, B, C](ga: Const[V, _], ga: Const[V, _])(f: (A, B) => C): Const[V, _] = monoid.op(ga, ga)
在当前 map2
函数中,映射 f
并没有被调用,因为返回值类型 Const[V, _]
与 C
无关,fa
和 fb
只需要通过 Monoid 的提供的二元操作进行结合。下面是不依赖于 Const[V, _]
类型的更加精简的写法:
scala
given monoidApplicative[V]: Conversion[Monoid[V], Applicative[[_] =>> V]] = (monoid: Monoid[V]) =>
new Applicative[[_] =>> V]:
override def unit[A](a: A): V = monoid.zero
override def map2[A, B, C](va: V, vb: V)(f: (A, B) => C): V = monoid.op(va, vb)
现在 Traversable 顺利地继承了 Foldable 和 Functor,不仅如此,我们还发现了 Applicative 和 Monoid 的联系,并且用具体的代码语言描述了它。
scala
trait Traverse[G[_]] extends Functor[G] with Foldable[G]:
//...
def traverse[F[_] : Applicative, A, B](ga: G[A])(f: A => F[B]) : F[G[B]]
override def foldMap[A, B](ga: G[A])(f: A => B)(monoid: Monoid[B]): B =
traverse[[_] =>> B, A, Nothing](ga)(f)(monoidApplicative(monoid))
为什么 Functor 不是 Foldable ?
能否将 Foldable 也纳入 Functor 里呢?这样 Traversable 就只需要继承 Foldable 了。这种猜想是合理的,比如可以尝试用 List 的 foldRight
来实现 map
:
scala
def mapWithFold[A, B](la: List[A])(f: A => B): List[B] = {
la.foldRight(List.empty[B])((item, acc) => f(item) :: acc)
}
大体的逻辑是,令 List[A]
遍历自身并对每一个元素应用函数 f
,然后将它们重新收集到另一个 List[B]
中。所有其他容器是否也遵循类似的模式呢?并不 。问题出在:不是所有的容器 G[A]
都可以通过一步映射 A => B
变换为 F[B]
。
举例说明。首先创建一个 StreamGen[A]
,它是一次性的有限流生成器,元素类型为 A
。需要给定初始值 a
,变换函数 g
和生成个数 n
,这些参数在初始化后就不可改变。同时,分别为这个 StreamGen[A]
定义一个 Foldable 和一个 Functor:
scala
case class StreamGen[A](a: A, g: A => A, n: Int):
@tailrec
private def gen(a: A, g: A => A, n:Int)(acc: List[A] = List.empty) : List[A] =
if n == 0 then acc else gen(g(a), g, n - 1)(acc :+ a)
lazy val seq: List[A] = gen(a, g, n)()
object StreamGenFoldable extends Foldable[StreamGen]:
override def fold[A](sa: StreamGen[A])(zero: A)(f: (A, A) => A): A = sa.seq.fold(zero)(f)
object StreamGenFunctor extends Functor[StreamGen]:
override def map[A, B](sa: StreamGen[A])(f: A => B): StreamGen[B] = ???
StreamGenFoldable
的功能很明了,也很容易测试。比如现在要创建一个长度为 10 的等差数列,然后调用 fold
方法对数列内的元素求总和,它可以被表述为:StreamGenTraverse.fold(StreamGen(0, _ + 1, 10))(0)(_ + _)
。
但是 StreamGenFunctor
的 map
组合子要怎么实现呢?需要强调的一点是,Functor[StreamGen]
要映射的不是 StreamGen[A]
内部的列表,而是 StreamGen[A]
这个结构本身。函数 f
并不能映射 g
和 n
,因而无法构造出新的 StreamGen[B]
,除非在外部给出定制化代码来解决这个问题。由此可见,并非意味着所有的 Foldable 都可以提供有意义的 map
组合子。
实现带状态的遍历
State Monad 是非常强大的。将它引入到 Traversable 定义的内部,可以实现遍历的同时保持内部的状态,见 traverseState
函数:
scala
given stateMonad[S]: Monad[[V] =>> State[S, V]] with
override def unit[A](a: A): State[S, A] = State.unit(a)
override def flatMap[A, B](fa: State[S, A])(f: A => State[S, B]): State[S, B] = fa.flatMap(f)
trait Traverse[G[_]] extends Functor[G] with Foldable[G]:
//...
def traverse[F[_] : Applicative, A, B](ga: G[A])(f: A => F[B]) : F[G[B]]
def traverseState[S, A, B](ga: G[A])(f: A => State[S, B]) : State[S, G[B]]
= traverse[[X] =>> State[S, X], A, B](ga)(f)(using stateMonad)
第一个例子是 zipWithIndex
,它为 G[A]
内部的元素添加下标索引,从 0
开始。
scala
import State.*
def zipWithIndex[A](ga: G[A]): G[(A, Int)] =
traverseState[Int, A, (A, Int)](ga) {(a: A) => for{
i <- getState[Int]
_ <- setState(i + 1)
} yield (a, i)}.run(0)._1
传入的 for 推导式可能会被一次或多次,主要取决于 G[A]
的具体形式。可以将这段代码理解为递归,for 推导式会隐式地将状态传递给下一个 State,直到 G[A]
被遍历完毕。
第二个例子是 toList
,它将任何可遍历的容器 G[A]
转换为列表。和 zipWithIndex
不同的是,toList
更强调收集副作用,因此下面的 for 推导式不需要返回有意义的值。
scala
def toList[A](ga: G[A]) : List[A] =
ga match
case _: List[_] => ga.asInstanceOf[List[A]]
case _ => traverseState[List[A], A, Unit](ga){(a: A) => for{
list <- getState[List[A]]
_ <- setState(list :+ a)
}yield()}.run(Nil)._2
显然,zipWithIndex
和 toList
共享大部分代码模板,而区别只在于返回什么样的值,以及如何转移状态。它们可以被一个 (A, S) => (B, S)
的映射函数 f
来表示。我们将剩下共同的模式提取出来,并构建 mapAccum
函数。mapAccum
只需要返回计算完毕后的二元组,由外部调用来决定是使用返回值,还是累积的状态。
scala
def mapAccum[S, A, B](ga: G[A], s: S)(f: (A, S) => (B, S)) : (G[B], S) =
traverseState[S, A, B](ga)((a : A) => for{
s1 <- getState[S];
(b, s2) = f(a, s1)
_ <- setState[S](s2)
} yield b).run(s)
可以继续使用 mapAccum
实现更通用版本的 zip
,它将两个 G[A]
和 G[B]
融合为一个 G[(A, B)]
。
scala
def zip[A, B](ga: G[A], fb: G[B]): G[(A, B)] =
mapAccum(ga, toList(fb)){
case (_, Nil) => sys.error("fa: F[A] and fb: F[B] have different shapes")
case (a, b :: tails) => ((a, b), tails)
}._1
基于 mapAccum
的行为去理解 zip
函数是很容易的。先是尝试将 gb
展平,然后不断地从中取出头元素和 G[A]
的 每一个 a
结合。但需要注意的是,当前版本的 zip
不兼容 G[A]
与 G[B]
结构不同的情形。我们可以分别实现 zipL
和 zipR
函数,由用户决定是维持左侧 G[A]
的结构,还是维持右侧 G[B]
的结构。
scala
def zipL[A, B](ga: G[A], fb: G[B]) : G[(A, Option[B])] =
mapAccum(ga, toList(fb)){
case (a, Nil) => ((a, None), Nil)
case (a, b :: tails) => ((a, Some(b)), tails)
}._1
def zipR[A, B](ga: G[A], fb: G[B]): G[(Option[A], B)] =
mapAccum(fb, toList(ga)){
case (b, Nil) => ((None, b), Nil)
case (b, a :: tails) => ((Some(a), b), tails)
}._1
组合 Traversable
刚才的 zip
展示了如何用 Traversable 组合两个 Applicative。不仅如此,Traversable 之间也是可以相互组合的。
scala
trait Traverse[G[_]] extends Functor[G] with Foldable[G]:
thisF =>
// ....
// def traverse[G[_] : Applicative, A, B](fa: F[A])(f: A => G[B]) : G[F[B]]
def compose[T[_]: Traverse]: Traverse[[X] =>> G[T[X]]] = new Traverse[[X] =>> G[T[X]]]:
override def traverse[F[_] : Applicative, A, B](gta: G[T[A]])(f: A => F[B]): F[G[T[B]]] =
val F: Applicative[F] = summon[Applicative[F]]
val T: Traverse[T] = summon[Traverse[T]]
val G: Traverse[G] = thisG
G.traverse(gta){ta => T.traverse(ta)(f)(F)}
现在,我们可以轻松地实现对 List[Map[K, Option[A]]]
,或者是 Map[List[K, Option[List[A]]]]
的遍历。就像在 Monoid 章节的实践一样,只需要构建配套的 Traversable 模块,然后通过 compose
将这些模块链接起来。下面是一个例子:
scala
object ListTraverse extends Traverse[List]:
override def traverse[F[_] : Applicative, A, B](fa: List[A])(f: A => F[B]): F[List[B]] =
val G = summon[Applicative[F]]
fa.foldRight(G.unit(List.empty[B])) { (a, acc) =>
G.map2(f(a), acc)(_ :: _)
}
// Scala 2:
// implicit def MapTraverse[K] = new Traverse[({type λ[V] = Map[K, V]})#λ]{....}
// Scala 的 object 不能携带类型参数,因此这里 given 一个匿名实现作为单例。
given MapTraverse[K]: Traverse[[V] =>> Map[K, V]] with
override def traverse[F[_] : Applicative, A, B](fa: Map[K, A])(f: A => F[B]): F[Map[K, B]] =
val G = summon[Applicative[F]]
fa.foldRight(G.unit(Map.empty[K, B])) {case ((k, a), acc) =>
G.map2(f(a), acc)((t, map) => map.updated(k, t))
}
val mapList = Map[String,List[Int]]("1" -> List(1, 2, 3, 4), "2" -> List(5, 6, 7, 8))
// 求 mapList 中列表内的元素之和。
MapTraverse[String].compose(ListTraverse).foldMap(mapList)(identity)(monoid)
Monad 可组合吗?*
我们在 "Monad vs Applicative" 章节的探讨中留了一点小尾巴。本文已经通过各种实践验证 Monoid 是可组合的,Applicative 也是组合的,Traversable 也是可组合的,却唯独没有探讨 Monad 也是可组合的。
事实上,两个 Monad 之间没法以通用的形式组合,除非引入新的假设:其中一个 Monad 是可遍历的 Traversable 。见下文composeMonad
的完整实现:
scala
def composeMonad[F[_], G[_]](F: Monad[F], G: Monad[G], T:Traverse[G]): Monad[[X] =>> F[G[X]]] =
new Monad[[X] =>> F[G[X]]]:
override def unit[A](a: A): F[G[A]] = F.unit(G.unit(a))
override def flatMap[A, B](fga: F[G[A]])(f: A => F[G[B]]): F[G[B]] =
val fgfgb: F[G[F[G[B]]]] = F.map(fga){ga =>G.map(ga){ a => f(a)}}
F.flatMap(fgfgb) { gfgb =>
F.map(T.sequence(gfgb)(F)) { ggb => G.flatten(ggb)}
}
如果将 f
直接用在 F[G[A]]
的 A
元素上,我们会得到一个 F[G[F[G[B]]]]
,没有通用的手段能处理这个复杂的嵌套类型。但如果 G
也是可遍历的,那事情就好办得多了。Traverse[G]
恰好可以提供 sequence
函数将 F[G[F[G[B]]]]
翻转为 F[F[G[G[B]]]]
,那么剩下要做的事情仅仅是分别针对 F
和 G
进行展平。
在上述代码中,对 G
的展平是显而易见的,对 F
的展平则是通过嵌套 flatMap
和 map
来实现的。但在其他情况下,必须编写定制化代码去解决 F[G[F[G[B]]]]
的问题,我们称之为 Monad 转换器。比如,下面的 OptionT[M, A]
用于将 Option Monad 和其他的 Monad 融合。
scala
case class OptionT[M[_]: Monad, A](value: M[Option[A]]):
def flatMap[B](f: A => OptionT[M, B]) : OptionT[M, B] =
val M = summon[Monad[M]]
OptionT {
M.flatMap(value) {
case Some(v) => f(v).value
case None => M.unit(None)
}
}
OptionT 通过自定义一个偏函数将 M[Option[M[Option[B]]]]
展平为 M[Option[B]]
类型。由此可见,Monad 强大的表达力是以牺牲模块性和组合性为代价的,在缺少 Traversable 假设的条件下,我们没有通用的策略去组合任意两个 Monad。
结尾
目前已经发现函数式编程当中的几个通用模式:Monoid 、Applicative 、Monad 和 Traversable 及其相关的法则。它们都在不同的角度描述一件事情 ------ 组合:
- Monoid 组合一类数据
A
。它定义了一个封闭的二元操作,还有一个单位元作为零值。 - Monad 将上下文
F
组合为一个,而上下文存在依赖关系。 - Applicative 也将多个上下文
F
组合为一个,但是这些上下文是独立的,不依赖于彼此的结果。 - Traversable 在原上下文
G
的基础上引入一个新的上下文Applicative[F]
,并且可以翻转新上下文的语义,但结构不变。已经证明了它是 Foldable 的更泛化形式。
这些不同的抽象最终几乎都指向了 Functor --- "映射"。虽然 Monoid 和 Applicative 源自不同的数学背景,但是我们也在编程层面将它们统一了起来。
函数式编程是一个完整的范式。还有最后一个话题没有提及,那就是函数式程序应当如何与外界进行交互,毕竟我们总是得通过读写文件或者访问数据库来实现 IO。剩下的部分会专门探讨如何处理这类 外部作用 (external effect),我们会为此开发一套专门的 IO Monad。