Haskell 趣学指南
这是一本讲解 Haskell 这门函数式编程语言的入门指南,语言通俗易懂,插图生动幽默,示例短小清晰,结构安排合理。书中从 Haskell 的基础知识讲起,涵盖了所有的基本概念和语法,内容设计基本语法、递归、类型和类型类、函子、applicative 函子、monad、zipper 及所有 Haskell 重要特性和强大功能。
本书适合对函数式编程及 Haskell 语言感兴趣的开发人员阅读。
准备工作
- 在 Windows 上安装 Haskell 环境
在 PowerShell 会话中运行以下命令(作为非管理员用户):
配置国内镜像源
1 |
|
安装
1 |
|
- 为 VSCode 安装 Haskell 插件:
Haskell
、Haskell Syntax Highlighting
第 1 章 各就各位,预备!
安装成功 Haskell 开发环境后,打开终端,输入 ghci,看到如下欢迎信息:
GHCi,version 8.10.7: https://www.haskell.org/ghc/ :? for help
说明安装成功。
GHCi 的默认命令提示符为 Prelude>
,配置其为 ghci>
:
- 临时方法,输入
:set prompt "ghci>"
,缺点:每次都要输入一遍 - 永久方法,在 home 目录下创建一个
.ghci
文件,内容为:set prompt "ghci>"
简单使用
1 |
|
1.1 调用函数
*
是一个函数,它可以将两个数相乘。这种函数被称为中缀函数
。
其他的大部分函数,属于前缀函数
。在 Haskell 中调用前缀函数时,先是函数名和一个空格,后跟参数列表(也由空格分隔)。
举例:succ
函数可以取得参数的后继,前提是它要有明确的后继。ghci> succ 8
9
min、max 函数:ghci> min 9 10
ghci> min 3.4 3.2
ghci> max 100 101
将前缀形式转成中缀形式:用反引号(`)将函数括起来,就能以中缀形式调用。
例如:div 92 10
等效于 92 `div` 10
1.2 小朋友的第一个函数
函数的声明与它的调用形式大体相同,都是先函数名,后跟空格分隔的参数表。不过后面多了一个等号(=),并且后面的代码定义了函数的行为。doubleMe x = x + x
将它保存为baby.hs
或者任意名字,然后转至文件所在目录,打开 CHCi,执行:l baby
装载它。随后就可以使用它了:
1 |
|
定义几个不同的函数:
1 |
|
1.3 列表
在 Haskell 中,列表是一种单类型的数据结构,可以用来存储多个类型相同的元素。
用 let 关键字定义一个列表常量:let lostNumbers = [4,8,15,16,23,42]
拼接列表
方式一:++
运算符,两个操作数必须都为列表[1,2,3,4] ++ [9,10,11,12] -> [1,2,3,4,9,10,11,12]
、
方式二::
运算符(被称为 Cons 运算符),仅将一个元素插入到列表的头部'A':" SMALL CAT" -> "A SMALL CAT"
访问列表中的元素
使用!!
运算符,下标从 0 开始:"Steve Buscemi" !! 6 -> 'B'
[9.4,33.2,96.2,11.2,23.25] !! 1 -> 33.2
嵌套列表
let b = [[1,2,3,4],[5,3,3,3],[1,2,2,3,4],[1,2,3]]
b ++ [[1,1,1,1]]
[6,6,6] : b
b !! 2
列表中的列表可以是不同长度的,但类型必须相同。
比较列表
两个列表的大小以第一个不相等的元素的大小为准。
[3,2,1] > [2,1,0] -> True
[3,2,1] > [2,10,100] -> True
[3,4,2] < [3,4,3] -> True
[3,4,2] > [2,4] -> True
更多列表操作
head
函数返回一个列表的头部:head [5,4,3,2,1] -> 5
tail
函数返回一个列表的尾部,也就是列表除去头部之后的部分:tail [5,4,3,2,1] -> [4,3,2,1]
last
函数返回一个列表的最后一个元素:last [5,4,3,2,1] -> 1
init
函数返回一个列表除去最后一个元素的部分:init [5,4,3,2,1] -> [5,4,3,2]
length
函数7返回一个列表的长度:length [5,4,3,2,1] -> 5
null
函数检查一个列表是否为空:null [1,2,3] -> False; null [] -> True
reverse
函数将一个列表反转:reverse [5,4,3,2,1] -> [1,2,3,4,5]
take
函数取一个数字和一个列表作为参数,返回列表中指定的前几个元素:take 3 [5,4,3,2,1] -> [5,4,3]
take 5 [1,2] -> [1,2]
take 0 [6,6,6] -> []
drop
函数删除一个列表中指定的前几个元素:drop 3 [8,4,2,1,5,6] -> [1,5,6]
drop 0 [1,2,3,4] -> [1,2,3,4]
drop 100 [1,2,3,4] -> []
maximum
函数返回一个列表的最大元素:maximum [1,9,2,3,4] -> 9
minimum
函数返回一个列表的最小元素:minimum [1,9,2,3,4] -> 1
sum
函数返回一个列表中所有元素的和:sum [5,2,1,6,3,2,5,7] -> 31
product
函数返回一个列表中所有元素的积:product [6,2,1,2] -> 24
elem
函数可以用来判断一个元素是否包含于一个列表,通常以中缀形式调用它:4 `elem` [3,4,5,6] -> True
10 `elem` [3,4,5,6] -> False
1.4 得州区间
区间是构造列表的方法之一,而且其值必须是可枚举的,或者说,是可以排序的。例如,要得到包含 1~20 中的所有自然数的列表,只要录入[1..20]
即可,这与录入[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20]
完全等价。
['a'..'z'] -> "abcdefghijklmnopqrstuvwxyz"
['K'..'Z'] -> "KLMNOPQRSTUVWXYZ"
- 区间很聪明,允许你告诉它一个步长。例如:
要得到 1 到 20 之间的偶数:[2,4..20]
要得到 1 到 20 之间 3 的倍数:[3,6..20]
要得到 20 到 1 之间的列表:[20,19..1]
- 无限列表的使用:
- 生成前 24 个 13 的倍数:
take 24 [13,26..]
cycle
函数接收一个列表作为参数并返回一个无限列表:take 10 (cycle [1,2,3]) -> [1,2,3,1,2,3,1,2,3,1]
take 12 (cycle "LOL") -> "LOL LOL LOL "
repeat
函数接收一个值作为参数,并返回一个仅包含该值的无限列表:take 10 (repeat 5) -> [5,5,5,5,5,5,5,5,5,5]
- 用
replicate
函数直接得到包含相同元素的列表:replicate 3 10 -> [10,10,10]
- 生成前 24 个 13 的倍数:
在区间上使用浮点数要格外小心:[0.1, 0.3 .. 1] -> [0.1,0.3,0.5,0.7,0.8999999999999999,1.0999999999999999]
1.5 我是列表推导式
列表推导式(list comprehension)
是一种过滤、转换或者组合列表的方法。类似于数学中的集合推导式(set comprehension)
,通过它可以从既有的集合中按照规则产生一个新的集合。因此,前 10 个偶数的集合可以写为 {$2·x\ |\ x \in N,\ x \leq 10$}。
在 Haskell 中可以利用列表推导式实现上述表达式:[x*2 | x <- [1..10]]
。上述代码通过[x <- [1..10]]
取了[1..10]
这个列表中的每一项元素,x
即[1..10]
中的每一项元素的值,也就是说,x
即[1..10]
中的每一项元素的绑定。竖线(|)前面的部分指列表推导式的输出,表示所取的值与计算结果的映射关系。
一些其他例子:
取 1 的 10 中,乘以 2 后大于等于 12 的元素:[x*2 | x <- [1..10], x*2 >= 12]
取 50 到 100 中所有除以 7 的余数为 3 的元素:[x | x <- [50..100], x`mod`7 == 3]
一个列表推导式,使列表中所有大于 10 的奇数变为 “BANG”,小于 10 的奇数变为 “BOOM”,其他则统统扔掉:boomBangs xs = [ if x < 10 then "BOOM!" else "BANG!" | x <- xs, odd x ]
boomBangs [7..13] -> ["BOOM!","BOOM!","BANG!","BANG!"]
谓词可以有多项,如:取 10 ~ 20 之间所有不等于 13、15 或 19 的数:[x | x <- [10..20], x /= 13, x /= 15, x /= 19]
从多个列表中去元素也是可以的:[x+y | x <- [1,2,3], y <- [10,100,1000]] -> [11,101,1001,12,102,1002,13,103,1003]
[x*y | x <- [2,5,10], y <- [8,10,11]] -> [16,20,22,40,50,55,80,100,110]
[x*y | x <- [2,5,10], y <- [8,10,11], x*y > 50] -> [55,80,100,110]
一个包含一组名词和形容词的列表推导式,也许写诗用得到:let nouns = ["hobo","frog","pope"]
let adjectives = ["lazy","grouchy","scheming"]
[adjective ++ " " ++ noun | adjective <- adjectives, noun <- nouns]
用列表推导式实现自己的length
函数:length' xs = sum [1 | _ <- xs]
字符串也是列表,完全可以使用列表推导式来处理字符串:
一个去除字符串中所有非大写字母的函数:removeNonUppercase st = [c | c <- st, c `elem` ['A'..'Z']]
对于列表的列表可以通过嵌套的列表推导式处理:
假设有一个包含许多数值的列表的列表,在不拆开它的前提下除去其中的所有奇数:let xxs = [[1,2,3],[4,5,6],[7,8,9]]
[[x | x <- xs, even x] | xs <- xxs]
1.6 元组
元组(tuple)允许我们将多个异构的值组合为一个单一的值。元组的长度固定,将元素存入元组的同时,必须明确元素的数目。
元组由括号括起,其中的项由逗号隔开。(1, 3)
(3, 'a', "hello")
(50, 50.4, "hello", 'b')
1.6.1 使用元组
只有两个元组的元素数量的类型完全一样,才是相同的类型。
长度为 2 的元组称为pair
,也作序对;长度为 3 的元组称为tripe
,也作三元组。
1.6.2 使用序对
fst
函数取一个序对作为参数,返回其首项:fst (8,11) -> 8
。snd
函数取一个序对作为参数,返回其尾项:snd (8,11) -> 11
。
zip
函数取两个列表作为参数,然后将它们交叉配对,形成一组序对。如:
zip [1,2,3,4,5] [5,5,5,5,5] -> [(1,5),(2,5),(3,5),(4,5),(5,5)]
zip [1..5] ["one","two","three","four","five"] ->
[(1,"one"),(2,"two"),(3,"three"),(4,"four"),(5,"five")]
如果要组合的两个列表长度不相等,会在较短的列表结束为止。
由于 Haskell 是惰性的,可以使用zip
组合有限和无限的列表:zip [1..] ["apple","orange","cherry","mango"]
1.6.3 找直角三角形
使用 Haskell 来找出所有满足下列条件的直角三角形:
- 三边长度皆为整数;
- 三边长度皆小于等于 10;
- 周长(三边之和)为 24 的直角三角形。
假设 c 为斜边,a、b 为直角边:
let rightTriangles' = [(a,b,c) | c <- [1..10], a <- [1..c], b <- [1..a], a^2 + b^2 == c^2, a+b+c == 24]
rightTriangles' -> [(8,6,10)]
第 2 章 相信类型
在 Haskell 中,每个表达式都会在编译时得到明确的类型,从而提高代码的安全性。Haskell 中一切皆类型,因此编译器在编译时可以得到较多的信息来检查错误。Haskell 支持类型推导。
2.1 显式类型声明
在 GHCi 中,通过 :t 命令,后跟任何合法的表达式,即可得到该表达式的类型。
1 |
|
:: 读作“它的类型为”。凡是明确的类型,其首字母必须为大写。
同样,函数也有类型。编写函数时,可以给它一个显式的类型声明。例如:
1 |
|
这里 removeNonUppercase 的类型为 [Char] -> [Char],意为它取一个字符串作为参数,返回另一个字符串。
2.2 Haskell 的常见类型
- Int:整数,有界。
- Integer:整数,无界。
- Float:单精度浮点数。
- Double:双精度浮点数。
- Bool:布尔值。
- Char:Unicode 字符。
- 元组。
2.3 类型变量
有时让一些元素处理多种类型将更加合理。比如 head 函数,它可以取一个列表作为参数,返回这个列表头部的元素。head 函数的类型是 head :: [a] -> a
。这里的 a 是一个类型变量(type variable),因此,a 可以是任何类型。
通过类型变量,我们可以在类型安全(type safe)的前提下,轻而易举地编写能够处理多种类型的函数。这一点与其他语言中的泛型(generic)很相似,但在 Haskell 中要更为强大,更容易写出通用的函数。
使用了类型变量的函数被称为多态函数(polymorphic function)。
2.4 类型类入门
类型类(typeclass)是定义行为的接口。如果一个类型是某类型类的实例(instance),那它必须实现了该类型类所描述的行为。
具体来讲,类型类是一组函数的集合,如果将某类型实现为某类型类的实例,那就需要为这一类型提供这些函数的相应实现。
例如:== 运算符的类型签名为 (==) :: (Eq a) => a -> a -> Bool
。=> 符合的左侧叫做类型约束(type constraint)。我们可以这样读这段类型声明:“相等性函数取两个相同类型的值作为参数并返回一个布尔值,而这两个参数的类型同为 Eq 类型类的实例”。
注意 千万不要将 Haskell 的类型类与面向对象语言中的类(Class)的概念混淆。
- Eq 类型类:用于可判断相等性的类型,要求它的实例必须实现 == 和 /= 两个函数。
- Ord 类型类:用于可比较大小的类型,包含了所有标准比较函数,如 <、>、<=、>= 等。
- Show 类型类:实例为可以表示为字符串的类型,目前为止提到的除函数以外的所有类型都是 Show 的实例。show 函数可以取 Show 的实例类型作为参数,并将其转换为字符串。
- Read 类型类:与 Show 相反的类型类。目前为止提到的所有类型都是 Read 的实例。read 函数可以取一个字符串作为参数并转换为 Read 的某个实例的类型。
- Enum 类型类:其实例类型都是具有连续顺序的——它们的值都是可以枚举的。我们可以在区间中使用这些类型,每个值都有相应的后继(successer)和前驱(predecesor),分别可以通过 succ 函数和 pred 函数得到。
- Bounded 类型类:实例类型都有一个上界和下界,分别可以通过 maxBound 和 minBound 两个函数获取到。
- Num 类型类:表示数值的类型类,它的实例类型都具有数的特征。只有已经属于 Show 与 Eq 的实例类型,才可以成为 Num 类型类的实例。
- Floating 类型类:仅包含 Float 和 Double 两种浮点类型,用于存储浮点数。
- Integeral 类型类:仅包含整数,其实例类型有 Int 和 Integer。
由于类型类定义的是一个抽象的接口,一个类型可以作为多个类型类的实例,一个类型类也可以含有多个类型作为实例。有时,一个类型必须在成为某类型类的实例之后,才能成为另一个类型类的实例。