1. 引言

函数式编程社区的一大亮点是其善于从代码中识别出抽象模式,并将这些模式与数学中定义明确、具备法则的结构对应起来。这些抽象通常用代数结构(Algebraic Structures)和代数定律(Algebraic Laws)来描述。

本文将从函数式编程的角度出发,定义这些术语,然后逐步建立对代数结构的直觉认知 —— 特别是 Monoid 和 Semigroup。但在深入之前,我们先用最直白的语言来定义 Monoid:

Monoid 是一个接受两个参数的函数,并遵循两个定律:结合律(Associativity)和单位元(Identity)

2. 代数结构

代数结构由一组操作和它们作用的集合组成。在像 Scala 这样的函数式语言中:

  • 集合 指的是数据类型(Data Types)
  • 操作 指的是类型类(Type Classes)

Monoid 和 Semigroup 就是两种典型的代数结构。

核心思想是将行为与数据分离,通过代数结构定义操作,从而描述数据的行为。

在深入 Monoid 和 Semigroup 之前,我们快速回顾一下类型和类型类。

2.1. 类型(Types)

类型是值的集合。例如 IntDoubleString 都是类型。在 Scala 中,类型是静态的,编译时就确定。

函数式编程中的基本数据类型构建块是 乘积类型(Products)和类型(Coproducts),统称为 代数数据类型(ADTs)

它们是用“与”和“或”来表达数据的惯用方式:

  • 一个形状可以是矩形 圆形
  • 矩形有宽度 高度
  • 圆形有半径

在 ADT 中:

  • “与”类型(如矩形)是 乘积类型
  • “或”类型(如形状)是 和类型

在 Scala 中,通常使用 case class 表示乘积类型,使用 sealed trait 表示和类型:

sealed trait Shape
final case class Rectangle(width: Double, height: Double) extends Shape
final case class Circle(radius: Double) extends Shape

val rect: Shape = Rectangle(3.0, 4.0)
val circ: Shape = Circle(1.0)

2.2. 类型类(Type Class)

类型类是一种类型系统构造,用于支持一组重载操作(方法)。它通过定义一组函数或常量名称及其类型来实现。

✅ 例如:Monoid 和 Semigroup 就是典型的类型类。

类型类包含三个重要组成部分:

  1. 类型类本身
  2. 特定类型的类型类实例
  3. 使用类型类的方法

我们先定义 Semigroup 和 Monoid 的类型类:

trait Semigroup[A] {
  def op(x: A, y: => A): A
}
trait Monoid[A] extends Semigroup[A] {
  def zero: A
}

到此为止,我们已经对类型、类型类、类型类实例有了基本认知。接下来,我们将用这些知识来研究代数数据结构 —— Monoid 和 Semigroup。

3. Monoid

代数结构中的 Semigroup 是一个集合和一个关联的二元运算符,该运算符将集合中的两个元素合并。

Monoid 则是带单位元(Identity)的 Semigroup

我们先用 Scala 来编码这些定义。

二元操作

二元操作是一个接受两个参数并返回结果的函数:

def |+|[A] (left: A, right: A): A

结合律(Associativity)

Monoid 的二元操作必须满足结合律 —— 操作顺序不重要,可以去掉括号:

(x |+| y) |+| z = x |+| (y |+| z)

单位元(Identity)

Monoid 还必须有一个单位元,对二元操作来说就是“啥都不做”的元素:

e |+| x = x |+| e = x

✅ 这就是 Monoid 的全部定义了!

它们在编程中随处可见。理解它们最好的方式是看几个例子:

  • 整数在加法或乘法下构成 Monoid
  • 列表的拼接

基本上,任何能“合并”并满足 Monoid 法则的结构都可以称为 Monoid。

接下来,我们用这个抽象来做一个 MapReduce 风格的词频统计任务。

4. 使用 Monoid 实现 MapReduce

在这一节,我们将使用 Monoid 来开发一个 MapReduce 函数。

我们首先定义函数签名:

def wordCount(string: String): Map[String, Int] = ???
def mapMonoid[V: Numeric](map1: Map[K, V], map2: Map[K, V]): Map[K, V] = ???
def frequency(wordCounts: Map[String, Int]*)(implicit monoid: Monoid[Map[String, Int]]): Map[String, Int] = ???

4.1. 统计词频

wordCount 是一个词频统计函数,将被分发到多个计算节点上执行。使用不可变的 Map 保证线程安全:

object WordCount {
  case class WordCount(word: String, count: Int)
  def wordCount(s: String): Map[String, Int] =
    s.split("\\s+")
      .map(x => WordCount(x, 1))
      .groupBy(w => w.word)
      .map(x => (x._1 -> x._2.foldLeft(0)((a, c) => c.count + a)))
      .toMap
}

4.2. Monoid 实现

接下来,我们为 Map[K, V] 实现一个 Monoid 实例,其中 V 必须是 Numeric 类型:

object MapMonoidInstance {
  implicit def mapMonoid[K, V: Numeric]: Monoid[Map[K, V]] = 
    new Monoid[Map[K, V]] {
      override def zero: Map[K, V] = Map()
      override def op(l: Map[K, V], r: => Map[K, V]): Map[K, V] = 
        l.keySet.union(r.keySet)
          .map(k => (k -> implicitly[Numeric[V]].plus(
                      l.getOrElse(k, implicitly[Numeric[V]].zero),
                      r.getOrElse(k, implicitly[Numeric[V]].zero)))).toMap
    }
}

4.3. 使用 Monoid

最后,frequency 函数是 MapReduce 中的 reduce 阶段。它通过 Monoid 实例将多个 Map 合并为一个:

def frequency(wordCounts: Map[String, Int] *)(implicit monoid: Monoid[Map[String, Int]]): Map[String, Int] =
   wordCounts.foldLeft(monoid.zero)(monoid.op(_, _))

⚠️ 关键点:由于 Monoid 满足结合律,聚合顺序不影响结果,因此非常适合并发和分布式计算。

5. 总结

本文我们学习了 Monoid 和 Semigroup 这两种代数结构。

我们首先回顾了类型类和类型类实例,然后自定义了 Monoid 和 Semigroup 的类型类。最后,我们用 Monoid 实现了一个线程安全的 MapReduce 程序。

✅ 代码可以在 GitHub 上找到。


原始标题:Monoids and Semigroups in Scala