Parsec:Applicatives与Monads的区别

23

我刚刚开始学习Parsec(对Haskell的经验很少),关于使用monad或applicative我有点困惑。在阅读了《Real World Haskell》、《Write You a Haskell》和这里的一个问题之后,总体感觉是更喜欢applicative,但是实际上我不知道到底应该怎么做。

所以我的问题是:

  • 哪种方法更受欢迎?
  • 可以混合使用monad和applicative吗?(在其中一种比另一种更有用时使用它们)
  • 如果上一个答案是肯定的,我应该这样做吗?

2
我的建议是学习如何使用monadic do-notation来使用parsec。然后,当您了解applicatives时,可以开始在可以的地方使用它们来代替您的monadic代码。Applicatives比monads严格弱,因此可能有一些事情您只能使用monads完成。现在,您可以使用ApplicativeDo pragma使用do-notation编写applicative表达式,这样区别就不那么明显了。 - ErikR
3个回答

74

在确定何时使用每个类型之前,值得注意ApplicativeMonad之间的关键语义差异。比较类型:

(<*>) :: m (s -> t) -> m s -> m t
(>>=) :: m s -> (s -> m t) -> m t

要部署<*>,您需要选择两个计算,一个是函数,另一个是参数,然后将它们的值通过应用程序进行组合。要部署>>=,您需要选择一个计算,并解释如何利用其结果值来选择下一个计算。这就是“批处理模式”和“交互式”操作之间的区别。
在解析方面,Applicative(通过添加失败和选择以提供Alternative)捕获了语法中无上下文的方面。只有当您需要检查来自输入某部分的解析树以决定应使用哪个语法来处理输入的另一部分时,您才需要Monad提供的额外功能。例如,您可能会读取格式描述符,然后读取该格式的输入。尽量减少使用单子的额外功能可以告诉您哪些值依赖是必不可少的。
从解析转向并行处理,在必须使用单子的情况下,仅使用其必要的值依赖性,可以帮助您清楚地了解分散负载的机会。当两个计算使用<*>组合时,它们都不需要等待对方。在可以使用应用程序的情况下使用应用程序,但在必须使用单子的情况下使用单子,这是速度的公式。ApplicativeDo的目的是自动化以单子风格编写的代码的依赖关系分析,从而无意中过多地顺序化。
您的问题还涉及编码风格,对此有不同的看法。但是让我给您讲一个故事。我从Standard ML转到Haskell,在那里我习惯于使用直接风格编写程序,即使它们做了像抛出异常或修改引用这样的坏事。我在ML中做什么?在实现超纯类型理论(由于法律原因不能命名)时工作。当我在该类型理论中工作时,我无法编写使用异常的直接样式程序,但我设计了应用组合器作为尽可能接近直接样式的一种方式。
当我转到Haskell时,我震惊地发现人们似乎认为在伪命令do-notation中编程只是对最轻微的语义不纯的惩罚(当然,除了非终止)。我选择应用组合器作为一种风格选择(并使用“范畴括号”更接近直接样式),远在我掌握语义区别之前,即它们代表了单子接口的有用弱化。我只是不喜欢do-notation要求表达式结构的分散和无谓的命名方式。
换句话说,与命令式代码相比,使函数式代码更简洁易读的同样因素也使应用风格比do-notation更紧凑易读。我赞赏ApplicativeDo是一种制作更多应用程序(在某些情况下意味着更快)的好方法,这些应用程序是以单调风格编写的,而你没有时间进行重构。但除此之外,我认为在可以使用应用程序时使用应用程序,在必须使用单调时使用单调也是更好的方式来了解正在发生的事情。

值得一提的是,您可以使用<*>制作的解析器在表达能力上严格小于您可以使用>>=制作的解析器 - 尽管额外的表达能力并不总是必要的。这个答案进一步暗示了这些差异:https://dev59.com/R2sz5IYBdhLWcg3wfn66#7863380 - Sam Elliott
您偏爱应用程序编程(我所指的是与“Applicative”风格不同的“直接风格”)是否也包括更喜欢使用>>=而不是do - Benjamin Hodgson
1
@BenjaminHodgson:我认为“直接风格”在极端情况下是使用函数应用语法(保留给Haskell中的->函数空间)。关于现代环境中实现的例子,在Idris中,你可以写[| f x y + z |]来表示(+) <$> (f <$> x <*> y) <*> z - Cactus
1
我曾经看到应用程序并行化的一个例子是在Haxl ICFP'14论文中,例如length <$> intersect’ (friendsOf x) (friendsOf y) http://community.haskell.org/~simonmar/papers/haxl-icfp14.pdf。除了Haxl之外,还有其他具有并行性实现的Applicative实例示例吗? - Rob Stewart
@RobStewart Concurrentlyasync 中的并行处理函数。 - Fraser
2
@RobStewart 当我写下那个答案时,我所想到的就是Simon M的工作。同时,无限流形成了一个单子,其中repeatreturn,而join则取一个无限方阵的对角线。当然,如果你只想要对角线,计算甚至thunks的矩阵是愚蠢的:>>=至少生成一个三角形。在这种意义上合并流的明智方法是通过压缩,这正是适用行为。它可能不完全是并行性,但它是另一个例子,其中<*>的默认实现从>>=中效率低下地过度顺序化。 - pigworker

13

总的来说,从你觉得最合理的地方开始。然后考虑以下内容。

使用Applicative(甚至是Functor)是很好的实践。通常情况下,像GHC这样的编译器更容易优化这些实例,因为它们可以比Monad更简单。我认为社区的一般建议是在AMP之后尽可能地使约束更加通用。我建议使用GHC扩展ApplicativeDo,因为您可以统一使用do符号,只有在需要时才会得到Applicative约束。

由于ParsecT解析器类型既是Applicative的实例,也是Monad的实例,所以您可以混用两者。在某些情况下,这样做更易读——这完全取决于情况。

另外,考虑使用megaparsecmegaparsec是一个更活跃的维护者,通常更为干净、更新的parsec分支。

编辑

重新阅读我的回答和评论后,有两个事情我没有澄清清楚:

  • 使用Applicative的主要好处是,对于许多类型,它允许更有效率的实现(例如,(<*>)ap性能更高)。

  • 如果您只想编写类似(+) <$> parseNumber <*> parseNumber 的内容,则无需使用ApplicativeDo - 它会变得更冗长。只有在您开始编写非常长或嵌套的applicative表达式时,才应使用ApplicativeDo


谢谢!我会看看megaparsec,它看起来很不错(Unicode和缩进支持看起来非常棒!) - José Miguel
1
这并不完全是关于编译器优化的问题;它更多地涉及到优化。一些类型基于它们的结构支持额外高效的<$fmap*><*和/或<*>;而其他类型则不支持。Data.Sequence每向下迈出一步都会获得主要的速度提升(尽管<*目前缺失;我将不得不修复它)。另一方面,列表只会获得微小的提升(除了map/coerce的特殊情况)。 - dfeuer
@dfeuer 嗯,这就是我所说的“这些实例可能比Monad更简单”的意思。但如果你在评论,那么我的回答就不明显了。 :) 我不想在这里过分强调这一点,因为当我查看ParsecT(<*>)时,它似乎与(>>=)相比并没有额外的效率。 - Alec
可能并不是!我认为大多数传统的解析器组合库都是基于不支持特别快速应用操作的解析器类型。我不知道是否有任何支持单子界面并优化应用程序接口的解析器组合库。 - dfeuer

7

继@pigworker之后(我在这里太新了,无法评论),值得注意的是join $ fM <*> ... <*> ... <*> ...也是一种模式。它可以像<$><*>一样,为您提供“bind1,bind2,bind3…”家族。

作为一种风格上的东西,当你足够熟悉组合器时,你可以使用do与使用let相同:作为一种突出显示想要命名某些内容的方式。例如,在解析器中,我倾向于更经常地命名事物,因为那可能对应于规范中的名称!


2
你能否详细说明一下?第一段有些模糊,第二段可以举例说明。 - dfeuer

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接