写在前面
最早接触过,后来又了解了和,实际上还有很多(比如、、等),位于mtl package,可以通过ghc-pkg命令来查看:
P.S.Haskell Platform默认包含mtl package,不必手动安装
一.Writer Monad
追踪执行过程
在理解递归算法的时候,有一个强烈的需求,就是想要记录中间过程。当时是这样做的:
通过来添加日志:
When called, trace outputs the string in its first argument, before returning the second argument as its result.
接受一个字符串和值,打印输出字符串,再原样返回输入的值,例如:
成功追踪到了执行过程,但需要修改源码,把每个函数都换成带日志的版本太麻烦,所以通过工具函数来做(想知道什么就什么):
以Haskell经典快排为例:
添加日志,看左边的处理过程():
试玩一下:
从日志得知,第一趟左边是(pivot是),继续下去是(pivot是),然后左边就是了(pivot是),(的)另一边的第一趟左边是,继续下去左边就是了。原始数组的左边处理完毕,右边类似,不再赘述
勉强能解决问题,但存在几个缺陷:
日志输出混在结果里,日志看起来不很直观
日志会影响原结果输出,缺少隔离
只能打印输出,没办法收集起来进一步处理,不够灵活
那么,想要追踪执行过程的话,有没有更优雅的方式?
Writer登场
能否在运算的同时,自动维护一份操作日志?
如果把附加的日志信息看做context,似乎与Monad有些关系,比如可以在值参与运算的同时,自动收集日志(维护这个context)
这就是的由来:
Writer则是加进一个附加值的context,好比log一般
Writer可以让我们在计算的同时搜集所有log纪录,并汇集成一个log并附加在结果上
长这样:
从类型声明来看,是对元组()的包装,被指定成了:
看起来没什么用,仔细看一下:声明了一个叫做的包装类型,还实现了,的行为是把给定值包起来,的行为是对左侧包起来的值应用右侧函数。还是没发现有什么用……实际上,它相当于界的,能够把一个值包成参与运算,此外什么也不做,就应用场景而言,就像一样,有些时候就是需要个什么都不做的(就像有时候需要个什么都不做的函数一样)
Identity allows us to define just monad transformers and then define their corresponding monads just as SomeT Identity.
P.S.关于的更多讨论,见Why is Identity monad useful?
接下来看的实现:
其中是值的类型,是附加的的类型。从实现来看,从左侧取出值和附加信息,将右侧函数应用到上,并从结果取出值和附加信息,结果值为,附加信息为,最后用包装结果返回类型的值,作为值构造器的参数
注意,关键点就是在值运算的同时,对附加信息做,以此保留日志context,实现自动维护操作日志
令的话(就是这么定义的),具体过程相当于:
P.S.中的表示惰性模式匹配(具体见Haskell/Laziness | Lazy pattern matching):
prepending a pattern with a tilde sign delays the evaluation of the value until the component parts are actually used. But you run the risk that the value might not match the pattern — you’re telling the compiler ‘Trust me, I know it’ll work out’. (If it turns out it doesn’t match the pattern, you get a runtime error.)
试玩一下:
没有直接暴露出值构造器,但可以通过函数来构造,例如:
更进一步地,可以用更清晰的do表示法来描述:
日志黏在一起了,换用数组来盛放:
可以从中分离出计算结果和日志:
上面提到的几个缺陷似乎都完美解决了,还有个问题,如果只想插入一句无关的日志呢?
当然可以。可以用来插入不含值的额外信息:
类似于I/O场景里的:
作用也完全一致,不含值,仅记录一条信息,例如:
能够以类似后缀表达式的形式,把操作数和操作符记录下来:
具体过程相当于:
回过头看追踪执行过程这件事,的解决方案是给参与运算的值添上日志context,在运算过程中持续维护这份日志(通过内部的),这样运算结果的context就带有完整的操作日志:
我们不过是把普通的value重写成Monadic value,剩下的就靠跟来帮我们处理一切
所以要想把普通函数变成带日志的版本,只要把参数(和运算中的常量)包装成就好了
Difference list
上面我们用了List来盛放日志,隐约有点不安
因为List的运算默认右结合(即向List头部插入),效率较高。如果频繁向List尾部插入的话,每次都需要遍历构建左边的List,效率很低。那么,有没有更高效的List?
有,叫做Difference list,能够进行高效的操作。其实现思路非常巧妙:
首先,把每个List都转换成函数:
注意:关键点是函数体里只对List做,这样就保证了运算的效率(头部插入效率很高)
自然地,List的操作就变成了函数组合:
给Difference list(是个接受一个List参数的函数)传入空List就能取出结果List:
所以,这样定义:
再实现:
试玩:
那么,性能差异到底有多少?简单测试一下:
倒着数数的场景,利用记录倒数过程中的每个数,区别在于用List盛放日志,而用了
多数一会儿,比如五十万个数:
就肉眼可见的效率而言,越跑越慢,始终流畅输出
P.S.更科学的测试方法,见Performance | 5 Benchmarking libraries
P.S.DiffList的完整实现,见spl/dlist
P.S.另外,Haskell Platform默认不带dlist package(所以默认也没有内置的),需要手动装,见本文开头
二.Reader Monad
实际上就是,函数也是Monad,这怎么理解?
一个函数也可以被想做是包含一个context的。这个context是说我们期待某个值,他还没出现,但我们知道我们会把他当作函数的参数,调用函数来得到结果。
也就是说,之前已经知道了它是,也是。竟然还是,其具体实现如下:
没有额外实现,所以是的:
接受一个任意值(),返回一个函数(),该函数接受一个参数,忽略掉并返回之前传入的任意值。这样做是为了把一个值包进函数context,使之能够参与函数运算:
要让一个函数能够是某个定值的唯一方法就是让他完全忽略他的参数。
从实现上看会生成一个新函数(),该函数接受一个参数(),这个参数会被传递给左侧的monadic value(也是个函数,),再把返回值()传递给右侧的函数(),返回一个monadic value(仍然是函数,),接受参数(),最后返回一个monadic value
P.S.把作为参数传递给看起来比较奇怪,这是因为是个monadic value,具有context(是个函数),要从函数context里取出值,必须喂给它参数。同理,把从取出的值喂给,返回一个具有函数context的东西,最后把参数喂给它,得到最终结果
好了,function现在是Monad了,那它有什么用?
心里想一个数字,用它加上52.8,再乘以5,然后减去3.9343,再除以0.5,最后再减去心里想的那个数的十倍
看一下翻译的过程,我们把计算公式分为相关的两部分,最后做减法。先试玩一下:
注意,除了外,我们还多输入了一个参数,因为结果被包进了,所以需要随便填个参数才能取出结果
回想一下Function Monad的实际作用:
把所有的函数都黏在一起做成一个大的函数,然后把这个函数的参数都喂给全部组成的函数,这有点取出他们未来的值的意味
P.S.“取出他们未来的值”指的是最后的,调皮的描述
实际上,更科学的描述是这样的:
The Reader monad (also called the Environment monad). Represents a computation, which can read values from a shared environment, pass values from function to function, and execute sub-computations in a modified environment.
其中,共享环境指的是,即do block里的每一个monadic value,都共享这个大函数的参数,在function之间传值的含义类似于“取出他们未来的值”,至于在篡改过的环境中进行子计算,可能指的是依赖注入之类的应用场景(具体见What is the purpose of the Reader Monad?)
P.S.能够从共享环境中读取值,这也是称之为的原因
三.State Monad
除日志追踪、共享环境外,还有一类最常见的问题是状态维护
然而,有一些领域的问题根本上就是依赖于随着时间而改变的状态。虽然我们也可以用 Haskell 写出这样的程序,但有时候写起来蛮痛苦的。这也是为什么 Haskell 要加进 State Monad 这个特性。这让我们在 Haskell 中可以容易地处理状态性的问题,并让其他部份的程序还是保持纯粹性。
这就是的存在意义,想让状态维护变得更容易,同时不影响其它纯的部分
从实现角度看,是个函数,接受一个状态,返回一个值和新状态
类似于,结果值与context附加的额外信息(这里是)是分离的,通过二元组组织起来
具体实现如下:
把接受到的值放进一个的状态操作函数,再包装成
从左侧取出状态操作函数,传入取出新状态和计算结果,然后把右侧的函数应用到计算结果上,又得到一个monadic value,再通过取出里面的状态操作函数,应用到新状态上,得到二元组并返回。这样lambda的类型就是标准的,最后,塞给,构造出新的monadic value
能让状态维护操作更简洁地表达,那么,这个东西能把状态维护操作简化到什么程度呢?且看随机数的示例
随机数与State Monad
就场景而言,随机数需要维护状态(随机数种子),非常适合用State Monad来处理
具体的,之前在随机数的场景,通过给函数换不同的随机数种子来生成随机数:
例如:
要生成3个随机数的话,最直接的方法是:
当然,可以封装得稍微优雅一些:
看起来舒服一些了,但感觉还是很麻烦。换用:
看起来相当优雅,函数恰好满足的形式,所以直接丢给构造值即可。试玩一下:
结果中的状态是第4个随机数种子(算上传入的),因为这个种子是最新的状态(其余中间状态都被丢掉了)
是的,又简化了一个状态维护的通用场景,帮我们自动完成了中间状态的维护,让一切变得尽可能地简洁
四.Error Monad
最后,异常处理也是一个重要场景,同样可以借助来简化
Building computations from sequences of functions that may fail or using exception handling to structure error handling.
我们已经知道了是,能够用来表达可能会产生错误的计算,那么呢?是不是也可以?
当然。实际上,就是(也称之为)实例:
(摘自Control.Monad.Except)
P.S.注意,Control.Monad.Error和Control.Monad.Trans.Error都已经过时了,建议使用,具体见Control.Monad.Error
没什么好说的,约定表示错误(表示正常结果),能够用来捕获错误,如果没发生错误就直接什么都不做。所以一般模式是这样:
例如:
捕获错误,再直接用丢出去,所以得到了报错:
上面do block中的操作实际上依赖的是自身的实现:
等价于:
也就是说,只是帮那些能表达错误的类型(如、)实现了额外的和,并没有做侵入式修改,但有了这两个行为,我们确实可以优雅地处理错误了,这与上面介绍的几个不同
除了,另一个实现了的重要实例是(当然,不止这2个):
包起来之后,就可以用身上定义的throw和catch了,所以能给其它添上错误处理能力,其实现如下:
其实就是把其它的值()包进了,并添上异常信息(),同时保证类型正确(仍然是)
把错误信息用转成,再用包装成想要的,最后塞给构造出值
通过取出左侧,看一眼是否发生了错误,再决定要不要丢给右侧的handler
全弄明白了,那现在尝试给I/O操作添上异常处理:
注意其中的,用来把提升到要求的上下文(在上例中是)里:
Lift a computation from the IO monad.
而用于取出被包在里的,例如:
试玩一下:
符合预期,输入非法的话,就用默认的字符串
P.S.另外,还在的基础上定义了:
具体见Control.Monad.Trans.Except
五.Monad的魅力
能够赋予计算一些额外的能力,比如:
:能够把函数转换成带日志的版本,用来追踪执行过程,或者给数据变换添加额外的信息
:能够让一系列函数在一个可控的共享环境中协同工作,比如从这个环境中读取参数,读取其它函数的结果等等
:能够自动维护状态,适用于需要维护状态的场景,比如生成一系列随机数
:提供了一种错误处理机制,能够很方便地让运算更安全地进行
Monad的意义在于,从这些常见场景中抽象出通用模式,以简化操作,比如状态维护、日志收集等都能够通过自动完成
单从使用的角度来看,用包一下(没错,就这么简单),就能获得额外的能力,这就是的魅力
参考资料
Control.Monad.Reader
Control.Monad.Error
Control.Monad.Except
联系ayqy
领取专属 10元无门槛券
私享最新 技术干货