第 3 章:定义类型并简化函数

定义新的数据类型

尽管列表和元组都非常有用,但是,定义新的数据类型也是一种常见的需求,这种能力使得我们可以为程序中的值添加结构。

而且比起使用元组,对一簇相关的值赋予一个名字和一个独一无二的类型显得更有用一些。

定义新的数据类型也提升了代码的安全性:Haskell 不会允许我们混用两个结构相同但类型不同的值。

本章将以一个在线书店为例子,展示如何去进行类型定义。

使用 data 关键字可以定义新的数据类型:

  1. -- file: ch03/BookStore.hs
  2. data BookInfo = Book Int String [String]
  3. deriving (Show)

跟在 data 关键字之后的 BookInfo 就是新类型的名字,我们称 BookInfo类型构造器。类型构造器用于指代(refer)类型。正如前面提到过的,类型名字的首字母必须大写,因此,类型构造器的首字母也必须大写。

接下来的 Book值构造器(有时候也称为数据构造器)的名字。类型的值就是由值构造器创建的。值构造器名字的首字母也必须大写。

Book 之后的 IntString[String] 是类型的组成部分。组成部分的作用,和面向对象语言的类中的域作用一致:它是一个储存值的槽。(为了方便起见,我们通常也将组成部分称为域。)

在这个例子中, Int 表示一本书的 ID ,而 String 表示书名,而 [String] 则代表作者。

BookInfo 类型包含的成分和一个 (Int, String, [String]) 类型的三元组一样,它们唯一不相同的是类型。[译注:这里指的是整个值的类型,不是成分的类型。]我们不能混用结构相同但类型不同的值。

举个例子,以下的 MagzineInfo 类型的成分和 BookInfo 一模一样,但 Haskell 会将它们作为不同的类型来区别对待,因为它们的类型构构造器和值构造器并不相同:

  1. -- file: ch03/BookStore.hs
  2. data MagzineInfo = Magzine Int String [String]
  3. deriving (Show)

可以将值构造器看作是一个函数 —— 它创建并返回某个类型值。在这个书店的例子里,我们将 IntString[String] 三个类型的值应用到 Book ,从而创建一个 BookInfo 类型的值:

  1. -- file: ch03/BookStore.hs
  2. myInfo = Book 9780135072455 "Algebra of Programming"
  3. ["Richard Bird", "Oege de Moor"]

定义类型的工作完成之后,可以到 ghci 里载入并测试这些新类型:

  1. Prelude> :load BookStore.hs
  2. [1 of 1] Compiling Main ( BookStore.hs, interpreted )
  3. Ok, modules loaded: Main.

再看看前面在文件里定义的 myInfo 变量:

  1. *Main> myInfo
  2. Book 9780135072455 "Algebra of Programming" ["Richard Bird","Oege de Moor"]

在 ghci 里面当然也可以创建新的 BookInfo 值:

  1. *Main> Book 0 "The Book of Imaginary Beings" ["Jorge Luis Borges"]
  2. Book 0 "The Book of Imaginary Beings" ["Jorge Luis Borges"]

可以用 :type 命令来查看表达式的值:

  1. *Main> :type Book 1 "Cosmicomics" ["Italo Calvino"]
  2. Book 1 "Cosmicomics" ["Italo Calvino"] :: BookInfo

请记住,在 ghci 里定义变量的语法和在源码文件里定义变量的语法并不相同。在 ghci 里,变量通过 let 定义:

  1. *Main> let cities = Book 173 "Use of Weapons" ["Iain M. Banks"]

使用 :info 命令可以查看更多关于给定表达式的信息:

  1. *Main> :info BookInfo
  2. data BookInfo = Book Int String [String]
  3. -- Defined at BookStore.hs:2:6
  4. instance Show BookInfo -- Defined at BookStore.hs:3:27

使用 :type 命令,可以查看值构造器 Book 的类型签名,了解它是如何创建出 BookInfo 类型的值的:

  1. *Main> :type Book
  2. Book :: Int -> String -> [String] -> BookInfo

类型构造器和值构造器的命名

在前面介绍 BookInfo 类型的时候,我们专门为类型构造器和值构造器设置了不同的名字( BookInfoBook ),这样区分起来比较容易。

在 Haskell 里,类型的名字(类型构造器)和值构造器的名字是相互独立的。类型构造器只能出现在类型的定义,或者类型签名当中。而值构造器只能出现在实际的代码中。因为存在这种差别,给类型构造器和值构造器赋予一个相同的名字实际上并不会产生任何问题。

以下是这种用法的一个例子:

  1. -- file: ch03/BookStore.hs
  2. -- 稍后就会介绍 CustomerID 的定义
  3.  
  4. data BookReview = BookReview BookInfo CustomerID String

以上代码定义了一个 BookReview 类型,并且它的值构造器的名字也同样是 BookReview

类型别名

可以使用类型别名,来为一个已存在的类型设置一个更具描述性的名字。

比如说,在前面 BookReview 类型的定义里,并没有说明 String 成分是干什么用的,通过类型别名,可以解决这个问题:

  1. -- file: ch03/BookStore.hs
  2. type CustomerID = Int
  3. type ReviewBody = String
  4.  
  5. data BetterReview = BetterReview BookInfo CustomerID ReviewBody

type 关键字用于设置类型别名,其中新的类型名字放在 = 号的左边,而已有的类型名字放在 = 号的右边。这两个名字都标识同一个类型,因此,类型别名完全是为了提高可读性而存在的。

类型别名也可以用来为啰嗦的类型设置一个更短的名字:

  1. -- file: ch03/BookStore.hs
  2. type BookRecord = (BookInfo, BookReview)

需要注意的是,类型别名只是为已有类型提供了一个新名字,创建值的工作还是由原来类型的值构造器进行。[注:如果你熟悉 C 或者 C++ ,可以将 Haskell 的类型别名看作是 typedef 。]

代数数据类型

Bool 类型是代数数据类型(algebraic data type)的最简单也是最常见的例子。一个代数类型可以有多于一个值构造器:

  1. -- file: ch03/Bool.hs
  2. data Bool = False | True

上面代码定义的 Bool 类型拥有两个值构造器,一个是 True ,另一个是 False 。每个值构造器使用 | 符号分割,读作“或者” —— 以 Bool 类型为例子,我们可以说, Bool 类型由 True 值或者 False 值构成。

当一个类型拥有一个以上的值构造器时,这些值构造器通常被称为“备选”(alternatives)或“分支”(case)。同一类型的所有备选,创建出的的值的类型都是相同的。

代数数据类型的各个值构造器都可以接受任意个数的参数。[译注:不同备选之间接受的参数个数不必相同,参数的类型也可以不一样。]以下是一个账单数据的例子:

  1. -- file: ch03/BookStore.hs
  2. type CardHolder = String
  3. type CardNumber = String
  4. type Address = [String]
  5. data BillingInfo = CreditCard CardNumber CardHolder Address
  6. | CashOnDelivery
  7. | Invoice CustomerID
  8. deriving (Show)

这个程序提供了三种付款的方式。如果使用信用卡付款,就要使用 CreditCard 作为值构造器,并输入信用卡卡号、信用卡持有人和地址作为参数。如果即时支付现金,就不用接受任何参数。最后,可以通过货到付款的方式来收款,在这种情况下,只需要填写客户的 ID 就可以了。

当使用值构造器来创建 BillingInfo 类型的值时,必须提供这个值构造器所需的参数:

  1. Prelude> :load BookStore.hs
  2. [1 of 1] Compiling Main ( BookStore.hs, interpreted )
  3. Ok, modules loaded: Main.
  4.  
  5. *Main> :type CreditCard
  6. CreditCard :: CardNumber -> CardHolder -> Address -> BillingInfo
  7.  
  8. *Main> CreditCard "2901650221064486" "Thomas Gradgrind" ["Dickens", "England"]
  9. CreditCard "2901650221064486" "Thomas Gradgrind" ["Dickens","England"]
  10.  
  11. *Main> :type it
  12. it :: BillingInfo

如果输入参数的类型不对或者数量不对,那么引发一个错误:

  1. *Main> Invoice
  2.  
  3. <interactive>:7:1:
  4. No instance for (Show (CustomerID -> BillingInfo))
  5. arising from a use of `print'
  6. Possible fix:
  7. add an instance declaration for (Show (CustomerID -> BillingInfo))
  8. In a stmt of an interactive GHCi command: print it

ghci 抱怨我们没有给 Invoice 值构造器足够的参数。

[译注:原文这里的代码示例有错,译文已改正。]

什么情况下该用元组,而什么情况下又该用代数数据类型?

元组和自定义代数数据类型有一些相似的地方。比如说,可以使用一个 (Int, String, [String]) 类型的元组来代替 BookInfo 类型:

  1. *Main> Book 2 "The Wealth of Networks" ["Yochai Benkler"]
  2. Book 2 "The Wealth of Networks" ["Yochai Benkler"]
  3.  
  4. *Main> (2, "The Wealth of Networks", ["Yochai Benkler"])
  5. (2,"The Wealth of Networks",["Yochai Benkler"])

代数数据类型使得我们可以在结构相同但类型不同的数据之间进行区分。然而,对于元组来说,只要元素的结构和类型都一致,那么元组的类型就是相同的:

  1. -- file: ch03/Distinction.hs
  2. a = ("Porpoise", "Grey")
  3. b = ("Table", "Oak")

其中 ab 的类型相同:

  1. Prelude> :load Distinction.hs
  2. [1 of 1] Compiling Main ( Distinction.hs, interpreted )
  3. Ok, modules loaded: Main.
  4.  
  5. *Main> :type a
  6. a :: ([Char], [Char])
  7.  
  8. *Main> :type b
  9. b :: ([Char], [Char])

对于两个不同的代数数据类型来说,即使值构造器成分的结构和类型都相同,它们也是不同的类型:

  1. -- file: ch03/Distinction.hs
  2. data Cetacean = Cetacean String String
  3. data Furniture = Furniture String String
  4.  
  5. c = Cetacean "Porpoise" "Grey"
  6. d = Furniture "Table" "Oak"

其中 cd 的类型并不相同:

  1. Prelude> :load Distinction.hs
  2. [1 of 1] Compiling Main ( Distinction.hs, interpreted )
  3. Ok, modules loaded: Main.
  4.  
  5. *Main> :type c
  6. c :: Cetacean
  7.  
  8. *Main> :type d
  9. d :: Furniture

以下是一个更细致的例子,它用两种不同的方式表示二维向量:

  1. -- file: ch03/AlgebraicVector.hs
  2. -- x and y coordinates or lengths.
  3. data Cartesian2D = Cartesian2D Double Double
  4. deriving (Eq, Show)
  5.  
  6. -- Angle and distance (magnitude).
  7. data Polar2D = Polar2D Double Double
  8. deriving (Eq, Show)

Cartesian2DPolar2D 两种类型的成分都是 Double 类型,但是,这些成分表达的是不同的意思。因为 Cartesian2DPolar2D 是不同的类型,因此 Haskell 不会允许混淆使用这两种类型:

  1. Prelude> :load AlgebraicVector.hs
  2. [1 of 1] Compiling Main ( AlgebraicVector.hs, interpreted )
  3. Ok, modules loaded: Main.
  4. *Main> Cartesian2D (sqrt 2) (sqrt 2) == Polar2D (pi / 4) 2
  5.  
  6. <interactive>:3:34:
  7. Couldn't match expected type `Cartesian2D'
  8. with actual type `Polar2D'
  9. In the return type of a call of `Polar2D'
  10. In the second argument of `(==)', namely `Polar2D (pi / 4) 2'
  11. In the expression:
  12. Cartesian2D (sqrt 2) (sqrt 2) == Polar2D (pi / 4) 2

错误信息显示, (==) 操作符只接受类型相同的值作为它的参数,在类型签名里也可以看出这一点:

  1. *Main> :type (==)
  2. (==) :: Eq a => a -> a -> Bool

另一方面,如果使用类型为 (Double, Double) 的元组来表示二维向量的两种表示方式,那么我们就有麻烦了:

  1. Prelude> -- 第一个元组使用 Cartesian 表示,第二个元组使用 Polar 表示
  2. Prelude> (1, 2) == (1, 2)
  3. True

类型系统不会察觉到,我们正错误地对比两种不同表示方式的值,因为对两个类型相同的元组进行对比是完全合法的!

关于该使用元组还是该使用代数数据类型,没有一劳永逸的办法。但是,有一个经验法则可以参考:如果程序大量使用复合数据,那么使用 data 进行类型自定义对于类型安全和可读性都有好处。而对于小规模的内部应用,那么通常使用元组就足够了。

其他语言里类似代数数据类型的东西

代数数据类型为描述数据类型提供了一种单一且强大的方式。很多其他语言,要达到相当于代数数据类型的表达能力,需要同时使用多种特性。

以下是一些 C 和 C++ 方面的例子,说明怎样在这些语言里,怎么样实现类似于代数数据类型的功能。

结构

当只有一个值构造器时,代数数据类型和元组很相似:它将一系列相关的值打包成一个复合值。这种做法相当于 C 和 C++ 里的 struct ,而代数数据类型的成分则相当于 struct 里的域。

以下是一个 C 结构,它等同于我们前面定义的 BookInfo 类型:

  1. struct book_info {
  2. int id;
  3. char *name;
  4. char **authors;
  5. };

目前来说, C 结构和 Haskell 的代数数据类型最大的差别是,代数数据类型的成分是匿名且按位置排序的:

  1. --file: ch03/BookStore.hs
  2. data BookInfo = Book Int String [String]
  3. deriving (Show)

按位置排序指的是,对成分的访问是通过位置来实行的,而不是像 C 那样,通过名字:比如 book_info->id

稍后的“模式匹配”小节会介绍如何访代数数据类型里的成分。在“记录”一节会介绍定义数据的新语法,通过这种语法,可以像 C 结构那样,使用名字来访问相应的成分。

枚举

C 和 C++ 里的 enum 通常用于表示一系列符号值排列。代数数据类型里面也有相似的东西,一般称之为枚举类型

以下是一个 enum 例子:

  1. enum roygbiv {
  2. red,
  3. orange,
  4. yellow,
  5. green,
  6. blue,
  7. indigo,
  8. violet,
  9. };

以下是等价的 Haskell 代码:

  1. -- file: ch03/Roygbiv.hs
  2. data Roygbiv = Red
  3. | Orange
  4. | Yellow
  5. | Green
  6. | Blue
  7. | Indigo
  8. | Violet
  9. deriving (Eq, Show)

在 ghci 里面测试:

  1. Prelude> :load Roygbiv.hs
  2. [1 of 1] Compiling Main ( Roygbiv.hs, interpreted )
  3. Ok, modules loaded: Main.
  4.  
  5. *Main> :type Yellow
  6. Yellow :: Roygbiv
  7.  
  8. *Main> :type Red
  9. Red :: Roygbiv
  10.  
  11. *Main> Red == Yellow
  12. False
  13.  
  14. *Main> Green == Green
  15. True

enum 的问题是,它使用整数值去代表元素:在一些接受 enum 的场景里,可以将整数传进去,C 编译器会自动进行类型转换。同样,在使用整数的场景里,也可以将一个 enum 元素传进去。这种用法可能会造成一些令人不爽的 bug 。

另一方面,在 Haskell 里就没有这样的问题。比如说,不可能使用 Roygbiv 里的某个值来代替 Int 值[译注:因为枚举类型的每个元素都由一个唯一的值构造器生成,而不是使用整数表示。]:

  1. *Main> take 3 "foobar"
  2. "foo"
  3.  
  4. *Main> take Red "foobar"
  5.  
  6. <interactive>:9:6:
  7. Couldn't match expected type `Int' with actual type `Roygbiv'
  8. In the first argument of `take', namely `Red'
  9. In the expression: take Red "foobar"
  10. In an equation for `it': it = take Red "foobar"

联合

如果一个代数数据类型有多个备选,那么可以将它看作是 C 或 C++ 里的 union

以上两者的一个主要区别是, union 并不告诉用户,当前使用的是哪一个备选, union 的使用者必须自己记录这方面的信息(通常使用一个额外的域来保存),这意味着,如果搞错了备选的信息,那么对 union 的使用就会出错。

以下是一个 union 例子:

  1. enum shape_type {
  2. shape_circle,
  3. shape_poly,
  4. };
  5.  
  6. struct circle {
  7. struct vector centre;
  8. float radius;
  9. };
  10.  
  11. struct poly {
  12. size_t num_vertices;
  13. struct vector *vertices;
  14. };
  15.  
  16. struct shape
  17. {
  18. enum shape_type type;
  19. union {
  20. struct circle circle;
  21. struct poly poly;
  22. } shape;
  23. };

在上面的代码里, shape 域的值可以是一个 circle 结构,也可以是一个 poly 结构。 shape_type 用于记录目前 shape 正在使用的结构类型。

另一方面,Haskell 版本不仅简单,而且更为安全:

  1. -- file: ch03/ShapeUnion.hs
  2. type Vector = (Double, Double)
  3.  
  4. data Shape = Circle Vector Double
  5. | Poly [Vector]
  6. deriving (Show)

[译注:原文的代码少了 deriving (Show) 一行,在 ghci 测试时会出错。]

注意,我们不必像 C 语言那样,使用 shape_type 域来手动记录 Shape 类型的值是由 Circle 构造器生成的,还是由 Poly 构造器生成, Haskell 自己有办法弄清楚一点,它不会弄混两种不同的值。其中的原因,下一节《模式匹配》就会讲到。

[译注:原文这里将 Poly 写成了 Square 。]

模式匹配

前面的章节介绍了代数数据类型的定义方法,本节将说明怎样去处理这些类型的值。

对于某个类型的值来说,应该可以做到以下两点:

  • 如果这个类型有一个以上的值构造器,那么应该可以知道,这个值是由哪个构造器创建的。
  • 如果一个值构造器包含不同的成分,那么应该有办法提取这些成分。

对于以上两个问题, Haskell 有一个简单且有效的解决方式,那就是类型匹配

模式匹配允许我们查看值的内部,并将值所包含的数据绑定到变量上。以下是一个对 Bool 类型值进行模式匹配的例子,它的作用和 not 函数一样:

  1. -- file: ch03/myNot.hs
  2. myNot True = False
  3. myNot False = True

[译注:原文的文件名为 add.hs ,这里修改成 myNot.hs ,和函数名保持一致。]

初看上去,代码似乎同时定义了两个 myNot 函数,但实际情况并不是这样 —— Haskell 允许将函数定义为一系列等式myNot 的两个等式分别定义了函数对于输入参数在不同模式之下的行为。对于每行等式,模式定义放在函数名之后, = 符号之前。

为了理解模式匹配是如何工作的,来研究一下 myNot False 是如何执行的:首先调用 myNot , Haskell 运行时检查输入参数 False 是否和第一个模式的值构造器匹配 —— 答案是不匹配,于是它继续尝试匹配第二个模式 —— 这次匹配成功了,于是第二个等式右边的值被作为结果返回。

以下是一个复杂一点的例子,这个函数计算出列表所有元素之和:

  1. -- file:: ch03/sumList.hs
  2. sumList (x:xs) = x + sumList xs
  3. sumList [] = 0

[译注:原文代码的文件名为 add.hs 这里改为 sumList.hs ,和函数名保持一致。]

需要说明的一点是,在 Haskell 里,列表 [1, 2] 实际上只是 (1:(2:[])) 的一种简单的表示方式,其中 (:) 用于构造列表:

  1. Prelude> []
  2. []
  3.  
  4. Prelude> 1:[]
  5. [1]
  6.  
  7. Prelude> 1:2:[]
  8. [1,2]

因此,当需要对一个列表进行匹配时,也可以使用 (:) 操作符,只不过这次不是用来构造列表,而是用来分解列表。

作为例子,考虑求值 sumList [1, 2] 时会发生什么:首先, [1, 2] 尝试对第一个等式的模式 (x:xs) 进行匹配,结果是模式匹配成功,并将 x 绑定为 1xs 绑定为 [2]

计算进行到这一步,表达式就变成了 1 + (sumList [2]) ,于是递归调用 sumList ,对 [2] 进行模式匹配。

这一次也是在第一个等式匹配成功,变量 x 被绑定为 2 ,而 xs 被绑定为 [] 。表达式变为 1 + (2 + sumList [])

再次递归调用 sumList ,输入为 [] ,这一次,第二个等式的 [] 模式匹配成功,返回 0 ,整个表达式为 1 + (2 + (0)) ,计算结果为 3

最后要说的一点是,标准函数库里已经有 sum 函数,它和我们定以的 sumList 一样,都可以用于计算表元素的和:

  1. Prelude> :load sumList.hs
  2. [1 of 1] Compiling Main ( sumList.hs, interpreted )
  3. Ok, modules loaded: Main.
  4.  
  5. *Main> sumList [1, 2]
  6. 3
  7.  
  8. *Main> sum [1, 2]
  9. 3

组成和解构

让我们稍微慢下探索新特性的脚步,花些时间,了解构造一个值、和对这个值进行模式匹配之间的关系。

我们通过应用值构造器来构建值:表达式 Book 9 "Close Calls" ["John Long"] 应用 Book 构造器到值 9"Close Calls"["John Long"] 上面,从而产生一个新的 BookInfo 类型的值。

另一方面,当对 Book 构造器进行模式匹配时,我们逆转(reverse)它的构造过程:首先,检查这个值是否由 Book 构造器生成 —— 如果是的话,那么就对这个值进行探查(inspect),并取出创建这个值时,提供给构造器的各个值。

考虑一下表达式 Book 9 "Close Calls" ["John Long"] 对模式 (Book id name authors) 的匹配是如何进行的:

  • 因为值的构造器和模式里的构造器相同,因此匹配成功。
  • 变量 id 被绑定为 9
  • 变量 name 被绑定为 Close Calls
  • 变量 authors 被绑定为 ["John Long"]

因为模式匹配的过程就像是逆转一个值的构造(construction)过程,因此它有时候也被称为解构(deconstruction)。

[译注:上一节的《联合》小节里提到, Haskell 有办法分辨同一类型由不同值构造器创建的值,说的就是模式匹配。

比如 Circle …Poly … 两个表达式创建的都是 Shape 类型的值,但第一个表达式只有在匹配 (Circle vector double) 模式时才会成功,而第二个表达式只有在 (Poly vectors) 时才会成功。这就是它们不会被混淆的原因。]

更进一步

对元组进行模式匹配的语法,和构造元组的语法很相似。

以下是一个可以返回三元组中最后一个元素的函数:

  1. -- file: ch03/third.hs
  2. third (a, b, c) = c

[译注:原文的源码文件名为 Tuple.hs ,这里改为 third.hs ,和函数的名字保持一致。]

在 ghci 里测试这个函数:

  1. Prelude> :load third.hs
  2. [1 of 1] Compiling Main ( third.hs, interpreted )
  3. Ok, modules loaded: Main.
  4.  
  5. *Main> third (1, 2, 3)
  6. 3

模式匹配的“深度”并没有限制。以下模式会同时对元组和元组里的列表进行匹配:

  1. -- file: ch03/complicated.hs
  2. complicated (True, a, x:xs, 5) = (a, xs)

[译注:原文的源码文件名为 Tuple.hs ,这里改为 complicated.hs ,和函数的名字保持一致。]

在 ghci 里测试这个函数:

  1. Prelude> :load complicated.hs
  2. [1 of 1] Compiling Main ( complicated.hs, interpreted )
  3. Ok, modules loaded: Main.
  4.  
  5. *Main> complicated (True, 1, [1, 2, 3], 5)
  6. (1,[2,3])

对于出现在模式里的字面(literal)值(比如前面元组例子里的 True5 ),输入里的各个值必须和这些字面值相等,匹配才有可能成功。以下代码显示,因为输入元组和模式的第一个字面值 True 不匹配,所以匹配失败了:

  1. *Main> complicated (False, 1, [1, 2, 3], 5)
  2. *** Exception: complicated.hs:2:1-40: Non-exhaustive patterns in function complicated

这个例子也显示了,如果所有给定等式的模式都匹配失败,那么返回一个运行时错误。

对代数数据类型的匹配,可以通过这个类型的值构造器来进行。拿之前我们定义的 BookInfo 类型为例子,对它的模式匹配可以使用它的 Book 构造器来进行:

  1. -- file: ch03/BookStore.hs
  2. bookID (Book id title authors) = id
  3. bookTitle (Book id title authors) = title
  4. bookAuthors (Book id title authors) = authors

在 ghci 里试试:

  1. Prelude> :load BookStore.hs
  2. [1 of 1] Compiling Main ( BookStore.hs, interpreted )
  3. Ok, modules loaded: Main.
  4.  
  5. *Main> let book = (Book 3 "Probability Theory" ["E.T.H. Jaynes"])
  6.  
  7. *Main> bookID book
  8. 3
  9.  
  10. *Main> bookTitle book
  11. "Probability Theory"
  12.  
  13. *Main> bookAuthors book
  14. ["E.T.H. Jaynes"]

字面值的比对规则对于列表和值构造器的匹配也适用: (3:xs) 模式只匹配那些不为空,并且第一个元素为 3 的列表;而 (Book 3 title authors) 只匹配 ID 值为 3 的那本书。

模式匹配中的变量名命名

当你阅读那些进行模式匹配的函数时,经常会发现像是 (x:xs) 或是 (d:ds) 这种类型的名字。这是一个流行的命名规则,其中的 s 表示“元素的复数”。以 (x:xs) 来说,它用 x 来表示列表的第一个元素,剩余的列表元素则用 xs 表示。

通配符模式匹配

如果在匹配模式中我们不在乎某个值的类型,那么可以用下划线字符 “_” 作为符号来进行标识,它也叫做通配符。它的用法如下。

  1. -- file: ch03/BookStore.hs
  2. nicerID (Book id _ _ ) = id
  3. nicerTitle (Book _ title _ ) = title
  4. nicerAuthors (Book _ _ authors) = authors

于是,我们将之前介绍过的访问器函数改得更加简明了。现在能很清晰的看出各个函数究竟使用到了哪些元素。

在模式匹配里,通配符的作用和变量类似,但是它并不会绑定成一个新的变量。就像上面的例子展示的那样,在一个模式匹配里可以使用一个或多个通配符。

使用通配符还有另一个好处。如果我们在一个匹配模式中引入了一个变量,但没有在函数体中用到它的话,Haskell 编译器会发出一个警告。定义一个变量但忘了使用通常意味着存在潜在的 bug,因此这是个有用的功能。假如我们不准备使用一个变量,那就不要用变量,而是用通配符,这样编译器就不会报错。

穷举匹配模式和通配符

在给一个类型写一组匹配模式时,很重要的一点就是一定要涵盖构造器的所有可能情况。例如,如果我们需要探查一个列表,就应该写一个匹配非空构造器 (:) 的方程和一个匹配空构造器 [] 的方程。

假如我们没有涵盖所有情况会发生什么呢。下面,我们故意漏写对 [] 构造器的检查。

  1. -- file: ch03/BadPattern.hs
  2. badExample (x:xs) = x + badExample xs

如果我们将其作用于一个不能匹配的值,运行时就会报错:我们的软件有 bug!

  1. ghci> badExample []
  2. *** Exception: BadPattern.hs:4:0-36: Non-exhaustive patterns in function badExample

在上面的例子中,函数定义时的方程里没有一个可以匹配 [] 这个值。

如果在某些情况下,我们并不在乎某些特定的构造器,我们就可以用通配符匹配模式来定义一个默认的行为。

  1. -- file: ch03/BadPattern.hs
  2. goodExample (x:xs) = x + goodExample xs
  3. goodExample _ = 0

上面例子中的通配符可以匹配 [] 构造器,因此应用这个函数不会导致程序崩溃。

  1. ghci> goodExample []
  2. 0
  3. ghci> goodExample [1,2]
  4. 3

记录语法

给一个数据类型的每个成分写访问器函数是令人感觉重复而且乏味的事情。

  1. -- file: ch03/BookStore.hs
  2. nicerID (Book id _ _ ) = id
  3. nicerTitle (Book _ title _ ) = title
  4. nicerAuthors (Book _ _ authors) = authors

我们把这种代码叫做“样板代码(boilerplate code)”:尽管是必需的,但是又长又烦。Haskell 程序员不喜欢样板代码。幸运的是,语言的设计者提供了避免这个问题的方法:我们在定义一种数据类型的同时,就可以定义好每个成分的访问器。(逗号的位置是一个风格问题,如果你喜欢的话,也可以把它放在每行的最后。)

  1. -- file: ch03/BookStore.hs
  2. data Customer = Customer {
  3. customerID :: CustomerID
  4. , customerName :: String
  5. , customerAddress :: Address
  6. } deriving (Show)

以上代码和下面这段我们更熟悉的代码的意义几乎是完全一致的。

  1. -- file: ch03/AltCustomer.hs
  2. data Customer = Customer Int String [String]
  3. deriving (Show)
  4.  
  5. customerID :: Customer -> Int
  6. customerID (Customer id _ _) = id
  7.  
  8. customerName :: Customer -> String
  9. customerName (Customer _ name _) = name
  10.  
  11. customerAddress :: Customer -> [String]
  12. customerAddress (Customer _ _ address) = address

Haskell 会使用我们在定义类型的每个字段时的命名,相应生成与该命名相同的该字段的访问器函数。

  1. ghci> :type customerID
  2. customerID :: Customer -> CustomerID

我们仍然可以如往常一样使用应用语法来新建一个此类型的值。

  1. -- file: ch03/BookStore.hs
  2. customer1 = Customer 271828 "J.R. Hacker"
  3. ["255 Syntax Ct",
  4. "Milpitas, CA 95134",
  5. "USA"]

记录语法还新增了一种更详细的标识法来新建一个值。这种标识法通常都会提升代码的可读性。

  1. -- file: ch03/BookStore.hs
  2. customer2 = Customer {
  3. customerID = 271828
  4. , customerAddress = ["1048576 Disk Drive",
  5. "Milpitas, CA 95134",
  6. "USA"]
  7. , customerName = "Jane Q. Citizen"
  8. }

如果使用这种形式,我们还可以调换字段列表的顺序。比如在上面的例子里,name 和 address 字段的顺序就被移动过,和定义类型时的顺序不一样了。

当我们使用记录语法来定义类型时,还会影响到该类型的打印格式。

  1. ghci> customer1
  2. Customer {customerID = 271828, customerName = "J.R. Hacker", customerAddress = ["255 Syntax Ct","Milpitas, CA 95134","USA"]}

让我们打印一个 BookInfo 类型的值来做个比较;这是没有使用记录语法时的打印格式。

  1. ghci> cities
  2. Book 173 "Use of Weapons" ["Iain M. Banks"]

我们在使用记录语法的时候“免费”得到的访问器函数,实际上都是普通的 Haskell 函数。

  1. ghci> :type customerName
  2. customerName :: Customer -> String
  3. ghci> customerName customer1
  4. "J.R. Hacker"

标准库里的 System.Time 模块就是一个使用记录语法的好例子。例如其中定义了这样一个类型:

  1. data CalendarTime = CalendarTime {
  2. ctYear :: Int,
  3. ctMonth :: Month,
  4. ctDay, ctHour, ctMin, ctSec :: Int,
  5. ctPicosec :: Integer,
  6. ctWDay :: Day,
  7. ctYDay :: Int,
  8. ctTZName :: String,
  9. ctTZ :: Int,
  10. ctIsDST :: Bool
  11. }

假如没有记录语法,从一个如此复杂的类型中抽取某个字段将是一件非常痛苦的事情。这种标识法使我们在使用大型结构的过程中更方便了。

参数化类型

我们曾不止一次地提到列表类型是多态的:列表中的元素可以是任何类型。我们也可以给自定义的类型添加多态性。只要在类型定义中使用类型变量就可以做到这一点。Prelude 中定义了一种叫做 Maybe 的类型:它用来表示这样一种值——既可以有值也可能空缺,比如数据库中某行的某字段就可能为空。

  1. -- file: ch03/Nullable.hs
  2. data Maybe a = Just a
  3. | Nothing
  4. 译注:MaybeJustNothing 都是 Prelude 中已经定义好的类型
  5. 这段代码是不能在 ghci 里面执行的,它简单地展示了标准库是怎么定义 Maybe 这种类型的

这里的变量 a 不是普通的变量:它是一个类型变量。它意味着 `Maybe 类型使用另一种类型作为它的参数。从而使得 Maybe 可以作用于任何类型的值。

  1. -- file: ch03/Nullable.hs
  2. someBool = Just True
  3. someString = Just "something"

和往常一样,我们可以在 ghci 里试着用一下这种类型。

  1. ghci> Just 1.5
  2. Just 1.5
  3. ghci> Nothing
  4. Nothing
  5. ghci> :type Just "invisible bike"
  6. Just "invisible bike" :: Maybe [Char]

Maybe 是一个多态,或者称作泛型的类型。我们向 Maybe 的类型构造器传入某种类型作为参数,例如 Maybe Int</code> 或 <code>Maybe [Bool]。 如我们所希望的那样,这些都是不同的类型(译注:可能省略了“但是都可以成功传入作为参数”)。

我们可以嵌套使用参数化的类型,但要记得使用括号标识嵌套的顺序,以便 Haskell 编译器知道如何解析这样的表达式。

  1. -- file: ch03/Nullable.hs
  2. wrapped = Just (Just "wrapped")

再补充说明一下,如果和其它更常见的语言做个类比,参数化类型就相当于 C++ 中的模板(template),和 Java 中的泛型(generics)。请注意这仅仅是个大概的比喻。这些语言都是在被发明之后很久再加上模板和泛型的,因此在使用时会感到有些别扭。Haskell 则是从诞生之日起就有了参数化类型,因此更简单易用。

递归类型

列表这种常见的类型就是递归的:即它用自己来定义自己。为了深入了解其中的含义,让我们自己来设计一个与列表相仿的类型。我们将用 Cons 替换 (:) 构造器,用 Nil 替换 [] 构造器。

  1. -- file: ch03/ListADT.hs
  2. data List a = Cons a (List a)
  3. | Nil
  4. deriving (Show)

List a= 符号的左右两侧都有出现,我们可以说该类型的定义引用了它自己。当我们使用 Cons 构造器创建一个值的时候,我们必须提供一个 a 的值作为参数一,以及一个 List a 类型的值作为参数二。接下来我们看一个实例。

我们能创建的 List a 类型的最简单的值就是 Nil。请将上面的代码保存为一个文件,然后打开 ghci 并加载它。

  1. ghci> Nil
  2. Nil

由于 Nil 是一个 List a 类型(译注:原文是 List 类型,可能是漏写了 a),因此我们可以将它作为 Cons 的第二个参数。

  1. ghci> Cons 0 Nil
  2. Cons 0 Nil

然后 Cons 0 Nil 也是一个 List a 类型,我们也可以将它作为 Cons 的第二个参数。

  1. ghci> Cons 1 it
  2. Cons 1 (Cons 0 Nil)
  3. ghci> Cons 2 it
  4. Cons 2 (Cons 1 (Cons 0 Nil))
  5. ghci> Cons 3 it
  6. Cons 3 (Cons 2 (Cons 1 (Cons 0 Nil)))

我们可以一直这样写下去,得到一个很长的 Cons 链,其中每个子链的末位元素都是一个 Nil

Tip

List 可以被当作是 list 吗?

让我们来简单的证明一下 List a 类型和内置的 list 类型 [a] 拥有相同的构型。让我们设计一个函数能够接受任何一个 [a] 类型的值作为输入参数,并返回 List a 类型的一个值。

  1. -- file: ch03/ListADT.hs
  2. fromList (x:xs) = Cons x (fromList xs)
  3. fromList [] = Nil

通过查看上述实现,能清楚的看到它将每个 (:) 替换成 Cons,将每个 [] 替换成 Nil。这样就涵盖了内置 list 类型的全部构造器。因此我们可以说二者是同构的,它们有着相同的构型。

  1. ghci> fromList "durian"
  2. Cons 'd' (Cons 'u' (Cons 'r' (Cons 'i' (Cons 'a' (Cons 'n' Nil)))))
  3. ghci> fromList [Just True, Nothing, Just False]
  4. Cons (Just True) (Cons Nothing (Cons (Just False) Nil))

为了说明什么是递归类型,我们再来看第三个例子——定义一个二叉树类型。

  1. -- file: ch03/Tree.hs
  2. data Tree a = Node a (Tree a) (Tree a)
  3. | Empty
  4. deriving (Show)

二叉树是指这样一种节点:该节点有两个子节点,这两个子节点要么也是二叉树节点,要么是空节点。

这次我们将和另一种常见的语言进行比较来寻找灵感。以下是在 Java 中实现类似数据结构的类定义。

  1. class Tree<A>
  2. {
  3. A value;
  4. Tree<A> left;
  5. Tree<A> right;
  6.  
  7. public Tree(A v, Tree<A> l, Tree<A> r)
  8. {
  9. value = v;
  10. left = l;
  11. right = r;
  12. }
  13. }

稍有不同的是,Java 中使用特殊值 null 表示各种“没有值”, 因此我们可以使用 null 来表示一个节点没有左子节点或没有右子节点。下面这个简单的函数能够构建一个有两个叶节点的树(叶节点这个词习惯上是指没有子节点的节点)。

  1. class Example
  2. {
  3. static Tree<String> simpleTree()
  4. {
  5. return new Tree<String>(
  6. "parent",
  7. new Tree<String>("left leaf", null, null),
  8. new Tree<String>("right leaf", null, null));
  9. }
  10. }

Haskell 没有与 null 对应的概念。尽管我们可以使用 Maybe 达到类似的效果,但后果是模式匹配将变得十分臃肿。因此我们决定使用一个没有参数的 Empty 构造器。在上述 Tree 类型的 Java 实现中使用到 null 的地方,在 Haskell 中都改用 Empty

  1. -- file: ch03/Tree.hs
  2. simpleTree = Node "parent" (Node "left child" Empty Empty)
  3. (Node "right child" Empty Empty)

练习

  • 请给 List 类型写一个与 fromList 作用相反的函数:传入一个 List a 类型的值,返回一个 [a]
  • 请仿造 Java 示例,定义一种只需要一个构造器的树类型。不要使用 Empty 构造器,而是用 Maybe 表示节点的子节点。

报告错误

当我们的代码中出现严重错误时可以调用 Haskell 提供的标准函数 error :: String -> a。我们将希望打印出来的错误信息作为一个字符串参数传入。而该函数的类型签名看上去有些特别:它是怎么做到仅从一个字符串类型的值就生成任意类型 a 的返回值的呢?

由于它的结果是返回类型 a,因此无论我们在哪里调用它都能得到正确类型的返回值。然而,它并不像普通函数那样返回一个值,而是立即中止求值过程,并将我们提供的错误信息打印出来。

mySecond 函数返回输入列表参数的第二个元素,假如输入列表长度不够则失败。

  1. -- file: ch03/MySecond.hs
  2. mySecond :: [a] -> a
  3.  
  4. mySecond xs = if null (tail xs)
  5. then error "list too short"
  6. else head (tail xs)

和之前一样,我们来看看这个函数在 ghci 中的使用效果如何。

  1. ghci> mySecond "xi"
  2. 'i'
  3. ghci> mySecond [2]
  4. *** Exception: list too short
  5. ghci> head (mySecond [[9]])
  6. *** Exception: list too short

注意上面的第三种情况,我们试图将调用 mySecond 的结果作为参数传入另一个函数。求值过程也同样中止了,并返回到 ghci 提示符。这就是使用 error 的最主要的问题:它并不允许调用者根据错误是可修复的还是严重到必须中止的来区别对待。

正如我们之前所看到的,模式匹配失败也会造成类似的不可修复错误。

  1. ghci> mySecond []
  2. *** Exception: Prelude.tail: empty list

让过程更可控的方法

我们可以使用 Maybe 类型来表示有可能出现错误的情况。

如果我们想指出某个操作可能会失败,可以使用 Nothing 构造器。反之则使用 Just 构造器将值包裹起来。

让我们看看如果返回 Maybe 类型的值而不是调用 error,这样会给 mySecond 函数带来怎样的变化。

  1. -- file: ch03/MySecond.hs
  2. safeSecond :: [a] -> Maybe a
  3.  
  4. safeSecond [] = Nothing
  5. safeSecond xs = if null (tail xs)
  6. then Nothing
  7. else Just (head (tail xs))

当传入的列表太短时,我们将 Nothing 返回给调用者。然后由他们来决定接下来做什么,假如调用 error 的话则会强制程序崩溃。

  1. ghci> safeSecond []
  2. Nothing
  3. ghci> safeSecond [1]
  4. Nothing
  5. ghci> safeSecond [1,2]
  6. Just 2
  7. ghci> safeSecond [1,2,3]
  8. Just 2

复习一下前面的章节,我们还可以使用模式匹配继续增强这个函数的可读性。

  1. -- file: ch03/MySecond.hs
  2. tidySecond :: [a] -> Maybe a
  3.  
  4. tidySecond (_:x:_) = Just x
  5. tidySecond _ = Nothing
  1. 译注:(_:x:_) 相当于 (_:(x:_)),考虑到列表的元素只能是同一种类型
  2. 假想第一个 _ a 类型,那么这个模式匹配的是 (a:(a:[a, a, ...])) (a:(a:[]))
  3. 即元素是 a 类型的值的一个列表,并且至少有 2 个元素
  4. 那么如果第一个 _ 匹配到了 [],有没有可能使最终匹配到得列表只有一个元素呢?
  5. ([]:(x:_)) 说明 a 是列表类型,那么 x 也必须是列表类型,x 至少是 []
  6. ([]:([]:[])) -> ([]:[[]]) -> [[], []],还是 2 个元素

第一个模式仅仅匹配那些至少有两个元素的列表(因为它有两个列表构造器),并将列表的第二个元素的值绑定给 变量 x。如果第一个模式匹配失败了,则匹配第二个模式。

引入局部变量

在函数体内部,我们可以在任何地方使用 let 表达式引入新的局部变量。请看下面这个简单的函数,它用来检查我们是否可以向顾客出借现金。我们需要确保剩余的保证金不少于 100 元的情况下,才能出借现金,并返回减去出借金额后的余额。

  1. -- file: ch03/Lending.hs
  2. lend amount balance = let reserve = 100
  3. newBalance = balance - amount
  4. in if balance < reserve
  5. then Nothing
  6. else Just newBalance

这段代码中使用了 let 关键字标识一个变量声明区块的开始,用 in 关键字标识这个区块的结束。每行引入了一个局部变量。变量名在 = 的左侧,右侧则是该变量所绑定的表达式。

Note

特别提示

请特别注意我们的用词:在 let 区块中,变量名被绑定到了一个表达式而不是一个。由于 Haskell 是一门惰性求值的语言,变量名所对应的表达式一直到被用到时才会求值。在上面的例子里,如果没有满足保证金的要求,就不会计算 newBalance 的值。

当我们在一个 let 区块中定义一个变量时,我们称之为let 范围内的变量。顾名思义即是:我们将这个变量限制在这个 let 区块内。

另外,上面这个例子中对空白和缩进的使用也值得特别注意。在下一节 “The offside rule and white space in an expression” 中我们会着重讲解其中的奥妙。

let 区块内定义的变量,既可以在定义区内使用,也可以在紧跟着 in 关键字的表达式中使用。

一般来说,我们将代码中可以使用一个变量名的地方称作这个变量名的作用域(scope)。如果我们能使用,则说明在 作用域内,反之则说明在作用域外 。如果一个变量名在整个源代码的任意处都可以使用,则说明它位于最顶层的作用域。

屏蔽

我们可以在表达式中使用嵌套的 let 区块。

  1. -- file: ch03/NestedLets.hs
  2. foo = let a = 1
  3. in let b = 2
  4. in a + b

上面的写法是完全合法的;但是在嵌套的 let 表达式里重复使用相同的变量名并不明智。

  1. -- file: ch03/NestedLets.hs
  2. bar = let x = 1
  3. in ((let x = "foo" in x), x)

如上,内部的 x 隐藏了,或称作屏蔽(shadowing), 外部的 x。它们的变量名一样,但后者拥有完全不同的类型和值。

  1. ghci> bar
  2. ("foo",1)

我们同样也可以屏蔽一个函数的参数,并导致更加奇怪的结果。你认为下面这个函数的类型是什么?

  1. -- file: ch03/NestedLets.hs
  2. quux a = let a = "foo"
  3. in a ++ "eek!"

在函数的内部,由于 let-绑定的变量名 a 屏蔽了函数的参数,使得参数 a 没有起到任何作用,因此该参数可以是任何类型的。

  1. ghci> :type quux
  2. quux :: t -> [Char]

Tip

编译器警告是你的朋友

显然屏蔽会导致混乱和恶心的 bug,因此 GHC 设置了一个有用的选项 -fwarn-name-shadowing。如果你开启了这个功能,每当屏蔽某个变量名时,GHC 就会打印出一条警告。

where 从句

还有另一种方法也可以用来引入局部变量:where 从句。where 从句中的定义在其所跟随的主句中有效。下面是和 lend 函数类似的一个例子,不同之处是使用了 where 而不是 let

  1. -- file: ch03/Lending.hs
  2. lend2 amount balance = if amount < reserve * 0.5
  3. then Just newBalance
  4. else Nothing
  5. where reserve = 100
  6. newBalance = balance - amount

尽管刚开始使用 where 从句通常会有异样的感觉,但它对于提升可读性有着巨大的帮助。它使得读者的注意力首先能集中在表达式的一些重要的细节上,而之后再补上支持性的定义。经过一段时间以后,如果再用回那些没有 where 从句的语言,你就会怀念它的存在了。

let 表达式一样,where 从句中的空白和缩进也十分重要。 在下一节 “The offside rule and white space in an expression” 中我们会着重讲解其中的奥妙。

局部函数与全局变量

你可能已经注意到了,在 Haskell 的语法里,定义变量和定义函数的方式非常相似。这种相似性也存在于 letwhere 区块里:定义局部函数就像定义局部变量那样简单。

  1. -- file: ch03/LocalFunction.hs
  2. pluralise :: String -> [Int] -> [String]
  3. pluralise word counts = map plural counts
  4. where plural 0 = "no " ++ word ++ "s"
  5. plural 1 = "one " ++ word
  6. plural n = show n ++ " " ++ word ++ "s"

我们定义了一个由多个等式构成的局部函数 plural。局部函数可以自由地使用其被封装在内的作用域内的任意变量:在本例中,我们使用了在外部函数 pluralise 中定义的变量 word。在 pluralise 的定义里,map 函数(我们将在下一章里再来讲解它的用法)将局部函数 plural 逐一应用于 counts 列表的每个元素。

我们也可以在代码的一开始就定义变量,语法和定义函数是一样的。

  1. -- file: ch03/GlobalVariable.hs
  2. itemName = "Weighted Companion Cube"

表达式里的缩进规则和空白字符

请看 lendlend2 的定义表达式,左侧空出了一大块。这并不是随随便便写的,这些空白字符是有意义的。

Haskell 依据缩进来解析代码块。这种用排版来表达逻辑结构的方式通常被称作缩进规则。在源码文件开始的那一行,首个顶级声明或者定义可以从该行的任意一列开始,Haskell 编译器或解释器将记住这个缩进级别,并且随后出现的所有顶级声明也必须使用相同的缩进。

以下是一个顶级缩进规则的例子。第一个文件 GoodIndent.hs 执行正常。

  1. -- file: ch03/GoodIndent.hs
  2. -- 这里是最左侧一列
  3.  
  4. -- 顶级声明可以从任一列开始
  5. firstGoodIndentation = 1
  6.  
  7. -- 只要所有后续声明也这么做!
  8. secondGoodIndentation = 2

第二个文件 BadIndent.hs 没有遵守规则,因此也不能正常执行。

  1. -- file: ch03/BadIndent.hs
  2. -- 这里是最左侧一列
  3.  
  4. -- 第一个声明从第 4 列开始
  5. firstBadIndentation = 1
  6.  
  7. -- 第二个声明从第 1 列开始,这样是非法的!
  8. secondBadIndentation = 2

如果我们尝试在 ghci 里加载这两个文件,结果如下。

  1. ghci> :load GoodIndent.hs
  2. [1 of 1] Compiling Main ( GoodIndent.hs, interpreted )
  3. Ok, modules loaded: Main.
  4. ghci> :load BadIndent.hs
  5. [1 of 1] Compiling Main ( BadIndent.hs, interpreted )
  6.  
  7. BadIndent.hs:8:2: parse error on input `secondBadIndentation'
  8. Failed, modules loaded: none.

紧跟着的(译注:一个或多个)空白行将被视作当前行的延续,比当前行缩进更深的紧跟着的行也是如此。

let 表达式和 where 从句的规则与此类似。一旦 Haskell 编译器或解释器遇到一个 letwhere 关键字,就会记住接下来第一个标记(token)的缩进位置。然后如果紧跟着的行是空白行或向右缩进更深,则被视作是前一行的延续。而如果其缩进和前一行相同,则被视作是同一区块内的新的一行。

  1. -- file: ch03/Indentation.hs
  2. foo = let firstDefinition = blah blah
  3. -- 只有注释的行被视作空白行
  4. continuation blah
  5.  
  6. -- 减少缩进,于是下面这行就变成了一行新定义
  7. secondDefinition = yada yada
  8.  
  9. continuation yada
  10. in whatever

下面的例子演示了如何嵌套使用 letwhere

  1. -- file: ch03/letwhere.hs
  2. bar = let b = 2
  3. c = True
  4. in let a = b
  5. in (a, c)

变量 a 只在内部那个 let 表达式中可见。它对外部那个 let 是不可见的。如果我们在外部使用变量 a 就会得到一个编译错误。缩进为我们和编译器提供了视觉上的标识,让我们可以一眼就看出来作用域中包含哪些东西。

  1. -- file: ch03/letwhere.hs
  2. foo = x
  3. where x = y
  4. where y = 2

于此类似,第一个 where 从句的作用域即是定义 foo 的表达式,而第二个 where 从句的作用域则是第一个 where 从句。

letwhere 从句中妥善地使用缩进能够更好地展现代码的意图。

对制表符和空格说两句

在使用诸如 Emacs 这种能够识别 Haskell 代码的编辑器时,通常的默认配置是使用空格来表示缩进。如果你的编辑器不能识别 Haskell,建议您手工设置使用空格来表示缩进。

这么做的好处是可移植性。在一个使用等宽字体的编辑器里,类 Unix 系统和 Windows 系统对制表符的默认显示宽度并不一样,前者相当于 8 个字符宽度,而后者则相当于 4 个。这就意味着无论你自己认为制表符应该显示为多宽,你并不能保证别人的编辑器设置会尊重你的看法。使用制表符来表示缩进一定会在某些人的设置那里看起来一团糟。甚至还有可能导致编译错误,因为 Haskell 语言标准的实现是按照 Unix 风格制表符宽度来的。而使用空格则完全不用担心这个问题。

缩进规则并不是必需

我们也可以使用显式的语法结构来代替排版,从而表达代码的意图。例如,我们先写一个左大括号,然后写几个赋值等式,每个等式之间用分号分隔,最后加上一个右大括号。下面这个两个例子所表达的意图是一模一样的。

  1. -- file: ch03/Braces.hs
  2. bar = let a = 1
  3. b = 2
  4. c = 3
  5. in a + b + c
  6.  
  7. foo = let { a = 1; b = 2;
  8. c = 3 }
  9. in a + b + c

当我们使用显式语法结构时,普通的排版不再起作用。就像在第二个例子中那样,我们可以随意的使用缩进而仍然保持正确。

显式语法结构可以用在任何地方替换普通排版。它对 where 从句乃至顶级声明也是有效的。但是请记住,尽管你可以这样用,但在 Hasekll 编程中几乎没有人会使用显式语法结构。

Case 表达式

函数定义并不是唯一我们能使用模式匹配的地方。case 结构使得我们还能在一个表达式内部使用模式匹配。如下面的例子所示,这个函数(定义在 Data.Maybe 里)能解构一个 Maybe 值,如果该值为 Nothing 还可以返回默认值。

  1. -- file: ch03/Guard.hs
  2. fromMaybe defval wrapped =
  3. case wrapped of
  4. Nothing -> defval
  5. Just value -> value

case 关键字后面可以跟任意表达式,这个表达式的结果即是模式匹配的目标。of 关键字标识着表达式到此结束,以及匹配区块的开始,这个区块将用来定义每种模式及其对应的表达式。

该区块的每一项由三个部分组成:一个模式,接着一个箭头 ->,接着一个表达式;如果这个模式匹配中了,则计算并返回这个表达式的结果。这些表达式应当是同一类型的。第一个被匹配中的模式对应的表达式的计算结果即是 case 表达式的结果。匹配按自上而下的优先级进行。

如果使用通配符 _ 作为最后一个被匹配的模式,则意味着“如果上面的模式都没有被匹配中,就使用最后这个表达式”。如果所有模式匹配都没中,我们会看到前面章节里出现过的运行时错误。

新手在使用模式时常见的问题

在某些情况下,Haskell 新手会误解或误用模式。下面就是一些错误地使用模式匹配的例子。建议阅读时先自己想一想期望的结果是什么,然后看看实际的结果是否出乎你的意料。

错误地对变量进行匹配

  1. -- file: ch03/BogusPattern.hs
  2. data Fruit = Apple | Orange
  3. deriving (Show)
  4.  
  5. apple = "apple"
  6.  
  7. orange = "orange"
  8.  
  9. whichFruit :: String -> Fruit
  10.  
  11. whichFruit f = case f of
  12. apple -> Apple
  13. orange -> Orange

随意一瞥,这段代码的意思似乎是要检查 f 的值是 apple 还是 orange

换一种写法可以让错误更加显而易见。(译注:原文的例子太晦涩,换了评论中一个较清楚的例子)

  1. -- file: ch03/BogusPattern.hs
  2. whichFruit2 :: String -> Fruit
  3. whichFruit2 apple = Apple
  4. whichFruit2 orange = Orange

这样写明白了吗?就是这里,显然 apple 并不是指在上层定义的那个变量名为 apple 的值,而是当前作用域的一个模式变量。

Note

不可拒绝的模式

我们把这种永远会成功匹配的模式称作不可拒绝。普通的变量名和通配符 _ 都属于不可拒绝的模式。

上面那个函数的正确写法应该如此:

  1. -- file: ch03/BogusPattern.hs
  2. betterFruit f = case f of
  3. "apple" -> Apple
  4. "orange" -> Orange

我们针对字符串值 "apple""orange" 进行模式匹配,于是问题得到了解决。

进行了错误的相等比较

如果我们想比较一个 Tree 类型的两个节点值,如果相等就返回其中一个应该怎么做?请看下面这个尝试。

  1. -- file: ch03/BadTree.hs
  2. bad_nodesAreSame (Node a _ _) (Node a _ _) = Just a
  3. bad_nodesAreSame _ _ = Nothing

一个命名在一组模式绑定中只能使用一次。将同一个变量放在多个位置并不意味着“这些变量应该相等”。要解决这类问题,我们需要使用 Haskell 的另一个重要的特性,守卫

使用守卫实现条件求值

模式匹配针对的是值长成什么样子,因此有着诸多局限。除此之外,我们常常需要在对函数体求值之前进行各种各样的检查。Haskell 也为此提供了守卫这个特性。我们将通过改写上面这个用来比较树的两个节点是否相等的这段函数来讲解它的使用方法。

  1. -- file: ch03/BadTree.hs
  2. nodesAreSame (Node a _ _) (Node b _ _)
  3. | a == b = Just a
  4. nodesAreSame _ _ = Nothing

在上面的例子中,我们首先使用了模式匹配已确保值的形式是对的,然后使用了一个守卫来比较其中的一个部分。

一个模式后面可以跟着 0 个或多个 Bool 类型的守卫。我们引入了 | 这个符号来标识守卫的使用。即在它后面写一个守卫表达式,再写一个 = 符号(如果是 case 表达式则应该用 ->),然后写上在这个守卫表达式的值为真的情况下将被使用的函数体。如果某个模式匹配成功,那么它后面的守卫将按照顺序依次被求值。如果其中的某个守卫值为真,那么使用它所对应的函数体作为结果。如果所有的守卫值都为假,那么模式匹配继续进行,即尝试匹配下一个模式。

当对守卫表达式求值时,它所对应的模式中提到的所有变量都会被绑定,因此能够使用。

下面是一个用守卫重构过的 lend 方法。

  1. -- file: ch03/Lending.hs
  2. lend3 amount balance
  3. | amount <= 0 = Nothing
  4. | amount > reserve * 0.5 = Nothing
  5. | otherwise = Just newBalance
  6. where reserve = 100
  7. newBalance = balance - amount

otherwise 看上去像是守卫表达式的某种特殊语法,但实际上它只是一个被绑定为值 True 的普通变量,这样写是为了提高可读性。

任何使用模式的地方都可以使用守卫。用模式匹配和守卫把方法写成一组等式会大大提高可读性。还记得我们在条件求值<>_一节中定义的 myDrop 方法吗?

  1. -- file: ch02/myDrop.hs
  2. myDrop n xs = if n <= 0 || null xs
  3. then xs
  4. else myDrop (n - 1) (tail xs)

下面是用模式和守卫重构过的例子。

  1. -- file: ch02/myDrop.hs
  2. niceDrop n xs | n <= 0 = xs
  3. niceDrop _ [] = []
  4. niceDrop n (_:xs) = niceDrop (n - 1) xs

这种格式的代码清楚的依次列举了我们对函数在不同情况下的不同用法的期望。如果使用 if 表达式,我们的想法很容易就被藏进某个函数里,而代码则会变得不可读。

练习

1.写一个函数,用来计算一个列表元素的个数.出于测试要求,保证其输出的结果和标准函数 length 保持一致.

2.添加函数的类型签名于你的源文件.出于测试要求,再次加载源文件到ghci.

3.写一个函数,用来计算列表的平均值,即,列表元素的总和除以列表的长度.(你可能需要用到 fromIntegral 函数将列表长度变量从 integer 类型到 float 类型进行转换.)

4.将一个列表变成回文序列,即,他应该读起来完全一样,不管是从前往后还是从后往前.举个例子,考虑一个列表 [1,2,3],你的函数应该返回 [1,2,3,3,2,1].

5.写一个函数,用来确定他的输入是否是一个回文序列.

6.创造一个函数,用于排序一个包含许多列表的列表,其排序规则基于他的子列表的长度.(你可能要看看 Data.List 模块的 sortBy 函数.)

7.定义一个函数,其用一个分隔符将一个包含许多列表的列表连接在一起.函数类型定义如下:

  1. -- file: ch03/Intersperse.hs
  2. intersperse :: a -> [[a]] -> [a]

这个分割应该出现于列表的元素之间,除了最后一个元素末尾之外.你的函数需运行得如下所示:

  1. ghci> :load Intersperse
  2. [1 of 1] Compiling Main ( Intersperse.hs, interpreted )
  3. Ok, modules loaded: Main.
  4. ghci> intersperse ',' []
  5. ""
  6. ghci> intersperse ',' ["foo"]
  7. "foo"
  8. ghci> intersperse ',' ["foo","bar","baz","quux"]
  9. "foo,bar,baz,quux"

8.使用我们在前面章节中定义的二叉树类型,写一个函数用于确定一棵二叉树的高度.高度的定义是从根节点到叶子节点经过的最大节点数.举个例子,Empty 这棵树的高度是0; Node "x" Empty Empty 这棵树的高度是1; Node "x" Empty (Node "y" Empty Empty) 这棵树的高度是2;依此类推.

9.考虑三个二维的点 a, b,和c.如果我们观察沿着线段AB(由a,b节点组成)和线段BC(由b,c节点组成)形成的角度,它或者转向(turn)左边,或者转向右边,或者组成一条直线.定义一个 Direction(方向)的数据类型反映这些可能的情况.

10.写一个函数,用于计算三个二维坐标点组成的转向(turn),并且返回其 Direction(方向).

11.定义一个函数,输入二维坐标点的序列并计算其每个连续三个的(方向)Direction.考虑一个点的序列 [a,b,c,d,e],他应该开始计算 [a,b,c]的转向(turn), 接着计算 [b,c,d]的转向,再是[c,d,e]的.你的函数应该返回一个Direction(方向)的序列.

12.运用前面三个练习的代码,实现 Graham 扫描算法,用于扫描由二维点集构成的凸包.你能从 Wikipedia&lt;http://en.wikipedia.org/wiki/Convex_hull&gt;_ 上找到"什么是凸包",以及 "Graham扫描算法"&lt;http://en.wikipedia.org/wiki/Graham_scan&gt;_ 的完整解释.