免责声明:单子有很多东西。众所周知,它们很难解释,所以我不会尝试解释什么是单子一般来说在这里,因为这个问题没有要求这一点。我假设你对什么有基本的了解Monad
接口以及它如何处理一些有用的数据类型,例如Maybe
, Either
, and IO
.
什么是效果?
你的问题以注释开头:
所有 monad 文章经常指出,monad 允许您按顺序对效果进行排序。
唔。这很有趣。事实上,它很有趣,有几个原因,其中之一你已经确定了:它意味着 monad 可以让你创建某种排序。确实如此,但这只是图片的一部分:它also表明测序发生在effects.
但问题是……什么是“效果”?两个数相加有效果吗?根据大多数定义,答案是否定的。将某些内容打印到标准输出怎么样,这是效果吗?那么,我想大多数人都会同意答案是肯定的。然而,考虑一些更微妙的事情:通过产生短路计算Nothing
效果?
错误影响
让我们看一个例子。考虑以下代码:
> do x <- Just 1
y <- Nothing
return (x + y)
Nothing
该示例的第二行“短路”是由于Monad
实例为Maybe
。这也能算是一种效果吗?从某种意义上说,我认为是这样,因为它是非本地的,但从另一种意义上说,可能不是。毕竟,如果x <- Just 1
or y <- Nothing
行交换后,结果仍然相同,因此顺序并不重要。
然而,考虑一个稍微复杂的例子,它使用Either
代替Maybe
:
> do x <- Left "x failed"
y <- Left "y failed"
return (x + y)
Left "x failed"
现在这更有趣了。如果你现在交换前两行,你会得到不同的结果!不过,这是否像您在问题中提到的那样代表了“效果”?毕竟,这只是一堆函数调用。如你所知,do
符号只是一系列用法的替代语法>>=
运算符,所以我们可以将其展开:
> Left "x failed" >>= \x ->
Left "y failed" >>= \y ->
return (x + y)
Left "x failed"
我们甚至可以替换>>=
运算符与Either
-完全摆脱单子的特定定义:
> case Left "x failed" of
Right x -> case Left "y failed" of
Right y -> Right (x + y)
Left e -> Left e
Left e -> Left e
Left "x failed"
因此,很明显 monad 确实强加了某种排序,但这并不是因为它们是 monad,也不是因为 monad 很神奇,只是因为它们碰巧启用了一种编程风格,looks比 Haskell 通常允许的更不纯粹。
单子和状态
但也许这并不令你满意。错误处理并不引人注目,因为它只是短路,结果实际上没有任何排序!好吧,如果我们达到一些稍微复杂的类型,我们就可以做到。例如,考虑Writer
类型,它允许使用单子接口进行某种“日志记录”:
> execWriter $ do
tell "hello"
tell " "
tell "world"
"hello world"
这比以前更有趣,因为现在每个计算的结果都在do
block没有被使用,但是仍然影响输出!这显然是有副作用的,而且顺序显然非常重要!如果我们重新排序tell
表达式,我们会得到非常不同的结果:
> execWriter $ do
tell " "
tell "world"
tell "hello"
" worldhello"
但这怎么可能呢?好吧,我们再次重写它以避免do
符号:
execWriter (
tell "hello" >>= \_ ->
tell " " >>= \_ ->
tell "world")
我们可以内联以下定义>>=
再次为了Writer
,但它太长了,无法在这里很好地说明。但重点是,Writer
只是一个完全普通的 Haskell 数据类型,不执行任何 I/O 或类似操作,但我们使用单子接口来创建看起来像有序效果的东西。
我们可以更进一步,创建一个如下所示的界面可变状态使用State
type:
> flip execState 0 $ do
modify (+ 3)
modify (* 2)
6
再次,如果我们重新排序表达式,我们会得到不同的结果:
> flip execState 0 $ do
modify (* 2)
modify (+ 3)
3
显然,单子是创建接口的有用工具,look尽管实际上只是普通的函数调用,但它是有状态的并且具有明确定义的顺序。
为什么 monad 可以做到这一点?
是什么赋予单子这样的力量?好吧,它们并不神奇——它们只是普通的纯 Haskell 代码。但请考虑类型签名>>=
:
(>>=) :: Monad m => m a -> (a -> m b) -> m b
请注意第二个参数如何取决于a
,并且获得的唯一方法是a
是从第一个参数开始的吗?这意味着>>=
需要“运行”第一个参数来产生一个值before它可以应用第二个参数。这与评估顺序无关,而与实际编写进行类型检查的代码有关。
现在,Haskell 确实是一种懒惰的语言。但 Haskell 的懒惰对于这一切来说并不重要,因为所有这些代码实际上都是纯粹的,即使是使用的示例State
!它只是一种以纯粹的方式对计算进行编码的模式,看起来有点有状态,但如果您实际上实现了State
你自己,你会发现它只是传递了定义中的“当前状态”>>=
功能。没有任何实际的突变。
就是这样。 Monad 凭借其接口,对如何评估其参数以及实例施加了排序Monad
利用它来制作有状态的界面。你不need Monad
不过,正如您所发现的那样,要进行评估排序;显然在(1 + 2) * 3
加法将在乘法之前进行计算。
但是关于IO
??
好吧,你明白了。问题是这样的:IO
是魔法。
Monad 不是魔法,但是IO
is.上面的所有示例都是纯函数式的,但显然读取文件或写入 stdout 并不是纯粹的。那么到底是怎么做的IO
work?
Well, IO
由 GHC 运行时实现,您无法自己编写。然而,为了使其与 Haskell 的其余部分很好地工作,需要有一个明确定义的评估顺序!否则,事情就会以错误的顺序打印出来,并且各种其他地狱都会爆发。
好吧,事实证明Monad
的接口是确保评估顺序可预测的好方法,因为它已经适用于纯代码。所以IO
利用相同的接口来保证评估顺序相同,并且运行时实际上定义了该评估的含义。
不过,不要被误导!你不需要 monad 来用纯语言进行 I/O,也不需要IO
具有单元效应。 Haskell 的早期版本尝试使用非单子方式进行 I/O,这个答案的其他部分解释了如何获得纯粹的单子效应。请记住,单子并不特殊或神圣,它们只是 Haskell 程序员因其各种属性而发现有用的一种模式。