第 01 章:课程简介
课程名称:计算概论A (实验班) - 函数式程序设计
授课教师:胡振江,张伟
开课单位:信息科学技术学院 / 计算机学院
开课时间:2025 年 09 ~ 12 月
本文档的内容为课程课件;未经允许,请勿用做商业用途。
第 02 章:初见函数式思维
01 两句很有哲理的话
-
工欲善其事,必先利其器。
-
To a man with a hammer, everything looks like a nail.
- 思维方式是一种工具;不能被思维方式束缚
02 “函数式思维” 是一种什么样的思维方式
-
使用 “数学中的函数” 作为 求解信息处理问题的基本成分。
-
“使用方式” 包括:
-
从零开始,定义一些基本函数
-
把已有的函数组装起来,形成新的函数
-
03 简要回顾:数学中的函数
定义: 函数 / Function
对任何两个集合
X
和Y
,称两者之间的关系f ⊆ X ✗ Y
是一个函数,当且仅当如下条件成立:
∀ x ∈ X, ∃ (u, v) ∈ f, x = u
∀ (x, y) ∈ f, ∀ (u, v) ∈ f, x = u => y == v
也即:对
X
中的任何元素x
,存在且仅存唯一一个元素y ∈ Y
,满足(x, y) ∈ f
函数相关的表示符号:
对任何两个集合X
和Y
,
-
X ✗ Y
-
一个集合,其定义为:
{ (x, y) | x ∈ X, y ∈ Y }
-
也称为集合
X
和Y
的 笛卡尔积
-
-
X -> Y
- 一个集合,包含且仅包含所有从
X
到Y
的函数
- 一个集合,包含且仅包含所有从
-
f : X -> Y
-
声明
f
是一个从X
到Y
的函数。也称:f
是一个类型为X -> Y
的函数 -
称:
X
为f
的定义域 (Domain);Y
为f
的值域 (Codomain)
-
-
f(x)
-
函数
f
的定义域中元素x
映射到的值域中的那个元素, -
显然可知:
-
f(x) : Y
,也即:f(x)
的类型为Y
-
(x, f(x)) ∈ f
-
-
常用的集合及其表示符号:
在 Haskell 中,“类型” 和 “集合” 是同义词
-
ℕ
:自然数集合/类型 -
ℤ
:整数集合/类型 -
ℚ
:有理数集合/类型 -
ℝ
:实数集合/类型 -
𝔹 = { true, flse }
:布尔集合/类型。其中,-
true
表示 “真”;flse
表示 “假” -
稍后给出
𝔹
的一种更为形式化的定义
-
定义: 函数的组合 / Function Composition
对任何两个函数
f : X -> Y
、g : Y -> Z
,两者的组合,记为g * f
,是一个函数。该函数的定义如下:
[ X Y Z : Set, f : X -> Y, g : Y -> Z ] def g * f : X -> Z = [x : X] g(f(x))
说明:
上述定义不是采用 Haskell 语言书写的程序
本章中出现的所有程序 (除了最后一个),都不是 Haskell 程序
- 这些程序所采用的语法,来源于我们正在设计中的一种用于数学证明的语言
04 为什么在函数的基础上,可以形成一种思维方式
-
函数可以建模 “变换” 和 “因果关系”
-
信息处理问题,本质上是一种信息的变换问题
-
在面向特定领域问题的软件应用中,大量涉及对物理世界中因果关系的仿真
-
05 几个简单的函数
-
逻辑非函数
def not : 𝔹 -> 𝔹 = [b] match b { true => flse, flse => true, }
-
逻辑与函数
def and : (𝔹 ✗ 𝔹) -> 𝔹 = [p] match p { (true, true) => true, _ => flse, }
另一种定义方式:
def and : 𝔹 -> 𝔹 -> 𝔹 = [l] [r] match (l, r) { (true, true) => true, _ => flse, }
-
为了定义关于自然数
ℕ
的函数,我们首先需要给出ℕ
的定义def ℕ : Type = { ctor zero : Self ctor succ : Self -> Self }
这是一种递归定义,其含义如下:
-
zero
是ℕ
中的一个元素 -
如果
n
是ℕ
中的一个元素,那么,succ n
也是ℕ
中的一个元素 -
ctor
是一个关键字 (Key Word),其英文单词 “constructor” 的缩写-
ctor
后面的那个元素是一个公理 (无需给出元素的定义)- 所谓公理,就是一个神秘存在
-
为什么可以这样定义自然数呢?
因为在这种定义下,我们可以做出如下设定:
0 === zero
1 === succ(zero)
2 === succ(succ(zero))
3 === succ(succ(succ(zero)))
小和尚:
- 这不就是上古传说中的 “结绳记数” 吗!
唐僧:
- 思维真敏捷;真是一个值得教育的好孩子!
小和尚:
- 但是,这么 low 的自然数定义,真的适合在北京大学的课堂上讲吗?
唐僧:
-
我猜测,也许你的思维被你的高中数学老师囚禁在数学宇宙的一片荒漠中了
-
采用类似的方式,我们也可以对
𝔹
给出形式化的定义def 𝔹 : Type = { ctor flse : Self, ctor true : Self, }
-
-
自然数的加法运算
def plus : ℕ -> (ℕ -> ℕ) = [a] [b] match (a, b) { (m, zero) => m, (m, succ(n)) => succ(plus(m)(n)) }
加法运算示例:
plus(3)(4) -- 因为实在受不了“结绳记数”的自然数,所以局部回归人类世俗文明😅 === plus(3)(succ(3)) === succ(plus(3)(3)) === succ(plus(3)(succ(2))) === succ(succ(plus(3)(2))) === succ(succ(plus(3)(succ 1))) === succ(succ(succ(plus(3)(1)))) === succ(succ(succ(plus(3)(succ 0)))) === succ(succ(succ(succ(plus(3)(0))))) === succ(succ(succ(succ(3)))) === (succ * succ * succ * succ)(3)
不要被上面这种看似复杂的定义所困扰。
它只不过用递归的方式定义了一件很简单的事情:
plus(m)(n) === (succ * succ * succ * ... * succ)(m) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ "the composition of n succ"
-
自然数的乘法运算
def mult : ℕ -> (ℕ -> ℕ) = [a] [b] match (a, b) { (m, zero) => zero, (m, succ(n)) => plus(m)(mult(m)(n)) }
乘法运算示例:
mult(3)(4) === mult(3)(succ 3) === plus(3)(mult(3)(3)) === plus(3)(mult(3)(succ 2)) === plus(3)(plus(3)(mult(3)(2))) === plus(3)(plus(3)(mult(3)(succ 1))) === plus(3)(plus(3)(plus(3)(mult(3)(1)))) === plus(3)(plus(3)(plus(3)(mult(3)(succ 0)))) === plus(3)(plus(3)(plus(3)(plus(3)(mult(3)(0))))) === plus(3)(plus(3)(plus(3)(plus(3)(0)))) === (plus(3) * plus(3) * plus(3) * plus(3))(0)
不要被上面这种看似复杂的定义所困扰。
它只不过用递归的方式定义了一件很简单的事情:
mult(m)(n) === (plus(m) * plus(m) * ... * plus(m))(zero) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ "the composition of n plus(m)"
-
自然数的指数运算
def expn : ℕ -> (ℕ -> ℕ) = [a] [b] match (a, b) { (m, zero) => succ(zero), (m, succ(n)) => mult(m)(expn(m)(n)) }
指数运算示例:
expn(3)(4) === expn(3)(succ 3) === mult(3)(expn(3)(3)) === mult(3)(expn(3)(succ 2)) === mult(3)(mult(3)(expn(3)(2))) === mult(3)(mult(3)(expn(3)(succ 1))) === mult(3)(mult(3)(mult(3)(expn(3)(1)))) === mult(3)(mult(3)(mult(3)(expn(3)(succ 0)))) === mult(3)(mult(3)(mult(3)(mult(3)(expn(3)(0))))) === mult(3)(mult(3)(mult(3)(mult(3)(1)))) === (mult(3) * mult(3) * mult(3) * mult(3))(1)
不要被上面这种看似复杂的定义所困扰。
它只不过用递归的方式定义了一件很简单的事情:
expn(m)(n) === (mult(m) * mult(m) * ... * mult(m))(succ(zero)) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ "the composition of n mult(m)"
小和尚:
- 总是n个相同函数的组合;能不能有些新东西呢?
唐僧:
- 何必让自己这么累;这样划水不挺好嘛!
-
阶乘运算
def fact : ℕ -> ℕ = [m] match m { zero => succ(zero), succ(n) => mult(succ(n))(fact(n)), }
不要被上面这种看似复杂的定义所困扰
它只不过用递归的方式定义了一件很简单的事情:
fact(m) === (mult(m) * mult(m - 1) * ... * mult(1))(1) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ "the composition of n mult(_)"
唐僧: 看,是不是有那么一点点新东西了 😅
-
斐波那契函数
def fib : ℕ -> ℕ = [m] match m { zero => zero, succ(zero) => succ(zero), succ(succ(n)) => plus(fib(n))(fib(succ n)), }
斐波那契函数运算示例:
fib(5) === plus(fib(3))(fib(4)) === plus(plus(fib(1))(fib(2)))(plus(fib(2))(fib(3))) === plus(plus(1)(plus(fib(0))(fib(1))))(plus(plus(fib(0))(fib(1)))(plus(fib(1))(fib(2)))) === plus(plus(1)(plus(0)(1)))(plus(plus(0)(1))(plus(1)(plus(fib(0))(fib(1))))) === plus(plus(1)(plus(0)(1)))(plus(plus(0)(1))(plus(1)(plus(0)(1))))
小和尚: 这下好了,没有规律了。看你怎么圆过来 😜
06 自然数上的 fold 函数
-
plus
mult
expn
这三个函数之间存在共性 -
这种共性可以被封装在一个函数中
[T : Type] def fold : (T -> T) -> (T -> (ℕ -> T)) = [h : T -> T] [c : T] [m : ℕ] match m { zero => c, succ n => h(fold(h)(c)(n)) } ------ 引入一点语法糖 ------ = [h : T -> T, c : T, m : ℕ] match m { zero => c, succ n => h(fold(h)(c)(n)) }
-
给定
h : T -> T
,c : T
,令f === fold(h)(c)
,则可知:-
f(zero) === c
-
f(succ n) === h(f(n))
-
-
如果不理解这个定义的含义,请看如下解释:
给定一个自然数
n
,可知:n === (succ * succ * succ * ... * succ)(zero) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ "the composition of n succ"
已知
f === fold(h)(c)
,则可知:f(n) === ( h * h * h * ... * h )(c) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ "the composition of n h"
也即:
-
f(n)
把n
中的zero
替换为c
,把每一个succ
替换为h
-
n
和f(n)
是同构的,即:两者具有相同的结构
-
-
使用
fold
函数,可以对plus
mult
expn
这三个函数进行更深刻的定义def plus : ℕ -> (ℕ -> ℕ) = [m] fold(succ)(m) def mult : ℕ -> (ℕ -> ℕ) = [m] fold(plus(m))(zero) def expn : ℕ -> (ℕ -> ℕ) = [m] fold(mult(m))(succ(zero))
示例:
----the composition of n succ ----- n === (succ * succ * ... * succ )(zero) | | | | | plus(m)(n) === (succ * succ * ... * succ )(m) | | | | | mult(m)(n) === (plus(m) * plus(m) * ... * plus(m))(zero) | | | | | expn(m)(n) === (mult(m) * mult(m) * ... * mult(m))(succ(zero))
-
使用
fold
函数,也可以对fact
fib
这两个函数进行更深刻的定义首先引入两个辅助函数:
[A B : Type] def fst : A ✗ B -> A = [(a, b)] a [A B : Type] def snd : A ✗ B -> B = [(a, b)] b
fact
函数的定义:def fact : ℕ -> ℕ = { def f : ℕ ✗ ℕ -> ℕ ✗ ℕ = [(m, n)] (m + 1, (m + 1) * n); ret snd * (fold(f)(0, 1)); }
----the composition of n succ ---- n === ( succ * succ * ... * succ )(0) | | | | | | fact(n) === snd( ( f * f * ... * f )(0, 1) )
fib
函数的定义:def fib : ℕ -> ℕ = { def g : ℕ ✗ ℕ -> ℕ ✗ ℕ = [(m, n)] (n, m + n); ret fst * (fold(g)(0, 1)); }
----the composition of n succ ---- n === ( succ * succ * ... * succ )(0) | | | | | | fib(n) === fst( ( g * g * ... * g )(0, 1) )
07 List 类型
-
在信息处理问题中,经常涉及一组按照某种顺序排列的数据;
我们将这类数据用 List 类型进行表示。
-
例如:对于排序问题
-
待排序的数据通常采用 List 的方式进行输入
-
排序的结果自然也以 List 的方式返回
-
-
-
List 类型的定义
def List : Type -> Type = [A] { ctor nil : Self, ctor (+>) : A -> Self -> Self, }
-
List 类型的示例
-
List(ℕ)
:自然数序列类型 -
nil
:一个不包含任何元素的空序列 -
1 +> nil
:仅包含 1 个元素1
的自然数序列 -
1 +> (2 +> (3 +> (4 +> nil)))
:包含 4 个元素1
2
3
4
的自然数序列
-
-
List 类型相关的函数
添加元素函数:
[T : Type] def cons : T -> (List(T) -> List(T)) = [x, xs] x +> xs -- 这个函数就是把运算符 +> 进行了函数化
长度函数:
[T : Type] def len : List(T) -> ℕ = [xs] match xs { nil => 0, a +> yx => 1 + len(yx) }
逆序函数:
[T : Type] def rev : List(T) -> List(T) = { def rev-memo : List(T) -> (List(T) -> List(T)) = [xs, ys] match ys { nil => xs, m +> ms => rev-memo(m +> xs)(ms), }; ret rev-memo(nil); }
序列拼接函数:
[T : Type] def concat : List(T) -> (List(T) -> List(T)) = [xs, ys] match xs { nil => ys, m +> ms => m +> (concat(ms)(ys)), }
过滤函数:
[T : Type] def filter : (T -> 𝔹) -> (List(T) -> List(T)) = [f, xs] match xs { nil => nil, m +> ms => match f(m) { true => m +> filter(f)(ms), flse => filter(f)(ms), } }
08 List 上的 fold 函数
-
如果我的理解没有错误,在任何类型上都存在 fold 函数
这个观点待确认。 数学上的事情,只要没有给出证明,都不能随意相信。
-
无论如何,
List
类型上存在 fold 函数,而且存在两个。我们将这两个函数分别命名为
foldl
和foldr
。- 其中的后缀
l
和r
分别表示left
和right
- 其中的后缀
-
foldr
函数[A B : Type] def foldr : (A -> (B -> B)) -> (B -> (List(A) -> B)) = [h : A -> (B -> B), b : B, xs : List(A)] match xs { nil => b, a +> ys => h(a)(foldr(h)(b)(ys)) }
如果不理解这个定义,请看如下解释:
-
给定
xs : List(A)
,不失一般性,令:xs === xn +> xn-1 +> ... +> x1 +> nil
则可知:
xs === ( cons(xn) * cons(xn-1) * ... * cons(x1) )(nil)
-
已知
f === foldr(h)(b)
,则可知:f(xs) === ( h(xn) * h(xn-1) * ... * h(x1) )(b)
-
也即:
-
f(xs)
把xs
中的nil
替换为b
,把xs
中的每一个cons
替换为h
-
xs
和f(xs)
是同构的,即:两者具有相同的结构
-
-
-
foldl
函数[A B : Type] def foldl : (B -> (A -> B)) -> (B -> (List(A) -> B)) = [h : B -> (A -> B), b : B, xs : List(A)] match xs { nil => b, a +> ys => foldl(h)(h(b)(a))(ys), }
如果不理解这个定义,请看如下解释:
-
引入一个工具函数
[A B C : Type] def flip : (A -> (B -> C)) -> (B -> (A -> C)) = [f, b, a] f a b
-
给定
xs : List(A)
,不失一般性,令:xs === xn +> xn-1 +> ... +> x1 +> nil
则可知:
xs === ( cons(xn) * cons(xn-1) * ... * cons(x1) )(nil)
-
已知
f === foldr(h)(b)
,令h' = flip(h)
,则可知:f(xs) === ( h'(x1) * h'(x2) * ... * h'(xn) )(b)
-
也即:
-
f(xs)
把xs
中的nil
替换为b
,把xs
中的每一个cons
替换为h'
-
同时,还顺带逆序了一下
-
-
但实际上,并不存在一个显式的逆序环节;更真实的计算过程如下
f(xs) === b ≺ xn ≺ xn-1 ≺ ... ≺ x1 ≺ nil
其中:运算符
≺
具有左结合性,且b ≺ a === h(b)(a)
-
09 使用fold函数,重定义List相关的函数
-
len
函数[A : Type] def len : List(A) -> ℕ = { def h : A -> ℕ -> ℕ = [_, n] n + 1; ret foldr(h)(0) }
xs === ( cons(xn) * cons(xn-1) * ... * cons(x1) )(nil) len(xs) === ( h(xn) * h(xn-1) * ... * h(x1) )(0)
-
rev
函数[A : Type] def rev : List(A) -> List(A) = foldl(flip(cons))(nil)
-
concat
函数[A : Type] def concat : List(A) -> (List(A) -> List(A)) = [xs, ys] foldr(cons)(ys)(xs)
-
filter
函数[A : Type] def filter : (A -> 𝔹) -> (List(A) -> List(A)) = [f] { def h : (A -> 𝔹) -> (A -> (List(A) -> List(A))) = [f, a, xs] match f(a) { true => a +> xs, flse => xs, } ret foldr(h(f))(nil); }
xs === ( cons(xn) * cons(xn-1) * ... * cons(x1) )(nil) filter(f)(xs) === ( h(f)(xn) * h(f)(xn-1) * ... * h(f)(x1) )(nil)
10 一种排序算法
-
快速排序算法
def qsort : List(ℕ) -> List(ℕ) = [xs] match { nil => nil, n +> ns => { def left = qsort(filter([m] m < n)(ns)); def rigt = qsort(filter([m] m >= n)(ns)); ret concat(left)(n +> rigt); } }
小和尚:
- 这段代码看起来还不错 👍
唐僧:
-
你的审美能力看起来也不错 👏
-
可以让你看一下去年的形式:
qsort : List(ℕ) -> List(ℕ) qsort(nil) = nil qsort(n +> ns) = concat(concat(qsort(filter(lt(n))(ns)))([n]))(qsort(filter(ge(n))(ns))) where lt : ℕ -> (ℕ -> 𝔹) lt(n)(m) = if m < n then true else flse ge : ℕ -> (ℕ -> 𝔹) ge(n)(m) = not(lt(n)(m))
小和尚:
- 如果这就是用 FP 书写的算法,此生绝不学 FP!
唐僧:
- 好孩子,如果给你三生三世的财富,学否?
小和尚:
- 佛学工作者可是不能撒谎的哦!!!
-
内容 与 形式
-
这是一个关于 “内容” 与 “形式” 两者之间关系的问题
-
内容:对自然数序列进行排序的一种方法
-
形式:表现这种排序方法的形式
-
-
进一步而言,去年的程序存在的问题可以表述为:
- “形式” 小于 “内容”: 内容是很好的,但形式实在是太糟糕了
-
如果你能体会到这一点,你会发现:这个问题的严重程度并不像表面上看起来的那样
-
为什么这么说呢?因为,本质(内容)毕竟还是很好的
-
-
重走长征路
-
在某种意义上,我们正在“重走长征路”
-
在很多年以前,科研工作者们就已经意识到了这个问题
- 即:函数式思维的 “形式” 小于 “内容”
-
在这个问题的驱使下,他/她们设计了各种各样的函数式程序设计语言
-
我们即将介绍的Haskell语言,就是这些函数式程序设计语言的集大成者
-
不过,目前看来,Haskell 语言正在老去:一鲸落,万物生!
- 例如,本章中程序的语法,就是在 Haskell 语言的基础上改良形成的
-
11 剧透:采用 Haskell 语言编写的 qsort 算法
qsort :: Ord a => [a] -> [a]
qsort [] = []
qsort (p:xs) = qsort lt ++ [p] ++ qsort ge
where
lt = filter (< p) xs
ge = filter (>= p) xs
本章作业
本章没有作业。
但是,你需要想清楚:这门课是否适合你。
第 03 章:初见 Haskell
使用 Haskell 语言定义函数
在这一节中,我们采用 Haskell 语言,对上一章给出的若干函数示例进行重定义,并结合这些示例对 Haskell 语言的相关细节进行说明。
01 逻辑非运算
第 1 种定义方式:模式匹配 (Pattern Matching)
not :: Bool -> Bool
not True = False
not False = True
相关信息说明:
-
你可以在
https://play.haskell.org/
这个页面实际运行一下这个程序在 Haskell 缺省加载的信息中已经存在了
not
函数的定义;
为了避免重名导致的编译错误,最简单的调整方式:把函数名改变一下。尝试在
https://play.haskell.org/
中输入如下程序:main :: IO () main = do putStrLn $ show $ not' True not' :: Bool -> Bool not' True = False not' False = True
然后点击 “Run” 按钮
-
Bool
是一个类型;它包含两个值:True
False
-
not :: Bool -> Bool
:声明not
的类型是Bool -> Bool
小和尚: 为什么不用单个冒号
:
作为类型声明符?唐僧: 在Haskell中,单冒号另有它同;
- 目前看来,“双冒号作为类型声明符” 这个设计决策并不好
-
not True = False
:把not True
这个表达式定义为False
-
not False = True
:把not False
这个表达式定义为True
上述程序的一种轻微变体:
not :: Bool -> Bool
not x = case x of
True -> False
False -> True
第 2 种定义方式:条件表达式 (Conditional Expression)
not :: Bool -> Bool
not x = if x == True then False else True
相关信息说明:
-
在 Haskell 中,
=
和==
具有完全不同的含义-
=
表示 “定义为”,即:将其左侧的表达式定义为右侧的表达式 -
==
是一个 逻辑运算符,用来计算左右两侧表达式的值是否相等
-
第 3 种定义方式:Guarded Equations
not :: Bool -> Bool
not x | x == True = False
| x == False = True
或者,
not :: Bool -> Bool
not x | x = False
| otherwise = True
02 逻辑与运算
第 1 种定义方式:Pattern Matching
and :: Bool -> Bool -> Bool
and True True = True
and True False = False
and False True = False
and False False = False
相关信息说明:
-
在 Haskell 中,运算符
->
满足右结合律- 也即:
Bool -> Bool -> Bool
等价于Bool -> (Bool -> Bool)
思考:
(Bool -> Bool) -> Bool
这个类型的含义是什么? - 也即:
第 2 种定义方式:Pattern Matching + Wildcard
and :: Bool -> Bool -> Bool
and True True = True
and _ _ = False
- 一个单独的下划线
_
是一个 wildcard,表示这里有一个值,但我们选择无视它
作业 01
关于逻辑与 (and) 函数,你还能想到其他定义方式吗?
请用 Haskell 语言写出至少其他三种定义方式。
更传统的一种定义方式
and :: (Bool, Bool) -> Bool
and (True, True) = True
and (_ , _ ) = False
03 整数的算术运算
算术运算符
在书写算术运算时,通常不会采用函数的形式进行书写,而是采用更为直观的算术运算符 (Operator)
- 例如,一般不会书写
plus a b
,而是写为a + b
Haskell 提供了常用的算术运算符:
+
加法运算;-
减法运算;*
乘法运算;^
指数运算
二元运算符的函数化
对于两个整数 x
y
:
-
x + y
===(+) x y
-
也即,
(+)
是一个函数,其类型为Integer -> Integer -> Integer
-
Integer
是 Haskell 提供的一种整数类型,可以表示任意整数值
在此基础上,Haskell还提供了一种增强语法
-
(x +) y
===x + y
-
(+ y) x
===x + y
函数的运算符化
对于一个函数,也可以将其转换为对应的运算符:
- 例如,
div x y
===x
`div`y
整数的除运算
-
如果你需要的是整除运算,使用函数
div
-
如果你需要的是带有小数的除运算,使用运算符
/
作业 02
请用目前介绍的 Haskell 语言知识,给出函数
div
的一种或多种定义。
- div :: Integer -> Integer -> Integer
说明:
不用关注效率
不用关注第二个参数为 0 的情况
如果你认为这个问题无解或很难,请给出必要的说明
- 为什么无解? 或者,困难在哪里?
04 自然数相关的函数
自然数类型
Haskell 标准库的模块Numeric.Natural
中定义了一种自然数类型Natural
,可表示任意自然数。
但这个类型缺省没有被加载到程序中。
为了在程序中使用这个类型,我们需要在程序文件的开始添加如下语句:
import Numeric.Natural (Natural)
- 含义:把
Numeric.Natural
模块中的元素Natural
引入到当前程序文件中
阶乘函数
fact :: Natural -> Natural
fact 0 = 1
fact n = n * fact (n - 1)
-
这是一个采用 pattern matching 进行定义的函数,含义如下:
- 对于一个自然数
n
:- 如果
n == 0
,则将fact n
定义为1
- 否则,将
fact n
定义为n * fact (n - 1)
- 如果
- 对于一个自然数
-
在程序运行过程中,如果要评估表达式
fact x
的值,则会按照定义中给出的顺序进行模式匹配。具体而言:
-
首先检查
x
是否可以匹配到0
上:若匹配成功,则将fact x
评估为0
;否则,继续匹配。 -
继续检查
x
是否可以匹配到通配符n
上。
因为:n
是通配符,可以匹配到任何自然数上。
所以:这次匹配一定成功,从而将fact x
评估为x * fact (x - 1)
-
-
在 Haskell 中,函数应用 (Function Application) 具有最高优先级
- 因此:
x * fact (x - 1)
等价于x * (fact (x - 1))
- 因此:
作业 03
关于阶乘函数,你还能想到其他定义方式吗?
请分别使用 “guarded equations” 和 “conditional expression” 写出阶乘函数的定义。
自然数上的 fold 函数
fold :: (t -> t) -> t -> Natural -> t
fold h c 0 = c
fold h c n = h (fold h c (n - 1))
说明:
- 在
fold
的类型中,小写字母t
表示一个 类型变量,Natural
表示一个具体的类型
小和尚:
- 如何确定函数类型声明中出现的名称,是一个具体类型,还是一个类型变量呢?
唐僧:
- 对于该问题,Haskell 在语法层次上给出了一种简单有效的解决方案:
- 如果名称的 首字符 是 小写字母,则表示 类型变量
- 如果名称的 首字符 是 大写字母,则表示 具体类型
-
表达式
h (fold h c (n - 1))
中出现的圆括号不是:主流编程语言 (C/C++/Java/Rust) 中函数调用时用于传参的圆括号
而是:用于 调整运算顺序 的圆括号。
- 如果不加这些括号,则
h fold h c n - 1
===((((h fold) h) c) n) - 1
- 如果不加这些括号,则
-
在 Haskell 中:
-
函数调用具有最高优先级。因此,肯定比运算符的优先级高
-
函数调用满足左结合律。因此,
f g h k
===((f g) h) k
-
-
如果你对这种调整运算顺序的括号很反感,Haskell 提供了另一种解决方案:
二元运算符:
$
-
该运算符:1. 具有最低优先级;2. 满足右结合律
-
运算效果:把左侧的函数 应用到 右侧的元素上
因此,如下三个表达式等价:
h (fold h c (n - 1)) h $ fold h c (n - 1) h $ fold h c $ n - 1
-
阶乘函数 (fold版本)
fact :: Natural -> Natural
fact = snd . (fold f (0, 1))
fst :: (a, b) -> a
fst (x, _) = x
snd :: (a, b) -> b
snd (_, y) = y
f :: (Natural, Natural) -> (Natural, Natural)
f (m, n) = (m + 1, (m + 1) * n)
说明:
-
dot运算符
.
实现 函数组合 的功能-
f (g x)
===(f . g) x
===f . g $ x
-
f . g x
===f . (g x)
-
斐波那契函数 (fold版本)
fib :: Natural -> Natural
fib = fst . (fold g (0, 1))
g :: (Natural, Natural) -> (Natural, Natural)
g (m, n) = (n, m + n)
05 序列相关的函数
Haskell 中的序列类型
对于任意类型 a
,其对应的序列类型,可以书写为 [a]
。
序列值的表示方式 (以[Natural]
为例):
-
空序列:
[]
-
包含一个自然数
1
的序列:[1]
或1 : []
- 这里出现的单冒号
:
是一个二元运算符
- 这里出现的单冒号
-
包含三个自然数
1
2
3
的序列:[1, 2, 3]
或1 : 2 : 3 : []
- 可知:二元运算符
:
满足右结合律
- 可知:二元运算符
序列上的 fold 函数
foldr :: (a -> b -> b) -> b -> [a] -> b
foldr h c [] = c
foldr h c (x:xs) = h x (foldr h c xs)
foldl :: (b -> a -> b) -> b -> [a] -> b
foldl h c [] = c
foldl h c (x:xs) = foldl h (h c x) xs
序列上的若干函数
len :: [a] -> Natural
len [] = 0
len (n:ns) = 1 + len ns
原始递归版本 | fold函数版本 |
---|---|
|
|
|
|
|
|
|
|
06 一种快速排序算法
qsort :: [Integer] -> [Integer]
qsort [] = []
qsort (n:ns) = (qsort $ filter (< n) ns) ++ [n] ++ (qsort $ filter (>= n) ns)
思考: 猜一猜,二元运算符
++
的功能是什么?
Haskell还提供了一些语法机制,可以让qsort的定义更加结构化。
let ... in ... 表达式
qsort :: [Integer] -> [Integer]
qsort [] = []
qsort (n:ns) = let smaller = qsort $ filter (< n) ns
larger = qsort $ filter (>= n) ns
in smaller ++ [n] ++ larger
- 在
in
后面的这个表达式中,可以访问let ... in
之间定义的变量
where 子句
qsort :: [Integer] -> [Integer]
qsort [] = []
qsort (n:ns) = smaller ++ [n] ++ larger
where
smaller = qsort $ filter (< n) ns
larger = qsort $ filter (>= n) ns
let-in 表达式
与where 子句
的 区别:
在大部分情况下,两者没有本质的区别,仅仅反映了不同的表现形式
在一些情况下,
where 子句
定义的变量具有更大的作用范围f x y | cond1 x y = g z | cond2 x y = h z | otherwise = k z where z = p x y
在
where 子句
中定义的一个变量z
,可在 guarded equations 的任何地方访问
let in 表达式
不具有这样的能力
唐僧:
你的感觉如何?
我们用了一些朝三暮四的把戏 (规定一些语法规则),
把函数式思维的形式变得更容易理解了。小和尚:
我再观察一下;
如果你继续搞这些朝三暮四的小把戏,我就准备退课了!
Haskell 中标识符和运算符的命名规则
元素命名
在很多情况下,我们需要为程序中定义的元素 命名
-
所谓 “命名”,就是给一个东西赋予一个具有区分作用的名称
-
名称的作用:可以通过名称引用到所指向的那个程序元素
Haskell 中的名称,分为两大类:
-
标识符 (Identifier)
-
运算符 (Operator Symbol)
01 标识符的命名规则
-
由一或多个字符顺序构成
-
首字符只能是一个字母 (letter),具体包括:
- ASCII 编码表中的所有字母 (即:所有英文大小写字母)
- Unicode字符集中的所有字母
-
其他字符只能是
字母
/数字
/英文下划线
/英文单引号
-
不能与 Haskell 的保留词重名
case
class
data
default
deriving
do
else
foreign
if
import
in
infix
infixl
infixr
instance
let
module
newtype
of
then
type
where
_
-
根据程序元素的不同,Haskell 还对标识符的首字符进行了进一步的限制
-
一些程序元素,其 标识符的首字符 只能是 大写字母
-
其他程序元素,其 标识符的首字符 只能是 小写字母
目前已经涉及到的程序元素包括:
-
函数 / 变量 / 类型变量:名称首字符必须是小写字母
-
类型:名称首字符必须是大写字母
-
-
02 运算符的命名规则
-
由一或多个符号 (symbol) 顺序构成,具体包括:
- ASCII 编码表中的所有符号:
!
#
$
%
&
*
+
.
/
<
=
>
?
@
\
^
|
-
~
:
- Unicode 字符集中的大部分符号
- ASCII 编码表中的所有符号:
-
不能与Haskell的保留运算符重名
..
:
::
=
\
|
<-
->
@
~
=>
-
Haskell进一步将运算符分为两类:(1) 以英文冒号
:
为首字符的运算符;(2) 其他运算符- 更多信息,按需说明
Hello, World!
01 Haskell 中的 Hello, World!
main = do
putStrLn "Hello, World!"
说明:
-
这是一个合法的 Haskell 程序 (符合 Haskell 语言规范)
-
它隐藏了一些代码,以至于看起来有些奇怪
-
恢复这些代码后,会得到一个更完整的程序:
module Main(main) where
import Prelude
main :: IO ()
main = do
putStrLn "Hello, World!"
-
module Main(main) where
:- 这个文件定义了一个模块
Main
- 这个模块对外输出了一个名称为
main
的元素 - 这个模块中定义的元素出现在
where
之后
- 这个文件定义了一个模块
Haskell 中的模块
关于模块 (Module),Haskell 语言规范中给出了如下信息:
一个 Haskell 程序由一或多个模块构成,且每一个模块定义在一个单独的文件中
一个 Haskell 程序必须包含一个名为
Main
的模块
main
元素的类型必须是IO t
;其中,需要把t
替换为一个具体的类型
IO
是Prelude
模块对外输出的一个元素,用于封装 IO 运算
Prelude
模块对外输出的所有元素都会被默认加载到任何一个模块中一个 Haskell 程序的运行就是对
Main
模块中的main
元素进行求值的过程。
而且,最终获得的值会被抛弃。模块的名称必须满足如下两个条件之一
一个以大写字母开头的标识符。
- 例如:
MyModule
两或多个以大写字母开头的标识符,通过字符
.
连接在一起。
- 例如:
This.Is.Mymodule
如果:一个模块在设计时就已经确定不会被其他模块所引用
那么:该模块可以放在任意一个具有合法名称的文件中
通常,
Main
模块不会被其他模块所引用
因此,可以把Main
模块放在任意一个文件中但是,将
Main
模块所在文件名设定为Main
,不失为一个好选择如果:一个模块可能会被其他模块所引用,
那么:该模块所在的文件必须满足如下条件:
如果:模块名是一个标识符,那么:模块所在文件的名称必须与模块名相同
如果:模块名是多个标识符通过
.
字符连接在一起,那么:
模块所在文件的名称必须与模块名中最后的标识符相同
- 例如,模块
This.Is.Mymodule
必须放置在一个名为Mymodule
的文件中
- 这里的文件名,不包含文件扩展名
模块名中前面的每一个标识符及其之间的顺序关系,
必须与文件系统的目录名以及目录之间的包含关系存在一一对应
- 例如,模块
This.Is.Mymodule
必须放置在源文件目录下的This/Is/Mymodule
这个文件中。
-
import Prelude
-
把
Prelude
模块对外输出的所有程序元素加载到当前模块中 -
Haskell 规定:
-
若模块源码中不存在
import Prelude
声明,则表明其缺省存在- 效果:你在程序中可以直接使用
Prelude
输出的所有元素
- 效果:你在程序中可以直接使用
-
若模块源码中存在
import Prelude
或其变体,则从Prelude
中按需加载元素-
例如:如果模块中存在
import Prelude(Integer, (+), (-))
则表明只需把Prelude
对外输出的类型Integer
、加运算符
、减运算符
三个元素加载到当前模块中;Prelude对外输出的其他元素无需加载 -
例如:下面的 Haskell 模块定义不合法
module Main(main) where import Prelude(Integer, (+), (-)) main :: IO () main = do putStrLn "Hello, World!"
原因:该模块中出现了两个未定义的元素
IO
putStrLn
-
-
-
-
main :: IO ()
-
main
元素的类型是IO ()
-
()
是一个类型,称为0 元组
(0-tuple) 类型 -
IO
是一个类型构造器 (type constructor) -
IO ()
是一个类型,其中封装了 IO 运算,且该运算会返回一个 0 元组
小和尚:
-
为什么
main
的类型不是函数呢?C/C++/Java/Rust语言的main都是函数。
唐僧:
-
因为
main
本来就不是函数。你在哪本数学书上见到过
main
这样的函数?哪有函数一言不合就向控制台输出字符串的呢?
小和尚:
- 既然如此,C/C++等语言为什么把main作为函数呢?
唐僧:
-
我想,它们大概是为了让自己显得很 NB。
任何东西,能和数学沾上关系,就会显得高大上了
例如,有的公司举办了全球数学竞赛,又不公布成绩,...
-
-
-
main = do
putStrLn "Hello, World!"
-
定义了
main
中封装的 IO 运算 -
这个 IO 运算中仅包含了一个 IO action,即:在控制台输出一串字符
-
如果你愿意,可以继续添加一个IO action:
putStrLn "Hello, World! AGAIN"
- 注意:应保持与上一行相同的缩进
小和尚:
do
是什么梗?唐僧: 一言难尽啊!
- 简而言之:
do
是一种语法糖 (syntax sugar)- 在函数的世界里,没有“顺序执行”这个概念 (这句话其实有些含糊)
- 但是,可以用一些机制去仿真“顺序执行”
do
的作用就是把这些机制封装起来,让程序具有更好的易理解性
-
-
putStrLn "Hello, World!"
-
putStrLn
是Prelude
模块输出的一个程序元素,定义如下:putStrLn :: String -> IO () putStrLn s = do putStr s putStr "\n"
-
02 一个具有更多交互性的程序
module Main(main) where
import Prelude
main :: IO()
main = do
putStrLn "Please input your name:"
name <- getLine
putStrLn $ "Hello, " ++ name
putStrLn "Please input an integer:"
str1 <- getLine
putStrLn "Please input another integer:"
str2 <- getLine
let int1 = (read str1 :: Integer)
let int2 = (read str2 :: Integer)
putStrLn $ str1 ++ " + " ++ str2 ++ " = " ++ (show $ int1 + int2)
-
name <- getLine
getLine
是Prelude
模块输出的一个元素,其定义如下:getLine :: IO String getLine = do c <- getChar if c == '\n' then return "" else do s <- getLine return (c:s)
符号
<-
是与do
绑定的一种语法。在这行代码中,
<-
的效果,暂时可以做如下理解:- 把
getLine
返回的IO String
类型的值中的那个String
类型的值赋给name
- 把
-
let int1 = (read str1 :: Integer)
这一行代码看来既熟悉又陌生:
- 我们看到了熟悉的
let
,却没有看到它的好伙伴in
- 这个
let
就是let in
中的let
,用于定义在后面被使用的变量;
但是,in
被语法糖隐藏了 (时机合适时,再介绍细节)
read str1 :: Integer
read
是Prelude
输出的一个函数,其类型大约是String -> a
- 这个表达式的效果:把一个字符串转换为一个整数值
- 在调用
read
时,若无法从上下文中推断出a
对应的具体类型,
则需在其后面放置:: X
,显式说明a
的具体类型为X
- 我们看到了熟悉的
-
show $ int1 + int2
show
是Prelude
输出的一个函数,其类型大约是a -> String
show
的功能:把一个值转换为一个字符串
唐僧:
- 掌握了Haskell 语言IO相关的操作,再加上前面介绍的知识,你应该可以做很多事情了
- 非常遗憾的是,这些程序目前还不能运行
- 不用太担心,想让程序运行,分分钟的事
小和尚:
- 分分钟是多久呢?
唐僧: 😅 ... (画外音:气氛突然有些尴尬)
Haskell程序的编译、运行、管理
当你用自然语言写了一本小说,可以把它发表在互联网上;
然后,读者们就可以阅读这本小说了当你用 Haskell 语言写了一个程序,也可以把它发表在互联网的某个代码托管网站上;
然后,程序员们就可以阅读这个程序了与小说不同的是,程序还有另外一类读者:计算机
- 计算机需要理解程序,并在各类硬件和软件资源的支持下,执行程序所表达的计算过程
对于程序设计语言的发明者们而言,定义语言的语法形式,仅仅是万里长征的第一步
为了让程序能够在硬件上运行,还需提供一系列软件支撑工具
这些工具又被称为程序设计语言的 工具链 (toolchain)
在本节中,我们主要介绍 Haskell 语言工具链中的三个基本工具:
-
GHC (Glasgow Haskell Compiler):
- 一种得到广泛使用的Haskell语言编译器,
能够把合法的Haskell程序变换计算机可执行的机器指令序列
- 一种得到广泛使用的Haskell语言编译器,
-
GHCi:
- Haskell程序的一种交互式 (interactive) 运行环境;
程序员可在其中输入任意合法的表达式,然后 GHCi 对表达式进行求值,并输出结果
- Haskell程序的一种交互式 (interactive) 运行环境;
-
Stack:
- 一种常用的 Haskell 软件项目构建管理工具
00 Haskell 工具链的安装
进入页面 https://www.haskell.org/ghcup/
。
按照说明,在自己的计算机上安装 Haskell 工具链。
- 安装过程中总会遇到各种问题
- 遇到问题,莫慌张,主动寻求助教或其他同学的帮助
01 GHC 的使用
ghc 是 Haskell 语言的一种编译器 (compiler)
作用:把一个合法的 Haskell 程序转换/编译为在当前计算机上可运行的二进制程序
Step 1: 把下面的程序代码放置到某个文件夹下的Main.hs
文件中
-- This is my first Haskell program
module Main(main) where
import Prelude
main :: IO ()
main = do
putStrLn "Hello, World!"
-
第一行为程序 注释 (comment)
- 注释就是对程序做的一些说明,不会改变程序的运行时行为
-
Haskell 的注释分为两类:
-
单行注释: 以两个连续连字符
--
开始的一行文字 -
多行注释: 以
{-
开始、以-}
结束的所有文字
-
Step 2: 打开终端 (Terminal) 应用,把当前目录设置为Main.hs
所在的文件夹
Step 3: 编译程序 (即:在终端中输入命令ghc Main.hs
,并回车)
Step 4: 运行程序 (即:在终端中输入命令./Main
,并回车)
对于第一次接触程序设计语言的同学,这是一个具有历史意义的时刻。
这是人类的一大步,却只是个体的一小步。
许多年之后,面对未名湖边随风摇曳的垂柳,
你将会回想起,费尽千辛万苦终于成功运行这个无聊程序的那个遥远的夜晚。
动手练一练 01
请把前文介绍的那个更有交互性的 Haskell 程序,用 ghc 命令编译为可执行程序。
然后运行该程序,观察程序和你的交互过程。
关于 ghc 的详细使用说明,可访问其官方网站:https://www.haskell.org/ghc/
- 没事不要打开这个链接;打开了也看不懂。
你需要在学习过编译原理相关的知识后,再来看一看。
02 GHCi 的使用
ghci 是 Haskell 程序的一种交互式运行环境。
ghci 默认加载Prelude
模块;因此,可直接使用该模块输出的元素
你可以在其中输出合法的 Haskell 表达式;ghci 会输出求值结果。
ghci 中的常用命令:
-
:?
列出ghci支持的所有命令 -
:quit
或:q
退出当前ghci环境 -
:load <模块文件名>
把一个指定的模块加载到当前环境中 -
:reload
重新加载那些已经加载的模块 (这些模块可能被修改了)
:load
命令使用示例:
- 打开终端 (Terminal) 应用,把当前目录设置为
Main.hs
所在的文件夹
动手练一练 02
- 把前文介绍的快速排序函数
qsort
封装在一个 Haskell 模块中;- 在 ghci 中加载这个模块;
- 在 ghci 中对
qsort
函数的正确性进行测试
- 即:把这个函数作用到若干序列数据上,观察函数的返回值是否符合预期
03 Stack 的使用
stack 是一种面向 Haskell 程序开发的构建管理工具。
其管理内容覆盖:代码组织方式、编译器版本及编译参数、外部依赖关系、测试等。
ghc、ghci 适合做一些小打小闹的事情:
- 例如,学习 Haskell 语言、编写一个小规模的 Haskell 程序等
- 其中,ghci 可以作为一种入门级的程序调试环境
真实的软件开发是一种面向群体的智力密集型活动。
小和尚:
- 我就一个人开发一个复杂的软件应用,不可以吗?
唐僧:
可以,一个建筑工人也可以独立建造一栋摩天大楼;只要给他足够的时间。
群体软件开发还面临各种复杂的管理问题
- 包括:人力资源管理、需求管理、软件制品管理、编译环境管理、开发进度管理等
工欲善其事,必先利其器:需要采用合适的工具应对这些问题
下面,我们基于stack 的官方使用说明,对它进行简要的介绍。
stack new
命令
-
使用
stack new
命令,可以创建一个具有特定名称的软件开发项目,其中包含一个包(package) -
Package这个概念在语言规范中并不存在,但在实践中得到广泛应用。
在逻辑上,一个 package 包含若干相关的 Haskell 模块。
- 例如:可以把一个完整的Haskell程序打包为一个package,其中包含一个Main模块、若干个被Main模块加载的自定义模块、以及相关的测试模块。
-
一个 package 具有一个全局唯一的名称
- package 的名称由若干个单词通过连字符
-
连结在一起 - 每个单词由若干字母或数字组成,且至少包含一个字母
- package 的名称由若干个单词通过连字符
如果要在一个特定的文件夹下创建一个名称为foo
的项目,可以这么做:
-
打开终端应用,将当前目录设定为项目所在的文件夹
-
运行如下命令 (确保你的计算机处于联网状态)
stack new foo
如果一切顺利,当前文件夹下会存在一个名为foo
的文件夹
foo
项目的所有信息都被放在这个文件夹中
使用cd foo
命令进入这个文件夹,可以看到其中存在的信息:
. ├── CHANGELOG.md ├── LICENSE ├── README.md ├── Setup.hs ├── app │ └── Main.hs ├── foo.cabal ├── package.yaml ├── src │ └── Lib.hs ├── stack.yaml ├── stack.yaml.lock └── test └── Spec.hs
stack build
命令
- 在终端的
foo
目录下输入命令stack build
对foo
项目进行构建
stack exec
命令
-
在终端的
foo
目录下输入命令stack exec foo-exe
,运行当前项目- 如果你觉得
foo-exe
这个名字不好,可以在配置文件中修改成另外一个
- 如果你觉得
stack test
命令
-
在终端的
foo
目录下输入命令stack test
,可以触发对当前项目的测试-
stack 已经帮助我们建立了一个空的测试程序
-
我们需要根据项目的实际内容向其中填写相应的测试代码
-
例如,如果你自己编写了一个排序函数,为了确保功能的正确性,你需要在若干种具有代表性的数据上测试排序函数的输出是否符合你的预期。
-
只要把这些测试数据按照规定的方式写在特定的文件中,
stack test
命令就会自动执行对应的测试活动,并给出测试结果.
-
-
stack 在foo
项目中创建的文件
-
三个文件:
LICENSE
/README.md
/CHANGELOG.md
-
LICENSE
声明当前项目版权相关的信息 -
README.md
对当前项目的简要说明、 -
CHANGELOG.md
记录项目在不同版本中发生的变更情况
- 这三个文件不会参与到编译活动中,不会对构建过程产生影响
-
-
两个文件:
helloworld.cabal
/Setup.hs
- 这是更底层的构建工具
cabal
相关的两个文件;无需关注
- 这是更底层的构建工具
-
一个文件:
stack.yaml
。其中记录了两条信息:packages: - .
- 含义:当前项目中包含一个 package,它就存在于
stack.yaml
所在的文件夹中
snapshot: url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/24/4.yaml
- 含义:一个 URL,指向互联网上的一个 yaml 文件,其中指明了当前项目使用的 GHC 版本以及一些可用的外部 package
- 含义:当前项目中包含一个 package,它就存在于
-
一个文件:
package.yaml
name: foo version: 0.1.0.0 github: "githubuser/foo" license: BSD-3-Clause author: "Author name here" maintainer: "example@example.com" copyright: "2025 Author name here" extra-source-files: - README.md - CHANGELOG.md # Metadata used when publishing your package # synopsis: Short description of your package # category: Web # To avoid duplicated efforts in documentation and dealing with the # complications of embedding Haddock markup inside cabal files, it is # common to point users to the README.md file. description: Please see the README on GitHub at <https://github.com/githubuser/foo#readme> dependencies: - base >= 4.7 && < 5 ghc-options: - -Wall - -Wcompat - -Widentities - -Wincomplete-record-updates - -Wincomplete-uni-patterns - -Wmissing-export-lists - -Wmissing-home-modules - -Wpartial-fields - -Wredundant-constraints library: source-dirs: src # 当前项目的 lib 文件放在 src 文件夹中 executables: foo-exe: # stack exec 命令后跟的那个名字;可以被修改为其他名称 main: Main.hs # main 元素定义在 Main.hs 中 source-dirs: app # foo-exe 的源文件放在 app 文件夹中 ghc-options: - -threaded - -rtsopts - -with-rtsopts=-N dependencies: - foo tests: foo-test: main: Spec.hs source-dirs: test ghc-options: - -threaded - -rtsopts - -with-rtsopts=-N dependencies: - foo
-
三个 hs 文件:
-
app/Main.hs
module Main (main) where import Lib main :: IO () main = someFunc
-
src/Lib.hs
module Lib ( someFunc ) where someFunc :: IO () someFunc = putStrLn "someFunc"
-
test/Spec.hs
main :: IO () main = putStrLn "Test suite not yet implemented"
-
唐僧: 我想,你大概明白
stack new foo
做了什么吧?
它帮我们创建了一个 Haskell 程序的骨架以及编译和运行环境。
- 所有的这一切,stack 都进行了很好的封装,使得我们只需要使用stack提供的几个命令,就能对一个软件开发项目进行便捷的管理
动手练一练 03
请使用stack 创建一个名为qsort 的项目。然后:
- 在
src/Lib.hs
中添加并输出前面介绍的qsort
函数;- 在
app/Main.hs
中加载Lib
模块,
然后,找几个待排序的数据,用qsort
函数对它们进行排序,打印出排序的结果
基于 stack 的 package 管理
-
有人说,他站在了巨人的肩膀上,看到了很远的地方
此言确实不虚,在软件开发中也是如此
-
在真实的软件开发项目中,很少有开发者从零开始编写所有的软件代码
开发者总是尽可能复用其他开发者已经开发完成的功能模块。
- 例如,前面我们看到的
Prelude
模块就是 Haskell 标准库提供的一个模块。 - Haskell 标准库还提供了很多其他模块;具体参见Haskell语言规范
- Haskell 也提供了 import 语句来支持对其他模块的复用
- 例如,前面我们看到的
-
但是,事情并没有到此结束
-
软件开发者群体是一个乐于分享的群体
-
有很多程序员耗费了大量的精力,开发出很多高质量的软件模块,然后把这些模块放在互联网,供其他开发者免费使用
-
然后,其他开发者在前人开发的模块的基础上又开发出新的模块,并共享到开发者群体中
-
长此以往,就形成了一种欣欣向荣的生态系统
-
在这个生态系统中,丰富多样的软件模块不断涌现,持续演化,就像自然界生态系统所展现出的物种的多样性和持续演化那样
-
-
这种乐于分享的特点在 Haskell 开发者群体中也是存在的,也在此基础上形成了欣欣向荣的生态
-
在这个生态系统中,开发者分享工作成果的基本单位是 package,
- 也即:一个开发者把一组相关的Haskell模块封装为一个package,然后将其发布到互联网上
小和尚:
- 分享工作成果的基本单位什么不能是模块呢?
唐僧:
其实,你把一个模块单独封装为一个package也是可以的
在更一般的意义上,不以模块作基本发布单位的主要原因如下:
模块不存在版本的概念
- 在软件开发生态系统中,演化是一种常态
- 缺失了版本的概念,使得我们不能对同一个模块的不同版本进行有效管理
在很多场景下,模块过于细粒度
- 如果要对外发布一个复杂的Haskell应用程序,以模块为基本单元显然不合适
当你对外发布一个模块时,为了使得其他开发者对该模块的质量具有足够的信心,你可能还需要将该模块的测试数据和程序一起对外发布。此时,将一个模块以及附带的测试模块打包一个 package,具合理性.
-
使用
stack new
创建的项目,其中就包含了一或多个 package- 这些 package 的信息记录在
stack.yaml
文件的packages
配置项中 - 例如,在
foo
项目中,packages 下面只包含一个值,即:点符号.
- 这表明,在
foo
项目的根目录中存在一个 package
- 这表明,在
- 这些 package 的信息记录在
-
在 stack 项目中,每一个 package 的管理信息记录在一个名为
package.yaml
的文件中.-
该文件包含一个重要的配置项
dependencies
-
它记录了当前 package 依赖的所有其他 package 的名称与版本信息
-
例如,在
foo
项目包含的唯一一个 package 的package.yaml
文件中,配置项dependencies
信息如下:dependencies: - base >= 4.7 && < 5
- 含义:当前 package 依赖于一个名称为
base
的 package,且要求base
的版本号落在区间[4.7, 5)中
一个重要问题:如何获得这个名称为base的特定版本的 package 呢?
- 含义:当前 package 依赖于一个名称为
-
-
-
Haskell 社区维护了一个在线的 package 仓库,并将其命名为 Hackage
https://hackage.haskell.org/
任何一个开发者都可以向这个仓库中发布自己开发的 package,也可以从这个仓库中下载特定名称和特定版本的 package
-
你可以在 Hackage 中搜索名称为
base
的 package在结果页面上,可以看到
base
的所有版本,和每一版本包含的所有模块在长长的模块列表中,会看到两个熟悉的名字:
Prelude
和Numeric.Natural
- 因为
base
包含了这两个模块,且当前的项目依赖于base
,所以,在当前项目中,就可以使用import
语句加载这两个模块了
- 因为
-
你可以在
package.yaml
文件的dependencies
配置项中添加更多的 package 名称以及对应的版本需求然后,使用
stack build
命令,stack 就会自动到 Hackage 仓库中下载对应 package如果你不相信,就试试下面的练习吧
动手练一练 04
Hackage 中有一个名称为
random
的 package:
- 其中包含一个名称为
system.Random
的模块;其中定义了一个名称randomIO
的元素。在 do 后面的代码块中,使用
rnd <- randomIO :: IO Int
,就能得到一个随机生成的整数。请完成如下事情:
使用 stack 创建一个名称为
random-num
的项目在 package.yaml 文件的
dependencies
下添加一个值:random == 1.2.0
- 含义:当前 package 依赖一个名称
random
、版本1.2.0
的 package- 在Mac的M系列芯片上,可能需要把这个值修改为
random >= 1.2 && < 2
在当前项目中实现 “向终端打印出一个随机数” 的功能
请特别注意,当你使用
stack new
命令后,终端的输出信息。
-
需要指出的是,在主流的程序设计语言开发社区中,都存在类似的package管理方式
-
即:一个被开发者广泛认同的 package 仓库、一个配套的构建管理工具。
- 这是在互联网时代形成的一种群体软件开发模式,可能会陪伴你很长的时间。
- 选择一个开发者社区,选择一个有价值的软件开发项目,努力成为项目的核心贡献者,你会收获很多很多。
-
-
关于 stack,暂且讲到这里吧。有兴趣的同学可自行阅读相关材料
Haskell 程序的书写
Haskell 程序的 “书写风格”
对于学习过C / C++ / Java语言的同学而言,可能会觉得Haskell程序的书写有些奇怪
-
在传统语言中,源程序中会出现大量的分号
;
和花括号对{ ... }
- 前者的作用:作为一条语句的终结符
- 后者的作用:把几条语句封装为一个代码块 (Code Block)
-
但是,在前面出现的 Haskell 程序中,从来没有看到过花括号和分号。
其实,你误解 Haskell 了。
-
Haskell 规定,在
where
/let
/do
/of
这四个关键词后,需要放置一个代码块 -
在代码块的书写上,Haskell提供了两种书写风格:
-
Layout-sensitive (排布无关)
- 利用代码行的缩进表示语句的结束或代码块的结束
-
Layout-insensitive (排布相关)
- 利用分号表示语句的结束,利用一对花括号形成一个代码块
-
-
下面是前文出现的一个采用 layout-sensitive 风格书写的源程序
module Main(main) where import Prelude main :: IO() main = do putStrLn "Please input your name:" name <- getLine putStrLn $ "Hello, " ++ name putStrLn "Please input an integer:" str1 <- getLine putStrLn "Please input another integer:" str2 <- getLine let int1 = (read str1 :: Integer) let int2 = (read str2 :: Integer) putStrLn $ str1 ++ " + " ++ str2 ++ " = " ++ (show $ int1 + int2)
对应的 layout-insensitive 程序如下:
module Main(main) where { import Prelude; main :: IO(); main = do { putStrLn "Please input your name:"; name <- getLine; putStrLn $ "Hello, " ++ name; putStrLn "Please input an integer:"; str1 <- getLine; putStrLn "Please input another integer:"; str2 <- getLine; let { int1 = (read str1 :: Integer); }; let { int2 = (read str2 :: Integer); }; putStrLn $ str1 ++ " + " ++ str2 ++ " = " ++ (show $ int1 + int2); } }
唐僧:
- 你更喜欢哪一种风格呢?
- 由繁入简易,由简返繁难;回不去咯!
小和尚:
- 在采用 layout-sensitive 风格书写程序时,如何确定一行代码的缩进长度?
唐僧: 记住三条朦胧的准则:
-
相同缩进 => 开始一条新语句
-
更多缩进 => 继续上一条语句
-
更少缩进 => 结束一个代码块
Haskell 程序的 “书写方式”
Haskell 提供了两种源程序的书写方式:
-
文件扩展名为
hs
的书写方式把 layout-insensitive/sensitive 风格的程序放置在扩展名为
hs
的文件中 -
文件扩展名为
lhs
的书写方式注释 与 其他代码 的地位发生了变化:
- 书写注释时,不需要使用 前缀
--
或起始/终止字符串{-
/-}
; - 书写其他代码时,每一行开始必须添加 符号
>
- 书写注释时,不需要使用 前缀
hs | lhs |
---|---|
|
|
-
注意:lhs 文件的书写存在一个硬性的要求
- 以符号
>
开始的代码行 与 注释 之间 至少存在一个空行
- 以符号
小和尚:
- 为什么要发明lhs这种书写方式呢?
唐僧:
- 这个问题,你自己慢慢体会吧;不重要。
本章作业
作业 01
关于逻辑与 (and) 函数,你还能想到其他定义方式吗?
请用 Haskell 语言写出至少其他三种定义方式。
作业 02
请用目前介绍的 Haskell 语言知识,给出函数
div
的一种或多种定义。
- div :: Integer -> Integer -> Integer
说明:
- 不用关注效率
- 不用关注第二个参数为 0 的情况
- 如果你认为这个问题无解或很难,请给出必要的说明
- 为什么无解? 或者,困难在哪里?
作业 03
关于阶乘函数,你还能想到其他定义方式吗?
请分别使用 “guarded equations” 和 “conditional expression” 写出阶乘函数的定义。
作业 04
小明同学学习了这么多 Haskell 语言的知识后,感觉很累。
于是,他想用 Haskell 语言编写一个简单的命令行游戏让自己放松一下。这个游戏描述如下:
- 系统随机生成一个 1~100 之间的整数,记为
x
- 在命令行中提示用户输入一个整数
- 接收用户输入的整数,记为
x’
- 如果
x’ < x
,提示用户他/她输入的值比真实值小,跳转到2
- 如果
x’ > x
,提示用户他/她输入的值比真实值大,跳转到2
- 如果
x’ == x
,提示用户他/她成功了,游戏结束小明同学太累了,所以想请你帮他写一个这样的程序。你觉得这个事情可行吗?
- 请尝试编写一个这样的程序
- 如果你发现这个事情有困难,请告诉我们:
- 你的求解思路是什么 (多种思路也可以) ?
- 在按照一个思路前进的过程中,遇到了什么困难,使得你无法继续走下去
第 04 章:类型与类簇
中英文对照:
- 类型 =>
Type
- 类簇 =>
Type Class
小和尚:
- 为什么不把 “Type Class” 翻译为 “类型类” 呢?
唐僧:
- 这是一件鸡毛蒜皮的事情;甚至后面很多地方使用的就是 “类型类”
- 最好的应对方式:不做任何翻译,直接用原始的英文
01 类型
类型是什么
在 Haskell 中,
-
一个类型是由一组值构成的集合
-
例如,
Bool
类型,包含两个值:True
False
类型错误 (Type Error)
当在应用一个函数时
- 如果:“传入的实际参数” 的类型 与 “函数的形式参数” 的类型 不一致,
- 则称:发生了 类型错误
-
在上面这个示例中,
-
+
运算符接收的两个参数应该都是数值 -
实际传给
+
运算符的第二个参数False
不是一个数值,而是Bool
类型的一个值 -
因此,产生了类型错误
-
表达式的类型
如果一个表达式 e
在评估后会得到类型 T
的一个值,则称:e
的类型为 T
- 在 Haskell 程序中写作:
e :: T
任何一个良构的表达式 (Well-formed Expression) 都具有一个确定的类型
-
表达式的类型在编译时刻即能够自动被确定
-
确定表达式的类型的过程,称为 类型推导 (Type Inference)
f :: A -> B, e :: A -----------------------[app] f e :: B
上面是类型论中非常经典的一条推导规则;含义如下:
-
如果:表达式
f
的类型为A -> B
,且 表达式e
的类型为A
-
那么:表达式
f e
的类型为B
-
-
所有的类型错误在编译时刻都会被发现
- 因此,无需在运行时刻进行类型错误的检查,从而提高了程序运行的安全型和效率
在 GHCi
中,使用命令 :type
可以确定一个表达式的类型 (无需评估这个表达式的值)

02 Haskell 中的基础数据类型
Bool
该类型具有两个逻辑值:True
False
- exported by Prelude (即:该类型是
Prelude
模块的输出元素之一)
前面已经介绍:
Prelude
输出的所有元素都会被自动导入到任何模块中
- 因此,在程序中可以直接使用
Prelude
输出的元素
Char
该类型的每一个值,对应 Unicode 字符编码规范中的一个字符 (code point)
-
关于 Unicode 字符的更多信息请参看 http://www.unicode.org/
-
exported by Prelude
String
该类型在 Haskll 中的定义:type String = [Char]
-
即,一个字符串是一个由
Char
类型的值形成的List
-
exported by Prelude
Int
一种定长的整数类型
-
在 GHC 编译器中,该类型能够表达的整数范围是
[-2^63, 2^63 - 1]
-
exported by Prelude
Integer
一种不定长的整数类型
-
因此,可以表达任何一个整数值
-
exported by Prelude
Word
一种定长的无符号整数类型
-
与
Int
具有相同的长度 -
exported by Prelude
Natural
一种不定长的无符号整数类型
-
因此,可以表达任何一个自然数
-
exported by
Numeric.Natural
in thebase
package
Float
/ Double
单/双精度浮点数类型 (exported by Prelude)

03 List 类型
一个 list 是由若干个相同类型的值形成的序列。

给定一个类型 T
,[T]
是一个类型
[T]
的每一个值,都是一个元素类型为T
的 list
重要信息:
-
[T]
类型没有对 list 的长度信息 (即,list 中元素的数量) 进行任何限制- 因此,包含
1
个T
元素的 list、包含0
个T
元素的 list、以及包含3
个T
元素的 list,它们的类型都是[T]
- 因此,包含
-
任何一个类型
T
,都具有一个对应的[T]
类型- 因此,
[[T]]
也是一个合法的类型
- 因此,

04 Tuple 类型
给定两个类型 X
和 Y
:
-
(X, Y)
是一个二元组 (2-tuple) 类型-
该类型的任何一个值具有形式
(x, y)
,满足:x :: X
且y :: Y
-
在集合论中,类型
(X, Y)
就是两个集合X
和Y
的笛卡尔积
-
给定三个类型 X
Y
Z
:
-
(X, Y, Z)
是一个三元组 (3-tuple) 类型- 该类型的任何一个值具有形式
(x, y, z)
,满足:x :: X
、y :: Y
且z :: Y
- 该类型的任何一个值具有形式
类似地,还有 4-tuple、5-tuple、...
重要信息:
-
Haskell 中 不存在 一元组 (1-tuple) 类型
-
Haskell 中 存在 零元组 (0-tuple) 类型
-
零元组类型,写作
()
-
零元组类型具有唯一一个值,写作
()
小和尚:
- 零元组类型 和 唯一的零元组值 在形式上都是
()
;这不会产生混乱吗?
唐僧:
-
你的观察很仔细,非常棒 👍
-
不用担心,在 Haskell 中,类型和值具有截然不同的上下文
- 因此,从
()
的上下文能够判定 “它到底是类型还是值”
- 因此,从
-
-
三元组和更多元组,在理论上,没有存在的必要 (在实践中,为了使用上的方便,有存在的必要)
-
原因:基于二元组,可以构造出三元组、四元组、...
-
例如:
(1, (2, 3))
是一个采用二元组表示的三元组
-
05 Function 类型
给定两个类型 X
和 Y
:
-
X -> Y
是一个函数类型- 该类型的每一个值都是一个函数,且该函数把类型
X
的值映射到类型Y
的值
- 该类型的每一个值都是一个函数,且该函数把类型
重要信息:
-
任何类型都可以作为类型
X -> Y
中的X
和Y
例如:List 类型 和 Tuple 类型都可以作为
X
和Y
add :: (Int, Int) -> Int add (x, y) = x + y
zeroto :: Int -> [Int] zeroto n = [0..n] -- 这里有一个细节的语法 -- [0..n] 表示一个 list,其中依次包含从 0 到 n 的所有整数值
05.01 柯里化函数 (Curried functions)
小和尚:
- “柯里化” (Curried) 这个奇怪的名字来源于哪里呢?
唐僧:
- 如果这么说,那么 “Haskell” 这个奇怪的名字又来源于哪里呢?
希望很多年以后,你的 “姓” 或 “名” 也能出现在教科书中。
给定一个函数 f :: A -> B
-
如果:
A
是一个二元组类型, -
那么:我们可以通过柯里化,把
f
转换为一个等价的但第一个参数不再是元组类型的函数 -
例如,上面出现的函数
add :: (Int, Int) -> Int
,它对应的柯里化函数为:add :: Int -> Int -> Int add x y = x + y
类似地,如果函数的第一个参数是n
元组类型,我们也可以通过柯里化把第一个参数拆解开。例如:
mult :: (Int, Int, Int) -> Int
-- 柯里化版本
mult :: Int -> Int -> Int -> Int
Haskell 的 Prelude
模块中定义了两个函数:
curry :: ((a, b) -> c) -> a -> b -> c
curry f x y = f (x, y)
uncurry :: (a -> b -> c) -> ((a, b) -> c)
uncurry f p = f (fst p) (snd p)
-
这两个函数支持在 “非柯里化函数” 和 “柯里化函数” 之间相互转换
- 把类型中出现的
a
b
c
,理解为任意类型即可
- 把类型中出现的
与非柯里化函数相比,柯里化函数具有更好的灵活性:
-
应用 非柯里化函数 时,需要一次提供全部的参数
-
应用 柯里化函数 时,可以按需传入参数
-
例如,对于上面引入的
mult
函数,如下应用方式都是合法的:mult 2
,mult 2 3
,mult 2 3 4
-
关于柯里化函数的一个语法约定 (Conversion)
-
运算符
->
满足右结合律- 例如:
Int -> Int -> Int -> Int
等价于Int -> (Int -> (Int -> Int))
- 例如:
06 多态函数 (Polymorphic Functions)
称一个函数为 多态函数,
- 如果它的类型中包含一或多个 类型变量 (type variable)
例如,如下是一个多态函数:
len :: [a] -> Natural
len [] = 0
len (n:ns) = 1 + len ns
-
在 Haskell 中,类型声明中 “以小写字符开头” 的标识符,被编译器视为一个 类型变量
-
len
的类型的含义:- 对于任意类型
a
,len
函数接收一个[a]
类型的值,返回一个自然数
- 对于任意类型
-
在实际应用
len
函数的时候,编译器会根据传入的参数的类型,推导出a
的具体类型
Prelude
模块中的若干多态函数
fst :: (a, b) -> a
fst (x, _) = x
- 获得二元组中第一个分量
snd :: (a, b) -> b
snd (_, y) = y
- 获得二元组中第二个分量
head :: [a] -> a
head (x:_) = x
-
获得一个 list 中的第一个元素
-
注意:
head
是一个 partial function,因为它在 “长度为零的 list” 上没有定义
tail :: [a] -> [a]
tail (_:xs) = xs
- 获得一个 list 中删除第一个元素后的 list;仍然是一个 partial function
last :: [a] -> a
last [x] = x
last (_:xs) = last xs
- 获得一个 list 中的最后一个元素;仍然是一个 partial function
重载函数 (Overloaded Functions)
称一个多态函数为 重载函数
- 如果它的类型中包含一或多个 类型类约束 (type class constraint)
例如,如果你在 ghci 中输入命令 :type (+)
,就会看到 (+)
的类型
ghci> :type (+)
(+) :: Num a => a -> a -> a
-
在这个类型中,
=>
左侧的东西,称为 类型类约束 -
这个约束的含义是:适用于
(+)
的类型a
必须是类型类Num
的一个实例
上面这个截图表明:
-
(+)
适用于 “整数” 和 “浮点数”,但是不适用于 “字符” -
原因:类型
Char
不是类型类Num
的实例
07 类型类 (Type Class)
Prelude
模块输出了很多类型类,其中最基础的三个是:
Eq
Ord
Num
这三个类型类出现在了很多函数中:

上面的截图表明:
-
相等关系运算符
==
适用于所有实现了Eq
的类型 -
小于关系运算符
<
适用于所有实现了Ord
的类型 -
加法运算符
+
适用于所有实现了Num
的类型
07.01 Eq
类型类 Eq
的定义如下:
class Eq a where
(==), (/=) :: a -> a -> Bool
x /= y = not (x == y)
x == y = not (x /= y)
-
对任意类型
a
,-
如果:它想要成为类型类
Eq
实例 -
那么:它必须要实现
==
和/=
这两个运算符 (两者的类型均为a -> a -> Bool
)
-
-
在上面的定义中,
-
==
和/=
的缺省实现已经给出;但是,这是一种循环定义 -
因此,在将类型
a
声明为Eq
的实例时,至少要对==
和/=
两者之一给出定义
-
-
Prelude
输出的所有基本数据类型都是Eq
的实例 -
如果:一个组合类型中包含的每一个类型都是
Eq
的实例,
那么:编译器可以将这个组合类型自动实现为Eq
的实例。
Haskell 规范没有对实现
Eq
的类型应该满足的性质给出任何规定在数学的意义上,如果类型
T
实现了Eq
(即,T
是Eq
的实例),则应满足如下性质:
自反性 (Reflexivity)
- 对于类型为
T
的任何一个表达式x
,满足:x == x
===True
对称性 (Symmetry)
- 对于类型为
T
的任何两个表达式x
y
,满足:x == y
===y == x
传递性 (Transitivity)
- 对于类型为
T
的任何三个表达式x
y
z
,满足:
- 如果:
(x == y) && (y == z)
===True
- 那么:
x == z
=== True外延性 (Extensionality)
- 对于任何函数
f :: X -> T
,以及类型为X
的两个表达式a
b
,满足:- 如果:
a == b
===True
- 那么:
f a == f b
===True
Negation
- 对于类型为
T
的任何两个表达式x
y
,满足:
x /= y
===not (x == y)
Eq 的最小完整实现:
(==) | (/=)
- 如果:你想将类型
T
声明为Eq
的一个实例,- 那么:你可以仅提供
==
和/=
两者之一在T
上的实现
07.02 Ord
类型类 Ord
的定义依赖于另外一个类型:
data Ordering = LT | EQ | GT
-
data
是 Haskell 的一个关键字 (Key Word),用于声明一个类型小和尚:
- 声明类型的关键字为什么不是
type
唐僧:
-
在 Haskell 中,关键字
type
被用来声明类型的别名- “类型的别名”:就是给一个类型赋予另外一个名字
- 这样,一个类型就同时有两个名字了
- “类型的别名”:就是给一个类型赋予另外一个名字
-
我个人认为:
type
的这种功能,是一个设计决策上的错误
- 声明类型的关键字为什么不是
-
这个类型定义,用 “第二章:初见函数式思维” 中的那种语言,可以表述为如下形式:
def Ordering : Type = { ctor LT : Self, ctor EQ : Self, ctor GT : Self, }
-
这个类型定义,用人类语言描述,就是:
-
Ordering
这个类型具有三个值LT
EQ
GT
- 类似于:
Bool
这个类型具有两个值True
False
- 类似于:
-
类型类 Ord
的定义如下:
class (Eq a) => Ord a where
compare :: a -> a -> Ordering
(<), (<=), (>), (>=) :: a -> a -> Bool
max, min :: a -> a -> a
compare x y = if x == y then EQ
else if x <= y then LT
else GT
x < y = case compare x y of { LT -> True; _ -> False }
x <= y = case compare x y of { GT -> False; _ -> True }
x > y = case compare x y of { GT -> True; _ -> False }
x >= y = case compare x y of { LT -> False; _ -> True }
max x y = if x <= y then y else x
min x y = if x <= y then x else y
这个定义看起来有点复杂,让我们仔细捋一捋:
class (Eq a) => Ord a where -- 这行代码含义如下:
-
声明了一个类型类
Ord
-
声明了一个类型类约束
Eq a
,含义如下:- 对于任何一个类型
a
- 如果:
a
想要成为Ord
的实例 - 那么:
a
必须首先成为Eq
的实例
- 如果:
- 对于任何一个类型
compare :: a -> a -> Ordering
(<), (<=), (>), (>=) :: a -> a -> Bool
max, min :: a -> a -> a -- 这三行代码含义如下:
- 对于任何类型
a
- 如果:
a
想要成为Ord
的实例, - 那么:
a
就必须实现上面声明的所有 7 个函数/运算符
- 如果:
-- 其余的代码,给出了所有待实现的函数/运算符的缺省实现;不再赘述
Ord
定义了一种 全序 (Total Order) 关系,因此,满足如下性质:
-
可比性 (Comparability)
x <= y
||y <= x
===True
-
传递性 (Transitivity)
- 如果:
x <= y
&&y <= z
===True
- 那么:
x <= z
===True
- 如果:
-
自反性 (Reflexivity)
x <= x
===True
-
反对称性 (Antisymmetry)
- 如果:
x <= y
&&y <= x
===True
- 那么:
x == y
===True
- 如果:
同时,Haskell 规范 希望:Ord
的任何实现,应保持下述性质成立:
x >= y
===y <= x
x < y
===x <= y
&&x /= y
x > y
===y < x
x < y
===compare x y == LT
x > y
===compare x y == GT
x == y
===compare x y == EQ
(min x y) == (if x <= y then x else y)
===True
(max x y) == (if x >= y then x else y)
===True
Ord
的最小完整实现:compare | (<=)
- 如果:你想将类型
T
声明为Ord
的一个实例,- 那么:你可以仅提供
compare
和<=
两者之一在T
上的实现
07.03 Num
类型类 Num
的定义如下:
class Num a where
(+), (-), (*) :: a -> a -> a
negate :: a -> a -- Unary negation
abs :: a -> a -- Absolute value
signum :: a -> a -- Sign of a number
fromInteger :: Integer -> a -- Conversion from an Integer
x - y = x + negate y
negate x = 0 - x
Haskell 规范没有对实现
Num
的类型应该满足的性质给出任何规定在数学的意义上,如果类型
T
实现了Num
(即,T
是Num
的实例),则应满足如下性质:
+
满足结合律 (Associativity)
(x + y) + z
===x + (y + z)
+
满足交换律 (Commutativity)
x + y
===y + x
fromInteger 0
是+
的单位元
x + fromInteger 0
===x
negate
是+
的逆元
x + negate x
===fromInteger 0
*
满足结合律
(x * y) * z
===x * (y * z)
fromInteger 1
是*
的单位元
x * fromInteger 1
===x
fromInteger 1 * x
===x
*
对+
的分配律 (Distributivity)
- a * (b + c) === (a * b) + (a * c)
- (b + c) * a === (b * a) + (c * a)
Num
的最小完整实现:
(+)
,(*)
,abs
,signum
,fromInteger
,(negate | (-))
也即:
- 前 5 个必须给出实现;
negate
和(-)
两者之一给出实现
本章作业
作业 01
请写出如下表达式的类型:
['a', 'b', 'c']
('a', 'b', 'c')
[(False, '0'), (True, '1')]
([False, True], ['0', '1'])
[tail, init, reverse]
- 说明:
tail
init
reverse
是Prelude
模块输出的三个元素
作业 02
请写出如下函数的类型:
second xs = head (tail xs)
swap (x, y) = (y, x)
pair x y = (x, y)
double x = x * 2
palindrome xs = reverse xs == xs
twice f x = f (f x)
作业 03
阅读教科书,用例子 (在
ghci
上运行) 展示:
Int
与Integer
的区别,以及
show
和read
的用法。
作业 04
阅读教科书和
Prelude
模块文档
理解
Integral
和Fractional
这两个 Type Class 中定义的函数和运算符用例子 (在
ghci
中运行) 展示每一个函数/运算符的用法
第 05 章:函数的定义
主要知识点:
- 利用已有函数定义新函数 / 条件表达式 / 模式匹配 / Lambda表达式 / Section
01 利用已有函数定义新函数
问题 1:判断一个整数是否是偶数
even :: Int -> Bool
even n = mod n 2 == 0
- 其中,
mod
是一个已经存在的函数
问题 2:计算一个浮点数的倒数
recip :: Double -> Double
recip x = 1 / x
- 其中,
(/)
是一个已经存在的函数
问题 3:将一个 list 在位置 n 分开
splitAt :: Int -> [a] -> ([a], [a])
splitAt n xs = (take n xs, drop n xs)
- 其中,
take
和drop
是两个已经存在的函数
02 条件表达式 (Conditional Expression)
如同大多数编程语言一样,Haskell 中也存在 条件表达式
abs :: Int -> Int
abs n = if n >= 0 then n else -n
- 函数
abs
- 接收一个整数
n
- 如果
n
是一个非负值,则返回n
;否则,返回-n
- 接收一个整数
条件表达式可以被嵌套
signum :: Int -> Int
signum n = if n < 0 then -1 else
if n == 0 then 0 else 1
- 在第一个条件表达式的
else
分支中,又嵌套了一个条件表达式
在 Haskell 中,不存在 三分支或更多分支的条件表达式
- 但通过条件表达式的嵌套,可以表达出三分支/多分支的语义
在 Haskell 中,不存在 单分支条件表达式
- 在 Rust 中,在语法上确实存在单分支条件表达式,但在语义上它仍然是一个双分支表达式
03 Guarded Equation
在定义函数时,也可以通过 Guarded Equation 语法实现多分支的效果:
abs :: Int -> Int
abs n | n >= 0 = n
| otherwise = -n
-
对于函数应用
abs n
,- 当条件
n >= 0
成立时,abs n
被定义为n
- 当条件
otherwise
成立时,abs n
被定义为-n
- 当条件
-
otherwise
是Prelude
模块输出的一个元素,其定义为otherwise = true
-
因此,
| otherwise = -n
是一个兜底的分支
signum :: Int -> Int
signum n | n < 0 = -1
| n == 0 = 0
| otherwise = 1
- 显然,Guarded Equation 用来表达多分支结构,太方便了
04 模式匹配 (Pattern Matching)
很多函数更适合使用模式匹配进行定义:
not :: Bool -> Bool
not False = True
not True = False
-
这是模式匹配的一种极简形式,所以看起来有些无聊
即便如此,如果不使用模式匹配,你能用其他方法定义
not
函数吗? -
为什么这种定义方式称为模式匹配呢?解释如下:
-
Bool
是Prelude
模块输出的一个类型,其定义如下:data Bool = True | False
-
data
是 Haskell 语言中定义类型的关键字 -
这个类型定义,用 “第二章:初见函数式思维” 中的那种语言,可以表述为如下形式:
def Bool : Type = { ctor True : Self, ctor False : Self, }
-
也即,类型
Bool
的值仅存在两种模式 / 构造方式 / Constructor
-
-
因此,如果在这两种模式上对
not
给出了定义,自然地,就给出了not
的完整定义
-
-
称上面的模式匹配是一种极简形式,原因是:
-
其中涉及的两个模式
True
False
没有参数- 在更一般的情况下,模式中存在参数;然后,就会很有趣
-
-
在更本质的意义上,“模式匹配” 就是 分情况讨论
利用模式匹配,定义 逻辑与
函数。三种方式:
-- 方式一
(&&) :: Bool -> Bool -> Bool
True && True = True
True && False = False
False && True = False
False && False = False
-- 方式二
(&&) :: Bool -> Bool -> Bool
True && True = True
_ && _ = False
-
下划线
_
是一个通配符,可以匹配到任何值 -
上面的程序表明:模式匹配的顺序存在语义
- 即:按照定义中出现的模式匹配语句依次进行匹配
-- 方式三
(&&) :: Bool -> Bool -> Bool
True && b = b
False && _ = False
-
与前两种定义方式相比,这种方式具有更高的效率
- 原因:它完全避免了对第二个参数的评估
-
在第一个模式匹配语句中出现的
b
, 称为 变量模式- 它的效果:把第二个参数绑定到局部变量
b
上
- 它的效果:把第二个参数绑定到局部变量
Haskell 不支持在一个模式匹配语句中出现两个相同的变量模式。
例如,如下定义存在编译时错误:
(&&) :: Bool -> Bool -> Bool
b && b = b
_ && _ = False
-
在表面上看起来,这样的程序似乎没有问题
-
但在一般的意义上,判断两个东西是否相等,存在理论或技术上的困难性
05 序列模式 (List Pattern)
List
类型的定义如下:data List a = [] | (:) a (List a)
其中,出现了两个模式 / 构造方式 / Constructor:
[]
:其类型为List a
(:)
:其类型为a -> List a -> List a
Haskell 支持采用如下语法表达一个 list:
[1, 2, 3, 4, 5]
这实际上是一种语法糖,去糖后,得到的表达式如下:
1 : (2 : (3 : (4 : (5 : []))))
然后,Haskell 规定:运算符
:
满足右结合律。因此,该表达式可进一步简化为:
1 : 2 : 3 : 4 : 5 : []
删除空格后,得到更为紧凑的形式:
1:2:3:4:5:[]
定义 List
上的函数时,一种常见的模式是 x:xs
。其效果是:
-
把一个 list 的第一个元素 绑定到 局部变量
x
上 -
把一个 list 删除第一个元素后得到的 list 绑定到 局部变量
xs
上
以下为两个示例函数:
head :: [a] -> a
head (x:_) = x
tail :: [a] -> [a]
tail (_:xs) = xs
-
只有非空 list 才能匹配到模式
x:xs
上- 因此,这两个函数都是 partial function (它们在
[]
上没有定义)
- 因此,这两个函数都是 partial function (它们在
注意:以下程序会产生编译错误:
head :: [a] -> a
head x:_ = x
原因:
-
在 Haskell 中,function application (函数应用) 具有最高优先级
-
因此,
head x:_ = x
会被编译器理解为(head x):_ = x
06 元组模式 (Tuple Pattern)
元组模式没有什么好说的,仅以两个示例意思一下:
-- Extract the first component of a pair.
fst :: (a, b) -> a
fst (x, _) = x
-- Extract the second component of a pair.
snd :: (a, b) -> b
snd (_, y) = y
07 λ 表达式 (Lambda Expression)
在非常肤浅的层面上,λ 表达式提供了如下能力:
- 创建匿名函数:即,创建一个没有名字的函数
例如:表达式 \x -> x + x
是一个匿名函数
- 该函数接收一个
x
,返回x + x
可以把 λ 表达式中的左斜线
\
理解为字母λ
的 谐形字母
- 为什么要这样呢?
- 原因:键盘输入
λ
不方便
λ 表达式 为柯里化函数的定义提供了更加精确的含义
-
例如:
add x y = x + y
,其含义是: -
add = \x -> (\y -> x + y)
λ 表达式 可对仅使用一次的函数进行 “匿名原地构造”
odds n = map f [0..n-1]
where
f x = x * 2 + 1
-- defined in Prelude
map :: (a -> b) -> [a] -> [b]
map _ [] = []
map f (x:xs) = f x : map f xs
-
在上面的程序中,
odds
函数的定义中,出现了一个仅使用了一次的函数f
-
可以使用 λ 表达式 在使用的地方对该函数进行匿名原地构造
odds n = map (\x -> x * 2 + 1) [0..n-1]
08 Operator Sections
把一个二元运算符放在一对圆括号中,就能得到该运算符对应的柯里化函数
ghci> 1 + 2
3
ghci> (+) 1 2
3
ghci> :type (+)
(+) :: Num a => a -> a -> a
甚至可以在圆括号中放置一个参数
ghci> (+ 1) 2
3
ghci> :type (+ 1)
(+ 1) :: Num a => a -> a
ghci> (1 +) 2
3
ghci> :type (1 +)
(1 +) :: Num a => a -> a
ghci> (1 -) 2
-1
ghci> :type (1 -)
(1 -) :: Num a => a -> a
但是,存在一个特殊情况
ghci> :type (- 1)
(- 1) :: Num a => a
ghci> (- 1) 2
<interactive>:5:1: error: [GHC-39999]
- 其中,
- 1
被编译器理解为对1
取负数
在一般意义上,对于任意二元运算符
⊕
,如下三种形式称为 “section”
(⊕)
,(x ⊕)
,(⊕ y)
这三种 section 的定义如下:
(⊕)
===\x -> (\y -> x ⊕ y)
(x ⊕)
===\y -> x ⊕ y
(⊕ y)
===\x -> x ⊕ y
使用 section,可以方便地定义一些函数
-
(+ 1)
:后继函数 -
(1 /)
:倒数函数 -
(* 2)
:翻倍函数 -
(/ 2)
:减半函数
本章作业
作业 01
定义一个
safetail
函数,满足如下要求:
- 该函数与
tail
函数具有相同的类型- 当作用在一个非空 list 上,该函数与
tail
行为相同- 当作用在一个空 list 上,该函数返回一个空 list
说明:
- 如果你愿意,可以使用函数
null :: [a] -> Bool
判断 list 是否为空
作业 02
Luhn 算法被用于检查银行卡号中可能存在的简单书写错误 (例如,写错了一个数字)。
该算法的工作流程如下所述:
- 将银行卡号中的每一个数字字符视为一个独立的整数
- 从右向左,偶数位的数乘 2 (奇数位的数不变)
- 对于每一个大于 9 的数,减去 9;然后将所有的数相加
- 如果相加的结果能被 10 整除,则表示银行卡号合法;否则,非法
定义函数
luhn :: Int -> Int -> Int -> Int -> Int
,对 4 位卡号的合法性进行检查。例如:ghci> luhn 1 7 8 4 True
ghci> luhn 4 7 8 3 False
第 06 章:List Comprehension
主要知识点:
- Generator / Guard / String Comprehension
01 List Comprehension
在集合论中,我们通常使用 Set Comprehension 实现 “从已有集合出发构造新的集合” 的效果:
{ x² | x ∈ { 1, 2, 3, 4, 5 } }
在 Haskell 中,一种类似的 List Comprehension 语法,可以实现 “从已有 list 出发构造新的 list” 的效果:
{ x^2 | x <- [1..5] }
上面这个表达式,从一个已有的 list [1, 2, 3, 4, 5]
出发,构造出了一个新的 list [1, 4, 9, 16, 25]
。
02 Generator
在上面的示例中,x <- [1..5]
称为一个 generator。
- 因为:它能够 生成 (generate) 一些值
在一个 List Comprehension 中,可以存在多个 generators:
[ (x, y) | x <- [1, 2, 3], y <- [4, 5] ]
这个表达式构造的 list 是:
[ (1, 4), (1, 5), (2, 4), (2, 5), (3, 4), (3, 5) ]
改变多个 generators 的顺序,会导致形成的 list 中的元素的顺序发生变化:
[ (x, y) | y <- [4, 5], x <- [1, 2, 3] ]
这个表达式构造的 list 是:
[ (1, 4), (2, 4), (3, 4), (1, 5), (2, 5), (3, 5) ]
Dependant Generator
后面的 generator 可以依赖于前面的 generator 生成的值:
[ (x, y) | x <- [1..3], y <- [x..3] ]
这个表达式构造的 list 是:
[ (1, 1), (1, 2), (1, 3), (2, 2), (2, 3), (3, 3) ]
利用 Dependant Generator,可以给出 Prelude 模块中 concat
函数的定义:
concat :: [[a]] -> [a]
concat xss = [ x | xs <- xss, x <- xs ]
ghci> concat [ [1, 2, 3], [4, 5], [6] ]
[1,2,3,4,5,6]
03 Guard
可用使用 Guard 对 generator 生成的值进行过滤:
[ x | x <- [1..10], even x ]
这个表达式构造的 list 是:
[ 2, 4, 6, 8, 10 ]
示例: 使用 Guard,可以定义 “计算一个正整数的所有因子” 的函数:
factors :: Int -> [Int]
factors n = [ x | x <- [1..n], mod n x == 0 ]
ghci> factors 1000
[1,2,4 5,8,10,20,25,40,50,100,125,200,250,500,1000]
在此基础上,可以定义 “判断一个正整数是否是素数” 的函数:
prime :: Int -> Bool
prime n = factors n == [1,n]
ghci> prime 1
False
ghci> prime 72
False
ghci> prime 73
True
在此基础上,可以定义 “计算前 n 个素数” 的函数:
primes :: Int -> [Int]
primes n = [x | x <- [2..n], prime x]
ghci> primes 70
[2,3,5,7,11,13,17,19,23,29,31,37,41,43,47,53,59,61,67]
04 zip
函数
Prelude 模块中存在一个函数 zip
,其定义如下:
zip :: [a] -> [b] -> [(a,b)]
zip [] _ = []
zip _ [] = []
zip (a:as) (b:bs) = (a,b) : zip as bs
ghci> zip ['a', 'b', 'c'] [1, 2, 3, 4]
[('a',`),('b',2),('c',3)]
你应该能够理解 zip
函数的效果:即,把两个 list 向拉链一样合并到一起
示例: 使用 zip
函数,可以定义 “计算一个 list 中所有相邻元素对” 的函数
pairs :: [a] -> [(a,a)]
pairs xs = zip xs (tail xs)
ghci> pairs [1..10]
[(1,2),(2,3),(3,4),(4,5),(5,6),(6,7),(7,8),(8,9),(9,10)]
示例: 使用 zip
函数,可以定义 “判断一个 list 是否处于有序 (从小到大) 状态” 的函数
sorted :: Ord a => [a] -> Bool
sorted xs = and [ x <= y | (x, y) <- pairs xs ]
ghci> sorted [1..10]
True
ghci> sorted [1,3,2,4]
False
示例: 使用 zip
函数,可以定义 “计算一个值在一个 list 中所有出现的位置” 的函数
positions :: Eq a => a -> [a] -> [Int]
positions x xs = [ i | (x',i) <- zip xs [0..], x == x' ]
05 String Comprehension
在 Haskell 中,一个 字符串字面量 (String Literal) 是一个由一对双引号包围的字符序列。
"abcd" :: String
其中,类型 String
是类型 [Char]
的别名。因此,上述声明等价于:
['a', 'b', 'c', 'd'] :: [Char]
因为 String
是一种特殊的 List 类型,所以任何定义在 List 类型上的多态函数也适用于 String
类型。
ghci> length "abcde"
5
ghci> take 3 "abcde"
"abc"
ghci> zip "abcd" [1, 2, 3, 4]
[('a',1),('b',2),('c',3),('d',4)]
类似地,List Comprehension 也可用于定义作用在 String
上的函数。例如,下面给出了 “计算字符在字符串中出现次数” 的函数定义:
count :: Char -> String -> Int
count x xs = length [ x' | x' <- xs, x == x' ]
ghci> count 'g' "googlegod"
3
06 问题示例:凯撒加密
为了对字符串文本进行加密,凯撒发明了如下的加密方式:
-
把一个英文字母替换为它在字母表中后面的第 3 个字母
-
如果到达了字母表的末尾 (即:字母
z
),则回转到第一个字母a
- 也即,字母
z
的后继字母是a
- 也即,字母
下面,我们按照这种方式,分别定义 加密 和 解密 两个函数。
ghci> :type encode
encode :: Int -> String -> String
ghci> encode 3 "haskell is fun"
"kdvnhoo lv ixq"
ghci> encode (-3) "kdvnhoo lv ixq"
"haskell is fun"
ghci> :type crack
crack :: String -> String
ghci> crack "kdvnhoo lv ixq"
"haskell is fun"
加密 / encode
import Data.Char(ord, chr, isLower)
-- ord :: Char -> Int // 将字符转换为编码值
-- chr :: Int -> Char // 将编码值转换为字符
-- isLower :: Char -> Bool // 判断字符是否为小写字母
encode :: Int -> String -> String
encode n xs = [ shift n x | x <- xs ]
shift :: Int -> Char -> Char
shift n c | isLower c = int2let $ mod (let2int c + n) 26
| otherwise = c
let2int :: Char -> Int
let2int c = ord c - ord 'a'
int2let :: Int -> Char
int2let n = chr $ ord 'a' + n
解密 / crack
对凯撒加密后的字符串进行解密的关键:
- 在英文语料中,不同字母出现的频率是不同的
下面定义 table
依次记录了 26 个英文字母的出现百分比:
table :: [Float]
table = [ 8.1, 1.5, 2.8, 4.2, 12.7, 2.2, 2.0, 6.1, 7.0,
0.2, 0.8, 4.0, 2.4, 6.7, 7.5, 1.9, 0.1, 6.0,
6.3, 9.0, 2.8, 1.0, 2.4, 0.2, 2.0, 0.1 ]
chi-square statistic:一种对一组 期望频率 (expected frequencies) 和 一组 观察频率 (observed frequencies) 进行比较的标准方法。
假设存在两个长度为 n
的频率序列: 1.期望频率序列 es
;2.观察频率序列 os
。则两者的 chi-square statistic 定义如下:
\[ \sum_{i=0}^{n - 1} \frac{(os[i] - es[i])^2}{es[i]}\]
#![allow(unused)] fn main() { import Data.Char(ord, chr, isLower) crack :: String -> String crack xs = encode (- factor) xs where factor = position (minimum chitab) chitab -- minimum: a function exported by Prelude -- 计算每种加密偏移量下的chisqr chitab = [ chisqr (rotate n table') table | n <- [0..25] ] -- 计算密文中字母的出现频率 table' = freqs xs table :: [Float] table = [ 8.1, 1.5, 2.8, 4.2, 12.7, 2.2, 2.0, 6.1, 7.0, 0.2, 0.8, 4.0, 2.4, 6.7, 7.5, 1.9, 0.1, 6.0, 6.3, 9.0, 2.8, 1.0, 2.4, 0.2, 2.0, 0.1 ] position :: Eq a => a -> [a] -> Int rotate :: Int -> [a] -> [a] freqs :: String -> [Float] chisqr :: [Float] -> [Float] -> Float }
本章作业
作业 01
请给出凯撒解密函数的完整定义:即,把上述定义中缺失的函数补充完整
说明:
仅考虑 “明文中仅包含小写字母和空格” 的情况
自行学习如何使用 Prelude 中的如下函数实现从整数类型到浮点数类型的转换
fromIntegral :: (Integral a, Num b) => a -> b
作业 02
称一个三元组
(x, y, z)
为 毕达哥拉斯 (Pythagorean) 三元组,如果其满足如下条件:
x² + y² === z²
使用 List Comprehension,定义一个函数:
pyths :: Int -> [(Int, Int, Int)]
该函数接收一个正整数
n
,返回区间[1..n]
中所有的毕达哥拉斯三元组 (返回的三元组序列按照从小到大的顺序排列)例如:
ghci> pyths 5 [(3,4,5),(4,3,5)]
作业 03
称一个正整数为完美 (perfect) 数,如果它等于自身所有因子 (不含自身) 的和
使用 List Comprehension,定义一个函数:
perfects :: Int -> [Int]
该函数接收一个正整数
n
,返回区间[1..n]
中的所有完美数 (返回的序列按照从小到大的顺序排列)例如:
ghci> perfects 500 [6, 28, 496]
作业 04
对于两个长度为
n
的整数序列xs
ys
,两者的的点积 (Scalar Product) 定义如下:\[ \sum_{i=0}^{n-1} (xs[i] * ys[i])\]
使用 List Comprehension,定义一个 “计算两个整数序列的点积” 的函数:
第 07 章:递归函数
01 递归函数
在前文中已经看到,可以基于已有的函数定义新的函数:
fac :: Int -> Int
fac n = product [1..n]
在很多情况下,一个函数可以通过自身对自身进行定义:
fac :: Int -> Int
fac 0 = 1
fac n = n * fac (n-1)
这类函数称为 递归函数 (Recursive Function)。
02 为什么需要递归函数?
-
一些函数,其递归定义方式更为简洁
-
一些函数,其定义本身就天然存在递归
-
在一些情况下,递归定义的函数,其数学性质更易于证明
03 List 上的递归函数
递归不仅适用于整数类型,也适用于 List 以及其他类型。
示例:List 中元素的乘积
product :: Num a => [a] -> a
product [] = 1
product (n:ns) = n * product ns
示例:List 的长度
length :: [a] -> Int
length [] = 0
length (_:xs) = 1 + length xs
示例:List 逆序
reverse :: [a] -> [a]
reverse [] = []
reverse (x:xs) = reverse xs ++ [x]
示例:插入排序
isort :: Ord a => [a] -> [a]
isort [] = []
isort (x:xs) = insert x (isort xs)
insert :: Ord a => a -> [a] -> [a]
insert x [] = [x]
insert x (y:ys) | x <= y = x:y:ys
| otherwise = y:(insert x ys)
04 多参数递归
具有多个参数的函数,也可以进行递归定义。
示例:zip 函数
zip :: [a] -> [b] -> [(a,b)]
zip [] _ = []
zip _ [] = []
zip (x:xs) (y:ys) = (x, y) : zip xs ys
示例:drop 函数
drop :: Int -> [a] -> [a]
drop 0 xs = xs
drop _ [] = []
drop n (_:xs) = drop (n-1) xs
示例:序列拼接函数
(++) :: [a] -> [a] -> [a]
[] ++ ys = ys
(x:xs) ++ ys = x : (xs ++ ys)
05 多重递归 (Multiple Recursion)
所谓 多重递归,指的是:在定义一个函数时,对函数自身进行了多次递归调用。
fib :: Int -> Int
fib 0 = 0
fib 1 = 1
fib n = fib (n - 2) + fib (n - 1)
qsort :: Ord a => [a] -> [a]
qsort [] = []
qsort (x:xs) = qsort smaller ++ [x] ++ qsort larger
where
smaller = [a | a <- xs, a <= x]
larger = [b | b <- xs, b > x]
qsort [3, 2, 4, 1, 5]
=== qsort [2,1] ++ [3] ++ qsort [4,5]
=== qsort [1] ++ [2] ++ qsort [] ++ [3] ++ qsort [] ++ [4] ++ qsort [5]
=== [1] ++ [2] ++ [] ++ [3] ++ [] ++ [4] ++ [5]
06 互递归 (Mutual Recursion)
所谓 互递归,指的是:在定义两或多个函数时,这些函数通过相互调用对方进行定义。
even :: Int -> Bool
even 0 = True
even n = odd (n-1)
odd :: Int -> Bool
odd 0 = False
odd n = even (n-1)
本章作业
作业 01
在不查看 Prelude 源码的情况下,使用递归定义如下函数:
- 判断
[Bool]
类型的一个值中的所有元素是否都为True
and :: [Bool] -> Bool
- 将类型
[[a]]
的一个值中包含的所有 list 拼接为一个 listconcat :: [[a]] -> [a]
- 获得一个 list 中编号为
n
的元素 (从0
开始编号)(!!) :: [a] -> Int -> a
- 生成一个包含
n
个重复元素的 listreplicate :: Int -> a -> [a]
- 判断一个元素是否包含在一个 list 中
elem :: Eq a => a -> [a] -> Bool
作业 02
采用递归的方式定义如下函数:
merge :: Ord a => [a] -> [a] -> [a]
该函数接收两个已经处于从小到大排序状态的 list,然后把其中包含的所有元素归并成一个保持排序状态的 list。
例如:
ghci> merge [2,5,6] [1,3,4] [1,2,3,4,5,6]
作业 03
采用递归的方式定义归并排序函数:
msort :: Ord a => [a] -> [a]
它的递归定义包含两条规则:
- 长度小于 2 的 list 已经处于排序状态
- 对于长度大于 1 的 list,将其从中间断开,形成两个更短的 list,然后:
- 对这两个更短的 list 分别进行归并排序
- 将排序后形成的两个 list 进行归并
第 08 章:高阶函数
“高阶函数” 的英文为: Higher-order Function
01 高阶函数
所谓 “高阶函数”,指的是:这个函数的某个参数或返回值是一个函数。
twice :: (a -> a) -> a -> a
twice f x = f (f x)
上面这个 twice
是一个高阶函数,原因如下:
-
这个函数接收的第一个参数是一个函数 (类型为
a -> a
);或者 -
这个函数接收第一个参数后返回一个函数 (类型为
a -> a
)
02 为什么需要高阶函数
-
一些常用的程序设计模式 (Common Programming Idiom) 可以表示为高阶函数
-
领域特定语言 (Domain Specific Language) 的很多成分,也可以表示为高阶函数
-
高阶函数具有的代数性质,可用于程序性质证明
03 map 函数
Prelude 模块中的 map
是一个经典的高阶函数,其功能是把一个函数作用到一个 list 中的每个元素上。
map :: (a -> b) -> [a] -> [b]
ghci> map (+1) [1, 2, 3, 4, 5]
[2, 3, 4, 5, 6]
map
函数可以使用 List Comprehension 进行简洁的定义:
map :: (a -> b) -> [a] -> [b]
map f xs = [f x | x <- xs]
map
函数也可以使用递归方式进行定义:
map :: (a -> b) -> [a] -> [b]
map _ [] = []
map f (x:xs) = f x : map f xs
04 filter 函数
Prelude 模块中的 filter
是一个经典的高阶函数,其功能是把 list 中不满足指定条件的元素删除。
filter :: (a -> Bool) -> [a] -> [a]
ghci> filter even [1..10]
[2,4,6,8,10]
filter
函数可以使用 List Comprehension 进行定义:
filter :: (a -> Bool) -> [a] -> [a]
filter pred xs = [x | x <- xs, pred x]
filter
函数也可以使用递归方式进行定义:
filter :: (a -> Bool) -> [a] -> [a]
filter _ [] = []
filter pred (x:xs)
| pred x = x : filter pred xs
| otherwise = filter pred xs
05 List 上的 foldr 函数
一些定义在 list 上的函数,可以使用如下的递归模式进行定义:
f [] = v
f (x:xs) = x ⊕ f xs
其含义是:
-
函数
f
将一个空 list[]
映射到值v
-
函数
f
将一个非空 list(x:xs)
映射为一个函数(⊕)
作用到x
和f xs
上
请看下面的三个示例:
sum [] = 0
sum (x:xs) = x + sum xs
sum = foldr (+) 0
product [] = 1
product (x:xs) = x * product xs
product = foldr (*) 1
and [] = True
and (x:xs) = x && and xs
and = foldr (&&) True
在 Haskell 中,foldr
是 type class Foldable
中的一个函数:
class Foldable t where
foldr :: (a -> b -> b) -> b -> t a -> b
...
-
在一般意义上,
foldr
是定义在一种结构 (structure) 上的满足右结合律的折叠 (fold) 操作,且具有惰性求值的特点- “结构”:理解为为 “类型” 即可;每一种类型都可以视为一种结构
- “惰性求值”:大致可以理解为,当前用不到的计算结果,绝对不会去计算
-
在 List 这种结构上的
foldr
,具有如下行为:foldr f z [x1, x2, ..., xn] === x1 `f` (x2 `f` ... (xn `f` z) ...) -- 因为 `f` 满足右结合律,所以 === x1 `f` x2 `f` ... xn `f` z === f x1 (f x2 (... (f xn z) ...))
-
在对上面
===
右侧的表达式进行求值时,按照惰性求值的策略,首先对最外层函数应用进行求值- 因此,如果函数
f
对其第二个参数也具有惰性求值的行为,那么,即使foldr
的三个参数是一个 infinite list,foldr
函数也有可能终止
- 因此,如果函数
List 上的 foldr
可以采用递归方式进行定义:
foldr :: (a -> b -> b) -> b -> [a] -> b
foldr f v [] = v
foldr f v (x:xs) = f x (foldr f v xs)
在宏观上,可以将 List 上的 foldr
理解为:将一个 list 中的 []
替换为一个指定的值;同时,将所有的 (:)
替换为一个指定的函数
若干示例:
sum = foldr (+) 0
sum [1, 2, 3]
=== foldr (+) 0 [1, 2, 3]
=== foldr (+) 0 (1 : (2 : (3 : [])))
=== 1 + (2 + (3 + 0 ))
=== 6
product = foldr (*) 1
product [1, 2, 3]
=== foldr (*) 1 [1, 2, 3]
=== foldr (*) 1 (1 : (2 : (3 : [])))
=== 1 * (2 * (3 * 1 ))
=== 6
length :: [a] -> Int
length [] = 0
length (_:xs) = 1 + length xs
length [1, 2, 3]
=== length (1 : (2 : (3 : [])))
=== 1 + (1 + (1 + 0 ))
=== 3
length :: [a] -> Int
length = foldr (⊕) 0 where
_ ⊕ n = 1 + n
length [1, 2, 3]
=== foldr (⊕) 0 (1 : (2 : (3 : [])))
=== 1 ⊕ (2 ⊕ (3 ⊕ 0 ))
=== 1 + (1 + (1 + 0 ))
=== 3
reverse :: [a] -> [a]
reverse [] = []
reverse (x:xs) = reverse xs ++ [x]
reverse [1, 2, 3]
=== reverse (1 : (2 : (3 : [])))
=== (([] ++ [3]) ++ [2]) ++ [1]
=== [3, 2, 1]
reverse :: [a] -> [a]
reverse = foldr (⊕) [] where
x ⊕ xs = xs ++ [x]
reverse [1, 2, 3]
=== foldr (⊕) [] (1 : (2 : (3 : [])))
=== 1 ⊕ (2 ⊕ (3 ⊕ []))
=== (([] ++ [3]) ++ [2]) ++ [1]
=== [3, 2, 1]
最后,可以看到,函数 (++)
采用 foldr
进行定义非常简洁:
(++) :: [a] -> [a] -> [a]
(++ ys) = foldr (:) ys
遗憾的是,Haskell 目前并不支持这种定义方式。
以下是两种可以通过编译的定义方式:
(++) :: [a] -> [a] -> [a]
(++) xs ys = foldr (:) ys xs
(++) :: [a] -> [a] -> [a]
(++) = flip $ foldr (:)
-- flip 是 Prelude 中的一个函数,其定义如下:
flip :: (a -> b -> c) -> b -> a -> c
flip f x y = f y x
(++) xs ys
=== (flip $ foldr (:) ) xs ys
=== (flip (foldr (:))) xs ys
=== flip (foldr (:)) xs ys
=== (foldr (:)) ys xs
=== foldr (:) ys xs
为什么需要 foldr
-
一些函数,使用
foldr
定义,非常简洁 -
foldr
具有的代数性质,可以用于程序性质证明 -
使用
foldr
定义的函数便于进行性能优化
06 List 上的 foldl 函数
在 List 上的一些函数,也可以采用左结合的方式进行递归定义。共性模式如下:
f v [] = v
f v (x:xs) = f (v ⊕ x) xs
List 上的 foldl
函数可以采用递归方式定义:
foldl :: (b -> a -> b) -> b -> [a] -> b
foldl f v [] = v
foldl f v (x:xs) = foldl f (f v x) xs
与 foldl
类似,在 Haskell 中,foldr
是 type class Foldable 中的一个函数:
class Foldable t where
foldr :: (a -> b -> b) -> b -> t a -> b
foldl :: (b -> a -> b) -> b -> t a -> b
...
-
在一般意义上,
foldl
是定义在一种结构 (structure) 上的满足左结合律的折叠 (fold) 操作,且具有惰性求值的特点 -
在 List 这种结构上的
foldl
,具有如下行为:foldl f z [x1, x2, ..., xn] === (((z `f` x1) `f` x2)...) `f` xn -- 因为 `f` 满足左结合律,所以 === z `f` x1 `f` x2 ... `f` xn === f (... (f (f z x1) x2) ...) xn
-
在对上面 === 右侧的表达式进行求值时,按照惰性求值的策略,首先对最外层函数应用进行求值
- 因此,如果
foldl
的三个参数是一个 infinite list,则foldl
函数 不会终止
- 因此,如果
-
如果想要一个传统高效的
foldl
,可以使用foldl'
函数
07 Prelude 中的若干高阶函数
函数组合
(.) :: (b -> c) -> (a -> b) -> a -> c
(.) f g = \x -> f $ g x
-
其中,
\x -> f $ g x
是 Haskell 中声明匿名函数的语法 -
使用示例:
odd :: Int -> Bool odd = not . even
all 函数
all
函数计算一个结构中的所有元素是否都满足一个指定的条件 (谓词)
all :: Foldable t => (a -> Bool) -> t a -> Bool
在 List 上,all
函数的定义如下:
all :: (a -> Bool) -> [a] -> Bool
all p xs = and [p x | x <- xs]
any 函数
any
函数计算一个结构中的所有元素中是否存在至少一个满足指定条件的元素
any :: Foldable t => (a -> Bool) -> t a -> Bool
在 List 上,any
函数的定义如下:
any :: (a -> Bool) -> [a] -> Bool
any p xs = or [p x | x <- xs]
takeWhile 函数
takeWhile
函数持续取出一个 list 中的元素,直到遇到第一个不满足指定条件的元素
takeWhile :: (a -> Bool) -> [a] -> [a]
takeWhile _ [] = []
takeWhile p (x:xs)
| p x = x : takeWhile p xs
| otherwise = []
ghci> takeWhile (/= ' ') "abc def"
"abc"
dropWhile 函数
与 takeWhile
相反,dropWhile
函数持续地忽略一个 list 中的元素,直到遇到第一个不满足指定条件的元素
dropWhile :: (a -> Bool) -> [a] -> [a]
dropWhile _ [] = []
dropWhile p xs@(x:xs')
| p x = dropWhile p xs'
| otherwise = xs
其中出现了一种新的语法
xs@(x:xs')
。你猜猜这种语法的效果是什么?
08 应用 01:Binary String Transmitter
2 进制数 转换到 10 进制数
效果:
ghci> bin2int [1, 0, 1, 1]
13
- 待转换的二进制数放置在 list 中,且:低位在左,高位在右
定义方式一:
type Bit = Int
-- 将 Bit 作为类型 Int 的别名
bin2int :: [Bit] -> Int
bin2int bits = sum [ w * b | (w, b) <- zip weights bits ]
where weights = iterate (* 2) 1
-- iterate is defined in Prelude
iterate :: (a -> a) -> a -> [a]
iterate f x = x : iterate f (f x)
定义方式二:
type Bit = Int
bin2int :: [Bit] -> Int
bin2int = foldr (\x y -> x + 2 * y) 0
10 进制数 转换到 8 位 2 进制数
效果:
ghci> int2bin8 13
[1, 0, 1, 1, 0, 0, 0, 0]
定义:
int2bin :: Int -> [Bit]
int2bin 0 = []
int2bin n = mod n 2 : int2bin (div n 2)
make8 :: [Bit] -> [Bit]
make8 bits = take 8 $ bits ++ repeat 0
-- repeat is defined in Prelude
repeat :: a -> [a]
repeat x = xs where xs = x : xs
int2bin8 :: Int -> [Bit]
int2bin8 = make8 . int2bin
文字序列编码
效果:
ghci> encode "abc"
[1,0,0,0,0,1,1,0,0,1,0,0,0,1,1,0,1,1,0,0,0,1,1,0]
定义:
encode :: String -> [Bit]
encode = concat . map (make8 . int2bin . ord)
2 进制序列解码
效果:
ghci> decode [1,0,0,0,0,1,1,0,0,1,0,0,0,1,1,0,1,1,0,0,0,1,1,0]
"abc"
定义:
decode :: [Bit] -> String
decode = map (chr . bin2int) . chop8
chop8 :: [Bit] -> [[Bit]]
chop8 [] = []
chop8 bits = take 8 bits : chop8 (drop 8 bits)
09 应用 02.01:投票算法 之 First Past the Post
在这种投票系统中,每一个投票者仅可以投一个候选项。获得票数最多的候选项,成为获胜者。
以下给出投票结果的一个示例:
votes :: [String]
votes = ["Red", "Blue", "Green", "Blue", "Blue", "Red"]
编写两个函数 result
和 winner
,实现如下效果:
ghci> result votes
[(1,”Green"),(2,"Red"),(3,"Blue")]
ghci> :type result
result :: Ord a => [a] -> [(Int, a)]
ghci> winner votes
"Blue"
ghci> :type winner
winner :: Ord a => [a] -> a
定义:
result :: Ord a => [a] -> [(Int, a)]
result vs = sort [ (count v vs, v) | v <- rmdups vs ]
-- The sort function is defined in Data.List
rmdups :: Eq a => [a] -> [a]
rmdups [] = []
rmdups (x:xs) = x : filter (/= x) (rmdups xs)
count :: Eq a => a -> [a] -> Int
count x = length . filter (== x)
winner :: Ord a => [a] -> a
winner = snd . last . result
09 应用 02.02:投票算法 之 Alternative Vote
在这种投票系统中,每一个投票者:
-
可以给任意多个候选项进行投票
-
但是需要给所投的候选项排序,从而表明自己对所投候选项的偏好
- 排序在第 1 位的候选项,为第 1 选择;排序在第 2 位的候选项,为第 2 选择;以此类推
下面给出了记录所有投票者投票结果的示例 (包含 5 个投票者的投票结果):
ballots :: [[String]]
ballots = [["Red", "Green"],
["Blue"],
["Green", "Red", "Blue"],
["Blue", "Green", "Red"],
["Green"]]
编写一个 winner
函数,实现如下效果:
ghci> winner ballots
"Green"
ghci> :type winner
winner :: Ord a => [[a]] -> a
获胜者的确定规则:
-
如果某个投票者的投票结果为空,则将其从全部投票结果中删除
-
在所有投票者的第一选择中,确定得票数最少的候选项,然后将该候选项从全部投票结果中删除
-
重复执行上述步骤 1 和 2,直到仅存在一个候选项;该候选项即为最终获胜者
下面,我们以 ballots
为例,展示整个计算过程:
ballots :: [[String]]
ballots = [["Red", "Green"],
["Blue"],
["Green", "Red", "Blue"],
["Blue", "Green", "Red"],
["Green"]]
-
执行步骤 1:
- 因为不存在为空的投票结果,所以
ballots
没有发生变化
- 因为不存在为空的投票结果,所以
-
执行步骤 2:
- 在所有第 1 选择中,得票最少的是
"Red"
;所以,将所有"Red"
从ballots
中删除
ballots :: [[String]] ballots = [["Green"], ["Blue"], ["Green", "Blue"], ["Blue", "Green"], ["Green"]]
- 在所有第 1 选择中,得票最少的是
-
执行步骤 1:
- 因为不存在为空的投票结果,所以
ballots
没有发生变化
- 因为不存在为空的投票结果,所以
-
执行步骤 2:
- 在所有第 1 选择中,得票最少的是
"Blue"
;所以,将所有"Blue"
从ballots
中删除
ballots :: [[String]] ballots = [["Green"], [], ["Green"], ["Green"], ["Green"]]
- 在所有第 1 选择中,得票最少的是
-
执行步骤 1:
- 删除所有为空的投票
ballots :: [[String]] ballots = [["Green"], ["Green"], ["Green"], ["Green"]]
-
只剩下一个候选项
"Green"
;产生获胜者
定义:
winner :: Ord a => [[a]] -> a
winner bs = case rank $ filter (/= []) bs of
[c] -> c
(c:cs) -> winner $ map (filter (/= c)) bs
rank :: Ord a => [[a]] -> [a]
rank = map snd . result . map head
本章作业
作业 01
对
[f x | x <- xs, p x]
使用函数map
和filter
进行表达
作业 02
使用
foldr
对map f
和filter p
进行定义
作业 03
对 binary string transmitter 示例进行改写,实现 “检测传输错误” 的功能。
具体而言,采用 “奇偶校验位” 对传输错误进行检测:
- 在编码时,每 8 个二进制位添加 1 个奇偶校验位
- 当这 8 个二进制位 包含奇数个
1
时,将校验位设为1
;否则,设置为0
- 在解码时,对每 9 个二进制位进行校验
- 若奇偶校验位正确,则将校验位抛弃;否则,输出错误,并终止程序
提示:
- 库函数
error :: String -> a
具有 “输出错误信息并终止程序” 的效果- 该函数的返回值类型是一个类型参数,所以它可以在任何函数中使用,而不会产生类型错误