[譯]JavaScript 讓 Monad 更簡單(軟體編寫)(第十一部分)

吳曉軍發表於2017-10-17

JavaScript 讓 Monad 更簡單(軟體編寫)(第十一部分)

Smoke Art Cubes to Smoke — MattysFlicks — (CC BY 2.0)
Smoke Art Cubes to Smoke — MattysFlicks — (CC BY 2.0)

(譯註:該圖是用 PS 將煙霧處理成方塊狀後得到的效果,參見 flickr。)

這是 “軟體編寫” 系列文章的第十一部分,該系列主要闡述如何在 JavaScript ES6+ 中從零開始學習函數語言程式設計和組合化軟體(compositional software)技術(譯註:關於軟體可組合性的概念,參見維基百科
< 上一篇 | << 返回第一篇

在開始學習 Monad 之前,你應當瞭解過:

  • 函式組合:compose(f, g)(x) = (f ∘ g)(x) = f(g(x))
  • Functor 基礎:對於 Array.map() 操作有清晰的理解

Gilad Bracha 曾說過,“一旦你明白了 monad,你反而就沒法向其他人解釋什麼是 monad 了”,這就好像 Lady Mondegreen 空耳詛咒一樣,我們都可以稱其為 Lady Monadgreen 詛咒了。(Gilad Bracha 這段話最著名的引用者你不會陌生,他是 Douglas Crockford)。

譯註:Mondegreen 指空耳,Lady Modegreen 是該詞的來源,當年一個小女孩把 “and laid him on the green” 錯聽成了 “and Lady Mondegreen”。

Kurt Vonnegut's 在其小說 Cat's Cradle 中寫到:“Hoenikker 博士常說,任何無法對一個 8 歲大的孩子解釋清楚他是做什麼的科學家都是騙子”。

如果你在網上搜尋 “Monad”,你會被各種範疇學理論搞得頭皮發麻,很多人也貌似 “很有幫助地” 用各種術語去解釋它。

但是,別被那些專業術語給唬住了,Monad 其實很簡單。我們看一下 Monad 的本質。

一個 Monad 是一種組合函式的方式,它除了返回值以外,還需要一個 context。常見的 Monad 有計算任務,分支任務,或者 I/O 操作。Monad 的 type lift(型別提升),flatten(展平)以及 map(對映)操作使得資料型別統一,從而實現了,即便組合鏈中存在 a => M(b) 這樣的型別提升,函式仍然可組合。a => M(b) 是一個伴隨著某個計算 context 的對映過程,Monad 通過 type lift,flatten 及 map 完成,但是使用者不需要關心實現細節:

  • 函式 map: a => b
  • 具有 Functor context 的 map: Functor(a) => Functor(b)
  • 具備 Monad context,且需要 flatten 的 map:Monad(Monad(a)) => Monad(b)

但是,“flatten”、“map” 和 “context” 究竟意味著什麼?

  • map 指的是,“應用一個函式到 a,返回 b”。即給定某輸入,返回某輸出。
  • context 是一個 Monad 組合(包括 type lift,flatten 和 map)的計算細節。Functor/Monad 的 API 用到了 context,這些 API 允許你在應用的某些部分組合 Monad。Functor 及 Monad 的核心在於將 context 進行抽象,使我們在進行組合的時候不需要關注其中細節。在 context 內部進行 map 意味著你可以在 context 內部應用一個 map 函式完成 a => b,而新返回的 b 又被包裹了相同的 context。如果 a 的 context 是 Observable,那麼 b 的 context 就也是 Observable,即 Observable(a) => Observable(b)。同理有,Array(a) => Array(b)
  • type lift 指的是將一個型別提升到對應的 context 中,值因此被賦予了對應 context 擁有的 API 用於計算,驅動 context 相關計算等等。型別提升可以描述為 a => F(a)。(Monad 也是一種 Functor,所以這裡我們用了 F 表示 Monad)
  • flatten 指的是去除值的 context 包裹。即 F(a) => a

上面的說明還是有些抽象,現在看個例子:

const x = 20;             // `a` 資料型別的 `x`
const f = n => n * 2;     // 將 `a` 對映為 `b` 的函式
const arr = Array.of(x);  // 提升 `x` 的型別為 Array
// JavaScript 中對於陣列型別的提升可以使用語法糖:`[x]`
// `Array.prototype.map()` 在 `x` 上應用了 map 函式 `f`,
// map 發生的 context 正是陣列
const result = arr.map(f); // [40]複製程式碼

在這個例子中,Array 就是 context,x 是進行 map 的值。

這個例子沒有涉及巢狀陣列,但是在 JavaScript 中,你可以通過 .concat() 展開陣列:

[].concat.apply([], [[1], [2, 3], [4]]); // [1, 2, 3, 4]複製程式碼

你早就用過 Monad 了

無論你對範疇學知道多少,使用 Monad 都會優化你的程式碼。不知道利用 Monad 的好處的程式碼就可能讓人頭疼,如回撥地獄,巢狀的條件分支,冗餘程式碼等。

本系列已經不厭其煩的說過,軟體開發的本質即是組合,而 Monad 使得組合更加容易。再回顧下 Monad 的實質:

  • 函式 map,這要求函式的輸入輸出是整齊劃一的: a => b
  • 具有 Functor context 的 map,要求函式的輸入輸出是 Functor: Functor(a) => Functor(b)
  • 具備 Monad context,且需要 flatten 的 map,則允許組合中發生型別提升:Monad(Monad(a)) => Monad(b)

這些都是描述函式組合的不同方式。函式存在的真正目的就是讓你去組合他們,編寫應用。函式幫助你將複雜問題劃分為若干簡單問題,從而能夠分而治之的處理這些小問題,在應用中,不同的函式組合,就帶來了解決不同問題的方式,從而讓你無論面對什麼大的問題,都能通過組合進行解決。

理解函式及如何正確使用函式的關鍵在於更深刻地認識函式組合。

函式組合是為資料流建立一個包含有若干函式的管道。在管道入口,你匯入資料,在管道出口,你獲得了加工好的資料。但為了讓管道工作,管道上的每個函式接受的輸入應當與上一步函式的輸出擁有同樣的資料型別。

組合簡單函式非常容易,因為函式的輸入輸出都有整齊劃一的型別。只需要匹配輸出型別 b 為 輸入型別 b 即可:

g:           a => b
f:                b => c
h = f(g(a)): a    =>   c複製程式碼

如果你的對映是 F(a) => F(b),使用 Functor 的組合也很容易完成,因為這個組合中的資料型別也是整齊劃一的:

g:             F(a) => F(b)
f:                     F(b) => F(c)
h = f(g(Fa)):  F(a)    =>      F(c)複製程式碼

但是如果你想要從 a => F(b)b => F(c) 這樣的形式進行函式組合,你就需要 Monad。我們把 F() 換為 M() 從而讓你知道 Monad 該出場了:

g:                  a => M(b)
f:                       b => M(c)
h = composeM(f, g): a    =>   M(c)複製程式碼

等等,在這個例子中,管道中流通在函式之間的資料型別沒有整齊劃一。函式 f 接收的輸入是型別 b,但是上一步中,fg 處拿到的型別卻是 M(b)(裝有 b 的 Monad)。由於這一不對稱性,composeM() 需要展開 g 輸出的 M(b),把獲得的 b 傳給 f,因為 f 想要的型別是 b 而不是 M(b)。這一過程(通常稱為 .bind() 或者 .chain()) 就是 flatten 和 map 發生的地方。

下面的例子中展現了 flatten 的過程:從 M(b) 中取出 b 並傳遞給下一個函式:

g:             a => M(b) flattens to => b
f:                                      b           maps to => M(c)
h composeM(f, g):
               a       flatten(M(b)) => b => map(b => M(c)) => M(c)複製程式碼

Monad 使得型別整齊劃一,從而使 a => M(b) 這樣,發生了型別提升的函式也可被組合。

在上面的圖示中,M(b) => b 的 flatten 操作及 b => M(c) 的 map 操作都在 chain 方法內部完成了。chain 的呼叫發生在了 composeM() 內部。在應用層面,你不需要關注內在的實現,你只需要用和組合一般函式相同的手段組合返回 Monad 的函式即可。

由於大多數函式都不是簡單的 a => b 對映,因此 Monad 是需要的。一些函式需要處理副作用(如 Promise,Stream),一些函式需要操縱分支(Maybe),一些函式需要處理異常(Either),等等。

這兒有一個更加具體的例子。假如你需要從某個非同步的 API 中取得某使用者,之後又將該使用者傳給另一個非同步 API 以執行某個計算:

getUserById(id: String) => Promise(User)
hasPermision(User) => Promise(Boolean)複製程式碼

讓我們撰寫一些函式來驗證 Monad 的必要性。首先,建立兩個工具函式,compose()trace()

const compose = (...fns) => x => fns.reduceRight((y, f) => f(y), x);
const trace = label => value => {
  console.log(`${ label }: ${ value }`);
  return value;
};複製程式碼

之後,嘗試進行函式組合解決問題(根據 Id 獲得使用者,進而判斷使用者是否具有某個許可權):

{
  const label = 'API call composition';
  // a => Promise(b)
  const getUserById = id => id === 3 ?
    Promise.resolve({ name: 'Kurt', role: 'Author' }) :
    undefined
  ;
  // b => Promise(c)
  const hasPermission = ({ role }) => (
    Promise.resolve(role === 'Author')
  );
  // 嘗試組合上面兩個任務,注意:這個例子會失敗
  const authUser = compose(hasPermission, getUserById);
  // 總是輸出 false
  authUser(3).then(trace(label));
}複製程式碼

當我們嘗試組合 hasPermission()getUserById()authUser() 時,我們遇到了一個大問題,由於 hasPermission() 接收一個 User 物件作為輸入,但卻得到的是 Promise(User)。為了解決這個問題,我們需要建立一個特別地組合函式 composePromises() 來替換掉原來的 compose(),這個組合函式知道使用 .then() 去完成函式組合:

{
  const composeM = chainMethod => (...ms) => (
    ms.reduce((f, g) => x => g(x)[chainMethod](f))
  );
  const composePromises = composeM('then');
  const label = 'API call composition';
  // a => Promise(b)
  const getUserById = id => id === 3 ?
    Promise.resolve({ name: 'Kurt', role: 'Author' }) :
    undefined
  ;
  // b => Promise(c)
  const hasPermission = ({ role }) => (
    Promise.resolve(role === 'Author')
  );
  // 組合函式,這次大功告成了!
  const authUser = composePromises(hasPermission, getUserById);
  authUser(3).then(trace(label)); // true
}複製程式碼

稍後我們會討論 composeM() 的細節。

再次牢記 Monad 的實質:

  • 函式 map: a => b
  • 具有 Functor context 的 map: Functor(a) => Functor(b)
  • 具備 Monad context,且需要 flatten 的 map:Monad(Monad(a)) => Monad(b)

在這個例子中,我們的 Monad 是 Promise,所以當我們組合這些返回 Promise 的函式時,對於 hasPermission() 函式,它得到的是 Promise(User) 而不是 Promise 中裝有的 User 。注意到,如果你去除了 Monad(Monad(a)) 中外層 Monad() 的包裹,就剩下了 Monad(a) => Monad(b),這就是 Functor 中的 .map()。如果我們再有某種手段能夠展開 Monad(x) => x 的話,就走上正軌了。

Monad 的構成

每個 Monad 都是基於一種簡單的對稱性 -- 一個將值包裹到 context 的方式,以及一個取消 context 包裹,將值取出的方式:

  • Lift/Unit:將某個型別提升到 Monad 的 context 中:a => M(a)
  • Flatten/Join:去除 context 包裹:M(a) => a

由於 Monad 也是 Functor,因此它們能夠進行 map 操作:

  • Map:進行保留 context 的 map:M(a) -> M(b)

組合 flatten 以及 map,你就能得到 chain -- 這是一個用於 monad-lifting 函式的函式組合,也稱之為 Kleisli 組合,名稱來自 Heinrich Kleisli

  • FlatMap/Chain: flatten 以後再進行 map:M(M(a)) => M(b)

對於 Monad 來說,.map() 方法通常從公共 API 中省略了。type lift 和 flatten 不會顯示地要求 .map() 呼叫,但你已經有了 .map() 所需要的全部。如果你能夠 lift(也稱為 of/unit) 以及 chain(也稱為 bind/flatMap),你就能完成 .map(),即完成 Monad 中值的對映:

const MyMonad = value => ({
  // <... 這裡可以插入任意的 chain 和 of ...>
  map (f) {
    return this.chain(a => this.constructor.of(f(a)));
  }
});複製程式碼

所以,如果你為 Monad 定義了 .of().chain() 或者 .join() ,你就可以推匯出 .map() 的定義。

lift 可以由工廠函式、構造方法或者 constructor.of() 完成。在範疇學中,lift 叫做 “unit”。list 完成的是將某個型別提升到 Monad context。它將某個 a 轉換到了一個包裹著 a 的 Monad。

在 Haskell 中,很令人困惑的是,lift 被叫做 return,一般我們認為的 return 指的都是函式返回。我仍有意將它稱之為 “lift” 或者 “type lift”,並在程式碼中使用 .of() 完成 lift,這樣更符合我們的理解。

flatten 過程通常被叫做 flatten() 或者 join()。多數時候,我們用不上 flatten() 或者 join(),因為它們內聯到了 .chain() 或者 .flatMap() 中。flatten 通常會配合上 map 操作在組合中使用,因為去除 context 包裹以及 map 都是組合中 a => M(a) 需要的。

去除某類 Monad 可能是非常簡單的。例如 Identity Monad,Identity Monad 的 flatten 過程類似它的 .map() 方法,只不過你不用將返回的值提升回 Monad context。Identity Monad 去除一層包裹的例子如下:

{ // Identity monad
const Id = value => ({
  // Functor Maping
  // 通過將被 map 的值傳入到 type lift 方法 .of() 中
  // 使得 .map() 維持住了 Monand context 包裹:
  map: f => Id.of(f(value)),
  // Monad chaining
  // 通過省略 .of() 進行的型別提升
  // 去除了 context 包裹,並完成 map
  chain: f => f(value),
  // 一個簡便方法來審查 context 包裹的值:
  toString: () => `Id(${ value })`
});
// 對於 Identity Monad 來說,type lift 函式只是這個 Monad 工廠的引用
Id.of = Id;複製程式碼

但是去除 context 包裹也會與諸如副作用,錯誤分支,非同步 IO 這些怪傢伙打交道。在軟體開發過程中,組合是真正有意思的事兒發生的地方。

例如,對於 Promise 物件來說,.chain() 被稱為 .then()。呼叫 promise.then(f) 不會立即 f()。取而代之的是,then(f) 會等到 Promise 物件被 resolve 後,才呼叫 f() 進行 map,這也是 then 命名的來由:

{
  const x = 20;                 // 值
  const p = Promise.resolve(x); // context
  const f = n => 
    Promise.resolve(n * 2);     // 函式
  const result = p.then(f);     // 應用程式
  result.then(
    r => console.log(r)         // 結果:40
  );
}複製程式碼

對於 Promise 物件,.then() 就用來替代 .chain(),但其實二者完成的是同一件事兒。

可能你聽到說 Promise 不是嚴格意義上的 Monad,這是因為只有 Promise 包裹的值是 Promise 物件時,.then() 才會去除外層 Promise 的包裹,否則它會直接做 .map(),而不需要 flatten。

但是由於 .then() 對 Promise 型別的值和其他型別的值處理不同,因此,它不會嚴格遵守數學上 Functor 和 Monad 對任何值都必須遵守的定律。實際上,只要你知道 .then() 在處理不同資料型別上的差異,你也可以把它當做是 Monad。只需要留意一些通用組合工具可能無法工作在 Promise 物件上。

構建 monadic 組合(也叫做 Kleisli 組合)

讓我們深入到 composeM 函式裡面看看,這個函式我們用來組合 promise-lifting 的函式:

const composeM = method => (...ms) => (
  ms.reduce((f, g) => x => g(x)[method](f))
);複製程式碼

藏在古怪 reducer 裡面的是函式組合的代數定義:f(g(x))。如果我們想要更好地理解 composeM,先看看下面的程式碼:

{
  // 函式組合的算數定義:
  // (f ∘ g)(x) = f(g(x))
  const compose = (f, g) => x => f(g(x));
  const x = 20;    // 值
  const arr = [x]; // 值的容器
  // 待組合的函式
  const g = n => n + 1;
  const f = n => n * 2;
  // 下面程式碼證明了 .map() 完成了函式組合
  // 對 map 的鏈式呼叫完成了函式組合
  trace('map composes')([
    arr.map(g).map(f),
    arr.map(compose(f, g))
  ]);
  // => [42], [42]
}複製程式碼

這段程式碼意味著我們可以撰寫一個泛化的組合工具來服務於任何能夠應用 .map() 方法的 Fucntor,例如陣列等:

const composeMap = (...ms) => (
  ms.reduce((f, g) => x => g(x).map(f))
);複製程式碼

這個函式是 f(g(x)) 另一個表述形式。給定任意數量的、發生型別提升的函式 a -> Functor(b),迭代待組合的函式,它們接受輸入 x,並通過 .map(f) 完成 map 和 type lift。.reduce() 方法接受一個兩引數函式:一個引數是累加器(本例中是 f,表示組合後的函式),另一個引數是當前值(本例中是當前函式 g)。

每次迭代都返回了一個新的函式 x => g(x).map(f),這個新函式也是下一次迭代中的 f。我們已經證明 x => g(x).map(f) 等同於將 compose(f, g)(x) 的值提升到 Functor 的 context 中。換言之,即等同於對 Functor 中的值應用 f(g(x)),在本例中,這指的是對原陣列中的值應用組合後的函式進行 map。

效能警告:我不建議對陣列這麼做。以這種方式組合函式將要求對整個陣列進行多重迭代,假如陣列規模很大,這樣做的時間開銷很大。對於陣列進行 map,要麼進行簡單函式組合 a -> b,再在陣列上一次性應用組合後的函式,要麼優化 .reducer() 的迭代過程,要麼直接使用一個 transducer。

譯註:transducer 是一個函式,其名稱複合了 transform 和 reducer。transducer 即為每次迭代指明瞭 tramsform 的 reducer:

const increment = x => x + 1
const square = x => x * x
const transducer = R.map(R.compose(square, increment))
const data = [1, 2, 3]
const initialData = [0]
const accumulator = R.flip(R.append)
R.transduce(transducer, accumulator, initialData, data) // => [0, 4, 9, 16]複製程式碼

上述程式碼相當於:

const increment = x => x + 1
const square = x => x * x
const transform = R.compose(square, increment)
const data = [1, 2, 3]
const initialData = [0]
data.reduce((acc, curr) => acc.concat([transform(curr)]), initialData) // => [0, 4, 9, 16]複製程式碼

參考資料: ramda .transduce()

對於同步任務,陣列的對映函式都是立即執行的,因此需要關注效能。然而,多數的非同步任務都是延遲執行的,並且這部分任務通常需要應對異常或者空值這樣的令人頭痛分支狀況。

這樣的場景對 Monad 再合適不過了。在組合鏈中,當前 Monad 需要的值需要上一步非同步任務或者分支完成時才能獲得。在這些情景下,你無法在組合外部拿到值,因為它們被一個 context 包裹住了,組合過程是 a => Monad(b) 而不是 a => b

無論何時你的一個函式接收了一些資料,觸發了一個 API,返回了對應的值,另一個函式接收了這些值,觸發了另一個 API,並且返回了這些資料的計算結果,你會想要使用 a => Monad(b) 來組合這些函式。由於 API 呼叫是非同步的,你會需要將返回值包上類似 Promise 或者 Observable 這樣的 context。換句話說,這些函式的簽名會是 a -> Monad(b) 以及 b -> Monad(c)

組合 g: a -> b, f: b -> c 型別的函式是很簡單的,因為輸入輸出是整齊劃一的。h: a -> c 這個變化只需要 a => f(g(a))

組合 g: a -> Monad(b), f: b -> Monad(c) 就稍微有些困難。h: a -> Monad(c) 這個變化不能通過 a => f(g(a)) 完成,因為 f() 需要的是 b,而不是 Monad(b)

讓我們看一個更具體的例子,我們組合了一系列非同步任務,它們都返回 Promise 物件:

{
  const label = 'Promise composition';
  const g = n => Promise.resolve(n + 1);
  const f = n => Promise.resolve(n * 2);
  const h = composePromises(f, g);
  h(20)
    .then(trace(label))
  ;
  // Promise composition: 42
}複製程式碼

怎麼才能寫一個 composePromises() 對非同步任務進行組合,並獲得預期輸出呢?提示:你之前可能見到過。

對的,就是我們提到過的 composeMap() 函式?現在,你只需要將其內部使用的 .map() 換成 .then() 即可,Promise.then() 相當於非同步的 .map()

{
  const composePromises = (...ms) => (
    ms.reduce((f, g) => x => g(x).then(f))
  );
  const label = 'Promise composition';
  const g = n => Promise.resolve(n + 1);
  const f = n => Promise.resolve(n * 2);
  const h = composePromises(f, g);
  h(20)
    .then(trace(label))
  ;
  // Promise composition: 42
}複製程式碼

稍微有些古怪的地方在於,當你觸發了第二個函式 f,傳給 f 的不是它想要的 b,而是 Promise(b),因此 f 需要去除 Promise 包裹,拿到 b。接下來該怎麼做呢?

幸運的是,在 .then() 內部,已經擁有了一個將 Promise(b) 展平為 b 的過程了,這個過程通常稱之為 join 或者 flatten

也許你已經留意到了 composeMap()composePromise() 的實現幾乎一樣。因此我們建立一個高階函式來為不同的 Monad 建立組合函式。我們只需要將鏈式呼叫需要的函式混入一個柯里化函式即可,之後,使用方括號包裹這個鏈式呼叫需要的方法名:

const composeM = method => (...ms) => (
  ms.reduce((f, g) => x => g(x)[method](f))
);複製程式碼

現在,我們能針對性地為不同的 Monad 建立組合函式:

const composePromises = composeM('then');
const composeMap = composeM('map');
const composeFlatMap = composeM('flatMap');複製程式碼

Monda 定律

在你開始建立你的 Monad 之前,你需要知道所有的 Monad 都要滿足的一些定律:

  1. 左同一律: unit(x).chain(f) ==== f(x)(譯註:將 x 提升到 Monad context 後,使用 f() 進行 map,等同於直接對 x 直接使用 f 進行 map)
  2. 右同一律: m.chain(unit) ==== m(譯註:Monad 物件進行 map 操作的結果等於原物件 )
  3. 結合律: m.chain(f).chain(g) ==== m.chain(x => f(x).chain(g))

同一律(Identity Law)

左同一律及右同一律
左同一律及右同一律

一個 Monad 也是一個 Functor。一個 Functor 是兩個範疇之間一個態射(morphism):A -> B,其中箭頭符號即描述了態射。除了物件間顯式的態射,每一個範疇中的物件也擁有一個指向自己的箭頭。換言之,對於範疇中的每一個物件 X,存在著一個箭頭 X -> X。該箭頭稱之為同一(identity)箭頭,通常使用一個從自身出發並指回自身的弧形箭頭表示。

同一態射
同一態射

結合律(Associativity)

結合律意味著我們不需要關心我們組合時在哪裡放置括號。如果我們是在做加法,加法有結合律: a + (b + c) 等同於 (a + b) + c。這對於函式組合也同樣適用: (f ∘ g) ∘ h = f ∘ (g ∘ h)

並且,這對於 Kleisli 組合仍然適用。對於這種組合,你應該從前往後地看,把組合運算 chain 當作是 after 即可:

h(x).chain(x => g(x).chain(f)) ==== (h(x).chain(g)).chain(f)複製程式碼

Monda 的定律證明

接下來我們證明同一 Monad 滿足 Monad 定律:

{ // Identity monad
  const Id = value => ({
    // Functor Maping
    // 通過將被 map 的值傳入到 type lift 方法 .of() 中
    // 使得 .map() 維持住了 Monand context 包裹:
    map: f => Id.of(f(value)),
    // Monad chaining
    // 通過省略 .of() 進行的型別提升
    // 去除了 context 包裹,並完成 map
    chain: f => f(value),
    // 一個簡便方法來審查 context 包裹的值:
    toString: () => `Id(${ value })`
  });

  // 對於 Identity Monad 來說,type lift 函式只是這個 Monad 工廠的引用
  Id.of = Id;
  const g = n => Id(n + 1);
  const f = n => Id(n * 2);
  // 左同一律
  // unit(x).chain(f) ==== f(x)
  trace('Id monad left identity')([
    Id(x).chain(f),
    f(x)
  ]);
  // Id Monad 左同一律: Id(40), Id(40)

  // 右同一律
  // m.chain(unit) ==== m
  trace('Id monad right identity')([
    Id(x).chain(Id.of),
    Id(x)
  ]);
  // Id Monad right identity: Id(20), Id(20)

  // 結合律
  // m.chain(f).chain(g) ====
  // m.chain(x => f(x).chain(g)  
  trace('Id monad associativity')([
    Id(x).chain(g).chain(f),
    Id(x).chain(x => g(x).chain(f))
  ]);
  // Id monad associativity: Id(42), Id(42)
}複製程式碼

總結

Monad 是組合型別提升函式的方式:g: a => M(b), f: b => M(c)。為了做到,Monad 必須在應用函式 f() 之前,展平 M(b) 取出 b 交給 f()。換言之,Functor 是你可以進行 map 操作的物件,而 Monad 是你可以進行 flatMap 操作的物件:

  • 函式 map: a => b
  • 具有 Functor context 的 map: Functor(a) => Functor(b)
  • 具備 Monad context,且需要 flatten 的 map:Monad(Monad(a)) => Monad(b)

每個 Monad 都是基於一種簡單的對稱性 -- 一個將值包裹到 context 的方式,以及一個取消 context 包裹,將值取出的方式:

  • Lift/Unit:將某個型別提升到 Monad 的 context 中:a => M(a)
  • Flatten/Join:去除 context 包裹:M(a) => a

由於 Monad 也是 Functor,因此它們能夠進行 map 操作:

  • Map:進行保留 context 的 map:M(a) -> M(b)

組合 flatten 以及 map,你就能得到 chain -- 這是一個用於 monad-lifting 函式的函式組合,也稱之為 Kleisli 組合。

  • FlatMap/Chain: flatten 以後再進行 map:M(M(a)) => M(b)

Monads 必須滿足三個定律(公理),合在一起稱之為 Monad 定律:

  • 左同一律:unit(x).chain(f) ==== f(x)
  • 右同一律:m.chain(unit) ==== m
  • 結合律:m.chain(f).chain(g) ==== m.chain(x => f(x).chain(g)

每天撰寫 JavaScript 程式碼的時候,你或多或少已經在使用 Monad 或者 Monad 類似的東西了,例如 Promise 和 Observable。Kleisli 組合允許你組合資料流邏輯時不用操心組合中的資料型別,也不用擔心可能發生的副作用,條件分支,以及其他一些組合中去除 context 包裹時的細節,這些細節全部都藏在了 .chain() 操作中。

這一切都讓 Monad 在簡化程式碼中扮演了重要角色。在閱讀文字之前,興許你還不明白 Monad 內部到底做了什麼就已經從 Monad 中受益頗豐,現在,你對 Monad 底層細節也有了一定認識,這些細節也並不可怕。

回到開頭,我們不用再懼怕 Lady Monadgreen 的詛咒了。

通過一對一輔導提升你的 JavaScript 技巧

DevAnyWhere 能幫助你最快進階你的 JavaScript 能力:

  • 直播課程
  • 靈活的課時
  • 一對一輔導
  • 構建真正的應用產品

https://devanywhere.io/
https://devanywhere.io/

Eric Elliott“編寫 JavaScript 應用” (O’Reilly) 以及 “跟著 Eric Elliott 學 Javascript” 兩書的作者。他為許多公司和組織作過貢獻,例如 Adobe SystemsZumba FitnessThe Wall Street JournalESPNBBC 等 , 也是很多機構的頂級藝術家,包括但不限於 UsherFrank Ocean 以及 Metallica

大多數時間,他都在 San Francisco Bay Area,同這世上最美麗的女子在一起。

掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章