Typescript版圖解Functor , Applicative 和 Monad

荒山發表於2019-08-25

本文是經典的Functors, Applicatives, And Monads In Pictures的Typescript翻譯版本。

Functor/Applicative/Monad是函數語言程式設計中的一些比較‘基礎’的概念,反正我是不認同‘基礎’這個說法的,筆者也閱讀過很多類似介紹Monad的文章,最後都不了了之,這些概念是比較難以理解的,而且平時程式設計實踐中也很難會接觸到這些東西。

後來拜讀了Functors, Applicatives, And Monads In Pictures, 不錯,好像懂了。於是自己想通過翻譯,再深入消化消化這篇文章,這裡使用Typescript作為描述語言,對於前端來說會更好理解。

有理解不正確的地方,敬請指正. 開始吧!


這是一個簡單的值:

Typescript版圖解Functor , Applicative 和 Monad

例如這些

1        // number
'string' // string
複製程式碼

大家都知道怎麼將一個函式應用到這個值上面:

Typescript版圖解Functor , Applicative 和 Monad

// So easy
const add3 = (val: number) => val + 3
console.log(add3(2)) // 5
複製程式碼

很簡單了. 我們來擴充套件一下, 讓任意的值是可以包裝在一個**上下文(context)**當中. 現在的情況你可以想象一個可以把值放進去的盒子:

Typescript版圖解Functor , Applicative 和 Monad

現在你把一個函式應用到這個包裝值的時候, 根據其上下文型別你會得到不同的結果. 這就是 Functor, Applicative, Monad, Arrow 之類概念的基礎.

Maybe 就是一個典型的資料型別, 它定義了兩種相關的‘上下文’, Maybe本身也是一個‘上下文’(除了值,其他型別都可以是一個上下文?):

Typescript版圖解Functor , Applicative 和 Monad

原文基於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

當一個值被包裝在一個上下文中時, 你就不能拿普通函式來應用了:

Typescript版圖解Functor , Applicative 和 Monad

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
複製程式碼

Typescript版圖解Functor , Applicative 和 Monad

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


究竟什麼是 Functor 呢?

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

Typescript版圖解Functor , Applicative 和 Monad

在Typescript中, 一個Functor認為是定義了fmap的任意型別. 看看fmap是如何工作的:

Typescript版圖解Functor , Applicative 和 Monad

  1. 一個Functor型別的 fa, 例如Just 2
  2. fa 定義了一個fmap, fmap 接受一個函式fn,例如add3
  3. 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) 時,這是幕後發生的事情:

Typescript版圖解Functor , Applicative 和 Monad

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

Typescript版圖解Functor , Applicative 和 Monad

None.fmap(add3) // Nothing
複製程式碼

Typescript版圖解Functor , Applicative 和 Monad

就像《黑客帝國》中的 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)上會發生什麼?

Typescript版圖解Functor , Applicative 和 Monad

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) // ❓
複製程式碼

這是一個函式:

Typescript版圖解Functor , Applicative 和 Monad

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

Typescript版圖解Functor , Applicative 和 Monad

其結果是又一個函式!

// 僅作示例,不要模仿
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 一樣包裝在一個上下文中

Typescript版圖解Functor , Applicative 和 Monad

不一樣的是,我們將Functor中的函式(例如add3)也包裝在一個上下文中

Typescript版圖解Functor , Applicative 和 Monad

嗯。 我們繼續深入。 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,
  // 因為MaybeTypescriptUnion型別,沒辦法給它擴充套件方法,這裡將MaybeJust混在一起了
  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應用圖解:

Typescript版圖解Functor , Applicative 和 Monad

陣列型別的Applicative應用圖解:

Typescript版圖解Functor , Applicative 和 Monad

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

  1. 你要取得電腦科學博士學位。
  2. 然後把它扔掉,因為在本文你並不需要它!

Monad 增加了一個新的轉變。

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

Typescript版圖解Functor , Applicative 和 Monad

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

Typescript版圖解Functor , Applicative 和 Monad

Monad 將一個返回已包裝值的函式應用到一個已包裝的值上。 Monad 定義一個函式flatMap(在 Haskell 中是使用操作符 >>= 來應用Monad,讀作“bind”)來做這個。

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

Typescript版圖解Functor , Applicative 和 Monad

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

// 這就是一個典型的: "返回已包裝值"的函式
function half(value: number): Maybe<number> {
  if (value % 2 === 0) {
    return Just.of(value / 2)
  }
  return None
}
複製程式碼

Typescript版圖解Functor , Applicative 和 Monad

如果我們餵給它一個已包裝的值會怎樣?

Typescript版圖解Functor , Applicative 和 Monad

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

Typescript版圖解Functor , Applicative 和 Monad

以下是它的工作方式:

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[]
複製程式碼

Typescript版圖解Functor , Applicative 和 Monad

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 運作的情況!

Typescript版圖解Functor , Applicative 和 Monad

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

Typescript版圖解Functor , Applicative 和 Monad

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

Just.of(20).flatMap(half).flatMap(half).flatMap(falf) // => Nothing
複製程式碼

Typescript版圖解Functor , Applicative 和 Monad
Typescript版圖解Functor , Applicative 和 Monad


很炫酷哈!所以我們現在知道Maybe既是一個Functor、Applicative,還是一個Monad。

原文還示範了另一個例子: IO Monad, 我們這裡就簡單瞭解一下

Typescript版圖解Functor , Applicative 和 Monad

IO的簽名大概如下:

class IO<T> {
  val: T
  // 具體實現我們暫不關心
  flatMap(fn: (val: T) => IO<U>): IO<U>
}
複製程式碼

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

Typescript版圖解Functor , Applicative 和 Monad

function getLine(): IO<string>
複製程式碼

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

Typescript版圖解Functor , Applicative 和 Monad

function readFile(filename: string): IO<string>
複製程式碼

putStrLn 輸出字串到控制檯:

Typescript版圖解Functor , Applicative 和 Monad

function putStrLn(str: string): IO<void>
複製程式碼

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

Typescript版圖解Functor , Applicative 和 Monad

getLine().flatMap(readFile).flatMap(putStrLn)
複製程式碼

太棒了! 前排佔座來看 monad 展示!我們不需要在取消包裝和重新包裝 IO monad 的值上浪費時間. flatMap 為我們做了那些工作!

Haskell 還為 monad 提供了語法糖, 叫做 do 表示式:

foo = do
    filename <- getLine
    contents <- readFile filename
    putStrLn contents
複製程式碼

總結

  1. functor 是實現了 fmap 的資料型別。
  2. applicative 是實現了 apply 的資料型別。
  3. monad 是實現了 flatMap 的資料型別。
  4. Maybe 實現了這三者,所以它是 functor、 applicative、 以及 monad。

這三者有什麼區別呢?

Typescript版圖解Functor , Applicative 和 Monad

  1. functor: 可通過 fmap 將一個函式應用到一個已包裝的值上。
  2. applicative: 可通過 apply 將一個已包裝的函式應用到已包裝的值上。
  3. 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 深入這方面做得很棒.


擴充套件

本文在原文的基礎上, 參考了下列這些翻譯版本,再次感謝這些作者:

相關文章