Functors, Applicatives, and Monads
Functors、Applicatives 和 Monads:解密函数式编程概念
您好!今天,我们将探讨函数式编程中的 Functors、Applicatives 和 Monads 的概念。我们将逐步讨论它们是什么以及为什么它们很重要。请注意,所有示例都将使用 Haskell,但您无需了解 Haskell 即可阅读此文章。
我还添加了一个部分,讲述了与 Haskell 基金会相关人员进行的一次令人失望的互动。
Functors
这是一个封闭的盒子:
当然,对于每个封闭的盒子,除非打开它,否则无法真正知道里面是什么,对吧?所以让我们打开它,然后……惊喜!这个盒子包含了生命、宇宙和一切的终极问题的答案1:
现在,让我们将盒子类比转化为编程。一个盒子可以是被上下文包围的任何数据结构。换句话说,一个包装器或容器类型,它为底层数据添加了额外的信息或行为。
在这个例子中,我们将说蓝色盒子代表可选值的可能性,这在 Haskell 中用 Maybe 类型表示。但是,这个概念存在于许多语言中:Rust 中的 Option,Go 中的 sql.NullString,Java 中的 Optional 等。
然而,也存在其他类型的盒子。例如:
- 一个盒子表示包装的值可以是来自一种类型或另一种类型。例如,在 Haskell 中,
Either用于表示一个值是Left还是Right,或者在 Rust 中,Result用于表示成功或错误。 - 更一般地说,大多数我们可以想到的 经典 数据结构,例如列表、映射、树,甚至字符串。这些数据结构可以包含零个、一个或多个值。例如,一个字符串可以由零个、一个或多个字符组成。
我们已经知道如何将函数应用于一个简单值;例如,应用一个将给定的整数加一的函数:
这里,白色正方形表示一个函数,它接受一个整数并将它加一。
但是,如果我们想将同一个函数应用于盒子里的值呢?我们可以打开盒子,从中提取值,应用函数,然后将结果放回盒子中:
然而,在 Haskell 中,我们可以使用 fmap 直接将函数应用于盒子;无需自己执行所有步骤:
fmap 用于将转换函数(此处为 (+1))应用于盒子内部的值,并将结果放入另一个盒子中。
在这个例子中,盒子本身被称为 functor。Functor 是一种抽象,它允许将函数映射到上下文中的值,而不改变 functor 的上下文。
不改变上下文至关重要;functor 与 薛定谔实验中的盒子不同。在量子物理学中,打开一个盒子会改变里面东西的状态。在这里,情况并非如此:带有值 42 的盒子保持不变2。
查看此示例的 Haskell 代码:
fmapEx :: Maybe Int -- A function returning a Maybe Int
fmapEx = fmap (+ 1) (Just 42) -- Result: Just 43
如果您不熟悉 Haskell,让我们花 30 秒来讲解这段代码。第一行定义了 fmapEx 的签名,它是一个不接受任何输入并产生 Maybe Int 输出的函数。第二行代表了函数的核心逻辑:将 (+1) 转换函数应用于 Just 42。这里,Maybe 是一种盒子类型,而 Just 42 是此盒子的一个实例,其中包含值 42。
正如我们所说,一个盒子可以包含一个值,也可以是空的,那么如果我们将 (+1) 转换应用于一个空盒子会发生什么?结果也是一个空盒子:
实际上,将 (+1) 应用于一个不存在的值不会得到 1。如果您没有银行账户,就无法增加银行账户余额;这在 Haskell 中也是如此。
这是这个新例子的代码:
fmapEx :: Maybe Int
fmapEx = fmap (+ 1) Nothing -- Result: Nothing
Nothing 表示一个 Maybe 盒子,里面没有任何值。
我们还说过,盒子类比可以应用于其他数据结构;例如,值的列表。让我们使用绿色盒子来表示元素列表。我们可以重用 fmap 将 (+1) 函数应用于每个列表的元素:
fmapEx :: [Int] -- A function returning a list
fmapEx = fmap (+ 1) [1, 2, 3] -- Result: [2, 3, 4]
非常方便,对吧?我们可以将一个转换函数提供给 fmap,Haskell 会处理剩下的事情,而无需手动循环遍历每个元素并创建一个新列表。
这就是 functors 的本质:一种抽象,表示我们可以将函数应用于其中的值。然而,在下一节中,我们将看到 functors 有些局限性,以及为什么我们需要一个更高级别的抽象:applicatives。
Applicatives
如果不是将转换函数应用于盒子,而是想将转换函数应用于盒子内部呢?
这里,(+1) 被包装在一个 Maybe 盒子中。在这种情况下,使用 fmap 和 functors 会导致编译错误:
fmapEx :: Maybe Int
fmapEx = fmap (Just (+ 1)) (Just 42) -- Does not compile
实际上,fmap 函数只在转换函数位于任何盒子外部时才有效。
但是等等……我们还没有讨论盒子内部函数的作用。
一些例子:
- 当我们想要表示一个函数是可选的或者可能缺失的情况(例如,由于错误或不完整的计算),我们可以使用
Maybe来表示它。 - 当我们想要处理可变数量的函数时,我们可以将这些函数放在列表中。
现在我们了解了为什么盒子内部的函数是一种可能性,让我们讨论如何处理这种情况:
解决方案是切换到另一种类型:applicative functors,简称 applicatives。在这种情况下,我们必须在 Haskell 中使用带有 applicatives 的 <*> 运算符:
感谢 <*> 运算符,我们现在可以将 Maybe applicative 内部的 (+1) 函数应用于另一个 Maybe applicative 内部的值。
一个小提示:您是否注意到我们将 <*> 称为运算符?在 Haskell 中,运算符也是一个函数,但以中缀表示法编写,意味着位于其参数之间:
applicativeEx :: Maybe Int
applicativeEx = Just (+ 1) <*> Just 42 -- Result: Just 43
现在,如果我们尝试在两种不同的 applicative 类型上使用 <*> 会发生什么?例如,一个 Maybe Int 和一个 Int 列表:
在这种情况下,这是一个编译错误。Applicatives 也是为了安全起见;上下文必须相同才能使用 <*> 运算符。但是,如果转换函数也在一个列表中,那么它就可以工作:
如果第一个盒子中有多个转换函数,第二个盒子中有多个值会发生什么?Haskell 将每个转换函数应用于每个值的组合:
这就是 applicative:另一种抽象,它允许将包装在上下文中的函数应用于相同上下文中的值。
最后一件事:如果转换函数保留在盒子外部会发生什么?
我们应该将此函数放入盒子中吗?我们应该将 applicative 变成 functor 来应用 fmap 吗?这些都不是必需的。我们可以使用 <$> 运算符,基本上是 applicatives 的 fmap 版本:
它说明了 applicative 是 functor 的扩展,因为它可以涵盖两种情况(在这些例子中,A 和 B 是泛型类型):
- 如果函数位于盒子外部,我们可以使用
<$>运算符:

<$> 接受一个 A 到 B 的函数,将其应用于 applicative 内部的值,并将结果放入另一个 applicative 中。
- 如果函数位于盒子内部,我们可以使用
<*>运算符:

<*> 接受一个 applicative 内部的 A 到 B 的函数,将其应用于 applicative 内部的值,并将结果放入另一个 applicative 中。
然而,与 functors 一样,我们也会看到 applicatives 在提供帮助方面存在限制。现在,是时候进入最终 boss:monads 了。
Monads
到目前为止,我们已经处理了两种转换函数(同样,A 和 B 是泛型类型):
- 盒子外部的函数:
-- For example:
plusOne :: Int -> Int
plusOne x = x + 1
- 盒子内部的函数:
-- For example:
plusOne :: Maybe (Int -> Int)
plusOne = Just (+ 1)
但是,如果一个函数接受一个盒子外部的值,应用一个转换,并将结果放入一个盒子中呢?
例如:
首先,让我们讨论一下这种函数的实现方式,然后再讨论它的用途。
在 Haskell 中有两种方法可以做到这一点。我们可以使用 Just,因为我们想要返回一个 Maybe Int:
plusOne :: Int -> Maybe Int
plusOne x = Just (x + 1)
这个函数接受一个盒子外部的 x,并将 x + 1 的总和放入一个 Maybe Int 盒子中。
但还有第二种方法可以做同样的事情,这次使用 return:
plusOne :: Int -> Maybe Int
plusOne x = return (x + 1)
如果您不了解 Haskell,您可能会对这段代码感到困惑。值得了解的是,return 是一个将某些东西(一个 int,一个函数,任何东西)包装在盒子内部的函数。感谢类型推断和函数签名,Haskell 知道应用于 (x + 1) 的 return 应该将此值放入 Maybe Int 内部。我们稍后会回到 Haskell 中 return 的本质。
现在,让我们讨论一下它的用途。为什么一个函数会接受一个盒子外部的值并返回一个盒子内部的值?例如,考虑一个 divide 函数,它处理分母为零的情况:
divide :: Float -> Float -> Maybe Float
divide _ 0 = Nothing
divide x y = return (x / y)
此函数接受两个 Float 并返回一个 Maybe Float。它使用模式匹配:
- 如果分母为 0,则返回
Nothing(第 2 行) - 否则,它返回
x/y的结果,将其放入一个Maybe Float盒子中(第 3 行)
divide 说明了一个接受盒子外部的输入并返回盒子内部值的函数。
现在让我们回到我们最初的问题:applicative 是否可以与返回盒子内部值的函数一起使用?让我们试一试。
Applicatives 的局限性
让我们实现一个具体的场景。我们想要实现一个





















