首页 > 代码库 > 单子与太空衣

单子与太空衣

单子与太空衣

Garfileo posted @ 2012年2月18日 08:32 in  业余程序猿的足迹 with tags  Haskell  单子 , 102 阅读

Eric Kow (firstname.lastname@loria.fr)
20 November 2005
Version: 0.8.2 (2007-08-30)

原文:http://www.iterasi.net/openviewer.aspx?sqrlitid=ixx7fcluvek_9lfolsxr_g

这份文档尝试以直观的方式解释单子(Monad)的概念。我要使用空间站与宇航员作为隐喻来消除抽象。希望这一隐喻可以帮助你透析单子的内部逻辑且不致让你头昏。

阅读本文需要注意:我并不打算解释单子的用法,只是专注于揭示它的工作机理。最适合阅读这篇文章的人也许是那些已经对如何操纵单子代码有一些了解但是想深入其内幕的人。另外,我也不会解释有关单子的一些概念缘何而设,只是使用空间站的隐喻来表现其合理性。


我想从两个简单的函数开始讲起,它们对于粘合一些函数很有用。它们虽然与单子没有直接的关系,但是有助于让你明白如何以函数式语言写出命令式风格的代码。

美元

设想有以下代码:

1
i (h (g (f x))) 

相当难看,对不?幸好,Haskell 库提供了一个比较方便的操作符,即 $,它可以让我们写出同样功能但是更易读的代码:

1
i $ h $ g $ f x 

美元的实现只是函数应用的一种简单的方式。下面是它的实现,只用了一行 Haskell 代码:

1
($) f x = f x 

注意:美元符号之所以可消除括号的原因是它降低了优先级——它让其他操作符先完成任务——而且它是右结合的。

欧元

如果你能明白美元的功能,那么现在请你想像一下与美元的操作方式相反会是什么情况。例如,我们写出一个名为欧元的操作符,让它也像美元那样工作,区别是前者的参数次序与后者相反,即:

1
() x f = f x 

注意:欧元符号在 Haskell 中是无效的。这里只是在你的想象中构造出这样的一个符号。

这样做究竟有什么好处?让我们重新审视上面美元的例子:

1
i $ h $ g $ f x 

如果使用欧元,上面的代码可以写为:

1
f x  g  h  i 

如果你熟悉 C 与 Java 这类命令式语言,那么情况看上去便有点熟悉了,就像“首先计算关于 x 的函数 f;然后是 g;然后是 h,然后是 i”。为了便于理解,可将上面示例写成多行:

1 2 3 4
f x  g  h  i 

上面的比喻有点不恰当,因为上述代码并没有像命令式语言那样每一步均向下一步传递一些东西。可能更好的比喻是 Unix 管道:将 f x 的结果传递给 g,然后 g 的结果再传递给 h,依次类推。


现在,言归正传。这一节介绍单子幕后的环境,主要是揭示它与欧元操作符的相似性。

不熟悉的领域中一些抽象的东西常常让我犯难,单子便是这种东西。为了解决这类问题,我有时喜欢将它们与一些直观的东西建立一些联系,因此请容忍我下面的愚蠢。

空间站的隐喻

想像一下,我们身处于巨大的太空。太空中分布着许多空间站。一个空间站比喻着一个函数:它接受宇航员进入,也吐出宇航员。

这些宇航员可以是任何事物,如美国人、法国人、小狗、大猩猩、鲸鱼等等。只是有一样,作为一个函数,无论何时你向它输入一个宇航员,它必须也要输出 一个宇航员。例如,某个空间站,Bob 进去了,那么 Fred 就得出去。另外,空间站是类型化的,比如可以接受美国人进入而让法国人出去的空间站,或者接受小猫进入又将小猫输出的空间站,无论如何,这些空间站总是处 理同类的输入与输出。

至此,我们还没有做什么不同寻常的事情,只是简单的提供了 Haskell 函数的一个新颖的隐喻。先喘口气,看一下已经建立的隐喻:

  • 空间站(函数)
  • 宇航员(输入)

我们很想做的一件事是将空间站连接起来,即:将一个宇航员置入一个空间站,然后将这个空间站的输出的宇航员再导到另一个空间站中。我们面临的问题是无法将宇航员直接放到真空中进行空间站之间的传递。

一个相当好的解决方案是要求所有的空间站在放出宇航员之前为他们穿好太空衣。

从现在开始,无论我们看到的是什么函数都会沿袭下面这个通用的模板:

1
\a -> putInSuit (???) 

这种函数的类型为:

1
a -> m b 

这意味着这些函数接受某种类型为 a 的值,返回某种类型为 b 的值,由于是身处太空,所以将类型 b 置于太空衣 m 中。

注意:现实中许多有用的空间站并不满足我们制定的规范,但是也没什么,因为我将会在后面的内容中展示一种方法,可以对这些空间站进行翻新使之满足需求。


绑定(>>=)

如前文所述,我们的工作是将空间站连接在一起,即:从一个空间站向另一个空间站派遣宇航员。现在,我们已经将这个任务完成了一半,即:声明所有的空间站必须要输出穿着太空衣的宇航员。

还剩下的问题是空间站不接受太空衣,它们只接受宇航员!一个可行的解决方案是对现有空间站进行一些修改,使之具备为宇航员解除太空衣的功能,但是这种方案既麻烦又不优雅。

实际上,我们要做的是制造一种机器人,让它去解除宇航员的太空衣并将宇航员送入空间站中。这种机器人在 Haskell 中使用术语“绑定(Bind)”表示,但是它的书面表示是“>>=”符号。bind 所做的工作大致如下:

1 2 3
(>>=) suit fn =  let a = extractAstronaut suit  in fn a 

这个函数的类型签名为:

1
(>>=) :: m a -> (a -> m b) -> m b 

这表示它接受一个穿着太空衣的宇航员(m a)和一个空间站(a -> m b),然后它开始解除太空衣,并将宇航员置入空间站中,然后空间站又输出一个身穿太空衣的宇航员。

确切的说,bind 有时要对空间站发送出的太空衣进行一些处理,不过这一细节现在可暂时忽略。

Bind 与欧元

还记得这篇文章开始时讲过的欧元操作符么? 如果忽略解除宇航员所穿太空衣的事情,那么 bind 的意图与它相同。使用 Bind,我们可以将空间站连接起来,在非单子代码中使用欧元操作符也是在做同样的事情。

下面是使用 bind 连接空间站的一个示例。

1 2 3 4
astronautInASpaceSuit >>= (\a1 -> putInSuit (singing a1)) >>= (\a2 -> putInSuit (dancing a2)) >>= (\a3 -> putInSuit (farting a3)) 

现在,你已经理解了 bind 机器人的机理。本文剩下的内容要展现创建 Bind 机器人的一些不同的方法。

空间的种类(单子)

现在情况变得更加复杂:有许多种不同种类的空间,不同种类的空间需要不同种类的 bind 机器人。接下来,我将会讨论两种简单的空间种类:Maybe 空间与 List 空间,并且展示与之相应的 Bind 机器人。

在继续之前,我们简要回顾一下前文的内容:

  • 空间站(函数)
  • 宇航员(输入)
  • 包含宇航员的太空衣(单子化的值)
  • bind 机器人(>>=)
  • 空间种类(单子)

Maybe 单子

Maybe 单子是最简单的单子之一,但是其中包含了很有趣的东西。在 Maybe 空间中,bind 机器人看上去像:

1 2 3 4
(>>=) suit fn =  case suit of  Nothing -> Nothing  Just a -> fn a 

其中有何玄机?在 Maybe 空间中有两种太空衣:一种是什么也不包含的,一种是包含一个宇航员的。如果 bind 机器人接受的是一件什么也不包含的太空衣,它便什么也不做,只是返回一个同样是空的太空衣;如果它接受的是一件包含着一个宇航员的太空衣,它便使用模式匹 配将宇航员从太空衣中解脱出来,然后将其送入空间站 fn。

现在要记住,我们所涉及的所有空间站所输出的穿着太空衣的宇航员,太空衣的类型都是相同的。例如:Maybe 空间的 bind 机器人无论返回的是 Nothing 还是 fn a,它们都是 Maybe 类型的太空衣。对于这一点,形式化描述如下:

1
(>>=) :: m a -> (a -> m b) -> m b 

在 Maybe 空间中,可以具体化为:

1
(>>=) :: Maybe a -> (a -> Maybe b) -> Maybe b 

List 单子

要学习的另一个简单的单子是 List。下面是 List 空间中的 bind 机器人:

1 2 3 4
(>>=) suit fn =  case suit of  [] -> []  xs -> concat (map fn xs) 

它与 Maybe 单子的 bind 很像,除了空太空衣与包含宇航员的太空衣是包含一组宇航员(同一类型)之外。如果接受的太空衣是空的,那么只需返回一件空太空衣即可。

如果太空衣中包含了一组宇航员,那么我们便不得不将他们逐个解脱出来并送入空间站。这样我们得到的是一组太空衣,每件太空衣都包含着一个宇航员,所以必须要将它们合并(concat)为单件太空衣以保证空间站运转正常。

下面是 List 空间的 bind 机器人的类型:

1
(>>=) :: [a] -> (a -> [b]) -> [b] 

至此,如果你已经明白了上文出现的所有折磨人的隐喻,那么我要祝贺你,因为你已经明白了单子的工作机理。接下来,我们要关心一些比处理 Maybe 与 List 更重要的事情。

return

在进一步探讨之前,我们需要一点时间来消除一点社会上的不公平。前文我们宣称所有的空间站必须输出穿戴太空衣的宇航员。但是那些老式的空间站已经无法再翻新以输出太空衣,这种情况该如何应对?

这些空间站可以配备一个叫做 return 的小机器人,它唯一的工作就是接受宇航员然后将它置入太空衣。有了 return 机器人,这些古代的空间站就可以符合我们的要求了。

return 机器人的类型签名如下:

1
return :: a -> m a 

Maybe 空间的 return 机器人是这个样子:

1
return a = Just a 

List 空间的 return 机器人则是:

1
return a = [a] 

前文中我们虚构了一个函数 putInSuit,实际上它就是 return。

为何如此折腾?

return 机器人将宇航员置入太空衣,而 bind 机器人则与之相反。你可能会问为什么要为这种自我抵消的事情搞的如此麻烦。

一个原因是一些空间站具有内建的单子兼容性,他们不需要 return 这样的机器人,因为返回身着太空衣的宇航员是它们的固有功能。你能够通过这种空间站的类型识别出它们,因为它们总是返回一个单子化的值。例如 putStr 函数,返回 IO (),即一个包含着类型为 () 的宇航员的 IO 太空衣。

我们使用这种单子化的东西的真正原因是我们想以一种优雅的方式处理新型的空间站。如果只是将老式的空间站(缺乏为宇航员穿戴太空衣的空间站)连到一起,我们就不需要这样麻烦,只需像美元或欧元那样处理即可。

如果你还想知道真正的真正的原因,这篇文章着实做不到。


状态单子

到状态单子这里,才开始真正有用,不过也开始有点疯狂。无论发生什么,我建议你要保持头脑清醒,一直记住前面所讲的 bind 机器人所做的工作是什么。

如果你想在运行你的函数的同时传递一些信息,这时便需要状态单子的帮助。

状态空间中的太空衣本身就是一个函数!

1
return a = \st -> (a, st) 

这看上去有点费解,但是如果我们将 return 的一些实现并列起来看,就可以发现本质是一样的。

  • Maybe
1
return a = Just a 
  • List
1
return a = [a] 
  • State
1
return a = \st -> (a, st) 

看,没啥特殊的。对于 Maybe,我们返回一个 maybe;对于 List,我们返回一个 list;对于 State,我们返回一个函数。

在状态空间中,太空衣比较复杂:所有的太空衣有一个读票器,当你向这种太空衣塞入一张票,那么它便会打开来,显露一个宇航员以及一张票,即 (a, st)。

我更愿意将 return 返回的票视为收据。对于 return 这种情况,太空衣会输出与你提供给它相同的票,但是其他类型的太空衣可能就不是这样。实际上,这正是最关键的地方。票在这里可用于表示某种状态,这种接受 票并吐出收据的机制表示这我们将状态信息从一个空间站传递到另一个空间站。

状态与 bind 机器人

在状态单子中,bind 机器人实现了我的朋友 Dmitry 称为官僚式乱糟糟的东西。不过要记住,状态空间中的 bind 机器人所做的工作与其他空间中 bind 机器人所做的也没什么本质的区别。

1 2 3 4 5
(>>=) suit fn =  \st ->  let (a, st2) = suit st  suit2 = fn a  in suit2 st2 

我们的状态空间的 bind 机器人首先会向它所接受的太空衣 suit 塞入一张票 st 打开太空衣,从而得到宇航员 a与收据 st2,然后将宇航员 a 置入空间站 fn 从而得到太空衣 suit2。

最难理解的部分是 suit2 st2。 如果我们将 let ... in 表达式忽略,那么 bind 机器人可以简写为 \st -> suit2 st2。它所做的一切就是将一个有趣的反应链封装到一个容器型的太空衣中。当你向这个容器塞入一张票(st)的时候,容器内便会进行以下反应:

  1. st 被塞入第一件太空衣,结果得到一个宇航员与一张收据 (a, st2);
  2. a 被送入空间站 fn,结果得到一件新的太空衣 suit2;
  3. 容器型太空衣现在将所得的新票 st2 塞进新的太空衣 suit2,结果又出来一个宇航员与一张收据。

这整个过程,像 Rube Goldberg 机一样。

这就是最难理解的部分了。一旦你弄明白了这些,前面就是坦途。

两个有用的函数

这里有一对函数可提高状态单子的可用性。注意,它们也是空间站,接受宇航员,输出身穿太空衣的宇航员。不过有一件事情使得它们有些特殊,它们是单子兼容的内建函数,虽然只限于状态空间。

get

get 函数只是简单的返回当前状态:

1
get = \a -> \st -> (st,st) 

这个函数看上去有点奇怪:首先我们完全忽略了传递给它的任何宇航员;其次输出的宇航员只是一张票!

put

put 函数所做的与 get 相反。它可将当前状态设置为任意的值。

1
put x = \a -> \st -> ((),x) 

这里,我们输出的宇航员是 (),这表示将什么东西存为状态与宇航员无关,它只与票有关。


(I)O 单子

明白了状态单子,那么经常用到的 IO 单子便很容易理解了。为了简化一下问题,我们可以将 IO 单子拆分为 I 单子与 O 单子。先来看 O 单子,它是一种简单的状态单子,其状态(票)是一组要写到屏幕上的东西。

putStr

我们可以通过 putStr 函数来理解 O 单子。要记住,所有的函数都是接受宇航员输出太空衣的。这里,我们接受一个宇航员,然后完全的忽略它:

1 2
putStr str =  \a -> \out -> ((), out ++ str) 

这个函数所做的一切就是将一个字符串添加到输出中。如果还有些不解,可以再回顾一下状态单子中的 put 函数。

关于输入

像 stdin 与 stderr 这些复杂的东西该如何理解?其实也一样,IO 单子是一种状态单子,只不过在这里状态不再是一个列表,而是列表元组,其中每一个元素都表示一个文件句柄。或者更确切的讲,IO 单子的状态是某种相当复杂的数据结构,它表达了计算机在计算过程中某些阶段的状态。当你在进行 IO 操作的时候,你所传递的是:像一个状态那样的整个环境。


do 是语法糖

现在你已经知道了单子的方方面面,可能你满怀希望要做的是以一种舒适的方式使用它们。好的,你可以写出像下面这样的单子化代码:

1 2 3 4
astronautInASpaceSuit >>= \a1 -> foo a1 >>= \a2 -> bar a2 >>= \a3 -> baz a3 

我认为这种代码相当的繁冗。因此 Haskell 提供了一点语法糖。但是在讲述这个语法糖之前,我们先调整一下上面的代码:

1 2 3 4
astronautInASpaceSuit >>= \a1 -> foo a1 >>= \a2 -> bar a2 >>= \a3 -> baz a3 

do 语法糖所做的就是将右侧的代码移到左侧:

1 2 3 4 5
do  a1 <- astronautInASpaceSuit  a2 <- foo a1  a3 <- bar a2  baz a3 

明白了么?相同的代码,只是穿上了糖衣。与 do 语法糖相关的知识还有许多,特别是 let 的用法,如果想了解它们,可以去阅读更加正式的 Haskell 指南。


总结

这篇文章所用的比喻很贴切,而且也很深入浅出的道出了单子的奥妙。但是也许是我英文水平非常有限,也许是作者写东西喜欢弯弯绕,所以上面的文字有很多让我感到很难进行直译,只好根据自己的理解进行意译。

等我翻译到作者最后进行的总结时,我觉得都是废话,就没耐心再翻译了。感兴趣的话,还是看原文吧!

转载时,希望不要链接文中图片,另外请保留本文原始出处:http://garfileo.is-programmer.com




单子与太空衣