Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第 01 章:课程简介

课程名称:计算概论A (实验班) - 函数式程序设计

授课教师:胡振江,张伟

开课单位:信息科学技术学院 / 计算机学院

开课时间:2025 年 09 ~ 12 月



本文档的内容为课程课件;未经允许,请勿用做商业用途。

第 02 章:初见函数式思维

01 两句很有哲理的话

  • 工欲善其事,必先利其器。

  • To a man with a hammer, everything looks like a nail.

  • 思维方式是一种工具;不能被思维方式束缚

02 “函数式思维” 是一种什么样的思维方式

  • 使用 “数学中的函数” 作为 求解信息处理问题的基本成分。

  • “使用方式” 包括:

    • 从零开始,定义一些基本函数

    • 把已有的函数组装起来,形成新的函数

03 简要回顾:数学中的函数

定义: 函数 / Function

对任何两个集合XY,称两者之间的关系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

函数相关的表示符号:

对任何两个集合XY

  • X ✗ Y

    • 一个集合,其定义为:{ (x, y) | x ∈ X, y ∈ Y }

    • 也称为集合 XY笛卡尔积

  • X -> Y

    • 一个集合,包含且仅包含所有从XY的函数
  • f : X -> Y

    • 声明f是一个从XY的函数。也称:f是一个类型为X -> Y的函数

    • 称:Xf定义域 (Domain);Yf值域 (Codomain)

  • f(x)

    • 函数f的定义域中元素x映射到的值域中的那个元素,

    • 显然可知:

      • f(x) : Y,也即:f(x)的类型为Y

      • (x, f(x)) ∈ f

常用的集合及其表示符号:

在 Haskell 中,“类型” 和 “集合” 是同义词

  • :自然数集合/类型

  • :整数集合/类型

  • :有理数集合/类型

  • :实数集合/类型

  • 𝔹 = { true, flse }:布尔集合/类型。其中,

    • true 表示 “真”;flse 表示 “假”

    • 稍后给出 𝔹 的一种更为形式化的定义

定义: 函数的组合 / Function Composition

对任何两个函数 f : X -> Yg : 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

    • nf(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 函数,而且存在两个。

    我们将这两个函数分别命名为 foldlfoldr

    • 其中的后缀 lr 分别表示 leftright
  • 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

      • xsf(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函数版本
len :: [a] -> Natural
len [] = 0
len (n:ns) = 1 + len ns
len :: [a] -> Natural
len = foldr h 0 where
    h x n = n + 1
concat :: [a] -> [a] -> [a]
concat [] ns = ns
concat (m:ms) ns = m : concat ms ns
concat :: [a] -> [a] -> [a]
concat xs ys = foldr (:) ys xs
filter :: (a -> Bool) -> [a] -> [a]
filter p [] = []
filter p (n:ns)
    | p n   =  n : filter p ns
    | otherwise =  filter p ns
filter :: (a -> Bool) -> [a] -> [a]
filter p = foldr (k p) [] where
 -- k :: (a -> Bool) -> a -> [a] -> [a]
    k p n ns | p n   = n : ns
             | otherwise = ns
rev :: [a] -> [a]
rev = revm [] where
 -- revm :: [a] -> [a] -> [a]
    revm xs [] = xs
    revm xs (y:ys) = revm (y:xs) ys
rev :: [a] -> [a]
rev = foldl (flip (:)) []

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 中的名称,分为两大类:

  1. 标识符 (Identifier)

  2. 运算符 (Operator Symbol)

01 标识符的命名规则

  1. 由一或多个字符顺序构成

  2. 首字符只能是一个字母 (letter),具体包括:

    • ASCII 编码表中的所有字母 (即:所有英文大小写字母)
    • Unicode字符集中的所有字母
  3. 其他字符只能是 字母 / 数字 / 英文下划线 / 英文单引号

  4. 不能与 Haskell 的保留词重名

    • case class data default deriving do else foreign if import in infix infixl infixr instance let module newtype of then type where _
  5. 根据程序元素的不同,Haskell 还对标识符的首字符进行了进一步的限制

    • 一些程序元素,其 标识符的首字符 只能是 大写字母

    • 其他程序元素,其 标识符的首字符 只能是 小写字母

      目前已经涉及到的程序元素包括:

      • 函数 / 变量 / 类型变量:名称首字符必须是小写字母

      • 类型:名称首字符必须是大写字母

02 运算符的命名规则

  1. 由一或多个符号 (symbol) 顺序构成,具体包括:

    • ASCII 编码表中的所有符号:! # $ % & * + . / < = > ? @ \ ^ | - ~ :
    • Unicode 字符集中的大部分符号
  2. 不能与Haskell的保留运算符重名

    • .. : :: = \ | <- -> @ ~ =>
  3. 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 语言规范中给出了如下信息:

  1. 一个 Haskell 程序由一或多个模块构成,且每一个模块定义在一个单独的文件中

  2. 一个 Haskell 程序必须包含一个名为Main的模块

    • main元素的类型必须是 IO t;其中,需要把 t 替换为一个具体的类型

    • IOPrelude模块对外输出的一个元素,用于封装 IO 运算

      • Prelude模块对外输出的所有元素都会被默认加载到任何一个模块中
    • 一个 Haskell 程序的运行就是对Main模块中的main元素进行求值的过程。
      而且,最终获得的值会被抛弃。

  3. 模块的名称必须满足如下两个条件之一

    • 一个以大写字母开头的标识符。

      • 例如:MyModule
    • 两或多个以大写字母开头的标识符,通过字符.连接在一起。

      • 例如:This.Is.Mymodule
  4. 如果:一个模块在设计时就已经确定不会被其他模块所引用
    那么:该模块可以放在任意一个具有合法名称的文件中

    • 通常,Main模块不会被其他模块所引用
      因此,可以把Main模块放在任意一个文件中

    • 但是,将Main模块所在文件名设定为Main,不失为一个好选择

  5. 如果:一个模块可能会被其他模块所引用,
    那么:该模块所在的文件必须满足如下条件:

    • 如果:模块名是一个标识符,那么:模块所在文件的名称必须与模块名相同

    • 如果:模块名是多个标识符通过.字符连接在一起,那么:

      • 模块所在文件的名称必须与模块名中最后的标识符相同

        • 例如,模块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!"

    • putStrLnPrelude模块输出的一个程序元素,定义如下:

      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

    getLinePrelude模块输出的一个元素,其定义如下:

    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

    • readPrelude输出的一个函数,其类型大约是String -> a
    • 这个表达式的效果:把一个字符串转换为一个整数值
    • 在调用read时,若无法从上下文中推断出a对应的具体类型,
      则需在其后面放置 :: X,显式说明a的具体类型为X
  • show $ int1 + int2

    • showPrelude输出的一个函数,其类型大约是a -> String
    • show的功能:把一个值转换为一个字符串

唐僧:

  • 掌握了Haskell 语言IO相关的操作,再加上前面介绍的知识,你应该可以做很多事情了
  • 非常遗憾的是,这些程序目前还不能运行
  • 不用太担心,想让程序运行,分分钟的事

小和尚:

  • 分分钟是多久呢?

唐僧: 😅 ... (画外音:气氛突然有些尴尬)

Haskell程序的编译、运行、管理

  • 当你用自然语言写了一本小说,可以把它发表在互联网上;
    然后,读者们就可以阅读这本小说了

  • 当你用 Haskell 语言写了一个程序,也可以把它发表在互联网的某个代码托管网站上;
    然后,程序员们就可以阅读这个程序了

  • 与小说不同的是,程序还有另外一类读者:计算机

    • 计算机需要理解程序,并在各类硬件和软件资源的支持下,执行程序所表达的计算过程
  • 对于程序设计语言的发明者们而言,定义语言的语法形式,仅仅是万里长征的第一步

  • 为了让程序能够在硬件上运行,还需提供一系列软件支撑工具

  • 这些工具又被称为程序设计语言的 工具链 (toolchain)

在本节中,我们主要介绍 Haskell 语言工具链中的三个基本工具:

  1. GHC (Glasgow Haskell Compiler):

    • 一种得到广泛使用的Haskell语言编译器,
      能够把合法的Haskell程序变换计算机可执行的机器指令序列
  2. GHCi:

    • Haskell程序的一种交互式 (interactive) 运行环境;
      程序员可在其中输入任意合法的表达式,然后 GHCi 对表达式进行求值,并输出结果
  3. Stack:

    • 一种常用的 Haskell 软件项目构建管理工具

00 Haskell 工具链的安装

进入页面 https://www.haskell.org/ghcup/

按照说明,在自己的计算机上安装 Haskell 工具链。

  • 安装过程中总会遇到各种问题
  • 遇到问题,莫慌张,主动寻求助教或其他同学的帮助

ghcup

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 的注释分为两类:

    1. 单行注释: 以两个连续连字符 -- 开始的一行文字

    2. 多行注释:{- 开始、以 -} 结束的所有文字

Step 2: 打开终端 (Terminal) 应用,把当前目录设置为Main.hs所在的文件夹

Step 3: 编译程序 (即:在终端中输入命令ghc Main.hs,并回车)

Step 4: 运行程序 (即:在终端中输入命令./Main,并回车)

ghc

  • 对于第一次接触程序设计语言的同学,这是一个具有历史意义的时刻。

    这是人类的一大步,却只是个体的一小步。

  • 许多年之后,面对未名湖边随风摇曳的垂柳,
    你将会回想起,费尽千辛万苦终于成功运行这个无聊程序的那个遥远的夜晚。

动手练一练 01

请把前文介绍的那个更有交互性的 Haskell 程序,用 ghc 命令编译为可执行程序。
然后运行该程序,观察程序和你的交互过程。

关于 ghc 的详细使用说明,可访问其官方网站:https://www.haskell.org/ghc/

  • 没事不要打开这个链接;打开了也看不懂。
    你需要在学习过编译原理相关的知识后,再来看一看。

02 GHCi 的使用

ghci 是 Haskell 程序的一种交互式运行环境。
ghci 默认加载Prelude模块;因此,可直接使用该模块输出的元素

ghci-enter

你可以在其中输出合法的 Haskell 表达式;ghci 会输出求值结果。

ghci-expression

ghci 中的常用命令:

  • :? 列出ghci支持的所有命令

  • :quit:q 退出当前ghci环境

  • :load <模块文件名> 把一个指定的模块加载到当前环境中

  • :reload 重新加载那些已经加载的模块 (这些模块可能被修改了)

:load 命令使用示例:

  • 打开终端 (Terminal) 应用,把当前目录设置为Main.hs所在的文件夹

ghci-load

动手练一练 02

  1. 把前文介绍的快速排序函数qsort封装在一个 Haskell 模块中;
  2. 在 ghci 中加载这个模块;
  3. 在 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 的名称由若干个单词通过连字符-连结在一起
    • 每个单词由若干字母或数字组成,且至少包含一个字母

如果要在一个特定的文件夹下创建一个名称为foo的项目,可以这么做:

  1. 打开终端应用,将当前目录设定为项目所在的文件夹

  2. 运行如下命令 (确保你的计算机处于联网状态)

    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 buildfoo项目进行构建

stack exec 命令

  • 在终端的foo目录下输入命令stack exec foo-exe,运行当前项目

    • 如果你觉得foo-exe这个名字不好,可以在配置文件中修改成另外一个

stack test 命令

  • 在终端的foo目录下输入命令stack test,可以触发对当前项目的测试

    • stack 已经帮助我们建立了一个空的测试程序

    • 我们需要根据项目的实际内容向其中填写相应的测试代码

      • 例如,如果你自己编写了一个排序函数,为了确保功能的正确性,你需要在若干种具有代表性的数据上测试排序函数的输出是否符合你的预期。

      • 只要把这些测试数据按照规定的方式写在特定的文件中,stack test命令就会自动执行对应的测试活动,并给出测试结果.

stack 在foo项目中创建的文件

  • 三个文件: LICENSE / README.md / CHANGELOG.md

    1. LICENSE 声明当前项目版权相关的信息

    2. README.md 对当前项目的简要说明、

    3. 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.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 文件:

    1. app/Main.hs

      module Main (main) where
      
      import Lib
      
      main :: IO ()
      main = someFunc
      
    2. src/Lib.hs

      module Lib
          ( someFunc
          ) where
      
      someFunc :: IO ()
      someFunc = putStrLn "someFunc"
      
    3. test/Spec.hs

      main :: IO ()
      main = putStrLn "Test suite not yet implemented"
      

唐僧: 我想,你大概明白stack new foo做了什么吧?

  • 它帮我们创建了一个 Haskell 程序的骨架以及编译和运行环境。

    • 所有的这一切,stack 都进行了很好的封装,使得我们只需要使用stack提供的几个命令,就能对一个软件开发项目进行便捷的管理

动手练一练 03

请使用stack 创建一个名为qsort 的项目。然后:

  1. src/Lib.hs中添加并输出前面介绍的qsort函数;
  2. app/Main.hs中加载Lib模块,
    然后,找几个待排序的数据,用qsort函数对它们进行排序,打印出排序的结果

基于 stack 的 package 管理

  • 有人说,他站在了巨人的肩膀上,看到了很远的地方

    此言确实不虚,在软件开发中也是如此

  • 在真实的软件开发项目中,很少有开发者从零开始编写所有的软件代码

    开发者总是尽可能复用其他开发者已经开发完成的功能模块。

    • 例如,前面我们看到的Prelude模块就是 Haskell 标准库提供的一个模块。
    • Haskell 标准库还提供了很多其他模块;具体参见Haskell语言规范
    • Haskell 也提供了 import 语句来支持对其他模块的复用
  • 但是,事情并没有到此结束

  • 软件开发者群体是一个乐于分享的群体

    • 有很多程序员耗费了大量的精力,开发出很多高质量的软件模块,然后把这些模块放在互联网,供其他开发者免费使用

    • 然后,其他开发者在前人开发的模块的基础上又开发出新的模块,并共享到开发者群体中

    • 长此以往,就形成了一种欣欣向荣的生态系统

    • 在这个生态系统中,丰富多样的软件模块不断涌现,持续演化,就像自然界生态系统所展现出的物种的多样性和持续演化那样

  • 这种乐于分享的特点在 Haskell 开发者群体中也是存在的,也在此基础上形成了欣欣向荣的生态

  • 在这个生态系统中,开发者分享工作成果的基本单位是 package

    • 也即:一个开发者把一组相关的Haskell模块封装为一个package,然后将其发布到互联网上

小和尚:

  • 分享工作成果的基本单位什么不能是模块呢?

唐僧:

  • 其实,你把一个模块单独封装为一个package也是可以的

  • 在更一般的意义上,不以模块作基本发布单位的主要原因如下:

    1. 模块不存在版本的概念

      • 在软件开发生态系统中,演化是一种常态
      • 缺失了版本的概念,使得我们不能对同一个模块的不同版本进行有效管理
    2. 在很多场景下,模块过于细粒度

      • 如果要对外发布一个复杂的Haskell应用程序,以模块为基本单元显然不合适
    3. 当你对外发布一个模块时,为了使得其他开发者对该模块的质量具有足够的信心,你可能还需要将该模块的测试数据和程序一起对外发布。此时,将一个模块以及附带的测试模块打包一个 package,具合理性.

  • 使用stack new创建的项目,其中就包含了一或多个 package

    • 这些 package 的信息记录在stack.yaml文件的packages配置项中
    • 例如,在foo项目中,packages 下面只包含一个值,即:点符号 .
      • 这表明,在foo项目的根目录中存在一个 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 呢?

  • Haskell 社区维护了一个在线的 package 仓库,并将其命名为 Hackage

    https://hackage.haskell.org/

    任何一个开发者都可以向这个仓库中发布自己开发的 package,也可以从这个仓库中下载特定名称和特定版本的 package

  • 你可以在 Hackage 中搜索名称为base的 package

    在结果页面上,可以看到base的所有版本,和每一版本包含的所有模块

    在长长的模块列表中,会看到两个熟悉的名字:PreludeNumeric.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,就能得到一个随机生成的整数。

请完成如下事情:

  1. 使用 stack 创建一个名称为random-num的项目

  2. 在 package.yaml 文件的dependencies下添加一个值:random == 1.2.0

    • 含义:当前 package 依赖一个名称random、版本1.2.0的 package
    • 在Mac的M系列芯片上,可能需要把这个值修改为 random >= 1.2 && < 2
  3. 在当前项目中实现 “向终端打印出一个随机数” 的功能

请特别注意,当你使用stack new命令后,终端的输出信息

  • 需要指出的是,在主流的程序设计语言开发社区中,都存在类似的package管理方式

    • 即:一个被开发者广泛认同的 package 仓库、一个配套的构建管理工具。

      • 这是在互联网时代形成的一种群体软件开发模式,可能会陪伴你很长的时间。
      • 选择一个开发者社区,选择一个有价值的软件开发项目,努力成为项目的核心贡献者,你会收获很多很多。
  • 关于 stack,暂且讲到这里吧。有兴趣的同学可自行阅读相关材料

Haskell 程序的书写

Haskell 程序的 “书写风格”

对于学习过C / C++ / Java语言的同学而言,可能会觉得Haskell程序的书写有些奇怪

  • 在传统语言中,源程序中会出现大量的分号 ; 和花括号对 { ... }

    • 前者的作用:作为一条语句的终结符
    • 后者的作用:把几条语句封装为一个代码块 (Code Block)
  • 但是,在前面出现的 Haskell 程序中,从来没有看到过花括号和分号。

其实,你误解 Haskell 了

  • Haskell 规定,在 where / let / do / of 这四个关键词后,需要放置一个代码块

  • 在代码块的书写上,Haskell提供了两种书写风格:

    1. Layout-sensitive (排布无关)

      • 利用代码行的缩进表示语句的结束或代码块的结束
    2. 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 风格书写程序时,如何确定一行代码的缩进长度?

    唐僧: 记住三条朦胧的准则:

    1. 相同缩进 => 开始一条新语句

    2. 更多缩进 => 继续上一条语句

    3. 更少缩进 => 结束一个代码块

Haskell 程序的 “书写方式”

Haskell 提供了两种源程序的书写方式:

  1. 文件扩展名为hs的书写方式

    把 layout-insensitive/sensitive 风格的程序放置在扩展名为hs的文件中

  2. 文件扩展名为 lhs 的书写方式

    注释 与 其他代码 的地位发生了变化:

    • 书写注释时,不需要使用 前缀 -- 或起始/终止字符串 {- / -}
    • 书写其他代码时,每一行开始必须添加 符号 >
hs / lhs 对比
hs lhs
-- This is my first Haskell program
-- Stored in file: Main.hs

module Main(main) where

main :: IO ()
main = do
  putStrLn "Hello, World!"

-- This is the end.
This is my first Haskell program
Stored in file: Main.lhs

> module Main(main) where
>
> main :: I0 ()
> main = do
>     putstrun "Hello, World!"

This is the end.
  • 注意:lhs 文件的书写存在一个硬性的要求

    • 以符号 > 开始的代码行 与 注释 之间 至少存在一个空行

小和尚:

  • 为什么要发明lhs这种书写方式呢?

唐僧:

  • 这个问题,你自己慢慢体会吧;不重要。

本章作业

作业 01

关于逻辑与 (and) 函数,你还能想到其他定义方式吗?
请用 Haskell 语言写出至少其他三种定义方式。

作业 02

请用目前介绍的 Haskell 语言知识,给出函数div的一种或多种定义。

  • div :: Integer -> Integer -> Integer

说明:

  • 不用关注效率
  • 不用关注第二个参数为 0 的情况
  • 如果你认为这个问题无解或很难,请给出必要的说明
    • 为什么无解? 或者,困难在哪里?

作业 03

关于阶乘函数,你还能想到其他定义方式吗?
请分别使用 “guarded equations” 和 “conditional expression” 写出阶乘函数的定义。

作业 04

小明同学学习了这么多 Haskell 语言的知识后,感觉很累。
于是,他想用 Haskell 语言编写一个简单的命令行游戏让自己放松一下。

这个游戏描述如下:

  1. 系统随机生成一个 1~100 之间的整数,记为 x
  2. 在命令行中提示用户输入一个整数
  3. 接收用户输入的整数,记为 x’
  4. 如果 x’ < x,提示用户他/她输入的值比真实值小,跳转到 2
  5. 如果 x’ > x,提示用户他/她输入的值比真实值大,跳转到 2
  6. 如果 x’ == x,提示用户他/她成功了,游戏结束

小明同学太累了,所以想请你帮他写一个这样的程序。你觉得这个事情可行吗?

  1. 请尝试编写一个这样的程序
  2. 如果你发现这个事情有困难,请告诉我们:
    • 你的求解思路是什么 (多种思路也可以) ?
    • 在按照一个思路前进的过程中,遇到了什么困难,使得你无法继续走下去

第 04 章:类型与类簇

中英文对照:

  • 类型 => Type
  • 类簇 => Type Class

小和尚:

  • 为什么不把 “Type Class” 翻译为 “类型类” 呢?

唐僧:

  • 这是一件鸡毛蒜皮的事情;甚至后面很多地方使用的就是 “类型类”
  • 最好的应对方式:不做任何翻译,直接用原始的英文

01 类型

类型是什么

在 Haskell 中,

  • 一个类型是由一组值构成的集合

  • 例如,Bool 类型,包含两个值:True False

类型错误 (Type Error)

当在应用一个函数时

  • 如果:“传入的实际参数” 的类型 与 “函数的形式参数” 的类型 不一致,
  • 则称:发生了 类型错误

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 可以确定一个表达式的类型 (无需评估这个表达式的值)

Type-Evaluation

02 Haskell 中的基础数据类型

Bool

该类型具有两个逻辑值:True False

  • exported by Prelude (即:该类型是 Prelude 模块的输出元素之一)

前面已经介绍:Prelude 输出的所有元素都会被自动导入到任何模块中

  • 因此,在程序中可以直接使用 Prelude 输出的元素

Char

该类型的每一个值,对应 Unicode 字符编码规范中的一个字符 (code point)

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 the base package

Float / Double

单/双精度浮点数类型 (exported by Prelude)

Float-Double

03 List 类型

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

Type-List

给定一个类型 T[T]是一个类型

  • [T]的每一个值,都是一个元素类型为T的 list

重要信息:

  • [T]类型没有对 list 的长度信息 (即,list 中元素的数量) 进行任何限制

    • 因此,包含 1T 元素的 list、包含 0T 元素的 list、以及包含 3T 元素的 list,它们的类型都是 [T]
  • 任何一个类型T,都具有一个对应的 [T] 类型

    • 因此,[[T]] 也是一个合法的类型

List-List

04 Tuple 类型

给定两个类型 XY

  • (X, Y) 是一个二元组 (2-tuple) 类型

    • 该类型的任何一个值具有形式 (x, y),满足:x :: Xy :: Y

    • 在集合论中,类型 (X, Y) 就是两个集合 XY 的笛卡尔积

给定三个类型 X Y Z

  • (X, Y, Z) 是一个三元组 (3-tuple) 类型

    • 该类型的任何一个值具有形式 (x, y, z),满足:x :: Xy :: Yz :: Y

类似地,还有 4-tuple、5-tuple、...

重要信息:

  1. Haskell 中 不存在 一元组 (1-tuple) 类型

  2. Haskell 中 存在 零元组 (0-tuple) 类型

    • 零元组类型,写作 ()

    • 零元组类型具有唯一一个值,写作 ()

    小和尚:

    • 零元组类型 和 唯一的零元组值 在形式上都是();这不会产生混乱吗?

    唐僧:

    • 你的观察很仔细,非常棒 👍

    • 不用担心,在 Haskell 中,类型和值具有截然不同的上下文

      • 因此,从()的上下文能够判定 “它到底是类型还是值”
  3. 三元组和更多元组,在理论上,没有存在的必要 (在实践中,为了使用上的方便,有存在的必要)

    • 原因:基于二元组,可以构造出三元组、四元组、...

    • 例如:(1, (2, 3)) 是一个采用二元组表示的三元组

05 Function 类型

给定两个类型 XY

  • X -> Y 是一个函数类型

    • 该类型的每一个值都是一个函数,且该函数把类型 X 的值映射到类型 Y 的值

重要信息:

  • 任何类型都可以作为类型 X -> Y 中的 XY

    例如:List 类型 和 Tuple 类型都可以作为 XY

    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” 这个奇怪的名字又来源于哪里呢?

Haskell-Curry

希望很多年以后,你的 “姓” 或 “名” 也能出现在教科书中。


给定一个函数 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 2mult 2 3mult 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 的类型的含义:

    • 对于任意类型 alen 函数接收一个 [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 的一个实例

Type-Class

上面这个截图表明:

  • (+) 适用于 “整数”“浮点数”,但是不适用于 “字符”

  • 原因:类型 Char 不是类型类 Num 的实例

07 类型类 (Type Class)

Prelude 模块输出了很多类型类,其中最基础的三个是:

  • Eq Ord Num

这三个类型类出现在了很多函数中:

Type-Class-Three

上面的截图表明:

  1. 相等关系运算符 == 适用于所有实现了 Eq 的类型

  2. 小于关系运算符 < 适用于所有实现了 Ord 的类型

  3. 加法运算符 + 适用于所有实现了 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 (即,TEq 的实例),则应满足如下性质:

  1. 自反性 (Reflexivity)

    • 对于类型为 T 的任何一个表达式 x,满足:x == x === True
  2. 对称性 (Symmetry)

    • 对于类型为 T 的任何两个表达式 x y,满足:x == y === y == x
  3. 传递性 (Transitivity)

    • 对于类型为 T 的任何三个表达式 x y z,满足:
      • 如果:(x == y) && (y == z) === True
      • 那么:x == z === True
  4. 外延性 (Extensionality)

    • 对于任何函数 f :: X -> T,以及类型为 X 的两个表达式 a b,满足:
    • 如果:a == b === True
    • 那么:f a == f b === True
  5. 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 -- 这行代码含义如下:
  1. 声明了一个类型类 Ord

  2. 声明了一个类型类约束 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) 关系,因此,满足如下性质:

  1. 可比性 (Comparability)

    • x <= y || y <= x === True
  2. 传递性 (Transitivity)

    • 如果:x <= y && y <= z === True
    • 那么:x <= z === True
  3. 自反性 (Reflexivity)

    • x <= x === True
  4. 反对称性 (Antisymmetry)

    • 如果:x <= y && y <= x === True
    • 那么:x == y === True

同时,Haskell 规范 希望Ord 的任何实现,应保持下述性质成立:

  1. x >= y === y <= x
  2. x < y === x <= y && x /= y
  3. x > y === y < x
  4. x < y === compare x y == LT
  5. x > y === compare x y == GT
  6. x == y === compare x y == EQ
  7. (min x y) == (if x <= y then x else y) === True
  8. (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 (即,TNum 的实例),则应满足如下性质:

  1. + 满足结合律 (Associativity)

    • (x + y) + z === x + (y + z)
  2. + 满足交换律 (Commutativity)

    • x + y === y + x
  3. fromInteger 0+ 的单位元

    • x + fromInteger 0 === x
  4. negate+ 的逆元

    • x + negate x === fromInteger 0
  5. * 满足结合律

    • (x * y) * z === x * (y * z)
  6. fromInteger 1* 的单位元

    • x * fromInteger 1 === x
    • fromInteger 1 * x === x
  7. *+ 的分配律 (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 reversePrelude 模块输出的三个元素

作业 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上运行) 展示:

  • IntInteger的区别,以及

  • showread的用法。

作业 04

阅读教科书和 Prelude 模块文档

  • 理解 IntegralFractional 这两个 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)
  • 其中,takedrop 是两个已经存在的函数

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
  • otherwisePrelude 模块输出的一个元素,其定义为 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 函数吗?

  • 为什么这种定义方式称为模式匹配呢?解释如下:

    1. BoolPrelude 模块输出的一个类型,其定义如下:

      data Bool = True | False
      
      • data 是 Haskell 语言中定义类型的关键字

      • 这个类型定义,用 “第二章:初见函数式思维” 中的那种语言,可以表述为如下形式:

        def Bool : Type = {
            ctor True  : Self,
            ctor False : Self,
        }
        
      • 也即,类型 Bool 的值仅存在两种模式 / 构造方式 / Constructor

    2. 因此,如果在这两种模式上对 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:

  1. []:其类型为 List a
  2. (:):其类型为 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 (它们在 [] 上没有定义)

Head-on-Empty_List

注意:以下程序会产生编译错误:

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 函数,满足如下要求:

  1. 该函数与 tail 函数具有相同的类型
  2. 当作用在一个非空 list 上,该函数与 tail 行为相同
  3. 当作用在一个空 list 上,该函数返回一个空 list

说明:

  • 如果你愿意,可以使用函数 null :: [a] -> Bool 判断 list 是否为空

作业 02

Luhn 算法被用于检查银行卡号中可能存在的简单书写错误 (例如,写错了一个数字)。

该算法的工作流程如下所述:

  1. 将银行卡号中的每一个数字字符视为一个独立的整数
  2. 从右向左,偶数位的数乘 2 (奇数位的数不变)
  3. 对于每一个大于 9 的数,减去 9;然后将所有的数相加
  4. 如果相加的结果能被 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 源码的情况下,使用递归定义如下函数:

  1. 判断 [Bool] 类型的一个值中的所有元素是否都为 True
    and :: [Bool] -> Bool
    
  2. 将类型 [[a]] 的一个值中包含的所有 list 拼接为一个 list
    concat :: [[a]] -> [a]
    
  3. 获得一个 list 中编号为 n 的元素 (从 0 开始编号)
    (!!) :: [a] -> Int -> a
    
  4. 生成一个包含 n 个重复元素的 list
    replicate :: Int -> a -> [a]
    
  5. 判断一个元素是否包含在一个 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]

它的递归定义包含两条规则:

  1. 长度小于 2 的 list 已经处于排序状态
  2. 对于长度大于 1 的 list,将其从中间断开,形成两个更短的 list,然后:
    1. 对这两个更短的 list 分别进行归并排序
    2. 将排序后形成的两个 list 进行归并

第 08 章:高阶函数

“高阶函数” 的英文为: Higher-order Function

01 高阶函数

所谓 “高阶函数”,指的是:这个函数的某个参数或返回值是一个函数。

twice :: (a -> a) -> a -> a
twice f x  =  f (f x)

上面这个 twice 是一个高阶函数,原因如下:

  1. 这个函数接收的第一个参数是一个函数 (类型为 a -> a);或者

  2. 这个函数接收第一个参数后返回一个函数 (类型为 a -> a)

02 为什么需要高阶函数

  1. 一些常用的程序设计模式 (Common Programming Idiom) 可以表示为高阶函数

  2. 领域特定语言 (Domain Specific Language) 的很多成分,也可以表示为高阶函数

  3. 高阶函数具有的代数性质,可用于程序性质证明

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

其含义是:

  1. 函数 f 将一个空 list [] 映射到值 v

  2. 函数 f 将一个非空 list (x:xs) 映射为一个函数 (⊕) 作用到 xf 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"]

编写两个函数 resultwinner,实现如下效果:

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. 在所有投票者的第一选择中,确定得票数最少的候选项,然后将该候选项从全部投票结果中删除

  3. 重复执行上述步骤 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:

    • 因为不存在为空的投票结果,所以 ballots 没有发生变化
  • 执行步骤 2:

    • 在所有第 1 选择中,得票最少的是 "Blue";所以,将所有 "Blue"ballots 中删除
    ballots :: [[String]]
    ballots  =  [["Green"],
                 [],
                 ["Green"],
                 ["Green"],
                 ["Green"]]
    
  • 执行步骤 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] 使用函数 mapfilter 进行表达

作业 02

使用 foldrmap ffilter p 进行定义

作业 03

对 binary string transmitter 示例进行改写,实现 “检测传输错误” 的功能。

具体而言,采用 “奇偶校验位” 对传输错误进行检测:

  • 在编码时,每 8 个二进制位添加 1 个奇偶校验位
    • 当这 8 个二进制位 包含奇数个 1 时,将校验位设为 1;否则,设置为 0
  • 在解码时,对每 9 个二进制位进行校验
    • 若奇偶校验位正确,则将校验位抛弃;否则,输出错误,并终止程序

提示:

  • 库函数 error :: String -> a 具有 “输出错误信息并终止程序” 的效果
  • 该函数的返回值类型是一个类型参数,所以它可以在任何函数中使用,而不会产生类型错误