Haskell 中的单子——洪峰老师讲创客道(三十五)
Posted Linux内核之旅
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Haskell 中的单子——洪峰老师讲创客道(三十五)相关的知识,希望对你有一定的参考价值。
在上一讲中,我给大家介绍了范畴论中的范畴和函子的概念,大家已经看到有了范畴和函子的概念以后,Haskell语言的类型系统变得丰富起来。我们拥有类型类,通过类型类定义范畴到范畴之间的态射。范畴到范畴之间的态射实际上也是形影关系,所以它也可以用一个箭头来表示。与原来定义在集合之间的函数关系不一样的是,这个时候我们可以把重点放在从一个范畴中的箭头到另外一个范畴中的箭头的形影关系上。
在此基础上,Haskell语言的设计师对于范畴论的内容进行了深入的研究,根据范畴论的基础提出了单子的概念。这里有听众会问,Haskell语言引入单子的目的究竟是什么?根据我的理解,我认为引入单子的目的有两个:一个是要在Haskell语言中支持函数的多态性;另外一个是利用单子解决对纯与不纯混合的状态的支持,因为Haskell语言是纯函数式编程语言。这两个理由我认为是Haskell语言的设计师引入单子的根本原因,这两个方面的设计,相互之间也产生了新的有趣的现象。下面我就顺着这两条线索进行解说。
在开始这一讲的内容之前,我这里向大家打个招呼,这一讲的内容时间上比较长,请大家保持耐心听下去。而且你一旦听懂了这个讲座中我说的内容的话,你会在自己的编程生涯中,终身受益。前面我已经提到了掌握运用范畴论是我们理解Haskell语言的关键,从范畴论的角度掌握理解Haskell语言有事半功倍的效果。在我前面提到的斯普林格出版社出版的范畴论的著作中,Saunders Mac Lane是怎么定义单子的呢?我们先看看他给出的数学定义,在这本书中 Mac Lane教授并没有很快的给出单子的概念,他迟在第六章,就是Monads and Algebras这一章才给出了单子的定义。要介绍单子的定义,首先要搞清楚一个概念叫 EndoFunctor。它是一个特殊的函子,表示范畴X到范畴X自身的态射。有了这个EndoFunctor之后呢,就有这么一个定义。一个单子是一个四元组,它是由范畴X,态射T,还有两个自然变换η和μ构成的四元组。也就是说在一个范畴X中的函子,我们叫做T,T是X到X自身的态射。而且η和μ是两个自然变换,一个变换——η是一个自然变换,另外一个变化——μ是T平方,也就是T与T的复合的自然变换。自然变换就是从一个函子到另外一个函子的形影关系。这两个自然变换一个叫η一个叫μ,它们在T的三次方也就是T的三次复合、T的平方也就是T的二次复合与T之间建立了一个四边形的关系,以及由另个四边形拼起来的图解。
这个图解在这本书的137页给出了清晰的说明。在Mac Lane教授给出的定义中,我们看到出现了EndoFunctor——一种具有自反关系的函子。这里我们有必要回顾一下,我们在PM1/2/3的3中讲集合论的时候关于自反关系的一些内容。我们知道自反关系是一种特殊的二元关系,也就是一个元素自己与自己的关系。在图论里面,用一个箭头指向自身来表示自反关系。当我们把映射、态射或者是自反关系当做一个动作的时候,在汉语里面找不到反声动词来表达态射。而在其他的语言里面,比如说俄语里面有所谓的反声动词来表示自己与自己的关系。俄语中的одеватьатье和 одеваться 这两个词,就有词义上的微妙差异。 одеватьатье 是给别人穿衣,比如说妈妈给小孩穿衣,就是одеватьатье。如果是妈妈自己给自己穿衣服就是,одеваться(这两句俄语小编尽力了,ORZ...)。俄语中这种词很多,比如说:脱衣、洗澡、穿戴、洗脸、刮脸、理发、梳头、穿鞋、擦手、擦脸还有隐藏等等。俄语对于中国人之所以难学是因为他有很多的曲折变化,包括一些汉语中没有的语法。现代汉语是一种词素语言,所以我们需要用新增加单词来表示自反关系。比如说我刚才讲的妈妈给自己穿衣服,给自己穿衣服就是新增加的词汇来表达反声的概念。当然了,汉语的神奇和伟大在他的词汇里面也得到了反应。比如说内省这个词,这个词就是表示自反关系。在儒家经典著作《论语》中有这样的话:曾子曰,吾日三省吾身,为人谋而不忠乎?与朋友交而不信乎?传不习乎?这里的吾日三省吾身中的省就是内省的意思。
在艺术的领域里,自己与自己有关的有很多的作品。我这里列举一例,在我上大学的时期(八几年的时候)曾经流行过法国的吉他演奏家尼古拉·德·安捷罗斯 (Nicolas de Angelis)他演奏的一首非常有名的吉他曲目,叫做《镜中的安娜》。尼古拉是伟大的西班牙集团演奏家安德列斯 塞戈维亚(Andres Segovia)的学生,塞戈维亚一生有很多教学活动,所以他的桃李满天下。《镜中的安娜》曲目的法文原名是《Quelques Notes Pour Anna》,翻译成中文应该是《几张写给安娜的便条》。我问过我许多的朋友,他们听了这个曲目后有什么样的感受,每个人的回答都是不尽相同的。我听了这首曲目后我的感受是安娜应该是作曲者的女朋友,作者无法向安娜倾诉心中的爱慕之意,所以作者在自己的心中和自己对话,表达对安娜的爱慕之意,整个曲目听起来罗曼蒂克非常浪漫。有时候有一些听众问我,如何让程序员具有艺术修养。我的答案很简单,多听听像镜中的安娜这样的罗曼蒂克的曲目,会有助于提高你的艺术修养。
佛教里的佛也是讲觉悟了的人,觉悟了就含有内省的意思。我们知道佛教的流派很多,但是不论哪一派哪一宗教,基本思想都是一样的。从学的角度来看,那就是信、解、行、正,从教的角度来看,就是教、礼、行、苟这四个方面。无论那个流派,在具体实践的时候,第一步就是要让修行者内省,从而发现自我。如果他发现不了自我,就做不到忘我。忘我首先要有一个我,有自我的存在,才能做到忘我。不能忘我,也就进入不了诸法无我的状态。这也是三法印之一。我以前跟一个朋友讨论佛法的关系的时候,我跟他写过一首诗。诗是这样写的:五蕴不空有极善,性色明深转义链。万千大众诸此界,飘萍浮梗业因缘。立地成佛一念间,内省方得悟真界。古今如来多少法,自反关系第一关。
这里已经有同学坐不住了,他说洪老师你说了这么多关于自反关系,那么自反关系跟我们的Haskell语言究竟有什么关系呢?别急,洪老师讲课一贯是三兼顾的,我谈哲理和数理最终的目的还是要把机理方面的事说清楚,最后一定会回到我们具体的机理层次上来。
在前面的讲座中我在介绍Haskell的类型系统的时候,我已经给大家介绍过Haskell的类型系统有一个很重要的特征就是:它的类型系统是静态(Static)的。从Haskell的编译器的角度来看,Haskell编译器所维护的符号表里面,在关于类型的子表中,一条记录插入以后,在这个程序相关类型所存在的生命周期里,他是不会发生变化的。我这里说的不会变化指的是这条记录在这个类型子表中的存在不会发生变化。只要这条记录所刻画的类型被程序所需要,也就是说它的生命周期还没有结束,它就会作为一条记录一直存放在子表中。Haskell语言的类型系统最为微妙的地方也就是这里,我刚才说了,记录在它的生命周期里会存在下去,但是并不等于记录是单一的。实际上,记录所刻画的类型里面还可能有其他的很多子类型在里面。我们把这个称为类型的多态。所以要全面准确的理解Haskell的类型系统,我们必须对多态性(Polymorphic)进行详细的考察。
在北宋的时候,有一位伟大的诗人叫苏轼(苏东坡),这个大家都知道。他文采飞扬,才气过人。关于他的传说有很多,其中有一个是这样讲的。有一天北方某一个邻国的一位使者来到了北宋王朝,在宫廷上当着皇上的面,给他出了一副对联的上联,让北宋的文武百官对出下联。上联是这么写的:三光日月星。这个对联非常难对。为什么呢?因为日月星把天上所有发光的东西都说完了,下联必须要根据这个特点说,而且下联必须在气势上超过上联,要不然就被北方的使者羞辱了。当时很多人一时想不出好的办法,这个时候苏东坡出场了。他对出了下联,叫做:四诗风雅颂。下联在字数上跟上联完全一样,都是五个字,但是下联的内容要比上联来的多。因为上联是三光,下联是四诗,多一个。这里有一个什么窍门呢?苏东坡知道诗经里面有风雅颂,而雅有大雅和小雅,因此诗经由原来的风雅颂三种变成了四种。苏东坡的下联非常好的回击了北方来使的嚣张气焰,因为这幅下联说了风雅颂,地上的所有的诗的典范都收录在诗三百里边,而风雅颂是我们中原人民的发明创造,不是你们北方的蛮荒民族写出来的。所以下联在气势上完全压住了上联。苏东坡在对联里面巧妙利用了大雅和小雅都是雅的多态性,战胜了北方的使者。
在编程语言中,编程语言对类型多态性的支持很早就有了。比方说在Lisp语言里面可以通过一个空的函数,我们叫做分类对策的函数,你可以写很多分叉,每一个分叉你可以根据外面传进来的参数绑定到一个Lambda表达式上去,这样同一个函数的名称在default特殊表里面就可以根据传入的参数类型选择所对应的绑定。大家看,Lisp语言我们前面讲过,是人类历史上第二门被发明出来的高级语言,在五十年代末期就已经被发明出来。 从那个时代开始,函数的多态性在Lisp里面已经存在而且的到了很好的支持。这里可以看出数学家们发明的语言,比工程师们发明的编程语言在概念上要先进很多、领先很多。为了让大家明白我的这个说法,这里来分析一下C/C++语言是如何支持函数的多态性的。
在理解了C语言对函数多态支持之后,我们开看看C++语言是如何实现对函数多态支持的。我们已经知道C++语言对C语言的扩展基本上有三个方面:一个是类,通过封装性,把一堆逻辑上相关联的代码封装到类里面,从而从计算的力度上改善了C的语言特点;第二个是继承性,可以用类的继承机制达到代码的某种重用;这两个特点我们C语言里面可以通过技巧实现,而C++语言对C语言的根本性扩展是应该在多态性的支持上。为什么这么讲呢?因为C++语言对函数多态性的支持,既有编译时多态性支持也有运行时多态性支持。关于这两点,我下面稍微详细展开一下。
C++中在编译时对函数多态性的支持是比较好理解的,如果大家有C语言的基础的话。C++直接支持函数的重载(Overloading of Functions),也就是说对同一个函数他的返回值不一样、传入参数表中的参数个数或者类型不一样,我们都可以用同一个函数名声明和构造函数。这个用法非常好,在C++里面对一个类的构造函数的编写我们可以大量使用重载函数。对一个类而言,我们有多种方法从类中构造出不同的对象来。不同的对象就是通过使用不同的构造函数得来的,构造函数名称和类名相同。在这个特殊函数中,我们可以向构造函数提供不同的参数,从而让类得到不同的实例化,得到不同的对象。函数重载在C++中非常常见,不光是用在类的构造函数中,对于其他函数也可以重载。在类的继承性中,子类对于超类的某一个virtual函数字段,在子类中我们可以重新定义,这个叫对函数的重叠(Overrading),这也是编译时多态性的支持。除此之外C++提供了对函数运行时多态性的支持,这个是怎么实现的呢?在类中放入一个纯虚函数,比如说这个函数叫fuba,定义一个virtual fuba = 0,通过这种形式构造一个类,这个类是不能被实例化的。含有纯虚函数的类,在C++里面我们称之为抽象类。在Java语言里面,把这个概念也推广了,延伸到了Interface上。同时Java语言本身直接提供了对抽象类的支持,可以使用Abstract这个关键词来修饰class这种方式定义一个抽象类。当C++编译器遇到了含有纯虚函数的抽象类的时候,会把这个类编译成为指向表的入口的占位符,表中有许多条记录。记录究竟对应到一个什么样的函数实现上去呢?这个是在运行时才会发生真正的绑定。运行时与一个具体的函数的绑定,需要借助操作系统文件系统的帮助才能实现。这就是说在C++的发展历史上,对动态链接库(Unix社团里面叫shared Object)的支持一直有好坏两种争论。为什么呢?因为一个软件会存在许多的版本,一旦使用了动态链接库或者我们叫做共享文件,对于共享文件的版本管理成为一个让人很头痛的问题。这个问题为什么让人如此头痛呢?因为原来的老版本会使用一个接口,然后使用老版本的函数实现。而新版本发布以后,仍然会编译到同一个接口上去。这个时候文件系统对这个版本的变换并不知情,在安装的时候就有可能把新的函数实现作为新的动态链接库增加到操作系统中去。程序运行的时候就有不知道要绑定老版本函数的实现还是绑定新版本函数的实现,这就是许多软件bug的来源。针对这个问题,有许多的补救措施。补救措施大部分围绕文件系统,利用文件系统对不同的版本实施版本编号,通过版本编号的不同区分不同实现的动态链接库,从而解决这个迷惑。还有一些方案是使用静态链接库,就是说我把软件版本所需要所有的共享文件作为一个静态链接库打包到应用程序中,一同发布。然而,种种此类补救措施都不能弥补C++编译器对运行时多态支持上的缺陷。
从我刚才分析的情况来看,通过对比Lisp语言发明和C/C++的语言发明,我们看出工程师们发明出来的语言虽然在工程上可行,但是在语言的优美和简洁方面是不如数学家们发明的语言的。
现在我们回过头来看看Haskell,在我的讲座里面面,当我谈论Haskell的时候,我一直是把它作为一个Lisp语言的现代版本看待的,为什么呢?因为Haskell语言是纯函数式编程语言,纯函数式编程语言是Lisp语言的一个显著特征,另外Lisp语言依赖的Lambda演算在Haskell里面也得到了直接的支持和应用。而且Haskell语言和Lisp语言相比,在许许多多的问题上,比如它做事的时候、在设计的时候,它显得更加具有原则性。我这里说的原则性主要是指类型和类型系统方面,关于类型的构造、检查和自动推导方面。而且Haskell的编译器非常聪明,它会把原来Lisp系统在类型中所做的关于类型检查方面的工作、类型构造方面的工作全推到编译时完成。说的在明白一些就是Haskell语言的编译器会在程序编译时,利用类型类和我们前面提到的函子以及我现在正在讲的单子的概念,把对于类型的构造以及对于构造出来的类型的使用放在程序的编译中完成。这个构成了Haskell语言的函数多态性丰富的内容。后面我会讲到Haskell语言函数多态性这套机制比C++以及其他语言这套机制简洁优美很多。这也是Haskell语言能在当今的软件界掀起一场生产力革命的一个根本原因。
这里要讲到的单子的概念与函数的多态性是密切相关的。刚才通过举例说明了什么叫多态性,什么叫函数的多态性。在Haskell中,单子首先定义成为一个范畴到他自身的态射,数学的术语叫EndoFunctor。这里为什么要用具有自反关系的态射呢?答案现在看起来是非常简单的,因为我需要利用EndoFuncror对我们编译器所维护的符号表中关于类型的子表中的某一条记录进行某种操作,换句话说就是让这条记录内部可以运作的空间变得非常大,从而可以容纳更多的类型,也就是达到我们所讲的函数的多态,为了在一条类型记录里面,我们不妨把类型里面的这一条记录视为一个范畴,为了在这一范畴里面增加新的类型,那么我们就需要在这个范畴里面设计一个机制,让这个机制可以构造新的类型,也就是说我们要有一种机制在记录进入内部它可以有一种Type Constructor(类型构造器),前面我已经说过多次,在Haskell语言里函数是一个类型,也就是说一旦我们能够在type class里面能够找到一种机制让这个函数作为一种新的类型被构造出来,那我们就达到了我们的目的,而要做到这一点,就有必要向大家介绍一个概念,叫做Aplication of Functor(应用函子),请大家注意,这个应用函子可以被单子所利用,也可以不被单子利用单独的存在,也就是说Aplication of Functor既可以作为一个范畴到另外一个范畴之间的态射,也可以像单子那样用于一个范畴到它自身的态射,这个Aplication of Functor听起来神乎其神但实际上它是Lisp语言和Racket语言里map函数的一个推广.我们知道在Lisp和Racket里边map是把一个函数施加到一个链表上去,然后构造出一个新的链表,这个新的链表中的每一个元素都已经被给的那个函数作用过。这就是map高阶旋子的一个本质。Aplication of Functor我们称为Fmap,这个Fmap实际上是Racket里map的一个升级版,和map非常相似,它也要提供一个函数,比如说a->b。
它的第二个参数不是像map那样是一个链表,而是一个函数,我们写成f(a),这个Fmap的返回值也是一个函数我们写成f(b),我在黑客道教学实践中讲述Haskell语言时发现许多学员在这里卡了,为什么呢?因为他们没有理解函数的签名和这个函数的应用这两者之间存在的重大的差别,其实这个差别一旦说清楚了也很好理解,以Fmap为例,Fmap里边的第一个参数就是一个函数,这个函数是以签名的形式给出来的,那就是a->b的形式给出的,而后面的f(a)以及它的返回值f(b)都是以函数应用的形式给出来的,回忆一下前面我给大家讲Lambda演算的时候,我给大家讲过Lambda演算的定义有四种形式,第一个代表一个常量,第二个代表一个变量,第三个是代表一个表达式对另一个表达式施加一种作用,最后一个就是Lambda表达式本身的定义,它可以定义成一个参数,一个点后边带一个Lambda表达式,说明对Lambda表达式的实现,我们刚才提到的f(a)或者f(b)实际上就是把一个函数施加到一个你提供给它的数据上面去,比方说a或者b上面去,我们可以类比C++中的构造函数,我们知道在C++中的构造函数是你需要向构造函数提供构造对象的初始值,你向构造函数提供不同的初始值和不同的类型的话,那么你这时候从同一个类中调用构造函数所构造出的对象是不一样的。
在我们Fmap里面f(a)和f(b)会向你提供一个值或者表达式叫做a,那么你这个f(a)会施加到所给的这个值上面去,也就是a上面去,然后你会构造出一个新的类型,对于返回值那就是f(b),也就是说我会对b值施加f函数的作用,然后把类型构造出来,我们可以从函数复合的角度看待Fmap,如果我们刚才定义中出现的f(a)可以写成一个函数,比方说r->a,返回值f(b)可以写成另外一个函数r->b,那么这个时候Fmap所做的工作就是把两个函数复合成一个新的函数,哪两个函数?一个是r->a,另外一个是a->b,这两个函数中间是不是都有a,中间这个a可以通过结合法则把它去掉,那就变成了r->b,所以说Fmap这是一个函子,是map的升级版,从函数复合的角度看它就是普通的函数的复合,我们还可以知道函数的复合本质上是形影关系的一种复合,那么这一种思路我们也可以推广到函数与函子的复合,函子与函子的复合等等这些所有关于形影关系的操作上去.
刚才Fmap这个函子的定义中,Aplication of Functor的定义中出现的f(a)和f(b)中的f我们既可以把它看成一个函数也可以把它视为一个函子,具体把它视为什么取决于程序设计的定义,不管怎样,从类型构造的角度来看我们都是提供了一个值,把这个值提供给一个类型构造函数,我们叫做Type Constructor,这个Type Constructor也就是我们在Fmap函数里提到的f,它会吸纳闭包的值,然后进行类型的构造,这就是类型构造函数的本质,在有些讲解Haskell的书里,有些作者把这个f形象化的比喻成一个盒子,或者说成一种环境,我认为这种比方都是恰当的,为什么呢?因为把它当成一个盒子也好,一个环境也好,本质上都是一个闭包,我们就是说把一个值传给一个闭包,让这个闭包吸纳这个值以后进行的类型构造,这就是Fmap工作的本质,所以说Fmap看起来神秘兮兮的,但是我们从函数的复合和类型的构造这个角度来看的话,它的本质是一目了然的,所以这个拦道虎可以很轻松的干掉.
相比第一头被干掉的拦道虎下边第二头拦道虎难度会大一点,因为什么呢?因为这个涉及到Haskell类型系统里边类型构造的时候是可以带参数的,比方说Haskell里边用的很多Maybe,Maybe可以视为Type Constructor,就是类型的构造函数,它可以构造出一个失败的类型,我们叫做Nothing,也可以构造出一个成功的类型,我们把它称之为Just a,说到这里请大家回顾一下我讲Lisp语言的时候,讲非确定计算的时候我给大家讲过一首词,就是三国演义这部小说开头的《临江仙》,滚滚长江东逝水,浪花淘尽英雄,是非成败转头空。青山依旧在,几度夕阳红。你看看在这个词里面,是非成败转头空,这个是非成败是不是含有两种状态啊,它既有成功的状态也有失败的状态,那么对于我的类型构造函数而言也可以考虑成功的状态和失败的状态,这个都体现在Maybe这个类型构造函数里面,换句话讲,函子的Aplication里边Fmap的定义让计算具有回朔性,而我前边讲希腊故事的时候讲过这个回朔具有线的概念,也就是连续的概念,所以回到我们Haskell里边讲单子的时候当我们把一个值放到一个Lambda表达式里面去,把它放到一个函数里面去、放到一个盒子里面去或者放到一个环境里面去的时候,对它的类型构造有可能成功也有可能失败,那么如何去应对这一种情况呢?
我们在单子的定义里面出现了大于、大于等于的概念,也就是说对于提供了一个值的一个环境,比方说m(a)就是一个类型构造的函数,我们同时还会提供一个函数,这个函数是从a这个值映射到(->)m(b)上面去,也就是把一个值b放到一个环境中去,然后它的返回值就是把b放到这个环境中去,这叫做连续这个函数,这个是单子的第二个要件。前面我讲的把一个值放给一个环境,传递一个闭包,这个在单子的定义里被定义为Return,注意Haskell里面的return和其它语言比如说C或者C++里面rreturn是完全不一样的,Haskell里面的return是针对你给定的值,为这个值施加一个环境,也就是提供一个Lambda,提供一个闭包给这个提供的值施加到这个值上面去,单子的第二个构成的要件就是要运算或者函数与函数之间产生函数的复合,具有某种连续,这个连续是通过幺半群来实现的,这个地方我要提醒大家注意这个幺半群的使用,我前面讲过这个是针对一种运算而言,这个运算要满足封闭性,满足结合律,同时它还要具有单位元,要有这三个运算才称为幺半群,单子的定义里面关于第二个要件它就一定会和我们的幺半群发生关联,只是这个地方我们原来的运算范围已经扩大到了一个范畴。
定义单子的第三个要件实际上就是为我刚才提到的函数的复合提供某种保证,让它在它失败的情况下也能成功,具体来讲就是一个a我们把它放到一个闭包里面去,就是m(a),这是第一件,然后第二件就是对于一个值b我们也把它放到一个闭包里面去,叫做m(b),那么这两个参数会有一个返回值,那就是我们得到把b放回到闭包m里面去,这个是由两个大于符号写在一起表示的,表示某种连续性,也就是说我会确认你对这一个类型构造成功性我会进行某种确认,保证你一定回朔会成功,这就是单子的第三个要件。
最后一个要件就是一旦我出错的时候会有出错的一些信息来以字符串的形式告诉程序员,这个作为第四个要件我们可以把它写到单子里面。所以说总体来看单子作为它的定义来讲,作为它在Haskell语言里面的定义来讲,和单子在范畴论里面的定义是有一些差异的,但是无论它则么复杂,我刚才提到关于单子的四个要件从我们范畴论的角度理解都是十分容易的,这一点我向大家说明一下。那么到此为止关于Haskell语言第二个轮回讲解它动的部分,也就是函数、函子和单子的概念我就到此全部结束了,在这一讲中我们已经清楚的看到单子就是一种特殊的En,EndoFunctor是一种特殊的函子,引入单子的目的之一关于函数多态性的概念我在这一讲已经花了很多的时间去讲解,那么在下一讲我会介绍如何使用单子去模拟状态的变化,这是下一讲的主题。
以上是关于Haskell 中的单子——洪峰老师讲创客道(三十五)的主要内容,如果未能解决你的问题,请参考以下文章
范畴和函子,以及它们在 Haskell 中的应用——洪峰老师讲创客道(三十四)
函数的概念Lambda 演算与 Haskell 语言——洪峰老师讲创客道(三十二)
函数的概念lambda 演算与 Haskell 语言——洪峰老师讲创客道(三十三)