单子(Monads)有什么特别之处?

28

单子是一种数学结构,广泛用于(纯)函数式编程,基本上是Haskell。然而,还有许多其他可用的数学结构,例如应用函子、强单子或幺半群。一些结构更具体,一些则更加通用。然而,单子更受欢迎。为什么呢?

我想到的一个解释是,它们处于通用性和特定性之间的一个甜点。这意味着单子捕获了关于数据的足够假设,以应用我们通常使用的算法,我们通常拥有的数据也满足单子法则。

另一个解释可能是Haskell针对单子(do-notation)提供了语法,但没有针对其他结构提供语法,这意味着Haskell程序员(因此函数式编程研究人员)在直觉上倾向于单子,虽然更通用或特定(高效)的功能同样适用。


11
嗯,我不确定你的前提是否正确:我认识的大多数高级Haskell程序员在某种程度上更喜欢应用函子。我肯定是这样的。人们也经常使用单子来完成完全无关的任务。范畴论有时也会自行出现。单子只是因为某种原因在非Haskell使用电路中得到了大量关注。 - Tikhon Jelvis
1
@TikhonJelvis:我没有看到“我认识的大多数高级Haskeller在某种程度上更喜欢应用程序”。我看到受到最关注的事情与Applicatives无关。例如,为了使用我一直在努力的东西,我判断自由单子仍然比自由应用程序更受关注。不过,最近对自由应用程序的兴趣有所增加。 - Luis Casillas
6
据我所记,Monad 是由 Wadler 推广的。当时,实现 IO 操作而不需要使用繁琐的 CPS (Continuation Passing Style) 和无需显式状态传递的解析功能是 Monad 的巨大卖点,这是一个非常令人兴奋的时期。据我所知,Haskell 并没有构造器类,但 Gofer(Hugs 的前身)有。Wadler 提出了对 Monad 进行列表推导的重载,因此 do 符号稍后才出现。一旦 IO 成为了 Monad,Monad 就成为初学者必须掌握的重要概念。Applicatives 更加优雅,Arrows 更加通用,但它们是后来的东西,而 IO 强调 Monad 的重要性。 - AndrewC
6个回答

29

我怀疑对这个特定类型类 (Monad) 的过度关注主要是历史上的偶然。人们经常将 IOMonad 联系起来,虽然这两个概念是独立有用的 (就像列表反转和香蕉一样)。由于 IO 是神奇的(有一个实现但没有表示),而 Monad 经常与 IO 相关联,因此很容易陷入关于 Monad 的神奇思维中。

(顺便说一下:值得怀疑的是,IO 是否真的是单子。单子法则成立吗?对于 IO,这些规则甚至意味着什么?请注意,它与状态单子的相关性存在问题。)


6
“is magical”这个词组指的是“具有实现但没有指称”,我会将其纳入我的工具箱中。 - J. Abrahamson
1
我敢肯定我们用香蕉写过列表反转!至少我不记得需要镜头、信封或铁丝网... - sclv
3
当然有!看看Lawvere的经典作品或Wells和Barr的《Toposes, Triples and Theories》就知道了。 - sclv
5
谢谢,Ben。我考虑的是指称相等性。(顺便提一句,当Eq实例确实存在时,我总是希望它们的(==)对应于指称相等性。)请注意,我并没有暗示IO不符合单子律(或者事实上任何等式法则),而是我们需要精确定义相等性(最好是通过指称方式,考虑到我们选择的编程范式),以便有一个有意义的问题。 - Conal
1
@Conal Eq 不能真正对应于指称相等,因为我们有底部。但它可以近似。 - augustss
显示剩余11条评论

17
如果一个类型 m :: * -> * 有一个 Monad 实例,那么你就可以使用函数组合的方式实现具有类型 a -> m b 的图灵完备性。这是一个非常有用的属性。你可以将各种图灵完备的控制流程抽象出来,使其不再与特定的含义相关。这是一个最小的组合模式,支持抽象任何控制流程以适应支持该模式的类型。

相比之下,例如在Applicative里,你只能获得等效于推动式自动机的计算能力的组合模式。当然,更多类型支持具有更有限的能力的组合。当你限制可用的功能时,你可以进行额外的优化。这两个原因是为什么Applicative类存在并且很有用的原因。但通常可以成为Monad实例的东西,这样类型的用户就可以执行最通用的操作。

编辑: 根据广大读者要求,以下是一些使用Monad类的函数:

ifM :: Monad m => m Bool -> m a -> m a -> m a
ifM c x y = c >>= \z -> if z then x else y

whileM :: Monad m => (a -> m Bool) -> (a -> m a) -> a -> m a
whileM p step x = ifM (p x) (step x >>= whileM p step) (return x)

(*&&) :: Monad m => m Bool -> m Bool -> m Bool
x *&& y = ifM x y (return False)

(*||) :: Monad m => m Bool -> m Bool -> m Bool
x *|| y = ifM x (return True) y

notM :: Monad m => m Bool -> m Bool
notM x = x >>= return . not

结合使用do语法(或原始的>>=运算符)可以实现名称绑定,无限循环和完整的布尔逻辑。这是一个众所周知的原语集,足以实现图灵完备性。请注意,所有函数都已被提升以在单子值上工作,而不是简单的值上。只有在必要时才绑定所有单子效应-仅将ifM选择分支的效应绑定到其最终值中。*&&*||在可能的情况下忽略其第二个参数。依此类推..

现在,那些类型签名可能不涉及每个单子操作数的功能,但那只是认知简化。如果所有非函数参数和结果都更改为() -> m a,则除了bottom之外,不会有语义差异。它只是友好地优化了用户的认知负担。

现在,让我们看看那些具有Applicative接口的函数会发生什么。

ifA :: Applicative f => f Bool -> f a -> f a -> f a
ifA c x y = (\c' x' y' -> if c' then x' else y') <$> c <*> x <*> y

嗯,它有同样的类型签名。但是这里已经有一个很大的问题了。无论选择哪个值,x和y的影响都会绑定到组合结构中。

whileA :: Applicative f => (a -> f Bool) -> (a -> f a) -> a -> f a
whileA p step x = ifA (p x) (whileA p step <$> step x) (pure x)

好的,那似乎还不错,除了它是一个无限循环,因为ifA总会执行两个分支...除非它甚至没有那么接近。pure x的类型是f a。而whileA p step <$> step x的类型是f (f a)。这甚至不是一个无限循环。这是一个编译错误。让我们再试一次。

whileA :: Applicative f => (a -> f Bool) -> (a -> f a) -> a -> f a
whileA p step x = ifA (p x) (whileA p step <*> step x) (pure x)

哎呀,这可糟了。甚至没能完成第一步。whileA p step的类型是a -> f a。如果你尝试将其用作<*>的第一个参数,它会获取顶层类型构造函数(即(->))的Applicative实例,而不是f的实例。是的,这也不起作用。

实际上,从我的Monad示例中,唯一可以与Applicative接口一起使用的函数是notM。这个特定的函数只需要Functor接口就可以正常工作。剩下的都失败了。

当然,使用Monad接口编写代码并不能使用Applicative接口所不能的。毕竟,Monad更加强大。但有趣的是你失去了什么。你失去了基于输入更改效果的函数组合能力。也就是说,你失去了编写具有a -> f b类型的函数组合的某些控制流模式的能力。

图灵完备的组合正是使Monad接口变得有趣的原因。如果它不允许图灵完备的组合,作为程序员,你将无法组合在任何特定控制流中使用IO动作。正是使用Monad原语来表达任何控制流,使得IO类型成为Haskell管理IO问题的可行方式。

除了IO之外,许多类型都具有语义上有效的Monad接口。而且恰巧Haskell具有抽象整个接口的语言功能。由于这些因素,Monad是提供实例时非常有价值的类。这样做可以让你访问所有现有的与单子类型一起使用的抽象功能,而不管具体类型是什么。

所以如果Haskell程序员似乎总是关心某个类型的Monad实例,那是因为它是最通用的实例。


3
图灵完备性与此有什么关系?(请注意,非图灵完备的语言完全可以使用单子,例如Agda) - Ben Millwood
1
@BenMillwood Agda允许您使用许多通常是图灵完备的结构,只要您证明它们的特定用途将终止-例如非结构递归。关键在于monad接口允许您描述图灵完备模式,而不是要求您使用它们。这是因为=<<允许您让其第一个操作数使用的递归结构取决于第二个操作数的值。下一个最接近的组合运算符<*>来自Applicative,要求递归结构不依赖于第二个操作数的值。 - Carl
3
我仍然认为ApplicativeMonad之间的区别与图灵完备性无关。我不明白这两个概念如何相关- 图灵完备性大致意味着“能够模拟图灵机,并且可以被图灵机模拟”。显然,这里讨论的一切都符合后者条件。Monad中的函数组合a -> m b能否模拟图灵机?它怎么可能尝试做到这一点呢? - Ben Millwood
1
图灵完备性也等同于“可以解析上下文敏感语法”。应用程序解析器最多可以解析无上下文语法。单子解析器可以解析上下文敏感语法。这是接口之间能力的根本差异。 - Carl
3
这是我所缺失的内容:图灵完备性是计算模型的一种属性——它指的是能够接受某种输入并输出某种结果的过程。我无法理解一种特定函数组合如何构成计算模型,除非是像Haskell本身那样。输入在哪里?输出从哪里产生? - Ben Millwood
显示剩余6条评论

12
首先,我认为单子并不比任何其他类别更受欢迎; Functor 和 Monoid 都有许多实例不是单子。但它们都非常具体; Functor 提供映射,Monoid 提供串联。Applicative 是我所能想到的唯一一个可能被低估的类别,由于它是一种相对较新的语言特性,因此具有相当大的能力。
但是,确实单子非常流行。其中一部分原因是 do 符号;很多 Monoid 提供 Monad 实例,仅将值附加到运行累加器(基本上是隐式 writer)。blaze-html 库是一个很好的例子。我认为原因是类型签名 (>>=) :: Monad m => m a -> (a -> m b) -> m b 的威力。虽然 fmap 和 mappend 非常有用,但它们能做的事情相对狭窄。相反,绑定可以表达各种各样的东西。当然,在 IO 单子中规范化之外,它还提供了隐式状态 (Reader/Writer/ST),从而避免了一些非常繁琐的变量传递。各种状态单子,尤其是,非常重要,因为它们提供了一个保证状态是单线程的保证,允许在融合之前在纯 (非 IO) 代码中使用可变结构。但是 bind 还有一些更奇特的用途,例如扁平化嵌套数据结构(List 和 Set 单子),两者在其位置上都非常有用(我通常看到它们被解糖,显式调用 liftM 或 (>>=),因此这不是 do 符号的问题)。因此,尽管 Functor、Monoid(以及稍微罕见的 Foldable、Alternative、Traversable 等)为一个相当简单的函数提供了标准接口,但 Monad 的 bind 具有更大的灵活性。
总之,我认为你所有的原因都有一定的作用;单子的流行既是历史偶然(由 do 符号和后期定义的 Applicative 组合而成) ,也是它们的力量、普遍性(相对于 Functor、Monoid 等)和易理解性(相对于箭头)的结合。

7
首先,让我解释一下单子的作用:单子非常强大,但在某种意义上:你几乎可以使用单子来表达任何东西。Haskell语言没有像操作循环、异常、变异、goto等这些东西。单子可以在语言内部表达(因此它们不是特殊的),并使所有这些功能可达。
这有积极和消极两面性:积极的是,您可以表达从命令式编程中所知道的所有控制结构以及其中许多你不知道的控制结构。我最近开发了一种单子,它可以让您在计算过程中重新进入中间,并带有稍微调整的上下文。这样,您就可以运行计算,如果失败,只需尝试稍微调整值即可重试。此外,单子操作是第一类的,这就是您构建诸如循环或异常处理之类的内容的方式。虽然while在C中是原始的,在Haskell中它实际上只是一个常规函数
消极的一面是,单子几乎没有任何保证。它们是如此强大,以至于您可以做任何您想做的事情,简而言之。换句话说,就像您从命令式语言中所知道的那样,仅通过查看代码来推理代码可能很困难。
更一般的抽象是更一般的,因为它们允许表达一些概念,您无法将其表达为单子。但这只是故事的一部分。即使对于单子,您也可以使用称为应用程序样式的样式,其中使用应用程序接口从小型隔离部件组合程序。这样做的好处是您可以通过查看代码来推理代码,并且可以开发组件而无需注意系统的其余部分。

2
有很多事情可以用Functor或Applicative表达,但不能用Monad表达。为什么Haskell程序员仍然更喜欢Monad?https://dev59.com/bmw05IYBdhLWcg3weBvu#7220548 - qznc
在我看来,因为社区首先学会了如何解决更一般的问题,并且仍在学习如何使用其受限子集。最近,“Applicative”已经在这方面获得了推动力并颠覆了一些事情,但我们仍有很长的路要走。 - Luis Casillas
1
@qznc - FunctorApplicative接口所能表达的任何内容都可以通过Monad接口来表达。Monad更加强大。你提供的链接是关于实现接口的,这是另一个方向。当然,严格意义上更不强大的接口有更多(或相等,但在这种情况下不是)可能的实现。 - Carl
使用for循环作为示例,我发现do允许您绑定另一个上下文,这意味着您可以将高维数据绑定到低维变量,这意味着您可以在不编写for循环的情况下表达事物,这有点疯狂。 - windmaomao

2
“Monad”有什么特别之处?
在Haskell中,单子接口的主要优点是它在替换原始和笨重的基于对话的I/O机制方面的作用。
至于它们在正式调查背景下的地位……这只是一个看似循环的努力的迭代,现在(2021年10月)已经大约有半个世纪的历史了。
在20世纪60年代,几位研究人员开始着手证明关于程序的一些事情。他们努力证明:

  • 一个程序是正确的。

  • 两个具有不同代码的程序在给定相同输入时计算出相同的答案。

  • 一个程序比另一个程序更快。

  • 一个给定的程序将总是终止。

虽然这些都是抽象的目标,但它们实际上都与“调试程序”的实际目标相同。

这项工作中出现了几个困难问题。其中一个是“规范化”问题:在证明程序正确之前,必须正式而明确地指定“正确”的含义。已经开发了用于指定程序含义的正式系统,并且它们看起来非常像编程语言。

引自:编程语言解剖学, Alice E. Fischer and Frances S. Grodzinsky.

...回到“编程语言”还只有极少数敢于冒险的时代,它们绝对是命令式的。

有人想将这个谜题提升到千禧难题的级别吗?解决它肯定会推进计算科学和软件工程的发展,无论采用哪种方式都是如此...


0

单子因为 do 符号而变得特殊,它让你可以在函数式语言中编写命令式程序。单子是一种抽象,它允许你从更小、可重用的组件(它们本身就是命令式程序)中拼接出命令式程序。单子变换器也很特殊,因为它们代表着用新功能增强命令式语言。


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