整理自《Functional Programming In Scala》第十章。
Monoid(幺半群) 是一个代数定义,是纯代数(purely algebraic)的一种,它简单、普遍存在且很实用。除了满足同样的代数法则外不同 Monoid 实例之间很少有关联,但这种代数结构定义了用于实现实用的多态函数所必须的所有法则。
操作列表、连接字符串或在循环中累加都可以被拆解成 Monoid。下面介绍它在两个方面的使用方式:将问题拆分成小部分然后并行计算;将简单的部分组装成复杂的计算。
比如在字符串拼接的代数表达中,“foo” + “bar” 得到“foobar”,空串是这个操作的单位元(identity)(或称“幺元”)元素,即 “”+s 与 s + “” 的值都是 s。进一步,如果将三个串相加,r + s + t,由于这个操作是可结合的(associative),因此 (r +s) +t 与 r + (s+t) 的结果是一样的。
该规则同样适用于整数相加,它也是可结合的。(x+y)+z 与 x +(y+z) 的结果相同,而且由一个单位元素 0,它去其他整数相加时不会影响结果。同样乘法也是一样,它的单位元元素是 1。
布尔操作符 || 和 && 同样是可结合的,它们的单位元元素是 true 和 false。
像这样的代数便成为 Monoid,结合律(associativity)和同一律(identity)则一起被称为monoid法则。一个 Monoid 由如下几部分构成:
x:A, y:A, z:A
来说,OP(OP(x,y), z)
和OP(x, OP(y,z))
是等价的;zero:A
,它是一个单位元,对于任何x:A
来说,zero
与它的操作都等于 x 自身:OP(x, zero) == x
或OP(zero, x) == x
。可以使用 Scala 表示:
1 | trait Monoid[A] { |
然后是 String 实例:
1 | val stringMonoid = new Monoid[String] { |
或者是 List 的连接:
1 | // 注意这里是 def,一个返回 Monoid 实例的函数,否则将丢失类型参数 A |
“有”一个 Monoid,还是“是”一个 Monoid:
当程序员和数学家讨论:一个类型是 Monoid,或,有一个 Monoid实例,有两种不一致的表述方式。程序员易于认为一个
Monoid[A]
的实例是 Monoid,但这并不准确。Monoid 实际上是类型和定义法则的实例。更准确的说是类型 A 和Monoid[A]
实例定义的操作构成了一个 Monoid。
一个类型、一个此类型的二元操作(满足结合律)、一个单位元元素,这三者构成一个 Monoid。
Monoid 和列表联系紧密,从 List 的foldLeft
/foldRight
签名中可以发现参数的类型很特别:
1 | def foldRight[B](z: B)(f: (A, B) => B): B |
当 A 和 B 类型一样时:
1 | def foldRight(z: A)(f: (A, A) => A): A |
如果一个字符串的列表,可以传递 StringMonoid 中的 OP 和 zero,用于将字符串进行拼接:
1 | val words = List("Hic", "Est", "Index") |
会发现两个操作的结果一样,这正是因为结合律与同一律法则,无论左右结合效果都一样。
1 | words.foldRight("")(_ + _) == (("" + "Hic") + "Est") + "Index" |
可以编写一个通用的 concatenate 函数,使用 Monoid 去折叠列表:
1 | def concatenate[A](as:List[A], m:Monoid[A]) :A = as.foldLeft(m.zero)(m.op) |
但是假如列表中的元素类型不是 Monoid 实例该如何处理呢,总是可以将列表 map 成另外的类型:
1 | def foldMap[A, B](as:List[A], m:Monoid[B])(f: A => B): B |
Monoid 操作的结合律意味着可以自由选择如何进行数据结构的折叠操作。前面展示了使用列表的 foldLeft 和 foldRight 去调用满足结合律的函数,对列表按照顺序向左或向右的 reduce。如果有个 Monoid 可以使用平衡折叠法(balance fold)对列表进行 reduce,这样一些操作可能更加高效或支持并行化。
假设一个有序集 a, b, c, d,三种不同的折叠方式:
1 | op(a, op(b, op(c, d))) // foldRight |
在平衡折叠中,因为两个 op 是独立的,因此支持同时运行。当每个 op 的时间花费与参数的长度成正比时平衡树的结构可以变得更加高效,比如下面的表达式:
1 | List("lorem", "ipsum", "dolor", "sit").foldLeft("")(_ + _) |
其求值轨迹为:
1 | List("lorem", "ipsum", "dolor", "sit").foldLeft("")(_ + _) |
每次折叠,分配一个临时的字符串(foldLeft 的第一个参数)然后丢弃,下次又要分配一个更大的字符串。字符串的值是不变的,当 a + b 时,需要分配一个字符数组然后将 a 和 b 的值复制到这个新数组。这个时间花费与 a、b 的总长度是成正比的。相比更高效的方式是对半组合顺序集,先构建“loremipsum”和“dolorsit”,然后将他们加在一起。
如果需要统计字符串中的单词数,可以按顺序扫描字符串,寻找空格然后对连续的非空格字符计数。这样按顺序解析,解析器的状态可以表达成最后一个字符是否是空格。
但是如果要处理一个巨大的文件,达到单机内存装不下,需要对文件进行切分才能处理。策略是将文件拆分成多个可以管理的块(chunk),并行处理这些块,最后将结果合并起来。这时,解析器的状态可能会复杂一些,需要可以合并中间结果,无论这个部分是文件的开头、中间或结尾。这意味这合并操作需要时可结合的。
把下面的字符串当做一个大文件:
1 | "lorem ipsum dolor sit amet" |
假如对半拆分字符串,可能会将一个单词拆分。当累加这些字符串的计算结果时需要避免重复计入同一个单词。所以这里仅仅将单词作为整体来计数是不严谨的。需要一个数据结构能处理部分结果,并能记录完整的单词。单词计数的结果则可以表示成一个代数数据结构:
1 | sealed trait WC |
Stub
表示没有看到任何完整的单词,Part
保存看到的完整的单词的个数,以及左边的部分单词和右边的部分单词。
比如上面的字符串,拆分成“lorem ipsum do”和“lor sit amet”,对前者计数的结果为Part("lorem", 1, do)
,对后者的计数结果为Part("lor", 2, "")
。
Monoid 同态
可能你会发现 Monoid 的函数之间有个法则。比如字符串的连接 Monoid 和整数累加 Monoid。假如取两个字符串的长度相加,等于连接两个字符串然后取其长度:
1
2 > "foo".length + "bar".length == ("foo" + "bar").length
>
length
是一个函数,它将 String 转化为 Int 并保存 Monoid 结构。这样的结构称为Monoid 同态(homomorphism),一个 Monoid 同态 f 定义为在 Monoid M 和 N 之间对所有的值及 x、y 都遵守以下规则:
1
2 > M.op(f(x), f(y)) = f(N.op(x, y))
>
当设计自己的库时这个特性很有用,加入两个类型是 Monoid 并且他们之前存在函数,好的做法是考虑这个函数是否可以保持 Monoid 结构,并且测试其是否为 Monoid 同态。
某些时候两个 Monoid 之间是双向同态的,同质(isomorphic)是在 M 和 N 之间存在的两个同态的函数 f 和 g,而且
f andThen g
和g andThen f
是等同的函数。比如, String 和 List[Char] monoid 的连接操作是同质的。两个 Boolean monoid (false, ||) 和 (true, &&)通过取反(!)同样也是同质的。
现在需要为 IndexedSeq 实现一个折叠函数。一般处理这类数据结构中的额数据时,通常不在意具体结构是什么,也不在意是否延时或者提供有效的随机读写,等等。
比如有个结构中是整形,需要计算他们的总和,可以使用 foldRight:
1 | ints.foldRight(0)(_ + _) |
不需要关心 ints 的具体结构类型,他可以是 Vector、Stream 或其他列表,或者任何一个包含 foldRight 方法的类型。把这种通用性表达成下面的 trait:
1 | trait Foldable[F[_]] { |
这里抽象出一个类型构造器 F,就像在之前章节中构建的 Parser 类型。表示为F[_]
,这里的下划线表示 F 不是一个类型而是一个类型构造器,它接收一个类型参数。就像接收别的函数作为参数的函数被称为高阶函数,Foldable 是高阶类型构造函数或高阶类型。
Monoid 的真正强大之处在于组合。比如 类型 A、B 是 Monoid,那么 Tuple 类型 (A, B) 也是 Monoid (称 product)。
只需要包含的元素是 monoid,某些数据结构就能构建成 Monoid。比如当 value 类型是 Monoid 时,合并 key-value 映射的操作就能够构建 monoid:
1 | def mapMergeMonoid[K, V](V: Monoid[V]): Monoid[Map[K, V]] = { |
使用这个简单的组合子(combinator)既可以组装出复杂的 Monoid:
1 | val m:Monoid[Map[String, Map[String, Int]]] = |
多个 Monoid 可以被组合在一起,则折叠数据时可以同时执行多个计算。比如同时获得一个列表的总和与长度,来计算平均值:
1 | val m = prodocutMonoid(intAddition, intAddition) |
Monoid 是第一个纯抽象代数,它定义为抽象的操作和对应的法则。可以在不知道参数是什么,仅知道其类型可以构建 monoid的情况下编写可用的函数。