Kotlin 版圖解 Functor、Applicative 與 Monad

本文也發在我的個人博客上:Kotlin 版圖解 Functor、Applicative 與 Monad。

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

這是一個簡單的值:

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

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

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

sealed class Maybe<out T> {n object `Nothing#` : Maybe<Nothing>() {n override fun toString(): String = "Nothing#"n }n data class Just<out T>(val value: T) : Maybe<T>()n}n

很快我們就會看到將函數應用到 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 }nJust(value=5)n

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

究竟什麼是 Functor 呢?

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

在 Kotlin 中,可以認為 Functor 是一種定義了 fmap 方法/擴展函數的類型。 以下是 fmap 的工作原理:

所以我們可以這麼做:

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

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

fun <T, R> Maybe<T>.fmap(transform: (T) -> R): Maybe<R> = when(this) {n Maybe.`Nothing#` -> Maybe.`Nothing#`n is Maybe.Just -> Maybe.Just(transform(this.value))n}n

當我們寫 Maybe.Just(2).fmap { it + 3 } 時,這是幕後發生的事情:

那麼然後,就像這樣,fmap,請將 it + 3 應用到 Nothing# 上如何?

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

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

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

post = Post.find_by_id(1)nif postn return post.titlenelsen return nilnendn

而在 Kotlin 中:

findPost(1).fmap(::getPostTitle)n

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

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

infix fun <T, R> ((T) -> R).`($)`(maybe: Maybe<T>) = maybe.fmap(this)nn::getPostTitle `($)` findPost(1)n

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

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

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

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

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

這是一個函數:

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

其結果是又一個函數!

> fun <T, U, R> ((T) -> U).fmap(transform: (U) -> R) = { t: T -> transform(this(t)) }n> val foo = {x: Int -> x + 2}.fmap {x: Int -> x + 3}n> foo(10)n15n

所以函數也是 functor! 對一個函數使用 fmap,其實就是函數組合!

Applicative

Applicative 又提升了一個層次。 對於 Applicative,我們的值像 Functor 一樣包裝在一個上下文中:

但是我們的函數也包裝在一個上下文中!

嗯。 我們繼續深入。 Applicative 並沒有開玩笑。 Applicative 定義了 (*)(在 Haskell 中是 <*>),它知道如何將一個 包裝在上下文中的 函數應用到一個 包裝在上下文中的 值上:

即:

infix fun <T, R> Maybe<(T) -> R>.`(*)`(maybe: Maybe<T>): Maybe<R> = when(this) {n Maybe.`Nothing#` -> Maybe.`Nothing#`n is Maybe.Just -> this.value `($)` mayben}nnMaybe.Just {x: Int -> x + 3} `(*)` Maybe.Just(2) == Maybe.Just(5)n

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

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

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

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

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

> {y: Int -> {x: Int -> x + y}} `($)` Maybe.Just(5)nJust(value=(kotlin.Int) -> kotlin.Int) // 等於 `Maybe.Just {x: Int -> x + 5}n> Maybe.Just {x: Int -> x + 5} `($)` Maybe.Just(4)n錯誤 ??? 這究竟是什麼意思,這個函數為什麼包裝在 JUST 中?n

Applicative:

> {y: Int -> {x: Int -> x + y}} `($)` Maybe.Just(5)nJust(value=(kotlin.Int) -> kotlin.Int) // 等於 `Maybe.Just {x: Int -> x + 5}n> Maybe.Just {x: Int -> x + 5} `(*)` Maybe.Just(3)nJust(value=8)n

Applicative 把 Functor 推到一邊。 「大人物可以使用具有任意數量參數的函數,」它說。 「裝備了 ($) 與 (*) 之後,我可以接受具有任意個數未包裝值參數的任意函數。 然後我傳給它所有已包裝的值,而我會得到一個已包裝的值出來! 啊啊啊啊啊!」

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

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

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

並使用 liftA2 做同樣事情:

> {x: Int, y: Int -> x * y}.liftA2(Maybe.Just(5), Maybe.Just(3))nJust(value=15)n

Monad

如何學習 Monad 呢:

  1. 取得計算機科學博士學位。
  2. 然後把它扔掉,因為在本節中你並不需要!

Monad 增加了一個新的轉變。

Functor 將一個函數應用到一個已包裝的值上:

Applicative 將一個已包裝的函數應用到一個已包裝的值上:

Monad 將一個返回已包裝值的函數應用到一個已包裝的值上。 Monad 有一個函數 ))=(在 Haskell 中是 >>=,讀作「綁定」)來做這個。

讓我們來看個示例。 老搭檔 Maybe 是一個 monad:

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

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

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

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

以下是它的工作方式:

> Maybe.Just(3) `))=` ::halfnNothing#n> Maybe.Just(4) `))=` ::halfnJust(value=2)n> Maybe.`Nothing#` `))=` ::halfnNothing#n

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

class Monad m wheren (>>=) :: m a -> (a -> m b) -> m bn

其中 >>= 是:

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

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

所以 Maybe 是一個 Monad:

infix fun <T, R> Maybe<T>.`))=`(f: ((T) -> Maybe<R>)): Maybe<R> = when(this) {n Maybe.`Nothing#` -> Maybe.`Nothing#`n is Maybe.Just -> f(this.value)n}n

這是與 Just(3) 互動的情況!

如果傳入一個 Nothing# 就更簡單了:

你還可以將這些調用串聯起來:

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

註: Kotlin 內置的空安全語法可以提供類似 monad 的操作,包括鏈式調用:

fun Int?.half() = this?.let {n if (this % 2 == 0) this / 2 else nulln}nnval n: Int? = 20nn?.half()?.half()?.half()n

太酷了! 於是現在我們知道 Maybe 既是 Functor 、又是 Applicative 還是 Monad。

現在我們來看看另一個例子:IO monad:

註: 由於 Kotlin 並不區分純函數與非純函數,因此根本不需要 IO monad。 這只是一個模擬:

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

具體來看三個函數。 getLine 沒有參數並會獲取用戶輸入:

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

readFile 接受一個字元串(文件名)並返回該文件的內容:

typealias FilePath = Stringnnfun readFile(filename: FilePath): IO<String> = IO(File(filename).readText()) n

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

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

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

getLine() `))=` ::readFile `))=` ::putStrLnn

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

foo = don filename <- getLinen contents <- readFile filenamen putStrLn contentsn

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

fun <T> `do` (ioOperations: () -> IO<T>) = ioOperations()nnval foo = `do` {n val filename = getLine().`(-`n val contents = readFile(filename).`(-`n putStrLn(contents)n}n

結論

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


推薦閱讀:

為什麼這段 Haskell 代碼比 C 慢那麼多?
搭建 Emacs 的 Haskell/Idris 環境教程
Haskell 的 Typeclass 怎麼理解?
為什麼haskell里需要monoid?
為什麼 Haskell 始終沒法流行呢?

TAG:Kotlin | Haskell | monad |