解构函数式编程的通用模式 | The Law Paves All, the Functor Expresses All 𓀀

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,其中,ab 源自同一个类型 A,且这个二元运算的结果也是 A ,称 Aop 运算下是 闭合 的。

数值类型的减法和除法不满足结合律,比如 1 - (2 - 3) 不等于 1 - 2 - 3 。此外,除法的计算也不是闭合的,比如任何一个数除以 0 都会得到一个 NaN,这不是一个有意义的数值。除此之外,还要区分 ""结合律" 和 "交换律" 这两个概念。比如,字符串拼接操作满足结合律但不满足交换律:"aa" + "bb" 并不等价于 "bb" + "aa"

我们还提到了 ""01 这些在不同运算下具备特殊意义的 "零值",它们称之为单位元 (幺元)。单位元应当满足这样的性质:

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

foldLeftfoldRight 则更加泛化一些,它以另一个 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)

特别地,当传入的 fidentity 函数时,foldMapfold 函数等价。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 值总和。这有两种方式:

  1. 首先将两个列表合并成一个,然后求这个大的列表的元素和。
  2. 分别求每一个小列表的元素和,然后将这两个元素和相加。

即,下方的两种调用是等价的:

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,则称 fg 构建了 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)
  ))

这样看起来就清晰一些了。mergeEmbeddedMapTest1mergeEmbeddedMapTest 本质上就是一套代码模板,只不过前者操作的是两个 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 是一个通过 法则 而非 具体行为 来定义自身的纯代数式接口,后文会介绍其他类似的模式。回顾之前各种版本的组合子实践:

  1. 自动化测试框架:用 Scala 编写 Property-based Testing API - 掘金 (juejin.cn)
  2. 并行计算库:Scala 纯函数式库设计:并行编程 - 掘金 (juejin.cn)
  3. JSON 语法解析器:基于代数式设计构建 JSON 语法解析器 - 掘金 (juejin.cn)

在这些实践中,我们首先构建了精简的原语,然后基于这套原语衍生出更加具体且实用的组合子。然而,有些特殊的组合子总是出现在原语集当中,包括 flatMapmapunit。这并不是巧合,本章会介绍它们构成了哪些通用模式 (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 是一套提供了 flatMapunit 这两个最小原语集的 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] = ???

从签名上看,mapflatMap 最终的返回值都是 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 组合子: sequencetraverse。其中 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 那样遵守一些规范。我们需要如何约束 flatMapunit 呢?

结合律

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 的第二个最小原语集:composeunit

左单位元律 / 右单位元律

不难发现,最小原语集中的 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 是一个实用的操作。比如,我们可以通过嵌套调用 iflatten 将一个 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 本身并不是一个通用的组合子,因为没有必要强绑定 flatMapmap重点是,通过嵌套组合 flatMapmap 同样实现像 flatten 那样展平的效果。

如 Haskell 这类纯函数式语言更倾向于将刚才的 flatten 函数命名为 join,但功能不变。join, map, unit 可以构成 Monad 的第三个最小原语集,而 flatMap 则可以通过组合 joinmap 实现。

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 的内部元素 Af 映射之后返回一个 F[F[A]] 类型。之后,由 join ( 或者称 flatten) 函数将重复嵌套的同一上下文 F 展平为一个,flatMap 的命名正是由此得来的。

应用 Monad

我们注意到, Monoid 修饰普通的元素类 A,而 Monad 修饰类型构造器 F。如果 Monoid 的意义在于提供了元素 A 的结合方式,那么经过类比后是否也能推断出 Monad 的作用呢?为了对 Monad 有一个更直观的理解,下面举两个具体的例子。

Identity Monad

之前构造了一个简单的容器类 Id,现在来为它完善 Monad 定义。这次将 flatMapmap 组合子作为方法定义在其内部。

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 中,如果一个类定义了 flatMapmap 这两个方法,那么它可以被应用 for 推导式(见:Scala +:类型推断,列表操作与 for loop - 掘金 (juejin.cn)。下面的写法更清晰一点:

scala 复制代码
for{
  str1 <- id1
  str2 <- id2
} yield {
  str1 + str2
}

看起来我们好像从 id1id2 两个上下文中提取出了元素值,然后在 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)}

我们在当时已经给出了所有必要的实现:mapmap2flatMapunit,所以 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 章节,我们用 flatMapunit 作为原语集构造出了更多的 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 是通过 map2unit 来实现的,它没有直接调用 flatMap。考虑另一种抽象,使用 map2unit 这两个组合子作为最小原语集,这个抽象称之为 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 可以被 map2unit 这两个原语级别的组合子来实现。实际上,Applicative 这个名词源于这样的事实,它使用 apply 组合子和 unit 构成最小原语集,而非 map2。不过,applymap2 是可以相互实现的:

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 调用分别确定了输入 fafb,最终得到 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 就得借助其他组合子将 AF[A] 中提取出来。显然 unitmap2 都不行,它们总是返回一个 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 表 deptssalaries,现给定 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)

当前对 deptssalaries 的两个查找是独立的,使用 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 呢?

再回顾一下之前的语法解析器章节。现在有两个语法解析器组合子 p1p2,如果 p1 flatMap p2,那么 p2 只会在 p1 解析成功之后才执行,否则直接返回解析失败,这样可以短路掉后续的无效尝试。

但有时,系统也会允许一部分调用部分失败,只使用成功的调用来返回结果,浏览器渲染 Web 页面就是这样的逻辑。一个 HTML body 内部通常包含多个静态资源,但一小部分资源加载失败(比如某个图片挂掉)不应导致整个页面加载失败。

在这里举一个更简单的例子:表单验证。程序需要负责检验用户提交的表单中,user 和 age 这两项是否为合法的值。

参考之前的文章 Scala:函数式编程下的异常处理 - 掘金 (juejin.cn) ,我们在此使用 Either[+E, +R] 包装解析成功或者失败这两个结果。首先为 Either 编写一个 Monad,将其中的 E 指派为 String 类型,以文本形式反馈错误。另一方面,我们构造了 validUservalidAge 两个检查器,每个检查器仅负责检查一项内容(满足最小职责原则)。

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)    

这个检查逻辑对用户不那么友好。比如用户填写的表单存在多处错误,链式检查器会反复将表单打回给用户修改,直到完全正确为止。更好的交互方式是,检查器一次性返回当前检查到的所有错误,等用户全部修改完之后再一并提交。直接使用 EitherMonadmap2 函数做替代可以吗?在这里 不行 。因为 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 之间总是可以组合的。假设 FG 都是 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

结合律

考虑基于 map2unit 衍生出一个 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)(_(_))

可以先将 fafb 绑定,最后再和 fc 绑定;也可以先选择将 fbfc 绑定,然后再和 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)

productunit 也可以构成 Applicative 的最小原语集。就像前文的 flatMapcomposemap2apply 那样,productmap2 也可以相互实现,它们构成的最小原语集在深层次上仍然是等价的。

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 方法首先从 EmployeePay 当中提取出字符串信息,然后再输出格式化后的内容模板。可以把这个提取信息的过程放到外部,这样 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)

很显然,把转化 EmployeePay 的过程放到 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 当中 sequencetraverse 这两个组合子:

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 。现在把 traversesequence 单独提取出来,以 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 ),但得做一些补充。对于像 ListMapTree 这样的集合类型,"遍历" 是一个直观的概念,因为我们可以逐个访问集合中的每个元素。然而,对于像 OptionFuture 这样的容器类型,"遍历" 可能听起来有些奇怪,因为它们只包含零个或一个元素,此时称 "访问" 应该要更加恰当。笔者要强调的是,traverse 行为在不同的容器中的语义可能是不同的。

可以参考泛化之前的例子推测 traverse 大概做了什么。如果 G 是某种集合容器,则 traverse 为每个元素提供新的上下文 F,然后将这些 "小" F 合并成一个 "大" F。显然,合并的逻辑是 Applicative[F] 提供的 map2 方法,因此 traverseF 引入了一个上下文界定。特别地,如果 G 只修饰一个元素,那么可以将 traverse 理解为仅仅是将 G[A] 放到 F 容器内部。

sequence 则是 traverse 的一种特殊情况,但功能通俗易懂。从函数签名上分析,它只是将 G[F[A]] 翻转为 F[G[A]]。翻转是一个实用操作,在不同场景下有不同的语义。比如:

  1. 给定 List[Option[Int]] => Option[List[Int]],用于检测外部输入的列表 List 是否存在至少一个 None 值。如果是,则 f 直接返回 None,否则,返回一个由 Some 包裹的列表。
  2. 给定 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 组合两个不同的上下文 GF,并返回一个嵌入的 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 事实上有很多相似之处:

  1. Monoid 要求提供 A 类型的单位元 zero,Applicative 则通过 unitA 提升为 F[A]
  2. Monoid 的 op 函数定义了两个 A 如何结合,Applicative 的 map2 函数则规定了两个 F 如何结合。

两者的根本区别是:Monoid 修饰元素 A,而 Applicative 修饰 F[A]。要是有一种类型能够调和 AF[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 无关,fafb 只需要通过 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)(_ + _)

但是 StreamGenFunctormap 组合子要怎么实现呢?需要强调的一点是,Functor[StreamGen] 要映射的不是 StreamGen[A] 内部的列表,而是 StreamGen[A] 这个结构本身。函数 f 并不能映射 gn,因而无法构造出新的 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

显然,zipWithIndextoList 共享大部分代码模板,而区别只在于返回什么样的值,以及如何转移状态。它们可以被一个 (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] 结构不同的情形。我们可以分别实现 zipLzipR 函数,由用户决定是维持左侧 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]]]],那么剩下要做的事情仅仅是分别针对 FG 进行展平。

在上述代码中,对 G 的展平是显而易见的,对 F 的展平则是通过嵌套 flatMapmap 来实现的。但在其他情况下,必须编写定制化代码去解决 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。

结尾

目前已经发现函数式编程当中的几个通用模式:MonoidApplicativeMonadTraversable 及其相关的法则。它们都在不同的角度描述一件事情 ------ 组合:

  • Monoid 组合一类数据 A。它定义了一个封闭的二元操作,还有一个单位元作为零值。
  • Monad 将上下文 F 组合为一个,而上下文存在依赖关系。
  • Applicative 也将多个上下文 F 组合为一个,但是这些上下文是独立的,不依赖于彼此的结果。
  • Traversable 在原上下文 G 的基础上引入一个新的上下文 Applicative[F],并且可以翻转新上下文的语义,但结构不变。已经证明了它是 Foldable 的更泛化形式。

这些不同的抽象最终几乎都指向了 Functor --- "映射"。虽然 Monoid 和 Applicative 源自不同的数学背景,但是我们也在编程层面将它们统一了起来。

函数式编程是一个完整的范式。还有最后一个话题没有提及,那就是函数式程序应当如何与外界进行交互,毕竟我们总是得通过读写文件或者访问数据库来实现 IO。剩下的部分会专门探讨如何处理这类 外部作用 (external effect),我们会为此开发一套专门的 IO Monad。

相关推荐
customer081 小时前
【开源免费】基于SpringBoot+Vue.JS周边产品销售网站(JAVA毕业设计)
java·vue.js·spring boot·后端·spring cloud·java-ee·开源
WaaTong1 小时前
《重学Java设计模式》之 单例模式
java·单例模式·设计模式
Yaml42 小时前
智能化健身房管理:Spring Boot与Vue的创新解决方案
前端·spring boot·后端·mysql·vue·健身房管理
小码编匠3 小时前
一款 C# 编写的神经网络计算图框架
后端·神经网络·c#
WaaTong3 小时前
《重学Java设计模式》之 原型模式
java·设计模式·原型模式
AskHarries3 小时前
Java字节码增强库ByteBuddy
java·后端
霁月风3 小时前
设计模式——观察者模式
c++·观察者模式·设计模式
佳佳_4 小时前
Spring Boot 应用启动时打印配置类信息
spring boot·后端