一個(gè)Monad的不嚴(yán)謹(jǐn)介紹

一個(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

下面我們來看看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依次做了以下事情:

  1. 獲取狀態(tài)state
  2. 設(shè)置新狀態(tài)為state+1
  3. 返回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ì)你能有所幫助。

相關(guān)鏈接

Wiki的Monad詞條

Functor、Applicative 和 Monad

對(duì)函數(shù)式語言的誤解

陳年譯稿——一個(gè)面向Scheme程序員的monad介紹

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 本文為翻譯,個(gè)人學(xué)習(xí)之用,原地址 程序狀態(tài) 如果你以前有其他語言的編程經(jīng)驗(yàn),你可能寫過一些函數(shù)或者方法來控制程序的...
    ParkinWu閱讀 3,229評(píng)論 0 3
  • 前言 近期又開始折騰起Haskell,掉進(jìn)這個(gè)深坑恐怕很難再爬上來了。在不斷深入了解Haskell的各種概念以及使...
    Tangentw閱讀 2,205評(píng)論 0 9
  • //Clojure入門教程: Clojure – Functional Programming for the J...
    葡萄喃喃囈語閱讀 4,021評(píng)論 0 7
  • “一個(gè)男子漢,不怕摔跤,就怕跌倒了不往起爬,那就變成了死狗了……” 人生就是一場(chǎng)自導(dǎo)自演的戲劇,至于戲的性質(zhì)是喜是...
    楓香寄語閱讀 238評(píng)論 0 0
  • 朝著光撒下的方向 我背上滿滿的行囊 決心奔赴落滿光輝的星球 星球很遠(yuǎn) 但是很漂亮 像童話鎮(zhèn)里的城堡 我不知道那個(gè)遙...
    山和與閱讀 273評(píng)論 0 1

友情鏈接更多精彩內(nèi)容