0
点赞
收藏
分享

微信扫一扫

Kotlin Monad的学习


文章目录

  • ​​0. 前言​​
  • ​​1. 一些数学概念(该小节可以跳过)​​
  • ​​1.1 半群 与 幺半群​​
  • ​​1.2 范畴、态射 与 同态​​
  • ​​1.3 函子 与 自函子​​
  • ​​1.4 Monad​​
  • ​​2. Monad 的一个模型​​
  • ​​3. Kotlin 中的 Monad​​
  • ​​3.1 一段代码​​
  • ​​3.2 考虑异常情况​​
  • ​​3.3 并行回调​​
  • ​​3.4 抽象工作流​​
  • ​​3.5 Monad​​
  • ​​4. Option、Either、Result​​
  • ​​5. Effect --- 协程版本的 Either​​
  • ​​5.1 简单示例​​
  • ​​5.2 处理异常​​
  • ​​5.3 配合 withContext​​
  • ​​总结​​
  • ​​参考​​

Kotlin Monad的学习_kotlin

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, )

半群有一个延展概念 ---- 幺半群(monoid),是一个存在单位元(幺元)的半群。 它除了满足半群的特性,还自带了另一个特性:

对于半群 (S, )
如果存在一个 e S, 使得 a e = e a = a
称三元组 (S, , e)

举个例子,比如​​(N+, * , 1)​​​ 就是一个幺半群, 范围是正整数,二元运算是
乘法操作满足结合律,而且任意正整数乘1都等于本身, 同理还有 ​​​(N, + , 0)​​ 这些。

1.2 范畴、态射 与 同态

范畴可以用下面有向图表示:

Kotlin Monad的学习_封装_02


A、B、C分别是一个对象,它们通过箭头,组成了一个范畴。

范畴(category) 是一种包含了对象及对象之间箭头的代数结构, 范畴满足三个特性:

  1. 对象之间的箭头可以复合
    例如有​​​f:A -> B​​​ 、​​g: B -> C​​​,那么它们可以复合成:​​g ○ f: A -> C​
  2. 对象的箭头复合是满足结合律的
    例如有​​​f:A -> B​​​ 、​​g: B -> C​​​、​​h: C -> D​​​ ,满足 :​​(f ○ g) ○ h = f ○ (g ○ h)​
  3. 每个对象都有自己一个单位箭头
    就是每个对象都有一个单位元素,简单来说,在对象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 抽象成一个模型:一个盒子

  1. 这个盒子里面可以装有对象
  2. 这个盒子也可以是空的,但并不是什么都没有的空,而是有一个 unit 单位值 (单位律的体现), 这种现象叫业务空值, 例如:乘法里的1, 加法里的0
  3. 这个盒子可以输入一个函数进去, 它能作用到盒子里面的对象去, 函数的作用无非是 A -> B,所以盒子里面的对象会被作用,然后将结果输出出来
  4. 这个盒子不仅可以输入一个函数, 还可以输入若干个函数进去,函数会复合(结合律的体现)然后应用到盒子里的对象上,最终输出一个结果

薛定谔的猫,大家应该是耳熟能详了,我们只知道这个盒子里面装了一个类型的对象,但是不知道这个对象具体是什么,我们对这个盒子施加了多个操作,最后它定能输出一个结果给我们。

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

我们的函数是输入一个 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>
}

可以用下图概括:

Kotlin Monad的学习_封装_03


然后我们获取 city 的代码可以写成:

fun workflow(speaker: Speaker): WorkflowThatReturns<City> {
return
speaker
.nextTalk()
.addStep { x -> x.getConference() }
.addStep { x -> x.getCity() }
}

我们通过两次 addStep ,在 step 中一次调用了 ​​getConference​​​ 和 ​​getCity​​,最终获取 City 的包装类。

如下图所示:

Kotlin Monad的学习_数据_04

3.5 Monad

在 FP 工程环境中, 上面这种工作流模式就是 Monad!。这和我们第二节提到的盒子模型类似,最初它只是一个类型数据,然后通过一些函数操作,最终可以得到任意类型的结果数据。

4. Option、Either、Result

当我们解开了 Monad 的面纱,我们会发现它并不难理解,我们甚至能在代码中找到它的身影。

​Option​​​、​​Either​​​、​​Result​​​ 都能体现出 Monad !不了解的同学可以看下之前的文章:​​Kotlin 异常处理之 Option、Either、Result​​

对于 Option 来说,它封装了一个数据, 这个数据可能是 ​​有值​​​ 或者 ​​无值​​​, Option 可以处理很多事情, 例如 ​​map​​​、 ​​flatmap​​​、​​filter​​, 它都体现了 Monad 的思想:

  1. 封装数据到一个计算环境中, 外界能够输入函数,对“盒子”中的数据进行计算,最后得到结果
  2. 它内部对异常进行处理, 在使用 Option 时,不会产生异常,所以它屏蔽了 Exception 这个副作用
  3. 提供了 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类型:

// 泛型<R> 用于表示异常, 泛型A 用于表示成功 。
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 { // 1、2
if (path.isEmpty()) shift(EmptyPath) // 3
else Unit
}

代码解析:

  1. ​readFile​​​ 接收一个 String,返回一个 Effect 类型, 失败时是一个​​EmptyPath​​ 类型,成功则是 Unit
  2. 使用​​effect{...}​​​ 来构造,它是实现​​Effect​​ 的函数体,便于我们创建 Effect
  3. ​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 } // 当 path 为空时,会调用代码块里面产生一个 Error 的数据
ensure(path.isEmpty()) { EmptyPath } // 当 path.isEmpty 为 true 时,会调用代码块里面产生一个 Error 的数据
}

最后,如果路径没有问题,我们可以把内容读取出来, 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))
}
}

验证:

// 这里的 shoubleBe 使用到了 Kotest
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 }

像 ​​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() // 如果shift 被调用,会取消 withContext
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​​​​范畴​​


举报

相关推荐

0 条评论