最近对 Haskell 进行了简要介绍,对于 monad 本质上是什么,有什么简短、简洁、实用的解释?
我发现我遇到的大多数解释都相当难以理解并且缺乏实际细节。
首先:如果你不是数学家, monad这个词有点空洞。另一个术语是计算构建器,它更能描述它们的实际用途。
它们是链接操作的模式。它看起来有点像面向对象语言中的方法链接,但机制略有不同。
该模式主要用于函数式语言(尤其是普遍使用 monad 的 Haskell),但可以用于任何支持高阶函数的语言(即可以将其他函数作为参数的函数)。
JavaScript 中的数组支持该模式,所以让我们将其用作第一个示例。
该模式的要点是我们有一个类型(Array在这种情况下),它有一个将函数作为参数的方法。提供的操作必须返回一个相同类型的实例(即返回一个Array)。
Array
首先是一个不使用 monad 模式的方法链接示例:
[1,2,3].map(x => x + 1)
结果是[2,3,4]。代码不符合 monad 模式,因为我们作为参数提供的函数返回一个数字,而不是一个数组。monad 形式的相同逻辑是:
[2,3,4]
[1,2,3].flatMap(x => [x + 1])
这里我们提供了一个返回 an 的操作Array,所以现在它符合模式。该flatMap方法为数组中的每个元素执行提供的函数。它期望一个数组作为每次调用的结果(而不是单个值),但会将结果集合并到一个数组中。所以最终结果是一样的,数组[2,3,4]。
flatMap
(提供给类似map或方法的函数参数flatMap在 JavaScript 中通常称为“回调”。我将其称为“操作”,因为它更通用。)
map
如果我们链接多个操作(以传统方式):
[1,2,3].map(a => a + 1).filter(b => b != 3)
数组中的结果[2,4]
[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>.
Array<T>
T
flatMap()
T => Array<U>
Array<U>
概括地说,monad 是Foo<Bar>具有“绑定”方法的任何类型,该方法接受类型的函数参数Bar => Foo<Baz>并返回Foo<Baz>.
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
do a <- [1,2,3] b <- [a+1] if b == 3 then [] else [b]
这隐藏了“管道”,让您专注于每一步应用的实际操作。
在一个do块中,每一行都是一个操作。约束仍然认为块中的所有操作都必须返回相同的类型。由于第一个表达式是一个列表,其他操作也必须返回一个列表。
后退箭头<-看起来像是一个赋值,但请注意这是绑定中传递的参数。因此,当右侧的表达式是整数列表时,左侧的变量将是单个整数——但将对列表中的每个整数执行。
<-
示例:安全导航(Maybe 类型)
列表说得够多了,让我们看看 monad 模式如何对其他类型有用。
某些函数可能并不总是返回有效值。在 Haskell 中,这由Maybe-type 表示,它是一个选项,要么是Just value要么Nothing。
Maybe
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类型可用于模拟具有可变状态的编程:
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 作为初始状态对其进行评估。
add2
显然,这仅在 Haskell 中才有意义。其他语言支持开箱即用的可变状态。Haskell 通常在语言特性上“选择加入”——您在需要时启用可变状态,并且类型系统确保效果是明确的。IO 是另一个例子。
IO
该IO类型用于链接和执行“不纯”函数。
像任何其他实用语言一样,Haskell 有一堆与外界交互的内置函数:putStrLine等等readLine。这些函数被称为“不纯”,因为它们要么会导致副作用,要么会产生不确定的结果。即使像获取时间这样简单的事情也被认为是不纯的,因为结果是不确定的——用相同的参数调用它两次可能会返回不同的值。
putStrLine
readLine
纯函数是确定性的——它的结果完全取决于传递的参数,除了返回一个值之外,它对环境没有副作用。
Haskell 大力鼓励使用纯函数——这是该语言的一个主要卖点。不幸的是,对于纯粹主义者来说,你需要一些不纯的函数来做任何有用的事情。Haskell 的折衷方案是将纯函数和不纯函数清晰地分开,并保证纯函数无法直接或间接执行不纯函数。
这是通过为所有不纯函数指定IO类型来保证的。Haskell 程序的入口点是main具有IO类型的函数,因此我们可以在顶层执行不纯函数。
main
但是语言如何防止纯函数执行不纯函数呢?这是由于 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(和其他一些语言)有一些内置的支持,隐藏了繁琐的部分,然后该模式可以用于各种有用的事情。喜欢:
Parser