Monads,组成和计算顺序

问题描述 投票:3回答:3

所有monad文章经常陈述,monad允许你按顺序排列效果。

但是简单的构图怎么样?是不是

f x = x + 1
g x = x * 2

result = f g x

需要在g x之前计算f ...

monad做同样的事情,但处理效果?

haskell monads composition
3个回答
13
投票

免责声明:Monads是很多东西。众所周知,它们难以解释,因此我不会试图解释一般的monad是什么,因为这个问题没有要求。我将假设您对Monad界面的基本掌握以及它如何适用于某些有用的数据类型,如MaybeEitherIO


What is an effect?

您的问题以便笺开头:

所有monad文章经常陈述,monad允许你按顺序排列效果。

嗯。这是有趣的。实际上,有趣的原因有几个,其中一个原因已经确定:它暗示monads可以让你创建某种排序。这是真的,但它只是图片的一部分:它还表明测序发生在效果上。

这是事情,但是......什么是“效果”?将两个数字加在一起效果?在大多数定义下,答案是否定的。打印东西到stdout怎么样,这是一种效果吗?在这种情况下,我认为大多数人会同意答案是肯定的。但是,考虑一些更微妙的东西:通过产生Nothing效果来缩短计算量?

错误效果

我们来看一个例子吧。请考虑以下代码:

> do x <- Just 1
     y <- Nothing
     return (x + y)
Nothing

由于MonadMaybe实例,该示例的第二行“短路”。这可能被认为是一种影响吗?从某种意义上说,我是这么认为的,因为它是非本地的,但在另一种意义上,可能不是。毕竟,如果交换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运算符来完全摆脱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通常允许的更不纯的编程风格。

Monads和州

但也许这对你来说并不令人满意。错误处理并不引人注目,因为它只是短路,它实际上并没有对结果进行任何排序!好吧,如果我们找到一些稍微复杂的类型,我们就可以做到。例如,考虑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是一个有用的工具,用于创建看起来有状态的接口,并且具有明确定义的顺序,尽管实际上只是普通的函数调用。

Why can monads do this?

是什么赋予monads这种力量?好吧,它们不是魔术 - 它们只是普通的纯Haskell代码。但请考虑>>=的类型签名:

(>>=) :: Monad m => m a -> (a -> m b) -> m b

注意第二个参数如何依赖于a,获得a的唯一方法是从第一个参数?这意味着>>=需要“运行”第一个参数才能生成一个值,然后才能应用第二个参数。这与评估顺序没有关系,因为它与实际编写将要进行类型检查的代码有关。

现在,Haskell确实是一种懒惰的语言。但是Haskell的懒惰并不重要,因为所有这些代码实际上都是纯粹的,甚至是使用State的例子!它只是一种模式,以纯粹的方式编码看似有状态的计算,但如果你自己实际实现了State,你会发现它只是在>>=函数的定义中绕过“当前状态”。没有任何实际的突变。

就是这样。 Monads凭借它们的接口,对如何评估它们的参数进行了排序,并且Monad的实例利用它来创建有状态的接口。但是,正如您所发现的那样,您不需要Monad进行评估排序;显然在(1 + 2) * 3中,将在乘法之前评估加法。

But what about IO??

好的,你有我。这是问题:IO是神奇的。

Monads不是魔法,但IO是。以上所有示例都是纯函数,但显然读取文件或写入stdout并不纯粹。那么IO如何工作?

好吧,IO是由GHC运行时实现的,你不能自己编写。但是,为了使其与Haskell的其余部分很好地协同工作,需要有一个明确定义的评估顺序!否则,事情将以错误的顺序打印出来,各种其他的地狱将会破裂。

好吧,事实证明Monad的界面是确保评估顺序可预测的好方法,因为它已经适用于纯代码。因此,IO利用相同的接口来保证评估顺序是相同的,并且运行时实际上定义了评估的含义。

但是,不要被误导!你不需要monad用纯语言做I / O,你不需要IO来产生monadic效果。 Early versions of Haskell experimented with a non-monadic way to do I/O,以及这个答案的其他部分解释了如何获得纯粹的monadic效果。请记住,monad不是特殊的或神圣的,它们只是Haskell程序员因其各种属性而发现有用的模式。


6
投票

是的,您建议的功能对标准数字类型是严格的。但并非所有功能都是!在

f _ = 3
g x = x * 2
result = f (g x)

事实并非g x必须在f (g x)之前计算。


2
投票

是的,monads使用函数组合来排序效果,并不是实现排序效果的唯一方法。

Strict semantics and side effects

在大多数语言中,通过首先应用于表达式的函数端的严格语义进行排序,然后依次应用于每个参数,最后将该函数应用于参数。所以在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中编写的抽象问题是,

  1. 我有一个程序,它计算{从(X,Y)对的函数到计算Zs的程序}。
  2. 我还有一个计算X的程序和一个计算Y的程序。
  3. 我想将这些全部放在一个计算Z的程序中。

这不仅仅是功能组合本身。但是一个功能可以做到这一点。

Peeking inside monads

monad是一种模式。模式是,有时你有一个形容词,当你重复它时,它不会增加太多。例如,当您说“延迟延迟x”或“零或更多(零或更多xs)”或“无论是null还是null或x时”都没有添加太多内容。类似地,对于IO monad,“计算用于计算x的程序的程序”没有添加太多,这在“用于计算x的程序”中不可用。

模式是有一些规范的合并算法合并:

加入:给予<adjective> <adjective> x,我会让你成为<adjective> x

我们还添加了另外两个属性,形容词应该是输出,

地图:鉴于x -> y<adjective> x,我会让你成为<adjective> y

并且普遍可嵌入

纯粹:给予x,我会让你成为<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))));
}

How this answers your question

请注意,在上面我们通过编写三个binds来选择操作顺序。我们本可以用其他顺序写出来。但我们需要所有参数来运行最终程序。

我们如何对操作进行排序的选择确实在函数调用中实现:您是100%正确的。但是你的方式,只有功能组合,是有缺陷的,因为它将效果降低到副作用,以便通过类型。

© www.soinside.com 2019 - 2024. All rights reserved.