单子、组合和计算顺序
Monads, composition and the order of computation
所有关于 monad 的文章都经常指出,monad 允许您按顺序排列效果。
但是简单的构图呢?不是
f x = x + 1
g x = x * 2
result = f g x
需要在 f ...
之前计算 g x
?
monad 是否做同样的事情但处理效果?
是的,monads 使用函数组合来排序效果,并且不是实现排序效果的唯一方法。
严格的语义和副作用
在大多数语言中,严格的语义顺序首先应用于表达式的函数端,然后依次应用于每个参数,最后将函数应用于参数。所以在JS中,函数申请表,
<Code 1>(<Code 2>, <Code 3>)
运行s 4段代码按照指定的顺序: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, Y) 对到计算 Zs 的程序的函数}。
- 我还有一个计算 X 的程序和一个计算 Y 的程序。
- 我想把这些都放在一个计算 Z 的程序中。
那不是只是函数组合本身。但是函数可以做到。
窥探 monads
单子是一种模式。模式是,有时你有一个形容词,当你重复它时并没有增加太多。例如,当您说 "a delayed delayed x" 或 "zero or more (zero or more xs)" 或 "either a null or else either a null or else an x." 时,添加的内容不多 同样,对于 IO monad,"a program to compute a program to compute an x" 添加的内容不多,而 "a program to compute an x."
模式是有一些规范的合并算法合并:
join: given an <adjective> <adjective> x
, I will make you an <adjective> x
.
我们还添加了另外两个属性,形容词应该是outputtish,
map: given an x -> y
and an <adjective> x
, I will make you an <adjective> y
和普遍可嵌入、
pure: given an x
, I will make you an <adjective> x
.
鉴于这三件事和一对公理,你碰巧有一个共同的 "monad" 想法,你可以为它开发一个真正的语法。
现在这个元编程思想显然包含了一个monad。在 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
。我们本可以按其他顺序编写这些内容。但是我们需要 运行 最终程序的所有参数。
我们在函数调用中确实实现了我们对操作排序方式的选择:你是 100% 正确的。但是方法你正在做的,只有 函数组合,是有缺陷的,因为它将影响降级为副作用,以便通过类型。
是的,您提出的函数对标准数值类型是严格的。但并非所有功能都是!在
f _ = 3
g x = x * 2
result = f (g x)
不是 g x
必须在 f (g x)
之前计算的情况。
免责声明:Monad 有很多东西。众所周知,它们很难解释,所以我不会在这里尝试解释什么是 一般,因为这个问题没有要求。我假设您基本了解 Monad
接口是什么以及它如何用于一些有用的数据类型,例如 Maybe
、Either
和 IO
。
什么是效果?
您的问题以注释开头:
All the monad articles often state, that monads allow you to sequence effects in order.
嗯。这是有趣的。事实上,它很有趣有几个原因,您已经确定了其中一个原因:它意味着 monad 可以让您创建某种排序。这是真的,但这只是图片的一部分:它 也 指出排序发生在 effects.
事情是这样的……什么是“效果”?两个数相加有效果吗?根据大多数定义,答案是否定的。打印一些东西到标准输出怎么样,这是一种效果吗?那样的话,我想大多数人都会同意答案是肯定的。但是,请考虑更微妙的事情:通过产生 Nothing
效果来短路计算吗?
错误影响
我们来看一个例子。考虑以下代码:
> do x <- Just 1
y <- Nothing
return (x + y)
Nothing
由于 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
特定的定义替换 >>=
运算符以完全摆脱 monads:
> 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
类型,它允许使用 monadic 接口进行某种“日志记录”:
> 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 可以做到这一点?
是什么赋予了 monad 这种力量?好吧,它们不是魔法——它们只是普通的纯 Haskell 代码。但是请考虑 >>=
:
的类型签名
(>>=) :: Monad m => m a -> (a -> m b) -> m b
请注意第二个参数如何依赖于 a
,而获得 a
的唯一方法是来自第一个参数?这意味着 >>=
需要“运行”第一个参数来产生一个值 ,然后 它可以应用第二个参数。这与评估顺序无关,因为它与实际编写将进行类型检查的代码有关。
现在,Haskell 确实是一种惰性语言。但是 Haskell 的惰性对于这一切并不重要,因为所有这些代码实际上都是纯净的,即使是使用 State
的示例!它只是一种编码计算的模式,以一种纯粹的方式看起来有点状态,但如果你真的自己实现了 State
,你会发现它只是绕过 [= 定义中的“当前状态” 32=] 函数。没有任何实际的突变。
就是这样。 Monads 凭借它们的接口,对如何评估它们的参数强加了一个顺序,Monad
的实例利用它来制作有状态的接口。不过,正如您所发现的,您 不需要 Monad
进行评估排序;显然在 (1 + 2) * 3
中,加法将在乘法之前求值。
但是IO
呢??
好的,你明白我的意思了。问题是:IO
很神奇。
Monad 并不神奇,但 IO
是。 以上所有示例都是纯函数式的,但显然读取文件或写入 stdout 并不纯粹。那么 IO
到底是如何工作的呢?
嗯,IO
是GHC 运行time实现的,你自己写不出来。但是,为了使其与 Haskell 的其余部分很好地协同工作,需要有一个明确定义的评估顺序!否则事情会以错误的顺序打印出来,并且各种其他地狱都会崩溃。
好吧,事实证明 Monad
的接口是确保评估顺序可预测的好方法,因为它已经适用于纯代码。因此 IO
利用相同的接口来保证评估顺序相同,并且 运行 时间实际上定义了评估的含义。
但是,不要被误导了!你不需要 monads 来用纯语言做 I/O,你不需要 IO
来产生 monadic 效果。 Early versions of Haskell experimented with a non-monadic way to do I/O,以及其他部分这个答案解释了如何获得纯单子效果。请记住,monad 并不特殊或神圣,它们只是 Haskell 程序员发现有用的一种模式,因为它具有各种属性。
所有关于 monad 的文章都经常指出,monad 允许您按顺序排列效果。
但是简单的构图呢?不是
f x = x + 1
g x = x * 2
result = f g x
需要在 f ...
之前计算 g x
?
monad 是否做同样的事情但处理效果?
是的,monads 使用函数组合来排序效果,并且不是实现排序效果的唯一方法。
严格的语义和副作用
在大多数语言中,严格的语义顺序首先应用于表达式的函数端,然后依次应用于每个参数,最后将函数应用于参数。所以在JS中,函数申请表,
<Code 1>(<Code 2>, <Code 3>)
运行s 4段代码按照指定的顺序: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, Y) 对到计算 Zs 的程序的函数}。
- 我还有一个计算 X 的程序和一个计算 Y 的程序。
- 我想把这些都放在一个计算 Z 的程序中。
那不是只是函数组合本身。但是函数可以做到。
窥探 monads
单子是一种模式。模式是,有时你有一个形容词,当你重复它时并没有增加太多。例如,当您说 "a delayed delayed x" 或 "zero or more (zero or more xs)" 或 "either a null or else either a null or else an x." 时,添加的内容不多 同样,对于 IO monad,"a program to compute a program to compute an x" 添加的内容不多,而 "a program to compute an x."
模式是有一些规范的合并算法合并:
join: given an
<adjective> <adjective> x
, I will make you an<adjective> x
.
我们还添加了另外两个属性,形容词应该是outputtish,
map: given an
x -> y
and an<adjective> x
, I will make you an<adjective> y
和普遍可嵌入、
pure: given an
x
, I will make you an<adjective> x
.
鉴于这三件事和一对公理,你碰巧有一个共同的 "monad" 想法,你可以为它开发一个真正的语法。
现在这个元编程思想显然包含了一个monad。在 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
。我们本可以按其他顺序编写这些内容。但是我们需要 运行 最终程序的所有参数。
我们在函数调用中确实实现了我们对操作排序方式的选择:你是 100% 正确的。但是方法你正在做的,只有 函数组合,是有缺陷的,因为它将影响降级为副作用,以便通过类型。
是的,您提出的函数对标准数值类型是严格的。但并非所有功能都是!在
f _ = 3
g x = x * 2
result = f (g x)
不是 g x
必须在 f (g x)
之前计算的情况。
免责声明:Monad 有很多东西。众所周知,它们很难解释,所以我不会在这里尝试解释什么是 一般,因为这个问题没有要求。我假设您基本了解 Monad
接口是什么以及它如何用于一些有用的数据类型,例如 Maybe
、Either
和 IO
。
什么是效果?
您的问题以注释开头:
All the monad articles often state, that monads allow you to sequence effects in order.
嗯。这是有趣的。事实上,它很有趣有几个原因,您已经确定了其中一个原因:它意味着 monad 可以让您创建某种排序。这是真的,但这只是图片的一部分:它 也 指出排序发生在 effects.
事情是这样的……什么是“效果”?两个数相加有效果吗?根据大多数定义,答案是否定的。打印一些东西到标准输出怎么样,这是一种效果吗?那样的话,我想大多数人都会同意答案是肯定的。但是,请考虑更微妙的事情:通过产生 Nothing
效果来短路计算吗?
错误影响
我们来看一个例子。考虑以下代码:
> do x <- Just 1
y <- Nothing
return (x + y)
Nothing
由于 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
特定的定义替换 >>=
运算符以完全摆脱 monads:
> 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
类型,它允许使用 monadic 接口进行某种“日志记录”:
> 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 可以做到这一点?
是什么赋予了 monad 这种力量?好吧,它们不是魔法——它们只是普通的纯 Haskell 代码。但是请考虑 >>=
:
(>>=) :: Monad m => m a -> (a -> m b) -> m b
请注意第二个参数如何依赖于 a
,而获得 a
的唯一方法是来自第一个参数?这意味着 >>=
需要“运行”第一个参数来产生一个值 ,然后 它可以应用第二个参数。这与评估顺序无关,因为它与实际编写将进行类型检查的代码有关。
现在,Haskell 确实是一种惰性语言。但是 Haskell 的惰性对于这一切并不重要,因为所有这些代码实际上都是纯净的,即使是使用 State
的示例!它只是一种编码计算的模式,以一种纯粹的方式看起来有点状态,但如果你真的自己实现了 State
,你会发现它只是绕过 [= 定义中的“当前状态” 32=] 函数。没有任何实际的突变。
就是这样。 Monads 凭借它们的接口,对如何评估它们的参数强加了一个顺序,Monad
的实例利用它来制作有状态的接口。不过,正如您所发现的,您 不需要 Monad
进行评估排序;显然在 (1 + 2) * 3
中,加法将在乘法之前求值。
但是IO
呢??
好的,你明白我的意思了。问题是:IO
很神奇。
Monad 并不神奇,但 IO
是。 以上所有示例都是纯函数式的,但显然读取文件或写入 stdout 并不纯粹。那么 IO
到底是如何工作的呢?
嗯,IO
是GHC 运行time实现的,你自己写不出来。但是,为了使其与 Haskell 的其余部分很好地协同工作,需要有一个明确定义的评估顺序!否则事情会以错误的顺序打印出来,并且各种其他地狱都会崩溃。
好吧,事实证明 Monad
的接口是确保评估顺序可预测的好方法,因为它已经适用于纯代码。因此 IO
利用相同的接口来保证评估顺序相同,并且 运行 时间实际上定义了评估的含义。
但是,不要被误导了!你不需要 monads 来用纯语言做 I/O,你不需要 IO
来产生 monadic 效果。 Early versions of Haskell experimented with a non-monadic way to do I/O,以及其他部分这个答案解释了如何获得纯单子效果。请记住,monad 并不特殊或神圣,它们只是 Haskell 程序员发现有用的一种模式,因为它具有各种属性。