高阶函数式编程二:在 Kotlin 中实现单位半群(Monoid)
其实按道理这个系列的第二篇应该写应用函子(Applicative)的,但是应用函子有点难搞,以后再说。这次先来讨论一个相对来说比较容易实现的概念——单位半群(monoid,又译幺半群),单位半群相比函子(functor)来说也更简单。
什么是单位半群?
在数学中,单位半群是群论中概念(不要被群论的名字吓住)。在函数式编程中,单位半群(monoid)和函子(functor)一样也是一个类型类(type class)。关于类型类的问题,上篇文章已经讨论过了。
那么它是怎样的一种类型类?一般来说单位半群具有以下特质:假设存在一个单位半群类型 A,它拥有一个固定值 a,和一个二元运算,将该二元运算应用到 a 和任意一个 A 类型的值,无论二者的顺序如何,运算得到的结果都是该任意值本身。
举个例子,乘法是一个二元运算,无论任何数 x 与 1 相乘,得到的结果都是这个数本身。字符串拼接(+)也是一个二元运算,任何字符串与空字符串 "" 拼接最终得到的也都是自身。
先看看 Haskel 中 Monoid 类型类的定义:
class Monoid m where
mempty :: m
mappend :: m -> m -> m
mconcat :: [m] -> m
mconcat = foldr mappend mempty
mempty 指的就是上面讨论的固定值,学名叫做单位元,而 mappend 函数就是上面说的二元运算。最后的这个 mconcat 函数接受一个单位半群类型的列表作为参数,最后将其归约为该类型的单个值,它已经具有默认实现,通过右折叠的方式以 mempty 作为初始值进行归约,在少数情况下,也许其他实现可以令 mconcat 拥有更高的效率,但在大多数情况下我们只需要使用其默认实现即可满足我们的需求。
使用 Kotlin 表示单位半群
单位半群的实现者与函子不同,并不需要是一个高阶类型,所以实现起来相对比较容易,不怎么需要复杂的泛型编程:
interface Monoid<M : Monoid<M>> {
val mempty: M
infix fun mappend(m: M): M
infix fun mconcat(list: List<M>): M = list.foldRight(mempty) { m1, m2 -> m1 mappend m2 }
}
我们还是用了一个自限定的泛型来表示 Monoid 接口的实现者自身。在 mconcat 的实现上我们也没费太多功夫,因为标准库中就已经提供了 List 的右折叠函数:foldRight。不过这里还是有瑕疵,在 Kotlin 中如果要使用 empty 属性或 mconcat 函数,都需要先有一个单位半群对象,然后用这个对象调用之。前面说过,Kotlin 中函数的接收者相当于一个特殊的参数,但实际上 mempty 和 mconcat 都不需要这个参数,但是这在当前的语法条件下没有办法,如果想用类名来引用 mempty 和 mconcat 就需要使用伴生对象,但 Kotlin 的 interface 没法强制约束实现者的伴生对象的行为,所以无法实现。那就委屈求全一下,就像前文的 Functor 的 fmap 函数一样,虽然调用的时候提供接收者,但函数内部(或变量)不使用。
实现几个单位半群
单位半群字符串
字符串 String 可以实现为单位半群,mempty 就是空字符串,而 mappend 就是字符串连接操作。
inline class MonoidString(private val str: String = "") : Monoid<MonoidString> {
override val mempty: MonoidString get() = MonoidString()
override fun mappend(m: MonoidString): MonoidString = MonoidString(str + m.str)
override fun toString(): String = str
}
为了更高效一点,这里使用了内联类(inline class),这样我们只在编译阶段拥有了一个新类型,而在实际运行期间,MonoidString 都会被它的 str 属性内联掉。内联类在 Haskell 中的类似物是 newtype,它们的功能近乎一样。
验证一下:
fun main() {
val monoidString = MonoidString("abcde")
println(monoidString.mempty mappend monoidString) // 输出:abcde
println(monoidString mappend monoidString.mempty) // 输出:abcde
}
输出结果符合预期。
单位半群的实现并非唯一
对于同一个类型,如果将其实现为单位半群,有时候实现方式并非是唯一的。比如说对于数字来说,1 * 任意数,结果都是这个任意数,但 0 + 任意数,结果也都是这个任意数,所以通常为了多种单位半群的实现,我们会用内联类定义多个类型来完成不同的实现。
请看下例:
inline class Product(private val i: Int = 1) : Monoid<Product> {
override val mempty get() = Product()
override fun mappend(m: Product): Product = Product(i * m.i)
override fun toString(): String = i.toString()
}
inline class Sum(private val i: Int = 0) : Monoid<Sum> {
override val mempty get() = Sum()
override fun mappend(m: Sum): Sum = Sum(i + m.i)
override fun toString(): String = i.toString()
}
我们为 Int 类型分别添加了乘法版的单位半群实现以及加法版的单位半群实现。在有些编程语言中,Int、Float、Long、Double 这些表示数字的类型通常有一个父类,比如可能叫做 Number,加减乘除这些基本数字运算是属于这个 Number 类的函数,但遗憾的是 Kotlin 并没有这样做。此外,Kotlin 1.4 也不支持手动声明联合类型,所以这里的 Product 类和 Sum 类只能为 Int 类型提供单位半群的扩展,如果 Float、Long、Double 等类型需要这样的能力,还需要额外动手实现。
我们也来验证一下这两个家伙:
fun main() {
val product = Product(1234)
println(product.mempty mappend product) // 输出:1234
println(product mappend product.mempty) // 输出:1234
val sum = Sum(5678)
println(sum.mempty mappend sum) // 输出:5678
println(sum mappend sum.mempty) // 输出:5678
}
它们实在太简单了,简直用大脑就能验证。
验证单位半群定律
上面验证单位半群使用的是它的交换律,实际上还有结合律(Kotlin 表达):
(x mappend y) mappend z = x mappend (y mappend z)
验证一下 MonoidString:
fun main() {
val x = MonoidString("x")
val y = MonoidString("y")
val z = MonoidString("z")
val result = (x mappend y) mappend z == x mappend (y mappend z)
println(result) // 输出:true
}
验证通过。
然后验证一下 Product 和 Sum:
fun main() {
val x1 = Product(7)
val y1 = Product(8)
val z1 = Product(9)
val result1 = (x1 mappend y1) mappend z1 == x1 mappend (y1 mappend z1)
println(result1) // 输出:true
val x2 = Sum(7)
val y2 = Sum(8)
val z2 = Sum(9)
val result2 = (x2 mappend y2) mappend z2 == x2 mappend (y2 mappend z2)
println(result2) // 输出:true
}
没有意外,验证依然通过。
尾声
这篇文章内容很简单,可以作为一个过度。下次争取写一篇应用函子(applicative)的,不过应用函子实现起来确实有难度,可能要花点时间。