【问题标题】:Monads, composition and the order of computation单子,组成和计算顺序
【发布时间】:2016-12-24 04:37:51
【问题描述】:

所有 monad 文章都经常声明,monad 允许您按顺序排列效果。

但是简单的组合呢?不是

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

result = f g x

要求g xf ... 之前计算?

单子做同样的事情但处理效果吗?

【问题讨论】:

  • 这不是函数组合,应该是(g . f) x。其次,不需要f x 的结果,因为haskell 有按需调用,也就是惰性求值。 f 将尽可能晚地应用,即打印result 时。最后,是的,monad 创建了一些类似于隐藏参数的东西,代表世界的状态,并将这些参数链接到动作之间,这样尽管有惰性求值,效果仍会按照预期的顺序发生
  • 它也不进行类型检查。 f g x(f g) x 相同,因此您将尝试将 1 添加到函数中。
  • 来吧,OP obv 的意思是f (g x),是的,它可能需要在“之前”计算g x f如果 f 的论点很严格。但如果f 是懒惰的,它可能不是。但在这里,这个特定的f 严格的,因为+ 是。

标签: haskell monads composition


【解决方案1】:

免责声明:Monad 是很多东西。众所周知,它们很难解释,所以我不会尝试在这里解释什么是单子一般,因为问题并没有要求这样做。我假设您基本了解Monad 接口是什么以及它如何处理一些有用的数据类型,例如MaybeEitherIO


什么是效果?

您的问题以注释开头:

所有 monad 文章都经常声明,monad 允许您按顺序排列效果。

嗯。这是有趣的。事实上,它之所以有趣有几个原因,您已经确定了其中一个原因:这意味着 monad 可以让您创建某种排序。确实如此,但这只是图片的一部分:它表明排序发生在效果上。

事情是这样的……什么是“效果”?两个数字相加有效果吗?根据大多数定义,答案是否定的。将某些内容打印到标准输出怎么样,这是一种效果吗?在那种情况下,我想大多数人都会同意答案是肯定的。然而,考虑一些更微妙的事情:通过产生Nothing 来使计算短路是一种效果吗?

错误影响

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

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

由于MonadMaybe 实例,该示例的第二行“短路”。这可以被认为是一种效果吗?从某种意义上说,我认为是这样,因为它是非本地的,但在另一种意义上,可能不是。毕竟,如果 x &lt;- Just 1y &lt;- Nothing 行交换,结果还是一样的,所以顺序无关紧要。

但是,考虑一个稍微复杂一点的例子,它使用Either 而不是Maybe

> do x <- Left "x failed"
     y <- Left "y failed"
     return (x + y)
Left "x failed"

现在这更有趣了。如果你现在交换前两行,你会得到不同的结果!不过,这是否代表了您在问题中提到的“效果”?毕竟,这只是一堆函数调用。如您所知,do 表示法只是 &gt;&gt;= 运算符的一系列用法的替代语法,因此我们可以将其扩展:

> Left "x failed" >>= \x ->
    Left "y failed" >>= \y ->
      return (x + y)
Left "x failed"

我们甚至可以将 &gt;&gt;= 运算符替换为 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 内联&gt;&gt;= 的定义,但是这里太长了,不能很好地说明。不过,重点是,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 代码。但请考虑&gt;&gt;= 的类型签名:

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

注意第二个参数如何依赖于a,而获得a 的唯一方法是从第一个参数?这意味着&gt;&gt;= 需要“运行”第一个参数以产生一个值它可以应用第二个参数。这与评估顺序无关,而是与实际编写将进行类型检查的代码有关。

现在,Haskell 确实是一种惰性语言。但是 Haskell 的懒惰对此并不重要,因为所有这些代码实际上都是纯代码,即使是使用 State 的示例!它只是一种以纯粹的方式对看起来有点有状态的计算进行编码的模式,但如果你自己实际实现了State,你会发现它只是传递了&gt;&gt;=函数定义中的“当前状态” .没有任何实际的突变。

就是这样。 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 程序员发现的一种有用的模式,因为它具有各种属性。

【讨论】:

    【解决方案2】:

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

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

    不是g x必须在f (g x)之前计算的情况。

    【讨论】:

    • IMO 这个问题实际上不是关于惰性或严格的评估顺序,而是关于单子接口如何允许效果排序以及它提供的内容超过普通的 Haskell 评估(无论它是否惰性)。对于我认为 OP 所要求的内容,请参阅我过长的答案,如果您认为我非常离谱,请随时纠正我。
    • @AlexisKing 懒惰绝对是问题的核心:使用 monad 在 Haskell 中变得流行正是因为,与严格的语言不同,懒惰的语言可以让某些东西完全不被评估,因此如果我们离开“有效“惰性片段中的东西,这些效果可能根本不会发生!后来证明单子本身很有用。但最初,他们是为了解决问题,而我的观点是,这个问题正是在问为什么对这个问题的天真、纯粹的解决方案还不够好。
    • 嗯,这很公平,即使我不完全同意(我认为这种推理主要适用于 IO,但 IO 可能是发明monads,历史对于新学习者来说有点无关紧要)。我不知道单子“发现”的历史,如果你有任何关于它们介绍的文献,我会非常有兴趣阅读它。不过,IMO,我认为我不同意你的最后一句话:我的回答具体描述了为什么除了IO 之外,单子效应的概念纯粹的。不过,只有 OP 可以澄清其意图。
    【解决方案3】:

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

    严格的语义和副作用

    在大多数语言中,首先将严格语义排序应用于表达式的函数端,然后依次应用于每个参数,最后将函数应用于参数。所以在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 的程序中。

    这不是只是函数组合本身。但是函数可以做到

    窥探 monads

    单子是一种模式。模式是,有时你有一个形容词,当你重复它时不会增加太多。例如,当您说“一个 delayed 延迟 x”或“零或多个(零或多个 xs)”或“要么为空,要么为空或其他 x。 "同样,对于 IO monad,“计算 x 的程序”中没有添加“计算 x 的程序”中没有的“程序计算程序”。

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

    加入:给定一个&lt;adjective&gt; &lt;adjective&gt; x,我会给你一个&lt;adjective&gt; x

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

    地图:给定一个x -&gt; y 和一个&lt;adjective&gt; x,我会给你一个&lt;adjective&gt; y

    通用嵌入

    纯粹的:给定一个x,我会给你一个&lt;adjective&gt; x

    鉴于这三件事和几个公理,您碰巧有一个共同的“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))));
    }
    

    这如何回答您的问题

    请注意,在上面我们选择操作顺序是根据我们编写三个binds 的方式。我们可以按其他顺序写这些。但是我们需要所有参数来运行最终程序。

    我们如何排序操作的这种选择确实在函数调用中实现你是 100% 正确的。但是你这样做的方式,与 only 函数组合是有缺陷的,因为它将效果降级为副作用以使类型通过。

    【讨论】:

    • 真的不想表现得消极或敌对,但我不知道还能怎么说:我不太喜欢这个答案全部。它读起来更像是一篇漫无边际的 monad 教程博客文章,而不是试图回答 OP 相当具体的问题,即使作为一个经验丰富的 Haskeller,我也不觉得它非常可读。似乎提出了 monad 是模式的论点,这很好,但大部分答案都花在说“这就是 monad 是什么;这是一个数学问题;它有一些操作和规律”,然后说“瞧,IO monad”,这似乎一点也不引人注目。
    • @AlexisKing 感谢您的反馈;它不被认为是消极的或敌对的。它确实比我通常所做的更杂乱无章。这部分是因为它是匆忙组成的,部分是因为我真的认为人们在意识到他们正在做元编程之前不会深入了解 IO monad。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2016-05-24
    • 1970-01-01
    • 1970-01-01
    • 2020-02-12
    • 1970-01-01
    • 2021-07-29
    • 1970-01-01
    相关资源
    最近更新 更多