有一个常见的笑话,每个 Haskell 程序员的成长仪式就是在他们认为自己终于理解单子如何工作时,写一篇 “单子教程” 的博客文章。不过,网上已经有足够多这样的文章了,所以我并不打算再写一篇单子教程。然而,基于我的学习经验,我确实有一些关于_为什么_人们似乎在单子上挣扎得如此厉害的想法,因此,为什么会有这么多这样的教程存在。
从高层次来看,单子的直觉是它们是编程中序列化的抽象。任何涉及 “做这个,然后使用之前的结果做那个” 的计算都可以被视为单子。
对于一些实现了 Monad
的类型,我认为像 IO
或 State
这样的 “计算单子”,这种直觉是有意义的。我们倾向于将这些类型视为计算,因为它们代表了像 “执行输入 / 输出操作” 或 “在跟踪状态的同时计算一个值” 这样的动词。从这个意义上说,我们将它们的单子实例解释为描述这两个计算如何可以顺序组合在一起。
我认为许多 Haskell 新手在尝试将这种直觉应用于一组看似无关的类型时会感到困惑,我称之为 “数据单子”。这些类型如列表和 Maybe
,我们通常认为它们代表名词,即 “一个值的列表” 或 “一个可选值”。问题是:这些数据类型如何能够顺序组合在一起?它们如何成为单子?
从根本上说,我认为这个问题源于单子模糊了_数据_和_计算_之间的界限,尽管我们通常认为这两者是不同的概念。我认为理解数据单子的关键是将它们解释为计算:作为动词而不是名词。
然而,这种数据与计算之间的模糊界限并不是单子所独有的。事实上,正是这种模糊的界限位于函数式编程的核心:一等公民函数。
计算作为数据#
一等公民函数,或者说将函数表示为普通值的能力,是函数式编程范式的核心方面。具有一等公民函数的语言允许任何函数返回另一个函数或将函数作为参数传递。
它们也准确地代表了数据与计算之间的模糊性。我们通常将函数视为计算:某种接受输入并计算输出的东西。但通过一等公民函数,我们可以将函数作为数据使用 —— 作为可以像字符串或整数一样传递的对象。
但函数并不是唯一的计算类型,或者至少,我们通常处理更复杂的计算类型,如 IO
和 State
。这些类型在计算一个值的同时,还跟踪函数正常输入和输出之外的其他效果。
Haskell 的类型系统之所以强大,部分原因在于它能够将这些其他类型的计算编码为数据。事实上,如果我们查看 State
的基本定义,我们会发现它实际上只是一个特定函数签名的通用包装:
newtype State s a = State (s -> (a, s))
也就是说,类型 State s a
是一个接受初始状态作为输入并返回更新值和状态的函数。通过将这个函数包装在数据类型中,我们可以更轻松地将有状态的计算视为数据。也就是说,我们可以将其用作函数的输入或输出,或者将其包装在其他计算中,这就是更实用的 StateT
单子变换器所做的。
一般来说,将计算视为数据类型的技术在 Haskell 代码中普遍存在。它允许人们将像 State
或 IO
这样的副作用代码与纯代码隔离并组合多个有副作用的计算。
那么,单子到底是什么?所有单子代表某种计算,即一个动词或动作,可以顺序组合在一起,并可能带有一些副作用。然而,这有时并不明确,因为我们通常将这些动作视为普通数据类型。
数据作为计算#
那么,像列表和 Maybe
这样的 “数据单子” 呢?它们也可以被解释为计算,它们的 Monad
实例允许我们利用一套丰富的通用工具来将它们视为计算。
我认为一个例子会更好地解释这个想法,所以让我们看看如何用 Maybe
单子表示数据。正如我之前所说,我们传统上将 Maybe a
视为一个通用数据类型,表示该数据的存在(使用 Just a
)或该数据的缺失(使用 Nothing
)。
但是,Maybe
的单子实例利用了对同一类型的不同解释。相反,Maybe a
可以被视为一个具有副作用的计算,即在计算 a
的过程中,计算可能返回无值。如果返回无值,则返回 Nothing
,否则返回 Just a
。
如果你将 Maybe
视为计算,那么该类型的单子实例可能会更有意义。请记住,单子绑定运算符 (>>=
) 的实现告诉我们如何顺序执行两个计算,同时传递内部值。我们可以这样实现 Maybe
的单子:
instance Monad Maybe where
m1 >>= m2 = case m1 of
Just x -> m2 x
Nothing -> Nothing
这个实例告诉我们如何将两个 Maybe
计算 m1
和 m2
串联在一起:如果第一个计算返回一个值(Just x
),那么序列返回下一个计算围绕该值的结果(m2 x
)。否则,如果第一个计算返回 Nothing
,那么序列也返回 Nothing
。
从本质上讲,这个实现意味着一系列 Maybe
计算在链中传递内部值,除非中间计算返回 Nothing
,在这种情况下,整个序列计算 Nothing
。因此,虽然理解像 Maybe
这样的数据如何能够顺序组合可能很困难,但如果我们将 Maybe
视为计算,那么如何对其进行序列化的问题就变得更直观了。
这当然只是一个例子,Maybe
的绑定实现特别简单,但我认为它说明了一个重要的观点:在尝试理解某个类型的单子实例时,首先要理解该类型代表什么样的计算,然后再处理这两个计算如何可以顺序组合。
对于列表单子也存在类似的数据 - 计算平行关系,我们将列表视为表示非确定性计算,或者探索所有可能的执行路径并合并每个路径的值。这种对列表的平行解释可能更好地帮助理解如何实现其单子实例。
结论#
总之,Haskell 允许并鼓励数据与计算之间的相互作用。它的类型系统将所有计算视为一等值,从而实现了计算作为数据的丰富表示。同时,单子提供了一套通用工具,用于将数据类型视为可以顺序和链接在一起的计算。
掌握这两个概念对我理解如何使用 Haskell 的单子至关重要,因此也许这对你也有所帮助。