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