本文是經典的Functors, Applicatives, And Monads In Pictures的Typescript翻譯版本。
Functor/Applicative/Monad是函數語言程式設計中的一些比較‘基礎’的概念,反正我是不認同‘基礎’這個說法的,筆者也閱讀過很多類似介紹Monad的文章,最後都不了了之,這些概念是比較難以理解的,而且平時程式設計實踐中也很難會接觸到這些東西。
後來拜讀了Functors, Applicatives, And Monads In Pictures, 不錯,好像懂了。於是自己想通過翻譯,再深入消化消化這篇文章,這裡使用Typescript
作為描述語言,對於前端來說會更好理解。
有理解不正確的地方,敬請指正. 開始吧!
這是一個簡單的值:
例如這些
1 // number
'string' // string
複製程式碼
大家都知道怎麼將一個函式應用到這個值上面:
// So easy
const add3 = (val: number) => val + 3
console.log(add3(2)) // 5
複製程式碼
很簡單了. 我們來擴充套件一下, 讓任意的值是可以包裝在一個**上下文(context)**當中. 現在的情況你可以想象一個可以把值放進去的盒子:
現在你把一個函式應用到這個包裝值的時候, 根據其上下文型別你會得到不同的結果. 這就是 Functor
, Applicative
, Monad
, Arrow
之類概念的基礎.
Maybe
就是一個典型的資料型別, 它定義了兩種相關的‘上下文’, Maybe本身也是一個‘上下文’(除了值,其他型別都可以是一個上下文?):
原文基於Haskell,它的Maybe型別有兩個上下文Just(藍色盒子)和None(紅色空盒子)。仿造Haskell在Typescript中我們可以使用可選型別(Maybe)
來表示:
type Maybe<T> = Just<T> | Nothing // Just 表示值‘存在’,Nothing表示空值,類似於null、undefined的概念
複製程式碼
Just和Nothing的基本結構:
// 我們只用None來取代null, 這裡我們將None作為一個值,而不是一個類
export class None {}
// 對應None的型別
export type Nothing = typeof None
// 判斷是否是Nothing,這裡使用Typescript的 `Type Guards`
export const isNothing = (val: any): val is Nothing => {
return val === None
}
// Just型別
export class Just<T> {
static of<T>(val: T) {
return new Just(val)
}
value: T
constructor(value: T) {
this.value = value
}
}
複製程式碼
使用示例:
let a: Maybe<number>;
a = None;
a = Just.of(3);
複製程式碼
說實在這個實現有點挫, 但是為了更加貼近原文描述,暫且使用這個實現。之前考慮過的一個版本是下面這樣的, 因為無法給它們擴充套件方法,就放棄了這個方案:
type Optional<T> = NonNullable<T> | nul
let a: Optional<number> = 1;
a = null;
複製程式碼
很快我們會看到對一個 Just<a>
和一個 Nothing 來說, 函式應用有何不同. 首先我們來看看 Functor!
Functors
當一個值被包裝在一個上下文中時, 你就不能拿普通函式來應用了:
declare let a: Just<number>;
const add3 = (v: number) => v + 3
add3(a) // ❌ 型別“Just<number>”的引數不能賦給型別“number”的參
複製程式碼
這時候, 該 fmap
出場了. fmap 翩翩而來,從容應對上下文(fmap is from the street, fmap is hip to contexts). 還有誰? fmap 知道怎樣將一個函式應用到一個包裝在上下文的值上. 你可以對任何一個型別為 Functor 的型別使用 fmap, 換句話說,Functor都定義了fmap.
比如說, 想一下你想把 add3 應用到 Just 2. 用 fmap:
Just.of(2).fmap(add3) // Just 5
複製程式碼
?嘭! fmap 向我們展示了它的成果。 但是 fmap 怎麼知道如何應用該函式的呢?
究竟什麼是 Functor 呢?
在 Haskell 中 Functor
是一個型別類(typeclass)。 其定義如下:
在Typescript中, 一個Functor認為是定義了fmap的任意型別. 看看fmap
是如何工作的:
- 一個Functor型別的 fa, 例如Just 2
- fa 定義了一個fmap, fmap 接受一個函式fn,例如add3
- fmap 直到如何將fa應用到fn中, 返回一個Functor型別的 fb. fa和fb的包裝上下文型別一樣, 例如fa是Just, 那麼fb也是Just; 反之fa是Nothing,fb也是Nothing;
用Typescript的函式簽名描述一下:
<Functor T>.fmap<U>(fn: (val: T) => U): <Functor U>
複製程式碼
所以我們可以這麼做:
Just.of(2).fmap(add3) // Just 5
複製程式碼
而 fmap 神奇地應用了這個函式,因為 Maybe 是一個 Functor, 它指定了 fmap 如何應用到 Just 上與 Nothing 上:
class Just<T> {
// ...
// 實現fmap
fmap<U>(fn: (val: T) => U) {
return Just.of(fn(this.value))
}
}
class None {
// None 接受任何函式都返回None
static fmap(fn: any) {
return None
}
}
複製程式碼
當我們寫 Just.of(2).fmap(add3)
時,這是幕後發生的事情:
那麼然後,就像這樣,fmap,請將 add3 應用到 Nothing 上如何?
None.fmap(add3) // Nothing
複製程式碼
就像《黑客帝國》中的 Morpheus,fmap 知道都要做什麼;如果你從 Nothing 開始,那麼你會以 Nothing 結束! fmap 是禪。
現在它告訴我們了 Maybe 資料型別存在的意義。 例如,這是在一個沒有 Maybe 的語言中處理一個資料庫記錄的方式, 比如Javascript:
let post = Post.findByID(1)
if (post != null) {
return post.title
} else {
return null
}
複製程式碼
有了fmap後:
// 假設findPost返回Maybe<Article>
findPost(1).fmap(getPostTitle)
複製程式碼
如果 findPost 返回一篇文章,我們就會通過 getPostTitle 獲取其標題。 如果它返回 Nothing,我們就也返回 Nothing! 較之前簡潔了很多對吧?
Typescript有了Optional Chaining後,處理null也可以很簡潔:
findPost(1)?.title // 異曲同工
複製程式碼
原文還有定義了一個fmap的過載操作符版本,因為JavaScript不支援操作符過載,所以這裡簡單帶過
getPostTitle <$> findPost(1) // 使用操作符過載<$> 來簡化fmap. 等價於上面的程式碼
複製程式碼
再看一個示例:如果將一個函式應用到一個 Array(Haksell 中是 List)上會發生什麼?
Array 也是 functor!
[1, 2, 3].map(add3) // [4, 5, 6]. fa是Array,輸出fb也是Array,符合Functor的定義吧,所以Javascript的map就是fmap,Array就是Functor
複製程式碼
好了,好了,最後一個示例:如果將一個函式應用到另一個函式上會發生什麼?
const multiply3 = (v: number) => v * 3
const add3 = (v: number) => v + 3
add3.fmap(multiply3) // ❓
複製程式碼
這是一個函式:
這是一個應用到另一個函式上的函式:
其結果是又一個函式!
// 僅作示例,不要模仿
interface Function {
fmap<V, T, U>(this: (val: V) => T, fn: (val: T) => U): (val: V) => U
}
Function.prototype.fmap = function(fn) {
return v => fn(this(v))
}
複製程式碼
所以函式也是 Functor! 對一個函式使用 fmap,其實就是函式組合(compose)! 也就是說: f.fmap(g)
等價於 compose(f, g)
Functor總結
通過上面的例子,可以知道Functor其實並沒有那麼難以理解, 一個Functor就是:
<Functor T>.fmap(fn: (v: T) => U): <Functor U>
複製程式碼
Functor會定義一個‘fmap’操作,這個fmap接受一個函式fn,fn接收的是具體的值,返回另一個具體的值,例如上面的add3. fmap決定如何來應用fn到源Functor(a), 返回一個新的Functor(b)。 也就是fmap的源和輸出的值‘上下文’型別是一樣的。比如
Just -> fmap -> Just
Nothing -> fmap -> Nothing
Maybe -> fmap -> Maybe
Array -> fmap -> Array
Applicative
現在練到二重天了。 Applicative 又提升了一個層次。
對於 Applicative,我們的值依然和 Functor 一樣包裝在一個上下文中
不一樣的是,我們將Functor中的函式(例如add3)也包裝在一個上下文中!
嗯。 我們繼續深入。 Applicative 並沒有開玩笑。不像Haskell,Typescript並沒有內建方式來處理Applicative。我們可以給需要支援Applicative的型別定義一個apply函式。apply函式知道怎麼將包裝在上下文的函式
應用到一個包裝在上下文的值
:
class None {
static apply(fn: any) {
return None;
}
}
class Just<T> {
// 使用方法過載,讓Typescript更好推斷
// 如果值和函式都是Just型別,結果也是Just型別
apply<U>(fn: Just<(a: T) => U>): Just<U>;
// 如果函式是Nothing型別,結果是Nothing.
// 嚴格上apply只應該接收同一個上下文型別的函式,即Just,
// 因為Maybe是Typescript的Union型別,沒辦法給它擴充套件方法,這裡將Maybe和Just混在一起了
apply<U>(fn: Nothing): Nothing;
// 如果值和函式都是Maybe型別, 返回一個Maybe型別
apply<U>(fn: Maybe<(a: T) => U>): Maybe<U> {
if (!isNothing(fn)) {
return Just.of(fn.value(this.value));
}
return None.apply(fn);
}
}
複製程式碼
再來看看陣列:
// 僅作示例
interface Array<T> {
apply<U>(fns: Array<(e: T) => U>): U[]
}
// 接收一個函式‘陣列(上下文)’,返回一個應用了‘函式’的新的陣列
Array.prototype.apply = function<T, U>(fns: Array<(e: T) => U>) {
const res: U[] = []
for (const fn of fns) {
this.forEach(el => res.push(fn(el)))
}
return res
}
複製程式碼
在Haskell中,使用<*>
來表示apply操作: Just (+3) <*> Just 2 == Just 5
. Typescript不支援操作符過載,所以忽略.
Just型別的Applicative應用圖解:
陣列型別的Applicative應用圖解:
const num: number[] = [1, 2, 3]
console.log(num.apply([multiply2, add3]))
// [2, 4, 6, 4, 5, 6]
複製程式碼
這裡有 Applicative 能做到而 Functor 不能做到的事情。 如何將一個接受兩個引數的函式應用到兩個已包裝的值上?
// 一個支援兩個引數的Curry型加法函式
const curriedAddition = (a: number) => (b: number) => a + b
Just.of(5).fmap(curriedAddition) // 返回 `Just.of((b: number) => 5 + b)`
// Ok 繼續
Just.of(4).fmap(Just.of((b: number) => 5 + b)) // ❌不行了,報錯了,Functor沒辦法處理包裝在上下文的fn
複製程式碼
但是Applicative可以:
Just.of(5).fmap(curriedAddition) // 返回 `Just.of((b: number) => 5 + b)`
// ✅噹噹噹
Just.of(3).apply(Just.of((b: number) => 5 + b)) // Just.of(8)
複製程式碼
這時候Applicative 把 Functor 推到一邊。 “大人物可以使用具有任意數量引數的函式,”它說。 “裝備了 <$>(fmap) 與 <*>(apply) 之後,我可以接受具有任意個數未包裝值引數的任意函式。 然後我傳給它所有已包裝的值,而我會得到一個已包裝的值出來! 啊啊啊啊啊!”
Just.of(3).apply(Just.of(5).fmap(curriedAddition)) // 返回 `Just.of(8)`
複製程式碼
Applicative總結
我們重申一個Applicative的定義, 如果Functor要求實現fmap的話,Applicative就是要求實現apply,apply符合以下定義:
// 這是Functor的fmap定義
<Functor T>.fmap(fn: (v: T) => U): <Functor U>
// 這是Applicative的apply定義,和上面對比,fn變成了一個包裝在上下文的函式
<Applicative T>.apply(fn: <Applicative (v: T) => U>): <Applicative U>
複製程式碼
Monad
終於練到三重天了!繼續⛽加油️
如何學習 Monad 呢:
- 你要取得電腦科學博士學位。
- 然後把它扔掉,因為在本文你並不需要它!
Monad 增加了一個新的轉變。
Functor
將一個函式
應用到一個已包裝的值
上:
Applicative
將一個已包裝的函式
應用到一個已包裝的值
上:
Monad 將一個返回已包裝值的函式
應用到一個已包裝的值
上。 Monad 定義一個函式flatMap
(在 Haskell 中是使用操作符 >>=
來應用Monad,讀作“bind”)來做這個。
讓我們來看個示例。 老搭檔 Maybe 是一個 Monad:
假設 half
是一個只適用於偶數的函式:
// 這就是一個典型的: "返回已包裝值"的函式
function half(value: number): Maybe<number> {
if (value % 2 === 0) {
return Just.of(value / 2)
}
return None
}
複製程式碼
如果我們餵給它一個已包裝的值
會怎樣?
我們需要使用flatMap(Haskell 中的>>=)來將我們已包裝的值塞進該函式。 這是 >>= 的照片:
以下是它的工作方式:
Just.of(3).flatMap(half) // => Nothing, Haskell中使用操作符這樣操作: Just 3 >>= half
Just.of(4).flatMap(half) // => Just 2
None.flatMap(half) // => Nothing
複製程式碼
內部發生了什麼?我們再看看flatMap的方法簽名:
// Maybe
Maybe<T>.flatMap<U>(fn: (val: T) => Maybe<U>): Maybe<U>
// Array
Array<T>.flatMap<U>(fn: (val: T) => U[]): U[]
複製程式碼
Array是一個Monad, Javascript的Array的flatMap已經正式成為標準, 看看它的使用示例:
const arr1 = [1, 2, 3, 4];
arr1.map(x => [x * 2]);
// [[2], [4], [6], [8]]
arr1.flatMap(x => [x * 2]);
// [2, 4, 6, 8]
// only one level is flattened
arr1.flatMap(x => [[x * 2]]);
// [[2], [4], [6], [8]]
複製程式碼
Maybe 也是一個 Monad:
class None {
static flatMap(fn: any): Nothing {
return None;
}
}
class Just<T> {
// 和上面的apply差不多
// 使用方法過載,讓Typescript更好推斷
// 如果函式返回Just型別,結果也是Just型別
flatMap<U>(fn: (a: T) => Just<U>): Just<U>;
// 如果函式返回值是Nothing型別,結果是Nothing.
flatMap<U>(fn: (a: T) => Nothing): Nothing;
// 如果函式返回值是Maybe型別, 返回一個Maybe型別
flatMap<U>(fn: (a: T) => Maybe<U>): Maybe<U> {
return fn(this.value)
}
}
// 示例
Just.of(3).flatMap(half) // Nothing
Just.of(4).flatMap(half) // Just.of(4)
複製程式碼
這是與 Just 3 運作的情況!
如果傳入一個 Nothing 就更簡單了:
你還可以將這些呼叫串聯起來:
Just.of(20).flatMap(half).flatMap(half).flatMap(falf) // => Nothing
複製程式碼
很炫酷哈!所以我們現在知道Maybe既是一個Functor、Applicative,還是一個Monad。
原文還示範了另一個例子: IO
Monad, 我們這裡就簡單瞭解一下
IO的簽名大概如下:
class IO<T> {
val: T
// 具體實現我們暫不關心
flatMap(fn: (val: T) => IO<U>): IO<U>
}
複製程式碼
具體來看三個函式。 getLine 沒有引數, 用來獲取使用者輸入:
function getLine(): IO<string>
複製程式碼
readFile 接受一個字串(檔名)並返回該檔案的內容:
function readFile(filename: string): IO<string>
複製程式碼
putStrLn 輸出字串到控制檯:
function putStrLn(str: string): IO<void>
複製程式碼
所有這三個函式都接受普通值(或無值)並返回一個已包裝的值,即IO。 我們可以使用 flatMap 將它們串聯起來!
getLine().flatMap(readFile).flatMap(putStrLn)
複製程式碼
太棒了! 前排佔座來看 monad 展示!我們不需要在取消包裝和重新包裝 IO monad 的值上浪費時間. flatMap 為我們做了那些工作!
Haskell 還為 monad 提供了語法糖, 叫做 do 表示式:
foo = do
filename <- getLine
contents <- readFile filename
putStrLn contents
複製程式碼
總結
- functor 是實現了
fmap
的資料型別。 - applicative 是實現了
apply
的資料型別。 - monad 是實現了
flatMap
的資料型別。 - Maybe 實現了這三者,所以它是 functor、 applicative、 以及 monad。
這三者有什麼區別呢?
- functor: 可通過 fmap 將一個
函式
應用到一個已包裝的值
上。 - applicative: 可通過 apply 將一個
已包裝的函式
應用到已包裝的值
上。 - monad: 可通過 flatMap 將一個
返回已包裝值的函式
應用到已包裝的值
上。
綜合起來看看它們的簽名:
// 這是Functor的fmap定義
<Functor T>.fmap(fn: (v: T) => U): <Functor U>
// 這是Applicative的apply定義,和上面對比,fn變成了一個包裝在上下文的函式
<Applicative T>.apply(fn: <Applicative (v: T) => U>): <Applicative U>
// Monad的定義, 而接受一個函式, 這個函式返回一個包裝在上下文的值
<Monad T>.flatmap(fn: (v: T) => <Monad U>): <Monad U>
複製程式碼
所以,親愛的朋友(我覺得我們現在是朋友了),我想我們都同意 monad 是一個簡單且高明的主意(SMART IDEA(tm))。 現在你已經通過這篇指南潤溼了你的口哨,為什麼不拉上 Mel Gibson 並抓住整個瓶子呢。 參閱《Haskell 趣學指南》的《來看看幾種 Monad》。 很多東西我其實掩飾了因為 Miran 深入這方面做得很棒.
擴充套件
本文在原文的基礎上, 參考了下列這些翻譯版本,再次感謝這些作者:
- Functors, Applicatives, And Monads In Pictures - 原文
- Swift Functors, Applicatives, and Monads in Pictures - Swift版本, 本文主要參考這篇文章
- Kotlin 版圖解 Functor、Applicative 與 Monad - Kotlin版本,翻譯非常棒
- Functor, Applicative, 以及 Monad 的圖片闡釋 - 中文版本,題葉翻譯
- Your easy guide to Monads, Applicatives, & Functors - Medium上一篇動圖圖解Monad的文章,寫得也不錯. 讀完本文可以再讀這篇文章