一尘不染

什么是monad

javascript

最近对 Haskell 进行了简要介绍,对于 monad 本质上是什么,有什么简短、简洁、实用的解释?

我发现我遇到的大多数解释都相当难以理解并且缺乏实际细节。


阅读 160

收藏
2022-02-21

共1个答案

一尘不染

首先:如果你不是数学家, monad这个词有点空洞。另一个术语是计算构建器,它更能描述它们的实际用途。

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

该模式主要用于函数式语言(尤其是普遍使用 monad 的 Haskell),但可以用于任何支持高阶函数的语言(即可以将其他函数作为参数的函数)。

JavaScript 中的数组支持该模式,所以让我们将其用作第一个示例。

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

首先是一个不使用 monad 模式的方法链接示例:

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

结果是[2,3,4]。代码不符合 monad 模式,因为我们作为参数提供的函数返回一个数字,而不是一个数组。monad 形式的相同逻辑是:

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

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

(提供给类似map或方法的函数参数flatMap在 JavaScript 中通常称为“回调”。我将其称为“操作”,因为它更通用。)

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

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

数组中的结果[2,4]

monad 形式的相同链接:

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

产生相同的结果,数组[2,4]

您会立即注意到 monad 形式比非 monad 更难看!这只是表明单子不一定是“好”的。它们是一种有时有益有时无益的模式。

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

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

这里的绑定是嵌套的而不是链式的,但结果是一样的。这是我们稍后会看到的 monad 的一个重要属性。这意味着两个操作组合在一起可以被视为一个单一的操作。

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

这可以使用 Typescript 表示法更正式地描述。数组具有 type Array<T>,其中T是数组中元素的类型。该方法flatMap()接受该类型的函数参数T => Array<U>并返回一个Array<U>.

概括地说,monad 是Foo<Bar>具有“绑定”方法的任何类型,该方法接受类型的函数参数Bar => Foo<Baz>并返回Foo<Baz>.

这回答monad 是什么。这个答案的其余部分将尝试通过示例来解释为什么 monad 在像 Haskell 这样对它们有很好的支持的语言中可以成为一种有用的模式。

Haskell 和 Do-notation

要将 map/filter 示例直接转换为 Haskell,我们flatMap>>=运算符替换:

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

运算符是 Haskell 中的>>=绑定函数。当操作数是一个列表时,它的作用与 JavaScript 中的相同flatMap,但是对于其他类型,它具有不同的含义。

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

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

这隐藏了“管道”,让您专注于每一步应用的实际操作。

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

后退箭头<-看起来像是一个赋值,但请注意这是绑定中传递的参数。因此,当右侧的表达式是整数列表时,左侧的变量将是单个整数——但将对列表中的每个整数执行。

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

列表说得够多了,让我们看看 monad 模式如何对其他类型有用。

某些函数可能并不总是返回有效值。在 Haskell 中,这由Maybe-type 表示,它是一个选项,要么是Just value要么Nothing

总是返回有效值的链接操作当然很简单:

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 用 monad 模式解决了这个问题Maybe

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

do块调用Maybe类型的绑定函数(因为第一个表达式的结果是 a Maybe)。如果值为 ,则绑定函数仅执行以下操作Just value,否则它只是传递Nothing

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

请注意,是monad 模式和 Haskell 中对 monad 友好的语法的组合产生了更简洁的代码。在像 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 有一堆与外界交互的内置函数:putStrLine等等readLine。这些函数被称为“不纯”,因为它们要么会导致副作用,要么会产生不确定的结果。即使像获取时间这样简单的事情也被认为是不纯的,因为结果是不确定的——用相同的参数调用它两次可能会返回不同的值。

纯函数是确定性的——它的结果完全取决于传递的参数,除了返回一个值之外,它对环境没有副作用。

Haskell 大力鼓励使用纯函数——这是该语言的一个主要卖点。不幸的是,对于纯粹主义者来说,你需要一些不纯的函数来做任何有用的事情。Haskell 的折衷方案是将纯函数和不纯函数清晰地分开,并保证纯函数无法直接或间接执行不纯函数。

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

但是语言如何防止纯函数执行不纯函数呢?这是由于 Haskell 的惰性。一个函数只有在它的输出被其他函数消耗时才会被执行。但是没有办法使用一个IO值,除非将它分配给main. 所以如果一个函数想要执行一个不纯的函数,它必须被连接main并具有IO类型。

对 IO 操作使用 monad 链接还可以确保它们以线性和可预测的顺序执行,就像命令式语言中的语句一样。

这将我们带到大多数人会用 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操作不能“泄漏”给纯函数。

加起来

在我的主观意见中,monad 模式只有在对模式有一些内置支持的语言中才真正值得。否则只会导致代码过于复杂。但是 Haskell(和其他一些语言)有一些内置的支持,隐藏了繁琐的部分,然后该模式可以用于各种有用的事情。喜欢:

  • 避免重复代码 ( Maybe)
  • 为程序的分隔区域添加可变状态或异常等语言功能。
  • 从好东西中分离出讨厌的东西 ( IO)
  • 嵌入式领域特定语言 ( Parser)
  • 将 GOTO 添加到语言中。
2022-02-21