什么是 Monad?

24 浏览
0 Comments

什么是 Monad?

最近我简要地了解了Haskell,那么能否简洁明了地解释一下单子(monad)本质上是什么呢?

我发现大多数我遇到的解释都相当晦涩难懂,缺乏实用细节。

admin 更改状态以发布 2023年5月22日
0
0 Comments

解释“什么是单子”有点像说“什么是数字”?我们一直在使用数字,但是想象一下,如果你遇到一个不知道数字的人,你会怎么解释数字是什么?而且你怎么开始描述为什么这有用呢?

什么是单子?简单的回答是:它是一种特定的链接操作的方式。

实质上,你正在编写执行步骤,并使用“绑定函数”将它们链接在一起。(在 Haskell 中,它的名称为 >>=.) 你可以自己编写对绑定运算符的调用,也可以使用语法糖,让编译器为你插入这些函数调用。但无论哪种方式,每个步骤都由对 bind 函数的调用分隔。

因此,bind 函数就像一个分号,它将处理过程中的步骤分隔开来。bind 函数的工作是获取前一个步骤的输出,并将其馈送到下一个步骤。

听起来并不难,对吧?但是,有不止一种单子。为什么?怎么回事?

好吧,bind 函数只能从一步骤中获取结果,并将其提供给下一步骤。但是,如果这是“所有”单子所做的事情…那实际上并不太有用。这一点很重要:每个有用的单子还会在除了成为单子之外做其他事情。每个有用的单子都有一种“特殊的能力”,使其独特。

(一个不做任何特殊操作的单子称为“恒等单子”。有点像恒等函数,这听起来像是毫无意义的东西,但实际上并不是…但这是另一个故事。)

基本上,每个单子都有其自己的绑定函数实现。你可以编写一个绑定函数,使其在执行步骤之间执行奇怪的操作。例如:

如果每个步骤都返回一个成功/失败指示器,您可以让绑定函数仅在前一个步骤成功时执行下一个步骤。这样,失败的步骤会“自动”中止整个序列,而不需要进行任何条件测试。(失败的单子)

扩展这个想法,您可以实现“异常”。 (错误单子或异常单子。)因为您自己定义它们而不是一种语言功能,所以您可以定义它们的工作方式。(例如,也许您想忽略前两个异常,只有在抛出第三个异常时才终止程序)

您可以使每个步骤返回多个结果,并让绑定函数循环遍历它们,将每一个输入到下一个步骤中。这样,当处理多个结果时,您就不必在所有地方都编写循环。绑定函数“自动”为您执行所有操作。(列表单子。)

除了从一个步骤传递“结果”到另一个步骤之外,您还可以让绑定函数传递额外的数据。现在,这些数据不会出现在您的源代码中,但您仍然可以从任何地方访问它们,而无需手动将其传递给每个函数。(读取器单子。)

您可以使“额外数据”可以被替换。这允许您模拟破坏性更新,而不实际进行破坏性更新。(状态单子及其近亲编写器单子。)

由于只是模拟破坏性更新,因此您可以轻松地完成实际破坏性更新无法完成的事情。例如,您可以撤消上次更新,或者恢复到旧版本。

您可以创建一个可以暂停计算的单子,以便您可以暂停程序,进入并调整内部状态数据,然后恢复程序运行。

您可以将“续集”实现为单子。这使您可以打破人们的思维限制!

使用单子可以实现所有这些功能以及更多功能。当然,没有单子也可以完全实现所有这些,但使用单子要简单得多。

0
0 Comments

首先:如果你不是一位数学家,那么术语单子(Monad)可能有些虚无缥缈。另一种替代术语是计算生成器(Computation Builder),更能描述它们的实际用途。

它们是一种用于链接操作的模式。它看起来有点像面向对象语言中的方法链接,但机制略有不同。

这种模式主要用于函数式编程语言(尤其是使用单子广泛的Haskell),但可以用于任何支持高阶函数的语言(即可以将其他函数作为参数传递的函数)。

JavaScript中的数组支持此模式,所以让我们以此为第一个示例。

模式的要点是,我们有一个类型(在这种情况下是Array),它有一个接受函数作为参数的方法。所提供的操作必须返回同一类型的实例(即返回一个Array)。

首先,看一个不使用单子模式的方法链接示例:

[1,2,3].map(x => x + 1)

结果是[2,3,4]。该代码不符合单子模式,因为我们提供的参数函数返回一个数字而不是一个数组。将相同的逻辑用单子形式表达如下:

[1,2,3].flatMap(x => [x + 1])

这里我们提供一个返回Array的操作,现在它符合模式了。flatMap方法为数组中的每个元素执行提供的函数。它期望每个调用的结果都是一个数组(而不是单个值),但将结果数组的集合合并为单个数组。最终结果是相同的,即数组[2,3,4]

在 JavaScript 中,调用 mapflatMap 等方法时提供的函数参数通常称为“回调函数”。由于它更为通用,我将其称为“操作”。

如果我们以传统的方式链接多个操作:

[1,2,3].map(a => a + 1).filter(b => b != 3)

结果是数组 [2,4]

以单子形式进行相同的链接:

[1,2,3].flatMap(a => [a + 1]).flatMap(b => b != 3 ? [b] : [])

会得到相同的结果,即数组 [2,4]

您会立即注意到,单子形式比非单子形式要难看得多!这恰恰表明,单子并不一定是“好”的。它们是一种模式,有时有益,有时则不然。

请注意,单子模式可以以不同的方式组合:

[1,2,3].flatMap(a => [a + 1].flatMap(b => b != 3 ? [b] : []))

这里的绑定是嵌套而非链接的,但结果是相同的。这是单子的一个重要属性,我们稍后会看到。它意味着两个组合的操作可以与单个操作相同地处理。

操作允许返回具有不同元素类型的数组,例如将数字数组转换为字符串数组或其他类型,只要仍然是一个数组即可。

可以使用 TypeScript 表示法更形式化地描述这一点。一个数组的类型是 Array,其中 T 是数组元素的类型。方法 flatMap() 采用类型为 T => Array 的函数参数,并返回一个 Array

泛化地说,单子是任何类型为 Foo 的类型,它具有一个“绑定”方法,该方法接受类型为 Bar => Foo 的函数参数,并返回一个 Foo

这回答了monads是什么。本回答的其余部分将通过实例来解释为什么在像Haskell这样支持它们的语言中,monads可以成为一个有用的模式。

Haskell和Do-notation

为了直接将映射/过滤示例翻译成Haskell,我们使用>=运算符替换flatMap

[1,2,3] >>= \a -> [a+1] >>= \b -> if b == 3 then [] else [b] 

>=运算符是Haskell中的绑定函数。当操作数为列表时,它与JavaScript中的flatMap执行相同的操作,但对于其他类型,它被重载为不同的含义。

但是,Haskell还有一个专门用于monad表达式的语法,即do-块,它完全隐藏了绑定运算符:

do 
  a <- [1,2,3] 
  b <- [a+1] 
  if b == 3 then [] else [b] 

这样就隐藏了“管道”,让您专注于每个步骤的实际操作。

do-块中,每行都是一个操作。约束仍然存在,即块中的所有操作必须返回相同的类型。由于第一个表达式是一个列表,因此其他操作也必须返回列表。

回箭头<-看起来容易被误解为赋值,但请注意,这是在绑定中传递的参数。因此,当右侧表达式是Integers列表时,左侧变量将是单个Integer——但在列表中的每个整数上都将执行。

示例:安全导航(Maybe类型)

足够讨论列表了,让我们看看monad模式如何对其他类型有用。

有些函数可能不总是返回有效值。在Haskell中,这由Maybe类型表示,它是一个选项,可以是Just valueNothing

链接操作总是返回有效值,这当然是直截了当的:

streetName = getStreetName (getAddress (getUser 17)) 

但是如果任何一个函数都可能返回Nothing呢?我们需要逐个检查每个结果,只有在它不是Nothing时才将值传递给下一个函数:

case getUser 17 of
      Nothing -> Nothing 
      Just user ->
         case getAddress user of
            Nothing -> Nothing 
            Just address ->
              getStreetName address

相当多的重复检查!想象一下链更长的情况。Haskell使用Maybe的monad设计解决了这个问题:

do
  user <- getUser 17
  addr <- getAddress user
  getStreetName addr

这个do块调用了Maybe类型的bind函数(因为第一个表达式的结果是一个Maybe)。bind函数只有在值为Just value时才执行以下操作,否则它只是将Nothing传递下去。

在这里,monad-pattern用于避免重复的代码。这类似于其他一些语言使用宏来简化语法,尽管宏以非常不同的方式实现了相同的目标。

请注意,是monad-pattern和Haskell中monad-friendly语法的组合导致代码更加简洁。在像JavaScript这样没有任何特殊语法支持monad的语言中,我怀疑monad设计能否在这种情况下简化代码。

可变状态

Haskell不支持可变状态。所有变量都是常量,所有值都是不可变的。但是State类型可以用来模拟带有可变状态的编程:

add2 :: State Integer Integer
add2 = do
        -- add 1 to state
         x <- get
         put (x + 1)
         -- increment in another way
         modify (+1)
         -- return state
         get
evalState add2 7
=> 9

add2函数构建了一个monad链,然后以7作为初始状态进行评估。

显然,这只有在Haskell中才有意义。其他语言默认支持可变状态。在Haskell中,语言特性通常是"选择性"的,当你需要时,你可以启用可变状态,类型系统可以确保其效果是显式的。IO就是另一个例子。

IO

IO类型被用于链接和执行"不纯"函数。

和任何实用语言一样,Haskell有一堆内置函数,用于与外界进行交互:如putStrLinereadLine等。这些函数被称为"不纯",因为它们要么产生副作用,要么具有非确定性结果。即使是简单的获取时间也被认为是"不纯"的,因为结果是非确定性的——使用相同的参数两次调用可能返回不同的值。

纯函数是确定性的——它的结果仅仅取决于传递的参数,并且在返回值之外没有对环境产生副作用。

Haskell非常鼓励使用纯函数——这是该语言的一个重要卖点。不幸的是,你需要一些不纯函数来做任何有用的事情。Haskell的妥协是清晰地将纯和不纯分离,并保证纯函数不能直接或间接执行不纯函数。

这是通过给所有不纯函数分配IO类型来保证的。在Haskell程序中,入口点是具有IO类型的main函数,因此我们可以在顶层执行不纯函数。

但是,这种语言如何防止纯函数执行不纯函数呢?这是由于Haskell的惰性本质。函数只有在其输出被另一个函数消耗时才被执行。但是,除了将其分配给``main``之外,没有任何方法可以使用一个``IO``值。因此,如果一个函数想要执行一个不纯的函数,它必须连接到``main``并具有``IO``类型。

使用流操作符进行IO操作还确保它们按线性和可预测的顺序执行,就像命令式语言中的语句一样。

这带我们来了Haskell中大多数人都会写的第一个程序:

main :: IO ()
main = do 
        putStrLn ”Hello World”

当只有一个操作且没有什么要绑定时,``do``关键字是多余的,但是出于一致性的考虑,我仍然保留它。

``()``类型意味着“空”。这种特殊的返回类型仅对于调用它们的副作用函数的IO函数有用。

长一点的示例是这样的:

main = do
    putStrLn "What is your name?"
    name <- getLine
    putStrLn ("hello" ++ name)

这构建了一个``IO``操作的链,由于它们被分配给``main``函数,因此它们被执行。

将``IO``与``Maybe``进行比较可以显示单子模式的多功能性。对于``Maybe``,该模式用于通过将条件逻辑移到绑定函数中来避免重复的代码。对于``IO``,该模式用于确保``IO``类型的所有操作被序列化,并且``IO``操作不能“泄漏”到纯函数中。

总结

在我的主观看法中,单子模式在只有一些内置支持该模式的语言中才真正值得使用。否则,它只会导致过度复杂的代码。但是Haskell(和其他一些语言)具有一些内置支持,隐藏了令人厌烦的部分,然后该模式可以用于各种有用的事情。比如:

  • 避免重复的代码(Maybe
  • 为程序的有限区域添加语言特点,例如可变状态或异常。
  • 将刺人的东西与美好的东西隔离开来(IO
  • 嵌入领域专用语言(Parser
  • 为语言添加GOTO。
0