當前位置:
首頁 > 知識 > 一個Monad的不嚴謹介紹

一個Monad的不嚴謹介紹


一個單子(Monad)說白了不過就是自函子範疇上的一個幺半群而已,這有什麼難以理解的?*

之前了解了下Monad,後來一段時間沒碰,最近研究Parser用到Monad時發現又不懂了。現在重新折騰,趁著記憶還熱乎,趕緊寫下來。本文不會完整講解Monad,而只介紹Monad相關的思想與編程技巧。

不要被唬人的數學概念嚇唬到了。對於程序員來說,Monad不過就是一種編程技巧,或者說是一種設計模式。Monad並非Haskell特有。實際上,大部分語言都有應用過Monad的思想。下面我將主要使用Scheme來解釋Monad。

Monad是什麼

Monad是一種數據類型,它有以下兩個特點:

  • Monad封裝了一個值。

    這個封裝的含義比較廣義,它既可以是用數據結構包涵了一個值,也可以是一個函數(通過返回值來表達被封裝的值)。所以一般也說Monad是一個「未計算的值」、「包含在上下文(context)中的值」。

  • 存在兩個Monad相關的函數: 提升(return函數)與綁定(>>=函數)。

    -- 提升 -- return :: a -> M a -- 綁定 -- >>= :: M a -> (a -> M b) -> M b

    代碼中ab表示兩種數據類型,M a表示封裝了a類型的Monad類型,M b表示封裝了b類型的Monad類型。提升函數將一個值封裝成一個Monad。而綁定函數就像一個管道,它解封一個Monad,將裡面的值傳到第二個參數表示的函數,生成另一個Monad。

以上是一個粗淺的定義。想要進一步了解的朋友可以去查看維基的Monad詞條。

另外有一點要注意,Monad的兩個操作中的提升操作做了封裝,但是並沒有提供解封的操作(M a -> a類型的操作)。下圖展示了Monad兩個操作的關係:

一個Monad的不嚴謹介紹

下面我們來看看Monad的應用。

Maybe

Maybe是最簡單,也是最常被提起的一個例子。Maybe類似C#中的Nullabe類型,表示有一個值,或者沒有值。我們可以在Scheme這樣表示Maybe類型:

; 有一個值
(define (just a) `(Just ,a))
; 沒有值
(define nothing "Nothing)

可以看到,Maybe類型封裝了值a,只缺提升和綁定操作就可以作為Monad了。定義提升和綁定如下:

; 提升
(define return just)
; 綁定
(define (>>= ma f)
(if (eq? ma nothing)
nothing
(f (cadr ma))))

接下來我們看一個求倒數的例子。我們定義一個inv函數,該函數接收一個數字x作為參數。當x等於0時,輸出Nothing;當x不為0時,計算x的倒數1/x,並封裝為(Just 1/x)

(define (inv x)
(if (zero? x) nothing (return (/ 1.0 x))))

定義完inv後,我們就能通過>>=將它應用到Maybe類型來求倒數了。測試一下:

(pretty-print (>>= (just 10) inv))
; > (Just 0.1)

(pretty-print (>>= (just 0) inv))
; > Nothing

(pretty-print (>>= nothing inv))
; > Nothing

Maybe這個例子還揭示了為什麼Monad沒有粗暴地提供一個解封的函數:並非所有Monad都能解封,(Just a)能解封,但是Nothing不能解封!因此只能通過綁定函數來訪問封裝裡面的值。

狀態

Monad最出名的用法是模擬狀態。眾所周知,Haskell是一門純函數語言,因而Haskell不得不大量使用Monad來模擬副作用。然而,Monad也僅僅是模擬,而非真正實現了副作用。應用了Monad技巧的函數仍然是純函數。王垠在他的《對函數式語言的誤解》準確了描述了Monad模擬副作用的本質:


為了讓 random 在每次調用得到不同的輸出,你必須給它「不同的輸入」。那怎麼才能給它不同的輸入呢?Haskell 採用的辦法,就是把「種子」作為輸入,然後返回兩個值:新的隨機數和新的種子,然後想辦法把這個新的種子傳遞給下一次的 random 調用。

現在問題來了。得到的這個新種子,必須被準確無誤的傳遞到下一個使用 random 的地方,否則你就沒法生成下一個隨機數。因為沒有地方可以讓你「暫存」這個種子,所以為了把種子傳遞到下一個使用它的地方,你經常需要讓種子「穿過」一系列的函數,才能到達目的地。種子經過的「路徑」上的所有函數,必須增加一個參數(舊種子),並且增加一個返回值(新種子)。這就像是用一根吸管扎穿這個函數,兩頭通風,這樣種子就可以不受干擾的通過。

為了減輕視覺負擔和維護這些進進出出的「狀態」,Haskell 引入了一種叫 monad 的概念。它的本質是使用類型系統的「重載」(overloading),把這些多出來的參數和返回值,掩蓋在類型裡面。這就像把亂七八糟的電線塞進了接線盒似的,雖然表面上看起來清爽了一些,底下的複雜性卻是不可能消除的。

雖然用Monad模擬狀態既複雜、用處也不多,但是學習一下既有樂趣又不乏啟發,所以姑且來看一下事情是怎麼做的。

為了調試與演示方便,我們這裡不用random函數作為例子,而是實現一個sequence函數。該函數不接收參數,每次調用的返回值都是上一次的返回值加1。

我們先考慮沒有實用Monad的情況。在這種情況下,sequence函數以及其他所有相關的函數需要一個狀態參數,並返回返回值與新狀態兩個值。現在我們考慮Monad的類型。我們要把返回的新狀態隱藏起來,很自然的思路就是將新狀態當作用來封裝返回值的Monad殼子(也可以理解為這個新狀態表達了一個上下文)。用一個pair來表示這個封裝:

(cons value new-state)

另外,還有一個要隱藏的,就是輸入到函數的狀態參數。如何將參數隱藏到Monad比較費腦。事實上,在我們編寫函數代碼時,我們根本就不知道這個狀態參數是從哪裡傳過來的,我們對狀態參數一無所知。既然我們對這個狀態參數一無所知,那我們對這個狀態參數的處理就是先不處理,等程序執行到這裡的時候再計算(這有點像惰性求值,聯想下非惰性求值語言是怎麼實現惰性求值的?),也就是說,我們要把與狀態參數相關的計算過程整個封裝起來,只有獲取到狀態參數時才能解封得到實際的值。用什麼來表示「計算過程」呢?答案是函數(lambda)。到這裡就清晰了,要同時隱藏返回值、返回的新狀態以及狀態參數,我們需要的Monad類型是個函數類型,它大概長這個樣子:

old-state -> (cons value new-state)
; type: number -> number * number

接下來定義提升函數,提升函數返回輸入的值i,並保持狀態不變:

(define (return i)
(lambda (state) (cons i state)))

綁定函數先利用狀態參數state解封m計算得m中的值與新狀態,再將f應用到解封得到的值和新的狀態

(define (>>= m f)
(lambda (state)
(let ([p (m state)])
((f (car p)) (cdr p)))))

為了實現sequence函數,我們還需要一個獲取狀態的函數get-state和一個「設置」狀態的函數set-stateget-state返回狀態值並保持狀態不變。set-state接收一個參數,將狀態設置為該參數,並返回(void)。代碼如下:

(define (get-state)
(lambda (state) (cons state state)))
(define (set-state state)
(lambda (old-state) (cons (void) state)))

萬事俱備!可以來實現sequence了。sequence依次做了以下事情:

  1. 獲取狀態state
  2. 設置新狀態為state+1
  3. 返回state+1

代碼如下:

(define (sequence)
(>>= (get-state)
(lambda (state)
(>>= (set-state (+ state 1))
(lambda (_)
(return (+ state 1)))))))

為了簡化嵌套回調,我寫了一個宏來處理嵌套回調:

(define-syntax do/m
(syntax-rules (<-) [(_ bind e) e] [(_ bind (v <- e0) e e* ...) (bind e0 (lambda (v) (do/m bind e e* ...)))] [(_ bind e0 e e* ...) (bind e0 (lambda (_) (do/m bind e e* ...)))]))

這樣sequence的實現可以簡化為:

(define (sequence1)
(do/m >>=
(state <- (get-state)) (set-state (+ state 1)) (return (+ state 1))))

有沒有很像命令式的寫法?下面來測試一下:

; 方便展示用的輔助函數,請忽視它是個有副作用的函數。
(define (printi v) (return (pretty-print v)))

(define run-program
(do/m >>=
(i1 <- (sequence)) (i2 <- (sequence)) (printi i1) (printi i2) (i3 <- (sequence)) (printi i3)))

注意到這裡的Monad是一個接受狀態參數的函數,我們要傳入初始的狀態參數來讓這段代碼真正跑起來。我們傳入初始狀態0

(run-program 0)

;output:
; > 1
; > 2
; > 3

其他應用Continuation

熟悉continuation的朋友可以看出continuation也是一種Monad。

JavaScript

根據JavaScript面向對象的特性,綁定函數可以定義為Monad的一個方法。下面定義了一個簡單的Monad類型,它單純封裝了一個值作為value屬性:

var Monad = function (v) {
this.value = v;
return this;
};

Monad.prototype.bind = function (f) {
return f(this.value)
};

var lift = function (v) {
return new Monad(v);
};

我們將一個除以2的函數應用的這個Monad:

console.log(lift(32).bind(function (a) {
return lift(a/2);
}));

// > Monad { value: 16 }

是不是有點像Promise?

連續應用除以2的函數:

// 方便展示用的輔助函數,請忽視它是個有副作用的函數。
var print = function (a) {
console.log(a);
return lift(a);
};

var half = function (a) {
return lift(a/2);
};

lift(32)
.bind(half)
.bind(print)
.bind(half)
.bind(print);

//output:
// > 16
// > 8

這是鏈式編程。

結尾

Monad雖然曲高和寡,但其思想悄悄地融入到了各個語言中。本文到此結束,希望對你能有所幫助。

喜歡這篇文章嗎?立刻分享出去讓更多人知道吧!

本站內容充實豐富,博大精深,小編精選每日熱門資訊,隨時更新,點擊「搶先收到最新資訊」瀏覽吧!


請您繼續閱讀更多來自 科技優家 的精彩文章:

「2017-05-25」WebForm母版頁
java函數式編程Lambda表達式的示例(一)

TAG:科技優家 |

您可能感興趣

今日最佳:講一個 story,long long ago,有一個 king……
一個反例讓我理解怎樣才是Bias lighting
如果有一個Party的Dress Code叫Van Gogh
有想法!Facebook定義了一個新的時間單位Flicks
work hard 和 hard work 哪一個是對的?
換了剪輯師的the unit,等待下一個Wanna One
angelababy賈玲奚夢瑤一起cos妲己,一個最美一個最可愛
沒有一個Mini bag,怎麼好意思說自己時髦?
Springwood春樹村一個你不能錯過的花坊
茶的叫法可不只有「tea」,char、chay、chaw,舌頭一繞就一個名
iPhoneX銷量不佳要停產 iPhone用戶卻迎來一個讓人振奮的消息
一個人做了十年,《Iconoclasts》絕對值得一玩
你不知道的 nike與Supreme 聯名,那是一個潮字了得
又來一個黑腳趾!Air Jordan 1 「6 Rings」 清晰曝光
又來一個黑腳趾!這雙 Air Jordan 1 「6 Rings」 還有新的亮點!
遇水不壞的榮耀Waterplay,陪我渡過一個驚險的午後
Colette 前員工開了一個精品店,是個潮流版的 Colette
A real charm person.一個人真正的魅力
Mac版Office與Windows共享一個代碼庫 獲得實時協作
英特爾正在為Spectre和Meltdown漏洞提供一個新的修復程序