Kotlin 版圖解 Functor、Applicative 與 Monad

jywhltj發表於2017-08-28

本文是從 Haskell 版 Functors, Applicatives, And Monads In Pictures 翻譯而來的 Kotlin 版。 我同時翻譯了中英文兩個版本,英文版在這裡

從 Swift 版翻譯而來的 Kotlin 版不同的是,本文是直接從 Haskell 版原文翻譯而來的。

這是一個簡單的值:

我們也知道如何將一個函式應用到這個值上:

這很簡單。 那麼擴充套件一下,我們說任何值都可以放到一個上下文中。 現在你可以把上下文想象為一個可以在其中裝進值的盒子:

現在,將一個函式應用到這個值上時,會根據上下文的不同而得到不同的結果。 這就是 Functor、 Applicative、 Monad、 Arrow 等概念的基礎。 Maybe 資料型別定義了兩種相關上下文:

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 如下:

> Maybe.Just(2).fmap { it + 3 }
Just(value=5)

嘭! fmap 向我們展示了它的成果。 但是 fmap 怎麼知道如何應用該函式的呢?

究竟什麼是 Functor 呢?

在 Haskell 中 Functor 是一個型別類。 其定義如下:

在 Kotlin 中,可以認為 Functor 是一種定義了 fmap 方法/擴充套件函式的型別。 以下是 fmap 的工作原理:

所以我們可以這麼做:

> Maybe.Just(2).fmap { it + 3 }
Just(value=5)

fmap 神奇地應用了這個函式,因為 Maybe 是一個 Functor。 它指定了 fmap 如何應用到 Just 上與 Nothing# 上:

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# 上如何?

> Maybe.`Nothing#`.fmap { x: Int -> x + 3 }
Nothing#

注: 這裡該 lambda 表示式的引數必須顯式標註型別,因為 Kotlin 中有很多型別可以與整數(Int)相加。

Bill O'Reilly 可是完全不瞭解 Maybe functor 哦

就像《黑客帝國》中的 Morpheus,fmap 知道都要做什麼;如果你從 Nothing# 開始,那麼你會以 Nothing# 結束! fmap 是禪道。 現在它告訴我們了 Maybe 資料型別存在的意義。 例如,這是在一個沒有 Maybe 的語言中處理一個資料庫記錄的方式:

post = Post.find_by_id(1)
if post
    return post.title
else
    return nil
end

而在 Kotlin 中:

findPost(1).fmap(::getPostTitle)

如果 findPost 返回一篇文章,我們就會通過 getPostTitle 獲取其標題。 如果它返回 Nothing#,我們就也返回 Nothing#! 非常簡潔,不是嗎?

我們還可以為 fmap 定義一箇中綴操作符 ($)(在 Haskell 中是 <$>),並且這樣更常見:

infix fun <T, R> ((T) -> R).`($)`(maybe: Maybe<T>) = maybe.fmap(this)

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

再看一個示例:如果將一個函式應用到一個 Iterable(Haksell 中是 List)上會發生什麼?

Iterable 也是 functor! 我們可以為其定義 fmap 如下:

fun <T, R> Iterable<T>.fmap(transform: (T) -> R): List<R> = this.map(transform)

好了,好了,最後一個示例:如果將一個函式應用到另一個函式上會發生什麼?

{x: Int - > x + 1}.fmap {x: Int -> x + 3}

這是一個函式:

這是一個應用到另一個函式上的函式:

其結果是又一個函式!

> 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 中是 <*>),它知道如何將一個 包裝在上下文中的 函式應用到一個 包裝在上下文中的 值上:

即:

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)

使用 (*) 可能會帶來很多有趣的情況。 例如:

infix fun <T, R>  Iterable<(T) -> R>.`(*)`(iterable: Iterable<T>) =
    this.flatMap { iterable.map(it) }

有了這個定義,我們可以將一個函式列表應用到一個值列表上:

> listOf<(Int) -> Int>({it * 2}, {it + 3}) `(*)` listOf(1, 2, 3)
[2, 4, 6, 4, 5, 6]

這裡有 Applicative 能做到而 Functor 不能做到的事情。 如何將一個接受兩個引數的函式應用到兩個已包裝的值上?

> {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:

> {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 推到一邊。 “大人物可以使用具有任意數量引數的函式,”它說。 “裝備了 ($)(*) 之後,我可以接受具有任意個數未包裝值引數的任意函式。 然後我傳給它所有已包裝的值,而我會得到一個已包裝的值出來! 啊啊啊啊啊!”

> {y: Int -> {x: Int -> x + y}} `($)` Maybe.Just(5) `(*)` Maybe.Just(3)
Just(value=15)

我們也可以定義另一個 Applicative 的函式 liftA2

fun <T> ((x: T, y: T) -> T).liftA2(m1: Maybe<T>, m2: Maybe<T>) =
    {y: T -> {x: T -> this(x, y)}} `($)` m1 `(*)` m2

並使用 liftA2 做同樣事情:

> {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:

正是閒逛的 monad

假設 half 是一個只適用於偶數的函式:

fun half(x: Int) = if (x % 2 == 0)
                       Maybe.Just(x / 2)
                   else
                       Maybe.`Nothing#`

如果我們餵給它一個已包裝的值呢?

我們需要使用 ))= 來將我們已包裝的值塞進該函式。 這是 ))= 的照片:

以下是它的工作方式:

> Maybe.Just(3) `))=` ::half
Nothing#
> Maybe.Just(4) `))=` ::half
Just(value=2)
> Maybe.`Nothing#` `))=` ::half
Nothing#

內部發生了什麼? Monad 是 Haskell 中的另一個型別類。 這是它(在 Haskell 中)的定義的片段:

class Monad m where
    (>>=) :: m a -> (a -> m b) -> m b

其中 >>= 是:

在 Kotlin 中,可以認為 Monad 是一種定義了這樣中綴函式的型別:

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

所以 Maybe 是一個 Monad:

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# 就更簡單了:

你還可以將這些呼叫串聯起來:

> Maybe.Just(20) `))=` ::half `))=` ::half `))=` ::half
Nothing#

注: Kotlin 內建的空安全語法可以提供類似 monad 的操作,包括鏈式呼叫:

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。 這只是一個模擬:

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

具體來看三個函式。 getLine 沒有引數並會獲取使用者輸入:

fun getLine(): IO<String> = IO(readLine() ?: "")

readFile 接受一個字串(檔名)並返回該檔案的內容:

typealias FilePath = String

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

putStrLn 接受一個字串並輸出之:

fun putStrLn(str: String): IO<Unit> = IO(println(str))

所有這三個函式都接受普通值(或無值)並返回一個已包裝的值。 我們可以使用 ))= 將它們串聯起來!

getLine() `))=` ::readFile `))=` ::putStrLn

太棒了! 前排佔座來看 monad 展示! Haskell 還為我們提供了名為 do 表示法的語法糖:

foo = do
    filename <- getLine
    contents <- readFile filename
    putStrLn contents

它可以在 Kotlin 中模擬(其中 Haskell 的 <- 操作符被替換為 (- 屬性與賦值操作)如下:

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 以及 MnO2Fleurer《Haskell 趣學指南》中文版譯者致謝。

本文也發在我的個人部落格上:https://hltj.me/kotlin/2017/08/25/kotlin-functor-applicative-monad-cn.html

相關文章