一個單子(Monad)說白了不過就是自函子範疇上的一個么半群而已,這有什麼難以理解的? " " 之前瞭解了下Monad,後來一段時間沒碰,最近研究Parser用到Monad時發現又不懂了。現在重新折騰,趁著記憶還熱乎,趕緊寫下來。本文不會完整講解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
代碼中
a
、b
表示兩種數據類型,M a
表示封裝了a
類型的Monad類型,M b
表示封裝了b
類型的Monad類型。提升函數將一個值封裝成一個Monad。而綁定函數就像一個管道,它解封一個Monad,將裡面的值傳到第二個參數表示的函數,生成另一個Monad。
以上是一個粗淺的定義。想要進一步瞭解的朋友可以去查看維基的Monad詞條。
另外有一點要註意,Monad的兩個操作中的提升操作做了封裝,但是並沒有提供解封的操作(M a -> a
類型的操作)。下圖展示了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-state
。get-state
返回狀態值並保持狀態不變。set-state
接收一個參數,將狀態設置為該參數,並返回(void)
。代碼如下:
(define (get-state)
(lambda (state) (cons state state)))
(define (set-state state)
(lambda (old-state) (cons (void) state)))
萬事俱備!可以來實現sequence
了。sequence
依次做了以下事情:
- 獲取狀態
state
- 設置新狀態為
state+1
- 返回
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雖然曲高和寡,但其思想悄悄地融入到了各個語言中。本文到此結束,希望對你能有所幫助。