若是你接触过函数式编程,你极可能遇到过 Monad 这个奇怪的名词。因为各类神奇的缘由,Monad 成了一个很难懂的概念。Douglas Crockford 曾转述过这样一句话来形容 Monad:javascript
Once you understand Monad, you lose the ability to explain it to someone else.html
这篇文章中,我会从使用场景出发来一步步推演出 Monad。而后,我会进一步展现一些 Monad 的使用场景,并解释一些我从 Haskell 翻译成 JS 的 ADT (Algebraic Data Type)。最后,我会介绍 Monad 在范畴论中的意义,并简单介绍下范畴论。java
假设你被一个奇怪的丛林部落抓住了,部落长老知道你是程序员,要你写个应用,写出来就放你走。做为一个资深码农,你暗自窃喜,内心想着老夫经历了这么多年产品经理各类变态需求的千锤百炼,没什么需求能难倒我!长老彷佛看出了你的心思,加了一个要求:这个应用只能用纯函数写,不能有状态机,不能有反作用!而后你崩溃了……react
再假设你不知道函数式编程,但你足够聪明,你可能会发明出一个函数来知足这个奇葩的要求。这个函数如此强大,你可能会叫它超级函数,但其实它无可避免就是一个 Monad。git
接下来咱们就来一步步推演出这个超级函数吧。程序员
函数组合你们都应该很是熟悉。好比,Redux 里面在组合中间件的时候会用到一个 compose
函数 compose(middleware1, middleware2)
。函数组合的意思就是,在若干个函数中,依顺序把前一个函数执行的结果传个下一个函数,逐次执行完。compose
函数的简单实现以下:github
const compose = (...fns) => fns.reduce((f, g) => (...args) => f(g(...args)))
复制代码
函数组合是个很强大的思想。咱们能够利用它把复杂问题拆解成简单问题,把这些简单问题逐个解决了以后,再把这些解决方案组合起来,就造成了最终的解决方案。编程
这里偷个懒再举一下我以前文章的例子吧:后端
// 在产品列表中找到相应产品,提取出价格,再把价格格式化
const formalizeData = compose(formatCurrency, pluckPrice, findProduct);
formalizeData(products)
复制代码
若是你理解了上面的代码,那么恭喜你,你已经懂了 Monoid!数组
所谓 Monoid 能够简单定义以下:
同时,这个二元运算要知足这些条件:
注意,上面这个定义是集合论中的定义,这里还没涉及到范畴论。
函数要能组合,类型签名必须一致。若是前一个函数返回一个数字,后一个函数接受的是字符串,那么是没办法组合的。因此,compose
函数接受的函数都符合以下函数签名:fn :: a -> a 也就是说函数接受的参数和返回的值类型同样。知足这些类型签名的函数就组成了 Monoid,而这个 Monoid 中的特殊元素就是 identity
函数:const identity = x => x;
结合律和单元律的证实比较简单,我就不演示了。
上面演示的函数组合看起来很舒服,可是实际用处还不是很大。由于 compose
接受的函数都是纯函数,只适合用来计算。而现实世界没有那么纯洁,咱们要处理 IO,逻辑分支,异常捕获,状态管理等等。单靠简单的纯函数组合是不行的。
先假设咱们有两个纯函数:
const addOne = x => x + 1
const multiplyByTwo = x => 2 * x
复制代码
理想状态下是咱们能够组合这两个函数:
compose(
addOne,
multiplyByTwo
)(2) // => 5
复制代码
可是咱们出于各类缘由要执行一些反作用。这里仅为了演示,就简单化了。假设上面两个函数在返回值以前还向控制台打印了内容:
const impureAddOne = x => {
console.log('add one!')
return x + 1
}
const impureMultiplyByTwo = x => {
console.log('multiply by two!')
return 2 * x
}
复制代码
如今这两个函数再也不纯洁了,咱们看不顺眼了。怎样让他们恢复纯洁?很简单,做弊偷个懒:
const lazyImpureAddOne = x => () => {
console.log('add one!')
return x + 1
}
// Java 代码看多了以后我也学会取长变量名了^_^
const lazyImpureMultiplyByTwo = x => () => {
console.log('multiply by two!')
return 2 * x
}
复制代码
修改以后的函数,提供一样的参数,每次执行他们都返回一样的函数,能够作到引用透明。这就叫纯洁啊!
而后咱们能够这样组合这两个偷懒函数:
composeImpure = (f, g) => x => () => f(g(x)())()
const computation = composeImpure(lazyImpureAddOne, lazyImpureMultiplyByTwo)(8)
computation() // => multiply by two!add one! 17
复制代码
在执行 computation
以前,咱们都在写纯函数。
我知道,我知道,上面的写法可读性不好。这样子写也不可维护。咱们来写个工具函数方便咱们组合这些不纯洁的函数:
const Effect = f => ({
map: g => Effect(x => g(f(x))),
runWith: x => f(x),
})
Effect.of = value => Effect(() => value)
复制代码
这个 Effect
函数接受一个非纯函数 f 为参数,返回一个对象。这个对象里面的 map
方法把自身接受的非纯回调函数 g 和 Effect
的非纯回调函数组合后,将结果再塞回给 Effect
。因为 map
返回的也是对象,咱们须要一个方法把最终的计算结果取出来,这就是 runWith
的做用。
用 Effect
重现咱们上一步的计算以下:
Effect(impureAddOne)
.map(impureMultiplyByTwo)
.runWith(2) // => add one!multiply by two! 6
复制代码
如今咱们就能够直接用非纯函数了,不用再用那么难读的函数调用了。在执行 runWith
以前,程序都是纯的,任你怎么组合和 map
。
若是你懂了上面的代码,那么恭喜你,你已经懂了 Functor!
一样,Functor 还要知足一些条件:
你能够把 Functor 理解成一个映射函数,它把一个类型里的值映射到同一个类型的其它值。好比数组操做 [1, 2, 3].map(String) // -> ['1', '2', '3'], 映射以后数据类型同样(仍是数组),内部结构不变。我在以前的文章中说数组就是个 Functor,这种表述是有误的,应该是说数组知足 Functor 的返回值条件。
上面的 Effect 函数把非纯操做都放进了一个容器里面,这样子作了以后,若是要对两个独立非纯操做的结果进行运算,就会很麻烦。
好比,咱们在 window 全局读取两个值 x, y, 并将读取结果求和。我知道这个例子很简单,不用函数式编程很容易作到,我只是在举简单例子方便理解。
假设 window 对象已经存在两个值 {x: 1, y: 2, ...otherProps}。咱们这样取:
const win = Effect.of(window)
const xFromWindow = win.map(g => g.x)
const yFromWindow = win.map(g => g.y)
复制代码
xFromWindow
和 yFromWindow
返回的都是一个 Effect 容器,咱们须要给这个容器新添加一个方法,以便将两个容器里层的值进行计算。
const Effect = f => ({
map: g => Effect(x => g(f(x))),
runWith: x => f(x),
ap: other => Effect(x => other.map(f(x)).runWith()),
})
复制代码
而后,咱们提供一个相加函数 add:
const add = x => y => x + y
复制代码
接下来借助这个 ap 函数,咱们能够进行计算了:
xFromWindow
.map(add)
.ap(yFromWindow)
.runWith() // => 3
复制代码
因为这种先 map 再 ap 的操做很广泛,咱们能够抽象出一个工具函数 liftA2:
const liftA2 = (f, m1, m2) => m1.map(f).ap(m2)
复制代码
而后能够简化点写了:
liftA2(add, xFromWindow, yFromWindow).runWith() // => 3;
复制代码
注意运算函数必须是柯里化函数。
新增 ap 方法以后的 Effect 函数除了是 Functor,仍是 Applicative Functor。这部分彻底看代码还不是很好懂。若是你不理解上面的代码,没有关系,它并不影响你理解 Monad。另外,不用纠结于本文代码里的具体实现。不一样的 Applicative 的 ap 方法实现都不同,能够多看几个。Applicative 是介于 Functor 和 Monad 之间的数据类型,不提它就不完整了。
Applicative 要知足下面这些条件:
假设咱们要从 window 全局读取配置信息,此配置信息提供目标 DOM 节点的类名 userEl
;根据这个类名,咱们定位到 DOM 节点,取出内容,而后打印到控制台。啊,读取全局对象,读取 DOM,控制台输出,全是做用,好可怕…… 咱们先用以前定义的 Effect
试试看行不行:
// DOM 读取和控制台打印的行为放进 Effect
const $ = s => Effect(() => document.querySelector(s))
const log = s => Effect(() => console.log(s))
Effect.of(window)
.map(win => win.userEl)
.map($)
.runWith() //因为上一个 map 里层也返回了 Effect,这里须要抹平一层
.map(e => e.innerHTML)
.map(log)
.runWith()
.runWith()
复制代码
勉强能作到,可是这样子先 map
再 runWith
实在太繁琐了,咱们能够再给 Effect
新增一个方法 chain:
const Effect = f => ({
map: g => Effect(x => g(f(x))),
runWith: x => f(x),
ap: other => Effect(x => other.map(f(x)).runWith()),
chain: g =>
Effect(f)
.map(g)
.runWith(),
})
复制代码
而后这样组合:
Effect.of(window)
.map(win => win.userEl)
.chain($)
.map(e => e.innerHTML)
.chain(log)
.runWith();
复制代码
线上 Demo 见这里
Voila! 咱们发现了 Monad!
在写上面的代码的时候我仍是以为逐行解释代码比较繁琐。咱们先无论代码具体实现,从函数签名开始看 Monad 是怎么回事。
让咱们回到 Monoid。咱们知道函数组合的前提条件是类型签名一致。fn :: a -> a. 但在写应用时,咱们会让函数除了返回值以外还干其余事。这里无论具体干了哪些事,咱们能够把这些行为扔到一个黑盒子里(好比刚刚写的 Effect),而后函数签名就成了 fn :: a -> m a。m 指的是黑盒子的类型,m a 意思是黑盒子里的 a. 这样操做以后,Monoid 接口再也不知足,函数不能简单组合。
但咱们仍是要组合。
其实很简单,在组合以前把黑盒子里的值提高一层就好了。最终咱们实现的组合实际上是这样:fn :: m a -> (a -> m b) -> m b. 这个签名里,函数 fn 接受黑盒子里的 a 为参数,再接受一个函数为参数,这个函数的入参类型是 a,返回类型是黑盒子里的 b。最终,外层函数返回的类型是黑盒子里的 b。这个就是 chain 函数的类型签名。
fn :: a -> m a 签名里面的箭头叫 Kleisli Arrow,其实就是一种特殊的函数。Kleisli 箭头的组合叫 Kleisli Composition,这也是 Ramda 里面 composeK 函数的来源。这里先了解一下,等下还会用到这个概念。
Monad 要知足的一些定律以下:
不少人误解 JS 里面的 Promise 就是个 Monad,我以前也有这样的误解,但后来想明白了。按照上面的定律来看检查 Promise:
Left identity:
Promise.resolve(a).then(f) === f(a)
复制代码
看起来知足。可是若是 a 是个 Promise 呢?要处理 Promise,那 f 应该符合符合这个函数的类型签名:
const f = p => p.then(n => n * 2)
复制代码
来试一下:
const a = Promise.resolve(1)
const output = Promise.resolve(a).then(f)
// output :: RejectedPromise TypeError: p.then is not a function
复制代码
报错的缘由是,a 在传给 f 以前,就已经被 resolve 掉了。
Right identity:
p.then(x => Promise.resolve(x)) === p
复制代码
知足。
Associativity:
p.then(f).then(g) === p.then(x => f(x).then(g))
复制代码
和左单元律同样,只有当 f 和 g 接受的参数不为 Promise,上面才成立。
因此,Monad 的三个条件,Promise 只符合一条。
上面演示的 Effect 函数,和我以前文章《不完整解释 Monad 有什么用》 里面演示的 IO 函数是同一个 ADT,它是用来处理程序中的做用的。函数式编程中还有不少不一样用处的 ADT,好比,处理异步的 Future,处理状态管理的 State,处理依赖注入的 Reader 等。关于为何这个 Monad 是代数数据类型,Monad 和你们熟知的代数有什么关系,这里不展开了,有兴趣进一步了解的话能够参考 Category Theory for Programmers 这本书。
这里再展现两个 ADT,Reader 和 State,比较它们 chain 和 ap 的不一样实现,对比 Monadic bind 函数类型签名 chain :: m a -> (a -> m b) -> m b,思考下它们是怎样实现 Monad 的。
const Reader = computation => {
const map = f => Reader(ctx => f(computation(ctx)))
const contramap = f => Reader(ctx => computation(f(ctx)))
const ap = other => Reader(ctx => computation(ctx)(other.runWith(ctx)))
const chain = f => {
return Reader(ctx => {
const a = computation(ctx)
return f(a).runWith(ctx)
})
}
const runWith = computation
return Object.freeze({
map,
contramap,
ap,
chain,
runWith,
})
}
Reader.of = x => Reader(() => x)
复制代码
题外话补充下,上面这种叫“冰冻工厂”的工厂函数写法,是我我的偏好。这样写会有必定性能和内存消耗问题。用 Class 性能更好,看你选择。
程序中可能会遇到某个函数对外部环境有依赖。用纯函数的写法,咱们能够把这个依赖同时传进函数。这样子,函数签名就是 fn :: (a, e) -> b。e 表明外部环境。这个签名不符合咱们前面提到的 a -> m b. 咱们到如今还只提到了一次函数柯里化,这个时候再一次要用柯里化了。柯里化后,有依赖的函数类型签名是 fn :: a -> (e, b), 你可能认出来了,中间那个箭头就是 Kleisli Arrow。
假设咱们有一段程序的多个模块依赖了共同的外部环境。要作到引用透明,咱们必须把这个环境传进函数。可是每个模块若是都接受外部环境为多余参数,那这些模块是没办法组合的。Reader 帮咱们解决这个问题。
来写个简单程序,执行这个程序时输出“你好,xx ... 再见,xx”。xx 由执行时的参数决定。
const concat = x => y => y.concat.call(y, x)
const greet = greeting => Reader(name => `${greeting}, ${name}`)
const addFarewell = farewell => str =>
Reader(name => `${str}${farewell}, ${name}`)
const buildSentence = greet('你好')
.map(concat('...'))
.chain(addFarewell('再见'))
buildSentence.runWith('张三')
// => 你好, 张三...再见, 张三
复制代码
上面这个例子过于简单。输出一个字符串用一个函数就行,用不了解构和组合。可是,咱们能够很容易扩展想象,若是 greet
和 addFarewell
是很复杂的模块,必须拆分,此时组合的价值就出现了。
在学习 Reader 时,我发现一篇很不错的文章。这篇文章大开脑洞,用 Reader 实现 React 里面的 Context。有兴趣能够了解下。The Reader monad and read-only context
// 这个写法你可能不习惯。
// 这是 K Combinator,Ramda 里面对应函数是 always, Haskell 里面是 const
const K = x => y => x
const State = computation => {
const map = f =>
State(state => {
const prev = computation(state)
return { value: f(prev.value), state: prev.state }
})
const ap = other =>
State(state => {
const prev = computation(state)
const fn = prev.value
return other.map(fn).runWith(prev.state)
})
const chain = fn =>
State(state => {
const prev = computation(state)
const next = fn(prev.value)
return next.runWith(prev.state)
})
const runWith = computation
const evalWith = initState => computation(initState).value
const execWith = initState => computation(initState).state
return Object.freeze({
map,
ap,
chain,
evalWith,
runWith,
execWith,
})
}
const modify = f => State(state => ({ value: undefined, state: f(state) }))
State.get = (f = x => x) => State(state => ({ value: f(state), state }))
State.modify = modify
State.put = state => modify(K(state))
State.of = value => State(state => ({ value, state }))
复制代码
State 里层最终返回的值由对象构成,对象里面包含了此时计算结果,以及当前的应用状态。
再举个简单的例子。假设咱们根据某状态数字进行计算,首先咱们在这个初始状态上加某个数字,而后咱们把状态 + 1, 再把新的状态和前一步的计算相乘,算出最终结果。一样,例子很简单,但已经包含了状态管理的核心。来看代码:
const add = x => y => x + y
const inc = add(1)
const addBy = n => State.get(add(n))
const multiplyBy = a => State.get(b => b * a)
const incState = n => State.modify(inc).map(K(n))
addBy(10)
.chain(incState)
.chain(multiplyBy)
.runWith(2) // => {value: 36, state: 3}
复制代码
上面最后一步组合,每一个函数类型签名一致,a -> m b, 构成 kleisli 组合,咱们还能够用工具函数改进一下写法:
const composeK = (...fns) =>
fns.reduce((f, g) => (...args) => g(...args).chain(f))
const calculate = composeK(
multiplyBy,
incState,
addBy
)
calculate(10).runWith(2) // => {value: 36, state: 3}
复制代码
Monad 有一个“臭名昭著”的定义,是这样:
A monad is just a monoid in the category of endofunctors, what's the problem?
我见过这句话的中文翻译。可是这种“鬼话”无论翻不翻译都差很少的表达效果,我以为仍是不用翻译了。不少人看到这句话不去查出处和上下文,就以此为据来批评 FP 社区故弄玄虚,我感到很无奈。
这句话出自这篇文章 Brief, Incomplete and Mostly Wrong History of Programming Languages. 这篇文章用戏谑调侃的方式把全部主流编程语言黑了一个遍。上面那句话是用来黑 Haskell 的。原本是句玩笑,结果就以讹传讹了。
上面那句话的原始出处是范畴论的奠定之做 Categories for the Working Mathematician 原话更拗口:
All told, a monad in X is just a monoid in the category of endofunctors of X, with product × replaced by composition of endofunctors and unit set by the identity endofunctor.
注意书名,那是给数学家看的,不是给程序员看的。你看不懂很正常,看不懂还要骂这些学术泰斗装逼就是你的不对了。
首先,说明下我数学学得差,我接下来要讲的名词我知道是在研究什么,再深刻细节我就不知道了。
你们知道数学有不少分支,好比集合论,逻辑学,类型论(Type Theory) 等等。后来,有些数学家发现,若是用足够抽象的概念工具去考察这些分支,其实他们都在讲一样的东西。桥接这些概念的工具是 isomorphism (同构)。isomorphic 就是在对象之间能够来回转换,每次转换没有信息丢失。好比,在逻辑学里面研究的某个问题,可能和类型论里面研究是同一个问题,只要二者之间能造成 isomorphism。
统一数学各分支的理论就是范畴论。范畴论须要足够抽象,避免细节,才能在相差巨大的各数学分支之间发现同构。这也是为何范畴论必须要用一些生僻的希腊词根合成词。由于它实在太抽象了,很难找到现有的词汇去对应它里面的一些概念。混用词汇确定会致使误解。
再后来,FP 祖师爷之一 Haskell Curry,和另外一个数学家一块儿发现了 Curry–Howard Isomorphism。这个理论证实了 proofs as programs, 就是说写电脑程序(固然是函数式)和写逻辑证实是一回事,二者造成同构。再后来,这个理论被扩展了一下,成了 Curry–Howard-Lambek Isomorphism, 就是说逻辑学,程序函数,和范畴论,三者之间造成同构。
看了上面的理论背景,你应该明白了为何函数式编程要从范畴论里面获取理论资源。
范畴实际上是很简单的一个概念。范畴由一堆(这个量词好难翻译,我见过 a bunch, a collection, 可是不能说 a set)对象,以及对象之间的关系构成。我分两部分介绍。
对象 (Object): 范畴论里面的对象和编程里面的对象是两回事。范畴中的对象没有属性,没有结构,你能够把它理解为不可描述的点。
箭头 (arrow, morphism, 两个词说的是同一个东西, 我后面就用箭头了): 链接对象,表示对象之间的关系。一样,箭头也是一个没有结构没有属性的一种 primitive。它只说明了对象之间存在关系,并不能说明是什么关系。
对象和箭头要构成一个范畴,还要知足这两个条件:
能够看出范畴论的起点真的很是简单。很难想象基于这么简单的概念能构建出一个完整的数学理论。
我一开始试着在范畴论中来解释 Monad,以失败了结。要介绍的拗口名词太多了,一篇文章根本讲不完。因此本文会折中一下,仍是用集合论的视角来解释一下范畴论概念。(范畴论的单个对象能够对应成一个集合,可是范畴论禁止谈论集合元素,全部关于对象的知识都由箭头和组合推理出来,因此很头疼。)
勘误:如下内容是我在仓促学了范畴论知识后想固然的推断,不够准确,请参考知乎评论区讨论。目前我暂无精力重学重写,见谅。
还记得咱们是用集合来定义 Monoid 的吧?Monoid 其实就是一个只有一个对象的范畴。范畴和范畴之间的映射叫 Functor。若是一个 Functor 把范畴映射回自身,那么这个 Functor 就叫 Endofunctor。Functor 和 Functor 之间的映射叫 Natural Transformation. 函数式编程其实只处理一个范畴,就是数据类型(Types)。因此,咱们前面提到的 Functor 也是 Endofunctor。
回到前面 Monad 中 chain 的类型签名:
chain :: m a -> (a -> m b) -> m b
能够看出 Monad 是把一个类型映射回自身(m a -> m b),那么它就是一个 Endofunctor。
再看看 Monad 中所运用的 Natural Transformation。仍是看 chain 的签名,前半部分 m a -> (a -> m b) 执行以后,类型签名是 m (m b), 而后再和后面的连起来,就是 m (m b) -> m b. 这其实就是把一个 functor (m (m b)) 映射到另外一个 Functor (m b)。m (m b) -> m b 看起来是否是很眼熟?一个 Functor 和本身组合,造成同一个范畴里的 Functor,这种组合就是 Monoid 啊!咱们一开始定义的 Monoid 中的二元运算,在 Monad 中其实就是 Natural Transformation。
那么,再回到这一部分开始时的定义:
A monad is just a monoid in the category of endofunctors.
有没有好理解一点?
这篇文章的目的不是鼓励你在你的代码中消灭状态机,消灭反作用,我本身都作不到的。我司后端是用 Java 写的,若是我告诉后端同事 “Yo,你的程序里不能出现状态机哦……”,怕是会被哄出办公室的。那么,为何要了解这些知识?
计算机科学中有两条截然相反的路径。一条是自下而上,从底层指令开始往上抽象(优先考虑性能),逐渐靠近数学。好比,一开始的 Unix 操做系统是用汇编写的,后来发现用汇编写程序太痛苦了,须要一些抽象,因此出现了高级语言 C,再后来因为各类编写应用的需求,出现了更高级的语言如 Python 和 JavaScript。另外一条路径是自上而下的,直接从数学开始(Lambda 演算),不考虑性能和硬件情况,按需逐渐减小抽象。前一条路径明显占了主流,表明语言是 Fortran, C, C++, Pascal, 和 Java 等。后面一条路径不够实用,比较小众,表明语言是 Algo, LISP 和 Haskell 等。
这两个阵营确定是有争论的。前者想劝后者从良:你别扔给我这么多函数,我无法不影响性能状况下处理那么多垃圾回收和函数调用!后者也想叫醒前者:不要过早深刻硬件细节,你会把本身锁定在没法逆转的设计错误上!二者分道扬镳了 60 多年,这些年总算开始融合了。好比,新出现的程序语言如 Scala,Kotlin,甚至系统编程语言 Rust,都大量借鉴了函数式编程的思想。
学些高阶抽象还能帮助你更容易理解一些看起来很复杂的概念。转述一个例子。C++ 编程里面最高的抽象是模板元编程(Template Meta Programming),听说很难懂。可是据 Bartosz Milewski 的解释,之因此这个概念难懂,是由于 C++ 的语言设计不适合表达这些抽象。若是你会 Haskell,就会发现其实一行代码就完成了。
本文还发表在 Lambda Academy
参考: