Monad 在實際開發中的應用

来源:https://www.cnblogs.com/tisonkun/archive/2019/09/06/11474111.html
-Advertisement-
Play Games

版權歸作者所有,任何形式轉載請聯繫作者。 作者:tison(來自豆瓣) 來源:https://www.douban.com/note/733279598/ 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)

UPDATE

我發現 Java 的場景是我沒有理解 primitive 數據類型的特殊性,實際上它是可以達到跟 Haskell 一樣的效果的,雖然沒有 do 語法糖看起來更像是展開 do 語法糖之後的樣子

IntStream.range(1, 10).boxed().flatMap(x ->
        IntStream.range(x, 10).boxed().flatMap(y ->
                Arrays.stream(new String[]{ x + " * " + 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 有但不是獨有的內容,跟隨這兩本書理解函數式編程裡面是怎麼由簡到繁,一步步地針對新的問題提供新的解法的,這個過程非常有趣。


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 先貼一張圖片做個例子 看不清圖片的朋友可以右鍵圖片,在<新標簽頁中打開圖片>; 可以看到,由於內容過長,導致後面操作的增刪改要拉到最後才能操作. 在我們的FastAdmin中,目前我還沒找到可以調整列表大小的文檔,如果有,歡迎大家告知.下麵是我用來解決這個問題的方法 我們可以在生成的頁面好添加兩段代 ...
  • 1 2 3 4 5 Document 6 13 21 22 23 24 25 ...
  • 1 <!DOCTYPE html> 2 <html lang="en"> 3 <head> 4 <meta charset="UTF-8"> 5 <title>Document</title> 6 <style type="text/css"> 7 #div1{ 8 width: 200px; 9 ... ...
  • 上一篇我們主要講解了函數的執行過程和原理,本篇我們將介紹函數的另外兩個特殊表現:閉包和立即執行函數。 一 閉包 1, 閉包的形成 之前我們提到,函數執行完畢,馬上就會銷毀自己的AO對象。但是如果遇到下麵這種情況:有子函數的定義,並將子函數返回。它真的就完全銷毀了自己的AO對象嗎? 這將列印什麼呢?表 ...
  • extend是jQuery中一個比較核心的代碼,如果有查看jQuery的源碼的話,就會發現jQuery在多處調用了extend方法。 作用 1. 對任意對象進行擴展 2. 擴展某個實例對象 3. 對jquery本身的實例方法進行擴展 實現 基礎版本, 對簡單對象進行擴展 jQuery.prototy ...
  • 本架構主要目的是改進軟體開發中松耦合、增加模塊的重用性、提高開發效率。 在常規的模塊間方法直接調用式開發中,新增的功能對原有模塊代碼的穩定性、重用性破壞大,不利於軟體的後期維護,且開發效率低。 另外,在傳統的軟體開發方法中,如果新增的功能的邏輯在其它模塊需要重覆使用,則只能通過copy代碼或方法調用 ...
  • 概述 靜態頁面生成是常用的提升性能手段,將一些高併發、變化頻率低、對延遲容忍度高的頁面生為靜態頁面,在電商場景中首頁、商品詳情頁、幫助中心頁、專題頁都是符合特征的頁面。通過生成靜態頁直接輸出給瀏覽器,能夠有效的減少資料庫及cpu的負載。 一般說來,靜態頁的生成和展示有如下幾個裝置: 頁面生成裝置 頁 ...
  • 一、HAProxy概述: HAProxy提供高可用性、負載均衡以及基於TCP和HTTP應用的代理,支持虛擬主機,它是免費、快速並且可靠的一種解決方案。根據官方數據,其最高極限支持10G的併發。 HAProxy特別適用於那些負載特大的web站點, 這些站點通常又需要會話保持或七層處理。HAProxy運 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...