0. 前言
在学习函数式编程时,函子(Funtor) 、单子(Monad) 是非常难啃的骨头,它们来自于数学范畴论,又在 Haskell 这种满是学术气息的语言上发展。
我阅读了多篇关于介绍 Monad 的文章,发现要了解它,一定要具备相关的抽象代数、范畴等知识,显然我没有这么多时间去学习这对我来说很“偏门”的知识。在浅浅的学习过程中,我发现它更接近与一种设计思想。
对我帮助最大的是 arrow-kt 框架中对于 monad 概念的阐述,而且是从 Kotlin 出发的, 文章链接:arrow对 Monad 的介绍, 本篇文章就是基于我对这位大佬所写精华的学习以及总结。
1. 一些数学概念(该小节可以跳过)
单子起源于数学,这里简单的将一些其数学概念列举出来,可以做了解,没有学习的必要性。
1.1 半群 与 幺半群
有这么一门门数学分支,叫 抽象代数,它里面有一个概念叫 半群(semi-group),半群是一个二元运算的代数系统,概念如下:
存在一个非空集合 S
存在两个 S 上的数 a, b。 定义一个二元运算 ○, 使得 a ○ b = c , c 也在 S 集合中。
对于任意 x 、 y 、z ∈ S,如果满足结合律 (x ○ y) ○ z = x ○ (y ○ z)
则称 (S, ○) 为半群 ,简称 S
半群有一个延展概念 ---- 幺半群(monoid),是一个存在单位元(幺元)的半群。 它除了满足半群的特性,还自带了另一个特性:
对于半群 (S, ○)
如果存在一个 e ∈ S, 使得 a ○ e = e ○ a = a
称三元组 (S, ○, e) 为幺半群
举个例子,比如(N+, * , 1) 就是一个幺半群, 范围是正整数,二元运算是 乘法操作满足结合律,而且任意正整数乘1都等于本身, 同理还有 (N, + , 0) 这些。
1.2 范畴、态射 与 同态
范畴可以用下面有向图表示: A、B、C分别是一个对象,它们通过箭头,组成了一个范畴。 范畴(category) 是一种包含了对象及对象之间箭头的代数结构, 范畴满足三个特性:
- 对象之间的箭头可以复合
例如有 f:A -> B 、g: B -> C ,那么它们可以复合成: g ○ f: A -> C - 对象的箭头复合是满足结合律的
例如有 f:A -> B 、g: B -> C 、h: C -> D ,满足 : (f ○ g) ○ h = f ○ (g ○ h) - 每个对象都有自己一个单位箭头
就是每个对象都有一个单位元素,简单来说,在对象A中,存在单位元 idA 使得 A中任意元素a,有 a ○ idA = a
而 态射(morphism)的定义是两个数据结构的之间保持结构的一种过程抽象,简单来说,就是上图中的箭头。 态射听起来和映射差不多,如果你不是严格主义者,那么可以将它们理解成一个东西。在集合论中,态射就是函数!
我们定义一个态射 f: X -> Y ,如果态射满足 f(a * b) = f(a) * f(b) ,那么称这个态射是一个同态。 什么意思,其实不难理解, 因为态射是两种对象间的映射,所以需要用同态来保证这个对象不会变成另外的对象,不然就把这个对象映射到其他范畴里面去了。 简单的来说,态射是一个广泛的、一般性的概念, 而同态则是一个具体的概念, 群结构上的态射都是同态的,因为我们最后还是会回到幺半群上研究问题,所以我们可以认为同态就是态射。
最后我们发现,范畴的特性和幺半群的特性存在相似之处,实际上: 幺半群实质上是只有单个对象的范畴
1.3 函子 与 自函子
函子(Funtor) 就是同态!!!
自函子则是一个能将范畴映射到自身的函子。 例如存在自函子 f 和 范畴 ob( C ), 满足 :f : ob(C) -> ob(C)
1.4 Monad
最后再来理解 Monad 的官方定义
A Monad is just a monoid in the category of endofunctors. Monad 不过是一个自函子范畴上的幺半群罢了
撇开定语, Monad 是一个 幺半群。
2. Monad 的一个模型
根据数学中的 Monad 特性: 结合律、 单位律,我们将 Monad 抽象成一个模型:一个盒子
- 这个盒子里面可以装有对象
- 这个盒子也可以是空的,但并不是什么都没有的空,而是有一个 unit 单位值 (单位律的体现), 这种现象叫业务空值, 例如:乘法里的1, 加法里的0
- 这个盒子可以输入一个函数进去, 它能作用到盒子里面的对象去, 函数的作用无非是 A -> B,所以盒子里面的对象会被作用,然后将结果输出出来
- 这个盒子不仅可以输入一个函数, 还可以输入若干个函数进去,函数会复合(结合律的体现)然后应用到盒子里的对象上,最终输出一个结果
薛定谔的猫,大家应该是耳熟能详了,我们只知道这个盒子里面装了一个类型的对象,但是不知道这个对象具体是什么,我们对这个盒子施加了多个操作,最后它定能输出一个结果给我们。
3. Kotlin 中的 Monad
下面将用代码来解释 Monad 模型
3.1 一段代码
下面用 演讲者(Speak )、演讲(Conference )来举个例子
class Speaker {
fun nextTalk(): Talk = TODO()
}
class Talk {
fun getConference(): Conference = TODO()
}
class Conference {
fun getCity(): City = TODO()
}
class City
我们的函数是输入一个 Speak,然后获取其演讲的 City
fun nextTalkCity(speaker: Speaker): City {
val talk = speaker.nextTalk()
val conf = talk.getConference()
val city = conf.getCity()
return city
}
这样的代码,上一行的输出是下一行的输入,所以可以优化成这样:
fun nextTalkCity(speaker: Speaker): City =
speaker
.nextTalk()
.getConference()
.getCity()
这段代码很美好,因为可读性高且简洁。
但是在实际开发环境中,我们不太可能写出这样的代码,因为可能会有异常情况。
3.2 考虑异常情况
考虑到属性为空的情况,如下情况:
class Speaker {
fun nextTalk(): Talk? = null
}
class Talk {
fun getConference(): Conference? = null
}
class Conference {
fun getCity(): City? = null
}
那么代码就变成了:
fun nextTalkCity(speaker: Speaker?): City? =
speaker
?.nextTalk()
?.getConference()
?.getCity()
虽然能够达到目的,并且代码也足够简洁,但是多了三个额外的 ? , 怎么样才能去除这几个烦人的东西呢?
通常情况下,可以引入 Either ,包装获取的数据:
object NotFound
class Speaker {
fun getTalk(): Either<NotFound, Talk> =
Left(NotFound)
}
class Talk {
fun getConference(): Either<NotFound, Conference> =
Left(NotFound)
}
class Conference {
fun getCity(): Either<NotFound, City> =
Left(NotFound)
}
这样我们可以使用 flatmap 来处理:
fun cityToVisit(speaker: Speaker): Either<NotFound, City> =
speaker
.getTalk()
.flatMap { talk -> talk.getConference() }
.flatMap { conf -> conf.getCity() }
> 换个写法:
fun cityToVisit(speaker: Speaker): Either<NotFound, City> =
speaker
.getTalk() .flatMap { x -> x
.getConference() }.flatMap { x -> x
.getCity() }
我们把右边蒙蔽起来,就是一开始的模样了。
解决了问题后,我们看下另外一种情况,即并行的情况
3.3 并行回调
如果我们的方法需要做一些网络请求或者读取数据库,该怎么办呢?幸运的是, Kotlin 提供了 suspend 挂起函数,可以解决嵌套的问题。
使用 suspend 来进行并行的操作,如下所示:
class Speaker {
suspend fun nextTalk(): Talk = TODO()
}
class Talk {
suspend fun getConference(): Conference = TODO()
}
class Conference {
suspend fun getCity(): City = TODO()
}
调用:
suspend fun nextTalkCity(speaker: Speaker): City =
speaker.nextTalk().getConference().getCity()
这样一来,挂起函数让我们又写出了简单、易读的代码。
3.4 抽象工作流
这几段代码,其实存在了一个模式。 我们在将 T? 、 Either<E, T> 、suspend () -> T 加入到工作流中,为了代码更加舒展。
我们可以把这一个工作流的过程抽象,比如 第一步是 nextTalk,第二步 getConference, 第三步 getCity,它们这些方法其实都是对一开始的数据 speaker 进行顺序处理, 然后输出一个数据,我们可以建模一个数据流处理类 WorkflowThatReturns<T> ,
class WorkflowThatReturns<T> {
fun addStep(step: (T) -> WorkflowThatReturns<U>): WorkflowThatReturns<U>
}
可以用下图概括: 然后我们获取 city 的代码可以写成:
fun workflow(speaker: Speaker): WorkflowThatReturns<City> {
return
speaker
.nextTalk()
.addStep { x -> x.getConference() }
.addStep { x -> x.getCity() }
}
我们通过两次 addStep ,在 step 中一次调用了 getConference 和 getCity ,最终获取 City 的包装类。
如下图所示:
3.5 Monad
在 FP 工程环境中, 上面这种工作流模式就是 Monad!。这和我们第二节提到的盒子模型类似,最初它只是一个类型数据,然后通过一些函数操作,最终可以得到任意类型的结果数据。
4. Option、Either、Result
当我们解开了 Monad 的面纱,我们会发现它并不难理解,我们甚至能在代码中找到它的身影。
Option 、Either 、Result 都能体现出 Monad !不了解的同学可以看下之前的文章:Kotlin 异常处理之 Option、Either、Result
对于 Option 来说,它封装了一个数据, 这个数据可能是 有值 或者 无值 , Option 可以处理很多事情, 例如 map 、 flatmap 、filter , 它都体现了 Monad 的思想:
- 封装数据到一个计算环境中, 外界能够输入函数,对“盒子”中的数据进行计算,最后得到结果
- 它内部对异常进行处理, 在使用 Option 时,不会产生异常,所以它屏蔽了 Exception 这个副作用
- 提供了 map、flatmap,进行数据态射
这么一看, Monad 是一个设计模式,它对数据进行封装。把 Option 是一个盒子, Result 是一个盒子, 是非常形象的。
5. Effect — 协程版本的 Either
我们可以使用 Option 、Result 来展现 Monad 思想,除此之外, arrow-kt 框架还定义了协程版本的 Either,那就是 Effect.kt , 它是一个专门用在 协程、挂起函数上的,因为上面关于 Speaker 的示例代码,我们了解了 suspend 的方式可以减小 flatmap 带来的理解负担,所以 suspend 是 Monad 发挥的极佳环境, arrow-kt 对协程上面做了很多的封装,致力帮助我们写出 FP 风格的代码。
Effect类型:
public interface Effect<R, A> {
public suspend fun <B> fold(
recover: suspend (shifted: R) -> B,
transform: suspend (value: A) -> B
): B
...
}
并且定义了 effect 代码块,它继承 Effect,代码中将会更多的用到这个代码块:
public inline fun <R, A> effect(crossinline f: suspend EffectScope<R>.() -> A): Effect<R, A> =
object : Effect<R, A> {
override suspend fun <B> fold(recover: suspend (R) -> B, transform: suspend (A) -> B): B =
suspendCoroutineUninterceptedOrReturn { cont ->
val token = Token()
val effectScope =
object : EffectScope<R> {
override suspend fun <B> shift(r: R): B = throw Suspend(token, r, recover as suspend (Any?) -> Any?)
}
try {
suspend { transform(f(effectScope)) }
.startCoroutineUninterceptedOrReturn(FoldContinuation(token, cont.context, cont))
} catch (e: Suspend) {
if (token == e.token) {
val f: suspend () -> B = { e.recover(e.shifted) as B }
f.startCoroutineUninterceptedOrReturn(cont)
} else throw e
}
}
}
5.1 简单示例
假设我们需要从目标路径的文件下读内容,我们首先要验证路径的正确性,这里仅做简单的判断内容,那么函数如下所示:
object EmptyPath
fun readFile(path: String): Effect<EmptyPath, Unit> = effect {
if (path.isEmpty()) shift(EmptyPath)
else Unit
}
代码解析:
readFile 接收一个 String,返回一个 Effect 类型, 失败时是一个 EmptyPath 类型,成功则是 Unit- 使用
effect{...} 来构造,它是实现 Effect 的函数体,便于我们创建 Effect shift(R) 用于快速生成一个 Suspend ,它继承自 Exception ,这里用 EmptyPath 去包装。 如果传入路径是无内容的,则生成这个数据类型。 关于异常捕获,可以详看上面 effect 的实现,这里不多做介绍了
if else 语句可能会产生嵌套,手动调用 shift 来创建一个Error数据可能会产生重复工作,所以Effect 还帮我们封装了一些 DSL,例如 ensureNotNull 、ensure ,我们来写第二个读取函数:
fun readFile2(path: String?): Effect<EmptyPath, Unit> = effect {
ensureNotNull(path) { EmptyPath }
ensure(path.isEmpty()) { EmptyPath }
}
最后,如果路径没有问题,我们可以把内容读取出来, Effect 的成功内容可以定义为一个 Content ,并且对错误数据补充,函数如下所示:
@JvmInline
value class Content(val body: List<String>)
sealed interface FileError
@JvmInline value class SecurityError(val msg: String?) : FileError
@JvmInline value class FileNotFound(val path: String) : FileError
object EmptyPath : FileError {
override fun toString() = "EmptyPath"
}
fun readFile(path: String?): Effect<FileError, Content> = effect {
ensureNotNull(path) { EmptyPath }
ensure(path.isNotEmpty()) { EmptyPath }
try {
val lines = File(path).readLines()
Content(lines)
} catch (e: FileNotFoundException) {
shift(FileNotFound(path))
} catch (e: SecurityException) {
shift(SecurityError(e.message))
}
}
验证:
readFile("").toEither() shouldBe Either.Left(EmptyPath)
readFile("knit.properties").toValidated() shouldBe Validated.Invalid(FileNotFound("knit.properties"))
readFile("gradle.properties").toIor() shouldBe Ior.Left(FileNotFound("gradle.properties"))
readFile("README.MD").toOption { None } shouldBe None
像 toEither 、 toValidataed 这些就是定义的一些扩展函数,比较简单的,你也可以自定义
5.2 处理异常
Effect 定义了协议异常处理,这和别的异常处理框架相似,都有像 handleError 、 handleErrorWith 、redeem 函数,如下
val failed: Effect<String, Int> =
effect { shift("failed") }
val resolved: Effect<Nothing, Int> =
failed.handleError { it.length }
val newError: Effect<List<Char>, Int> =
failed.handleErrorWith { str ->
effect { shift(str.reversed().toList()) }
}
val redeemed: Effect<Nothing, Int> =
failed.redeem({ str -> str.length }, ::identity)
val captured: Effect<String, Result<Int>> =
effect<String, Int> { 1 }.attempt()
suspend fun main() {
failed.toEither() shouldBe Either.Left("failed")
resolved.toEither() shouldBe Either.Right(6)
newError.toEither() shouldBe Either.Left(listOf('d', 'e', 'l', 'i', 'a', 'f'))
redeemed.toEither() shouldBe Either.Right(6)
captured.toEither() shouldBe Either.Right(Result.success(1))
}
5.3 配合 withContext
有了 Effect 后,我们可以将其运用到各种使用到协程的场合了,例如
suspend fun main() {
val exit = CompletableDeferred<ExitCase>()
effect<FileError, Int> {
withContext(Dispatchers.IO) {
val job = launch { awaitExitCase(exit) }
val content = readFile("failure").bind()
job.join()
content.body.size
}
}.fold({ e -> e shouldBe FileNotFound("failure") }, { fail("Int can never be the result") })
exit.await().shouldBeInstanceOf<ExitCase>()
}
这里不再介绍 Effect,大家有兴趣可以去看官方文档。
总结
- Monad 来源于数学,发展于FP, 在实际工程中,它指的是一个工作流模型,能够对源数据进行操作,最终输出结果。
Result 、Either 、Option 都能体现 Monad 的思想Effect 是 Arrow 框架对 Monad 的定义的接口,可以通过实现该接口来达到达到 Monad
参考
Kotlin 版图解 Functor、Applicative 与 Monad 函数式编程(四):函数组合、函子 幺半群 详解函数式编程之Monad 范畴
|