Haskell函数式编程入门

978-7-115-33801-3
作者: 张淞
译者:
编辑: 杨海玲

图书目录:

详情

这是一本讲解纯函数式编程语言Haskell的书,同时也是一本通过Haskell来讲解函数式编程的方法与思想的书。全书共分三个部分。第一部分介绍函数式编程在解决数学与算法问题的精简与直观的特色,让不熟悉Haskell的读者对其建立初步的了解;第二部分介绍一些略微深入的Haskell内容,包括函子、Monoid、IO与Monad转换器等;最后一部分则涉及快速测试、惰性求值和并行编程等主题。

图书摘要

Haskell函数式编程入门
张淞◎编著
人民邮电出版社

北京

其他

写给我亲爱的父母与曾经的自己。

前言

高级计算机语言的诞生大约是在20世纪50年代。虽然那时电子计算机的发展才刚刚起步,但计算机语言的设计却逐渐分成了两个阵营。

第一个阵营的设计直接基于计算机硬件的基本结构。这些语言的理念主要是让它们对计算机的内存、磁盘以及其他硬件的存储状态进行直接的操作,如 Pascal、C 语言等。它们常常被称为命令式(imperative)、顺序式(sequential)或者过程式(procedural)编程语言。在语句执行过程中,各个语句的先后顺序十分重要。

而另外一个阵营是希望计算机语言的设计直接从数学函数的角度出发,将计算过程抽象成函数运算,这种编程方式是对程序的一种更高层次的抽象。虽然起初在运行效率上并不是很高,但表达起来十分简洁,并且设计者也在努力,逐步使这类程序编译后更适应计算机的底层硬件以获得更高的运行效率,这一类语言就是函数式编程语言。

在顺序式计算机语言的发展进程中,它们的流行程度大致可以分成两种极端情况:一部分是像 C、C++、Java、C#这样的语言,一举成名,成为现在计算机编程语言的主流;另外是一些“冷门”的语言,可能是一些失败的实验探索性语言,也可能是一些未被很好地商业化的语言。但是,函数式编程语言在顺序式编程语言取得发展并广泛应用或者消失的几十年当中,被应用的程度却一直处于不温不火的状态,虽然没有取得广泛的商业化应用,但是有关的研究和发展从未间断。而软件行业发展到今天,由于顺序式编程语言在解决一些特定问题时会引起程序复杂度倍增和可靠程度降低等问题,人们的视角渐渐投向了函数式编程语言,这使得函数式编程语言在逐步兴起。

函数式编程语言的诞生可以追溯到20世纪50年代的Lisp。那时的计算机处理器不是很强大,函数式编程语言程序的效率要差一些,所以在当时一直没有变成主流。而如今的处理器速度的提升以及编译器的优化,使得函数式编程语言运行的效率已经不再是一个困扰性的问题。可是,顺序式语言也在发展,并且还加入了很多新的吸引人的特性,在软件工程的发展中占据了绝对的优势。即便这样,函数式编程仍然有着其他语言不可能有的优势,所以它的热度在一点一点地上升,比如微软公司就增强了自家.NET Framework 的函数式编程语言F#的功能,并且也能在除Windows之外的其他操作系统上使用。除此之外,Java、C++、C#在新的版本中也引入了 λ 表达式,这个想法其实就是源于函数式编程的。其他的语言(如 Python、Ruby、Scala等)中同样可以看到函数式编程思想的体现。

函数式编程语言有哪些呢?函数式编程语言其实也是一个很大的家族,它是一类语言,就像命令式编程语言并不单单指C语言或Java一样,一些常见的函数式编程语言有:Lisp[1]、Scheme、Ocaml、ML(Meta-Language)、Coq[2]、Erlang[3]、Haskell、Agda[4]、F#、Clojure[5]等。函数式语言表达程序十分精炼,但是这些并不说明像Java这样面向对象的顺序式编程语言冗长,各种语言在计算机发展进程中百花齐放,各有各的长处与用途。面向对象语言有很大的优势,各种设计模式在商业开发的路上也发展得非常成熟,而函数式编程的优势在于程序的严谨与可靠性,程序正确性的证明与测试时的简易性,另外,还有开发周期相对短,编写并发程序十分简洁且运行稳定。函数式编程的应用虽然在很长时间内都处于不温不火的状态,但它们的用途却非常广泛,常见的领域有人工智能、定理证明、无线通信、金融数据分析系统等。

另外值得一提的是,在Matlab和Wolfram Mathematica 这类理工科、工程学软件中,也常常能见到函数式编程的身影与思想的体现,很多基本的函数应用其实都是基于函数式编程的思想。例如,在《Mathematica Cookbook》一书中,第2章讲的便是Functional Programming,即函数式编程,并且书中说学习函数式编程是掌握 Mathematica 最重要的部分。所以,学习函数式编程不仅仅是学习一种新的编程语言,而是在更好地体会另外一种编程思想,这种编程思想在日后的学习与工作中可能会尤为重要。

本书通过 Haskell 这样一门语法经过精心设计和锤炼的纯函数式编程语言来讲解函数式编程的方法与思想。虽然很多例子都是解决数学与算法问题的,但也有一些例子是对Haskell在实际当中应用的展示,旨在说明函数式编程语言不单单是另外一种编程方式或者只是在教学与研究中使用的语言,同时也是一门可以解决现实编程问题的、非常务实的编程语言。Haskell实现了很多软件中的精品,如窗口管理器XMonad、Perl 6的Haskell 实现Pugs 以及高性能的网页框架Yesod、Snap等。

书中的例子都尽量保证以短小为主,以方便读者根据需要做跳跃、选择性的阅读。前半部分将简单介绍函数式编程在解决数学与算法问题时精简与直观的特色,使一些不熟悉 Haskell的读者对其建立初步的了解,通过解决一些数学算法问题,比如斐波那契数列、八皇后问题、排序问题、24点等,引发一些对这种新的编程方式的思考。

中间部分则是一些关于Haskell略为深入的介绍,包括函子、monoid、IO与monad转换器等。其中,monad的概念在Haskell中十分重要,所以建议读者可以花相对比较长的时间来实践、理解并做一些其他相关的阅读。这里值得说明的是,由于Haskell语言的特殊性,笔者将处理输入与输出的内容安排在了中间这一部分,也就是说,在第11章以前,读者将不会见到可在操作系统上直接运行的程序。

这本书最后几章的主题则是关于快速测试、惰性求值还有并行与并发编程的。这几章的内容相对零散,但是都讨论的是关于Haskell的非常重要的内容。其中,快速测试一章将讨论如何使用 QuickCheck 库来测试函数的正确性。测试这一课题在软件开发中非常重要而且是比较困难的,而我们会看到Haskell却能以一种较好的方式处理。惰性求值是Haskell最重要的特性之一,也就是说,在函数估值计算时,仅仅计算需要计算的部分。这在某些情况下是个好主意,但是在另外一些情形下可能会严重降低程序的效率,所以了解它并且正确地用好它很重要。最后一章将讨论如何在Haskell中写可以并发运行的函数。由于Haskell的纯函数特性,它可以随意地并发多个线程来计算纯函数,因为纯函数计算不会互相干扰。当需要共享变量时,可以使用另外一种非常好的处理并发的机制就是软件事务内存(STM)。经过多年的研究,STM 已成为了一种使用简单、运行高效、易于扩展的处理并发计算的方式。

有些章节后会有一些开放性的思考题,但并不是很难,读者可以试着独立完成,也可以在Haskell的中文论坛上与其他读者讨论,得到解决问题的思路或者更好的解决方法。

书的大部分是由笔者在课余时间以及假期完成的,但其中部分章节是由来自安徽的中国科技大学不愿透露姓名的博士朋友执笔撰写的。这些部分是9.5节的定长列表以及9.6节的运行时重载,还有11.3节中的实现printf函数。但是笔者对其原稿的一些内容做了适当的调整与修改,以保证全书风格的一致和内容的连贯与顺畅,在此感谢他的帮助与支持。

如果读者没有学过任何编程语言,那么以Haskell函数式语言作为第一语言就不必对思维进行转换了,我想说你非常幸运。如果读者有一些其他函数式编程语言的基础,那么想再了解一下Haskell那就再好不过了。了解其他顺序式语言的读者在学习Haskell时需要注意,由于Haskell中是没有内存变量和循环的,因此不会出现变量的赋值、循环。所以,如果读者已经深谙C语言或者Java等顺序式编程之道的话,那么在开始学习函数式编程时可能会产生一些不适应,毕竟没有for、while循环还有内存变量的编程方式不太容易一开始就能让熟练使用C语言与Java的人所接受。但是,一旦适应了Haskell函数式编程的这种方式,读者可能会发现这种编程方式的效率是相当高的,也有助于看清与理解程序算法的本质。另外,值得再次指出的是,由于Haskell的纯函数特性,输入/输出是在IO Monad中处理的,所以笔者决定等读者对Monad有了一定的了解之后再讨论,因此,在学习IO Monad以前,只能在解释器(GHCi)中运行程序。有些读者可能会因此感觉单调,但是了解了IO Monad以后,读者会发现这种设计是很有道理的。此外,有一些理论部分可能看上去有些枯燥,读者也没有在其他编程语言中涉及过,并且乍看上去感觉并没有什么用途,但是在编程实践中却恰恰相反,所以读者一定要看懂理论部分,让理论来指导编程实践。

相信读者已经感觉到本书虽然是用中文编写,但在阅读的过程当中却少不了谈论一些英文术语。有些术语是无法生硬地一对一翻译的,所以笔者给出了自己的翻译的同时保留了其英文术语,也有的术语并未做出任何翻译,因为生硬的翻译并不会帮助读者理解,所以保留了原有的英文,以便读者参阅相关的英文资料。

本书编写的目的是希望可以让越来越多的计算机专业的同学以及其他计算机行业的人只需要花费相对较少的时间就对函数式编程有一定的了解,同时也希望能够达到抛砖引玉的效果。因为本书的编写是从笔者自己对Haskell和函数式编程了解不多之时开始的,希望这能成为本书的优点,因为非常熟悉了解Haskell的人可能很难再以一个初学者的角度来阐述它。当然,这样的缺点则使书的内容会局限于我对Haskell的理解程度。如果读者对有些内容有更深入的理解或者认为书中有解释不合理或者内容性的错误,欢迎发邮件同我探讨。最后,当然无论作者与编辑多么努力,总还是会有一些错误藏匿书中,希望广大读者指出。

读者有任何问题欢迎通过Haskell.Zhang.Song@hotmail.com与我联系。

张淞

2013年10月于诺丁汉

注 释

[1].被认为是首个函数式编程语言,其他函数式编程语言的鼻祖,1958 由斯坦福大学教授John McCarthy开发。

[2].基于Ocaml,主要用于辅助定理证明,是一个证明辅助工具(proof assistant)。

[3].Erlang 是瑞典爱立信计算机实验室为开发实时、高并发、分布式系统还有提高软件安全与稳定性而研发的语言。

[4].Agda是基于Haskell 的一门依赖类型的函数式编程语言,与Coq 相似,也是主要用于辅助定义证明的工具。

[5].Clojure 为Lisp 的变种,由Rich Hickey 开发,可运行于Java虚拟机之上,具有良好的可移植性。

致谢

在这里,我想衷心地感谢诺丁汉大学和那些曾经无私地帮助过我的人们。正因为有你们的关怀与教导才使我学到越来越多关于计算机科学的知识,让我看到了计算机科学的前景并意识到了在中国有关函数式编程中文书籍的缺乏,致使我萌生了写一本关于函数式编程的书的念头,正是由于你们不断地支持我,最后促使我完成这本书的编写。

首先感谢Graham Hutton教授在我学习高级函数式编程课的时候给予我的详细讲解和对我本科的Haskell程序毕业设计的悉心指导。

感谢JP Moresmau 先生对EclipseFP 的无偿开发与在EclipseFP Github 论坛上对EclipseFP的使用中出现的问题给予的耐心解决。

这里,我想特别感谢 Nilsson Henrik 教授在百忙之中抽出时间对本书撰写的关注、支持,以及对我在编写本书中遇到的疑问的解答。是您的编译原理和编程基础两门课让我对 Haskell与函数式编程有了更为深入的了解和认识。同时,我也非常荣幸能经常与您在诺丁汉大学Jubilee校园里的Amenities Cafe一起讨论函数式编程和Haskell。

感谢我的私人导师Roland Backhouse教授在算法课上对于一些算法构造的细致讲解,以及对于我提出的有关算法构造与复杂度问题的耐心解答,也谢谢您在生活上给予我的帮助。此外,也感谢您为我研究生申请提供的推荐信,成为您的学生我感到无比的荣幸。

感谢Thomas Anberree讲师的函数式编程课,正是这门课使我了解了一种全新的编程方式。此外,我很感激您对学习函数式编程有困难的学生进行了课外辅导并且给出了更多的练习题,还有您在教师公寓楼下单独为我讲解复合函数的类型。

此外,我十分感谢Roland Backhouse 教授的学生陈炜博士,感谢您在大学课后组织的数学和算法问题的讨论课,虽然范围很宽松,涉及排列组合、命题逻辑证明、机器学习和RSA加密等,但是让我学到了课堂以外的更多内容。同时,我也十分高兴在平时常常能有机会和您一起运动和讨论问题。

感谢Haskell中文论坛http://www.haskellcn.org的创始人吴海生为喜欢Haskell与函数式编程的朋友提供了一个非常好的平台。

感谢来自中国科技大学的朋友在博士在读期间帮忙编写函数响应式编程等章节,虽然函数响应式编程一章由于结构与内容的问题最终未出现在本书中,但还是特别感谢。

最后,我还想感谢曾经教过我的老师们,他们是费继娟老师、李丹老师、李加福老师和于华民老师。谢谢您们给我的关心与帮助,伴我一点一滴地从一个孩子成长起来。谢谢您们带我走入这个充满挑战的学科,让我踏上了一条充满奇幻色彩的路。

第1章 Haskell简介

本章首先介绍 Haskell 相关的历史,从 Lisp 诞生到多种函数式编程语言百花齐放,再到Haskell诞生的过程和现在发展的概况;接下来讲解安装Haskell编译器和编写Haskell程序所需要的软件,以及调试与测试函数的工具GHCi;然后介绍Haskell中定义的两种源代码文件(一个是以lhs为扩展名,另外一个是以hs为扩展名)有着怎样的不同;最后编写一个Helloworld程序当做学习Haskell旅程的第一步。

1.1 Haskell的由来

要讲述Haskell的由来还要从函数式编程的诞生说起。函数式编程有着非常悠久的历史,比C语言还要久远。20世纪30年代,美国数学家Alonzo Church引入了λ演算(Lambda Calculus),这是一个通过使用符号表达变量的绑定和替换来定义函数计算的系统,它是函数式编程语言的重要基石。也就是说,早在电子计算机还没有诞生的20世纪30年代,函数式编程语言就已经在孕育之中了。

1958年,斯坦福大学的教授John McCarthy受卡内基梅隆大学开发的一个名为IPL(Information Processing Language)语言的影响,开发了一个名为Lisp 的函数式编程语言。虽然IPL 并不是严格意义上的函数式编程语言,但是它已经有了一些基本思想,如列表内包、高阶函数等,本书会在后面的章节中依次做介绍。这些特性深深地影响了Lisp的设计。在Lisp诞生之后,越来越多的人开始加入到函数式编程的阵营中来。同时,Lisp的诞生也影响了很多编程语言的设计,时至今日,仍然有相当一部分人在使用Lisp。

在20世纪60年代,牛津大学的Peter Landin和Christopher Strachey明确了λ演算对函数式编程具有极高的重要性,并于1966年开发出了一个名为ISWIM(If you See What I Mean)的函数式编程语言,这是一个基于λ演算的纯函数式编程语言,一定程度上奠定了函数式编程语言设计的基础。

函数式编程发展到20世纪70年代早期的时候,爱丁堡大学的Rod Burstall和John Darlington使用函数式编程语言通过模式匹配来进行程序变换,并且明确地引入了列表内包的语法来更好地生成列表。几乎在同一时期,David Tuner 教授在英国圣安德鲁斯大学,又开发了一个名为SASL(St Andrews Static Language)的纯函数式编程语言,一定基础上奠定了圣安德鲁斯大学在函数式编程领域研究的地位。

1975年,麻省理工学院的Gerry Sussman与Guy Steele开发了Scheme,这是一个更接近λ演算的语言,广泛应用于实践编程与教学中。如今,Scheme仍然被很多高校作为首要的课程来讲解。

不久,美国计算机科学家John Backus致力于函数式编程的研究,开发了一种名为FP的函数式编程语言。他引入了BNF符号系统以及对语言编译器系统的开发做出了突出的贡献,于1977年获得了图灵奖——他著名的图灵奖报告《编程是否能从冯诺依曼的体系风格中解放出来》(Can Programming be Liberated from the von Neumann Style?)[1]中提到的“解放”的方式,实际上指的就是函数式编程。

也几乎是在同一时期,剑桥大学的 Robin Milner 开发了 ML(Meta-Language),并发展出了多态类型系统——HM-System(Hindley-Milner type system),其中包括类型推断以及类型安全与异常处理机制。这个语言中的多态类型系统对函数式编程的发展意义重大,值得一说的是, Haskell使用的正是这种类型系统。

Scheme与ML都具有一定的顺序式语言的特性,但是这些语言改善了函数式编程的风格并且明确地引入了高阶函数的概念。20世纪70年代末与80年代初,惰性求值这一概念被重新被发展。SASL在1976 年加入了惰性求值的功能,也出现了Lazy ML,即ML 的惰性版本。在程序的运行过程中,惰性求值可使计算机仅仅计算需要的数据。

在近几十年的时间里,各个大学的学者们通过对函数式编程的研究,涌现出了很多基于函数式编程的理论。当然,与此同时还出现了很多不同的函数式编程语言。1987年,在美国俄勒冈州举行的函数式编程与计算机结构的会议(Functional Programming and Computer Architecture Conference,FPCA)上,与会者们就当时函数式编程语言种类过多、语法相似且大多数效率不高的现状进行了讨论。他们认为,这样下去的结果将会是越来越多的人研究和使用函数式编程语言,但是人们所使用的语言却得不到很好的统一。这种情形不利于函数式编程的研究、应用与发展。于是会议决定设计一个开源、免费、语法简单、运行稳定、效率高,可适用于函数式编程教学、研究,并且可编写应用软件的纯函数式编程语言来缓解函数式编程语言过多的混乱的局面,它的名字就是Haskell。它的命名源于美国数学家Haskell Brooks Curry,为纪念他在λ演算与组合逻辑(combinatory logic)方面作出的突出贡献。Haskell Curry使得函数式编程语言的设计在理论上有了非常坚实的基础。由此可见,Haskell是函数式编程发展了近60年的结晶,汇集了其他函数式编程语言的精华于一身,经过了20余年的发展,如今还在不断地壮大。

Haskell仅仅意为一门计算机编程语言,语言标准的1.0版本于1990年发布,语法的主要设计者之一是现任职于微软剑桥研究院的Simon Peyton Jones[2]教授。Haskell这门语言的编译器有很多种,其中GHC(Glasgow Haskell Compiler)是最常用的Haskell语言的编译器,名为哥拉斯哥是因为该编译器最初是基于Kevin Hammond教授在英国哥拉斯哥大学于1989年用Lazy ML所编写的原型。一年后,在Simon Peyton Jones的带领下,这个原型除分析器(parser)的部分全部被重写。第一个GHC的测试版于1991年4月1日发布,同时GHC也被作为了英国学术研究项目之一并且开始接受英国政府的资金支持。除GHC外,Haskell还有很多其他的编译器,比如由荷兰乌特勒克大学编写的UHC(Utrecht Haskell Compiler),由John Meacham所编写的JHC等。随着发展,Haskell语言的标准也开始发生变化,越来越多的特性与功能被逐渐加进来,两个里程碑的版本分别是Haskell 98和Haskell 2010。本书的大部分代码是符合Haskell 98 标准的,但有一部分用到了Haskell 2010 中增加的内容。读者可以参阅Haskell 98 report[3]和Haskell 2010 Report[4]来了解Haskell语法标准。

如果读者想要了解更多关于Haskell 的历史,可以阅读由Paul Hudak 和John Hughes 等人写的《A History of Haskell: Being Lazy With Class》,文章的在线链接是http://research.microsoft.om/en-us/um/people/simonpj/papers/history-of-haskell/history.pdf,还有(Hutton,2007)的 1.4 节与 (O’Sullivan et al,2009)中的前言部分。

1.2 Haskell编译器的安装以及编写环境

目前Haskell的主要编译器是GHC,它可以将写的好程序编译后直接运行。但在教学中常常使用Haskell的两个解释器,它们是Hugs与GHCi(Glasgow Haskell Compiler interpreter),这里使用的是GHCi。GHCi可以解析、调式Haskell程序而不必每一次都重新编译来测试代码,这在调式与测试代码时是一个非常大的优势。GHCi是GHC的一部分, GHC可以在http://www.haskell.org/ghc/下载到。这里推荐大家下载Haskell Platform(HP),它包含了所有开发所需要的工具,可以在http://www.haskell.org/platform/下载到。

Hugs是一个遵循Haskell 98 语言标准的解释器。由于Hugs不能将程序编译成可执行文件,也没有丰富的库函数,所以它很轻巧,适用于入门教学。Hugs 的下载地址是 http://cvs.haskell.org/Hugs/pages/downloading.htm。

WinGHCi程序窗口如图1-1所示。Notepad++文本编辑器窗口如图1-2所示。

读者可以使用自己喜欢的任何编辑器,在Windows下,笔者喜欢使用Notepad++,见图1-2。这是一个非常轻巧的编辑器,可以在http://notepad-plus-plus.org/download下载到。Notepad++可以高亮显示Haskell中的关键字,看起来更加舒服,当选取了Haskell模式时还会默认保存为.hs文件。Windows 下的记事本以及Linux或Mac OS下的sublime、emacs、vim、gedit都是非常好用的文本编辑器,并且很多也提供Haskell插件与设置。但是需要注意的是,由于Haskell代码的缩进与对齐有时非常重要,有的文本编辑器会让人将Tab与空格等字符搞混而引发一些错误。当然,正如其他语言一样,Haskell也有一些非常好的集成开发环境,如用Haskell编写的Haskell集成开发环境Leksah(Haskel的逆写),可以在http://leksah.org/下载。笔者更喜欢用EclipseFP,一个基于Eclipse的开源Haskell插件,具体安装可以详见http://eclipsefp.github.com/。

图1-1 WinGHCi程序窗口
图1-2 Notepad++文本编辑器窗口

1.3 GHCi 的使用

GHCi是一个对函数进行测试与调式的工具,可以导入Haskell源代码文件,然后调用其中的函数、查看函数的信息等。本节先学习如何使用 GHCi 中的命令来对文件和库进行导入等,再来了解如何在GHCi中调用函数。

启动GHCi后可以看到GHCi的版本,还有导入的库等,可以不用管它们,最后一行会有一个Prelude>提示符,其中Prelude指的是GHCi在运行时一个默认的初始环境。它是一个定义了很多类型与函数的库。启动GHCi后,用户可以不做任何设置而直接使用其中定义的内容。下面来看一下GHCi中的一些命令。

1.3.1 GHCi 中的命令

下面介绍一些常用的 GHCi 命令,学习如何导入代码文件和库模块,以及如何改变 GHCi的当前路径等。

■ :load:简写为:l,用来导入当前路径或者指定路径下的文件,但在Windows下要注意使用转义的反斜杠。比如,导入作者桌面上HelloWrold文件夹下的HelloWorld.hs,WinGHCi的用户可以直接使用打开按钮来打开程序文件。

Prelude>:l "C:\\Users\\User\\Desktop\\HelloWorld\\HelloWorld.hs"

■ :reload:简写为:r,用来重新导入当前的源代码文件。通常,在保存了源文件后,GHCi不会自动重新导入修改后的文件,用户可以很方便地使用:r来重新导入。WinGHCi的用户可以使用刷新按钮来重新导入程序文件。

■ :cd:改变当前GHCi的路径。这样做就不用每一次都输入绝对路径来导入文件了。例如: Prelude>:cd C:\Users\User\Desktop

■ :edit:用默认的文本编辑器编辑当前导入的文件。如果使用GHCi,它会读取系统环境变量中的EDITOR ,启动相应的编辑器。如果读者使用的是Hugs ,则需要设置HUGSFLAG环境变量来使得Hugs可以启动对应的文本编辑器。更多信息可以参阅Hugs用户手册3.1节,可以浏览http://cvs.haskell.org/Hugs/pages/users-guide/。

■ :module:导入一个库,简写为:m。使用:m +<module>与:m – <module>来增加与移除不同的模块。在后面会具体介绍如何使用这个命令。

■ :quit:退出GHCi。

■ :?:可以让GHCi输出帮助信息。

当然,GHCi 的命令还有很多,本书将在后面的章节再做介绍。这里约定:若没有特别说明,则GHCi指的就是WinGHCi,而不是命令行的下的GHCi。

1.3.2 在GHCi 中调用函数

很多数值比如整数、小数还有一些四则运算的函数都已经在上节中提过的 Prelude 初始环境中定义好了,所以可以直接使用。由于在 Prelude 中定义了各种数学运算符号,因此 GHCi可以当做一个计算器来使用。比如:

>4+6*7/3

18.0

此外还有自然对数函数、三角函数及圆周率π等。

> log 2.71828

0.999999327347282

> sin (pi/3) / cos (pi/3)

1.7320508075688767

> tan (pi/3)

1.7320508075688767

除数字的类型以外,Prelude中还定义了布尔类型,这种类型只有True与False两个值,表示真与假。Prelude中也定义了基于布尔值的运算符,读者可以直接用&&运算符号对布尔值做逻辑与运算。例如:

> True && False

False

除了逻辑与运算&&外,Prelude中还提供了逻辑或运算符||,用户可以在GHCi中测试这个函数。

Prelude中还提供非常实用的容器——列表。有了它就可以很灵活地对值进行存储和使用相关的函数。[1..4]表示遍历整数1~4,即[1,2,3,4]。

> [1..4]

[1,2,3,4]

sum是一个可以对列表中的数值进行求和的函数。也就是说,给定一个列表sum,会求得该列表中所有元素的和。比如:

> sum [1..4]

10

Prelude中的product函数可以求得一个列表的所有元素的乘积,读者可以在GHCi中计算[1..4]的乘积。

如果想引用之前调用的函数所计算的结果,可以使用it。比如,计算了 1~4 之间的整数之和后想再加100可以写为:

> it + 100

110

因为it在GHCi中可以指代前一次函数计算的结果,所以在定义函数还有测试时不要使用it作为函数或者变量的名称。

最后,约定如下:如果书中只用>符号,然后调用函数或者输入GHCi命令,则表示在GHCi的提示符中的操作,而C:\>则是系统命令行的提示符。

1.4 .hs 和.lhs文件、注释与库函数

GHCi和Hugs可以解析扩展名为.hs和.lhs的文件。两者所写程序在语法上完全相同,它们的差别是.lhs(literate Haskell script)文件是为了能让Haskell 的代码生成文档。有效程序代码可以用“大于号(>)”和空格开头。比如:

> add :: Int -> Int -> Int

> add x y = x + y

不以大于号和空格开头的内容将被视作注释处理,且注释与代码间必须有一行以上的间隔。还有一些.lhs代码文件中的代码以\begin{code}以\end{code}结尾——如果.lhs文件遵守一定的格式,就可以使用lhs2tex生成非常精美的文档以供人们阅读。如何使用读者可以参阅http:// www.andres-loeh.de/lhs2tex/。

使用.lhs文件书写代码的优点很明显。函数式编程的代码往往蕴含了编写者很大的思考量,需要用大段的注释进行解释说明,而代码又不是特别长,这个时候用.lhs 最好不过了——哪部分是代码、哪部分是注释一目了然,还能生成.pdf文件方便阅读与传播。

但是,当不需要多行注释还有生成文档的时候就可以用扩展名为.hs的文件。.hs文件里全局的函数要起头写,不可以有其他字符;单行的注释用两个减号(--)开头,多行注释用“{-”开头,以“-}”结尾。全书对编写的函数有非常多的阐述与解释,并不包括在源代码中,所以用.hs文件就可以了。有时需要在文件头处对GHC与GHCi声明一些编译器参数,此时需要在文件首处定义,并且以“{-#”开头,用“#-}”结尾。在后面的章节中,再具体学习使用编译器参数。

在启动 GHCi 的时候,Prelude 中的一些预加载函数已经被导入了。Prelude 里有很多常用的函数以及数据。安装hugs 的读者可以到C:\Program Files\WinHugs\packages\ hugsbase\ Hugs下找到Prelude.hs库函数文件,有兴趣的读者可以打开prelude或其他文件浏览代码。使用GHCi的读者可以在开始菜单的程序中找到Haskell的网页文档,打开Library链接,找到Prelude,再点击source查看。读者可以通过查看这些源文件来感受Haskell程序的风格。

Haskell的函数库是非常强大的。在发展的多年过程中,有很多库可供用户直接使用,也有像Java一样的API可以查阅。WinGHCi的用户一样可以在开始菜单程序中网页文档中找到,也可以在http://www.haskell.org/ghc/docs/latest/html/libraries在线浏览。第4章将会对几个库函数使用做简要的介绍。

1.5 第一个Haskell程序HelloWorld!

HelloWorld 程序虽然不能完全展示 Haskell 编程的风格与优势,但学习计算机编程都是从这里开始的。现在写一个HelloWorld程序,当作开始学习Haskell的第一步吧!

文本编辑器中输入:

main = putStrLn "Hello,World!"

保存并命名为Helloworld.hs。

这里可以看到,Haskell和C、Java一样,都以一个名叫main的函数作为程序的开始运行的入口。保存代码文件,打开命令行窗口,即“开始”→“运行”,输入cmd,按Enter键。在命令行中把目录改变到HelloWorld.hs所在的位置,输入ghc HelloWorld.hs。这时,得到了一个可执行的HelloWorld.exe文件,运行该文件就可以了。

如果读者只想在命令行中运行程序而不想进行编译,可以使用runghc命令:

C:\> runghc Helloworld.hs

Hello,World!

从GHCi中打开这个文件,在GHCi命令行里输入main也可以运行。除这个例子外,第 11章之前的部分都在讨论纯函数,所有的函数均需要在GHCi中运行。因为Haskell处理输入/输出的特殊性,所以只有到第 11章讨论输入/输出时读者才能写一些与操作系统互动的程序。为方便广大读者测试代码,本书的部分代码可以在 GitHub 下载到,同时还有对书中错误的更正,地址是 https://github.com/ HaskellZhangSong/Introduction_to_Haskell。

本章小结

学习本章后,相信读者对Haskell应该有了大致的了解。GHCi是一个常用的测试代码的工具,希望读者可以花更多的时间来熟悉。细心的读者可能会发现Haskell与C语言编译后可执行文件的大小有很大差异。其实,Haskell使用内存空间和硬盘空间的效率是有些低的,这也是早期函数式编程没有比C一类的语言更流行的原因之一。但是,如今计算机硬件已经发展到内存和硬盘不会像以前那样限制函数式编程语言能力了,在时间和空间的效率上也可以手动或自动调试优化。因此,相信在不久的未来,函数式编程会以它精炼、缜密的代码与安全、可靠的运行渐渐受到更多人的青睐。

注 释

[1].链接http://www.thocp.net/biographies/papers/backus_turingaward_lecture.pdf。

[2].Jones 教授的主页:http://research.microsoft.com/en-us/people/simonpj/。

[3].http://www.haskell.org/onlinereport/

[4].http://www.haskell.org/onlinereport/haskell2010/

第2章 类型系统和函数

本章来简单了解一下Haskell中的类型系统与函数。用户操作的数值在计算机看来是没有任何意义的,它们只是一串由0与1组成的二进制数。而对于高级程序语言来说,需要将这些二进制数分成不同的类别以便处理,在计算机语言中就称为类型(type)。一个类型下可能会有一些数据:可以有1个值,也可以有多个值;可以是无限的,也可以是有限的;在Haskell中,甚至还可以为空,即没有值。整数类型下有-7、11、98等,布尔类型下面只有True和False两个值。Haskell不但对这些二进制的值进行了类型的分类还对不同类型进行了整理,将有着共同属性的类型归为一种特定的类型类(typeclass),意为类型的类别。这里以布尔类型与整数类型为例,它们就有很多共同的属性。首先,它们都可以比较相等,如判定1与2是否相等,还可以判定True与True是否相等。而在Haskell中,可以判定一个类型的两个值是否可以相等就需要用到相等类型类Eq。又如整数与布尔值都是可以打印在命令行上的,这就是因为Prelude预加载库中定义了可显示类型类Show。也就是说,整数类型与布尔类型都是归于相等类型类Eq还有可显示类型类Show的。

对于Haskell的类型系统有了初步的了解以后,将会学习如何用Haskell中的各种表达式来定义函数。但在真正用表达式定义函数之前,需要了解函数与类型系统之间的关系,它们实际上并不是分离的,而是骨肉相连的。其中,函数的类型是“骨”,而函数的定义是“肉”,了解它们是如何相辅相成的,在今后的学习与编写Haskell程序中至关重要。

2.1 Haskell的类型与数据

2.1.1 Haskell 常用数据类型

Haskell是一种有着强类型(strong type)的语言,每一个数据和每一个函数都要有非常精确的类型,这使得函数的定义更为严格。在GHCi的提示符里,可以输入:type(可简写为:t)命令来查看一个数据的类型或者一个函数的类型(读者将体会到,在Haskell中,数据与函数是没有本质区别的,函数只是数据的一种)。下面介绍一下Haskell中常用的类型。

1.布尔类型:Bool

布尔类型是一个只有True与False两个值的数据类型。细心的读者可能已经发现,数据类型的首字母都是大写的。这里说明一下,Haskell中约定类型的名字与该类型的数据的首字母要大写(当然,数字的类型除外)。布尔值的运算符号和其他语言相似,&&表示“逻辑与”运算,||表示“逻辑或”运算,not表示“逻辑非”运算。例如,在GHCi里输入:

>True||False

True

>not True

False

可以使用:t来查看True的类型:

>:t True

True :: Bool

GHCi给了正确的答案,意思是说True有着Bool类型。

2.字符型:Char

键盘上的字符都是Char类型了,这一类型与其他语言是一致的。例如,在GHCi里输入:

>'a'

'a'

>:t 'a'

'a' :: Char

意思是说'a'是一个具有Char类型的数据。除此之外,还可以使用反斜杠与ASCII码的值来表示一个字符,比如从97~122为英文小写字母a~z,从65~90为英文字母A~Z。

> '\100'

'd'

有一些ASCII码用于控制输入和中断等,比如换行符\n、Tab \t,还有退出键\ESC。

常见的转义字符如表2-1所示。

比如,输出"\\",那么只会得到一个\,因为第一个反斜杠表示为将后面反斜杠的功能去掉。

表2-1 常用转义字符

> putStrLn "\\"

\

再比如,\&为空字符:

> putStrLn "abc\&def"

abcdef

3.有符号整数:Int

Int几乎是所有的计算机语言里都有的数据类型。它的范围与操作系统与GHC位数有关。这里使用的是32位的操作系统与GHC,故整数的范围是−231~231−1。如果在GHCi里输入:

> 2^32::Int

0

结果是0,相当于在231−1后转回到了−231,最后又回到了0。对于64位GHC来说Int的范围则是−263~263−1,所以2^64::Int,结果是0。::Int的意思是让Haskell把264作为整数处理。为什么一定要声明为整数呢?因为Haskell里还有另外一个整数类型——任意精度整数,如果不指明类型,Haskell会将232默认为任意精度整数处理。

4.无符号整数:Word

Word类型是无符号的整数,它的范围也是系统相关的。在32位的系统中,它的范围是0~232−1而64 位系统则为0~264−1。Haskell中的Word相当于C或者Java里的unsign int类型。使用Word类型需要导入Data.Word库,在GHCi中可以使用:module(简写为:m)来控制库的导入。

>:m +Data.Word

>-1::Word

4294967295

这样可以看到,虽然需要输入−1,但−1对于无符号的Word类型来说是4294967295,也就是232−1。

>:m –Data.Word可以移除不需要的库。

5.任意精度整数:Integer

与Int不同,Integer类型可以表示任意大小的整数,限制它的大小范围的唯一因素就是计算机的硬件。可以使用一个简单的product函数来试验一下, 在GHCi里输入:

> product [1..10000]

结果将会是一个很大很大的数字,它是1~10000所有整数的乘积,也就是10000的阶乘。再比如,输入:

>2^32::Integer

GHCi会给出正确的结果,即便用户不指定类型为Integer,GHCi也会默认2^32为此类型。其他的编程语言像Java需要用一些现有的类库来实现,而Haskell却很好地解决了这个问题。但付出的代价就是处理Integer的效率不如Int高。可是在处理一些数学问题,如RSA加密、大数字的运算等时,这会非常方便。

除了Int、Word与Integer,在Data.Int库中,Haskell还支持Int8、Int16、Int32和Int64,即8位、16位、32位和64位有符号整数。Haskell的Data.Word中还提供对应的Word8、Word16、Word32和Word64无符号整数,但只有在一些特殊的情况下才会用到。

6.小数与有理数类型:Float、Double、Rational

Haskell中的单精度浮点小数、双精度浮点小数与其他语言没有很大的区别。比如:

> pi :: Float

3.1415927

>pi :: Double

3.141592653589793

除此之外,Haskell还有有理数类型Rational,即用两个任意精度的整数来表示一个小数,这在做高精度数学运算时有很多好处。

> 4.1332::Rational

10333 % 2500

%相当于分数线,2500为分母,10333为分子,这样,一个小数就被转换为了分数。由于分子与分母可以为任意的整数,因此可以用这个类型来处理任意精度的有理数。

7.字符串类型:String

在Haskell里,String是通过其他类型定义的,这一点将在2.1.3节中讨论。String的类型定义为[Char],也就是说String是一个Char的列表。这样的定义也是十分合理的。在GHCi里输入['H', 'e', 'l', 'l', 'o'],会得到"Hello"。使用引号更为方便。

>['H', 'e', 'l', 'l', 'o']

"Hello"

8.元组类型

有时,需要一个数对来表示一些数据,比如,整数坐标的类型可以写做(Int,Int),这就是一个元组(tuple)。元组中的两个整数称为该元组的元件(component)。当函数需要返回两个不同类型的结果时,就可以使用二元元组来存储这个两结果。不但有二元元组,还可有多元元组,多元元组中可以有很多个元件,如(5,True, "Hello"...)。但是,一旦确定了元组中元件个数,就不可以像列表那样伸缩了。同时,每一个元件的类型也不能改变。

对于二元的元组有两个重要的函数,它们是fst与snd。它们会分别返回元组里的第一个元件和返回元组的第二个元件。例如,输入fst (5,True),GHCi会返回5。

> fst (5, True)

5

元组在Haskell里是非常实用的,已是一个非常重要的数据载体。比如,想记录一个学生的名字和学号,就可以用一个以(String, Int)为类型的元组来存储的,它可以是("Li Ming",6509843)。这样的元组很像是数据库里的一个条目,如一个数据库记录着用户名、性别、年龄、电话、地址等。

> ("Ling Ming", "Male", 20, "123456")

("Ling Ming", "Male", 20, "123456")

9.列表类型

这里来深入讨论一下列表类型的使用。列表(list)在Haskell里是一个非常重要的类型,很多函数都会用到。它是一个容器类型,也就是说,可以将各种值放在里边。放入的类型可以是任意的某一类型的值、布尔类型、整数类型,也可以存储函数类型,甚至可以是列表类型本身,但表中的数据类型是唯一的并且不可变的,比如,它不可以既存放字符又存放布尔值。列表在Haskell里是这样定义的:

(1)它可以是一个空的列表,即[];

(2)它可以是一个元素与一另个列表的结合,即x:xs。x是一个元素,xs是一个列表。xs可以是一个有元素的列表,亦可以是一个空的列表。元素与后边的列表用冒号(:)连接。例如:

> 1:[]

[1]

> 1:[2]

[1,2]

这样就得到了整数类型的列表(这样说不是特别的准确,实质上这是一个Num a => [a]类型的列表,Num是一个类型类,我们在2.2 节中介绍类型类)。再比如,输入[True, False],就得到一个布尔类型的列表。

如果输入[]:[],就会返回[[]],这里[]:[]的意思是说,这是一个列表中的列表,即列表里边有一个列表并且里边的列表是空列表:

> []:[]

[[]]

想一下,如果输入[]:[]:[],会返回什么作为结果呢?提示:(:)是右结合的运算,这里输入的其实是[]:([]:[])。

有了列表中的列表这种结构,如[[1,2,3],[3,4,5,1,4],[1,2,4,4]]::[[Int]],就有了一个类似于C或者Java中二维数组一样的结构。

与C与Java中的数组相比Haskell中的列表比起数组更为灵活,因为可以申缩,并且元素个数可以是有限的,还可以是无限的。另外,用户不需要指定它的长度。

读者已经知道,列表相当于一个容器,可以存放任何类型的值。在Haskell中函数与值的区别并不是那么明显的,函数不过是需要参数输入的值,那么列表一样也是可以存储函数的。在C语言中,可以存储函数的指针变量,但这种用法在C语言中并不常见,而在Java中却无法实现。Haksell的列表可以非常自由地存储这些函数,在解决一些问题时非常方便,这也正是Haskell具有极强的表达能力的原因之一。比如,一个存储了加、减、乘函数的列表可以定义如下:

> :t [(+),(-),(*)]

[(+),(-),(*)] :: Num a => [a -> a -> a]

各种的类型实际上是可以互相组合使用的,比如,([Int],[Bool])或[(Int,Bool)],或者再复杂一些的[(Int -> Bool,[String])]。如果需要,也可以存储为由不同类型组成的更为复杂的类型。

2.1.2 函数类型

函数可以理解为从参数到结果的一个映射,比如T1 -> T2。每一个函数都符合这样一个定义,只不过是T1或T2可能代表着更复杂的类型,可能是一些其他特殊的类型,也可能是一些函数类型,T1或T2若为函数,那么T1->T2函数可以称为高阶函数(high order function)。相关内容将在第7章中讲解。但无论是一个什么样的类型,它们都符合函数类型的定义。用户可以来定义一个函数,如整数加法:

add :: (Int, Int) -> Int

add (x,y) = x + y

很明显,加法是需要两个参数的函数。把这两个参数以一个二元元组的形式作为函数的输入,这样会得到一个整数结果。定义中的第 1 行就是这个函数的类型,(Int, Int)−> Int表示给定一个整数元组返回一个整数。第2行是函数定义,即给一个二元元组,其中的值是x与y,那么返回x与y的和。Haskell中的函数定义格式基本如此,在2.4节中将着重介绍如何定义函数,这里仅仅是将函数当做函数类型的值来讨论。Haskell中的函数有着不同的特点,本节将根据不同的特点来做一下简要的介绍。

1.柯里化函数

柯里化函数(curried function)也是一种函数类型。当函数有多个参数时,参数可以一个一个地依次输入,如果参数不足,将返回一个函数作为结果,这样的函数就是柯里化的函数。比如,之前定义过的整数加法,可以在GHCi中定义如下:

let add' x y = x + y::Int

这样的概念一开始可能有一些和直觉相背,但是在数学中常常使用这样的函数。比如,加号可以看做是一个需要两个数作为参数的二元函数,之前的add函数是这样定义的:f(x,y)=x+y。现在,如果已经给定第一个参数,那么希望这个函数就变成一个一元函数f(y)=4+y。使用元组无法做到这一点,add'却可以满足本例的需要,可以看一下add' 4的类型:

> :t add' 4

add' :: Int -> Int

这个类型意为给一个整数会返回一个整数,也就是函数f(x)=4+x的类型。调用柯里化的函数时,如果参数不足,得到的是一个还需要参数的函数。这种类似的思想将在后边的小节中做更充分的解释。到这里,读者应该可以体会到,将参数依次传入到柯里化函数add'与用元组将参数一次性传入的非柯里化(uncurried)函数add,实质上是等价的并且是可以相互转换的,即当一个函数需要多个参数得到结果时,使用一个元组将参数全部一次性在给出,等价于一个一个连续地给出。

如果从非柯里化的add函数想得到对应柯里化的函数,即add (x,y)到add' x y,这个过程叫做柯里化(curry)。

如果有柯里化的函数,想得到对应的非柯里化函数,即add' x y到add (x,y),这个过程叫做非柯里化(uncurry)。Prelude如定义curry与uncurry两个函数,例如:

> :t curry

curry :: ((a, b) -> c) -> a -> b ->c

> :t (curry add) 4

curry add 4 :: Int ->Int

> :t uncurry

uncurry :: (a -> b ->c) -> (a, b) ->c

> (uncurry add')(1,2)

3

柯里化与非柯里化的内容将在后边的章节中做更多的探讨。

2.多态函数

很多函数的参数不一定要有明确具体的类型的。比如,元组类型中使用了fst函数,也介绍了snd函数,它们分别会返回元组中第一个元件和第二个元件。

> fst (1,True)

1

> fst ([1,2,3], False)

[1,2,3]

这时,第一个fst调用后返回的是一个整数类型,而第二个fst返回的则是整数的列表类型。这样,fst函数包括了所有可能的类型,如(Int,Int)-> Int、(Int, Bool)-> Int、([Int],Char)-> [Int]等,本书无法将它们一一地列举出来。像fst这样可以应用在多种类型上的函数就称为多态函数(polymorphic function)。它的类型是(a,b)−> a,意为输入一个由任意的a类型与b类型组成的元组,会返回第一个为结果。

在Haskell中,这样的函数有很多。比如,length是一个得到列表长度的函数,但对于列表里存的是什么类型,这个函数可以完全忽略。比如,在GHCi里输入;

> length [1,2,3,4]

4

> length [True,False,False]

3

这样,在函数的类型中,以一个小写字母开头的名字来代替这些任意的类型,如a、b、c、ok、key等,它们是类型变量(type variable)。例如,length类型可以写为:

length:: [a] -> Int

其中,a只是一个名字,它可以改为任何的类型变量名。多态函数在Haskell十分常见,这里再给出两个例子。第一个例子是head,它可以取得列表的首元素,若列表为空则报错,它的类型是head ::[a]-> a。

> head [1,2,3]

1

第二个例子是将两个列表中的元素一一对应地合在一起,返回一个二元元组的列表的函数zip,读者可以试一下,当一个列表的元素比另一个多时会怎样,如zip :: [a] -> [b] -> [(a,b)]。

> zip [1,2,3] [4,5,6]

[(1,4),(2,5),(4,6)]

3.重载类型函数

在前边的内容中,数字5一直是被当成整数。但是,它还可以是一个任意精度整数,或是一个小数。这样一来,类型上可能会有一些不协调,因为5是一个有着很多类型的值,Haskell中用类型类(typeclass)这一概念来对这样的一些类型做了细致的分类。例如,在GHCi里输入:

>:type 5

5 :: Num a => a

=>是类型类的限定符号,意思是说,5有一个任意的a类型,但这个类型必须限定在一个名为Num的类型类中,即类型a必须是Num类型类的一个类型实例。这里还未介绍类型类,可以将a类型理解为一个数字,这个数字可以是整数,也可以是小数或其他数字类型。只有a符合相应的条件才能使用相应的数学计算函数,如加法。这样,通过对类型做一些限制,使得函数计算得以协调。下节简单讨论一下不同的类型类,在第9章再做进一步探讨。下面来看一下绝对值函数abs的类型:

>:t abs

abs :: Num a => a -> a

GHCi的意思就是说,abs这个函数需要一个实现了Num类型类的类型a。由于abs不论是对小数、整数还是其他类字类型都可以行进绝对值计算,但布尔值、字符串则不可以,因此Haskell引入了类型类的概念限定参数的类型,这样的函数就是重载函数。除Num类型类以外,还有Ord和Show类型类,下节中会一一介绍它们。

很多语言可以自由地支持函数的重载,但在 Haskell 中,函数或运算符的定义是唯一的,不可以出现第二种不同的定义,也就是说,对于每个函数,它的型只有一种。而在Java中,不但参数的类型可以不同,就连参数的个数也可以不同。Java中可以这样做是因为在调用函数时都必须给出全部的参数。如果读者回顾之前的内容就会知道,Java中的函数是非柯里化的函数。而在Haskell中,可以给出部分参数,而以函数作为返回的结果,这使得Haskell不能像Java一样对函数直接进行重载,但是借助类型类我们也可以做类似的事情。例如,在Java中定义:

static int function (int para1,boolean para2) {..}

static int function (int para1,char para2){..}

当使用这些方法的时候,要输入全部的参数。但是,在Haskell里定义如下:

function :: Int -> Bool -> Int

function para1 para2 = ..

function :: Int -> Char-> Int

function para1 para2 = ..

如果在Haskell中也可以这么做的话,那么function 5的类型就有两种结果了:一个是Bool->Int函数,另一个是Char->Int函数。这样,函数的类型就有了不确定性。

Java重载是通过对有不同参数的函数用不同定义实现的,但是它们的名字完全相同。在调用函数时,Java会根据参数的类型与个数匹配到不同的函数,比如print函数打印int、boolean,其实就是用的重载。而Haskell中是使不同的类型实现Show类型类来做到的。对于所有的实现为Show类型类实例的类型都可以使用show函数。

2.1.3 类型的别名

在解决实际问题中,一个数据的类型可以由多个其他的类型组成。这时,需要将这些类型统一命名成其他的名字,这个就是这些类型的别名(type synonym)。比如,一个像素点其实是一组RGB的值,即(Int,Int,Int)(其实这里用Word8更为恰当)。如果用户常常要用到这个类型,一直输入(Int,Int,Int)就显得有些麻烦。同样,对于研究函数的类型也很不方便。若一直用这些复杂的类型来写函数,这个类型的意义非常的不直观,不便于解决实际问题。在Haskell中,可以用type关键字将这些复杂的类型替换成为其他简单的名字,在定义时类型的名字要以大写字母开头。例如:

type RGB = (Int,Int,Int)

这样,Haskell就会将RGB类型自动替换(Int,Int,Int)。一幅图实质上是一个RGB的二维数组,这里可以用列表的列表来表示,即:

type Picture = [[RGB]]

再比如,管理一个图书馆的数据库,一本书在数据库里就是一个类型的条目:(Int, String, String, Int, String)。

看了上面这个类型后,读者可能还不知道哪部分都表示什么。但是,如果用type做一些替换就会好很多:

type ID  = Int

type BookName = String

type Author = String

type ISBN  = Int

type Publisher = String

这样,对于一本书的条目可以定义为(ID, BookName, Author, ISBN, Publisher)。如此一来,定义的类型就有意义多了并且可读性也更强了。然后,可以将这个类型再替换成Book:

type Book = (ID, BookName, Author, ISBN , Publisher)

type关键字只能做一些替换。在定义类型的别名时,它只是将已经有的数据类型组合成一个新的数据类型,并不能构造出新的数据类型。如何定义数据类型将于第8章讨论。

2.1.4 类型的重要性

为什么 Haskell 一定要强制用户使用类型?为什么不支持自由转型?下面讨论一下类型在编程中的重要性。看下面一段简单的程序:

while (x != 0) {

x := x - 1;

}

这段程序所做的事情就是如果x不等于 0,那么x就减少 1。这段小程序会不会停呢?有人说是否停止取决于x的值,如果x是大于0的整数,那么最终x会减小到0;如果是小于0的整数,这段程序不会停止,因为x会越来越小。其实,这段程序会不会停与x的类型有着更多的关系,而不是x的值。如果x为小数,并且小数部分不为 0,这段程序就不会停止。其实,即便小数等于0,是否会停止也与!=运算符如何实现还有与不等号后面的0的类型有关。另外如果x的类型固定精度的整数,如Int、Word,那么这个程序是会停的,因为即便x为负值,只要x小于−231时会再减1就会变为正数,最后程序会停止。但是,如果x为任意精度类型的整数那么情形就不一样了,所以类型的下面往往会隐藏很多信息。

除了上边的例子,在很多的函数中类型都扮有十分重要的角色。很多语言中支持运算符重载比如1 + False,还有1 + "Hello"等,如果不知道这里+的类型,很难揣测+的意思。而Haskell中函数与数据的类型是十分明确的,每个函数都有类型签名,并且编译器会在编译前做类型检查,所以不会出现程序员还要猜测运算符意思的情况。

类型在程序语言中非常重要。在Benjamin 的《Types and Programming》一书中简单阐述了类型系统的三个优点。

(1)错误检查。函数式编程的本质上是给函数输入参数,由计算机来计算并输出结果。每个函数都有特定的输入与输出类型,这样类型系统就是对程序的一种限定。但是,为什么要做出这种限定?因为人是很容易犯错误的,虽然类型检查可以辅助用户检查程序的类型是否正确,但可以通过类型检查并不能说明函数完全没有问题。但是,如果类型不正确,则说明函数一定有问题。事实上,类型系统可以帮助用户检查出大部分的错误。如果读者有使用过汇编或者Linuxbash脚本可能会体会到,汇编语言与bash中变量是无类型的(相比较C与Java而言,初学者很难写出没有问题的脚本),同时,汇编语言与 bash 在运行中出现的错误给出的错误信息也非常难懂(笔者也觉得C与C++的错误信息多数情况下都非常的难懂)。而有了强大的类型系统,用户就可以少犯错误,并且即便程序有错误,类型系统可以辅助用户尽快地找到错误的位置。

(2)对于程序的抽象。在对程序的抽象能力方面,Haskell有着独到的优势,尤其是在大型的系统中,类型系统可以起到十分关键的作用。Haskell中引入的Monad,将一些不同的计算分成了不同的种类,这样在编写程序时,它们的代码不会互相干扰。将各种不同的计算分别独立地抽象出来,最终引导用户得到一个更好的设计,在后面的编程中会慢慢体会到这一点。

(3)文档作用。类型签名不但可以辅助定义函数时,同时还会增加程序代码的可读性,作为补充文档便于软件维护,这在如今的软件开发过程中是至关重要的。

另外,类型还可以在团队开发时作为程序员讨论的媒介,现实开发中可以把问题分割成多种类型不同的函数,再分配给不同的程序员编写,此时函类型的功能就类似于其他语言中使用的接口,这样所有人定义的函数最终会被很容易地组合起来,完成所需要的工作。

2.2 Haskell中的类型类

Haskell给很多“类型”分成了“类型类”,归为一类的类型有着共同的属性,不同类型所归的类就称为类型类。类型有着某种属性,意为该类型可以实现特定的函数,类型通过现实了类型类中声明的函数来成为其中的一份子,具体如何实现将在第9章中深入讨论。布尔类型与整数类型都实现了Eq类型类,即各给定两个布尔类型的数据和整数类型的数据都可以分别比较它们是否相等。例如:

> 5 == 6

False

> True == True

True

这些类型类隐含在已经介绍过的几个类型里。比如相等类型类Eq、数字类型类Num、可显示类型类Show和有序类型类Ord。例如:

> :t 5

5 :: Num a => a

其中,Num就为数字类型类:

> :t (==)

(==) :: Eq a => a -> a -> Bool

类型结果中的Eq所指的就是Eq类型类。又比如,比较大小用到的关系运算符——小于号的类型是:

> :t (<)

(<) :: Ord a => a -> a -> Bool

一个类型中的值是否有序也是一种属性,有序类型类Ord就是规定了这种函数的类型类。Prelude库中的布尔类型实现了有序类型类,因此可以比较True与False的大小,如:

> True > False

True

下面一一介绍Haskell中常用的类型类。

2.2.1 相等类型类:Eq

可以比较是否相等的类型就需要实现相等类型类Eq。用户可以比较两个布尔值是否相等,可以比较整数是否相等,似乎成了顺理成章的事情。但是,实现的时候其实并没有想象的那么容易。有一些类型可以很直观的就看出是如何比较相等的,有些则要用户自己动手来实现。这里读者只需要知道,一旦一个类型实现了相等类型类,就可以比较相等与否。

在GHCi里可以输入:t (==)来查看它的类型:

> :t (==)

(==) :: Eq a => a -> a -> Bool。

实际上(==)是一个函数,它的意思是说,(==)是一个需要任意两个类型为a的值作为参数,然后会返回一个布尔值的为结果的函数,若返回True表示给定的两个参数相等,False表示不相等。但是,所应用的有着类型为a的参数一定要实现相等类型类Eq,否则(==)不知道怎么比较两个值。如Java一样,如果要比较两个对象是否相等,需要在这个类中写equals方法,以后的章节里会提到如何实现类型类。同时,如果一个类型实现了(==)函数,就可以判定其中的两个值是否不相等,即使用/=运算符。它的类型与==是一样的,例如:

> 5 /= 4

True

这样,在定义函数时,如果直接或者间接需要对某一类型使用==或者/=运算,就需要该类型用相等类型类Eq做出限定,也就是使用Eq a => ...。这里间接的意思指我们定义的函数并未使用==或/=,但是该函数调用了一个使用了==或者/=的函数。具体如何用类型类限定一个类型,将会在后面讨论。

2.2.2 有序类型类:Ord

有序类型类囊括的是可以比较大小的类型。Haskell中规定:一个有序类型一定为一个可比较相等的类型。也就是说,Ord是基于Eq的,那么可以比较大小的类型一定可以判定该类型的两个值是否相等。这样才能够使用<、<=、>、>= 等关系运算符。例如,可以比较数字的大小,因为数字是一个有序的类型。数字类型实现了Ord类型类,同时,也可以比较两个数字是否相等。另外一个例子是因为Haskell中的布尔类型实现成了Ord类型类,所以输入True > False,GHCi会返回True。同样的,Char类型、String类型都是有序的。这里,由于Char类型是有序的,因此基于Char类型的列表[Char],即String也是有序的。例如:

> "Hello" < "World"

True

同理,也可以比较整数列表的大小,比较的方式是从首元素开始依次地进行比较:

> [1,2,3] < [2,3,4]

True

通过了解有序类型类Ord,知道了它是基于相等类型类Eq的,也就是说,类型类之间也是有依赖关系的。它们之间的这种依赖关系十分简单,后面会介绍数字类型类Num里复杂的依赖关系。那么,如果需要对一个类型的值进行大小的比较,用到了>、<、>=等运算就需要加这个类型类的限定。

2.2.3 枚举类型类:Enum

有一些类型可以按一定的顺序枚举的。Integer是一个枚举类型,比如,上节里在GHCi里输入product [1..10000],为什么可以输入..来省略中间的数?因为Integer是一个枚举类型,即只要给定范围,Haskell就可以一个一个地对这个类型的数据进行枚举。

> [1..10]

[1,2,3,4,5,6,7,8,9,10]

字符类型也实现了枚举类:

>['a'..'d']

['a', 'b', 'c', 'd']

除..以外,一个类型如果实现了枚举类型类,那么给定一个元素(若这个元素不是最后一个)后总是可以使用枚举类型类中定义的succ函数得到下一个元素。同样地,如果给定一个元素,若这个元素不是第一个,则总是可以使用pred函数得到它的前一个值。

> succ 1

2

> pred 'M'

'L'

2.2.4 有界类型类:Bounded

可以枚举定义的数据往往是有界的,比如Bool、Int都是有最大值和最小值的。布尔类型中只有两个值,一个是True,它是布尔类型中的最大值;另一个是False,它是布尔类型中的最小值。

> maxBound :: Bool

True

> minBound :: Bool

False

此前,已经讨论过Int类型,在32位系统上它的最大值是231−1也就是2 147 483 647,它的最小值是−231,也就是−2 147 483 648。

> maxBound :: Int

2147483647

> minBound :: Int

-2147483648

字符类型也是有界的,有兴趣的读者可以在GHCi中查询一下最大的字符是什么。

2.2.5 数字类型类:Num

数字类型类Num指的就是日常生活中使用的整数、小数、有理数等,Int、Integer、Float,以及Double是数字类型中最基本的类型。Haskell对各种不同的数字做了细致地分解,数字类型类下面分了很多其他类型类,如Real、Fractional等。它们之间的层级结构十分复杂,但是通过图2-1可以让大家理清楚它们之间的关系。这些类型大都被直接定义在了Prelude预加载库中,固定长度的Word与Int类型分别定义在Data.Word与Data.Int中,同时,复数类型Complex还有比值类型Ratio则需要分别从Data.Complex与Data.Ratio中导入。

图2-1 数字相关类型与类型类关系图

图2-1中灰色方框内所示的就是“类型”了,如Float、Integer等。有一些类型还需要其他参数,比如,比例类型Ratio a,还有复数类型Complex a。这种关系用一面是小球的线来表示,是因为该类型中还存在类型变量,这种类型的定义将在第8章中讲解。

不在灰色方框里的表示类型类。类型类间的箭头表示两个类型类之间的依赖关系。例如,实现有序类型类Ord的类型,一定要先实现相等类型类Eq才可以。对于函数的使用将在后边的章节中具体介绍。

类型与类型类间的箭头则说明这个类型直接实现了该类型类。比如,Float与RealFloat之间有箭头,是因为Float类型实现了RealFloat中的一些预先定义好的函数,如truncate函数,它可以把小数取整。上图中每个类型类下面都写了一些该类型类中预定义的函数,读者可以自己试验一下它们的用途。这里仅仅介绍一些常见的类型类,在后边的章节还将定义自己的类型类与数据类型。

整数部分十分好理解,主要是带符号的整数与无符号的整数,它们都实现了整数除法与取余等运算。因为这些整数是实数的一部分,所以实现了Real类型类,而Real类型类又继承了Num类型类,因此它们就都是Num类型类的成员了。比值类型Ratio中需要一个整数类型,根据不同需要,可以用不同的整数来表示它,如Int、Word、Integer,这样它会用给定的两个整数来表示一个比值。它实现了小数Fractional类型类,那么它也间接地是Num类型类的成员了。

小数这一面大约分了3级,但是Float与Double类型都实现了小数所有的功能,比如除法运算(/)、小数乘方(**)、正弦还余弦等。而对于复数,还需要一个参数,可以是Float也可以是Double。只要实现了RealFloat类型类的类型就可以,这样就用两个有序的小数表示了一个复数。复数也是可以进行乘方、正弦与余弦等运算的,所以它可以实现浮点小数Floating类型类。例如:

>:m +Data.Complex

> (5:+5) + (1:+1)

6.0 :+ 6.0

也可以做些三角函数的运算:

> sin (5 :+ 5)

(-71.16172106192103) :+ 21.048644880883543)

Num这里仅仅是一个统称,图2-1中所有的数字类型都是可以比较相等的。但是数字类型类与相等类型类间用了虚线箭头,这是因为相等类型类E9与数字类型类Num没有依赖关系,一个类型需要分别实现它们。

另外,可能会引起困惑的地方是Float与Double两个小数类型实际是Enum类型类,这并不代表可以像遍历整数那样遍历所有小数,而只是可以以固定的差值来遍历。例如:

> [1.0,1.5..3.0]

[1.0,1.5,2.0,2.5,3.0]

此时,Haskell可以根据前两个值自推断出这个固定的差值是 0.5,而succ与pred默认的长度是1。例如:

> pred 0.1

-0.9

这些与数字有关的类型类内部规定了很多种运算,根据不同的功能,可能需要不同种类的数字。Haskell中的类型系统对它们进行了细分,这样,无论将来处理普通的数字运算还是高精度的数字运算Haskell都可以很好地胜任,强类型也可以保证结果与转型的正确。同时,有了这种分类清楚的数字,在以后的应用中也可以根据自己的需要来定制想要的数字类型。

介绍了数字的类型类,再介绍一下数字类型转换的函数。在编程中,数字间常常需要转型,尤其是小数和整数之间的转型。Haskell是一个类型非常严谨的语言,所以不能像其他语言那样强制转型,这样可以避免数字计算中的很多错误。下面介绍一些常用数字转型函数。

(1)fromInteger :: Num a => Integer-> a函数可以将一个一个的整数转为一个重载的数字类型a。比如,有时需要将一个整数转为复数类型或者比值类型,这时就可以使用它。例如:

>:m Data.Complex

> fromInteger 5 :: Complex Double

5.0 :+ 0.0

> :m Data.Ratio

> fromInteger 5 :: Ratio Int

5 % 1

由于这是一个重载的类型的函数,因此应尽量指明返回结果的类型。下面介绍的函数都是如此。

(2)toInteger :: Integral a => a -> Integer函数可以将实现为Integral类型类的类型转换为Integer,这些类型包括2.1节中讨论的Int、Integer、Int8、Word、Word8等。

(3)fromRational :: Fractional a => Rational -> a函数可以将一个有理数化为一个实现了Fractional类型类的类型。例如:

> fromRational (5%1)

5.0

(4)toRational :: Real a => a -> Rational函数会将有着实现了Real类型类的类型a化为有理数类型。

(5)fromIntegral :: (Integral a, Num b) => a -> b函数将一个实现整数类型类的类型转换为更为一般数字类型类的类型。

(6)truncate :: (Integral b, RealFrac a) => a -> b函数可以将一个小数的整数部分取出,而properFraction :: (Integral b, RealFrac a) => a -> (b, a)函数则会将一个小数的两部分分成整数与小数部分以一个元组返回。

(7)floor, ceiling, round :: (Integral b, RealFrac a) => a -> b这三个函数分别是下取整函数、上取整函数和四舍五入函数。它们的转型非常易懂和直接,这里就不再讨论了。

2.2.6 可显示类型类:Show

一个可以输出在GHCi中的类型就是可显示的类型。图2-1中没有画出Show类型类,因为所有的数字类型都是可以输出的。GHCi中可以输出的结果都在Show类型类的成员。例如,比值Ratio类型输出两个整数,两个整数之间用%分开,复数Complex类型输出两个小数,中间用:+分开。

这些类型在Haskell中都可以显示出来的。可是有些类型在Haskell中不知道如何显示出来,原因可能是没有办法定义,也可能是需要手动定义。比如,在Haskell里,函数类型也是一种类型,可GHCi不知道怎么显示它们。因为函数类型没有实现Show类型类,所以这里函数是无法输出在命令行上的。例如,输出下面的存有函数的列表,GHCi就报错了:

>[(+),(-)]

<interactive>:1:1:

No instance for (Show (a0 -> a0 -> a0))

arising from a use of `print'

Possible fix:

add an instance declaration for (Show (a0 -> a0 -> a0))

In a stmt of an interactive GHCi command: print it

加法与减法实际是二元函数,GHCi向用户报告这些二元函数没有实现可显示类型类Show,所以不能输出在GHCi中。即GHCi不知道如何将这两个函数输出在GHCi的命令行上。这引起了一个很有趣的思考题,用户输入 5,GHCi打印 5,但为什么 GHCi 不能直接打印输入的[(+),(-)]?

学过Java的读者可能会知道,在Java中打印一个对象需要实现toString方法。如果一个类没有实现toString方法,则输出的是根据该对象地址计算的十六进制的散列码(hash code),如@1a3ba42。Java中的复写toString方法和Haskell中实现Show这个类型类是相似的道理,只是Java中没有把toString明确定义为接口。关于如何定义新类型以及实现类型类,将在第 8章和第9章中进行讨论。

在定义函数的时候,函数的类型(即函数的标签)大多数情况下是可以省去的,但并不是所有的时候都能省去。对于每一个合法的函数,Haskell都会用它所带的类型推断系统(type inference system)给出一个推断的合理的类型,这就是为什么:t可以工作的原因。但是,这里建议读者在今后书写代码的时候将函数的类型写好,这样不但在定义函数时可以提供思路,也可以像注释一样在书写后检查类型是否匹配,日后对于自己和他人的理解都能提供帮助。

2.2.7 小结

相信读者已经可以区分前边的Bool、Int等类型与Eq、Ord等类型类了。它们是不同的概念。在Haskell里,类型是通过data关键字来定义的实体的类型,如Bool是这样定义的:data Bool =True | False。Int是一个Haskell中嵌入的类型,不是这样简单枚举定义出来的,它们都被称为类型。Eq、Ord、Eum等类型类在Haskell里是用class关键字定义。Haskell中的class写Java中的完全不同,却和Java的interface(接口)有些相像。与Scala等一些语言中的trait十分相似。类型类中定义了一些函数,如果定义了一个新的类型,只要这个类型实现了类型类中声明的方法,这个类型就属于该类型类了,这样一来,一些基于该类型类的函数也就自然地可以用在新的类型上了。这是一种非常好的使用已有代码的机制,在后边的章节中会有更详尽的说明。

2.3 Haskell中的函数

2.3.1 Haskell 中的值

Haskell 里变量的值在绑定后不会改变,所有变量一定意义上可以理解为定值。例如,创建一个名为Value.hs的文件,并在文件中定义:

a :: Int

a = 5

那么a就是一个全局的常量。在计算过程中,a的值是不会改变的,用户只能引用值而不能修改值。可以在GHCi导入Value.hs文件,在命令提示符里引用a,例如:

> 2*a

10

> 1+a

6

无论函数如何地被应用,a的值是不变的。再比如,加入一个布尔值b:

b :Bool

b = False

由于改动了文件,所以保存文件后要在GHCi里重新导入。GHCi工具栏里有一个快捷按钮可以做到这一点,也可以在提示符中输入:r来重新导入文件。导入后在提示符中使用一下b:

> b

False

>not b

True

> b || True

True

无论如何,定义过的值是没法再改变的。之前提到过,Haskell值与函数是统一的,函数只是需要其他参数输入的值。如果定义的是函数,那么这个函数的行为在运行过程中也是不会改变的,对于某一个特定的输入返回的结果总是确定的,这样的函数为纯函数。有人觉得不改内存状态的想法听上去很荒诞,甚至觉得这样是没有办法做计算的。其实,这两种想法都是错误的。不改变内存状态自有道理,而其他编程语言可以完成的工作Haskell一样可以完成。

接下来,了解一下这定义这些纯函数所需要的思想,然后学习如何在Haskell中定义函数。

2.3.2 函数思想入门

首先需要了解什么是函数。在函数类型中提到过,函数是从参数到结果的一个映射,将一个类型的值映射到另一个类型的值。这里的值不单单是狭义上的值,如5、True等,通常也指函数,区别只是5,True不需要任何的输入。再说明一下,函数与值在Haskell中没有本质区别,函数可以是单一的定值,也可以是任意两个函数间的映射,如函数到函数的映射。

另外,在Haskell的世界里,所有的运算符号都可以被看做是函数,如加号+实际上也是一个函数——一个需要两个参数的函数。如果在GHCi的命令行里输入:

>(+) 5 7

结果会输出12。这种写法只是将中缀运算符置前,(+)只是一个符号,也可以使用add、plus等用户想要的名字。在Haskell中,实际上每一个函数都有一个唯一的类型,在命令行里输入:t(+)就可以查看(+)的函数类型:

> :t (+)

(+) :: (Num a) => a -> a -> a

经过前面的学习,相信读者已经知道了,类型结果首先说明了这是一个柯里化的函数,然后说明了(+)需要两个参数,但这两个参数必须实现了Num类,最后它会返回一个数字。比如,给定了5与7就会返回12。如果只给定第一个参数5结果会怎样?例如:

>:t (5+)

(5+) :: Num a => a -> a

这时,就从一个函数得到了另一个函数。->是向右结合的符号,所以类型(Num a) => a -> a -> a指的是(Num a) => a -> (a -> a)。如果已经给定了第一个参数5,就会得到(5+):: Num a => (a -> a)这样类型的函数。因为类型中的第一个a已经给定为5,所以被消去了。这表明, 5+是需要一个数字作为参数返回一个数字的函数。这样,对于需要两个参数的函数,如果给出一个参数,那么实质上返回的是一个函数为结果的,这一点在很多语言中是无法做到的。在Haskell中,当给定第一个参数应用于一个n元函数,返回的结果是一个n−1元函数。那些没有给出所有参数的函数应用称为函数的不完全应用(partial application,有时译为偏函数调用),这是柯里化函数的灵活之处。

很多数学上的概念,在某种程度上都可以理解为函数。例如,在数学中常常表示坐标(x, y),而事实上,这个元组的类型是Num a =>(a, a)。再如,平面中的直线方程是y=kx+b,其中k与b为常数,x为变量,则对于给定的x值总会得到一个y值。通俗地说就是,输入一个数,该函数就输出一个数作为结果,所以它的类型应该写为Num a => a -> a。

求导数的过程实际上就是给定一个函数得一个新函数的过程。求一个函数的导数,实际上就是以一个函数作为参数输入,然后返回一个函数作为结果的函数。如果要写出求导函数的类型,则应该是diff :: Num a => (a -> a) -> (a -> a),这也就是前文中提过的从函数到函数的映射,如果有这样一个函数,那么diff (\x -> x + 5)的类型就是Num a => a -> a,即(\x -> x + 5)替换了diff类型中的左面的(a -> a),得到了右面的(a -> a),即导函数的结果。

而积分则是返回一个曲线族,可以理解为返回一个函数与一个任意常数组成的元组,可以写成(Num a) => (a -> a)> ((a -> a),C)。这里仅仅用C类型表示任意常数,如果不定义任意常数这个概念,也可以把积分理解为返回一个函数的列表的函数,也就是(Num a) => (a -> a) -> [(a -> a)]。当然,这是一个无限的列表。像导数与积分这样的运算,以其他以函数作为输入的函数或者以函数作为返回结果的函数,称之为高阶函数。高阶函数将在第7章中具体探讨。

在图像处理中,图像处理滤镜其实本质上也是函数,假设有一个名为Picture的图片类型,对图片的水平翻转、锐化、模糊等实际是以一个图片为参数,对图片做处理,得到一张新图为结果的函数,它们的类型就应该是Picture –> Picture。图片的旋转也是如此,仅仅是需要一个旋转的角度,给定一个整数的角度,再给定一张图片,返回旋转后的图片,这样它的类型应该写做Int –> Picture –> Picture。

现实中的很多实例都可以当做函数来处理,如数学中的导数、微分、积分、曲面、平面及空间直线;图像处理中的翻转,放缩,旋转;文件的类型转换、压缩。再比如C的编译器,它可以理解为一个以字符串String,也就是程序员的代码,作为输入,得到可执行的机器语言的函数,不过这个函数可能是由很多复杂函数复合而成的,但是它的类型可以简单地认为是String −> [Code],Code表示二进制机器指令。在后面的章节中将会编写简单的计算器,到时将会看到如何把一个问题看做一个函数,并将它们拆分成多个函数然后复合它们来解决。

2.3.3 函数的基本定义格式

现在,读者应该对Haskell的函数有了一定的了解,本节将讨论如何在Haskell中定义函数。一般情况下,先要在第一行定义函数名与函数的类型,它称为类型签名(type signature)。定义类型签名后,另起一行写出函数名、参数,最后定义函数体,在Haskell中定义的函数的大致格式是这样定义的:

函数名 :: 参数1 的类型 –> 参数2 的类型 ->...->结果类型

函数名 参数1 参数2 ..= 函数体

::指定函数的类型。类型与集合是不同的。但是,如果把类型简单地当做集合来看待,::后面的就是函数的类型,那么::可以理解为数学集合中的属于符号∈,比如定义:

i :: Int

i = 5

这里的i可以理解为i∈Int(数学上常用Z表示整数集),i的值为5。对于函数时也可以这样理解,若将Int -> Int看做一个函数的集合,如果参数为n,阶乘函数n!、将整数平方的函数n2等都在该集合之中。

Haskell有类型推断系统,它可以自动推断出一个函数的类型。但是,如果定义函数时可以给定该函数的类型,那么 Haskell 的类型推断系统会检查系统得出的类型与我们给定的类型是否一致,这在一定程度上可以保证函数定义的正确性。::可以指定类型,但具有相同类型的函数往往是定义在一起的,例如数学上我们常常写x,y∈Z,在Haskell中它们的类型也可以合并在一起,函数名之间用逗号分隔。比如:

add, sub :: Int -> Int -> Int

add a b = a + b

sub a b = a - b

当有多个类型类限定在一个类型上的时候,它们需要用括号写在一起,并且中间要用逗号隔开,或者像柯里化那样使用=>依次连接。例如:

function :: (Show a, Ord a) => a -> a -> a

function :: Show a => Ord a => a -> a -> a

.hs文件的全局函数的名必须要从每行最前端开始写,因为Haskell格式里可以省去很多大括号{}与分号;,所以这些格式对于Haskell编译代码十分重要。这里再重提一个细节,在普通的文本编辑器中写Haskell代码最好不要用Tab键进行缩进,最好多用一些空格代替,因为在这样的情况下,GHCi可能无法正确解析代码。在集成开发环境中,会将所有的Tab键自动换成空格。另外要注意的是,函数名不能以大写的英文字母和数字开头,因为大写开头的字母单词是被当成类型或者类型数据使用的,比如Bool类型是大写,并且True与False都以大写字母开头的类型的值。下面来定义一些初等的数学函数来了解在Haskell如何定义函数。创建名为Function.hs的文件书写下边的代码。

在数学中,常常将函数写成这样:f(x)=4x+1,其中x是一个数字,需要被限定在Num类型类中。它在Haskell里被写成这样:

f x :: Num a => a -> a

f x = 4 * x + 1

然后,可以在GHCi中调用这个函数,输入:

>f 5

21

这样就定义了一个名为f的函数:把一个数同4相乘,然后加1。

现在来定义一个函数,这个函数可以求一个圆的面积,即area(r)=πr2,π是Haskell预加载库中预定义的常量,写做pi,所以面积函数可以直接定义为:

area r = pi * r ^ 2

在数学中,有多元的函数,比如:f(x,y)=4x+5y+1。

f :: Num a => (a,a) -> a

f (x,y) = 4*x + 5*y + 1

很明显,这个函数以一个元组即一个实数对作为输入,然后得到一个新的实数。也可以这样定义:

f' :: Num a => a -> a -> a

f' x y = 4*x + 5*y + 1

通过 2.1 节我们知道,这两个非柯里化与柯里化的函数是等价的,但非柯里化的函数是必须同时输入x和y后得到结果的。而柯里化的函数则可以对参数x和y一个接一个地输入,当x被输入成5的时候,会得到:

f'' :: Num a => a -> a

f'' y = 4*5 + 5*y + 1

从函数f到f'的过程就是之前提到的柯里化。

2.3.4 λ表达式

Haskell还有另外一种书写函数的格式,与函数的类型相互对应,这就是λ表达式。

函数类型 :: 参数1 的类型->参数2 的类型-> 结果的类型

函数名 = \参数1 -> \参数2 -> ...-> 函数体

λ函数的写法源于λ演算,很多关于函数式编程的书中将这里的反斜杠\写成希腊字母λ,有些语言里定义函数写成f = λx → λy → x + y 或者f = λx.λy.(x+y),不过各个写法表达的都是同样的意思,就是将函数的参数依按次序输入到函数体中。这里输入的x与y不一定是值,也可以是函数,如g = λx.λy.x y 是把y 应用于函数x。

> (\x -> \y -> x y) (abs) (-5)

5

> (\x -> \y -> x y) (+9) 5

14

那么上节中的f '函数可以这样定义:

f' :: Num a => a -> a -> a

f' = \x -> \y -> 4*x + 5*y + 1

可以看出,函数的类型与函数的定义非常好的对应上了。在代码编译后,函数最终都将会被化为λ表达式。参数依次输入的时候,在Haskell中可以省去参数间的箭头而简写成:

f'=\x y -> 4*x + 5*y + 1

除λ表达式这种表达方法外,λ演算中还有一些其他的重要概念被应用到了Haskell中,它们是α替换、β化简与η化简,其中β化简与η化简在代码中常常出现,我们会在稍后一一介绍。

Haskell 为了避免重复计算和类型的歧义,引入了单一同态限定(MonomorphismRestriction)这一概念,这里我们简称它单态限定。单态限定的引入影响了我们前面提到的η化简。为了不让读者对它带来的问题感到困惑,在下面的第2小节中我们会简单地讨论一下它。最后我们来看一下λ表达式的用途。

1.λ演算中的α替换、β化简与η化简

α替换(α-conversion)所指的是在不出现命名冲突的前提下可以将函数的参数重新命名。比如,下面等号两边的定义是等价的:

这里我们只是将x重新命名为了a。但是,如果有变量名有冲突那么两者则不相等,例如:

β化简(β-reduction)则指的是参数到函数体的替换。应用参数N于函数λx→M,相当于在不出现命名冲突的前提下,把M中出现的x替换为N。

例如:

这样,就不必为x+2这个函数再起一个名字,借助λ表达式可以直接应用一个没有名字的函数到y。但是,遇到下面有命名冲突的情形则不能简单地替换,例如:

所得的结果将是λy→y+y,这明显是错误的,所以在遇到这种情况时要先做α替换,把其中的y改成一个不冲突的名字,然后才可以进行β化简,这就是引入α替换的主要原因。下面我们来对前面的例子应用一下β化简,手算一下函数应用与计算的具体过程:

(\x -> \y -> x y) (abs) (-5)

β(\y -> abs y) (-5) {应用β化简替换x}

β(abs(-5)) {应用β化简替换y}

=5 {根据abs 绝对值函数定义计算得出}

η化简(η-reduction)可以用来消去那些冗余的λ表达。在定义函数时,可以将一个参数传给函数M,进行计算的函数和M本身是同一个函数:

这里的意思是说,我们将一个参数a 应用到这个函数(λx→M x)a,在做β 化简后所得的结果与直接将参数a应用到函数M所得的结果相同。

例如,在Haskell中:

g :: Int -> Int -> Int

g = \y -> \x -> (+) x y

将加法的两个参数输入给加法函数来求和的一个函数,就是加法函数本身,所以函数g可以化简为g = \x -> (+) x,并且进一步化简为g = (+)。

注意,在做η化简时尽量标明函数类型签名,因为如果不指明函数的类型,则可能会由于GHC默认的“单一同态约束”引发一些问题。下面我们来简单讨论一下单一同态限定,读者不必完全理解它,可以在对类型系统有了一定的了解后再来看这部分内容。

2.单一同态限定

Haskell Report 2010 中给出引入单一同态限定的原因。引入它一是为了避免重复计算,二是为了消除类型歧义。我们分别来讨论一下。

(1)重复计算:计算中可能产生类型为(Num a, Num b) => (a,b)的结果,但是却可能被限定为(Num a) => (a,a)。因为(Num a, Num b) => (a,b)需要对a与b进行不同的两次运算,耶鲁大学的Paul Hudak构造了一个极端的函数,原本非常简单的函数应该很快被计算出来,但由于未限定为同一类型类使得计算有着指数级别的复杂度。

例如:在计算任意长度的列表时会用到Data.List库中的函数genericLength :: Num a => [b] -> a,而下面定义的函数f = (len,len)中len = genericLength xs。这里我们无法知道f中的两个len的类型是否一样,可能一个为整数,一个为小数,这样则可能需要计算两次,如果它们就是不一样的数字类型,那么f的类型则应当是(Num a, Num b) => (a, b)。但是,由于单一同态限定,Haskell默认它们是一样的。

> :m +Data.List

> :t genericLength

genericLength :: Num i => [b] -> i

> let f xs = let len = genericLength xs in (len,len)

> :t f

f :: Num t => [b] -> (t, t)

我们可以看到这里本来应该不同的两种数字类型却被限定为同一种数字类型t。如果关闭单一同态限定后重新定义函数f后会让len有不同的类型:

> :set -XNoMonomorphismRestriction

> let f xs = let len = genericLength xs in (len,len)

> :t f

f :: (Num t, Num t1) => [b] -> (t, t1)

(2)类型歧义。在不给定类型上下文的前提下,在GHCi中reads函数的类型应当如下:

> :t reads

reads :: Read a => ReadS a

> :i ReadS

type ReadS a = String -> [(a, String)]

-- Defined in `Text.ParserCombinators.ReadP'

这样,对于在函数体中的变量绑定[(n,s)] = reads t中的s的类型则会被推断为Read a =>String,这是一个有歧义的类型,因为类型类型签名中右侧并没有用到有类型类定义的类型。对于这种情形,GHC默认将由类型类限定的类型限定到一个类型。对于数字类型类Num先试用Integer,然后是Double,依次类推,如果都不可以就失败。

所以这里对加法函数做η化简而不加类型签名是没有问题的。比如,上一小节中的函数g,对于g 5 3的参数类型可以是Num类型中的任何值,结果可以返回Integer也可以返回Double类型,由于GHC在实现的时候对于数字运算有默认的规则,所以当定义g = (+)时,将文件导入GHCi中,会得到下面的警告:

Warning: Defaulting the following constraint(s) to type `Integer'

(Num a0) arising from a use of `+'

In the expression: (+)

In an equation for `g': g = (+)

意思是说,GHCi默认g的类型为Integet –> Integer -> Integer。

但是,如果不加类型签名定义基于相等类型类Eq的函数g = \x -> \y -> (x == y),还有η化简得到的g = \x -> (x==)和g = (==),则会得到下面的错误:

Ambiguous type variable `a0' in the constraint:

(Eq a0) arising from a use of `=='

Possible cause: the monomorphism restriction applied to the following:

g :: a0 -> a0 -> Bool

Probable fix: give these definition(s) an explicit type signature

or use –XnoMonomorphismRestriction

这就是由单一同态限定(monomorphism restriction)引起类型歧义。对于无法像数字类型类Num那样把重载类型限定到Integer或者Double上的类型类,GHCi会把限定到单位类型()上去,()是单位类型,它只有一个值,这个值记为()。例如:

> let f = (==)

> :t f

f :: () -> () -> Bool

为了解决这个问题,我们只需要添加相应的类型声明或者在源文件顶端向GHC声明{-# LANGUAGE NoMonomorphismRestriction #-}将单一同态约束关闭即可,但是这样就需要对潜在的那两个问题做出适当的处理。在GHCi中我们可以使用命令来设置,例如:

>:set -XNoMonomorphismRestriction

> let f = (==)

> :t f

f :: Eq a => a -> a -> Bool

同理,当关闭了单态限定后,对于η化简后的定义g = (+)的类型就不会限定为整数了,而是Num a => a -> a -> a。更多关于单一同态限定的内容可以参阅Haskell 2010 Report 4.5.5。

3.λ表达式的应用

λ表达式的应用主要有两个:对于柯里化函数,在不给定前一个参数的前提下给定后一个;另外一个应该是匿名函数(anonymous function)。在使用一些高阶函数时,如果不想定义新函数,可以使用λ函数来定义,这种函数也常常被称为匿名函数或者无名函数(nameless function),后面会见到这种情况。

使用中缀运算符,并且只给出部分参数时:

> :t (^) 2

(^) 2 :: (Num a, Integral b) => b -> a

对于(^)运算符,第一个参数为底数,第二个参数为幂。在使用的时候可以直接给出第二个参数而不给出第一个:

> :t (^3)

(^3) :: Num a => a -> a

这里只给出了(^)运算符的第二个参数,跳过了第一个参数。若(^)不为一个中缀运算符,通过λ表达式可以很容易做到这一点\x -> (^) x 3,这种情形下就不能做η化简了。

在上面的例子中,对于运算符其实可以不用λ表达式,但是对于之前定义的f',如果只想给定变量y,而不想给定x,又该怎么办呢?借助入表达式很容易做到。

f' :: Num a => a -> a -> a

f' x y = 4*x + 5*y + 1

只要将函数写为\x -> f' x 5就可以了。

因为 λ 函数不需要函数名来定义,所以在 Haskell 中入函数被称为匿名函数。当然,如果需要,那么也可以给它命名。在GHCi的命令行中,可以直接将函数应用到参数上:

> (\x -> (^) x 3) 2

8

若不借助λ函数,则需要对函数命名:

> let f x = x ^ 3

> f 2

8

2.3.5 参数的绑定

在数学上,求一个三角形的面积常常用到海伦公式。公式定义如下:

a、b、c是三角形的三边。由于p的值仅定义一次就好,这种情况就可以用let..in..在函数定义中做替换。海伦公式在Haskell中可以定义为:

s :: Double -> Double -> Double -> Double

s a b c = let p = (a+b+c)/2 in sqrt (p*(p-a)*(p-b)*(p-c))

这样,在应用函数的时候,p会自动被替换为(a+b+c)/2。用户不仅可以绑定表达式,还可以绑定函数,例如:

>let f x = x+1 in f 5

6

多个表达式绑定可以用分号隔开:

>let x=2; y=2 in x+y

4

除了用let..in..以外,还可以使用where。依旧以海伦公式为例,可以先定义好函数,然后在where关键字后边定义参数p作为函数定义的补充。就像数学公式里的括号一样,where是对公式的一个补充说明。

s' :: Double -> Double -> Double -> Double

s' a b c = sqrt (p*(p-a)*(p-b)*(p-c))

where

p = (a+b+c)/2

这样,在做计算的时候Haskell会计算(a+b+c)/2,再代入到定义中。但是,使用let..in..绑定要注意命名捕获(name capture),比如:

>let x = 6 in x * let x = 2 in x*x

24

表面上看这里计算的是三个x值相乘,但是这里的x值是不等的,第一个x的值是6,后两个x的值是2,所以计算的结果是6×2×2,结果是24。

但是,如果定义一个函数,用了where绑定,那么它在函数的全部的范围都是有效的。当然,也不可能用同一个变量的名。但是,where也可以像let那样有多级,即在where内定义的函数内又有where,读者可以自己尝试一下,但是这种用法并不常见。

2.4 Haskell中的表达式

2.4.1 条件表达式

条件表达式是语言中最常用到的。例如,先来定义一个简单的函数叫做isTwo,它的作用:如果输入的参数是2,就返回True;否则为假。

isTwo :: Int -> Bool

isTwo n = if n == 2 then True else False

加载到GHCi中,在命令提示行里输入:

>isTwo 2

True

>isTwo 7

False

isTwo函数非常简单而直接,事实上,只要写isTwo = (==2)就可以了。这里主要是为了介绍条件表达式。Haskell里的if..then..else顺序式编程语言有些不同,就是else后的表达式不可省略。也就是说,必须定义条件成立的时候返回的值,也必须定义条件不成立的时候返回的值,并且返回的类型必须相同,这样一定程度上保证了函数定义的完整性。在顺序式语言中,if..then..else..是一种表达式结构,而在Haskell中可以理解为一个运算符也可以理解为一个函数(运算符与函数其实是等价的,这一点将在后面的小节中详细说明),这个运算符既不像加号那样处于两个参数中间为中缀运算符(infix operator),也不像对数函数那样位于一个参数前边为前缀运算符(prefix operator)。它是一个混合位置运算符(mixfix operator),它是Bool −> a −> a −> a类型的一个函数,即需要一个条件参数,若满足,则返回第二个参数为结果;否则返回第三个。

2.4.2 情况分析表达式

情形分析表达式是用case与of关键字来对一个类型中不同的值进行讨论的,它与顺序式编程里的switch ..case类似。区别就是,Haskell不用像switch那样选择到一个条件继续向下运行而不自动跳出。因此在Haskell中,不需要用到break关键字。顺序式语言中default关键字和Haskell里通配符类似,即一个下划线“_”,表示除了上面的匹配外,匹配所有的值。下面用case..of..再来定义12月份不同天数的函数,中间的一些定义在这里省略了:

month :: Int -> Int

month n = case n of

1 -> 31

2 –> 28

...

9 -> 30

10 -> 31

11 -> 30

12 -> 31

_ -> error "invalid month"

这里不再用等号作为返回结果的符号而是一个箭头−>,和函数类型里用的箭头是一样的。省略的部分也需要依次被定义。这里的“_”意为除1~12以外的输入都会得到错误提示。如果有多个匹配满足,那么只有第一个会被返回。

2.4.3 守卫表达式

守卫表达式(guarded expression)是使用|将函数的参数按特定的条件分开,|像一个守卫一样,如果不能满足条件,它绝不会让不符合条件的表达式运算。不同条件的守卫表达式的需要对齐。本例用绝对值函数来演示:

abs :: Num a => a -> a

abs n | n > 0 = n

| otherwise = -n

otherwise是匹配时的默认定义。因为|后的一定是一个布尔类型,所以otherwise为布尔类型并且它的值很明显是True。有兴趣的读者可以打开Prelude.hs,找到otherwise的定义。这里需要说明的是,在定义函数时可能有很多条件,但是如果有多个条件同时满足Haskell只会匹配第一个。

2.4.4 模式匹配

模式匹配(pattern match)中的pattern理解为模式,它指的是一个类型的值或者定义成的形式。模式匹配的本质可以通过case..of..表达式体现出来,即每个类型的数据(或者称为值)都可以看做是该类型的一个具体形式,我们仅仅是要把需要匹配的情形依次写下,Haskell会从上到下的查询,直到找到第一个匹配,如果有多个复合的匹配,那么只有第一个匹配被执行。

month :: Int -> Int

month 1 = 31

month 2 = 28

month 3 = 31

month 4 = 30

month 5 = 31

month 6 = 30

month 7 = 31

month 2 = 40

month 8 = 31

month 9 = 30

month 5 = 20

month 10= 31

month 11= 30

month 12 = 31

month _ = error “invalid month”

> month 5

31

以上几种定义函数的方法并不一定非要分开使用,相反,它们常常在函数定义时同使用。这样就有了更多的自由度来对函数进行定义。这里,读者可以看到case..of..和模式匹配是对于类型值不同形式的分析,所以在一定意义上它们是等同的,而用竖线的守卫表达式和if..then..else..都是对参数的条件讨论的表达式,所以它们在一定程度上是等同的。

另外,也可以对列表的两种不同模式进行匹配。如果读者记得列表的定义则会知道,一个列表只可能为空,或者为一个元素与另外一个列表的组合。这样,可以定义head函数来返回一个列表的第一个元素(因为Prelude中已经定义了head,所以后加一个单引号)。

head' [] = error "empty list"

head' (x:_) = x

在定义函数的时候,如果我们并没有把所有的模式都分别定义好,那么当调用函数的时候可能会出现exception of non-exhaustive patterns的错误,即在模式与定义匹配的时候对于某些数据的模式有所遗漏,在今后写程序的时候应当注意这一点。比如,定义month函数时,我们使用了_通配符以匹配其他不合理的整数,让它们都返回error。

各种表达方式其实可以混合使用,我们后面会常常见到这样的例子。

2.4.5 运算符与函数

前面已经提到,运算符(如加号、减号等)实质上是与函数是一样的。这里所说的运算符指的是所有的运算符,因为Haskell所有的运算符都是基于函数定义的。

例如,(+) (−) (*)其实是有着同样类型的函数,它们的都是Num a => a −> a −> a。而(/)略有些特殊,它的类型是Fractional a => a −> a −> a,即所有参数都会被转换成小数进行运算。

运算符不过是一些规定了可以放在参数中间或者末尾的函数,并且用了一些特殊的符号表示,将它们写在最前端实质是一样的。例如在GHCi里输入(+) 5 6与5 + 6是一样的。读者可以将一些二元函数用反单引号(`)(位于数字1键左侧的~键)来转换成位于参数中间的运算符。比如,5 `div` 2表示div 5 2,其中div是整数除法函数。同理,取余函数mod也可以写为中缀运算符的形式,5 `mod`2与mod 5 2是一样的。运算符号其实不过是为了简化书写而约定的,本质还是函数,在Haskell里函数与运算符是等价的。

2.4.6 运算符与自定义运算符

运算符可能有3种属性,即优先级、结合性和位置。在Haskell中,运算符号有0~9,共十个优先级。结合性又分为左结合性、右结合性、无结合性。根据位置又可分为前缀、中缀和后缀运算符,一般使用的函数常常可以理解为前缀运算符。这样,在上节提到的函数也可以理解为运算符,定义的函数默认有着最高的优先级,比9还高并且为左结合。例如,f g h i意为(((f g) h) i)。因此,在某些情况下,要对函数的参数加括号才能正确使用这些函数。表2-2展示了一些常用运算符的优先级与结合性,它们都是中缀的运算符。

表2-2 Haskell中常用运算符的优先级与结合性

有些运算符在前边没有提到。这里对这些运算符进行简单的介绍。

■ (!!)从一个列表中取出第n个元素(从0 开始)。如果输入的数字大于等于列表的长度就会出现错误。例如,在GHCi里输入[1,2,3,4,5]!!3,会返回4。

> [1,2,3,4,5]!!3

4

■ (.)是复合函数的运算符号,将在第7章高阶函数中讨论。

■ (^)在Haskell里表示乘方。它的类型为(Num a, Integral b) => a -> b -> a。由它的类型可知,底数可以是一个任意的数字类型,而指数一定要是整数类型。

■ (^^)也为乘方函数,这里规定底数a必须是Fractional类型类的实例。它的类型为(Integral b, Fractional a) => a -> b -> a。

■ (**)还是乘方函数,但它的类型是Floating a => a -> a -> a,意思就是说,这个函数的两个参数都可以为小数。比如,它可以计算0.64的0.5次方等。例如:

> 0.64 ** 0.5

0.8

根据不同的需要,可以使用不同的乘方运算符号。由于类型不同它们的计算效率也不尽相同。

■ mod和rem是取余函数,quot和div是求商函数,但是在计算负数的时候,运算结果会有些不同。这里简单说明一下不同点。

在GHCi里运行一下:

■ div (−12) 5得−3;

■ mod (−12) 5得3;

■ quot (−12) 5得−2;

■ rem (−12) 5得−2。

可以看出,div与mod是一组,因为商−3乘以5再加上3正好是12。同理,quot与rem是一组。在计算的过程中,在保证余数r的绝对值小于除数的情况下,div总是要结果逼近于负无穷,而quot总是需要将结果逼近0。

很多情况下,一定要对负数加括号,否则出现会有一些“意外”的结果,比如−2 `mod` 6 我们会得到−2,而我们想要的结果是4,注意,减号“−”的优先级比mod要低,所以这里实际上在计算−(2 `mod` 6)。因为Haskell的语法中使用了大量的中缀运算符,所以在今后定义函数时要注意运算符的优先级,否则会因为括号遗漏或优先级没考虑而导致程序出现问题,从而没有得到预计的结果。

■ (:)正如前面所提到的,元素列表连接运算符也是一个函数,并且是一个有着类型a−>[a]−>[a]的函数,即将一个新的元素加入一个列表的第一个位置。比如:

>1:[1,2,2]

[1,1,2,2]

■ (++)列表连接符,顾名思义是连接两个列表的,类型为[a]−>[a]−>[a]。比如:

> [1,2]++[1,2]

[1,2,1,2]

优先级为4的第一行运算符就不做过多的解释了。/=表示的是不等于,不要与其他语言混淆,写成!=或者<>都是错误的写法。

优先级为1 的两个运算符是有基于Monad类型类的,这里先不讨论。直接看优先级为0的运算符。

■ ($)的定义为:

($) :: (a -> b) -> a -> b

f $ x = f x

这个函数是很实用的。当有多个函数应用时,Haskell默认计算为左结合,而这里因为($)有着最低的优先级,并且是右结合,那么使用($)就可以避免使用过多的括号来定义函数。比如:当需要定义f (g (h x))时,可以写成f $ g $ h x,使得代码更清楚并且十分方便,相当于把x输入给h,所得结果输入给g,依次类推。

想必读者应该知道为什么类型签名中的−>为右结合而函数的应用却是左结合的了,即f g h意为(f g) h,而a −> b −> c意为a −> (b −> c)[1]。因为类型签名中输入的类型在左,比如f :: a −> b −> c意为f是输入一个a类型的值,返回一个有着b −> c类型的函数。这样,输入参数的类型在左,可是应用函数到参数时对应的输入参数却写在函数名的右侧,即f x,如此一来,函数应用的结合性就与类型签名的结合性正好相反了。

以后读者在定义与使用函数时,不但需要给定函数参数,最好还要考虑类型在给定参数时是如何计算的,给定的参数替换中了函数类型中的哪些部分,会得到怎样的类型。只有明白了这些,才算是真正理清了函数与类型间的关系。

($!)、($!!)与seq的使用与Haskell的惰性求值特性有关,有关惰性求值的内容将在第 15章进一步探讨。

当然,这不是Haskell中所有的运算符,运算符只是定义了优先级的普通函数,在后面将见到更多的运算符。下面来定义一个运算符。虽然Haskell中通过类型类支持函数的重载,但Haskell不像C++那样非常自由地支持函数与库运算符重载的。比如,不可以随意地重载加号,因为它的类型必须是Num a => a −> a −> a。但在Haskell中,可以自由地定义自己的运算符号,但要声明它的结合性是怎样的,优先级是多少。声明运算符的关键字有三个:infixl为左结合, infixr是右结合,infix为无结合。定义时先用这三个关键字说明结合性,再声明优先级,最后给出运算符的符号。如果需要定义多个结合性与优先级相同的运算符,那么它们之间要用逗号分开。

infixr 5 <->,<+>

(<->),(<+>) :: Int -> Int -> Int

(<->) x y = x – y

(<+>) x y = x + y

这样就定义了一个新的作用为减号的运算符。不同的是,它是右结合的。如果导入文件到GHCi里,输入10 <−> 5 <−> 2会得到7,因为定义的这个符号(<−>)是右结合的,这个运算实质上计算的是10−(5−2)的结果。

本章小结

本章介绍了 Haskell 中的类型系统以及函数。首先学习了类型与类型类,其次学习了如何使用各种表达式定义函数和运算符。类型系统与函数是紧密联系的,了解它们是如何统一起来的非常重要。有些人刚开始可能会觉得类型系统在编程实践中是一种束缚,因为在编程时不但要考虑计算是如何进行的,还需要考虑类型是否协调。同时,在使用函数时,心里不但要清楚返回的值是什么,还要清楚返回值的类型,这样才能通过 Haskell 的类型检查。刚开始的时候可能会不适应,但是久了就会发现,Haskell的类型系统在把程序引到一个正确的方向上。

只有充分利用 Haskell 类型系统带来的好处,才能写出错误更少的代码。希望类型系统可以在定义函数时,成为你真正的朋友。

注 释

[1].有着a -> b -> c这样类型的函数是无法定义的,这里只是举个例子,读者可以想想为什么有着这样类型的函数无法定义。

后记

首先,十分感谢你购买这本书并且能把它读完!其实,把这本书当成是一本入门书并不是很恰当,因为这并不是严格意义上的入门书籍,其中的一些内容已经涉及了Haskell中比较深入的部分。虽然内容上相对深入了,但是读完这本书并不是学习Haskell的终点,而仅仅是一个起点。下面,我想简要地介绍一下其他的相关的文章、讲义和书籍。

我个人觉得在学习 Haskell 时,参考英文资料是必不可少的。中文书籍仅仅能起到引路的作用,毕竟大部分的Haskell与函数式编程的文章都采用英文撰写。如果读者在读一些文献有困难的话,不妨从语言简练的《Programming in Haskell》开始,这本书由有多年教学经验的Graham Hutton教授编写,它很薄,内容很实在。另一本就是语言随意轻快的《Learn You a Haskell for GreatGood》,全书语言简单、有亲和力,如果作为第一本英文的Haskell读物也会是一个比较不错的开始(网上有英文版可免费阅读,可以登录http://learnyouahaskell.com/chapters)。请读者不要认为看过本书以后再去读简单的入门的书籍只是浪费时间,其实不然,我常常可以从一些简单的书中想到新的东西,正可谓是温故而知新。

如果你想了解更多用Haskell来解数学与算法的趣题的内容,那么我推荐你阅读由已经从牛津大学退休的教授Richard Bird编写的《Pearls of Functional Algorithm Design》。书里面有很多算法问题,都是用函数式编程语言来解的,相信想用Haksell语言学习算法的读者一定会喜欢。另外一个很好的资料就是Haskell Wiki上的《函数式编程99题》,是《Lisp 99 题》的Haskell版本。本书从中选取了几个例子,读者可以访问http://www.haskell.org/haskellwiki/99_questions。网站上不但有题目,还有答案,虽然个别答案是错误的,但对于喜欢算法类趣题的读者是一个不错的资源。

有些读者可能对如何使用Haskell开发商业软件更感兴趣,那么,由O’Reilly出版的Real World Haskell是不应该错过的。这本书的作者之一是在斯坦福大学从事函数式编程多年研究与教学的Bryan O’Sullivan(网上可以免费阅读,http://book.realworldhaskell.org/read/)。书中所用的GHC版本有些旧了,不过书的新版应该已经在紧锣密鼓的计划当中,相信离出版上市不会很远了。

并发与并行一直是一个饱受关注的话题。如果读者想进一步了解并行与并发,可以阅读曾在微软剑桥研究院工作的 Simon Marlow 撰写的,由 O’Reilly 出版的《Parallel and Concurrent Programming in Haskell》。这是一本不错的书,部分内容来自 Simon 暑期课程的讲义。Simon Marlow曾是GHC项目领导者——Simon Peyton Jones的同事。Simon Marlow现任职于Facebook,为Facebook用Haskell更好地解决一些实践中的问题。他主要负责编写了GHC的运行时系统、异常处理、语法分析器生成器Happy以及文档生成工具Haddock等。他发表的关于Haskell的学术文章、讲义或者博客都是值得一读的。

若读者想对 Haskell 的语法与 GHC 的使用做更深入、更全面的了解,那么《Haskell 2010 Language Report》和《The Glorious Glasgow Haskell Compilation System User’s Guide》可以作为查阅的手册使用,网上也有免费的PDF版本可供阅读。同样,我推荐由Simon Peyton Jones 与Simon Marlow 一起编写的“The Glasgow Haskell Compiler”一文,文中讲述了GHC的结构和特性。可以登录http://www.aosabook.org/en/ghc.html阅读。另外,GHC作为一个免费开源的软件,大家可以在http://www.haskell.org/ghc/下载GHC的源代码,以便研究之用。

如果想进一步了解函数式编程语言的设计与实现,那么可以参阅Simon Peyton Jones 教授编写的《The Implementation of Functional Programming Language》在Simon Peyton Jones 教授所在的微软剑桥研究院的主页上可以免费下载到这本书的PDF扫描版本。期间,你可能要更深入地了解λ演算、类型与System F的相关内容,其中,λ演算与类型的内容可以参阅J.Roger Hindley编写的《Lambda-Calculus and Combinators》与《Basic Simple Type Theory》。你还可以阅读一本非常经典的、理论与实践相结合的书,是宾夕法尼亚大学Benjiamin C.Pierce 教授编写的《Types and Programming Language》。

如果你不知道需要读什么,但又很想对Haskell做进一步的了解,那么我建议你下载Simon Peyton Jones 教授与Simon Marlow 发表的相关论文,如“A History of Haskell”,其中讲述了Haskell的由来与历史,记录了Simon Peyton Jones 教授在实现Haskell类型类时遇到的困难与解决过程,还是相当有趣味性的。

本书出版之后,在我继续学业的同时,还会在业余时间计划在优酷视频网站上开一个频道,将Youtube网站上关于Haskell的视频讲座转传到优酷上供大家观看。如果有时间,可能会加一些中文字幕,也有可能自己做一系列的中文讲座,到时敬请大家关注。

最后,希望大家能通过这本书喜欢上Haskell,喜欢上函数式编程,愿Haskell可以给你学习编程带来乐趣,能给你的工作带来轻松和愉快。

图书在版编目(CIP)数据

Haskell函数式编程入门/张淞编著.--北京:人民邮电出版社,2014.3

ISBN 978-7-115-33801-3

Ⅰ.①H… Ⅱ.①张… Ⅲ.①函数—程序设计 Ⅳ.①TP311.1

中国版本图书馆CIP数据核字(2013)第277063号

内容提要

这是一本讲解Haskell这门经过精心设计和锤炼的纯函数式编程语言的书,同时也是一本通过Haskell来讲解函数式编程的方法与思想的书。全书共分三个部分。第一部分介绍函数式编程在解决数学与算法问题的精简与直观的特色,让不熟悉 Haskell 的读者对其建立初步的了解,同时通过解决一些算法问题,如斐波那契数列、八皇后问题、排序问题、24点等,引发一些对函数式编程方式的思考;第二部分介绍一些略微深入的Haskell内容,包括函子、Monoid、IO与Monad转换器等;最后一部分则涉及快速测试、惰性求值和并行编程等主题。

本书既适合对Haskell 和函数式编程感兴趣的程序员阅读,又适合作为 Haskell 语言入门教程,供计算机科学与数学专业的学生参考。

◆编著 张淞

责任编辑 杨海玲

责任印制 程彦红 焦志炜

◆人民邮电出版社出版发行  北京市丰台区成寿寺路11号

邮编 100164  电子邮件 315@ptpress.com.cn

网址 http://www.ptpress.com.cn

三河市海波印务有限公司印刷

◆开本:800×1000 1/16

印张:23.5

字数:538千字  2014年3月第1版

印数:1-3000册  2014年3月河北第1次印刷

定价:59.00元

读者服务热线:(010)81055410 印装质量热线:(010)81055316

反盗版热线:(010)81055315

相关图书

Clojure Web开发实战
Clojure Web开发实战
深入理解Scala
深入理解Scala
Haskell并行与并发编程
Haskell并行与并发编程
Haskell趣学指南
Haskell趣学指南
Clojure编程乐趣
Clojure编程乐趣
Clojure程序设计
Clojure程序设计

相关文章

相关课程