Kotlin版图解Functor、Applicative与Monad

2020-02-20 13:14:56 浏览数 (3)

本文是从 Haskell 版 Functors, Applicatives, And Monads In Pictures 翻译而来的 Kotlin 版。 我同时翻译了中英文两个版本,英文版在这里。 与从 Swift 版翻译而来的 Kotlin 版不同的是,本文是直接从 Haskell 版原文翻译而来的。

这是一个简单的值:

我们也知道如何将一个函数应用到这个值上:

这很简单。 那么扩展一下,我们说任何值都可以放到一个上下文中。 现在你可以把上下文想象为一个可以在其中装进值的盒子:

现在,将一个函数应用到这个值上时,会根据上下文的不同而得到不同的结果。 这就是 Functor、 Applicative、 Monad、 Arrow 等概念的基础。 Maybe 数据类型定义了两种相关上下文:

代码语言:javascript复制
sealed class Maybe<out T> {
   object `Nothing#` : Maybe<Nothing>() {
       override fun toString(): String = "Nothing#"
   }
   data class Just<out T>(val value: T) : Maybe<T>()
}

很快我们就会看到将函数应用到 Just<T>上 还是应用到 Nothing# 上会有多么不同。 首先我们来说说 Functor 吧!

注: 这里用 Nothing# 取代原文的 Nothing,因为在 Kotlin 中 Nothing 是一个特殊类型,参见 Nothing 类型。 另外 Kotlin 有自己的表达可选值的方式,并非使用 Maybe 类型这种方式,参见空安全。

Functor

当一个值被包装在上下文中时,你无法将一个普通函数应用给它:

这就轮到 fmap 出场了。 fmap 翩翩而来,从容应对上下文。 fmap 知道如何将函数应用到包装在上下文中的值上。 例如,你想将 {it 3} 应用到 Just(2)上。使用 fmap 如下:

代码语言:javascript复制
> Maybe.Just(2).fmap { it   3 }
Just(value=5)

嘭! fmap 向我们展示了它的成果。 但是 fmap 怎么知道如何应用该函数的呢?

究竟什么是 Functor 呢?

在 Haskell 中 Functor 是一个类型类。 其定义如下:

在 Kotlin 中,可以认为 Functor 是一种定义了 fmap 方法/扩展函数的类型。 以下是 fmap 的工作原理:

所以我们可以这么做:

代码语言:javascript复制
> Maybe.Just(2).fmap { it   3 }
Just(value=5)

fmap 神奇地应用了这个函数,因为 Maybe 是一个 Functor。 它指定了 fmap 如何应用到 Just 上与 Nothing# 上:

代码语言:javascript复制
fun <T, R> Maybe<T>.fmap(transform: (T) -> R): Maybe<R> =
    when(this) {
       Maybe.`Nothing#` -> Maybe.`Nothing#`
       is Maybe.Just -> Maybe.Just(transform(this.value))
    }

当我们写 Maybe.Just(2).fmap { it 3 } 时,这是幕后发生的事情:

那么然后,就像这样,fmap,请将 it 3 应用到 Nothing# 上如何?

代码语言:javascript复制
> Maybe.`Nothing#`.fmap { x: Int -> x   3 }
Nothing#

注: 这里该 lambda 表达式的参数必须显式标注类型,因为 Kotlin 中有很多类型可以与整数(Int)相加。

就像《黑客帝国》中的 Morpheus,fmap 知道都要做什么;如果你从 Nothing# 开始,那么你会以 Nothing# 结束! fmap 是禅道。 现在它告诉我们了 Maybe 数据类型存在的意义。 例如,这是在一个没有 Maybe 的语言中处理一个数据库记录的方式:

代码语言:javascript复制
post = Post.find_by_id(1)
if post
   return post.title
else
   return nil
end

而在 Kotlin 中:

代码语言:javascript复制
findPost(1).fmap(::getPostTitle)

如果 findPost 返回一篇文章,我们就会通过 getPostTitle 获取其标题。 如果它返回 Nothing#,我们就也返回 Nothing#! 非常简洁,不是吗?

我们还可以为 fmap 定义一个中缀操作符 ($)(在 Haskell 中是 <$>),并且这样更常见:

代码语言:javascript复制
infix fun <T, R> ((T) -> R).`($)`(maybe: Maybe<T>) =
    maybe.fmap(this)

::getPostTitle `($)` findPost(1)

再看一个示例:如果将一个函数应用到一个 Iterable(Haksell 中是 List)上会发生什么?

Iterable 也是 functor! 我们可以为其定义 fmap 如下:

代码语言:javascript复制
fun <T, R> Iterable<T>.fmap(transform: (T) -> R): List<R> =
    this.map(transform)

好了,好了,最后一个示例:如果将一个函数应用到另一个函数上会发生什么?

代码语言:javascript复制
{x: Int - > x   1}.fmap {x: Int -> x   3}

这是一个函数:

这是一个应用到另一个函数上的函数:

其结果是又一个函数!

代码语言:javascript复制
> fun <T, U, R> ((T) -> U).fmap(transform: (U) -> R) =
    { t: T -> transform(this(t)) }
> val foo = {x: Int -> x   2}.fmap {x: Int -> x   3}
> foo(10)
15

所以函数也是 functor! 对一个函数使用 fmap,其实就是函数组合!

Applicative

Applicative 又提升了一个层次。 对于 Applicative,我们的值像 Functor 一样包装在一个上下文中:

但是我们的函数也包装在一个上下文中!

嗯。 我们继续深入。 Applicative 并没有开玩笑。 Applicative 定义了 (*)(在 Haskell 中是 <*>),它知道如何将一个 包装在上下文中的 函数应用到一个 包装在上下文中的 值上:

即:

代码语言:javascript复制
infix fun <T, R>  Maybe<(T) -> R>.`(*)`(maybe: Maybe<T>): Maybe<R> =
    when(this) {
       Maybe.`Nothing#` -> Maybe.`Nothing#`
       is Maybe.Just -> this.value `($)` maybe
    }

Maybe.Just {x: Int -> x   3} `(*)` Maybe.Just(2) == Maybe.Just(5)

使用 (*) 可能会带来很多有趣的情况。 例如:

代码语言:javascript复制
infix fun <T, R>  Iterable<(T) -> R>.`(*)`(iterable: Iterable<T>) =
    this.flatMap { iterable.map(it) }

有了这个定义,我们可以将一个函数列表应用到一个值列表上:

代码语言:javascript复制
> listOf<(Int) -> Int>({it * 2}, {it   3}) `(*)` listOf(1, 2, 3)
[2, 4, 6, 4, 5, 6]

这里有 Applicative 能做到而 Functor 不能做到的事情。 如何将一个接受两个参数的函数应用到两个已包装的值上?

代码语言:javascript复制
> {y: Int -> {x: Int -> x   y}} `($)` Maybe.Just(5)
Just(value=(kotlin.Int) -> kotlin.Int) // 等于 `Maybe.Just {x: Int -> x   5}`
> Maybe.Just {x: Int -> x   5} `($)` Maybe.Just(4)
错误 ??? 这究竟是什么意思,这个函数为什么包装在 JUST 中?

Applicative:

代码语言:javascript复制
> {y: Int -> {x: Int -> x   y}} `($)` Maybe.Just(5)
Just(value=(kotlin.Int) -> kotlin.Int) // 等于 `Maybe.Just {x: Int -> x   5}`
> Maybe.Just {x: Int -> x   5} `(*)` Maybe.Just(3)
Just(value=8)

ApplicativeFunctor 推到一边。 “大人物可以使用具有任意数量参数的函数,”它说。 “装备了 ($)(*) 之后,我可以接受具有任意个数未包装值参数的任意函数。 然后我传给它所有已包装的值,而我会得到一个已包装的值出来! 啊啊啊啊啊!”

代码语言:javascript复制
> {y: Int -> {x: Int -> x   y}} `($)` Maybe.Just(5) `(*)` Maybe.Just(3)
Just(value=15)

我们也可以定义另一个 Applicative 的函数 liftA2

代码语言:javascript复制
fun <T> ((x: T, y: T) -> T).liftA2(m1: Maybe<T>, m2: Maybe<T>) =
   {y: T -> {x: T -> this(x, y)}} `($)` m1 `(*)` m2

并使用 liftA2 做同样事情:

代码语言:javascript复制
> {x: Int, y: Int -> x * y}.liftA2(Maybe.Just(5), Maybe.Just(3))
Just(value=15)

Monad

如何学习 Monad 呢:

  1. 取得计算机科学博士学位。
  2. 然后把它扔掉,因为在本节中你并不需要!

Monad 增加了一个新的转变。

Functor 将一个函数应用到一个已包装的值上:

Applicative 将一个已包装的函数应用到一个已包装的值上:

Monad 将一个返回已包装值的函数应用到一个已包装的值上。 Monad 有一个函数 ))=(在 Haskell 中是 >>=,读作“绑定”)来做这个。

让我们来看个示例。 老搭档 Maybe 是一个 monad:

假设 half 是一个只适用于偶数的函数:

代码语言:javascript复制
fun half(x: Int) = if (x % 2 == 0)
                      Maybe.Just(x / 2)
                  else
                      Maybe.`Nothing#`

如果我们喂给它一个已包装的值呢?

我们需要使用 ))= 来将我们已包装的值塞进该函数。 这是 ))= 的照片:

以下是它的工作方式:

代码语言:javascript复制
> Maybe.Just(3) `))=` ::half
Nothing#
> Maybe.Just(4) `))=` ::half
Just(value=2)
> Maybe.`Nothing#` `))=` ::half
Nothing#

内部发生了什么? Monad 是 Haskell 中的另一个类型类。 这是它(在 Haskell 中)的定义的片段:

代码语言:javascript复制
class Monad m where
   (>>=) :: m a -> (a -> m b) -> m b

其中 >>= 是:

在 Kotlin 中,可以认为 Monad 是一种定义了这样中缀函数的类型:

代码语言:javascript复制
infix fun <T, R> Monad<T>.`))=`(f: ((T) -> Monad<R>)): Monad<R>

所以 Maybe 是一个 Monad:

代码语言:javascript复制
infix fun <T, R> Maybe<T>.`))=`(f: ((T) -> Maybe<R>)): Maybe<R> =
    when(this) {
       Maybe.`Nothing#` -> Maybe.`Nothing#`
       is Maybe.Just -> f(this.value)
    }

这是与 Just(3) 互动的情况!

如果传入一个 Nothing# 就更简单了:

你还可以将这些调用串联起来:

代码语言:javascript复制
> Maybe.Just(20) `))=` ::half `))=` ::half `))=` ::half
Nothing#

注: Kotlin 内置的空安全语法可以提供类似 monad 的操作,包括链式调用:

代码语言:javascript复制
fun Int?.half() = this?.let {
   if (this % 2 == 0) this / 2 else null
}

val n: Int? = 20
n?.half()?.half()?.half()

太酷了! 于是现在我们知道 Maybe 既是 Functor 、又是 Applicative 还是 Monad

现在我们来看看另一个例子:IO monad:

注: 由于 Kotlin 并不区分纯函数与非纯函数,因此根本不需要 IO monad。 这只是一个模拟:

代码语言:javascript复制
data class IO<out T>(val `(-`: T)

infix fun <T, R> IO<T>.`))=`(f: ((T) -> IO<R>)): IO<R> =
    f(this.`(-`)

具体来看三个函数。 getLine 没有参数并会获取用户输入:

代码语言:javascript复制
fun getLine(): IO<String> = IO(readLine() ?: "")

readFile 接受一个字符串(文件名)并返回该文件的内容:

代码语言:javascript复制
typealias FilePath = String

fun readFile(filename: FilePath): IO<String> =
    IO(File(filename).readText())

putStrLn 接受一个字符串并输出之:

代码语言:javascript复制
fun putStrLn(str: String): IO<Unit> = IO(println(str))

所有这三个函数都接受普通值(或无值)并返回一个已包装的值。 我们可以使用 ))= 将它们串联起来!

代码语言:javascript复制
getLine() `))=` ::readFile `))=` ::putStrLn

太棒了! 前排占座来看 monad 展示! Haskell 还为我们提供了名为 do 表示法的语法糖:

代码语言:javascript复制
foo = do
   filename <- getLine
   contents <- readFile filename
   putStrLn contents

它可以在 Kotlin 中模拟(其中 Haskell 的 <- 操作符被替换为 (- 属性与赋值操作)如下:

代码语言:javascript复制
fun <T> `do` (ioOperations: () -> IO<T>) = ioOperations()

val foo = `do` {
 val filename = getLine().`(-`
 val contents = readFile(filename).`(-`
 putStrLn(contents)
}

结论

  1. (Haskell 中的)functor 是实现了 Functor 类型类的数据类型。
  2. (Haskell 中的)applicative 是实现了 Applicative 类型类的数据类型。
  3. (Haskell 中的)monad 是实现了 Monad 类型类的数据类型。
  4. Maybe 实现了这三者,所以它是 functor、 applicative、 以及 monad。

这三者有什么区别呢?

  • functor: 可通过 fmap 或者 ($) 将一个函数应用到一个已包装的值上。
  • applicative: 可通过 (*) 或者 liftA 将一个已包装的函数应用到已包装的值上。
  • monad: 可通过 ))= 或者 liftM 将一个返回已包装值的函数应用到已包装的值上。

所以,亲爱的朋友(我觉得我们现在是朋友了),我想我们都同意 monad 是一个简单且高明的主意(译注:原文是 SMART IDEA(tm))。 现在你已经通过这篇指南润湿了你的口哨,为什么不拉上 Mel Gibson 并抓住整个瓶子呢。 请参阅《Haskell 趣学指南》的《来看看几种 Monad》。 其中包含很多我已经炫耀过的东西,因为 Miran 深入这些方面做的非常棒。

译注:Miran 即 Miran Lipovača 是《Haskell 趣学指南》英文原版 Learn You a Haskell 的作者。

在此向 Functors, Applicatives, And Monads In Pictures 原作者 Aditya Bhargava 致谢, 向 Learn You a Haskell 作者 Miran Lipovača 以及 MnO2、Fleurer 等《Haskell 趣学指南》中文版译者致谢。

© 2017 灰蓝天际


0 人点赞