理解 Monad,一份 monad 的解惑指南

阅读数:2107 2017 年 12 月 7 日

话题:Java函数式编程语言 & 开发AI

本文要点:

  • 避免显式地处理状态值是有必要的
  • 通过使用 monad,你就可以移除代码中对状态值的明确处理。
  • 一个 monads 类型必须与特殊的函数(名为“bind”)相联系
  • 用了 monad 的 bind 函数后,状态值会从一个 monad 传递给下一个,而且始终在 monad 中(而非明确地在代码中被处理)
  • 许多问题都可以用 monad 来解决

随着函数式编程的再次兴起,“monad” 这种函数式结构再次让初学者感到恐惧。Monad 借鉴了数学中的范畴学理论, 该理论在 20 世纪 90 年代被引入了编程语言,是 Haskell 和 Scala 这类纯函数式编程语言的一种基本构件。

以下是大部分初学者对于 monads 的了解:

  • monad 对于输入输出很有用
  • monad 对输入和输出以外的东西也会有用
  • monad 很难理解,因为大部分与之相关的文章不是细节太多,就是细节太少

第三点就是促使我写这篇文章的原因,只有百分之一甚至是千分之一的文章对 monads 做过介绍。我希望在你阅读完这篇文章之后,会觉得其实 monad 并没有那么可怕。

系统的状态

在计算机程序中,“状态”这个词描述了全局变量、输入、输出以及对于特定函数而言非局部的东西(变量、输入、输出等)。以下是关于程序状态的几点:

  • 它的值在不同函数接连执行时是延续的。
  • 它可以用于多个函数。
  • 它可以是可变的。在这种情况下,它的值具有时间依赖性。

状态很难去管理,因为它不属于任何一个的函数。想象下以下场景:

  • 1 号函数从状态获得了一个值,并在执行其命令的时候使用了该值。与此同时,2 号函数修改了该值。结果会造成,1 号函数在执行命令时,使用并不是最新修改过的值。
  • 更糟糕的是,1 号函数会用旧的数据来执行计算,然后用其产生的新的不准确的值来替换已有的状态值。由此,1 号函数的错误开始具有传染性,从函数的内部开始扩散到整个系统。

不管怎样,因为系统状态是时间的函数,所以我们需要对时间维度多加考虑。我们不能直接问,“值 x 是多少?”,而需要问,“在时间点 t 时,值 x 是多少?”。这就增加了一个维度的复杂性,让代码推理很难进行。所以底线是...

   程序内有状态表示:bad!

   程序没无状态表示:good!

表达式和操作

一个表达式是一段含有值的文本。例如,以下的代码:  

  1. x = 5
  2. y = x + 7
  3. x = y + 1

x第一次出现是在值为 5 的表达式里,最后一次出现是在值为 13 的表达式里。代码里也包含其他的表达式。例如上述例子中间一行,x + 7是一个值为 12 的表达式。

在大部分的计算机语言中,从键盘读取命令是一个表达式,并且该表达式具有一个值。见以下语句:  

  1. x = nextInput()

你会在 Java、C++ 和一些其他的编程语言中见到这类型的语句。现在想象一下,当用户键入数字5,然后nextInput()是一个值为 5 的表达式。执行该语句会将nextInput()表达式的值 (值5) 赋给x。如果x是程序状态的一部分,那么这个语句就会改变状态值,但是正如我们上面讲过的,改变状态值可能会很危险。

在上述nextInput()的例子中,nextInput()的值有时间依赖性。执行nextInput()表达式一次,则值为5,再执行一次,则值为 17。在弱类型的语言中,x值可能会从5变成"Hello, world"

为了降低时间依赖性,我们会停止用nextInput(),而用另一个函数来替换它,以下我会称这个函数为doInput。作为一个表达式,doInput的值不再是517、或者"Hello, world",而是一个操作,一个在运行时从键盘获得输入的操作。

一个操作可能发生也可能不发生。从键盘读取输入是一个操作。在屏幕上写下"Hello,world"是一个操作。打开"/Users/barry/myfile.txt"对应的文件是一个操作。建立与http://www.infoq.com的超链接是一个操作。一个操作是某种计算(computation)。通常情况下,程序源代码中不会详细地列出某个操作的细节。相反地,一个操作是一种运行时的现象。

在一些编程语言中,提到类型(type),你通常会想到整型、浮点类型、字符串、布尔值和其他诸如此类的东西,可能并不会将操作也作为一种类型。但是当我们做 monadic I/O 时,类似于doInput()这样表达式的值是一个操作。换句话说,调用(call)doInput()的产生的返回值是一个操作。

这样想的话,doInput()的值就不再具有时间依赖性。不论在程序里的哪个位置,doInput()这个表达式始终具有相同的值,也就是获得键盘输入的操作。

由此,我们在有无状态这个问题上,取得了进展。

操作并不是万能的

这里我们会有个小问题。当我们想使用一个表达式,但是这个表达式的值是一个操作时,我们要如何处理呢?如果一个操作从键盘输入获得了 5,我们并不能直接加 1 到操作上。  

  1. x = doInput()
  2. print(x + 1)

在上述语言无关的代码中,变量x指的是一个操作。操作跟值 1 完全是不同的类型,所以表达式x+1只能解释为加 1 到重启计算机的操作上,因而表达式restart + 1毫无意义!

那么如果你并不想加 1 到用户输入上,以下的代码合理吗?  

  1. x = doInput()
  2. print(x)

当然不合理。语句x = doInput()将操作赋给了 x,所以print(x)语句是在试图显示一个操作。那操作显示到电脑屏幕上是什么样的呢?

你或许会争论说在弱类型语言中,你可以模糊从键盘得到输入的操作和值之间的区别。正如2 == "2"在 JavaScript 被判断为 True,在一些弱类型语言里5 == the_action_obtaining_5也可能会被判断为 True。但是在这个表象之下,其实要经过某些处理才能从the_action_obtaining_5中拿到值 5。而且,如果值 5 和the_action_obtaining_5之间没了区别,你就会回到最初的问题,输入函数调用为具有时间依赖性的表达式。你当然不想这样。

建立一个链(chain)

现在问题就很清楚了,我们不能直接打印输出表达式doInput()的值。但是,如果我们能够巧妙的将一系列的操作连接成一个链,在doInput()操作之后紧跟另一个效用为打印输出的操作又会怎么样呢?亲爱的读者,这就是 monad。

doInput()操作与用户键入了值(如数字 5)有关,在我们处理doInput()时,我们最感兴趣的是用户键入的数字 5,而不是doInput()操作本身。

让我们再走进些看看。 如果一个程序持续跟踪仓库中的库存,输入数字 5 可能代表货架上箱子的数量。值 5 在问题领域(problem domain)与货架上箱子的数量是相关的。如果你走到管理仓库的人说“你有五个箱子”,那么这个人就知道你的意思了。另一方面,doInput()表达式的值是一个操作,对管理库存的人员来说并没有意义。doInput()操作是我们处理库存过程中产生的 artifact(译者注:维基百科解释 artifact 为软件开发过程中一种有形的副产品)。因此,在本文中,我将对与操作相联系的相关值(pertinent value)和操作本身之间进行区分。为了强调这里所指的操作缺乏针对性,我把操作本身称为artifact

这里做一个回顾。当你执行 doInput()时,用户输入了数字 5,

  • 接收输入的操作就是我们所说的artifact
  • 数字 5 就是我们所说的相关值

这里的术语相关值和 artifact 并不适用于所有的 monad,但是它可以帮助我解释 monad 究竟是什么。为了做出更加清楚的解释,我将本文大部分例子中均将相关值设定为整数。

笼统来说,我认为doInput()是一个容器,里面盛装着相关值如值5。 容器的隐喻是有用的,因为一旦一个值与一个 monad 相联系,我们喜欢把这个值附加到 monad 上,并会防止相关值跑到 monad 容器之外。但请记住,当你认真对待 monads 时,这些相关性和容器的类比就会失效。但是即便如此,这样的类比也有助于你对 monad 形成直观的感受。

挑战是:我们必须对与doInput()操作相联系的用户输入相关值的使用方法,进行形式化描述。(在这篇文章中,“形式化”这个词并不意味着“绝对严格”,而是指“用简明的语言,将复杂的概念略加简化的描述出来”)

一些类型是 Monads,一些并不是

在传统的编程语言中,可能有整数类型、浮点类型、布尔类型、字符串类型以及许多不同类型的复合类型。你可以说一个类型是或者不是 monad 类型。那么什么样的类型是 monad 类型呢?

对于初学者来说,monad 类型有一个或几个相关值与它相联系。以下是几个有相关值的类型:

  1. I/O 操作类型:

    对于一个 input-from-keyboard 操作,相关值是用户输入的值,而操作本身就是 artifact。

  2. list 类型:

    想象一个含有值的列表(list):[3,17,24,0,1],则列表的相关值为 3、17、24、0 和 1。artifact 是这些值合在一起形成一个列表的事实。如果你不喜欢列表,那你也可以考虑数组、矢量或者其他你喜欢的编程语言的集合结构。

  3. Maybe 类型:

    空值(null value)的使用在许多编程语言中都存在问题,但是函数式编程对此有一个解决方法。在我简化的场景中,Maybe 值包含一个数字(如 5)或一个 Nothing 指示符(indicator)。如果计算未能确定一个值,则 Maybe 可能包含 Nothing 而不是一个值。

    Java 和 Swift 等语言都有 Optional 类型,类似于我们这里所说的 Maybe 类型。

    对于能产生 Maybe 值的计算,相关值可以是数字,也可以是 Nothing 指示符(indicator)。artifact 是一个概念,一个计算结果不是一个简单数字的概念。

  4. Writer 类型:

    Writer 是一个函数,它的一部分的功能就是生成会被写入运行日志的信息。想象一下,就比如一个规整的平方函数附带有第二个值。

    square(6) = (36, "false")

    数字366*6的结果。而文字"false"则表明结果36不能被 10 整除。多次运用 Writer 函数之后,日志可能显示如下:  

    "false true false"

    在这个例子中,相关值是一个数字格式的结果如36,而 artifact 是字符串的值("true" 或者 "false"),将数字格式的结果与字符串值捆绑在了一起。

形式化地描述上述各类型的相关值是一个挑战。尤其是:

  1. 对于 I/O 操作类型而言:

    你有一个与一些用户输入相联系的操作。你不能直接让加 1 到操作,也无法在屏幕显示操作。操作并不是可以和 1 相加的类型。你必须定义如何使用操作相关值的方法。

  2. 对于 list 类型而言:

    你有含有数字的列表(list),像之前所举的列表例子中含有 3、17、24、0 和 1。但是你不能对列表本身进行数字运算,因为列表类型不支持。你需要去定义如何使用列表中的每个数字(比如“+1”)。

  3. 对于 Maybe 类型而言:

    想象下这里有两个Maybe值,分别为maybe1maybe2maybe1与 Nothing 相联系,而maybe2与值 5 相联系。maybe1值是计算失败的产物,而maybe2值来自于某些结果为 5 的计算。

    那可以加 1 到maybe1值吗?不行,因为 Nothing 值与maybe1相联系,所以1 + maybe1毫无意义。

    那可以加 1 到maybe2值吗?出于学习 monads 的目的,我的回答仍然是否定的。虽然值 5 与maybe2相联系,但是maybe2值并不是一个数字,而是一个 artifact 结构,只是值 5 恰好与它相联系,所以1 + maybe2仍旧毫无意义。

  4. 对于 Writer 类型而言:

    从一些可以帮你找到的普通函数说起。 

    square(6) = 36

    plus4(36) = 40dividedBy5(40) = 8

    你可以将这些函数连接成链得到想要的结果: 

    dividedBy5(plus4(square(6))) = 8

    但是如果每个函数都是一个 Writer,而且每个函数结果均另含有是否能被 10 整除的指示符,整个事情就会不一样。

累计的日志会显示如下: 

  1. square(6) = (36, "false") "false"
  2. plus4(36) = (40, "true") "false true"
  3. dividedBy5(40) = ( 8, "false") "false true false"

你不能将plus4函数应用于square函数返回的一对(pair)结果。

    plus4(square(6))plus4((36, "false")并没有意义

取而代之地,你需要将plus4函数应用于square函数执行得到的相关值。以此类推,将dividedBy5函数应用于plus4函数执行得到的相关值。

通过简单的规则,就可以一劳永逸地对每个函数调用应用于前一个函数调用的相关值的方式进行形式化地描述。由此引出了bind函数。

一个类型必须含有 bind 函数,才能被称为 monad

在大多数的编程语言中,不同的类型有它们自己的函数。比如,整数类型有自己的 +、-、* 和 / 函数。字符串类型有它的连接函数(Concatenation function)。布尔类型有其 or、and 和 not 函数。

为了成为 monad,一个类型必须有一个函数使用了 monad 的相关值或者值,并且函数有特定的形式。下面就来讲下这些特定的函数形式。

第一种候选的函数形式(一个错误的想法)是用来从 monad 中分离相关值的函数(我将会把它称为badIdea)。例如,badIdea函数会从操作里面获得用户输入。如果你调用了badIdea函数,并且用户键入了数字 5,那么badIdea(doInput())就是值 5。通过调用badIdea函数,你就可以输出调用badIdea()的结果值,甚至可以加 1 到结果值。 

  1. x = badIdea(doInput())
  2. print(x)
  3. y = x + 1

现在回到我们之前开始提到nextInput()的时间依赖性的问题上。表达式badIdea(doInput())具有原始函数nextInput()所有的不良特性。将badIdea(doInput())函数表达式执行一次的值是5。再执行一次,值就变成了 17。在弱类型的语言中,badIdea(doInput())的值或许会从5变为"Hello, world"

通过函数badIdea, 你就可以从doInput()操作中抓取相关值,并用该值去执行任何操作,但这并不是一个好的方法。相反地,让我们从doInput()操作抓取相关值,然后再创建另一个操作来使用这个值。这很重要的:你需要在一个doInput()操作后面紧跟着使用另一个操作。当你形式化地描述这个想法的时候,这个操作类型就变成了一个 monad 类型。所以让我们开始形式化地描述这个想法:

我们从两个东西入手:操作和函数。例如:

  • doInput()操作,获得键入数字的操作。
  • doPrint函数,拿一个数字,并生成一个在屏幕上写入该数字的操作

    doPrint(5) = 在屏幕上写入 5 的操作

    doPrint(19) = 在屏幕上写入 19 的操作

图 1 对该场景进行了说明。图中每个齿轮均代表某种操作。

我可以把doPrint描述为一个from-number-to-action函数。当你做函数式编程的时候,很自然的会出现这种函数。

让我们在这里暂停一下,考虑下两个表达式,不带括号的opSystem和带括号的opSystem()opSystem表达式的值是个很特殊的函数,这个函数会去发现正在运行的操作系统,但是opSystem()表达式的值是一个名字,像是LinuxWindows 10。简单来说,

  • opSystem表示一个函数,而
  • opSystem()表示调用opSystem函数的返回值

记住这些后,请注意带括号的“操作doInput()”和不带括号的“函数doPrint”之间的区别。doInputdoPrint这两种函数都会返回值,而且返回的值都为操作。当我说“操作 doInput()”时,我指的是调用doInput()函数得到的值。当我说“函数 doPrint”时,我指的是doPrint函数本身,而不是函数的返回值。这也是为什么我在图 1 中用不同的方式去表示doInput()doPrint。为了说明doInput(),我画了一个齿轮,用来让你联想到一个操作。为了说明doPrint,我画了一个箭头,用来让你联想到一个函数。当你想到 monad 时,这些示意图能够帮你更直观地想到与之相关的问题。

有了doInput()操作和doPrint函数之后,我们还需要另一部分来组成一个 monad,需要一种将doInput()doPrint粘合在一起的方法。更准确地说,我们需要一个方程式(formula),来获取任何操作 A 和 from-number-to-action 函数 f,并且将它们结合起来创建一个新的操作。我们给这个方程式起名叫bind。见图 2。

在上段中,我把bind称为一个方程式(formula),但是bind其实是另一个函数。

复制代码
bind(A,f) = 某种操作

bind函数是一个高阶函数(higher-order function),因为它将函数作为其实参(argument)之一。如果你还不习惯考虑函数的函数,那么 bind 函数足以让你晕头转向。

对于输入和输出而言,bind函数必须是一个通用的规则,获取任何 I/O 操作 A 和任何 from-number-to-action 函数 f 作为它的形参(parameter)。bind函数必须返回一个新的 I/O 操作。例如:

  • doInput() = 拿到键入数字的操作
  • doPrint(x) = 将值 x 显示到屏幕的操作
    • bind(doInput(),doPrint)= 将doInput()的相关值显示到屏幕的操作

见图 3。

让我们稍微改动下例子:

  • doPrintPlusl(x) = 将x+1的值显示到屏幕的操作

    bind(doInput(),doPrintPlusl) = 将doInput()的相关值 +1 显示到的操作

见图 4。

一般来说,对于输入 / 输出操作 A 和 from-number-to-action 函数 f 而言:

    bind(A,f) = 将 f 应用于 A 的相关值的一个操作

见图 5。

如果你发现还是对bind的解释感到困惑,不用担心,你并不是唯一一个。高阶函数本来就不好理解。

在 Haskell 编程语言中,因为bind函数扮演了很重要的角色,所以bind操作(operator),>>=被直接内置到了这个语言内。事实上,很多处理 monads 的功能都是直接内置于 Haskell 内。

舍弃时间依赖性

让我们重温一下在图 3 中所示的 doInput(),doPrint 情景。

bind(doInput(),doPrint) = 将doInput()的相关值显示到屏幕的操作

不论出现在代码的何处,表达式bind(doInput(),doPrint)中没有任何一部分具有时间依赖性,

  • doInput()总是同一个操作——拿到键入的数字
  • doPrint也总是 from-number-to-action 函数
  • 完整的表达式bind(doInput(),doPrint)也总是相同的操作——将数字显示到屏幕的操作

用户键入的数字因时而异,但是表达式bind(doInput(),doPrint)中没有任何一部分表示那个值。我们并没有消除所有的负面影响,但是清除了我们代码中任何对系统状态的明确地提及。一旦用户在键盘上键入了一个数字,这个数字会像烫手山芋一样从一个操作传到下一个操作,而代码中的任何一个变量都不表示用户键入的那个数字。

Monads 不总是关于数字和输入 / 输出操作

在我们提过的例子中:

  • doInput的相关值是一个数字,而doPrint的实参类型也是一个数字
  • doInput()的类型是一个操作,而doPrint的返回类型也是一个操作

bind函数告诉我们如何将doInput()doPrint相结合来得到一个新的操作。

一些 monad 跟数字和输入 / 输出操作都没有关系。所以让我们用更笼统的说法来概括一下,我们对于doInputdoPrintbind函数的发现:

  • doPrint的实参类型和doInput()相关值的类型相同
  • doPrint的返回类型和doinput()的类型相同

bind函数告诉我们如何将doInput()doPrint相结合来得到一个全新的值。新值的类型与最初的doInput()值的类型相同。

更多的 Monad 类型

任何有 bind 函数(和另一个我将在文末描述的函数)的类型就是一个 monad。有了我在之前几段中提到的针对输入 / 输出的 bind 函数,输入 / 输出操作类型就变成了 monad。我们先前提到的 list、Maybe 和 Writer 类型也是 monad 类型。以下是 bind 函数与列表类型的关系:

从两件事说起,列表L和函数f。跟之前一样,函数f是某种特殊的类型。

  • f需要一个类型与列表中元素相同的值,作为它的实参
  • 作为返回结果,f会生成一个列表

如果列表含有数字,那么f就是一个from-number-to-list函数。这里有一个方程式(formula,一个bind的定义)可以获取任何的列表和 from-number-to-list 函数,并将它们相结合来形成一个新的列表:

bind(L,f)= 一个新的列表,即将 f 应用于 L 中每个元素后,扁平化一个返回值列表的列表(list of lists)得到的新列表

让我们来看一个例子:

  • f(x)= 列表[squareRoot(x),-squareRoot(x)]

    正如要求的一样,f是一个 from-number-to-list 函数。

    L = [4,25,81]

    根据我们对用于列表bind函数的定义:

    bind(L,f)= 扁平化[[2,-2], [5,-5], [9,-9]] = [2,-2,5,-5,9,-9]

bind函数给出了所有将函数f应用到列表L任意元素上所有可能得到的结果。见图 6。

  • 这里是一个不涉及数据的例子。假设x是一本书,令f(x) = 书的作者列表。

    例如:

    f(C_Programming_Lang) = [Kernighan,Ritchie]

    在这个例子中,f是一个 from-book-to-list 函数。根据我们对用于列表的bind函数的定义:

    bind([C_Programming_Lang, Design_Patterns], f)=

        扁平化的[[Kernighan,Ritchie],[Gamma,Helm,Johnson Vlissides]] =

        [Kernighan,Ritchie,Gamma,Helm,Johnson,Vlissides]

我们已经成功地从书的列表中拿到了作者列表。见图 7。

注意图 2- 图 7 这些图像的相似处:

  • 在图的左侧,有一个 monad 值(如操作,列表等)和一个函数。函数获取相关值来生成一个 monad 值。
  • 在图的中间,有用箭头表示的bind函数的应用。
  • 在图的右侧,有一个全新的 monad 值

对于 Maybe 类型的bind函数又是怎样的呢?假设 m 是一个 Maybe 值(包含一个数字或 Nothing),并令 f 是一个获得数字并生成 Maybe 值的函数。bind(m,f)的值取决于 Maybe 值的内容:

bind(m,f) =

f(m's pertinent value), 如果m不含有 Nothing

      或者

      Nothing,如果m含有 Nothing

让我们来举一些例子:

         假设maybe1含有 Nothing

         maybe2含有 0

         maybe3含有 0

         f(x) = 一个含有100/x的 Maybe 值

然后,

         bind(maybe,f) = Nothing

         bind(maybe2,f) = 含有 20 的 Maybe 值

         bind(maybe3,f) = Nothing

同样的,bind函数的实参是一个 monad(在该情况下,是一个 Maybe 值)和一个函数。函数的实参是一个 monad 的相关值,而函数会返回另一个 monad。

当然,我们可以为 Writer monad 创建一个bind函数。

bind((aNumber,aString), f) =

(f(aNumber)数字部分,aString+f(aNumber)的字符串部分

一些例子能够帮我们记住这个要点的例子。回想下之前的squareplus4dividedBy5函数——这些函数的返回值均包含能被 10 整除的指示符。

bind((36, "false"), plus4) = (40, "false true") 见图 8。

bind((40, "false true"), dividedBy5) = (8, "false true false")

当你应用bind的时候,结果的字符串部分会将早期计算得到的字符串都包括进去。期间,字符串部分会运行所有计算的日志。

Monad 需要一个额外的函数

bind函数可能没有很好理解,但是另一个作为一个 monad 必须有的函数却很好懂。我把它称为toMonad函数。

  • toMonad函数将一个相关值作为它的实参
  • toMonad函数返会还一个 monad

粗略地说,toMonad返回的是可能包含给定相关值的最简单的 monad。(这里有一个更精确的定义,涉及到toMonadbind的交互,但我在本文中不会讲这个。好奇的话,可以访问https://en.wikibooks.org/wiki/Haskell/Understanding_monads。)

  1. 对于 I/O 操作 monad,toMonad(aValue) = 一个相关值是aValue但是却没什么作用的操作。

  2. 对于列表 monad,toMonad(aValue) = 包含aValue并将其作为唯一入口的列表

  1. 对于 Maybe monad,toMonad(number) = 含有该number的 Maybe 值。

    请记住,含有 5 的 Maybe 值与原来普通的 5 并不完全相同。

  2. 对于 Writer monad,toMonad(number)会返回一个含有空字符串的 monad。

    toMonad(number) = (number, "")

写在最后

monad 是一个带有bind函数和toMonad函数的类型。bind函数在没有明确揭示 monad 相关值的情况下,机械地从一个 monad 执行到下一个 monad。

从功能上来看,monad 无处不在。有了 I/O 操作 monad,代码中的表达式都不再代表用户的输入,所以系统状态在你程序内的任何地方均不会明确地表示出来。进而,你代码中的表达式就不具有时间依赖性。你就不会去问“此时,这个程序中的x表示什么?”,而会问“在这个程序中,x表示什么?”

记住底线:

     程序内有状态表示:bad!

     程序内无状态表示:good!

我们生活的世界是有状态的,对此我们无能为力。Monad 并不能将从系统中消除状态,但是它可以帮我们消除在编程代码中对系统状态的显式表示。这可能会很有用。

原文作者简介:

本文作者 Dr.Barry Burd 撰写过很多文章和书籍,包括大受欢迎的《Java For Dummies》和《Android Application Development All-in-One For Dummies》这两本均出版于 Wiley Publishing 的书。除此以外,他还是 O'Reilly's Introduction to Functional Programming课程的讲师。自 1980 年以来,Dr.Burd 就一直担任着新泽西州麦迪逊德鲁大学数学和计算机科学系的教授,也曾在美国、欧洲、澳大利亚和亚洲的诸多会议上发表过演讲。

原文链接:Understanding Monads. A Guide for the Perplexed

感谢罗远航对本文的审校。