有人能解释一下Monad吗?
什么是Monad?简单来说,它是一种特定的操作链接方式。实际上,你在编写执行步骤,并使用“bind函数”将它们链接在一起。这个bind函数的作用就像一个分号,它将一个过程中的步骤分隔开来。bind函数的工作是将上一个步骤的输出传递给下一个步骤。
但是,有不止一种类型的Monad。为什么?每个有用的Monad除了作为Monad外,还有其他特殊功能。每个有用的Monad都有一个独特的“特殊能力”。
基本上,每个Monad都有自己的bind函数实现。你可以编写一个bind函数,使它在执行步骤之间执行一些特殊的操作。例如:
- 如果每个步骤返回一个成功/失败指示器,你可以让bind函数只在前一个步骤成功时才执行下一个步骤。这样,一个失败的步骤会“自动”中止整个序列,无需进行任何条件测试。(Failure Monad)
- 扩展这个想法,你可以实现“异常”(Error Monad或Exception Monad)。因为你是自己定义它们而不是语言的特性,所以你可以定义它们的工作方式。(例如,也许你想忽略前两个异常,只在抛出第三个异常时中止。)
- 你可以使每个步骤返回多个结果,并让bind函数循环处理它们,将每个结果传递给下一个步骤。这样,在处理多个结果时,你就不必到处写循环了,bind函数会自动为你完成所有这些。(List Monad)
- 除了将“结果”从一个步骤传递到另一个步骤,你还可以让bind函数传递额外的数据。这样,这些数据就不会出现在你的源代码中,但你仍然可以从任何地方访问它,无需手动将其传递给每个函数。(Reader Monad)
- 你可以使“额外数据”可以被替换。这样一来,你就可以模拟破坏性更新,而不实际进行破坏性更新。(State Monad及其相关的Writer Monad)
- 由于你只是“模拟”破坏性更新,所以你可以轻松地做一些使用“真实”的破坏性更新无法实现的事情。例如,你可以“撤销最后一次更新”,或者“恢复到旧版本”。
- 你可以创建一个可以“暂停”计算的Monad,这样你就可以暂停程序,进入并调整内部状态数据,然后再恢复它。
- 你可以将“continuations”实现为一个Monad。这让你能够“打破人们的思维!”
Monad能够实现以上所有功能以及更多。当然,所有这些功能在没有Monad的情况下也是完全可能的。只是使用Monad会更加容易。
Haskell至少支持这一点,从数学上讲,你可以以>>=和return的形式或join和ap的形式定义一个Monad。>>=和return是让Monad实际上有用的东西,但join和ap可以给出对Monad是什么的更直观的理解。
Monad是一种特定的操作链接方式,它使操作更加简单易懂。它为每个步骤提供了灵活的处理方式,并允许实现各种有用的功能。虽然在没有Monad的情况下也可以实现相同的功能,但使用Monad会更加方便。
什么是单子?这个问题的出现是因为单子这个术语对于非数学家来说有点空洞。另一个术语是“计算生成器”,这个术语更能描述单子的实际用途。它们是一种用于链接操作的模式。它看起来有点像面向对象语言中的方法链接,但机制略有不同。该模式主要用于函数式语言(特别是广泛使用单子的Haskell),但可以用于任何支持高阶函数的语言(即能够接受其他函数作为参数的函数)。JavaScript中的数组支持该模式,让我们以此为例进行说明。
模式的要点是,我们有一个类型(在这种情况下是Array),该类型有一个以函数作为参数的方法。提供的操作必须返回相同类型的实例(即返回一个Array)。首先是一个不使用单子模式的方法链接的示例:
[1,2,3].map(x => x + 1)
结果是[2,3,4]。代码不符合单子模式,因为我们提供的函数作为参数返回一个数字,而不是一个Array。以单子形式表示相同逻辑的代码是:
[1,2,3].flatMap(x => [x + 1])
这里我们提供了一个返回Array的操作,所以现在符合模式。flatMap方法对数组中的每个元素执行提供的函数。它期望每次调用的结果是一个数组(而不是单个值),但将生成的一组数组合并为一个数组。因此最终结果是相同的,即数组[2,3,4]。
(像map或flatMap这样的方法提供的函数参数在JavaScript中通常被称为“回调函数”。我将其称为“操作”,因为它更加通用。)
如果我们链接多个操作(以传统方式):
[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
这回答了“单子是什么”。本答案的其余部分将试图通过示例解释为什么单子可以成为像Haskell这样的语言中有用的模式。
哈斯克尔和Do-notation
为了直接将map/filter示例翻译成Haskell,我们将flatMap替换为>>=运算符:
[1,2,3] >>= \a -> [a+1] >>= \b -> if b == 3 then [] else [b]
>>=运算符是Haskell中的bind函数。当操作数是列表时,它执行与JavaScript中flatMap相同的操作,但对于其他类型,它具有不同的含义。
但是Haskell还有一个专用的语法用于单子表达式,即do块,它完全隐藏了bind运算符:
do
a <- [1,2,3]
b <- [a+1]
if b == 3 then [] else [b]
这隐藏了“连接”问题,并让您专注于每个步骤中应用的实际操作。
在do块中,每行都是一个操作。约束仍然是所有操作必须返回相同类型。由于第一个表达式是列表,其他操作也必须返回列表。
反斜杠<-看起来很像赋值,但请注意,这是传递给bind的参数。因此,当右侧的表达式是整数列表时,左侧的变量将是单个整数,但将为列表中的每个整数执行。
例子:安全导航(Maybe类型)
够了关于列表的内容,让我们看看单子模式如何适用于其他类型。
某些函数可能不总是返回有效值。在Haskell中,这由Maybe类型表示,它是一个选项,可以是Just值,也可以是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通过Maybe的单子模式来解决这个问题:
do
user <- getUser 17
addr <- getAddress user
getStreetName addr
这个do块调用Maybe类型的bind函数(因为第一个表达式的结果是Maybe)。如果值是Just value,bind函数才执行以下操作,否则仅传递Nothing。
这里单子模式用于消除重复代码。这类似于其他一些语言使用宏来简化语法,尽管宏以非常不同的方式实现相同的目标。
请注意,正是单子模式和Haskell中对单子友好的语法的组合才导致了更简洁的代码。在没有任何特殊语法支持单子的语言(比如JavaScript)中,我怀疑单子模式能够在这种情况下简化代码。
可变状态
Haskell不支持可变状态。所有变量都是常量,所有值都是不可变的。但是State类型可以用于模拟使用可变状态进行编程:
add2 :: State Integer Integer
add2 = do
-- 将状态加1
x <- get
put (x + 1)
-- 以另一种方式进行增加
modify (+1)
-- 返回状态
get
evalState add2 7
=> 9
add2函数构建了一个单子链,然后使用7作为初始状态进行评估。
显然,这只对Haskell有意义。其他语言直接支持可变状态。Haskell通常是按需启用语言功能的,“需要时启用可变状态,并且类型系统保证效果是显式的。IO就是另一个例子。
IO
IO类型用于链接和执行“不纯”函数。
与任何其他实用语言一样,Haskell具有许多内置函数与外部世界进行交互:putStrLine、readLine等等。这些函数被称为“不纯”,因为它们会引起副作用或具有非确定性结果。即使是获取时间这样简单的事情也被认为是不纯的,因为结果是非确定性的-使用相同的参数调用它两次可能返回不同的值。
纯函数是确定性的-其结果仅取决于传递的参数,并且对环境没有任何副作用,只返回一个值。
Haskell非常鼓励使用纯函数-这是该语言的一个重要卖点。对于纯粹主义者来说,你需要一些不纯的函数才能做到实用。Haskell的妥协是清晰地区分纯和不纯,并保证纯函数无法直接或间接地执行不纯函数。
这是通过给所有不纯函数赋予IO类型来实现的。Haskell程序的入口点是具有IO类型的main函数,因此我们可以在顶层执行不纯函数。
但是,语言如何防止纯函数执行不纯函数?这是由于Haskell的惰性特性。仅当其输出被其他函数消耗时,才执行函数。但是没有办法消耗IO值,除非将其分配给main。因此,如果函数想要执行不纯函数,它必须连接到main并具有IO类型。
使用IO操作的单子链接还确保按线性和可预测的顺序执行它们,就像命令式语言中的语句一样。
这将我们带到大多数人在Haskell中会写的第一个程序:
main :: IO ()
main = do
putStrLn "Hello World"
当只有单个操作且没有绑定时,do关键字是多余的,但为了一致性,我仍然保留它。
()类型表示“void”。此特殊返回类型仅对IO函数在意义上有用。
一个更长的示例:
main = do
putStrLn "What is your name?"
name <- getLine
putStrLn ("hello " ++ name)
这构建了一个IO操作链,并且由于它们被分配给main函数,它们将被执行。
将IO与Maybe进行比较可以显示单子模式的多功能性。对于Maybe,该模式用于通过将条件逻辑移到绑定函数中来避免重复代码。对于IO,该模式用于确保所有IO操作按顺序执行,并且IO操作不能“泄漏”到纯函数中。
总结
在我个人的观点中,单子模式只在语言对模式有一些内置支持的情况下才真正有价值。否则,它只会导致过于复杂的代码。但是Haskell(和其他一些语言)具有一些隐藏繁琐部分的内置支持,然后该模式可以用于各种有用的事情。例如:
避免重复代码(Maybe)
为程序的特定区域添加可变状态或异常
将令人讨厌的东西与好的东西隔离(IO)
嵌入领域特定语言(解析器)
为语言添加GOTO
我希望这篇文章对你有所帮助。但是,我仍然不知道单子是什么,更不用说简洁明了的解释了。