本文共 12297 字,大约阅读时间需要 40 分钟。
\\\本文要点:
\\
- 避免显式地处理状态值是有必要的\\t
- 通过使用monad,你就可以移除代码中对状态值的明确处理。\\t
- 一个monads类型必须与特殊的函数(名为“bind”)相联系\\t
- 用了monad的bind函数后,状态值会从一个monad传递给下一个,而且始终在monad中(而非明确地在代码中被处理)\\t
- 许多问题都可以用monad来解决\
随着函数式编程的再次兴起,“monad” 这种函数式结构再次让初学者感到恐惧。Monad借鉴了数学中的理论, 该理论在20世纪90年代被引入了编程语言,是Haskell和Scala这类纯函数式编程语言的一种基本构件。
\\以下是大部分初学者对于monads的了解:
\\第三点就是促使我写这篇文章的原因,只有百分之一甚至是千分之一的文章对monads做过介绍。我希望在你阅读完这篇文章之后,会觉得其实monad并没有那么可怕。
\\在计算机程序中,“状态”这个词描述了全局变量、输入、输出以及对于特定函数而言非局部的东西(变量、输入、输出等)。以下是关于程序状态的几点:
\\状态很难去管理,因为它不属于任何一个的函数。想象下以下场景:
\\不管怎样,因为系统状态是时间的函数,所以我们需要对时间维度多加考虑。我们不能直接问,“值x是多少?”,而需要问,“在时间点t时,值x是多少?”。这就增加了一个维度的复杂性,让代码推理很难进行。所以底线是...
\\程序内有状态表示:bad!
\ 程序没无状态表示:good!\\一个表达式是一段含有值的文本。例如,以下的代码:
\\x = 5
\\ty = x + 7
\\tx = y + 1
\x
第一次出现是在值为5的表达式里,最后一次出现是在值为13的表达式里。代码里也包含其他的表达式。例如上述例子中间一行,x + 7
是一个值为12的表达式。
在大部分的计算机语言中,从键盘读取命令是一个表达式,并且该表达式具有一个值。见以下语句:
\\x = nextInput()
\你会在Java、C++和一些其他的编程语言中见到这类型的语句。现在想象一下,当用户键入数字5
,然后nextInput()
是一个值为5的表达式。执行该语句会将nextInput()
表达式的值(值5
)赋给x
。如果x
是程序状态的一部分,那么这个语句就会改变状态值,但是正如我们上面讲过的,改变状态值可能会很危险。
在上述nextInput()
的例子中,nextInput()
的值有时间依赖性。执行nextInput()
表达式一次,则值为5
,再执行一次,则值为17。在弱类型的语言中,x
值可能会从5
变成\"Hello, world\"
。
为了降低时间依赖性,我们会停止用nextInput()
,而用另一个函数来替换它,以下我会称这个函数为doInput
。作为一个表达式,doInput
的值不再是5
、17
、或者\"Hello, world\"
,而是一个操作,一个在运行时从键盘获得输入的操作。
一个操作可能发生也可能不发生。从键盘读取输入是一个操作。在屏幕上写下\"Hello,world\"
是一个操作。打开\"/Users/barry/myfile.txt\"
对应的文件是一个操作。建立与http://www.infoq.com
的超链接是一个操作。一个操作是某种计算(computation)。通常情况下,程序源代码中不会详细地列出某个操作的细节。相反地,一个操作是一种运行时的现象。
在一些编程语言中,提到类型(type),你通常会想到整型、浮点类型、字符串、布尔值和其他诸如此类的东西,可能并不会将操作也作为一种类型。但是当我们做monadic I/O时,类似于doInput()
这样表达式的值是一个操作。换句话说,调用(call)doInput()
的产生的返回值是一个操作。
这样想的话,doInput()
的值就不再具有时间依赖性。不论在程序里的哪个位置,doInput()
这个表达式始终具有相同的值,也就是获得键盘输入的操作。
由此,我们在有无状态这个问题上,取得了进展。
\\这里我们会有个小问题。当我们想使用一个表达式,但是这个表达式的值是一个操作时,我们要如何处理呢?如果一个操作从键盘输入获得了5,我们并不能直接加1到操作上。
\\x = doInput()
\\tprint(x + 1)
\在上述语言无关的代码中,变量x
指的是一个操作。操作跟值1完全是不同的类型,所以表达式x+1
只能解释为加1到重启计算机的操作上,因而表达式restart + 1
毫无意义!
那么如果你并不想加1到用户输入上,以下的代码合理吗?
\\x = doInput()
\\tprint(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
之间没了区别,你就会回到最初的问题,输入函数调用为具有时间依赖性的表达式。你当然不想这样。
现在问题就很清楚了,我们不能直接打印输出表达式doInput()
的值。但是,如果我们能够巧妙的将一系列的操作连接成一个链,在doInput()
操作之后紧跟另一个效用为打印输出的操作又会怎么样呢?亲爱的读者,这就是monad。
当doInput()
操作与用户键入了值(如数字5)有关,在我们处理doInput()
时,我们最感兴趣的是用户键入的数字5,而不是doInput()
操作本身。
让我们再走进些看看。 如果一个程序持续跟踪仓库中的库存,输入数字5可能代表货架上箱子的数量。值5在问题领域(problem domain)与货架上箱子的数量是相关的。如果你走到管理仓库的人说“你有五个箱子”,那么这个人就知道你的意思了。另一方面,doInput()
表达式的值是一个操作,对管理库存的人员来说并没有意义。doInput()
操作是我们处理库存过程中产生的artifact(译者注:维基百科解释artifact为软件开发过程中一种有形的副产品)。因此,在本文中,我将对与操作相联系的相关值(pertinent value)和操作本身之间进行区分。为了强调这里所指的操作缺乏针对性,我把操作本身称为artifact。
这里做一个回顾。当你执行 doInput()
时,用户输入了数字5,
这里的术语相关值和artifact并不适用于所有的monad,但是它可以帮助我解释monad究竟是什么。为了做出更加清楚的解释,我将本文大部分例子中均将相关值设定为整数。
\\笼统来说,我认为doInput()
是一个容器,里面盛装着相关值如值5
。 容器的隐喻是有用的,因为一旦一个值与一个monad相联系,我们喜欢把这个值附加到monad上,并会防止相关值跑到monad容器之外。但请记住,当你认真对待monads时,这些相关性和容器的类比就会失效。但是即便如此,这样的类比也有助于你对monad形成直观的感受。
挑战是:我们必须对与doInput()
操作相联系的用户输入相关值的使用方法,进行形式化描述。(在这篇文章中,“形式化”这个词并不意味着“绝对严格”,而是指“用简明的语言,将复杂的概念略加简化的描述出来”)
在传统的编程语言中,可能有整数类型、浮点类型、布尔类型、字符串类型以及许多不同类型的复合类型。你可以说一个类型是或者不是monad类型。那么什么样的类型是monad类型呢?
\\对于初学者来说,monad类型有一个或几个相关值与它相联系。以下是几个有相关值的类型:
\\I/O操作类型:
\\t对于一个input-from-keyboard操作,相关值是用户输入的值,而操作本身就是artifact。\\t\\tlist类型:
\\t想象一个含有值的列表(list):[3,17,24,0,1],则列表的相关值为3、17、24、0和1。artifact是这些值合在一起形成一个列表的事实。如果你不喜欢列表,那你也可以考虑数组、矢量或者其他你喜欢的编程语言的集合结构。\\t\\tMaybe类型:
\\t空值(null value)的使用在许多编程语言中都存在问题,但是函数式编程对此有一个解决方法。在我简化的场景中,Maybe值包含一个数字(如5)或一个Nothing指示符(indicator)。如果计算未能确定一个值,则Maybe可能包含Nothing而不是一个值。\\\tJava和Swift等语言都有Optional类型,类似于我们这里所说的Maybe类型。
\\\t对于能产生Maybe值的计算,相关值可以是数字,也可以是Nothing指示符(indicator)。artifact是一个概念,一个计算结果不是一个简单数字的概念。
\\t\\tWriter类型:
\\tWriter是一个函数,它的一部分的功能就是生成会被写入运行日志的信息。想象一下,就比如一个规整的平方函数附带有第二个值。\\tsquare(6) = (36, \"false\")
\\\t数字36
是6*6
的结果。而文字\"false\"
则表明结果36
不能被10整除。多次运用Writer函数之后,日志可能显示如下:
\"false true false\"
\\\t在这个例子中,相关值是一个数字格式的结果如36
,而artifact是字符串的值(\"true\" 或者 \"false\"
),将数字格式的结果与字符串值捆绑在了一起。
形式化地描述上述各类型的相关值是一个挑战。尤其是:
\\对于I/O操作类型而言:
\\t你有一个与一些用户输入相联系的操作。你不能直接让加1到操作,也无法在屏幕显示操作。操作并不是可以和1相加的类型。你必须定义如何使用操作相关值的方法。\\t\\t对于list类型而言:
\\t你有含有数字的列表(list),像之前所举的列表例子中含有3、17、24、0和1。但是你不能对列表本身进行数字运算,因为列表类型不支持。你需要去定义如何使用列表中的每个数字(比如“+1”)。\\t\\t对于Maybe类型而言:
\\t想象下这里有两个Maybe
值,分别为maybe1
和maybe2
。maybe1
与Nothing相联系,而maybe2
与值5相联系。maybe1
值是计算失败的产物,而maybe2
值来自于某些结果为5的计算。\\\t那可以加1到maybe1
值吗?不行,因为Nothing值与maybe1
相联系,所以1 + maybe1
毫无意义。
那可以加1到maybe2
值吗?出于学习monads的目的,我的回答仍然是否定的。虽然值5与maybe2
相联系,但是maybe2
值并不是一个数字,而是一个artifact结构,只是值5恰好与它相联系,所以1 + maybe2
仍旧毫无意义。
对于Writer类型而言:
\\t从一些可以帮你找到的普通函数说起。 \\tsquare(6) = 36
plus4(36) = 40
dividedBy5(40) = 8
\\\t
你可以将这些函数连接成链得到想要的结果:
\\\tdividedBy5(plus4(square(6))) = 8
但是如果每个函数都是一个Writer,而且每个函数结果均另含有是否能被10整除的指示符,整个事情就会不一样。
\\t\累计的日志会显示如下:
\\square(6) = (36, \"false\") \"false\"
\\tplus4(36) = (40, \"true\") \"false true\"
\\tdividedBy5(40) = ( 8, \"false\") \"false true false\"
\你不能将plus4
函数应用于square
函数返回的一对(pair)结果。
plus4(square(6))
是plus4((36, \"false\")
并没有意义
取而代之地,你需要将plus4
函数应用于square
函数执行得到的相关值。以此类推,将dividedBy5
函数应用于plus4
函数执行得到的相关值。
通过简单的规则,就可以一劳永逸地对每个函数调用应用于前一个函数调用的相关值的方式进行形式化地描述。由此引出了bind函数。
\\在大多数的编程语言中,不同的类型有它们自己的函数。比如,整数类型有自己的+、-、*和/函数。字符串类型有它的连接函数(Concatenation function)。布尔类型有其or、and和not函数。
\\为了成为monad,一个类型必须有一个函数使用了monad的相关值或者值,并且函数有特定的形式。下面就来讲下这些特定的函数形式。
\\第一种候选的函数形式(一个错误的想法)是用来从monad中分离相关值的函数(我将会把它称为badIdea
)。例如,badIdea
函数会从操作里面获得用户输入。如果你调用了badIdea
函数,并且用户键入了数字5,那么badIdea(doInput())
就是值5。通过调用badIdea
函数,你就可以输出调用badIdea()
的结果值,甚至可以加1到结果值。
x = badIdea(doInput())
\\tprint(x)
\\ty = x + 1
\现在回到我们之前开始提到nextInput()
的时间依赖性的问题上。表达式badIdea(doInput())
具有原始函数nextInput()
所有的不良特性。将badIdea(doInput())
函数表达式执行一次的值是5
。再执行一次,值就变成了17。在弱类型的语言中,badIdea(doInput())
的值或许会从5
变为\"Hello, world\"
。
通过函数badIdea
, 你就可以从doInput()
操作中抓取相关值,并用该值去执行任何操作,但这并不是一个好的方法。相反地,让我们从doInput()
操作抓取相关值,然后再创建另一个操作来使用这个值。这很重要的:你需要在一个doInput()
操作后面紧跟着使用另一个操作。当你形式化地描述这个想法的时候,这个操作类型就变成了一个monad类型。所以让我们开始形式化地描述这个想法:
我们从两个东西入手:操作和函数。例如:
\\doInput()
操作,获得键入数字的操作。\\tdoPrint
函数,拿一个数字,并生成一个在屏幕上写入该数字的操作doPrint(5)
= 在屏幕上写入5的操作doPrint(19)
= 在屏幕上写入19的操作\图1对该场景进行了说明。图中每个齿轮均代表某种操作。
\\我可以把doPrint
描述为一个from-number-to-action函数。当你做函数式编程的时候,很自然的会出现这种函数。
让我们在这里暂停一下,考虑下两个表达式,不带括号的opSystem
和带括号的opSystem()
。opSystem
表达式的值是个很特殊的函数,这个函数会去发现正在运行的操作系统,但是opSystem()
表达式的值是一个名字,像是Linux
和Windows 10
。简单来说,
opSystem
表示一个函数,而\\topSystem()
表示调用opSystem
函数的返回值\记住这些后,请注意带括号的“操作doInput()
”和不带括号的“函数doPrint
”之间的区别。doInput
和doPrint
这两种函数都会返回值,而且返回的值都为操作。当我说“操作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()
= 拿到键入数字的操作\\tdoPrint(x)
= 将值x显示到屏幕的操作\\t bind(doInput(),doPrint)
= 将doInput()
的相关值显示到屏幕的操作\\t见图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),\u0026gt;\u0026gt;=
被直接内置到了这个语言内。事实上,很多处理monads的功能都是直接内置于Haskell内。
让我们重温一下在图3中所示的doInput(),doPrint情景。
\\bind(doInput(),doPrint)
= 将doInput()
的相关值显示到屏幕的操作
bind(doInput(),doPrint)
中没有任何一部分具有时间依赖性,\\ doInput()
总是同一个操作——拿到键入的数字\\tdoPrint
也总是from-number-to-action函数\\tbind(doInput(),doPrint)
也总是相同的操作——将数字显示到屏幕的操作\用户键入的数字因时而异,但是表达式bind(doInput(),doPrint)
中没有任何一部分表示那个值。我们并没有消除所有的负面影响,但是清除了我们代码中任何对系统状态的明确地提及。一旦用户在键盘上键入了一个数字,这个数字会像烫手山芋一样从一个操作传到下一个操作,而代码中的任何一个变量都不表示用户键入的那个数字。
在我们提过的例子中:
\\doInput
的相关值是一个数字,而doPrint
的实参类型也是一个数字\\tdoInput()
的类型是一个操作,而doPrint
的返回类型也是一个操作\bind
函数告诉我们如何将doInput()
和doPrint
相结合来得到一个新的操作。
一些monad跟数字和输入/输出操作都没有关系。所以让我们用更笼统的说法来概括一下,我们对于doInput
、doPrint
和bind
函数的发现:
doPrint
的实参类型和doInput()
相关值的类型相同\\tdoPrint
的返回类型和doinput()
的类型相同\bind
函数告诉我们如何将doInput()
和doPrint
相结合来得到一个全新的值。新值的类型与最初的doInput()
值的类型相同。
任何有bind函数(和另一个我将在文末描述的函数)的类型就是一个monad。有了我在之前几段中提到的针对输入/输出的bind函数,输入/输出操作类型就变成了monad。我们先前提到的list、Maybe和Writer类型也是monad类型。以下是bind函数与列表类型的关系:
\\从两件事说起,列表L
和函数f
。跟之前一样,函数f
是某种特殊的类型。
f
需要一个类型与列表中元素相同的值,作为它的实参\\tf
会生成一个列表\如果列表含有数字,那么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]
\\\t在这个例子中,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]
\\t\我们已经成功地从书的列表中拿到了作者列表。见图7。
\\ \\注意图2-图7这些图像的相似处:
\\bind
函数的应用。\\t对于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)
的字符串部分)
一些例子能够帮我们记住这个要点的例子。回想下之前的square
、plus4
和dividedBy5
函数——这些函数的返回值均包含能被10整除的指示符。
bind((36, \"false\"), plus4) = (40, \"false true\")
见图8。
bind((40, \"false true\"), dividedBy5) = (8, \"false true false\")
当你应用bind
的时候,结果的字符串部分会将早期计算得到的字符串都包括进去。期间,字符串部分会运行所有计算的日志。
bind
函数可能没有很好理解,但是另一个作为一个monad必须有的函数却很好懂。我把它称为toMonad
函数。
toMonad
函数将一个相关值作为它的实参\\ttoMonad
函数返会还一个monad\粗略地说,toMonad
返回的是可能包含给定相关值的最简单的monad。(这里有一个更精确的定义,涉及到toMonad
与bind
的交互,但我在本文中不会讲这个。好奇的话,可以访问。)
对于I/O操作monad,toMonad(aValue)
= 一个相关值是aValue
但是却没什么作用的操作。
对于列表monad,toMonad(aValue)
= 包含aValue
并将其作为唯一入口的列表
对于Maybe monad,toMonad(number)
= 含有该number
的Maybe值。
请记住,含有5的Maybe值与原来普通的5并不完全相同。
\\t\\t对于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 课程的讲师。自1980年以来,Dr.Burd就一直担任着新泽西州麦迪逊德鲁大学数学和计算机科学系的教授,也曾在美国、欧洲、澳大利亚和亚洲的诸多会议上发表过演讲。
\\原文链接:
\\感谢对本文的审校。
转载地址:http://ttmwa.baihongyu.com/