Haskell 趣学指南

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

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

Haskell 官网

准备工作

  • 在 Windows 上安装 Haskell 环境

在 PowerShell 会话中运行以下命令(作为非管理员用户):

配置国内镜像源

1
env:BOOTSTRAP_HASKELL_YAML = 'https://mirrors.ustc.edu.cn/ghcup/ghcup-metadata/ghcup-0.0.6.yaml'

安装

1
Set-ExecutionPolicy Bypass -Scope Process -Force;[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072;Invoke-Command -ScriptBlock ([ScriptBlock]::Create((Invoke-WebRequest https://mirrors.ustc.edu.cn/ghcup/sh/bootstrap-haskell.ps1 -UseBasicParsing))) -ArgumentList $true
  • 为 VSCode 安装 Haskell 插件:HaskellHaskell Syntax Highlighting

第 1 章 各就各位,预备!

安装成功 Haskell 开发环境后,打开终端,输入 ghci,看到如下欢迎信息:

GHCi,version 8.10.7: https://www.haskell.org/ghc/ :? for help

说明安装成功。

GHCi 的默认命令提示符为 Prelude>,配置其为 ghci>:

  1. 临时方法,输入:set prompt "ghci>",缺点:每次都要输入一遍
  2. 永久方法,在 home 目录下创建一个 .ghci 文件,内容为 :set prompt "ghci>"

简单使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
ghci>-- 简单算术运算
ghci>2 + 15
17
ghci>49 * 100
4900
ghci>1892 - 1472
420
ghci>5 / 2
2.5
ghci>(50 * 100) - 4999
1
ghci>50 * 100 - 4999
1
ghci>50 * (100 - 4999)
-244950
ghci>-- 逻辑运算
ghci>True && False
False
ghci>True && True
True
ghci>False || True
True
ghci>not False
True
ghci>not (True && True)
False
ghci>-- 判等
ghci>5 == 5
True
ghci>1 == 0
False
ghci>5 /= 5
False
ghci>5 /= 4
True
ghci>"hello" == "hello"
True
ghci>-- 同一个表达式中不能混用不同类型
ghci>5 + "llama"

<interactive>:88:1: error:
? No instance for (Num [Char]) arising from a use of ‘+’
? In the expression: 5 + "llama"
In an equation for ‘it’: it = 5 + "llama"

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
2
3
4
5
6
7
ghci>:l baby
[1 of 1] Compiling Main ( baby.hs,interpreted )
Ok,one module loaded.
ghci>doubleMe 9
18
ghci>doubleMe 8.3
16.6

定义几个不同的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
-- 两个参数的函数
doubleUs x y = x * 2 + y * 2

-- if 后必须跟 else
doubleSmallNumber x = if x > 100 then x else x * 2

-- 通常使用单引号来区分这是某函数的严格求值(与惰性求值相对)版本,
-- 或者是一个稍经修改但差别不大的函数。
doubleSmallNumber' x = (if x > 100 then x else x * 2) + 1

-- 无参函数,注意:函数不能以大写字母开头
-- 没有参数的函数通常被称作定义或者名字,在函数定义之后我们就再也不能修改它的内容。
connanO'Brien = "It's a-me,Connan O'Brien!"

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]

在区间上使用浮点数要格外小心:
[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
2
3
4
5
6
7
8
9
10
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

:: 读作“它的类型为”。凡是明确的类型,其首字母必须为大写。

同样,函数也有类型。编写函数时,可以给它一个显式的类型声明。例如:

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

这里 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。

由于类型类定义的是一个抽象的接口,一个类型可以作为多个类型类的实例,一个类型类也可以含有多个类型作为实例。有时,一个类型必须在成为某类型类的实例之后,才能成为另一个类型类的实例。

第 3 章 函数的语法

第 4 章 你好,递归

第 5 章 高阶函数

第 6 章 模块

第 7 章 构建我们自己的类型和类型类

第 8 章 输入与输出

第 9 章 更多的输入与输出操作

第 10 章 函数式地解决问题

第 11 章 applicative 函子

第 12 章 Monoid

第 13 章 更多 monad 的例子

第 14 章 再多一些 monad

第 15 章 zipper


Haskell 趣学指南
http://example.com/2023/08/13/haskell-primer/
作者
QiDianMaker
发布于
2023年8月12日
更新于
2023年8月20日
许可协议