一個(gè)單子(Monad)說白了不過就是自函子范疇上的一個(gè)幺半群而已,這有什么難以理解的?*
之前了解了下Monad,后來一段時(shí)間沒碰,最近研究Parser用到Monad時(shí)發(fā)現(xiàn)又不懂了。現(xiàn)在重新折騰,趁著記憶還熱乎,趕緊寫下來。本文不會(huì)完整講解Monad,而只介紹Monad相關(guān)的思想與編程技巧。
不要被唬人的數(shù)學(xué)概念嚇唬到了。對(duì)于程序員來說,Monad不過就是一種編程技巧,或者說是一種設(shè)計(jì)模式。 Monad并非Haskell特有。實(shí)際上,大部分語言都有應(yīng)用過Monad的思想。下面我將主要使用Scheme來解釋Monad。
Monad是什么
Monad是一種數(shù)據(jù)類型,它有以下兩個(gè)特點(diǎn):
-
Monad封裝了一個(gè)值。
這個(gè)封裝的含義比較廣義,它既可以是用數(shù)據(jù)結(jié)構(gòu)包涵了一個(gè)值,也可以是一個(gè)函數(shù)(通過返回值來表達(dá)被封裝的值)。所以一般也說Monad是一個(gè)“未計(jì)算的值”、“包含在上下文(context)中的值”。
-
存在兩個(gè)Monad相關(guān)的函數(shù): 提升(
return函數(shù))與綁定(>>=函數(shù))。-- 提升 -- return :: a -> M a -- 綁定 -- >>= :: M a -> (a -> M b) -> M b代碼中
a、b表示兩種數(shù)據(jù)類型,M a表示封裝了a類型的Monad類型,M b表示封裝了b類型的Monad類型。提升函數(shù)將一個(gè)值封裝成一個(gè)Monad。而綁定函數(shù)就像一個(gè)管道,它解封一個(gè)Monad,將里面的值傳到第二個(gè)參數(shù)表示的函數(shù),生成另一個(gè)Monad。
以上是一個(gè)粗淺的定義。想要進(jìn)一步了解的朋友可以去查看維基的Monad詞條。
另外有一點(diǎn)要注意,Monad的兩個(gè)操作中的提升操作做了封裝,但是并沒有提供解封的操作(M a -> a類型的操作)。下圖展示了Monad兩個(gè)操作的關(guān)系:

下面我們來看看Monad的應(yīng)用。
Maybe
Maybe是最簡(jiǎn)單,也是最常被提起的一個(gè)例子。Maybe類似C#中的Nullabe類型,表示有一個(gè)值,或者沒有值。我們可以在Scheme這樣表示Maybe類型:
; 有一個(gè)值
(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))))
接下來我們看一個(gè)求倒數(shù)的例子。我們定義一個(gè)inv函數(shù),該函數(shù)接收一個(gè)數(shù)字x作為參數(shù)。當(dāng)x等于0時(shí),輸出Nothing;當(dāng)x不為0時(shí),計(jì)算x的倒數(shù)1/x,并封裝為(Just 1/x)。
(define (inv x)
(if (zero? x) nothing (return (/ 1.0 x))))
定義完inv后,我們就能通過>>=將它應(yīng)用到Maybe類型來求倒數(shù)了。測(cè)試一下:
(pretty-print (>>= (just 10) inv))
; > (Just 0.1)
(pretty-print (>>= (just 0) inv))
; > Nothing
(pretty-print (>>= nothing inv))
; > Nothing
Maybe這個(gè)例子還揭示了為什么Monad沒有粗暴地提供一個(gè)解封的函數(shù):并非所有Monad都能解封,(Just a)能解封,但是Nothing不能解封!因此只能通過綁定函數(shù)來訪問封裝里面的值。
狀態(tài)
Monad最出名的用法是模擬狀態(tài)。眾所周知,Haskell是一門純函數(shù)語言,因而Haskell不得不大量使用Monad來模擬副作用。然而,Monad也僅僅是模擬,而非真正實(shí)現(xiàn)了副作用。應(yīng)用了Monad技巧的函數(shù)仍然是純函數(shù)。王垠在他的《對(duì)函數(shù)式語言的誤解》準(zhǔn)確了描述了Monad模擬副作用的本質(zhì):
為了讓 random 在每次調(diào)用得到不同的輸出,你必須給它“不同的輸入”。那怎么才能給它不同的輸入呢?Haskell 采用的辦法,就是把“種子”作為輸入,然后返回兩個(gè)值:新的隨機(jī)數(shù)和新的種子,然后想辦法把這個(gè)新的種子傳遞給下一次的 random 調(diào)用。
現(xiàn)在問題來了。得到的這個(gè)新種子,必須被準(zhǔn)確無誤的傳遞到下一個(gè)使用 random 的地方,否則你就沒法生成下一個(gè)隨機(jī)數(shù)。因?yàn)闆]有地方可以讓你“暫存”這個(gè)種子,所以為了把種子傳遞到下一個(gè)使用它的地方,你經(jīng)常需要讓種子“穿過”一系列的函數(shù),才能到達(dá)目的地。種子經(jīng)過的“路徑”上的所有函數(shù),必須增加一個(gè)參數(shù)(舊種子),并且增加一個(gè)返回值(新種子)。這就像是用一根吸管扎穿這個(gè)函數(shù),兩頭通風(fēng),這樣種子就可以不受干擾的通過。
為了減輕視覺負(fù)擔(dān)和維護(hù)這些進(jìn)進(jìn)出出的“狀態(tài)”,Haskell 引入了一種叫 monad 的概念。它的本質(zhì)是使用類型系統(tǒng)的“重載”(overloading),把這些多出來的參數(shù)和返回值,掩蓋在類型里面。這就像把亂七八糟的電線塞進(jìn)了接線盒似的,雖然表面上看起來清爽了一些,底下的復(fù)雜性卻是不可能消除的。
雖然用Monad模擬狀態(tài)既復(fù)雜、用處也不多,但是學(xué)習(xí)一下既有樂趣又不乏啟發(fā),所以姑且來看一下事情是怎么做的。
為了調(diào)試與演示方便,我們這里不用random函數(shù)作為例子,而是實(shí)現(xiàn)一個(gè)sequence函數(shù)。該函數(shù)不接收參數(shù),每次調(diào)用的返回值都是上一次的返回值加1。
我們先考慮沒有實(shí)用Monad的情況。在這種情況下,sequence函數(shù)以及其他所有相關(guān)的函數(shù)需要一個(gè)狀態(tài)參數(shù),并返回返回值與新狀態(tài)兩個(gè)值?,F(xiàn)在我們考慮Monad的類型。我們要把返回的新狀態(tài)隱藏起來,很自然的思路就是將新狀態(tài)當(dāng)作用來封裝返回值的Monad殼子(也可以理解為這個(gè)新狀態(tài)表達(dá)了一個(gè)上下文)。用一個(gè)pair來表示這個(gè)封裝:
(cons value new-state)
另外,還有一個(gè)要隱藏的,就是輸入到函數(shù)的狀態(tài)參數(shù)。如何將參數(shù)隱藏到Monad比較費(fèi)腦。事實(shí)上,在我們編寫函數(shù)代碼時(shí),我們根本就不知道這個(gè)狀態(tài)參數(shù)是從哪里傳過來的,我們對(duì)狀態(tài)參數(shù)一無所知。既然我們對(duì)這個(gè)狀態(tài)參數(shù)一無所知,那我們對(duì)這個(gè)狀態(tài)參數(shù)的處理就是先不處理,等程序執(zhí)行到這里的時(shí)候再計(jì)算(這有點(diǎn)像惰性求值,聯(lián)想下非惰性求值語言是怎么實(shí)現(xiàn)惰性求值的?),也就是說,我們要把與狀態(tài)參數(shù)相關(guān)的計(jì)算過程整個(gè)封裝起來,只有獲取到狀態(tài)參數(shù)時(shí)才能解封得到實(shí)際的值。用什么來表示“計(jì)算過程”呢?答案是函數(shù)(lambda)。到這里就清晰了,要同時(shí)隱藏返回值、返回的新狀態(tài)以及狀態(tài)參數(shù),我們需要的Monad類型是個(gè)函數(shù)類型,它大概長(zhǎng)這個(gè)樣子:
old-state -> (cons value new-state)
; type: number -> number * number
接下來定義提升函數(shù),提升函數(shù)返回輸入的值i,并保持狀態(tài)不變:
(define (return i)
(lambda (state) (cons i state)))
綁定函數(shù)先利用狀態(tài)參數(shù)state解封m計(jì)算得m中的值與新狀態(tài),再將f應(yīng)用到解封得到的值和新的狀態(tài)
(define (>>= m f)
(lambda (state)
(let ([p (m state)])
((f (car p)) (cdr p)))))
為了實(shí)現(xiàn)sequence函數(shù),我們還需要一個(gè)獲取狀態(tài)的函數(shù)get-state和一個(gè)“設(shè)置”狀態(tài)的函數(shù)set-state。get-state返回狀態(tài)值并保持狀態(tài)不變。set-state接收一個(gè)參數(shù),將狀態(tài)設(shè)置為該參數(shù),并返回(void)。代碼如下:
(define (get-state)
(lambda (state) (cons state state)))
(define (set-state state)
(lambda (old-state) (cons (void) state)))
萬事俱備!可以來實(shí)現(xiàn)sequence了。sequence依次做了以下事情:
- 獲取狀態(tài)
state - 設(shè)置新狀態(tài)為
state+1 - 返回
state+1
代碼如下:
(define (sequence)
(>>= (get-state)
(lambda (state)
(>>= (set-state (+ state 1))
(lambda (_)
(return (+ state 1)))))))
為了簡(jiǎn)化嵌套回調(diào),我寫了一個(gè)宏來處理嵌套回調(diào):
(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的實(shí)現(xiàn)可以簡(jiǎn)化為:
(define (sequence1)
(do/m >>=
(state <- (get-state))
(set-state (+ state 1))
(return (+ state 1))))
有沒有很像命令式的寫法?下面來測(cè)試一下:
; 方便展示用的輔助函數(shù),請(qǐng)忽視它是個(gè)有副作用的函數(shù)。
(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是一個(gè)接受狀態(tài)參數(shù)的函數(shù),我們要傳入初始的狀態(tài)參數(shù)來讓這段代碼真正跑起來。我們傳入初始狀態(tài)0:
(run-program 0)
;output:
; > 1
; > 2
; > 3
其他應(yīng)用
Continuation
熟悉continuation的朋友可以看出continuation也是一種Monad。
JavaScript
根據(jù)JavaScript面向?qū)ο蟮奶匦裕壎ê瘮?shù)可以定義為Monad的一個(gè)方法。下面定義了一個(gè)簡(jiǎn)單的Monad類型,它單純封裝了一個(gè)值作為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);
};
我們將一個(gè)除以2的函數(shù)應(yīng)用的這個(gè)Monad:
console.log(lift(32).bind(function (a) {
return lift(a/2);
}));
// > Monad { value: 16 }
是不是有點(diǎn)像Promise?
連續(xù)應(yīng)用除以2的函數(shù):
// 方便展示用的輔助函數(shù),請(qǐng)忽視它是個(gè)有副作用的函數(shù)。
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
這是鏈?zhǔn)骄幊獭?/p>
結(jié)尾
Monad雖然曲高和寡,但其思想悄悄地融入到了各個(gè)語言中。本文到此結(jié)束,希望對(duì)你能有所幫助。