1. 程式人生 > >Monad 在實際開發中的應用

Monad 在實際開發中的應用

版權歸作者所有,任何形式轉載請聯絡作者。
作者:tison(來自豆瓣)
來源:https://www.douban.com/note/733279598/

Monad 在實際開發中的應用

不同的人會從不一樣的角度接觸 Monad。大多數網上的教程和介紹都從其嚴格的定義出發,加上幾個玩具示例就當講解完畢。誠然,不少 FP 的愛好者都是形式邏輯的擁躉或強於數學的,但是我對 Monad 的理解卻不是從其定義入門的。相反,我是先頻繁接觸了其例項,這其中包括所有開發者都熟悉的列表(List),現代開發者應該熟悉的 Option/Maybe/Optional 和進一步的 Try/Either/Result,以及併發程式開發者熟悉的 Promise 等。當某天我忽然看到某一段文字提到說這些例項就是 Monad 的時候,結合我自己的使用經歷,突然能夠理解其定義的來由和所要解決的問題。或許這就是一個平凡的開發者接收程式設計手段演進的過程吧,即從實踐經驗出發,總結規律並對應到定義中來。

我也不是很明白怎麼從定義和抽象例項中去講明白 Monad 是什麼,有什麼用。所以按照我自己的尤里卡路徑,我打算從它的幾個經典例項出發,希望能幫助你思考這些抽象和名詞背後的一般思想。這裡我會提及 Try, Promise 和 List,不會包括函式式擁躉熱愛的 IO Monad,因為後者非常違反純函式式以外的世界的直覺。

Try

第一個要講的是 Try,這是考慮到併發程式設計暫時還沒有成為必備技能,Promise 並不是人人都會遇到的,而 List 開發者過於熟悉,從另一個角度看可能會有點反直覺。

Try 要解決的問題和傳統的 try-catch 控制塊是相似的,也就是處理錯誤和異常。我們來看一下傳統的 try-catch 控制塊寫出來的程式碼給人的直觀感受。

try {
      ... // some initializations
      ... // some operations that may cause Exception
} catch (XxxException e) {
    ... // ideally we do recovery
    ... // but most of time we log and rethrow
    ... // or swallow it
} finally {
    ... // some cleanups that must be done
}

這個結構在不巢狀的時候以及在 try 中只包含少數語句的時候看起來還不錯,因為我們還能很清楚地知道我們在做什麼。但是這個前提條件隱含著兩個問題。其一,由於 try 開啟了一個新的作用域的緣故,我們很多時候會寫一個很大的 try 塊,而不假思索的大 try 塊會讓我們忘記到底 try 裡面的語句哪個會發生什麼異常,以至於即使丟擲了異常,我們也只知道異常發生了,而不知道是誰由於什麼緣故觸發的。如果我們細分的拆成若干個小 try 塊,那麼我們很快會被滿屏的縮排和由於新作用域的緣故定義在 try 外而使用在 try 之後的值,以及需要額外做的 null check 干擾得無法閱讀實際業務程式碼。其二,有的時候我們通過巢狀的方式來處理需要具體 catch 和恢復的可能丟擲異常的語句,但是這種縮排正如後面要在 Promise 裡講的 callback hell 一樣,會快速的讓你失去層次的敏感度。實踐經驗指出只要有兩層 try-catch 就能讓一個新接手程式碼的開發者對這塊程式碼暈菜。

那麼 Try Monad 是怎麼解決這個問題的呢?我們來看一段典型的 Try 程式碼

val readFromFile = Try { /* IO */ } // possible IOException
val parseTheContent = readFromFile.flatMap(parse _) // possible ParseException

val tolerantParseException = parseTheContent.recoverWith {
  case _ : ParseException => /* try to fix and retry */
}

tolerantParseException.map(...)/* ... */

這段程式碼首先通過 Try { ... } 構造 Try Monad 的例項,這對應 Haskell Monad 中的 return 函式,即把一個型別升格為 Monad。我們直接看這個函式做了什麼

object Try {
  /** Constructs a `Try` using the by-name parameter.  This
   * method will ensure any non-fatal exception is caught and a
   * `Failure` object is returned.
   */
  def apply[T](r: => T): Try[T] =
    try Success(r) catch {
      case NonFatal(e) => Failure(e)
    }

}

我們忽略 NonFatal 這個問題,這段程式碼的意味是執行一個可能丟擲異常的操作,如果操作成功,返回其返回值,如果丟擲異常,則記錄異常。Try 有兩個子類

final case class Success[+T](value: T) extends Try[T] { ... }
final case class Failure[+T](exception: Throwable) extends Try[T] { ... }

分別對應這兩種情況。對於後續程式碼中 map 和 forEach 這樣處理正常邏輯的程式碼,如果 Try 是一個 Failure,它會永遠返回它自己,也就是說第一個錯誤的原因被持續的傳遞下去。直到呼叫 recover 或 recoverWith,對於這兩個方法,相反的 Success 永遠返回它自己,但是 Failure 能相應傳進來的偏函式,匹配具體的異常型別並試圖恢復。

因此,上面程式碼的邏輯就是,從檔案中讀入資料並解析,如果解析異常我們試著去恢復,隨後進行一系列操作。如果一開始的讀入有異常,我們直到最後都拿到一個 IOException,這可能在後面被恢復或吞掉或直接作為返回值向上返回交給上層處理。

實際上,我們可以用 try-catch 控制塊去實現這段程式碼的邏輯,但是我們會發現邏輯迷失在縮排、作用域和控制流的跳轉上;而使用 Try Monad,我們可以以線性的符合直覺的處理方式來對邏輯進行編碼。這也是函數語言程式設計的一個思想,即儘可能把所有的情況都納入型別系統中,提供最簡單的控制流(最極端的情況下只有 if-else 和 match-case)以保證程式邏輯是順著下來的,而不用做奇怪的跳轉。

那麼,這跟 Monad 有什麼關係呢(笑)。前面提到 try-catch 有兩個問題,現在其一作用域導致的大 try 塊已經被 Try {...} 也就是所謂的 return 函式弄到了 Try Monad 的包裝裡面,我們實際操作的是其中的 value 和 exception,但這是 Monad 的父型別類 Functor 就有的要求。對於第二個問題,巢狀的 try 塊,它的解決才彰顯出 Monad 最強大的地方,也就是 Haskell 中所謂的 bind 函式,我更喜歡 Scala 中沿用列表的稱呼 flatMap 函式。

在 Try 的例項中,我們對 value 的操作可能引入一個新的可能產生異常的動作(例如上面的 parse),這不同於 map 的時候我們的型別從 Try[T] 到 Try[U],parse 產生的是 Try[Try[U]],這樣在後面的解包處理的過程裡面,我們就要手動的解兩層巢狀的包裝,一旦串接的操作變多,我們將人為的記住需要解包的層數並進行機械的解包動作,雖然我們最終感興趣的只是其中的值。更加令人不快的是,我們明知道 parse 做的就是把值從前面的包裝取出來,對應的產生一個我們需要的 Try Monad 的結果,我們本不需要把它再裝入前面的包裝中。這就是 flatMap 存在的意義,把裝到前面的包裝中這個動作給去掉了。因此我們無論做多少次可能產生異常串接,最終的結果型別都是 Try[T]。可以說,不同於 Functor 和 Applicative Functor 的 flatMap 函式就是 Monad 的精髓。

Promise

其實我打算用 Java 的 CompletableFuture 來做例子,後者把 Promise 和 Future 的職責糅合在一起,說不定意外的好理解一點(實際上 Scala 內部實現的 Promise 就是同時混入 Promise 和 Future 的)。

在開題的時候我原本以為 Promise 和 Try 分別代表了不同的 Monad 例項,但是其實在錯誤恢復和處理以及多個子型別上面它們相似程度還不少。所以對於 Promise 和 Try 類似能夠分別代表非同步計算成功或失敗以及對應的線性處理以對付 callback hell 的問題就一筆帶過。這裡著重講一下在 Try Monad 中很自然但是在 Promise Monad 中尤為重要的另一個特性:

通過使用 map/flatMap 串接操作,能保證計算是順序執行的。

我們來看下面一段程式碼

CompletableFuture<...> asyncOp1 = ...;
asyncOp1.thenCompose(res -> /* another async op */)
        .thenApply(res -> /* sync op */)

拋去其 Async 版本帶來的由於 Java Executor 框架引入的非同步問題,這段程式碼第一個非同步操作 asyncOp1 後接了一個非同步操作,在後面這個非同步操作結束後接了一個同步操作。這個過程還可以無限的延續下去。由於 Monad map/flatMap 天然的順序計算特性,即拿到運算元才能做下一步的動作,我們能夠保證這些非同步動作是按照安排好的順序依次執行的。這其實也是 callback 想解決的問題,同時在併發程式開發中能夠幫助 reasoning 程式碼。關於併發程式開發中怎麼同步和怎麼選擇順序和非同步操作的問題,那就是另一個有趣的主題了。

List

上面的兩個例子有個共同的特點,即都表明了計算的成功或失敗。但是這一點在 Monad 裡面其實不是必須的。

我們看到 List 也是個 Monad,對於這個大家都很熟悉的類我就不多做基礎的介紹,相反的,從 Monad 的定義來考察 List 是怎麼成為 Monad 的。

對於 Monad 來說,它需要一個 return 函式和一個 bind 函式。對於 List,它的 return 就是 x = [x], 而 bind 就是 List 的 flatMap 函式。

List 是一個更簡單的例子,能夠幫助我們看到 flatMap 發生的具體情況。例如我們要做一個九九乘法表,命令式的寫法是

for (int i = 1; i < 10; i++) {
  for (int j = i; j < 10; j++) {
    System.out.println(i + " x " + j + " = " + i * j);
  }
}

而利用 List Monad 的 flatMap 函式,我們可以寫作

mapM_ putStrLn
   $ do 
       x <- [1..9]
       y <- [x..9]
       return (show x ++ " + " ++ show y ++ " = " ++ show (x * y))

在 Java Stream 中我們可以拿到 x * y 的結果,但是捕獲前面的 x 和 y 稍微有點困難(可以使用 forEach,但是其實 forEach 已經是強制解包消費無法再裝包了)。

IntStream
    .range(1, 10)
  .flatMap(x -> IntStream.range(x, 10).map(y -> x * y))
  .forEach(System.out::println)

小結

Monad 的使用場景還是很廣泛的,無論是在異常處理和併發程式設計裡嶄露頭角的 Try 和 Promise,還是伴隨我們已久的 List,還有函式式的世界裡為了處理狀態變化的 State Monad 和為了附加副作用的 IO Monad,說到底,Monad 的核心就在於 flatMap 函式和附加在裝包解包上可以自定義的動作(在 Haskell 裡,底層平臺利用這個任意附加的操作實現了 IO Monad 的副作用)。從程式碼工匠的角度來看,多看多思考使用 Monad 特性的優質程式碼,能夠幫助理解和學習 Monad 的實際作用。這部分的程式碼專案比較多,簡單的可以推薦 Pravega 和 Apache Flink 這兩個大量使用了 Promise 的專案。書籍方面推薦《Java 函數語言程式設計》 和 《魔力 Haskell》。上面的介紹裡混雜了很多 Monad 有但不是獨有的內容,跟隨這兩本書理解函數語言程式設計裡面是怎麼由簡到繁,一步步地針對新的問題提供新的解法的,這個過程非常有趣