Haskell趣学指南

978-7-115-33559-3
作者: 【斯洛文尼亚】Miran Lipovaca
译者: 李亚舟宋方睿
编辑: 杨海玲

图书目录:

详情

这是一本讲解Haskell这门函数式编程语言的入门指南,语言通俗易懂,插图生动幽默,示例短小清晰,结构安排合理。书中从Haskell的基础知识讲起,涵盖了所有的基本概念和语法,内容涉及基本语法、递归、类型和类型类、函子、applicative 函子、monad、zipper及所有Haskell重要特性和强大功能。

图书摘要

版权信息

书名:Haskell趣学指南

ISBN:978-7-115-33559-3

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。


Copyright © 2011 by Miran Lipovača. Title of English-language original: Learn You a Haskell for Great Good!, ISBN 978-1-59327-283-8, published by No Starch Press. Simplified Chinese-language edition copyright © 2013 by Posts and Telecom Press. All rights reserved.

本书中文简体字版由美国No Starch出版社授权人民邮电出版社出版。未经出版者书面许可,对本书任何部分不得以任何方式复制或抄袭。

版权所有,侵权必究。


这是一本讲解Haskell 这门函数式编程语言的入门指南,语言通俗易懂,插图生动幽默,示例短小清晰,结构安排合理。书中从Haskell 的基础知识讲起,涵盖了所有的基本概念和语法,内容涉及基本语法、递归、类型和类型类、函子、applicative 函子、monad、zipper 及所有Haskell 重要特性和强大功能。

本书适合对函数式编程及Haskell 语言感兴趣的开发人员阅读。


每当朋友们问起:“Haskell是否值得学习?”我会毫不犹豫地肯定回答:“试问如果Haskell都不值得学习,还有哪门语言有同等的学习价值呢?”直到今天,Haskell仍排在本人心目中最值得学习的语言的前三位。若继续问下去:“为什么说值得学习?”回答却总是显得含糊一些:“开拓眼界吧?”“因为有趣?”

但是平心而论,“开拓眼界”和“有趣”中的一条就已经是足够充分的理由了。读者能否回忆一下最初学习编程的那种乐趣,是不是仿佛进入新世界一般?支持我们踏出去每一步的原动力,归根到底可能还是最单纯的“开拓眼界”和“有趣”。Haskell可以助你找回久违的耳目一新的感觉。而且,哪怕工作内容与函数式编程毫无关联,日常编写代码的思路也会在潜移默化中受到影响。读者不妨仔细体会。

2008年从朋友那里接触到Haskell之后,苦于国内没有全面的入门教程,便着手开始了《Learn You a Haskell》的翻译,这本书的内容风趣幽默,搭配精美的插图和丰富的例子,使得Haskell亲切可人,实在是入门的最佳选择。当时进展到第9章,后来作者更新到了第15章,却因为时间等原因没有跟上。未能使完整中文版面世,始终是几年间的一件憾事。

所幸人民邮电出版社引进了此书的中文翻译版权,经与方睿同学共同努力,基于之前的8章译稿,补完了后续的7章。期间得到了huangz、xiaohanyu、思寇特.熊、hotteran等朋友的热心帮助,在此一并致谢。对译者而言,翻译是最好的精读,虽然花费了很长时间,但更多花在学习Haskell本身,回忆起来仍是一段愉快的回忆。但是出版并非私人的事情,虽然此时稿件已告完工,仍惴惴不安,生怕拙劣的译笔使这么有爱的作品蒙尘。然而受水平所限,难免留下一些疏漏和错误,恳请广大读者批评指正。

李亚舟  

2013年11月于北京


本书主要面向已经有一定命令式编程经验(如C、C++、Java、Python等)、想要尝试一下Haskell的读者。还没有编程基础?没关系,像你这样的聪明小伙子一定能学好Haskell!

Haskell这门语言给我的第一印象是太晦涩。然而一旦迈过入门的门槛,随后的学习就顺畅多了。在一开始的学习中,Haskell可能稍显很古怪,但是不要放弃学习。学习Haskell的体验就像从零开始重新学习编程。它很有趣,而且能促使你按照不同的方式来思考。

注意:

如果在学习中遇到困难,Freenode网络的#haskell频道将是提问的绝佳去处。那儿的人们友善、有耐心且很照顾新人。对Haskell初学者而言,这是一个宝贵的资源。

Haskell是一种纯函数式编程语言(purely functional programming language)。

命令式语言中执行操作需要给电脑安排一组待执行的命令,随着命令的执行,状态会随之发生改变。例如,你将变量a赋值为5,随后做了其他一些事情之后a就可能变成其他值。此外,通过控制流结构(如for循环和while循环)可以让指令重复执行多次。然而在纯函数式编程语言中一切都与之不同。你不再像命令式语言那样命令电脑“要做什么”,而是通过用函数来描述问题“是什么”,如“阶乘是指从1到某数间所有数字的乘积”,或者“一列数值的总和等同于第一个数值与余下数值的总和相加的结果”。这两个操作都可以表示为函数。

在函数式编程语言中,变量一旦赋值,就不能更改了,你已经声明了a5,就不能改变主意,再另说a是别的什么数。做人不能食言,对不?

在纯函数式编程语言中,函数没有任何副作用。函数式编程语言中的函数能做的唯一一件事情就是求值并返回结果。一开始可能觉得这样会很受限,然而好处也正源于此:若以同样的参数调用同一函数两次,得到的结果总是相同的。这被称作引用透明性(referential transparency)。如此一来,允许你很容易地推论(甚至证明)一个函数的正确性,继而可以将一些简单的函数组合成更复杂的函数。

Haskell是惰性(lazy)的。也就是说,若非特殊指明,在真正需要结果以前,Haskell是不会执行函数的。这正是引用透明性的功劳:既然函数的返回值仅与给定的参数相关,那么函数在何时真正执行计算,就无关紧要了。Haskell作为一种惰性语言,基于这条性质,总是尽可能地推迟计算结果。只有当你需要展示计算结果时,Haskell才会执行最少量的计算,到足够展示结果为止。此外,惰性允许我们创建无限长度的数据结构,因为只有需要展示的部分数据结构才会真正被计算。

来看一个展示Haskell惰性的例子。假设你有一个数值的列表xs = [1,2,3,4,5,6,7,8],还有一个函数叫做doubleMe,它可以将列表中的所有元素都乘以2,并返回一个新的列表。如果想让整个列表乘以8,你可能会写下这样的代码:

doubleMe(doubleMe(doubleMe(xs)))

在命令式语言中,这会遍历一遍列表,复制一份列表,然后返回。随后还要重复遍历两次、复制两次,才能得到最终的结果。

在惰性语言中,不强迫输出结果的前提下,针对列表调用doubleMe,程序只会跟你说:“好,我待会做!”一旦你需要查看结果,第一个doubleMe就会要求第二个doubleMe马上将结果交给它。然后第二个doubleMe会向第三个doubleMe传达同样的话,这时第三个doubleMe只能不情愿地将1乘以22,交给第二个doubleMe。第二个doubleMe再乘以24,返回给第一个doubleMe。第一个doubleMe再将结果乘以2,最终告诉你结果列表中的首个元素是8。也就是说,因为惰性,这一切只需要遍历一次列表即可,而且仅在你真正需要结果时才会执行。

Haskell是静态类型(statically typed)的。当你编译程序时,编译器需要明确哪个是数字,哪个是字符串,等等。静态类型意味着很大一部分错误都可以在编译时被发现。比如,若试图将一个数字和字符串相加,编译器就会报错。

Haskell拥有一套强大的类型系统,支持类型推导(type inference)。这样一来你就不需要在每段代码上都标明它的类型,例如,如果计算a=5+4,你就不需要告诉编译器“a是一个数”,它可以自己推导出来。类型推导让你编写更加通用的程序更容易。假设有个二元函数是将两个数相加,你无需声明类型,这个函数即可对一切可以相加的值进行计算。

Haskell优雅且简洁。它采纳了许多高级概念,与同等的命令式语言相比,Haskell的代码往往更短。更短就意味着更容易维护,bug也就更少。

Haskell由一组天才的精英分子(很多博士)开发。Haskell的研发工作始于1987年,当时一个学会的研究员齐聚一堂,商讨设计一种强大的编程语言。时至1999年,Haskell Report发布,标志着稳定版本的最终确定。

简单讲,开始Haskell的学习,只需要一个编辑器和一个编译器。你可能已经安装了最喜欢的编辑器,在此不加赘述。如今最常用的Haskell编译器是Glasgow Haskell Compiler(GHC),本书也用它。

获取所需工具最简便的方式是下载Haskell Platform。除GHC编译器之外,Haskell Platform更包含了一系列有用的Haskell库!可以参照http://hackage.haskell.org/platform中的步骤,向你的操作系统中安装Haskell Platform。

GHC除了能够编译Haskell脚本(一般后缀为.hs),也提供了一个交互模式。在这里,你可以装载脚本中的函数,随后直接调用它们即可获得计算的结果。修改代码之后,使用交互模式观察代码的执行结果,要比编译然后执行方便得多。尤其是在学习过程中,交互模式十分有用。

安装Haskell Platform之后,打开一个终端窗口(假定你在使用Linux或者Mac OS X系统)如果你使用的是Windows系统,则打开命令提示符。然后,输入ghci并按回车键,进入交互模式。如果你的系统没有找到GHCi程序,可以尝试重启计算机。

假设你在一段脚本(如myfunctions.hs)中定义了几个函数,通过:l myfunctions命令即可将这些函数装载进入GHCi。(需要保证myfunctions.hs位于启动GHCi的同一目录之下。)

一旦修改了这个.hs脚本的内容,再次执行:l myfunctions.hs或者与之等价的:r,都可以重新装载该脚本。我本人通常的工作流程就是在.hs 文件中定义几个函数,然后装载到GHCi,对付它,修改文件,再装载,如此重复。这也正是我们将采用的基本流程。

感谢为本书提供勘误、建议乃至鼓励的每一个人。此外,感谢Keith、Sam和Marilyn使我变得像个真正的作家。


如果你属于那种从不看前言的人,我建议你还是回头看一下本书前言的最后一节比较好。那里讲解了如何使用本书,以及如何通过GHC加载函数。

首先,我们要做的就是进入GHC的交互模式,调用几个函数,以便我们简单地体验一把Haskel。打开终端,输入ghci,可以看到如下欢迎信息:

GHCi, version 6.12.3: http://www.haskell.org/ghc/ :? for help
Loading package ghc-prim ... linking ... done. 
Loading package integer-gmp ... linking ... done. 
Loading package base ... linking ... done. 
Loading package ffi-1.0 ... linking ... done. 

注意:

GHCi的默认命令行提示符为Prelude>,不过在本书接下来的例子中,我们将使用ghci>作为命令行提示符。若要设置你的命令行提示符与本书一致,输入:setprompt "ghci> "即可。如果不想每次打开GHCi都重新输入一遍,可以在你的home目录下创建一个.ghci文件,使它的内容为:set prompt "ghci>"

恭喜,你已经进入了GHCi!试几个简单的运算:

ghci> 2 + 15
17
ghci> 49 * 100
4900
ghci> 1892 - 1472
420
ghci> 5 / 2
2.5

如果在同一个表达式中使用了多个运算符,Haskell会按照运算符优先级顺序执行计算。比如*的优先级比-高,50*100-4999就相当于(50 * 100) - 4999

也可以通过括号来显式地指定运算次序,就像这样:

ghci> (50 * 100) - 4999
1
ghci> 50 * 100 - 4999
1
ghci> 50 * (100 - 4999) 
-244950

很酷,是吧?(哼,我知道一点也不酷,但请容许我自High一下嘛。)

有个小陷阱需要注意:只要表达式中存在负数常量,就最好用括号将它括起来。比如,执行5 * -3会使GHCi报错,而5*(-3)就不会有问题。

Haskell中的逻辑运算也同样直白。同许多编程语言一样,Haskell的布尔值为True与False,也有&;&;用来求逻辑与(布尔与运算)、||用来求逻辑或(布尔或运算),以及not用来对True或者False取反。

ghci> True && False
False
ghci> True && True
True
ghci> False || True
True
ghci> not False
True
ghci> not (True && True) 
False

也可以通过==与/=来判断两个值相等还是不等:

ghci> 5 == 5
True
ghci> 1 == 0
False
ghci> 5 /= 5
False
ghci> 5 /= 4
True
ghci> "hello" == "hello"
True

在同一个表达式中混用不一样的值要十分小心!如果我们输入了5+"llama"这样的代码,就会得到下面这样的出错消息了:

No instance for (Num [Char]) 
arising from a use of `+' at <interactive> :1:0-9
Possible fix: add an instance declaration for (Num [Char]) 
In the expression: 5 + "llama"
In the definition of `it': it = 5 + "llama"

GHCi 告诉我们,"llama"并不是数值,所以它不知道怎样才能给它加上5。+运算符要求它的两个参数都是数值才行。

另一方面,==可以比较任何两个可以比较的值,唯一标准就是:这两个值的类型必须相同。比如,我们如果输入True==5,GHCi就会报错。

注意:

5+4.0是合法的表达式,虽说4.0不是整数,但5既可以被看做整数也可以被看做浮点数。这样看,5与浮点数4.0的类型就匹配起来了。

到后面,我们会更加深入地学习类型。

也许你并未察觉,从始至终我们一直都在使用函数。*就是一个函数,它可以将两个数相乘。可见,在应用(又称调用)这个函数时,我们就像夹三明治一样用两个参数将它夹在中央,这种函数被称作中缀函数(infix function)。

至于其他的大部分函数,则属于前缀函数(prefix function)。在Haskell中调用前缀函数时,先是函数名和一个空格,后跟参数列表(也是由空格分隔)。简单举个例子,我们尝试调用Haskell中最无聊的函数succ

ghci> succ 8
9

succ函数可以取得参数的后继,前提是它要有明确的后继。一个整数的后继,就是比它大的下一个数了。

接下来尝试调用两个前缀函数minmax

ghci> min 9 10
9
ghci> min 3.4 3.2
3.2
ghci> max 100 101
101

minmax接受两个可比较大小的参数(如数),相应地返回较大或者较小的那个数。

在Haskell中,函数应用拥有最高的优先级。因而如下两句是等效的:

ghci> succ 9 + max 5 4 + 1
16
ghci> (succ 9) + (max 5 4) + 1
16

也就是说,取9乘10的后继,简单地像下面这样写是不行的:

ghci> succ 9 * 10

因为运算有优先级,这种写法相当于先取9的后继(得到10),然后再乘以10得100。要得到正确的写法,应该改成这样:

ghci> succ (9 * 10) 

得91。

如果某函数有两个参数,也可以用反引号(`)将它括起,以中缀函数的形式调用它。比如,div函数可以用来求两个整数的商,如下:

ghci> div 92 10
9

但这种形式并不容易理解:究竟是哪个数是除数,哪个数被除数?用上反引号,按照中缀函数的形式调用它就清晰多了:

ghci> 92 `div` 10
9

从命令式编程走过来的程序员往往觉得函数调用离不开括号,以至于一下子无法接受Haskell的风格。其实很简单:只要见到bar (bar 3)之类的东西,就是说以3为参数调用bar函数,随后用所得的结果再次调用bar。对应的C代码大约就是这样:bar(bar(3))

函数的声明与它的调用形式大体相同,都是先函数名,后跟由空格分隔的参数表。不过后面多了个等号(=),并且后面的代码定义了函数的行为。

举个例子,我们先写一个简单的函数,让它将一个数字乘以2。打开你最喜欢的编辑器,输入如下代码:

doubleMe x = x + x

将它保存为baby.hs或者任意名称,然后转至文件所在目录,打开GHCi,执行:l baby装载它。随后就可以跟我们的函数小朋友玩耍了:

ghci> :l baby
[1 of 1] Compiling Main             ( baby.hs, interpreted ) 
Ok, modules loaded: Main. 
ghci> doubleMe 9
18
ghci> doubleMe 8.3
16.6

+运算符对整数和浮点数都可用(实际上所有有数字特征的值都可以),所以我们的函数可以处理一切数值。

接下来声明一个取两个参数的函数,让它分别将两个参数乘以2再相加。修改baby.hs,将如下代码加到后面:

doubleUs x y = x * 2 + y * 2

注意:

Haskell中的函数定义并没有顺序的概念,所以baby.hs中函数定义的先后对程序没有任何影响。

将它保存,在GHCi中输入:l baby再次装载。测试它的结果是否符合预期:

ghci> doubleUs 4 9
26
ghci> doubleUs 2.3 34.2
73.0
ghci> doubleUs 28 88 + doubleMe 123
478

你也可以在函数中调用其他的函数,如此一来我们可以将doubleUs函数改为:

doubleUs x y = doubleMe x + doubleMe y

这种模式在Haskell中十分常见:编写一些明显正确的简单函数,然后将它们组合起来,形成一个较为复杂的函数。这是减少重复工作的金科玉律。设想,如果哪天有个数学家验证说2其实该是3,我们该怎么改?在这里,我们只需要将doubleMe改为x+x+x即可,由于doubleUs调用doubleMe,于是整个程序便轻松进入2即是3的古怪世界。

下面我们再编写一个函数, 它将小于等于100的数都乘以2 (因为大于100的数都已经足够大了)。

doubleSmallNumber x = if x > 100
                      then x
                      else x*2

这个例子就引出了Haskell的if语句。你也许已经对其他语言的else很熟悉,不过Haskell的if语句的与众不同之处就在于,else部分是不可省略的。

在命令式语言中,程序的执行就是一步又一步的操作,if语句可以没有else部分,如果条件不符合,就直接跳过这一步。因此,命令式语言中的if语句可以什么都不做。

而在Haskell中,程序是一系列函数的集合:函数取数据作为参数,并将它们转为想要的结果。每个函数都会返回一个结果,也都可以为其他函数所用。既然必须返回结果,那么每个if就必须同时跟着一个else,不管条件满足还是失败,都需要返回一个结果。一言以蔽之,Haskell中的if是一个必然返回结果的表达式(expression),而非语句(statement)。

假如我们想让之前的doubleSmallNumber函数的结果都加1,新的函数的定义将是如下的模样:

doubleSmallNumber' x = (if x > 100 then x else x*2) + 1

可以留意这里括号的使用,如果忽略掉括号,函数就会只在x小于等于100时给结果加1了。另外,也可以留意函数名最后的那个单引号,它没有任何特殊含义,只是一个函数名的合法字符罢了。通常我们会使用单引号来区分这是某函数的严格求值(与惰性求值相对)版本,或者是一个稍经修改但差别不大的函数。

既然'是合法字符,定义这样的函数也是可以的:

conanO'Brien = "It's a-me, Conan O'Brien!" 

在这里有两点需要注意。首先就是我们没有大写Conan的首字母,因为函数是不能以大写字母开始的(我们将在后面讨论其原因 0,另外就是这个函数并没有任何参数。没有参数的函数通常被称作定义或者名字,在函数被定义之后我们就再也不能修改它的内容,conanO'Brien从此与字符串"It's a-me, Conan O'Brien!"完全等价。

在Haskell中,列表是一种单类型的(homogeneous)数据结构,可以用来存储多个类型相同的元素。我们可以在里面装一组数字或者一组字符,但不能把字符和数字装在一起。

列表由方括号括起,其中的元素用逗号分隔开来:

ghci> let lostNumbers = [4,8,15,16,23,42] 
ghci> lostNumbers
[4,8,15,16,23,42] 

注意:

在GHCi中,可以使用let关键字来定义一个常量。在GHCi中执行let a = 1与在脚本中编写a=1随后用:l装载的效果是等价的。

拼接两个列表可以算是最常见的操作之一了,在Haskell中这可以通过++运算符实现:

ghci> [1,2,3,4] ++ [9,10,11,12] 
[1,2,3,4,9,10,11,12] 
ghci> "hello" ++ " " ++ "world"
"hello world"
ghci> ['w','o'] ++ ['o','t'] 
"woot"

注意:

Haskell中的字符串实际上就是一组字符组成的列表。"hello"只是['h','e','l','l','o']的语法糖而已。因此,我们可以使用处理列表的函数来对字符串进行操作。

在使用++运算符处理长字符串时要格外小心(对长的列表也是同样),Haskell会遍历整个第一个列表(++符号左边的列表)。在处理较短的字符串时问题还不大,但要是在一个长度为5000万的列表上追加元素,那可得执行好一会儿了。

不过,仅仅将一个元素插入列表头部的成本几乎为零,这里使用: 运算符(被称作Cons[1]运算符)即可:

ghci> 'A':" SMALL CAT"
"A SMALL CAT"
ghci> 5:[1,2,3,4,5] 
[5,1,2,3,4,5] 

可以留意,第一个例子中,取一个字符和一个字符构成的列表(即字符串)作为参数;第二个例子很相似,取一个数字和一个数字组成的列表作为参数。:运算符的第一个参数的类型一定要与第二个参数中列表中元素的类型保持一致。

而++运算符总是取两个列表作为参数。若要使用++运算符拼接单个元素到一个列表的最后,必须用方括号把它括起来,使之成为单元素的列表。

ghci> [1,2,3,4] ++ [5] 
[1,2,3,4,5] 

这里如果写成[1,2,3,4] ++ 5就错了,因为++运算符的两边都必须是列表才行,这里的5是个数,不是列表。

有趣的是,[1,2,3]实际上是1:2:3:[]的语法糖。[]表示一个空列表,若从前端插入3,它就成了[3],再插入2,它就成了[2,3],依此类推。

注意:

[][[]][[], [], []]是不同的。第一个是一个空的列表,第二个是含有一个空列表的列表,第三个是含有三个空列表的列表。

若是要按照索引取得列表中的元素,可以使用!!运算符,下标从0开始。

ghci> "Steve Buscemi" !! 6
'B'
ghci> [9.4,33.2,96.2,11.2,23.25] !! 1
33.2

但你如果想在一个只含有4个元素的列表中取它的第6个元素,就会得到一个错误。要小心!

列表同样也可以以列表为元素,甚至是列表的列表的列表:

ghci> let b = [[1,2,3,4],[5,3,3,3],[1,2,2,3,4],[1,2,3]] 
ghci> b
[[1,2,3,4],[5,3,3,3],[1,2,2,3,4],[1,2,3]] 
ghci> b ++ [[1,1,1,1]] 
[[1,2,3,4],[5,3,3,3],[1,2,2,3,4],[1,2,3],[1,1,1,1]] 
ghci> [6,6,6]:b
[[6,6,6],[1,2,3,4],[5,3,3,3],[1,2,2,3,4],[1,2,3]] 
ghci> b !! 2
[1,2,2,3,4] 

列表中的列表可以是不同长度的,但类型必须相同。与不能在同一个列表中混合放置字符和数组一样,混合放置数的列表和字符的列表也是不可以的。

只要列表内的元素是可以比较的,那就可以使用<、>、>= 和 <= 来比较两个列表的大小。 它会按照字典顺序,先比较两个列表的第一个元素,若它们的值相等,则比较二者的第二个元素,如果第二个仍然相等,再比较第三个,依此类推,直至遇到不同为止。两个列表的大小以二者第一个不等的元素的大小为准。

比如,执行[3, 4, 2] < [3, 4, 3]时,Haskell就会发现33相等,随后比较44,依然相等,再比较23,这时就得出结论了:第一个列表比第二个列表小。>、>=与<=也是同样的道理。

ghci> [3,2,1] > [2,1,0] 
True
ghci> [3,2,1] > [2,10,100] 
True
ghci> [3,4,2] < [3,4,3] 
True
ghci> [3,4,2] > [2,4] 
True
ghci> [3,4,2] == [3,4,2] 
True

此外,非空列表总认为比空列表更大。这样即可保证两个列表总是可以顺利地做比较,即使其中一个列表完全等同于另一个列表的开头部分。

下面是几个有关列表的常用函数,稍带用途与示例。

head函数返回一个列表的头部,也就是列表的第一个元素。

ghci> head [5,4,3,2,1] 
5

tail函数返回一个列表的尾部,也就是列表除去头部之后的部分:

ghci> tail [5,4,3,2,1] 
[4,3,2,1] 

last函数返回一个列表的最后一个元素。

ghci> last [5,4,3,2,1] 
1

init函数返回一个列表除去最后一个元素的部分。

ghci> init [5,4,3,2,1] 
[5,4,3,2] 

要更直观地看这些函数,我们可以把列表想象为一头怪兽,下面就是它的样子:

试一下,若是取一个空列表的头部又会怎样?

ghci> head []
*** Exception: Prelude.head: empty list

天哪,它翻脸了!如果怪兽压根就不存在,head又从何而来?在使用headtaillastinit时要小心,不要用到空列表上。这个错误不会在编译时被捕获,所以说做些工作以防止Haskell从空列表中取值是一个好的习惯。

length函数返回一个列表的长度:

ghci> length [5,4,3,2,1] 
5

null函数检查一个列表是否为空。如果是,则返回True;否则返回False:

ghci> null [1,2,3] 
False
ghci> null []
True

reverse函数将一个列表反转:

ghci> reverse [5,4,3,2,1] 
[1,2,3,4,5] 

take函数取一个数字和一个列表作为参数,返回列表中指定的前几个元素,如下:

ghci> take 3 [5,4,3,2,1] 
[5,4,3] 
ghci> take 1 [3,9,3] 
[3] 
ghci> take 5 [1,2] 
[1,2] 
ghci> take 0 [6,6,6] 
[]

如上,若是试图取超过列表长度的元素个数,只能得到原先的列表。若取0个元素,就会得到一个空列表。

drop函数的用法大体相同,不过它是删除一个列表中指定的前几个元素:

ghci> drop 3 [8,4,2,1,5,6] 
[1,5,6] 
ghci> drop 0 [1,2,3,4] 
[1,2,3,4] 
ghci> drop 100 [1,2,3,4] 
[]

maximum函数取一个列表作为参数,并返回最大的元素,其中的元素必须可以做比较。minimum函数与之相似,不过是返回最小的元素。

ghci> maximum [1,9,2,3,4] 
9
ghci> minimum [8,4,2,1,5,6] 
1

sum函数返回一个列表中所有元素的和。product函数返回一个列表中所有元素的积。

ghci> sum [5,2,1,6,3,2,5,7] 
31
ghci> product [6,2,1,2] 
24
ghci> product [1,2,5,6,7,9,2,0] 
0

elem函数可用来判断一个元素是否包含于一个列表,通常以中缀函数的形式调用它,这样更加清晰。

ghci> 4 elem [3,4,5,6] 
True
ghci> 10 elem [3,4,5,6] 
False

该怎样得到一个由1~20所有数组成的列表呢?我们完全可以用手把它们全都录入一遍,但显而易见,这并不是完美人士的方案,完美人士都用区间(range)。区间是构造列表的方法之一,而其中的值必须是可枚举的,或者说,是可以排序的。

例如,数字可以枚举为1、2、3、4等。字符同样也可以枚举:字母表就是A~Z所有字符的枚举。然而人名就不可以枚举了,“John”后面是谁?我不知道。

要得到包含1~20中所有自然数的列表,只要录入[1..20]即可,这与录入[1,2,3,4,5,6,7,8, 9,10,11,12,13,14,15,16,17,18,19,20]完全等价。两者的唯一区别是手写一串非常长的列表比较笨。

下面是一些例子:

ghci> [1..20] 
[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20] 
ghci> ['a'..'z'] 
"abcdefghijklmnopqrstuvwxyz"
ghci> ['K'..'Z'] 
"KLMNOPQRSTUVWXYZ"

区间很聪明,允许你告诉它一个步长。要得到1~20中所有的偶数,或者3的倍数该怎样?只要用逗号将前两个元素隔开,再标上区间的上限就好了:

ghci> [2,4..20] 
[2,4,6,8,10,12,14,16,18,20] 
ghci> [3,6..20] 
[3,6,9,12,15,18] 

尽管区间很聪明,但它恐怕还是难以满足人们对它过分的期许。比如,你就不能通过[1,2,4,8,16..100]这样的语句来获得100以下的所有2的幂。一方面是因为步长只能标明一次,另一方面就是仅凭前几项,数组后面的项也有可能是无法确定的。

注意:

要得到从20到1之间的列表,[20..1]是不可以的,必须得[20,19..1]。对于没有提供步长的区间(如[20..1]),Haskell会先构造一个空的列表,随后从区间的下限开始,不停地赠长,直到大于等于上限为止。既然20已经大于1了,那么所得的结果只能是个空列表。

你也可以不标明区间的上限,从而得到一个无限长度的列表。在后面我们会讲解关于无限列表的更多细节。取前24个13的倍数该怎样?下面是一种方法:

ghci> [13,26..24*13] 
[13,26,39,52,65,78,91,104,117,130,143,156,169,182,195,208,221,234,247,260,273,286,
299,312] 

但有更好的方法——使用无限长度的列表:

ghci> take 24 [13,26..] 
[13,26,39,52,65,78,91,104,117,130,143,156,169,182,195,208,221,234,247,260,273,286,
299,312] 

由于 Haskell 是惰性的,它不会对无限长度的列表直接求值(不然会没完没了)。它会等着,看你会从它那儿取哪些元素。在这里它见你只要前24个元素,便欣然交差。

下面是几个生成无限列表的函数。

ghci> take 10 (cycle [1,2,3]) 
[1,2,3,1,2,3,1,2,3,1] 
ghci> take 12 (cycle "LOL ")
"LOL LOL LOL "
ghci> take 10 (repeat 5) 
[5,5,5,5,5,5,5,5,5,5] 
ghci> replicate 3 10
[10,10,10] 

最后,在区间中使用浮点数要格外小心!浮点数依据定义,只能实现有限的精度。若是在区间中使用浮点数,你就会得到如下的糟糕结果:

ghci> [0.1, 0.3 .. 1] 
[0.1,0.3,0.5,0.7,0.8999999999999999,1.0999999999999999] 

列表推导式(list comprehension)是一种过滤、转换或者组合列表的方法。

学过数学的你对集合推导式(set comprehension)概念一定不会陌生。通过它,可以从既有的集合中按照规则产生一个新集合。前10个偶数的集合推导式可以写为{2 · x | xN, x≤ 10},先不管语法,它的含义十分直观:“取所有小于等于10的自然数,各自乘以2,将所得的结果组成一个新的集合。”

若要在Haskell中实现上述表达式,我们可以通过类似take 10 [2,4..]的代码来实现。不过,使用列表推导式也是同样的轻而易举:

ghci> [x*2 | x <- [1..10]] 
[2,4,6,8,10,12,14,16,18,20] 

我们可以再仔细看看这段代码,理解一下列表推导式的语法。

[x*2 | x &lt;- [1..10]]这段代码中,我们通过[x <- [1..10]]取了[1..10]这个列表中的每一项元素,x[1..10]中的每一项元素的值,也可以说,x[1..10]中每一项元素的绑定。竖线(|)前面的部分指列表推导式的输出,表示所取的值与计算结果的映射关系。在这个例子里,我们就是取[1..10]中的所有数字的2倍了。

看起来,这要比第一个例子长得多,也复杂得多。但是让所有数字乘以2只是一种简单的情景,如果遇到更复杂的情况该怎么办?这才是列表推导式大显身手的地方。

比如,我们想给这个列表推导式再添一条谓词(predicate)。它位于列表推导式最后面,与前面的部分由一个逗号分隔。在这里,我们只取乘以2后大于等于12的元素。

ghci> [x*2 | x <- [1..10], x*2 >= 12] 
[12,14,16,18,20] 

若是取50~100中所有除7的余数为3的元素该怎么办?很简单:

ghci> [ x | x <- [50..100], x `mod` 7 == 3] 
[52,59,66,73,80,87,94] 

注意:

从一个列表中筛选出符合特定谓词的元素的操作,也称为过滤(filter)。

再举个例子,假如我们想要一个列表推导式,它能够使列表中所有大于10的奇数变为"BANG",小于10的奇数变为"BOOM",其他则统统扔掉。方便起见,我们将这个推导式置于一个函数之中,使它易于重用。

boomBangs xs = [ if x < 10 then "BOOM!" else "BANG!" | x <- xs, odd x] 

注意:

记住,如果是在GHCi中定义这个函数,必须在函数名前面放一个let关键字。不过将函数写在脚本里再装载到GHCi的话就不必再加let了。

odd函数判断一个数是否为奇数:如果是,返回True;否则返回False。某项元素只有满足所有谓词时,才会被列表推导式留下。

ghci> boomBangs [7..13] 
["BOOM!","BOOM!","BANG!","BANG!"] 

也可以加多个谓词,中间用逗号隔开。比如,取10~20中所有不等于13、15或19的数,可以这样:

ghci> [ x | x <- [10..20], x /= 13, x /= 15, x /= 19] 
[10,11,12,14,16,17,18,20] 

除了多项谓词之外,从多个列表中取元素也是可以的。当分别从多个列表中取元素时,将得到这些列表中元素的所有组合:

ghci> [x+y | x <- [1,2,3], y <- [10,100,1000]] 
[11,101,1001,12,102,1002,13,103,1003] 

这里的x取自[1, 2, 3]y取自[10, 100, 1000]。随后这两个列表按照如下的方式组合:首先x成为1,同时y分别取[10, 100, 1000]中的每一个值。由于列表推导式的输出为x+y,可得到11、101、1001作为结果的开头部分(即1分别与10、100与1000相加的结果)。随后x成为2,同理可得12、102与1002,追加到结果的后面。对3也同理。

按照这一规律,[1,2,3]中的每个元素都与[10,100,1000]中的元素按照所有可能的方式相组合,再按x+y取得所有这些组合的和。

下面是另一个例子。假设有两个列表,即[2,5,10][8,10,11],要取它们所有组合的积,可以写出这样的列表推导式:

ghci> [ x*y | x <- [2,5,10], y <- [8,10,11]] 
[16,20,22,40,50,55,80,100,110] 

意料之中,得到的新列表长度为9。若只取乘积大于50的结果该怎样?只需要再加一条谓词:

ghci> [ x*y | x <-[2,5,10], y <- [8,10,11], x*y > 50] 
[55,80,100,110] 

我们再取一个包含一组名词和形容词的列表推导式吧,写诗的话也许用得着。

ghci> let nouns = ["hobo","frog","pope"]
ghci> let adjectives = ["lazy","grouchy","scheming"]
ghci> [adjective ++ " " ++ noun | adjective <- adjectives, noun <- nouns] 
["lazy hobo","lazy frog","lazy pope","grouchy hobo","grouchy frog", 
"grouchy pope","scheming hobo", "scheming frog","scheming pope"]

我们甚至可以通过列表推导式来编写自己的length函数!就叫它length'。这个函数首先将列表中的每个元素替换为1,随后通过sum将它们加起来,从而得到列表长度。

length' xs = sum [1 | _ <- xs] 

这里我们用了一个下划线(_)作为绑定列表中元素的临时变量,表示我们并不关心它具体的值。

记住,字符串也是列表,完全可以使用列表推导式来处理字符串。下面是一个除去字符串中所有非大写字母的函数:

removeNonUppercase st = [ c | c <- st, c `elem` ['A'..'Z']] 

在这里,主要的工作由谓词完成。它给出了保证:只有位于['A'..'Z']这一区间之内的字符才被视为是大写。我们可以将这个函数装载到GHCi测试一下:

ghci> removeNonUppercase "Hahaha! Ahahaha!" 
"HA"
ghci> removeNonUppercase "IdontLIKEFROGS"
"ILIKEFROGS"

对于列表的列表可以通过嵌套的列表推导式处理。假设有一个包含许多数值的列表的列表,让我们在不拆开它的前提下除去其中的所有奇数:

ghci> let xxs = [[1,3,5,2,3,1,2,4,5],[1,2,3,4,5,6,7,8,9],[1,2,4,2,1,6,3,1,3,2,3,6]] 
ghci> [ [ x | x <- xs, even x ] | xs <- xxs] 
[[2,2,4],[2,4,6,8],[2,4,2,6,2,6]] 

这里的输出部分嵌套了另一个列表推导式。已知列表推导式的返回结果永远是列表,因而可以看出,这里的返回结果是一个由数值组成的列表的列表。

注意:

为提升可读性,将列表推导式分成多行也是可以的。若非在GHCi之下,还是将列表推导式分成多行比较好,尤其是需要嵌套的时候。

元组(tuple)允许我们将多个异构的值组合成为一个单一的值。

从某种意义上讲,元组很像列表。但它们却有着本质的不同。首先就像前面所说,元组是异构的,这表示单个元组可以含有多种类型的元素。其次,元组的长度固定,在将元素存入元组的同时,必须明确元素的数目。

元组由括号括起,其中的项由逗号隔开。

ghci> (1, 3) 
(1,3) 
ghci> (3, 'a', "hello")
(3,'a',"hello")
ghci> (50, 50.4, "hello", 'b') 
(50,50.4,"hello",'b') 

为理解元组的用途,我们可以思考一下Haskell中二维向量的表示方法。使用列表是可以的,按照[x, y]的形式,它倒也工作良好。若要将一组向量置于一个列表中来表示二维坐标系中的平面图形又该怎样?我们可以写个列表的列表,像这样:[[1,2],[8,11],[4,5]]

但是,如果遇到[[1,2],[8,11,5],[4,5]]这样的列表并把它当做向量列表来使用,这种方法就有问题了。它并不是合法的向量列表,却是一个合法的列表的列表,毕竟其中元素的类型都相同(数值的列表组成的列表)。有这种情况存在,编写处理向量与图形的函数将复杂得多。

然而长度为2的元组(也称作序对,pair)与长度为3的元组(也称作三元组,triple)被视为不同的类型。这便意味着一个包含一组序对的列表不能再加入一个三元组。基于这个性质,使用元组来表示向量无疑更加合适。

要将原先的向量改为用元组表示,可以把里面的方括号改成括号,像这样[(1, 2), (8, 11), (4, 5)]。如果不小心将二元组与三元组混到了一起,就会报出以下的错误:

ghci> [(1,2),(8,11,5),(4,5)] 
Couldn't match expected type `(t, t1)'
against inferred type `(t2, t3, t4)'
In the expression: (8, 11, 5) 
In the expression: [(1, 2), (8, 11, 5), (4, 5)] 
In the definition of `it': it = [(1, 2), (8, 11, 5), (4, 5)] 

同样,即使两个元组的长度相同,但其中的元素的类型不一样,Haskell也会将它们视为不同的类型。比如[(1,2),("one",2)]这样的列表就有问题,因为其中的第一个元组是一对数,而第二个元组却成了一个字符串和一个数。

元组可以方便地用来表示一组数据的关联关系。比如,我们要表示一个人的姓名与年龄,可以使用这样的三元组:("Christopher", "Walken", 55)

需要记住,元组是固定大小的——使用元组时应事先了解它里面含有多少项。乍一接触可能会觉得挺死板,不过每个不同长度的元组都是独立的类型,这也就意味着你无法写一个通用的函数为它追加元素。而只能单独写一个将元素与二元组构成三元组的函数、将元素与三元组构成四元组的函数,以及将元素与四元组构成五元组的函数,依此类推。

同列表相同,只要其中的项是可比较的,元组也可以比较大小,只是不可以像比较不同长度的列表那样比较不同长度的元组。

可以有单元素的列表,但元组不行。可以这样想:单元素的元组的性质与它里面包含的元素本身几乎一模一样,那么徒增一个新的类型又有什么用处呢?

在Haskell中,将数据保存在序对里十分常见。对此,Haskell内置了许多有用的函数来处理序对。下面给出的是其中的两个函数。

ghci> fst (8,11) 
8
ghci> fst ("Wow", False) 
"Wow"
ghci> snd (8,11) 
11
ghci> snd ("Wow", False) 
False

注意:

这两个函数仅对序对有效,而不能应用于三元组、四元组和五元组之上。稍后,我们将过一遍从元组中取数据的所有方式。

zip函数可以用来酷酷地生成一组序对的列表。它取两个列表作为参数,然后将它们交叉配对,形成一组序对。它的外表很简单,不过当你需要同时遍历两个列表时,就可以体会到它的实用。比如:

ghci> zip [1,2,3,4,5] [5,5,5,5,5] 
[(1,5),(2,5),(3,5),(4,5),(5,5)] 
ghci> zip [1 .. 5] ["one", "two", "three", "four", "five"]
[(1,"one"),(2,"two"),(3,"three"),(4,"four"),(5,"five")]

注意,由于序对中可以含有不同的类型,zip函数也可以将不同类型的序对组合在一起。不过,若要组合两个不同长度的列表会怎么样?

ghci> zip [5,3,2,6,2,7,2,5,4,6,6] ["im","a","turtle"]
[(5,"im"),(3,"a"),(2,"turtle")]

可见,较长的列表会在中间断开,至较短的列表结束为止,余下的部分就直接忽略掉了。而且,由于Haskell是惰性的,我们也可以使用zip组合有限的和无限的列表:

ghci> zip [1..] ["apple", "orange", "cherry", "mango"]
[(1,"apple"),(2,"orange"),(3,"cherry"),(4,"mango")]

接下来思考一道同时用到元组与列表推导式的题目,使用Haskell来找到所有满足下列条件的直角三角形:

如果三角形中有一个角是直角,那它就是一个直角三角形。直角三角形有一条重要的性质,那就是直角的两条边的平方和等于对边的平方。如图所示,两条直角边分别标记为ab,直角的对边标记为c。其中c也被称作斜边。

首先,把所有三个元素都小于等于10的元组都列出来:

ghci> let triples = [ (a,b,c) | c <- [1..10], a <- [1..10], b <- [1..10] ] 

我们在三个列表中取值,并且左侧的输出部分将它们组合为一个三元组。随后在GHCi下边执行triples,可得到一个含有1000个元素的列表,就不在这里列出了。

我们接下来给它添加一个过滤条件,使其符合勾股定理(a2+b2=c2)。同时也考虑上a边要短于斜边(c边),b边要短于a边情况:

ghci> let rightTriangles = [ (a,b,c) | c <- [1..10], a <- [1..c], b <- [1..a], 
a^2 + b^2 == c^2] 

注意,在这里我们限制了求解的区间,不再检查多余的三元组,比如b边比斜边长的情形(直角三角形中,斜边总比直角边长),同时假定b边总不长于a边。这对结果是没有影响的,即使忽略掉所有使a^2+b^2=c^2b>a(a, b, c),还有(b, a, c)在——它们实际上同一个三角形,只是直角边的顺序相反罢了。否则,我们得到的列表将无端多出一半重复的三角形。

注意:

在GHCi中,不允许将定义与表达式拆为多行。不过在本书中,因为版面的原因,我们偶尔不得不将一行代码折行。(不然这本书将特别特别宽,宽到放不到普通的书架上了——读者也就只有买个大书架才行呢。)

已经差不多了。最后修改函数,告诉它只要周长为24的三角形。

ghci> let rightTriangles' = [ (a,b,c) | c <- [1..10], a <- [1..c], b <- [1..a],
a^2 + b^2 == c^2, a+b+c == 24] 
ghci> rightTriangles'
[(6,8,10)] 

得到正确结果!这便是函数式编程的一般思路:先取一个初始的集合并将其变形,随后持续地利用过滤条件缩小计算范围,最终取得一个(或多个)最终解。

[1] Cons来自于Lisp,意为构建(construct)一个序对,而序对正是Lisp中列表的基本节点。—译者注

[2] range为作者的双关语,它既有“区间”的意思,亦有“牧场”的意思。因此在这里的插图是得州牛仔。—译者注


强大的类型系统是Haskell的秘密武器。

在Haskell中,每个表达式都会在编译时得到明确的类型,从而提高代码的安全性。若你写的程序试图让布尔值与数相除,就不会通过编译。这样的好处就是与其让程序在运行时崩溃,不如在编译时捕获可能的错误。Haskell中一切皆有类型,因此编译器在编译时可以得到较多的信息来检查错误。

与Java和Pascal不同,Haskell支持类型推导(type inference)。写下一个数,不必额外告诉Haskell说“它是个数”,Haskell自己就能推导出来。

在此之前,我们对Haskell类型的讲解还只是一扫而过而已,到这里不妨打起精神来,因为理解这套类型系统对于Haskell的学习是至关重要的。

我们可以使用GHCi来检查表达式的类型。通过:t命令,后跟任何合法的表达式,即可得到该表达式的类型。先试一下:

ghci> :t 'a'
'a' :: Char
ghci> :t True
True :: Bool
ghci> :t "HELLO!" 
"HELLO!" :: [Char] 
ghci> :t (True, 'a') 
(True, 'a') :: (Bool, Char) 
ghci> :t 4 == 5
4 == 5 :: Bool

::读作“它的类型为”。凡是明确的类型,其首字母必为大写。'a'Char类型,意为字符(character)类型;TrueBool类型,意为布尔(Boolean)类型;而字符串"HELLO!"的类型显示为[Char],其中的方括号表示这是个列表,所以我们可以将它读作“一组字符的列表”。元组与列表不同,每个不同长度的元组都有其独立的类型,于是(True, 'a')的类型为(Bool, Char),而('a', 'b', 'c')的类型为(Char, Char, Char)4 == 5必返回False,因此它的类型为Bool

同样,函数也有类型。编写函数时,给它一个显式的类型声明是一个好习惯(至于比较短的函数就不必多此一举了)。从此刻开始,我们会对我们创建的所有函数都给出类型声明。

还记得第1章中我们写的那个过滤大写字母的列表推导式吗?给它加上类型声明便是这个样子:

removeNonUppercase :: [Char] -> [Char] 
removeNonUppercase st = [ c | c <- st, c `elem` ['A'..'Z']] 

这里removeNonUppercase的类型为[Char] -> [Char],意为它取一个字符串作为参数,返回另一个字符串作为结果。

不过,多个参数的函数该怎样指定其类型呢?下面便是一个将三个整数相加的简单函数。

addThree :: Int -> Int -> Int -> Int
addThree x y z = x + y + z

参数与返回类型之间都是通过->分隔,最后一项就是返回值的类型了。(在第5章,我们将讲解为何统统采用->分隔,而非Int, Int, Int -> Int之类“更好看”的方式。)

如果你打算给自己的函数加上类型声明,却拿不准它的类型是什么。那就先不管它,把函数先写出来,再使用:t命令测一下即可。因为函数也是表达式,所以:t对函数也是同样可用的。

接下来我们看几个Haskell中常见的基本类型,比如用于表示数、字符、布尔值的类型。

注意:

我们使用的 GHC 编译器规定 Int 的界限与机器相关。如果你的机器采用64位CPU,那么Int 的最小值一般为−263,最大值为263−1。

factorial :: Integer -> Integer
factorial n = product [1..n] 

然后通过:l将它装载入GHCi并进行测试:

ghci> factorial 50
30414093201713378043612608166064768844377641568960512000000000000
circumference :: Float -> Float
circumference r = 2 * pi * r

随后装载并测试:

ghci> circumference 4.0
25.132742
circumference' :: Double -> Double
circumference' r = 2 * pi * r

装载并测试。可以特别留意circumferencecircumference'两者在精度上的差异。

ghci> circumference' 4.0
25.132741228718345

有时让一些函数处理多种类型将更加合理。比如head函数,它可以取一个列表作为参数,返回这一列表头部的元素。在这里列表中元素的类型不管是数值、字符还是列表,都不重要。不管它具体的类型是什么,只要是列表,head函数都能够处理。

猜猜head函数的类型是什么呢?用:t检查一下:

ghci> :t head
head :: [a] -> a

这里的a是什么?是类型吗?想想我们在前面说过,凡是类型其首字母必大写,所以它不是类型。它其实是个类型变量(type variable),意味着a可以是任何类型。

通过类型变量,我们可以在类型安全(type safe))的前提下,轻而易举地编写能够处理多种类型的函数。这一点与其他语言中的泛型(generic)很相似,但在Haskell中要更为强大,更容易写出通用的函数。

使用了类型变量的函数被称作多态函数(polymorphic function)。head函数即为此例,从它的类型声明中可以看出,它的参数类型为任意类型的元素组成的列表,返回的类型也正是该类型。

注意:

在命名上,类型变量使用多个字符是合法的,不过约定俗成,通常都是使用单个字符作为名字,如a,b,c,d...

还记得fst吗?它可以返回一个序对中的首项。查一下它的类型:

ghci> :t fst
fst :: (a, b) -> a

可以看出fst取一个元组作为参数,且返回类型与元组中首项的类型相同。这便是fst能够处理任何类型序对的原因。注意,ab是不同的类型变量,并非特指二者表示的类型不同,这就意味着,在这段类型声明中元组首项的类型与返回值的类型可以相同。

类型类(typeclass)是定义行为的接口。如果一个类型是某类型类的实例(instance),那它必实现了该类型类所描述的行为。

说得更具体些,类型类是一组函数的集合,如果将某类型实现为某类型类的实例,那就需要为这一类型提供这些函数的相应实现。

可以拿定义相等性的类型类作为例子。许多类型的值都可以通过==运算符来判断相等性,我们先检查一下它的类型签名:

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

注意,判断相等性的==运算符实际上是一个函数,+、-、*、/之类的运算符也是同样。如果一个函数的名字皆为特殊字符,则默认为中缀函数。若要检查它的类型、传递给其他函数调用或者作为前缀函数调用,就必须得像上面的例子那样,用括号将它括起来。

在这里我们见到了一个新东西,即=>符号。它的左侧叫做类型约束(type constraint)。我们可以这样读这段类型声明:“相等性函数取两个相同类型的值作为参数并返回一个布尔值,而这两个参数的类型同为Eq类的实例。”

Eq这一类型类提供了判断相等性的接口,凡是可比较相等性的类型必属于Eq类。Haskell中所有的标准类型都是Eq类的实例(除与输入输出相关的类型和函数之外)。

注意:

千万不要将Haskell的类型类与面向对象语言中类(Class)的概念混淆。

接下来我们将观察几个Haskell中最常见的类型类,比如判断相等性的类型类、判断次序的类型类、打印为字符串的类型类等。

前面已提到,Eq类型类用于可判断相等性的类型,要求它的实例必须实现==/=两个函数。如果函数中的某个类型变量声明了属于Eq的类型约束,那么它就必然定义了==/=。也就是说,对于这一类型提供了特定的函数实现。下面即是操作Eq类型类的几个实例的例子:

ghci> 5 == 5
True
ghci> 5 /= 5
False
ghci> 'a' == 'a'
True
ghci> "Ho Ho" == "Ho Ho"
True
ghci> 3.432 == 3.432
True

Ord类型类用于可比较大小的类型。作为一个例子,我们先看看大于号也就是>运算符的类型声明:

ghci> :t (>)
(>) :: (Ord a) => a -> a -> Bool

>运算符的类型与==很相似。取两个参数,返回一个Bool类型的值,告诉我们这两个参数是否满足大于关系。

除了函数以外,我们目前所谈到的所有类型都是Ord的实例。Ord类型类中包含了所有标准的比较函数,如<、>、<=、>=等。

compare函数取两个Ord中的相同类型的值作为参数,返回一个Ordering类型的值。Ordering类型有GTLTEQ三种值,分别表示大于、小于和等于。

ghci> "Abrakadabra" < "Zebra"
True
ghci> "Abrakadabra" `compare` "Zebra"
LT
ghci> 5 >= 2
True
ghci> 5 `compare` 3
GT
ghci> 'b' > 'a' 
True

Show类型类的实例为可以表示为字符串的类型。目前为止,我们提到的除函数以外的所有类型都是Show的实例。操作Show类型类的实例的函数中,最常用的是show。它可以取任一Show的实例类型作为参数,并将其转为字符串:

ghci> show 3
"3"
ghci> show 5.334
"5.334"
ghci> show True
"True"

Read类型类可以看做是与Show相反的类型类。同样,我们提到的所有类型都是Read的实例。read函数可以取一个字符串作为参数并转为Read的某个实例的类型。

ghci> read "True" || False
True
ghci> read "8.2" + 3.8
12.0
ghci> read "5" - 2
3
ghci> read "[1,2,3,4]" ++ [3] 
[1,2,3,4,3] 

至此一切良好。但是,尝试read "4"又会怎样?

ghci> read "4"
<interactive >:1:0: 
   Ambiguous type variable 'a' in the constraint: 
     'Read a' arising from a use of 'read' at <interactive>:1:0-7
   Probable fix: add a type signature that fixes these type variable(s) 

GHCi跟我们抱怨,搞不清楚我们想要的返回值究竟是什么类型。注意前面我们调用 read之后,都利用所得的结果进行了进一步运算,GHCi也正是通过这一点来辨认类型的。如果我们的表达式的最终结果是一个布尔值,它就知道read的返回类型应该是Bool。在这里它只知道我们要的类型属于Read类型类,但不能明确到底是哪个类型。看一下read函数的类型签名吧:

ghci> :t read
read :: (Read a) => String -> a

注意:

String只是[Char]的一个别名。String[Char]完全等价、可以互换,不过从现在开始,我们将尽量多用String了,因为String更易于书写,可读性也更高。

可见,read的返回值属于Read类型类的实例,但我们若用不到这个值,它就永远都不会知道返回值的类型。要解决这一问题,我们可以使用类型注解(type annotation)。

类型注解跟在表达式后面,通过::分隔,用来显式地告知Haskell某表达式的类型。

ghci> read "5" :: Int
5
ghci> read "5" :: Float
5.0
ghci> (read "5" :: Float) * 4
20.0
ghci> read "[1,2,3,4]" :: [Int] 
[1,2,3,4] 
ghci> read "(3, 'a')" :: (Int, Char) 
(3, 'a') 

编译器通常可以辨认出大部分表达式的类型,但也不是万能的。比如,遇到 read "5" 时,编译器就会无法分辨这个类型究竟是Int还是Float了。只有经过运算,Haskell才能明确其类型;同时由于Haskell是一门静态类型语言,它必须在编译之前(或者在GHCi的解释之前)搞清楚所有表达式的类型。所以我们最好提前给它打声招呼:“嘿,这个表达式应该是这个类型,免得你认不出来!”

要Haskell辨认出read的返回类型,我们只需提供最少的信息即可。比如,我们将read的结果放到一个列表中,Haskell即可通过这个列表中的其他元素的类型来分辨出正确的类型。

ghci> [read "True", False, True, False] 
[True, False, True, False] 

在这里我们将read "True"作为由Bool值组成的列表中的一个元素,Haskell看到了这里的Bool类型,就知道read "True"的类型一定是Bool了。

Enum的实例类型都是有连续顺序的——它们的值都是可以枚举的。Enum类型类的主要好处在于我们可以在区间中使用这些类型:每个值都有相应的后继(successer)和前趋(predecesor),分别可以通过succ函数和pred函数得到。该类型类包含的类型主要有()、Bool、Char、Ordering、Int、Integer、FloatDouble

ghci> ['a'..'e'] 
"abcde"
ghci> [LT .. GT] 
[LT,EQ,GT] 
ghci> [3 .. 5] 
[3,4,5] 
ghci> succ 'B'
'C'

Bounded类型类的实例类型都有一个上限和下限,分别可以通过maxBoundminBound两个函数得到。

ghci> minBound :: Int
-2147483648
ghci> maxBound :: Char
'\1114111'
ghci> maxBound :: Bool
True
ghci> minBound :: Bool
False

minBoundmaxBound两个函数很有趣,类型都是(Bounded a) => a。可以说,它们都是多态常量(polymorphic constant)。

注意,如果元组中项的类型都属于Bounded类型类的实例,那么这个元组也属于Bounded的实例了。

ghci> maxBound :: (Bool, Int, Char) 
(True,2147483647,'\1114111') 

Num是一个表示数值的类型类,它的实例类型都具有数的特征。先检查一个数的类型:

ghci> :t 20
20 :: (Num t) => t

看样子所有的数都是多态常量,它可以具有任何Num类型类中的实例类型的特征,如IntIntegerFloatDouble

ghci> 20 :: Int
20
ghci> 20 :: Integer
20
ghci> 20 :: Float
20.0
ghci> 20 :: Double
20.0

作为例子,我们检查一下*运算符的类型:

ghci> :t (*)
(*) :: (Num a) => a -> a -> a

可见*取两个相同类型的数值作为参数,并返回同一类型的数值。由于类型约束,所以(5 :: Int) * (6 :: Integer)会导致一个类型错误,而5 * (6 :: Integer)就不会有问题。5既可以是Int类型也可以是Integer类型,但Integer类型与Int类型不能同时用。

只有已经属于ShowEq的实例类型,才可以成为Num类型类的实例。

Floating类型类仅包含FloatDouble两种浮点类型,用于存储浮点数。

使用Floating类型类的实例类型作为参数类型或者返回类型的函数,一般是需要用到浮点数来进行某种计算的,如sincossqrt

Integral是另一个表示数值的类型类。Num类型类包含了实数和整数在内的所有的数值相关类型,而Intgeral仅包含整数,其实例类型有IntInteger

有一个函数在处理数字时会非常有用,它便是fromIntegral。其类型声明为:

fromIntegral :: (Integral a, Num b) => a -> b

注意:

留意fromIntegral的类型签名中用到了多个类型约束,这是合法的,只要将多个类型约束放到括号里用逗号隔开即可。

从这段类型签名中可以看出,fromIntegeral函数取一个整数作为参数并返回一个更加通用的数值,这在同时处理整数和浮点数时尤为有用。举例来说,length函数的类型声明为:

length :: [a] -> Int

这就意味着,如果取了一个列表的长度,再给它加3.2就会报错(因为这是将Int类型与浮点数类型相加)。面对这种情况,我们即可通过fromIntegral来解决,具体如下:

ghci> fromIntegral (length [1,2,3,4]) + 3.2
7.2

由于类型类定义的是一个抽象的接口,一个类型可以作为多个类型类的实例,一个类型类也可以含有多个类型作为实例。比如,Char类型就是多个类型类的实例,其中包括OrdEq,我们可以比较两个字符是否相等,也可以按照字母表顺序来比较它们。

有时,一个类型必须在成为某类型类的实例之后,才能成为另一个类型类的实例。比如,某类型若要成为Ord的实例,那它必须首先成为Eq的实例才行。或者说,成为Eq的实例,是成为Ord的实例的先决条件(prerequisite)。这一点不难明白,比如当我们比较两个值的顺序时,一定可以顺便得出这两个值是否相等。


相关图书

Clojure Web开发实战
Clojure Web开发实战
深入理解Scala
深入理解Scala
Haskell并行与并发编程
Haskell并行与并发编程
Haskell函数式编程入门
Haskell函数式编程入门
Clojure编程乐趣
Clojure编程乐趣
Clojure程序设计
Clojure程序设计

相关文章

相关课程