解构函数式编程的通用模式 | 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。

相关推荐
new_daimond12 小时前
设计模式-装饰器模式详解
设计模式·装饰器模式
程序员爱钓鱼12 小时前
Go语言实战案例 — 项目实战篇:图书管理系统(文件存储)
后端·google·go
元闰子12 小时前
OLTP上云,哪种架构最划算?·VLDB'25
数据库·后端·云原生
IT_陈寒12 小时前
Vite 5.0重磅升级:8个性能优化秘诀让你的构建速度飙升200%!🚀
前端·人工智能·后端
hui函数13 小时前
scrapy框架-day02
后端·爬虫·python·scrapy
Moshow郑锴13 小时前
SpringBootCodeGenerator使用JSqlParser解析DDL CREATE SQL 语句
spring boot·后端·sql
小沈同学呀19 小时前
创建一个Spring Boot Starter风格的Basic认证SDK
java·spring boot·后端
方圆想当图灵21 小时前
如何让百万 QPS 下的服务更高效?
分布式·后端
凤山老林21 小时前
SpringBoot 轻量级一站式日志可视化与JVM监控
jvm·spring boot·后端
凡梦千华21 小时前
Django时区感知
后端·python·django