翻译自:Yet Another Monad Tutorial (part 1: basics) – Mike’s World-O-Programming

Haskell社区有一个老笑话:每一个Haskell程序员在他的学习生涯中最终都至少会写一篇monad教程。我当然也不例外,虽然我知道现在已经有了无数的monad教程,而且其中一些非常好,但我为什么又想要写一篇呢?有两个原因:

  1. 我认为在有些方面我可以解释的比大多数我所见过的monad教程都要好;
  2. 我自己对monad的理解有了极大的提升,如果可能的话我想将其传递下去。

预备知识

因为我将会在例子中使用Haskell语言,所以如果读者了解Haskell以及多态类型(polymorphic types)和类型类(type classes)的话会很有帮助,否则可能会发现这个文章中的材料很难理解。有很多不错的介绍Haskell的教程书籍或网络资料,你可能需要阅读其中的一些再回到这个系列的文章。

不会假设你有任何的范畴论知识(category theory),这是一个非常抽象的数学分支,monad(这里使用的术语)的概念就来自这里。虽然了解范畴论也没有坏处,但是对于理解这里的材料是绝对没有必要的,所以如果有人告诉你在理解monad之前需要了解范畴论,请不要相信。如果你对范畴论有所了解是有好处的,但请注意在这里我不会使用任何范畴论术语(除了“monad”这个词)。

免责声明

在这些文章中我不会教给你所有有关monad的知识,有两个原因:其一,这个过程太漫长了,我自己也不了解所有monad相关的知识,或许永远也不会了解。相反,我想给你一个清晰的概念上的理解,关于monad是什么,为什么有用,其工作的基本要素,以及一些在使用中最常见的monad。在本系列的最后一篇文章的结尾处,我会提供一些参考资料以供进一步的研究。

我也不会给你许多能够立即用在日常的编程工作中的实用代码实例,这不是一本cookbook!我坚信当你编程时,你需要真正理解你在做什么,而本教程的目的就是为了让你详细地理解monad。有了它,你就可以阅读其他任何monad教程(参见参考资料)来更好地了解如何使用monad解决实际的编程问题。但这里的重点是给你一个大方向并且帮助你真正理解monad是什么以及如何工作的。

最后,需要提前提醒你,我要从要点出发,重复一遍又一遍,直到把它们基本搞懂为止,因为我真的希望你明白我想说的是什么。我希望它不会很无聊,但是篇幅有点长,因为你不能用几句话来解释monad。所以,泡一壶茶,找一张舒服的椅子坐下,这需要一段时间。

动机:你为什么需要在意monad?

据我所知,monad在编程语言中的第一次应用就是在Haskell中,基于Eugenio Moggi和Philip Wadler(两个我永远不能企及的人)的工作。从那以后,它们出现在了一些其他的编程语言中(尤其是函数式语言)。但是为什么读者你(我假定你是一个尚未成为函数式的信徒的程序员)需要在意monad呢?

函数式编程的一个主要思想就是尽可能地使用纯函数。纯函数是一个黑箱:它接受一个或多个输入参数,计算返回一个值。在这个过程中它不会产生任何副作用:不读写文件或socket,不打印到终端,不读取或修改全局变量,不抛出或捕获异常等。这样做的好处在于纯函数的表现非常良好:如果给一个纯函数一个特定的输入,它将始终产生完全相同的输出。这使得纯函数非常易于测试,完全可以预测,并且不易出Bug。相反,在给定相同输入的情况下,不纯的函数(具有副作用的函数)不一定会得到相同的答案(比如使用的全局变量具有不同的值,或者读取的文件内容不同)。因此不纯的函数更难测试,更容易出现Bug,而且有更多的可能导致失败的方式。出于这个原因(和稍后会看到的其他原因),函数式编程语言强调尽可能地使用纯函数。

但是,只有纯函数的编程限制太大了。某些情况下,副作用会使某些程序更容易编写,尽管它们依旧可以只用纯函数(痛苦地)写。另外一些情况下,你绝对需要副作用,没有副作用这个程序就不能写。比如,将文件从一个目录复制到另一个目录必须与文件系统进行交互并对其进行更改,如果你的函数不允许读取或写入文件(这是副作用),它们将无法做到这一功能。所以即使在函数式语言中我们也需要一些方法进行副作用计算。

有两种函数式语言:纯的和不纯的。不纯的函数式语言(比如Scheme和Ocaml)基本上就解决了这个问题:它们允许你编写有副作用的函数,即使用户除非绝对必要通常避免这样做。纯的函数式语言(比如Haskell)就更加硬核:它们不允许你直接编写副作用函数(下面你就会明白我为什么会说直接)。因此,就像你想的那样,找出一种用纯函数式语言编写副作用程序的方法是一个长期以来的主要研究课题。

monad结果成为了解决这个问题的关键(更确切地说,它是一个关键;其他一些函数式语言使用不同的方法,比如Clean的uniqueness types)。monad允许我们用纯函数式语言来做所有我们想要的副作用计算,而不破坏语言纯度。有了monad我们可以使用类型系统将副作用计算从无副作用的计算中干净地分离出来,使得这两种计算不会相互影响。对于无副作用的代码我们得到了函数式编程的全部好处(类型系统保证这些函数不会产生副作用),然而在必要时仍然能够有副作用,非常强大。

如果这还不够的话,monad还有很多其他的用途。它们实际上是以一种行之有效的方法,来构造各种计算的非常通用的工具,可以大大简化许多不同类型的程序,而不仅仅是涉及副作用的程序。在很多情况下,monadic的代码可能比等效的非monadic的代码短很多而且更易于理解,在这个过程中我们会看到许多这样的例子。因此monad除了可以帮助我们处理函数式语言的副作用外(尽管它们确实可以处理),还具有普适性。

monad是编程语言中最令人惊叹的思想之一,非常值得学习。

总结:什么是monad?

“什么是monad?”是一个我多次被问及的问题。我不想把monad比作成一个事物,因为这是没有意义的,也是具有误导性的。所以我的总结是:

monad是函数,函数应用和函数复合的泛化,使它们能够处理比标准函数更为丰富的计算概念。

在我们的学习过程中,我希望不仅能够向你解释什么是monad以及它们是如何工作的,而且能够让你了解为什么monad会让很多不熟悉它的程序员感到困惑。(提示:这不是因为他们不够聪明,也不是因为他们没有足够的范畴论知识)

计算概念

OK,让我们把上面的总结进行分解,先从“计算概念”的含义开始。

最简单最良好的“计算概念”是普通的(纯)函数(即函数的数学定义)。简单起见,我们只考虑将单个输入参数映射到单个输出参数的函数(可以通过一个叫做*柯里化(currying)*的过程将多参数函数归约到单参数函数,针对这个问题我下面会多说一下,但现在只需要接受即可)。正如我上面所说,纯函数只是对于特定的输入始终产生完全相同的输出的一个规则。在Haskell这样的强类型语言中,函数具有良好定义的类型签名,这意味着存在类型ab使得函数将一个a类型的值映射到一个b类型的值。我们可以用下面的Haskell的记法来表示:

f :: a -> b

其中”::“表示“具有以下类型”,所以函数f具有类型a -> b,这意味着它接受一个a类型的值并返回一个b类型的值。实践中,ab通常是特定实际类型的名称(如IntFloatString),但在某些情况下,无论参数类型是什么,Haskell函数都可以工作。

所以纯函数是最简单的“计算概念”,还有什么其他的?程序员都很熟悉的,包含下面这几类的计算(除了将输入映射为输出):

  • 可能会在文件或终端中读写的
  • 可能会引发异常的
  • 可能会读写共享状态的(全局或局部)
  • 可能有时无法产生任何结果的
  • 可能同时产生多个结果的

还有许多……注意:现在起我将使用“输入/输出”(简称“I/O”)来指代文件或终端输入/输出(也称为副作用输入/输出),不要与函数将输入值映射到特定输出值的事实相混淆。

想一想你都是怎么用传统的编程语言(如C和Java)来处理这些计算概念的?可能执行I/O操作?没问题!C中的任何函数,Java中的任何方法都可以执行I/O操作。可能引发异常的计算?在C中有点麻烦,因为该语言没有内置的异常支持,通常选择在失败的情况下返回一个错误代码,指定失败情况(或者你真的硬核,使用setjmp/longjmp);在Java中你可以引发一个异常并处理它(希望这个异常能够在别处被捕捉到)。读写共享状态?也没问题!C和Java都可以读写局部和全局变量,尽管细节上自然有些不同。可能失败的计算?这可以被看作异常的特殊情况,所以也没问题。产生多个结果的计算?实际上我指的不是将多个结果作为单个对象返回(比如C的结构体或Java的对象),我的意思是可以“并行”(也称为非确定性)返回多个单一结果的函数,不是很清楚如何在C或Java中做到这一点。

非常需要注意的一点是:每种情况下,我们不再处理传统的函数概念,因为通常的将输入映射到单个输出的函数作用与“其他一些东西”一齐发生,而且所有这些不同的计算概念所代表的还有多种不同的“其他一些东西”。当我们编写程序时,我们通常不会考虑这个问题,而只是接受我们所写的函数与数学概念上的函数不是“真正”相同的,因为它们可以执行I/O,引发异常,改变全局变量的状态等等。这并不会使大多数程序员感到困扰,直到他们遇到了一个由全局变量的意外更改,或异常的意外抛出,或一些关于这类语言中非函数性质的函数的其他问题。所以我们希望尽可能地使用纯函数,但很多情况下这是不现实的(像我上面提到的),我们真的不得不做“其他一些东西”,即有副作用的计算。

这样做的结果是:我们希望兼得鱼与熊掌。我们既希望尽可能使用纯函数编写程序,获得其带来的所有好处(易于调试与组合),又想要在必要或有利的时候以一种可控的方式去做“其他一些东西”,这就是monad将要带给我们的。

但是:最后一段的关键词是”可控的“,如果都按照C或者Java的方式进行,我们可以做许多(但不是全部)非纯函数计算概念的事情,但是那样我们会失去函数式编程的所有好处,因为我们不能保证我们程序的任何函数都是纯函数(类型检查器在这方面帮不到我们)。所以我们需要一个系统的方式来处理这些其他的计算概念,不会污染不涉及它们的代码(纯函数代码)。

在这一点上,它将有助于回顾(纯)函数、(纯)函数复合和(纯)函数应用的基本概念,以便我们将这一点与做类似事情的monadic方式进行对比。

函数、函数应用与函数复合

正如我上面提到的,Haskell中的函数使用特定的记号来指定它们输入和输出的类型。 对于具有输入类型a和输出类型b的函数f,这个记号是:

f :: a -> b

因此f具有类型a -> b(读作”a箭头b“或者直接”ab“)。举一个更具体的例子,下面是一个输入加倍的函数的定义:

f :: Int -> Int
f x = 2 * x​​

f具有类型Int -> Int,因为它接受一个整数,将其乘以2,再返回另一个整数。

要使用函数,我们必须将其应用到它的参数上来(这里假设是单参数函数),通常是简单地将函数名与参数并置:

f 2 --> has value 4

注意在Haskell中与大多数计算机语言不同,我们不必使用括号来包围函数的参数。

题外话:柯里化(Currying)

如今在实践中,单参数函数不足以完成许多我们想要做的事情,我们如何定义双参数函数?例如,如何定义一个带有两个整数参数,并返回参数的平方和的函数q?函数的主体很容易编写:

q x y = x * x + y * y

但是函数签名比较奇怪,你可能希望它看起来像这样:

q :: Int Int -> Int

也许是这样:

q :: (Int, Int) -> Int

但实际上它是这样:

q :: Int -> Int -> Int

->从右向左结合,所以等价的形式是:

q :: Int -> (Int -> Int)

现在就很有意思了,像q这样的两个参数的函数在Haskell中被表示为一个参数的函数(这里的参数是x),它返回一个单参数函数,这个函数接受第二个参数(这里是y)然后返回结果值。Haskell与其他函数式编程语言一样,函数可以作为其他函数的返回值(另一种说法是,在函数式语言中,函数只是另一种类型的数据)。这种将接受多个参数的函数表示为接受单个参数的函数的方法叫做柯里化(Currying,以逻辑学家Haskell Curry来命名,其中Haskell这个名字也是来自于他;这个方法也被Schönfinkel独立发现,如果你喜欢也可以称之为Schönfinkeling)。例如,下面是接受四个整数参数wxyz并返回一个整数的函数r

r :: Int -> Int -> Int -> Int -> Int
r w x y z = ... -- some function of w, x, y, and z

因为->是从右向左结合的,所以等价的形式是:

r :: Int -> (Int -> (Int -> (Int -> Int)))
r w x y z = ... -- some function of w, x, y, and z

这里r是单参数(一个叫wInt)函数,它返回一个类型为(Int -> (Int -> (Int -> Int)))的函数,该函数应用于一个Int(例子中x)时返回一个类型为Int -> (Int -> Int)的函数,该函数应用于一个Int(例子中y)时返回一个类型为Int -> Int的函数,该函数应用于另一个Int时返回一个Int。整个函数调用(r w x y z)其实是((((r w) x) y) z),这就是所谓的柯里化,但Haskell函数会自动对它们的参数进行柯里化。柯里化非常好用,因为你可以一次只应用一个参数,而不用一次全部应用,这种部分应用的函数经常是很有用的。柯里化在理论上也是很有用的,因为从现在起,我们在讨论时只需要考虑一个参数的函数即可,这真是太好了!

有一个明确的函数应用操作符$,它的类型是:

($) :: (a -> b) -> a -> b

[在Haskell中,符号中缀运算符和用括号括起来的相同名称的函数是等同的,所以f $ 2($) f 2是一样的。当定义新的符号运算符时,为了方便起见我们经常将其写成函数形式(关于具体怎样写请参阅Haskell入门教程)。我们下面将大量使用这个东西。]

这意味着,对于任意类型ab,这个运算符接受一个从a类型到b类型的函数(它的第一个参数),将其作用于一个a类型的参数(第二个参数)并返回一个b类型的值。在函数式语言中,将函数作为参数传递给其他函数是合法的,所以这也是没有问题的。因此有:

f 2      --> 值为 4
f $ 2    --> 值也为 4
($) f 2  --> 值也为 4

这只是编写完全相同的东西的三种不同的方式。

现在,对于函数应用使用$运算符是没有必要的,因为可以将函数与其参数并置来将函数应用于参数(尽管实际上有一些涉及运算符优先级的常见用法,我们这里不会深入了解)。有趣的是,我们也可以定义一个“反向应用”运算符(表示为>$>),很像$,除了它的参数顺序是相反的:

(>$>) :: a -> (a -> b) -> b
x >$> f = f x  -- 也 = f $ x

这就很有吸引力了,因为我们可以把它读作“取一个值x,应用于函数f,得到结果”。如果你对unix有所了解,你可能会注意到unix shell中的管道(|)运算符就是这样工作的——你生成一些数据,然后应用一个程序来进行某种转换。任何时候我们都可以使用任意一个函数应用运算符来方便我们的目的,尽管通常情况下我们根本不使用操作符,只是简单的并置。

现在我们已经谈到了函数的应用,下一个重要的话题就是函数的复合,它真的非常重要。假设我们有两个函数fg和一个值x,类型如下:

x :: a
f :: a -> b
g :: b -> c

对于某些类型a, b, c,可能你想对x, f, g做的就是接受一个值x,将其传递给函数f(这个函数将值xa类型)转换为一个b类型的值),然后把这个值(b类型)传递给函数g(这个函数将b类型的值转换为一个c类型的值),用Haskell写就是:

g (f x)

注意,只有当fg类型兼容时才能正常工作,即如果f的输出类型也是g的输入类型(这里是类型b)。一个不同的看待这个问题的方式是,我们实际上把两个函数fg(类型分别是a - > bb - > c)组合成一个类型为a -> c的函数,然后将这个函数应用到x(类型为a)上,得到一个类型为c的值。取两个函数生成第三个函数的概念叫做函数复合(function composition),很容易为函数复合定义一个运算符(在Haskell中是.):

(.) :: (b -> c) -> (a -> b) -> (a -> c)
g . f = \x -> g (f x)

这里我们使用记号\x -> ...来表示有一个参数x的lambda表达式(匿名函数)。所以函数复合运算符.将两个函数作为参数,并返回一个函数。同样,在函数式语言中这种函数是完全有效的,因为函数可以作为其他函数的参数,也可以作为其他函数的返回值。