vlambda博客
学习文章列表

高阶函数式编程二:在 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)的,不过应用函子实现起来确实有难度,可能要花点时间。