翻译 通过Python的列表推导理解Monad

Table of Content:

原文链接: http://lukeplant.me.uk/blog/posts/understanding-monads-via-python-list-comprehensions/

在Python中,列表推导是一个非常棒的特性。这个特性借鉴自Haskell和ML语言,而归根究底,它来自于集合理论。你可能知道列表推导提供了一种非常直观的方式来从列表或者序列来建立新的列表。

1
2
3
>>> lst = [1, 2, 3]
>>> [x * 2 for x in lst]
[2, 4, 6]

列表表达式用于代替下面这种写法:

1
2
3
>>> newlist = []
>>> for x in lst:
... newlist.append(x*2)

列表表达式去除了冗余的部分,从而使得Python可以更为简明。列表推导自动会创建空的列表,并且添加新的元素。这样我们不需要每次都要反复写这些代码。

在学习Haskell的过程中,我发现本来熟悉的列表推导,变得有些诡异。因为Haskell中还提供了一种Monad的写法。在Haskell中,我们有着类似的代码。(在GHCi交互式命令行中,Prelude>是标准的命令前导):

1
2
3
Prelude> let lst = [1,2,3]
Prelude> [ x*2 | x <- lst ]
[2,4,6]

如果你懂得Python的话,这部分非常易于理解,只不过是将列表表达式翻译成英语而已。解包列表中的每个值,给它个名字x,然后每个返回为x * 2作为新的列表中的值。

Haskell还有一种格式来实现同样的东西,通过do表达式

1
2
Prelude> do { x <- lst; return (x*2) }
[2,4,6]

这个和上面的方式非常相似,实际上两者做的也是完全相同的事情。而且注意到,新的方式里面的表达式的顺序甚至更为接近英语,也更为符合命令式风格的思考方式。这个新的语法也反映了它最初来自于集合理论的数学表达符号。

然后,有一点不一样的地方。

列表推导清楚地表现了创建列表的部分。但是do表达式,却完全不存在列表的信息。实际上,上述表达式中关于列表的部分,仅仅是输入值的名字lst。为了证明这一点,我们可以基于上面的表达式,创建一个函数:

1
Prelude> let double val = do { x <- val; return (x*2) }

这里定义了一个函数double。这个函数有一个输入值val。(在交互式命令中,我们必须要添加一个let,而在Haskell的源代码中,是不要这个let的)。我们可以使用这个函数

1
2
Prelude> double [0,1,2]
[0,2,4]

GHCi没有抱怨任何东西,Haskell是一个静态类型的语言,而我们完全没有提val的类型是什么。那么它是什么类型呢?我们可以通过GHCi内置的:type命令来检查,间接的地通过检查double函数的类型:

{警告:下面部分有点tricky,但是很快又会变得简单了,坚持下}

1
2
Prelude> :type double
double :: (Monad m, Num a) => m a -> m a

哇!,这到底是什么意思啊?我们首先忽略括号的部分,直接先看后面的部分:

1
m a -> m a

箭头告诉我们,这是一个函数。(GHC这点至少是正确的了。)它的输入类型是m a,输出类型也是m am a是一个带参数的类型,或者说参数化的类型。简单地说,我们可以说m是是一个容器类型的占位符。而a是一个容器包含的东西的类型的占位符。
对于上面的例子,我们的输入是lstm就是列表。(一般使用[]来表示),a就是整数。这一般写为[Integer],整数列表。而不是[] Integer
对于我们的示例,double输入的整数列表,输出也是整数列表。

但是GHC知道我们的函数可以更为通用,而不仅仅是面向于整数的列表。对于do表达式,它并不知道列表,而是推导出m可以是任何的Monad
对于* 2,推导出a必须是一种数字类型,准确来说,就是实现了Num接口的类型(这个接口包含了整数,分数,等等)。
(Monad m, Num a) =>就是说明了这个限制。

那么我们的double函数又是如何在完全不知道任何关于列表的信息,但是却可以从列表中解包元素,对每个值翻倍,然后再把数据重新组织为列表的呢?
那是因为我们使用了do表达式,它隐式地使用了Monad接口的一系列方法。而列表是一种Monad,定义了这些方法,因为do表达式就可以做到这点了。

那么除了列表,我们还可以使用其他的Monad吗?当然,而且如果Monad这个接口只有list这一个实例的话,我们根本就不需要这么麻烦来创建一个接口了。
一个简单的例子是Maybe monad,它可以包含Nothing或者一个实际的值,写作Just somevalueMaybe这种Monad或者封装了一个值,或者没有。而如果它只是包含了Nothing的时候,可以应用于任何函数,但是全部都返回为Nothing

1
2
3
4
5
Prelude> double Nothing
Nothing
Prelude> double (Just 1.5)
Just 3.0

非常神奇吧,直接计算Nothing * 2会返回一个类型错误,但是通过do表达式,我们的函数就对于monad而言是通用的了。而且不需要任何额外的工作。是不是令人印象深刻?

在其他语言中,你也可以创建一个工作在不同类型的的集合collections的函数,例如使用Python中的iterator protocal迭代器协议,或者C#中的IEnumerable接口。但是我们这里使它达到一个新的高度:Monad接口是任何容器的抽象。

而且,Maybelist在计算值的时候,考虑的是不同的策略。Maybe要处理的情况是0或者1个值。而list要处理的是任意数量的值,并且应用在所有的之上。这引出单子的值表示了一种计算,一种计算一个值,然后绑定到输入值的方法的概念。???
当考虑到State transformation这种monad的时候,这会变得很重要,例如著名的IO monad。在这些情况下,容器实际上是一种函数。(如果这点使得你很头痛,那么先不要考虑这些)。

在Haskell中,Monad是一种非常通用的容器。如此通用,以至于在Haskell中有着特殊的语法糖支持。这类似于迭代器协议和列表在Python中有各种各样的语法糖支持(例如forin,列表推导等等)。这个接口比其他的容器更为抽象,因此也更难以理解。但是也更有力。更甜的语法糖,使得它可以更为广泛的使用。

例如,在Parsec parser库中,monad用于写一种格式,直接翻译自巴科斯范式BNF,这使得它更为易读。解析器monad知道如何应用限制,回溯等等,就像列表monad知道如果拿出一个值,然后在上面应用一个函数一样。写monad是困难的,但是在Haskell中使用它们是非常容易的,也让我们可以写出更为powerful的东西。

我知道以上部分解释了monad为什么有用。下面就是要理解monad接口的函数,这已经超出了这篇文章的范围了,但是我会尝试作出一个介绍。

你可能已经猜出monad方法的一些东西了。一个是很显然的,它的return方法,这个方法用于将东西再打包回monad。另外一个称之为bind或者>>=。它用于解包,在do表达式中,使用<-箭头。

实际上,bind方法不是真的用于解包和返回数据。实际上,它定义了这样一种方式,它内部处理了所有的解包,而你必须提供一个函数来返回monad中的数据。为什么这非常重要呢?因为在一些monad中,特别是IO monad中,需要确保数据是不会转义的,来保证程序按照预期来工作。类似于Maybe和列表monad则不那么有占有欲,你可以很容易从中很容易拿到数据。但是通过这样定义了monad接口,它就可以处理所有的情形,并且使得所有的方式都变得简单。

那么do表达式中的<-符号呢?它实际上是个语法糖。它帮助你可以简单地定义合适类型的函数,它看起来非常像“从monad中解包拿出数据,然后我就可以使用它了”,这在概念上有帮助。和do表达式的其他部分一起,构成了一个匿名lambda函数。
我们可以按照Python的风格来写这个函数。

1
2
def double(val):
return val.bind(lambda x: val.return_(x*2))

这里不得不使用return_,因为return是Python的关键词,不能用作函数的名字。Haskell的do表达式消除了显式的bindlambda调用,使得它进一步易于使用。当在一个很长的do代码块中,处理多个monad的对象的时候,就变得越来越重要了。另外,当你学习Haskell的时候,你会发现使用空白字符(换行和缩进)而不是分号和括号,是多么方便。

除了上面的double的小例子,我完整实现了List and Maybe Monads in Python,尽可能保持了在Haskell中的使用方式。当然,没有Haskell的类型系统,但是Python可以实现一个。因为不像其他的OOP的语言,Python可以有无实例的方法。这个代码也展示了函数式的代码风格,几乎所有的函数都在4行代码之内。

有没有一点帮助?我希望得到任何反馈或者订正。我本人也是一个Haskell的新手,因此可能哪个地方搞错了。