但是简单的组合呢?不是在使用单子时也能实现吗?
f x = x + 1
g x = x * 2
result = f g x
需要在计算f ...
之前计算g x
吗?
单子是否也可以处理效果,与此类似?
f x = x + 1
g x = x * 2
result = f g x
需要在计算f ...
之前计算g x
吗?
单子是否也可以处理效果,与此类似?
免责声明: 单子(Monads)是许多事情。它们被众所周知的难以解释,因此我不会在这里尝试解释单子的一般概念,因为问题不要求那样做。我假设您已经基本了解Monad
接口以及如何将其应用于某些有用的数据类型,例如Maybe
, Either
和IO
。
你的问题开始于一个注释:
所有单子文章通常都会说明,单子允许您按顺序序列化效果。
嗯。这很有趣。实际上,它有几个有趣之处,其中之一是:它意味着单子让你创建某种排序。这是正确的,但它只是部分表象:它还指出排序发生在效果上。
然而,问题在于……什么是“效果”?将两个数相加算作一种效果吗?根据大多数定义,答案是否定的。那么,将某些内容打印到终端,是否算作一种效果呢?在这种情况下,我认为大多数人会同意答案是肯定的。但是,考虑一些更微妙的事情:通过产生Nothing
来中断计算是否算作效果?
让我们看一个例子。考虑以下代码:
> do x <- Just 1
y <- Nothing
return (x + y)
Nothing
第二行代码示例中的“short-circuits”是由于Maybe
的Monad
实例引起的。这可以被视为一种效应吗?在某种意义上,我认为是的,因为它是非局部的,但在另一方面,可能不是。毕竟,如果将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
特定的定义来替换>>=
操作符,以完全摆脱单子:
> 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"
因此,很明显单子确实施加了某种排序,但这不是因为它们是单子和单子是魔法,而只是因为它们碰巧启用了一种编程风格,比 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或类似的操作,但是我们已经使用了单子接口来创建类似有序效果的东西。
我们甚至可以通过使用 State
类型来创建一个看起来像是可变状态的接口:
> flip execState 0 $ do
modify (+ 3)
modify (* 2)
6
再次说明,如果我们重新排列这些表达式,我们会得到一个不同的结果:
> flip execState 0 $ do
modify (* 2)
modify (+ 3)
3
明显地,单子是创建具有良好定义的顺序并具有类似状态的接口的有用工具,尽管实际上只是普通函数调用。
是什么赋予了单子这种能力?好吧,它们并不神奇-它们只是普通的纯Haskell代码。但是请考虑>>=
的类型签名:
(>>=) :: Monad m => m a -> (a -> m b) -> m b
注意第二个参数如何依赖于a
,而获取a
的唯一方法是从第一个参数中获得?这意味着>>=
需要在应用第二个参数之前“运行”第一个参数以产生一个值。这与评估顺序无关,更多地涉及实际编写能够进行类型检查的代码。
现在,确实Haskell是一种惰性语言。但是Haskell的惰性不太重要,因为所有这些代码实际上都是纯的,甚至包括使用State
的示例!它只是一种模式,以纯粹的方式编码看起来有点状态的计算,但如果你自己实现了State
,你会发现它只是在>>=
函数的定义中传递“当前状态”。没有任何实际的变化。
就是这样。由于它们的接口,单子对其参数可以进行评估的顺序施加了一种排序,并且Monad
的实例利用这个排序创建具有类似状态的外观的接口。您不需要Monad
来进行评估排序,正如您所发现的那样;显然,在(1 + 2) * 3
中,加法将在乘法之前进行评估。
IO
呢??好吧,你抓住我了。这里的问题是:IO
是魔法。
单子不是魔法,但IO
却是。以上所有示例都是纯函数式的,但是读取文件或写入stdout显然是不纯的。那么IO
怎么办?
嗯,IO
由GHC运行时实现,您无法自己编写它。但是,为了使其与Haskell的其余部分良好配合,需要有一个明确定义的评估顺序!否则,事情会以错误的顺序打印出来,所有其他的异常也会发生。
结果,Monad
的接口是确保评估顺序可预测的有效方法,因为它已经适用于纯代码。所以IO
利用相同的接口来保证评估顺序相同,而运行时实际上定义了该评估的含义。
但是,不要被误导!在纯语言中进行I/O不需要单子,而且在具有单子效应的情况下也不需要IO
。Haskell的早期版本尝试过一种非单子方式进行I/O,本回答的其他部分解释了如何在纯单调效应中实现它们。请记住,单子并不是特殊或神圣的,它们只是Haskell程序员发现其各种属性有用的模式。
是的,你提出的函数对于标准的数值类型是严格的。但并不是所有的函数都是这样的!
f _ = 3
g x = x * 2
result = f (g x)
并不是必须在计算f (g x)
之前先计算g x
。
IO
,但是虽然IO
可能是单子发明的驱动力,但历史对于新学习者来说有些无关紧要)。我不知道单子“发现”的历史,如果您有任何介绍它们引入的文献,我会非常感兴趣。尽管如此,我认为我不同意您的最后一句话:我的答案明确说明了单子效应的想法除了IO
之外是纯粹的。不过只有OP可以澄清意图。 - Alexis King是的,单子使用函数组合来序列化效果,并不是实现序列化效果的唯一方法。
在大多数语言中,通过对表达式的函数部分首先应用严格语义,然后依次对每个参数进行排序,最后将函数应用于参数来进行排序。因此,在JS中,函数应用形式如下:
<Code 1>(<Code 2>, <Code 3>)
按照指定顺序运行四个代码片段:1,2,3,然后检查1的输出是否为函数,接着使用这两个计算出的参数调用该函数。这样做是因为任何一个步骤都可能会产生副作用。代码示例:
const logVal = (log, val) => {
console.log(log);
return val;
};
logVal(1, (a, b) => logVal(4, a+b))(
logVal(2, 2),
logVal(3, 3));
这对于那些语言是有效的。在这个上下文中,我们可以说这些是“副作用”,这意味着JS的类型系统没有任何方法让您知道它们存在。
Haskell确实有一个严格的应用程序原语,但它想要保持“纯净”,这大致意味着它希望类型系统跟踪效果。因此,他们引入了一种“元编程”的形式,其中他们的一种类型是类型级形容词,“计算_____的程序”。程序与真实世界交互;理论上,Haskell代码不会。您必须定义“main
是计算单位类型的程序”,然后编译器实际上只是为您构建该程序作为可执行二进制文件。当该文件被运行时,Haskell实际上已经不再存在!
因此,这比普通函数应用程序更加“具体”,因为我在JavaScript中编写的抽象问题是:
这不仅仅是函数组合本身。但是函数可以做到这一点。
单子是一种模式。该模式是,有时您有一个形容词,重复它时不会增加太多内容。例如,当您说“延迟的延迟x”或“零个或多个(零个或多个xs)”或“任意一个null或者任意一个null或者一个x”时,没有添加太多内容。同样对于IO单子,“计算程序以计算x”比“计算x”的可用性不高。
模式是有一些规范的合并算法可以合并:
join:给定一个
<形容词> <形容词> x
,我将为您创建一个<形容词> x
。
我们还添加了另外两个属性,形容词应该是输出的,
map:给定一个
x -> y
和一个<形容词> x
,我将为您创建一个<形容词> y
并且是通用嵌入的,
pure:给定一个
x
,我将为您创建一个<形容词> x
。
在这些三个事物和几个公理的基础上,您恰好拥有一个常见的“单子”概念,可以为其开发一种真正的语法。
现在这个元编程的想法显然包含一个单子。在JS中,我们会这样写:interface IO<x> {
run: () => Promise<x>
}
function join<x>(pprog: IO<IO<x>>): IO<x> {
return { run: () => pprog.run().then(prog => prog.run()) };
}
function map<x, y>(prog: IO<x>, fn: (in: x) => y): IO<y> {
return { run: () => prog.run().then(x => fn(x)) }
}
function pure<x>(input: x): IO<x> {
return { run: () => Promise.resolve(input) }
}
// with those you can also define,
function bind<x, y>(prog: IO<x>, fn: (in: x) => IO<y>): IO<y> {
return join(map(prog, fn));
}
但是仅仅存在一个模式并不意味着它有用!我声称这些函数事实上就足以解决以上问题。而且,很容易理解为什么:你可以使用 bind
来创建一个函数作用域,在其中形容词不存在,并对你的值进行操作:
function ourGoal<x, y, z>(
fnProg: IO<(inX: x, inY: y) => IO<z>>,
xProg: IO<x>,
yProg: IO<y>): IO<z> {
return bind(fnProg, fn =>
bind(xProg, x =>
bind(yProg, y => fn(x, y))));
}
请注意,在上述示例中,我们通过编写三个bind
的方式来选择操作顺序。我们可以以其他方式编写这些内容,但是我们需要所有参数才能运行最终程序。
我们对如何安排操作顺序的选择确实在函数调用中得到了体现: 你是完全正确的。 但是,仅使用函数组合的方式存在缺陷,因为它会将效果降级为副作用,以便使类型正常工作。
(g . f) x
。其次,由于Haskell采用了惰性求值的方式,所以f x
的结果并不需要立即求出来。f
函数会在尽可能晚的时候被执行,也就是在打印result
时才会实际执行。最后,是的,Monad类似于隐藏的参数,它代表了世界的状态,并将这些参数链接在操作之间,以便尽管采用了惰性求值,效果仍然按照意图的顺序发生。 - nothingmuchf g x
与(f g) x
相同,因此您将尝试将1添加到一个函数中。 - chepnerf(g x)
,是的,如果f
对其参数严格要求,那么可能需要先计算g x
再计算f
。但是,如果f
是惰性的,那么就不需要这样做。然而,在这里,这个特定的f
是严格的,因为+
是严格的。 - Will Ness