StateMonad
State Monad 相比起其他类型的 Monad 例如 Maybe 或者 Either 要更难理解。并不是指其代码有多么复杂,而是它的作用并不是特别直观,以至于在初学的时候要花大量的时间来理解它的作用。因此本文从一个例子出发,来说明为什么需要 State Monad。Monad 虽然是被 haskell 发扬光大,但鉴于 haskell 的普及程度,本文还是决定采用 TS 来描述。另外,Monad 目前在大部分语言中都存在,不一定非要通过 haskell 来学习。
随机数生成
State Monad 顾名思义是和状态(State)相关的一个 Monad。这里先简单的介绍一个随机数生成方法线性同余法
,其原理非常简单:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> X n + 1 = ( a ∗ X n + b ) % m X_{n+1} = (a*X_n + b) \% m </math>Xn+1=(a∗Xn+b)%m
其中 a, b, m 都是参数。从公式来看,这是一个递推公式,即本次随机数的值也依赖于上一次随机数的结果。这里采用一个常用的参数即 a=25214903917,b=11,c=65535
并在 ts 里面实现:
ts
class Random{
readonly a = 25214903917
readonly b = 11
readonly m = 65535
state: number
constructor(){
this.state = (this.a * Date.now() + this.b) % this.m
}
nextInt(range: number): number{
this.state = (this.a * this.state + this.b) % this.m
return this.state % range
}
}
let random = new Random()
console.info(random.nextInt(100)) // 87
console.info(random.nextInt(100)) // 14
console.info(random.nextInt(100)) // 21
这段从面向对象的编程模式来看并没有什么问题。我们定义了一个成员变量 state 来存储上一次产生的随机数从而达到实现递推公式的目的。这里的 state 就是 State Monad 中的 state,可以认为是一个函数的外部变量。但是从函数式编程的角度来看,nextInt
这个方法修改了一个外部变量使得它变成了一个非纯函数。我们当然希望函数越纯越好
,于是可以这样实现:
ts
function nextInt(range: number, state: number): [number, number]{
const nextState = (25214903917 * state + 11) % 65535
return [nextState % range, nextState]
}
const [rand1, state1] = nextInt(100, Date.now())
const [rand2, state2] = nextInt(100, state1)
const [rand3] = nextInt(100, state2)
console.info(rand1, rand2, rand3) //58, 42, 65
现在 nextInt
是一个纯函数了,但是看起来总觉得怪怪的。为了实现纯函数,我们不得不把 state 通过参数传入传出,这样使用起来也太麻烦了。有没有一种办法能够既有保持纯函数,又不手动传递状态呢。这时候就该 StateMonad 粉墨登场了。
使用 StateMonad 实现
随机数生成的问题或者其他需要 state 的场景我们总可以抽象为 S => (A, S)
即通过一个状态 S 得到本次的输出 A 以及下一次的状态 S,可以称之为状态转化函数。StateMonad 并不直接存储状态,而是存储状态的转换:
ts
type StateFunction<S, A> = (state: S) => [A, S];
class StateMonad<S, A> {
runState: StateFunction<S, A>
constructor(runState: StateFunction<S, A>){
this.runState = runState
}
flatMap<B>(transform: (args: A) => StateMonad<S, B>): StateMonad<S, B>{
return new StateMonad(
s => {
const [v, newS] = this.runState(s)
return transform(v).runState(newS)
}
)
}
}
这里的 runState
就是刚刚提到的状态转换函数。但是,我们还需要一个方法将不同的 StateMonad
连接起来,即上面的 flatMap
方法。其签名为 A => StateMonad<S, B>
,即根据上一个 StateMonad 的输出,产生一个新的 StateMonad,并且让状态在这两个 Monad 之前传递。我们来采用 StateMonad 来实现一下:
ts
class Random extends StateMonad<number, number>{}
const nextInt = (range) => new Random(state => {
const nextState = (25214903917 * state + 11) % 65535
return [nextState % range, nextState]
})
const next = nextInt(100)
const [res] = next.flatMap(
x1 => next.flatMap(
x2 => next.flatMap(
x3 => new StateMonad<number, [number, number, number]>(s => [[x1, x2, x3], s])
)
)
).runState(Date.now())
console.info(res) // 74, 14, 59
emmm 很好,我们刚从非纯函数中解放出来,又踏入了回调地狱中。为了获取结果我们不得不嵌套 3 层回调函数,非常的难受。当然在 scala 或者 haskell 中我们可以使用 do-notation
来完成类似的操作,这样就更优雅了:
scala
for {
x1 <- next
x2 <- next
x3 <- next
} yield (x1, x2, x3)
不过这是一个使用 ts 的例子,我们没有这样的语法糖。但是忽略掉难看的回调函数,我们不得不承认,这里面的每一个函数都是纯的不能再纯的纯函数。nextInt 是纯函数,next.flatMap 同样是纯函数。我们使用了纯函数实现了随机算法,并且没有手动传递状态,这就是 StateMonad 的威力。
Dive Into flatMap
flatMap 可以说是 StateMonad 中最核心的一个函数了。它的参数是一个函数,其签名为:
ts
(args: A) => StateMonad<S, B>
其接受一个值,并将其转换为一个新的 StateMonad。为什么需要这样的函数,是因为很多时候我们需要把根据上一个 StateMonad 输出
(注意不是状态),并结合当前 StateMoand 输出。例如我需要三个随机数,那么我需要首先获得第一个随机数,再获得第二随机数,这样我们就必须能显示的获得前一个 StateMonad 的输出,也就是 这里的 A。我们再来看 flatMap
的实现:
ts
{
return new StateMonad(
s => {
const [v, newS] = this.runState(s) // 1. 执行状态转换函数,获取最新的状态
return transform(v).runState(newS) // 2. 根据本次输出得到下一个 StateMonad,并且将状态传递给下一个 StateMonad
}
)
}
}
实际上这里的核心代码就两行,第一行获取最新状态,第二行生成新的 StateMonad 并传递状态。简洁且优雅。 了解了原理之后,刚刚的例子我们还可以优化优化:
javascript
// 新增一个 collect 来收集输出
const collect = <S, T>(x: T) => new StateMonad<S, T>(s => [x, s])
const next = nextInt(100)
const [res] = next.flatMap(
x1 => next.flatMap(
x2 => next.flatMap(
x3 => collect([x1, x2, x3])
)
)
).runState(Date.now())
嗯,看起来舒服多了(并没有)。
总结
虽然本文使用随机数生成来简单介绍了 StateMonad 的使用场景以及原理,但是 StateMonad 的应用场景显然更加广泛。只要是符合 S => (A, S)
这样的场景都能使用。当然,除了 flatMap 之外, map
和 return
在本文中并没有介绍,但是 flatMap 就足够我们理解 StateMoand 了。感兴趣的同学也可以想一想如何在已经定义了 flatMap
的情况下获得 map
。