单子、组合和计算顺序

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中写的抽象问题是,

  1. 我有一个程序计算{从 (X, Y) 对到计算 Zs 的程序的函数}。
  2. 我还有一个计算 X 的程序和一个计算 Y 的程序。
  3. 我想把这些都放在一个计算 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 接口是什么以及它如何用于一些有用的数据类型,例如 MaybeEitherIO


什么是效果?

您的问题以注释开头:

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

由于 MaybeMonad 实例,该示例的第二行“短路”。这能算效果吗?在某种意义上,我认为是的,因为它是非本地的,但在另一种意义上,可能不是。毕竟,如果交换 x <- Just 1y <- 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 程序员发现有用的一种模式,因为它具有各种属性。