首发于可逆计算

写给小白的Monad指北

最近公司来了个新同事,他姓白,年纪又很小,我们都叫他小白。小白最近在学习函数式编程,前几天他过来问我一个问题。
小白:我正规985学校毕业,为什么看了这么多Monad介绍,还是云里雾里的。是这些文章写得的有问题,还是我的理解力有问题?
我:你学什么专业的?
小白:高分子。毕业后我在家自学了半年编程。
我:好吧...
最后我决定写这篇文章,帮小白搞清楚这个问题。

什么是Monad?从实用主义的角度上说,Monad就是函数式编程中特有的一种设计模式。为什么是函数式特有的?因为它说的就是函数之间如何组合的问题。

一. 函数

为了研究函数之间如何组合,我们首先需要定义什么是函数。初等数学中的函数,上初中的时候我们就学过,那是定义域到值域之间的一种映射关系。在编程语言中,我们可以通过类型来表示一个变量所能取值的范围,比如Integer表示取值范围为-2^31到2^31-1之间,因此函数就变成了从类型A到类型B的映射,形式上可以写成 f: A -> B。简单的说,编程语言中的函数可以被理解为从符号A到符号B的一个箭头,特别的,箭头本身也可以被看作是一个符号,因此从符号映射到箭头/从箭头映射到符号/从箭头映射到箭头也是一种函数,这就是所谓的高阶函数。

有了高阶函数的概念,我们就可以定义一个变换curry, 它负责将多参数函数化归为单参数函数。这样在理论上我们就只用研究单参数函数了。

function add(x,y){
     return x + y;
  }

   // 多参数函数的第一个参数可以和函数本身相结合,这叫作partial apply,
   // 返回一个新的函数(少了一个参数),而不是具体的值
  function curry_add(x){
     return function(y){
         return x + y;
      }
  }

  add(x,y) == curry_add(x)(y)

  // js内置了bind函数可以实现curry
  add(x,y) == add.bind(null,x)(y)

在数学上,我们可以将curry看作是以下变换(本质上就是把符号重新排列一下再给一个新的解释)

(a,b) -> c = a -> (b -> c) = a -> b -> c

第一个等式对应于curry,而第二个等式是lambda演算领域的一个约定,就是右边的箭头先结合,这样可以少写一个括号(数学家讨厌括号)。

redux中间件那个看起来有点吓人的形式 store => next => action => { ... } 表达的本质上就是一个多参数函数

函数的基本性质是满足结合律,即(f ∘ g) ∘ h = f ∘ (g ∘ h)。如果不使用符号∘ ,而是写得啰嗦一些,实际函数的复合规律为

compose(compose(f,g),h) == compose(f, compose(g,h))

  // 这里为了和数学定义保持一致,约定了先执行g,再执行f。
  // 在一般的程序语言中,我们可能会觉得从左到右执行看着更顺眼
  function compose(f,g){
    return function(x){
       return f(g(x));
     }
  }

如果写成中缀形式,会看起来和数学的定义更一致一些

(f compose g) compose h = f compose (g  compose h)
满足结合律意味着可以随意增加或者删除括号,甚至可以不写括号,因为计算结果与局部结合顺序无关。所以数学家们不喜欢不满足结合律的东西,因为不然的话他们要多写很多括号!

一个具体的例子:

function add_1(x){ return x + 1}

  function minus_2(y) { return y - 2}

  function multiply_3(z) { return z * 3}

  var p = compose(compose(add_1, minus_2), multiply_3)

  var q = compose(add_1, compose(minus_2, multiply_3))

  p(x) == q(x)

虽然在JS中,p和q是两个不同的函数对象,但它们在任何值上的运算结果都是相等的,因此在数学的意义上是等价的(一模一样)。

Monad本质上说的就是函数满足结合律这件事情,但它不是简单的说函数满足结合律(这不是明摆着的嘛),而是说某种特殊形式的函数满足结合律,而很多我们编程中常用的运算模式被证实都可以表达成这种特殊的函数形式。

函数调用本来是一个嵌套关系,但是单参数函数的嵌套调用f(g(h(x))在数学上可以写成一个线性形式 (f ∘ g ∘ h)(x)。其实,这在数学上仅仅是一个符号变换的问题,在数学上只要两种表示法能够建立一一对应规则,则它们就是完全等价的。我们甚至可以随时编造更多的形式,比如 x => h => g => f, 或者 x | h | g |f (类似于Unix Pipe),或者 $ = h(x); $ = g($) ; $ = f($); 这种形式上的线性化并不需要结合律的支持。

二. Promise

假设我们有如下两个异步调用函数,我们希望把它们复合成一个异步调用:

// String -> Promise<User>
async function getUserById(userId){ ... }

// User -> Promise<Dept>
async function getDeptByUser(user){ ... }

// getDeptByUserId: String -> Promise<Dept>
getDeptByUserId = compose(getDeptByUser, getUserById);

function composeM(f,g){
   return function(x){
       return g(x).then(f);
    }
}

异步调用函数可以通过一个特殊定义的composeM函数复合在一起,而且这种复合满足结合律。需要注意的是,异步调用函数对应于一个特别的函数类型: A -> Promise<B>

三. List

类型为 A -> List<B>的集合变换函数也满足类似异步调用函数的规律。

// 有些浏览器尚未支持flatMap函数
  Array.prototype.flatMap = function(f){
    var ret = [];
    for(var ary of this.map(f))
         ret = Array.prototype.concat.call(ret,ary);
     return ret;
  }

   // 接收一个值,返回一个列表
  function duplicate(x){
     return [x,x];
  }

  // 接收一个值,把它包装成列表,或者返回一个空列表
  function positive(x){
     return x > 0 ? [x] : [];
  }

  function composeM(f,g){
    return function(x){
       return g(x).flatMap(f);
    }
  }

  var p = composeM(duplicate, positive);
  console.log([1,-1, 2].flatMap(p)) // 输出[1,1,2,2]

四. Monad

有了上面的基本概念,下面就开始数学的表演了。数学是一门只关注“形式”的科学,形式上一样的东西在数学上可以认为是完全等价的,这就是我们常说的“抽象”的威力。按照“数学思维”,异步调用函数和列表变换函数的类型都满足一个特殊的模式

A -> M<B>

如果改成使用小写字母(打字时方便一些),再偷懒省略掉范型符号,就可以把上述类型模式表达为

a -> m b

如果上述类型的函数可以复合,则对应的composeM函数的类型为

(a -> m b) -> (x -> m a) -> (x -> m b)

也就是说 composeM把一个类型为 x -> m a的函数和一个类型为 a -> m b 的函数复合成一个类型为 x -> m b的函数。

m是什么?它就是一个符号,把它替换成Promise就表示异步调用函数,把它替换成List就表示集合变换函数,而如果把它替换成Identity,我们就得到了普通函数!所以,Identity也是Monad, 只不过是一种“平凡”的Monad, 称为Identity Monad。

Identity b == b, 所以  a -> Identity b == a -> b

有了结合律composeM,数学家想做的第一件事就是定义单位元unit, 它是一个对结合律来说透明的家伙

f composeM unit == unit composeM f == f

无论是左结合还是右结合,都相当于是什么也没结合!

根据composeM的类型声明,我们可以确定unit的类型为 a -> m a

// composeM(f, unit) == f
  (a -> m b) -> (a -> m a) -> (a -> m b)

满足这样性质的unit是否一定能存在?对于Monad,它是一定存在的。为什么?因为数学上Monad的定义就是“自函子范畴上的一个幺半群”,而所谓幺半群的定义就是有单位元的半群!

// 对于Promise而言
  function unit(a){
     return Promise.resolve(a);
  }

  // 对于List而言
  function unit(a){
     return [a];
  }
什么是半群?半群就是满足结合律的一切东西。为什么叫半群?因为它是不完整的“群”。为什么不完整?因为群要求具有单位元、结合律和逆元。Monad不是群,因为它没有要求有逆元。而可逆计算要求定义某种形式的逆元。参见
canonical:可逆计算:下一代软件构造理论zhuanlan.zhihu.com图标

如果我们采用一点“面向对象”的视角,希望突出一下那个看起来拽拽的m a, 则可以抛弃那个未知的x, 假定从m a类型的对象开始,定义一个新的函数bind(其实flatMap是一个更合适的名字)

// bind :: m a -> (a -> m b) -> m b
 function bind(ma, f){
   return composeM(f, ignored => ma)();
 }

bind(ma, f)对于Promise来说就是 ma.then(f), 对于List来说就是 ma.flatMap(f)。

如果采用中缀形式,并且连着应用多个函数,则类似于顺序执行,例如

ma bind f bind g

在概念层面上可以看作是从 ma开始,执行f, 再执行g。

数学是什么?数学是一种不安分,只要给数学一点颜色,它就要开染房!所以给了它一个单位元unit和一个结合律 composeM, 它就开始推导各种显然的和不显然的等价关系了。

上面我们用composeM定义了bind, 反过来,也可以用bind来定义composeM

function composeM(f,g){
     return function(a){
         return g(a).bind(f);
      }
  }

根据composeM的结合律,可以推导出bind的结合律

bind(unit(a), f) == f(a) 
  bind(ma, unit) == ma
  bind(bind(ma, f), g) == bind(ma, a => bind(f(a), g))

  // 翻译到Promise的实现,结合律对应于
  m.then(f).then(g) == m.then(function(x) { return f(x).then(g) } )

  // 翻译到List的实现,结合律对应于
  m.flatMap(f).flatMap(g) == m.flatMap(function(x){ return f(x).flatMap(g) })

利用bind和unit可以定义新的函数join和map

// join :: m (m a) -> m a
   function join(mma){
      return bind(mma, x => x); 
   }

  // map :: (a -> b) -> (m a -> m b)
  function map(f){
     return function(ma){
        return bind(ma, a => unit(f(a));
       }
   }

反过来,可以用join和map来定义bind

function bind(ma, f){
     return join(map(f)(ma))
  }

还可以证明更多的关系,例如:

compose(map(f), map(g)) == map(compose(f, g))
compose(join, map(join)) == compose(join, join)

为了得到上面这一串东西,我们付出的成本是什么?

  1. 定义一个函数类型 a -> m b, 这里a/m/b都是抽象的符号,具体是什么完全无所谓,只要能把一个东西弄成这个形式就行。
  2. 定义一个结合律composeM,确定具有如上类型的函数如何结合。
  3. 确定一个单位元unit, 它把一个类型a提升为m a, 但是它和任何函数结合都等于什么都没干。

然后我们就有了join/map/bind等等,以及它们之间让人眼花缭乱的衍生关系:你是我的依据,同时我是你的来源。注意,这一切都是免费的。

五. 作为设计模式的Monad

类似于工厂模式/单例模式/状态机模式等对于面向对象语言的作用,Monad是专门适用于函数组合的一种函数式语言的核心设计模式。Monad提供了一种对函数计算过程的通用抽象机制,关键是统一形式,统一操作模式,统一概念集合, 复用代码。

如果采用Monad的概念来改造Promise的实现,则在Java中可能类似如下接口定义

interface Promise<A>{
    <B> Promise<B> flatMap(Function<A, Promise<B>> f);

    static <T> Promise<T> unit(T a) { ... }

    static <A,B,C> Function<A, Promise<C>> compose(Function<B, Promise<C>> f, Function<A, Promise<B>> g){
        return a -> g.apply(a).flatMap(f);
    }

    // 接口根据数学关系提供一个缺省实现,实现类可以提供逻辑上等价的性能优化版本
    default <B> Promise<B> map(Function<A,B> f){
        return flatMap(a -> unit(f.apply(a)));
    }

    default Promise<A> join(Promise<Promise<A>> m){
        return m.flatMap(x -> x);
    }

    ... 其他方法
}

因为这种模式在函数式编程中很常见,一些程序语言会提供语法糖来方便编写,例如Python和Scala语言中的for-comprehension。

for( x <- mx;   y <- my; z <- mz)
  yield x + y + z

会被翻译为

mx.flatMap(function(x){
  return my.flatMap(function(y){
     return mz.flatMap(function(z){
       return unit(x + y + z);
     })
  })
})

最后一个flatMap和unit函数的作用可以被缩减为map调用

mx.flatMap(function(x){
  return my.flatMap(function(y){
     return mz.map(function(z){
       return x + y + z;
     })
  })
})

语法糖只是一种形式上的变换,因此只要一个接口上定义了flatMap和map函数,就可以使用for-comprehension语法糖,甚至它不满足结合律也无所谓!

需要注意一个细节,上式中为了通过闭包传递参数, flatMap是右结合的。一般我们自己写代码的时候,为了减少嵌套调用,我们都是尽量使用左结合的。例如

mx.flatMap(f).flatMap(g) 而不是 mx.flatMap(x -> f(x).flatMap(g))

六. Monad干了什么?

Monad相比于普通的函数复合,关键是引入了一个包装结构m, 相当于是把value包装在一个context中(monadic value = a value in a context),因此可以实现普通业务逻辑 + 统一环境处理的效果,有些类似AOP的作用。通过将一部分处理逻辑隐藏到环境中,整体调用形式就可以变成简单函数的嵌套调用,再通过形式变换即可实现线性表达形式,并且一般通过通用的for-comprehension语法提供语法糖支持。

参考List Monad, ma.bind(f)的执行逻辑可以分解为4步:

  1. 从包装结构中拆解出value, value的类型是a。有可能存在很多个value,会调用处理函f很多遍。
  2. 对每个value, 调用函数f, 返回一堆的包装结构,其中值的类型为b。
  3. bind内部拆解出所有类型为b的值,把它们通过某种运算合并在一起(join提供展平机制)。
  4. 再次包装为m b返回(unit提供类型提升机制用于创建包装结构)。

七. StateMonad

Monad对于函数式语言还有一个特别的意义,它提供了某种环境封装机制,因此可以把副作用隔离到某个环境对象中,保证函数式语言内在的“纯洁”。具体的做法可以参见下面StateMonad的实现。

假设,我们需要在程序中使用随机数

function addRandom(a){
  return a + Random.nextInt();
}

上面的函数依赖于全局变量Random,因此具有副作用,需要把它改造成通过参数来传递状态变量

 function addRandom(a, random){    return [a + random.nextInt(), random]; }

上面这个做法是一个通用模式。简单的说,为了避免依赖全局变量,我们必须把所有需要用到的状态变量都随身携带!因此我们需要定义的有状态函数的函数类型为

(a, s) -> (b, s)

利用currying, 可以知道它等价于 a -> (s -> (b, s))。也就是说为了封装状态,将简单函数的返回值类型修改为 s -> (b, s)即可将普通函数扩展到支持状态变化的情况。

考虑到Monad需要的函数形式是 a -> m b, 我们必须找到m的定义。这个其实很好办,只要把s -> (b,s)这个形式中所有除了b之外的符号都移动到左边,再给它起个名字叫作m就可以了。 具体做法为, 首先定义 (State s) b = s -> (b, s) , 则有状态函数的类型可以修改为 a -> State s b, 显然 可以用 State s 这两个符号来代表m

注意, State s b 在给定b的情况下对应的是一个函数,它还需要传入s参数,才能得到一个具体的值 (b,s)

有状态函数的复合形式为 ((b,s) -> (c, s)) -> ((a,s) -> (b,s)) -> ((a,s) -> (c,s))

通过符号改写,可以被重新写成 (b -> State s c) -> (a -> State s b) -> (a -> State s c)

注意,上面的变换仅仅只是把符号顺序重新排列了一下,可以想象通过字符串替换就可以直接从一种形式得到另一种形式。

想清楚形式之后,就可以定义bind和unit了。

function unit(a){
   // 返回的是State _ a, 其实就是一个函数
  return function(s){
     return [a, s];
  }
}

function bind(ma, f){
   return function(s){
      var r1 = ma(s); // [a, s1] = ma(s)
      var r2 = f(r1[0])(r1[1]); // [b,s2] = f(a)(s1)
      return r2; // [a和b的某种处理结果, s2];
   }
}

回到随机数的具体例子,我们可以定义

function addRandom(a){
      return function(random){
           return [a + random.nextInt(), random];
        }
    }

function multiplyRandom(a){
      return function(random){
           return [a * random.nextInt(), random];
        }
 }

bind(unit(3), addRandom)(random)最后的效果相当于是 [3 + 随机数, random]。 通过把random这个随机数生成器作为状态不断传递,可以避免依赖全局变量。

bind(unit(3), addRandom)(random)
==   function(random){
      [a, random] = ma(random) // 返回 [3, random]
      [b, random] = addRandom(a)(random) // 返回[3 + random.nextInt(), random]
      return [b, random]
   }(random)

整个操作过程其实就是把直接返回数据改造成返回函数,这个函数具有一个名为state的参数。bind的作用就是把这些函数串起来形成一个新的接收state变量的函数。 bind的实现过程负责把不停传递state变量的过程封装起来,这样从最外层调用者看来,似乎所有地方都不需要传递state参数。

// 看起来像是执行,但实际上仅仅是构造了一个函数f
 f = unit(3) bind addRandom bind multiplyRandom 
 调用 f(random) 才实际执行

StateMonad为什么可以封装副作用?原因在于StateMonad的bind仅仅负责组装函数,它返回的最终还是一个函数,这个函数并没有真的执行!

八. 其他的Monad

Monad实际上是很常见的一种处理模式,例如

  • 空值判断。比如取值a.b.c,我们一般情况下需要检查对象是否为空
if(a != null){
    if(a.b != null){
         if(a.b.c != null){
              ...
           }
     }
  }

但是kotlin语言中提供一个?机制,使得 a?.b?.c?不用判断是否为null, 如果遇到空值就一直返回null。这类似于OptionMonad。

  • 延迟加载。ORM引擎通过load(entityId)取到的对象是Proxy对象,只有实际调用它的属性时才会加载对象,如果访问的是关联对象属性,则返回的仍然是Proxy包装对象。
entity.getDept() 相当于是 load(entityProxy, getDept);

  function getDept(entity){
    return buildProxy(entity.deptId)
  }
  • Spark的RDD。RDD上提供了map/flatMap/filter等函数。而且spark的执行还利用了结合律,它通过依赖分析,把窄依赖的函数先结合在一起,然后再放到一个Stage中执行。

参考文献

  1. JavaScript Monads Made Simple
  2. Mostly adequate guide to FP (in javascript)
编辑于 2019-05-12

文章被以下专栏收录