zoukankan      html  css  js  c++  java
  • 一个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虽然曲高和寡,但其思想悄悄地融入到了各个语言中。本文到此结束,希望对你能有所帮助。

    相关链接

    Wiki的Monad词条

    Functor、Applicative 和 Monad

    对函数式语言的误解

    陈年译稿——一个面向Scheme程序员的monad介绍

  • 相关阅读:
    解决使用git出现 The file will have its original line endings in your working directory
    SpringBoot集成flowable碰见DMN不能初始化
    CF268D Wall Bars
    CF1327F AND Segments
    P2900 [USACO08MAR]Land Acquisition G
    CF279B Books
    CF859E Desk Disorder
    CF1147B Chladni Figure
    CF1147E Rainbow Coins
    P3565 [POI2014]HOT-Hotels
  • 原文地址:https://www.cnblogs.com/skabyy/p/6896421.html
Copyright © 2011-2022 走看看