构造我们自己的 Types 和 Typeclasses

Algebraic Data Types 入门

在前面的章节中,我们谈了一些 Haskell 内置的型别和 Typeclass。而在本章中,我们将学习构造型别和 Typeclass 的方法。
我们已经见识过许多态别,如 BoolIntCharMaybe 等等,不过在 Haskell 中该如何构造自己的型别呢?好问题,一种方法是使用 data 关键字。首先我们来看看 Bool 在标准函式库中的定义:
data Bool = False | True
data 表示我们要定义一个新的型别。= 的左端标明型别的名称即 Bool= 的右端就是_值构造子_ (Value Constructor),它们明确了该型别可能的值。| 读作"或",所以可以这样阅读该声明:Bool 型别的值可以是 TrueFalse。型别名和值构造子的首字母必大写。
相似,我们可以假想 Int 型别的声明:
data Int = -2147483648 | -2147483647 | ... | -1 | 0 | 1 | 2 | ... | 2147483647
头尾两个值构造子分别表示了 Int 型别的最小值和最大值,注意到真正的型别宣告不是长这个样子的,这样写只是为了便于理解。我们用省略号表示中间省略的一大段数字。
我们想想 Haskell 中图形的表示方法。表示圆可以用一个 Tuple,如 (43.1,55.0,10.4),前两项表示圆心的位置,末项表示半径。听着不错,不过三维矢量或其它什么东西也可能是这种形式!更好的方法就是自己构造一个表示图形的型别。假定图形可以是圆 (Circle) 或长方形 (Rectangle):
data Shape = Circle Float Float Float | Rectangle Float Float Float Float
这是啥,想想?Circle 的值构造子有三个项,都是 Float。可见我们在定义值构造子时,可以在后面跟几个型别表示它包含值的型别。在这里,前两项表示圆心的坐标,尾项表示半径。Rectangle 的值构造子取四个 Float 项,前两项表示其左上角的坐标,后两项表示右下角的坐标。
谈到「项」 (field),其实应为「参数」 (parameters)。值构造子的本质是个函数,可以返回一个型别的值。我们看下这两个值构造子的型别声明:
ghci> :t Circle
Circle :: Float -> Float -> Float -> Shape
ghci> :t Rectangle
Rectangle :: Float -> Float -> Float -> Float -> Shape
Cool,这么说值构造子就跟普通函数并无二致啰,谁想得到?我们写个函数计算图形面积:
surface :: Shape -> Float
surface (Circle _ _ r) = pi * r ^ 2
surface (Rectangle x1 y1 x2 y2) = (abs $ x2 - x1) * (abs $ y2 - y1)
值得一提的是,它的型别声明表示了该函数取一个 Shape 值并返回一个 Float 值。写 Circle -> Float 是不可以的,因为 Circle 并非型别,真正的型别应该是 Shape。这与不能写True->False 的道理是一样的。再就是,我们使用的模式匹配针对的都是值构造子。之前我们匹配过 []False5,它们都是不包含参数的值构造子。
我们只关心圆的半径,因此不需理会表示坐标的前两项:
ghci> surface $ Circle 10 20 10
314.15927
ghci> surface $ Rectangle 0 0 100 100
10000.0
Yay,it works!不过我们若尝试输出 Circle 10 20 到控制台,就会得到一个错误。这是因为 Haskell 还不知道该型别的字符串表示方法。想想,当我们往控制台输出值的时候,Haskell 会先调用 show 函数得到这个值的字符串表示才会输出。因此要让我们的 Shape 型别成为 Show 型别类的成员。可以这样修改:
data Shape = Circle Float Float Float | Rectangle Float Float Float Float deriving (Show)
先不去深究 deriving(派生),可以先这样理解:若在 data 声明的后面加上 deriving (Show),那 Haskell 就会自动将该型别至于 Show 型别类之中。好了,由于值构造子是个函数,因此我们可以拿它交给 map,拿它不全调用,以及普通函数能做的一切。
ghci> Circle 10 20 5
Circle 10.0 20.0 5.0
ghci> Rectangle 50 230 60 90
Rectangle 50.0 230.0 60.0 90.0
我们若要取一组不同半径的同心圆,可以这样:
ghci> map (Circle 10 20) [4,5,6,6]
[Circle 10.0 20.0 4.0,Circle 10.0 20.0 5.0,Circle 10.0 20.0 6.0,Circle 10.0 20.0 6.0]
我们的型别还可以更好。增加加一个表示二维空间中点的型别,可以让我们的 Shape 更加容易理解:
data Point = Point Float Float deriving (Show)
data Shape = Circle Point Float | Rectangle Point Point deriving (Show)
注意下 Point 的定义,它的型别与值构造子用了相同的名字。没啥特殊含义,实际上,在一个型别含有唯一值构造子时这种重名是很常见的。好的,如今我们的 Circle 含有两个项,一个是 Point 型别,一个是 Float 型别,好作区分。Rectangle 也是同样,我们得修改 surface 函数以适应型别定义的变动。
surface :: Shape -> Float
surface (Circle _ r) = pi * r ^ 2
surface (Rectangle (Point x1 y1) (Point x2 y2)) = (abs $ x2 - x1) * (abs $ y2 - y1)
唯一需要修改的地方就是模式。在 Circle 的模式中,我们无视了整个 Point。而在 Rectangle 的模式中,我们用了一个嵌套的模式来取得 Point 中的项。若出于某原因而需要整个 Point,那么直接匹配就是了。
ghci> surface (Rectangle (Point 0 0) (Point 100 100))
10000.0
ghci> surface (Circle (Point 0 0) 24)
1809.5574
表示移动一个图形的函数该怎么写?它应当取一个 Shape 和表示位移的两个数,返回一个位于新位置的图形。
nudge :: Shape -> Float -> Float -> Shape
nudge (Circle (Point x y) r) a b = Circle (Point (x+a) (y+b)) r
nudge (Rectangle (Point x1 y1) (Point x2 y2)) a b = Rectangle (Point (x1+a) (y1+b)) (Point (x2+a) (y2+b))
简洁明了。我们再给这一 Shape 的点加上位移的量。
ghci> nudge (Circle (Point 34 34) 10) 5 10
Circle (Point 39.0 44.0) 10.0
如果不想直接处理 Point,我们可以搞个辅助函数 (auxilliary function),初始从原点创建图形,再移动它们。
baseCircle :: Float -> Shape
baseCircle r = Circle (Point 0 0) r
baseRect :: Float -> Float -> Shape
baseRect width height = Rectangle (Point 0 0) (Point width height)
ghci> nudge (baseRect 40 100) 60 23
Rectangle (Point 60.0 23.0) (Point 100.0 123.0)
毫无疑问,你可以把你的数据型别导出到模块中。只要把你的型别与要导出的函数写到一起就是了。再在后面跟个括号,列出要导出的值构造子,用逗号隔开。如要导出所有的值构造子,那就写个..。
若要将这里定义的所有函数和型别都导出到一个模块中,可以这样:
module Shapes
( Point(..)
, Shape(..)
, surface
, nudge
, baseCircle
, baseRect
) where
一个 Shape (..),我们就导出了 Shape 的所有值构造子。这一来无论谁导入我们的模块,都可以用 RectangleCircle 值构造子来构造 Shape 了。这与写 Shape(Rectangle,Circle) 等价。
我们可以选择不导出任何 Shape 的值构造子,这一来使用我们模块的人就只能用辅助函数 baseCirclebaseRect 来得到 Shape 了。Data.Map 就是这一套,没有 Map.Map [(1,2),(3,4)],因为它没有导出任何一个值构造子。但你可以用,像 Map.fromList 这样的辅助函数得到 map。应该记住,值构造子只是函数而已,如果不导出它们,就拒绝了使用我们模块的人调用它们。但可以使用其他返回该型别的函数,来取得这一型别的值。
不导出数据型别的值构造子隐藏了他们的内部实现,令型别的抽象度更高。同时,我们模块的用户也就无法使用该值构造子进行模式匹配了。

Record Syntax

OK,我们需要一个数据型别来描述一个人,得包含他的姓、名、年龄、身高、电话号码以及最爱的冰淇淋。我不知你的想法,不过我觉得要了解一个人,这些数据就够了。就这样,实现出来!
data Person = Person String String Int Float String String deriving (Show)
O~Kay,第一项是名,第二项是姓,第三项是年龄,等等。我们造一个人:
ghci> let guy = Person "Buddy" "Finklestein" 43 184.2 "526-2928" "Chocolate"
ghci> guy
Person "Buddy" "Finklestein" 43 184.2 "526-2928" "Chocolate"
貌似很酷,就是难读了点儿。弄个函数得人的某项数据又该如何?如姓的函数,名的函数,等等。好吧,我们只能这样:
firstName :: Person -> String
firstName (Person firstname _ _ _ _ _) = firstname
lastName :: Person -> String
lastName (Person _ lastname _ _ _ _) = lastname
age :: Person -> Int
age (Person _ _ age _ _ _) = age
height :: Person -> Float
height (Person _ _ _ height _ _) = height
phoneNumber :: Person -> String
phoneNumber (Person _ _ _ _ number _) = number
flavor :: Person -> String
flavor (Person _ _ _ _ _ flavor) = flavor
唔,我可不愿写这样的代码!虽然 it works,但也太无聊了哇。
ghci> let guy = Person "Buddy" "Finklestein" 43 184.2 "526-2928" "Chocolate"
ghci> firstName guy
"Buddy"
ghci> height guy
184.2
ghci> flavor guy
"Chocolate"
你可能会说,一定有更好的方法!呃,抱歉,没有。
开个玩笑,其实有的,哈哈哈~Haskell 的发明者都是天才,早就料到了此类情形。他们引入了一个特殊的型别,也就是刚才提到的更好的方法 -- Record Syntax
data Person = Person { firstName :: String
, lastName :: String
, age :: Int
, height :: Float
, phoneNumber :: String
, flavor :: String
} deriving (Show)
与原先让那些项一个挨一个的空格隔开不同,这里用了花括号 {}。先写出项的名字,如 firstName,后跟两个冒号(也叫 Paamayim Nekudotayim,哈哈~(译者不知道什么意思~囧)),标明其型别,返回的数据型别仍与以前相同。这样的好处就是,可以用函数从中直接按项取值。通过 Record Syntax,Haskell 就自动生成了这些函数:firstName, lastName, age, height, phoneNumberflavor
ghci> :t flavor
flavor :: Person -> String
ghci> :t firstName
firstName :: Person -> String
还有个好处,就是若派生 (deriving) 到 Show 型别类,它的显示是不同的。假如我们有个型别表示一辆车,要包含生产商、型号以及出场年份:
data Car = Car String String Int deriving (Show)
ghci> Car "Ford" "Mustang" 1967
Car "Ford" "Mustang" 1967
若用 Record Syntax,就可以得到像这样的新车:
data Car = Car {company :: String, model :: String, year :: Int} deriving (Show)
ghci> Car {company="Ford", model="Mustang", year=1967}
Car {company = "Ford", model = "Mustang", year = 1967}
这一来在造车时我们就不必关心各项的顺序了。
表示三维矢量之类简单数据,Vector = Vector Int Int Int 就足够明白了。但一个值构造子中若含有很多个项且不易区分,如一个人或者一辆车啥的,就应该使用 Record Syntax。

Type parameters

值构造子可以取几个参数产生一个新值,如 Car 的构造子是取三个参数返回一个 Car。与之相似,型别构造子可以取型别作参数,产生新的型别。这咋一听貌似有点深奥,不过实际上并不复杂。如果你对 C++ 的模板有了解,就会看到很多相似的地方。我们看一个熟悉的型别,好对型别参数有个大致印象:
data Maybe a = Nothing | Just a
这里的a就是个型别参数。也正因为有了它,Maybe 就成为了一个型别构造子。在它的值不是 Nothing 时,它的型别构造子可以搞出 Maybe IntMaybe String 等等诸多态别。但只一个 Maybe 是不行的,因为它不是型别,而是型别构造子。要成为真正的型别,必须得把它需要的型别参数全部填满。
所以,如果拿 Char 作参数交给 Maybe,就可以得到一个 Maybe Char 的型别。如,Just 'a' 的型别就是 Maybe Char
你可能并未察觉,在遇见 Maybe 之前我们早就接触到型别参数了。它便是 List 型别。这里面有点语法糖,List 型别实际上就是取一个参数来生成一个特定型别,这型别可以是 [Int][Char] 也可以是 [String],但不会跟在 [] 的后面。
把玩一下 Maybe
ghci> Just "Haha"
Just "Haha"
ghci> Just 84
Just 84
ghci> :t Just "Haha"
Just "Haha" :: Maybe [Char]
ghci> :t Just 84
Just 84 :: (Num t) => Maybe t
ghci> :t Nothing
Nothing :: Maybe a
ghci> Just 10 :: Maybe Double
Just 10.0
型别参数很实用。有了它,我们就可以按照我们的需要构造出不同的型别。若执行 :t Just "Haha",型别推导引擎就会认出它是个 Maybe [Char],由于 Just a 里的 a 是个字符串,那么 Maybe a 里的 a 一定也是个字符串。
注意下,Nothing 的型别为 Maybe a。它是多态的,若有函数取 Maybe Int 型别的参数,就一概可以传给它一个 Nothing,因为 Nothing 中不包含任何值。Maybe a 型别可以有 Maybe Int 的行为,正如 5 可以是 Int 也可以是 Double。与之相似,空 List 的型别是 [a],可以与一切 List 打交道。因此,我们可以 [1,2,3]++[],也可以 ["ha","ha,","ha"]++[]
型别参数有很多好处,但前提是用对了地方才行。一般都是不关心型别里面的内容,如 Maybe a。一个型别的行为若有点像是容器,那么使用型别参数会是个不错的选择。我们完全可以把我们的Car型别从
data Car = Car { company :: String
, model :: String
, year :: Int
} deriving (Show)
改成:
data Car a b c = Car { company :: a
, model :: b
, year :: c
} deriving (Show)
但是,这样我们又得到了什么好处?回答很可能是,一无所得。因为我们只定义了处理 Car String String Int 型别的函数,像以前,我们还可以弄个简单函数来描述车的属性。
tellCar :: Car -> String
tellCar (Car {company = c, model = m, year = y}) = "This " ++ c ++ " " ++ m ++ " was made in " ++ show y
ghci> let stang = Car {company="Ford", model="Mustang", year=1967}
ghci> tellCar stang
"This Ford Mustang was made in 1967"
可爱的小函数!它的型别声明得很漂亮,而且工作良好。好,如果改成 Car a b c 又会怎样?
tellCar :: (Show a) => Car String String a -> String
tellCar (Car {company = c, model = m, year = y}) = "This " ++ c ++ " " ++ m ++ " was made in " ++ show y
我们只能强制性地给这个函数安一个 (Show a) => Car String String a 的型别约束。看得出来,这要繁复得多。而唯一的好处貌似就是,我们可以使用 Show 型别类的 instance 来作 a 的型别。
ghci> tellCar (Car "Ford" "Mustang" 1967)
"This Ford Mustang was made in 1967"
ghci> tellCar (Car "Ford" "Mustang" "nineteen sixty seven")
"This Ford Mustang was made in \"nineteen sixty seven\""
ghci> :t Car "Ford" "Mustang" 1967
Car "Ford" "Mustang" 1967 :: (Num t) => Car [Char] [Char] t
ghci> :t Car "Ford" "Mustang" "nineteen sixty seven"
Car "Ford" "Mustang" "nineteen sixty seven" :: Car [Char] [Char] [Char]
其实在现实生活中,使用 Car String String Int 在大多数情况下已经满够了。所以给 Car 型别加型别参数貌似并没有什么必要。通常我们都是都是在一个型别中包含的型别并不影响它的行为时才引入型别参数。一组什么东西组成的 List 就是一个 List,它不关心里面东西的型别是啥,然而总是工作良好。若取一组数字的和,我们可以在后面的函数体中明确是一组数字的 List。Maybe 与之相似,它表示可以有什么东西可以没有,而不必关心这东西是啥。
我们之前还遇见过一个型别参数的应用,就是 Data.Map 中的 Map k vk 表示 Map 中键的型别,v 表示值的型别。这是个好例子,Map 中型别参数的使用允许我们能够用一个型别索引另一个型别,只要键的型别在 Ord 型别类就行。如果叫我们自己定义一个 Map 型别,可以在 data 声明中加上一个型别类的约束。
data (Ord k) => Map k v = ...
然而 Haskell 中有一个严格的约定,那就是永远不要在 data 声明中添加型别约束。为啥?嗯,因为这样没好处,反而得写更多不必要的型别约束。Map k v 要是有 Ord k 的约束,那就相当于假定每个 Map 的相关函数都认为 k 是可排序的。若不给数据型别加约束,我们就不必给那些不关心键是否可排序的函数另加约束了。这类函数的一个例子就是 toList,它只是把一个 Map 转换为关联 List 罢了,型别声明为 toList :: Map k v -> [(k, v)]。要是加上型别约束,就只能是 toList :: (Ord k) =>Map k a -> [(k,v)],明显没必要嘛。
所以说,永远不要在 data 声明中加型别约束 --- 即便看起来没问题。免得在函数声明中写出过多无谓的型别约束。
我们实现个表示三维矢量的型别,再给它加几个处理函数。我么那就给它个型别参数,虽然大多数情况都是数值型,不过这一来它就支持了多种数值型别。
data Vector a = Vector a a a deriving (Show)
vplus :: (Num t) => Vector t -> Vector t -> Vector t
(Vector i j k) `vplus` (Vector l m n) = Vector (i+l) (j+m) (k+n)
vectMult :: (Num t) => Vector t -> t -> Vector t
(Vector i j k) `vectMult` m = Vector (i*m) (j*m) (k*m)
scalarMult :: (Num t) => Vector t -> Vector t -> t
(Vector i j k) `scalarMult` (Vector l m n) = i*l + j*m + k*n
vplus 用来相加两个矢量,即将其所有对应的项相加。scalarMult 用来求两个矢量的标量积,vectMult 求一个矢量和一个标量的积。这些函数可以处理 Vector IntVector IntegerVector Float 等等型别,只要 Vector a 里的这个 aNum 型别类中就行。同样,如果你看下这些函数的型别声明就会发现,它们只能处理相同型别的矢量,其中包含的数字型别必须与另一个矢量一致。注意,我们并没有在 data 声明中添加 Num 的类约束。反正无论怎么着都是给函数加约束。
再度重申,型别构造子和值构造子的区分是相当重要的。在声明数据型别时,等号=左端的那个是型别构造子,右端的(中间可能有|分隔)都是值构造子。拿 Vector t t t -> Vector t t t -> t 作函数的型别就会产生一个错误,因为在型别声明中只能写型别,而 Vector 的型别构造子只有个参数,它的值构造子才是有三个。我们就慢慢耍:
ghci> Vector 3 5 8 `vplus` Vector 9 2 8
Vector 12 7 16
ghci> Vector 3 5 8 `vplus` Vector 9 2 8 `vplus` Vector 0 2 3
Vector 12 9 19
ghci> Vector 3 9 7 `vectMult` 10
Vector 30 90 70
ghci> Vector 4 9 5 `scalarMult` Vector 9.0 2.0 4.0
74.0
ghci> Vector 2 9 3 `vectMult` (Vector 4 9 5 `scalarMult` Vector 9 2 4)
Vector 148 666 222

Derived instances

Typeclass 101 那一节里面,我们了解了 Typeclass 的基础内容。里面提到,型别类就是定义了某些行为的接口。例如,Int 型别是 Eq 型别类的一个 instance,Eq 类就定义了判定相等性的行为。Int 值可以判断相等性,所以 Int 就是 Eq 型别类的成员。它的真正威力体现在作为 Eq 接口的函数中,即 ==/=。只要一个型别是 Eq 型别类的成员,我们就可以使用 == 函数来处理这一型别。这便是为何 4==4"foo"/="bar" 这样的表达式都需要作型别检查。
我们也曾提到,人们很容易把型别类与 Java,Python,C++ 等语言的类混淆。很多人对此都倍感不解,在原先那些语言中,类就像是蓝图,我们可以根据它来创造对象、保存状态并执行操作。而型别类更像是接口,我们不是靠它构造数据,而是给既有的数据型别描述行为。什么东西若可以判定相等性,我们就可以让它成为 Eq 型别类的 instance。什么东西若可以比较大小,那就可以让它成为 Ord 型别类的 instance。
在下一节,我们将看一下如何手工实现型别类中定义函数来构造 instance。现在呢,我们先了解下 Haskell 是如何自动生成这几个型别类的 instance,Eq, Ord, Enum, Bounded, Show, Read。只要我们在构造型别时在后面加个 deriving(派生)关键字,Haskell 就可以自动地给我们的型别加上这些行为。
看这个数据型别:
data Person = Person { firstName :: String
, lastName :: String
, age :: Int
}
这描述了一个人。我们先假定世界上没有重名重姓又同龄的人存在,好,假如有两个 record,有没有可能是描述同一个人呢?当然可能,我么可以判定姓名年龄的相等性,来判断它俩是否相等。这一来,让这个型别成为 Eq 的成员就很靠谱了。直接 derive 这个 instance:
data Person = Person { firstName :: String
, lastName :: String
, age :: Int
} deriving (Eq)
在一个型别 derive 为 Eq 的 instance 后,就可以直接使用 ==/= 来判断它们的相等性了。Haskell 会先看下这两个值的值构造子是否一致(这里只是单值构造子),再用 == 来检查其中的所有数据(必须都是 Eq 的成员)是否一致。在这里只有 String 和 Int,所以是没有问题的。测试下我们的 Eqinstance:
ghci> let mikeD = Person {firstName = "Michael", lastName = "Diamond", age = 43}
ghci> let adRock = Person {firstName = "Adam", lastName = "Horovitz", age = 41}
ghci> let mca = Person {firstName = "Adam", lastName = "Yauch", age = 44}
ghci> mca == adRock
False
ghci> mikeD == adRock
False
ghci> mikeD == mikeD
True
ghci> mikeD == Person {firstName = "Michael", lastName = "Diamond", age = 43}
True
自然,Person 如今已经成为了 Eq 的成员,我们就可以将其应用于所有在型别声明中用到 Eq 类约束的函数了,如 elem
ghci> let beastieBoys = [mca, adRock, mikeD]
ghci> mikeD `elem` beastieBoys
True
ShowRead 型别类处理可与字符串相互转换的东西。同 Eq 相似,如果一个型别的构造子含有参数,那所有参数的型别必须都得属于 ShowRead 才能让该型别成为其 instance。就让我们的 Person 也成为 ReadShow 的一员吧。
data Person = Person { firstName :: String
, lastName :: String
, age :: Int
} deriving (Eq, Show, Read)
然后就可以输出一个 Person 到控制台了。
ghci> let mikeD = Person {firstName = "Michael", lastName = "Diamond", age = 43}
ghci> mikeD
Person {firstName = "Michael", lastName = "Diamond", age = 43}
ghci> "mikeD is: " ++ show mikeD
"mikeD is: Person {firstName = \"Michael\", lastName = \"Diamond\", age = 43}"
如果我们还没让 Person 型别作为 Show 的成员就尝试输出它,Haskell 就会向我们抱怨,说它不知道该怎么把它表示成一个字符串。不过现在既然已经 derive 成为了 Show 的一个 instance,它就知道了。
Read 几乎就是与 Show 相对的型别类,show 是将一个值转换成字符串,而 read 则是将一个字符串转成某型别的值。还记得,使用 read 函数时我们必须得用型别注释注明想要的型别,否则 Haskell 就不会知道如何转换。
ghci> read "Person {firstName =\"Michael\", lastName =\"Diamond\", age = 43}" :: Person
Person {firstName = "Michael", lastName = "Diamond", age = 43}
如果我们 read 的结果会在后面用到参与计算,Haskell 就可以推导出是一个 Person 的行为,不加注释也是可以的。
ghci> read "Person {firstName =\"Michael\", lastName =\"Diamond\", age = 43}" == mikeD
True
也可以 read 带参数的型别,但必须填满所有的参数。因此 read "Just 't'" :: Maybe a 是不可以的,read "Just 't'" :: Maybe Char 才对。
很容易想象 Ord 型别类 derive instance 的行为。首先,判断两个值构造子是否一致,如果是,再判断它们的参数,前提是它们的参数都得是 Ord 的 instance。Bool 型别可以有两种值,FalseTrue。为了了解在比较中进程的行为,我们可以这样想象:
data Bool = False | True deriving (Ord)
由于值构造子 False 安排在 True 的前面,我们可以认为 TrueFalse 大。
ghci> True `compare` False
GT
ghci> True > False
True
ghci> True < False
False
Maybe a 数据型别中,值构造子 NothingJust 值构造子前面,所以一个 Nothing 总要比 Just something 的值小。即便这个 something-100000000 也是如此。
ghci> Nothing < Just 100
True
ghci> Nothing > Just (-49999)
False
ghci> Just 3 `compare` Just 2
GT
ghci> Just 100 > Just 50
True
不过类似 Just (*3) > Just (*2) 之类的代码是不可以的。因为 (*3)(*2) 都是函数,而函数不是 Ord 类的成员。
作枚举,使用数字型别就能轻易做到。不过使用 EnumBounded 型别类会更好,看下这个型别:
data Day = Monday | Tuesday | Wednesday | Thursday | Friday | Saturday | Sunday
所有的值构造子都是 nullary 的(也就是没有参数),每个东西都有前置子和后继子,我们可以让它成为 Enum 型别类的成员。同样,每个东西都有可能的最小值和最大值,我们也可以让它成为 Bounded 型别类的成员。在这里,我们就同时将它搞成其它可 derive型别类的 instance。再看看我们能拿它做啥:
data Day = Monday | Tuesday | Wednesday | Thursday | Friday | Saturday | Sunday
deriving (Eq, Ord, Show, Read, Bounded, Enum)
由于它是 ShowRead 型别类的成员,我们可以将这个型别的值与字符串相互转换。
ghci> Wednesday
Wednesday
ghci> show Wednesday
"Wednesday"
ghci> read "Saturday" :: Day
Saturday
由于它是 EqOrd 的成员,因此我们可以拿 Day 作比较。
ghci> Saturday == Sunday
False
ghci> Saturday == Saturday
True
ghci> Saturday > Friday
True
ghci> Monday `compare` Wednesday
LT
它也是 Bounded 的成员,因此有最早和最晚的一天。
ghci> minBound :: Day
Monday
ghci> maxBound :: Day
Sunday
它也是 Enum 的 instance,可以得到前一天和后一天,并且可以对此使用 List 的区间。
ghci> succ Monday
Tuesday
ghci> pred Saturday
Friday
ghci> [Thursday .. Sunday]
[Thursday,Friday,Saturday,Sunday]
ghci> [minBound .. maxBound] :: [Day]
[Monday,Tuesday,Wednesday,Thursday,Friday,Saturday,Sunday]
那是相当的棒。

Type synonyms

在前面我们提到在写型别名的时候,[Char]String 等价,可以互换。这就是由型别别名实现的。型别别名实际上什么也没做,只是给型别提供了不同的名字,让我们的代码更容易理解。这就是 [Char] 的别名 String 的由来。
type String = [Char]
我们已经介绍过了 type 关键字,这个关键字有一定误导性,它并不是用来创造新类(这是 data 关键字做的事情),而是给一个既有型别提供一个别名。
如果我们随便搞个函数 toUpperString 或其他什么名字,将一个字符串变成大写,可以用这样的型别声明 toUpperString :: [Char] -> [Char], 也可以这样 toUpperString :: String -> String,二者在本质上是完全相同的。后者要更易读些。
在前面 Data.Map 那部分,我们用了一个关联 List 来表示 phoneBook,之后才改成的 Map。我们已经发现了,一个关联 List 就是一组键值对组成的 List。再看下我们 phoneBook 的样子:
phoneBook :: [(String,String)]
phoneBook =
[("betty","555-2938")
,("bonnie","452-2928")
,("patsy","493-2928")
,("lucille","205-2928")
,("wendy","939-8282")
,("penny","853-2492")
]
可以看出,phoneBook 的型别就是 [(String,String)],这表示一个关联 List 仅是 String 到 String 的映射关系。我们就弄个型别别名,好让它型别声明中能够表达更多信息。
type PhoneBook = [(String,String)]
现在我们 phoneBook 的型别声明就可以是 phoneBook :: PhoneBook 了。再给字符串加上别名:
type PhoneNumber = String
type Name = String
type PhoneBook = [(Name,PhoneNumber)]
Haskell 程序员们给 String 加别名是为了让函数中字符串的表达方式及用途更加明确。
好的,我们实现了一个函数,它可以取一名字和号码检查它是否存在于电话本。现在可以给它加一个相当好看明了的型别声明:
inPhoneBook :: Name -> PhoneNumber -> PhoneBook -> Bool
inPhoneBook name pnumber pbook = (name,pnumber) `elem` pbook
如果不用型别别名,我们函数的型别声明就只能是 String -> String -> [(String ,String)] -> Bool 了。在这里使用型别别名是为了让型别声明更加易读,但你也不必拘泥于它。引入型别别名的动机既非单纯表示我们函数中的既有型别,也不是为了替换掉那些重复率高的长名字体别(如 [(String,String)]),而是为了让型别对事物的描述更加明确。
型别别名也是可以有参数的,如果你想搞个型别来表示关联 List,但依然要它保持通用,好让它可以使用任意型别作 keyvalue,我们可以这样:
type AssocList k v = [(k,v)]
好的,现在一个从关联 List 中按键索值的函数型别可以定义为 (Eq k) => k -> AssocList k v -> MaybeAssocList 是个取两个型别做参数生成一个具体型别的型别构造子,如 Assoc Int String 等等。
*Fronzie 说*:Hey!当我提到具体型别,那我就是说它是完全调用的,就像 ``Map Int String``。要不就是多态函数中的 ``[a]`` 或 ``(Ord a) => Maybe a`` 之类。有时我和孩子们会说 "Maybe 型别",但我们的意思并不是按字面来,傻瓜都知道 ``Maybe`` 是型别构造子嘛。只要用一个明确的型别调用 ``Maybe``,如 ``Maybe String`` 可得一个具体型别。你知道,只有具体型别才可以保存值。
我们可以用不全调用来得到新的函数,同样也可以使用不全调用得到新的型别构造子。同函数一样,用不全的型别参数调用型别构造子就可以得到一个不全调用的型别构造子,如果我们要一个表示从整数到某东西间映射关系的型别,我们可以这样:
type IntMap v = Map Int v
也可以这样:
type IntMap = Map Int
无论怎样,IntMap 的型别构造子都是取一个参数,而它就是这整数指向的型别。
Oh yeah,如果要你去实现它,很可能会用个 qualified import 来导入 Data.Map。这时,型别构造子前面必须得加上模块名。所以应该写个 type IntMap = Map.Map Int
你得保证真正弄明白了型别构造子和值构造子的区别。我们有了个叫 IntMap 或者 AssocList 的别名并不意味着我们可以执行类似 AssocList [(1,2),(4,5),(7,9)] 的代码,而是可以用不同的名字来表示原先的 List,就像 [(1,2),(4,5),(7,9)] :: AssocList Int Int 让它里面的型别都是 Int。而像处理普通的 Tuple 构成的那种 List 处理它也是可以的。型别别名(型别依然不变),只可以在 Haskell 的型别部分中使用,像定义新型别或型别声明或型别注释中跟在::后面的部分。
另一个很酷的二参型别就是 Either a b 了,它大约是这样定义的:
data Either a b = Left a | Right b deriving (Eq, Ord, Read, Show)
它有两个值构造子。如果用了 Left,那它内容的型别就是 a;用了 Right,那它内容的型别就是 b。我们可以用它来将可能是两种型别的值封装起来,从里面取值时就同时提供 LeftRight 的模式匹配。
ghci> Right 20
Right 20
ghci> Left "w00t"
Left "w00t"
ghci> :t Right 'a'
Right 'a' :: Either a Char
ghci> :t Left True
Left True :: Either Bool b
到现在为止,Maybe 是最常见的表示可能失败的计算的型别了。但有时 Maybe 也并不是十分的好用,因为 Nothing 中包含的信息还是太少。要是我们不关心函数失败的原因,它还是不错的。就像 Data.Maplookup 只有在搜索的项不在 Map 时才会失败,对此我们一清二楚。但我们若想知道函数失败的原因,那还得使用 Either a b,用 a 来表示可能的错误的型别,用 b 来表示一个成功运算的型别。从现在开始,错误一律用 Left 值构造子,而结果一律用 Right
一个例子:有个学校提供了不少壁橱,好给学生们地方放他们的 Gun'N'Rose 海报。每个壁橱都有个密码,哪个学生想用个壁橱,就告诉管理员壁橱的号码,管理员就会告诉他壁橱的密码。但如果这个壁橱已经让别人用了,管理员就不能告诉他密码了,得换一个壁橱。我们就用 Data.Map 的一个 Map 来表示这些壁橱,把一个号码映射到一个表示壁橱占用情况及密码的 Tuple 里。
import qualified Data.Map as Map
data LockerState = Taken | Free deriving (Show, Eq)
type Code = String
type LockerMap = Map.Map Int (LockerState, Code)
很简单,我们引入了一个新的型别来表示壁橱的占用情况。并为壁橱密码及按号码找壁橱的 Map 分别设置了一个别名。好,现在我们实现这个按号码找壁橱的函数,就用 Either String Code 型别表示我们的结果,因为 lookup 可能会以两种原因失败。橱子已经让别人用了或者压根就没有这个橱子。如果 lookup 失败,就用字符串表明失败的原因。
lockerLookup :: Int -> LockerMap -> Either String Code
lockerLookup lockerNumber map =
case Map.lookup lockerNumber map of
Nothing -> Left $ "Locker number " ++ show lockerNumber ++ " doesn't exist!"
Just (state, code) -> if state /= Taken
then Right code
else Left $ "Locker " ++ show lockerNumber ++ " is already taken!"
我们在这里个 Map 中执行一次普通的 lookup,如果得到一个 Nothing,就返回一个 Left String 的值,告诉他压根就没这个号码的橱子。如果找到了,就再检查下,看这橱子是不是已经让别人用了,如果是,就返回个 Left String 说它已经让别人用了。否则就返回个 Right Code 的值,通过它来告诉学生壁橱的密码。它实际上就是个 Right String,我们引入了个型别别名让它这型别声明更好看。
如下是个 Map 的例子:
lockers :: LockerMap
lockers = Map.fromList
[(100,(Taken,"ZD39I"))
,(101,(Free,"JAH3I"))
,(103,(Free,"IQSA9"))
,(105,(Free,"QOTSA"))
,(109,(Taken,"893JJ"))
,(110,(Taken,"99292"))
]
现在从里面 lookup 某个橱子号..
ghci> lockerLookup 101 lockers
Right "JAH3I"
ghci> lockerLookup 100 lockers
Left "Locker 100 is already taken!"
ghci> lockerLookup 102 lockers
Left "Locker number 102 doesn't exist!"
ghci> lockerLookup 110 lockers
Left "Locker 110 is already taken!"
ghci> lockerLookup 105 lockers
Right "QOTSA"
我们完全可以用 Maybe a 来表示它的结果,但这样一来我们就对得不到密码的原因不得而知了。而在这里,我们的新型别可以告诉我们失败的原因。

Recursive data structures (递归地定义数据结构)

如我们先前看到的,一个 algebraic data type 的构造子可以有好几个 field,其中每个 field 都必须有具体的型态。有了那个概念,我们能定义一个型态,其中他的构造子的 field 的型态是他自己。这样我们可以递归地定义下去,某个型态的值便可能包含同样型态的值,进一步下去他还可以再包含同样型态的值。
考虑一下 List: [5]。他其实是 5:[] 的语法糖。在 : 的左边是一个普通值,而在右边是一串 List。只是在这个案例中是空的 List。再考虑 [4,5]。他可以看作 4:(5:[])。看看第一个 :,我们看到他也有一个元素在左边,一串 List 5:[] 在右边。同样的道理 3:(4:(5:6:[])) 也是这样。
我们可以说一个 List 的定义是要么是空的 List 或是一个元素,后面用 : 接了另一串 List。
我们用 algebraic data type 来实作我们自己的 List!
data List a = Empty | Cons a (List a) deriving (Show, Read, Eq, Ord)
这读起来好像我们前一段提及的定义。他要么是空的 List,或是一个元素跟一串 List 的结合。如果你被搞混了,看看用 record syntax 定义的可能比较清楚。
data List a = Empty | Cons { listHead :: a, listTail :: List a} deriving (Show, Read, Eq, Ord)
你可能也对这边的 Cons 构造子不太清楚。cons 其实就是指 :。对 List 而言,: 其实是一个构造子,他接受一个值跟另一串 List 来构造一个 List。现在我们可以使用我们新定义的 List 型态。换句话说,他有两个 field,其中一个 field 具有型态 a,另一个有型态 [a]
ghci> Empty
Empty
ghci> 5 `Cons` Empty
Cons 5 Empty
ghci> 4 `Cons` (5 `Cons` Empty)
Cons 4 (Cons 5 Empty)
ghci> 3 `Cons` (4 `Cons` (5 `Cons` Empty))
Cons 3 (Cons 4 (Cons 5 Empty))
我们用中缀的方式调用 Cons 构造子,这样你可以很清楚地看到他就是 :Empty 代表 [],而 4 `Cons` (5 `Cons` Empty) 就是 4:(5:[])
我们可以只用特殊字符来定义函数,这样他们就会自动具有中缀的性质。我们也能同样的手法套用在构造子上,毕竟他们不过是回传型态的函数而已。
infixr 5 :-:
data List a = Empty | a :-: (List a) deriving (Show, Read, Eq, Ord)
首先我们留意新的语法结构:fixity 宣告。当我们定义函数成 operator,我们能同时指定 fixity (但并不是必须的)。fixity 指定了他应该是 left-associative 或是 right-associative,还有他的优先级。例如说,* 的 fixity 是 infixl 7 *,而 + 的 fixity 是 infixl 6。代表他们都是 left-associative。(4 * 3 * 2) 等于 ((4 * 3) * 2)。但 * 拥有比 + 更高的优先级。所以 5 * 4 + 3 会是 (5 * 4) + 3
这样我们就可以写成 a :-: (List a) 而不是 Cons a (List a)
ghci> 3 :-: 4 :-: 5 :-: Empty
(:-:) 3 ((:-:) 4 ((:-:) 5 Empty))
ghci> let a = 3 :-: 4 :-: 5 :-: Empty
ghci> 100 :-: a
(:-:) 100 ((:-:) 3 ((:-:) 4 ((:-:) 5 Empty)))
Haskell 在宣告 deriving Show 的时候,他会仍视构造子为前缀函数,因此必须要用括号括起来。
我们在来写个函数来把两个 List 连起来。一般 ++ 在操作普通 List 的时候是这样的:
infixr 5 ++
(++) :: [a] -> [a] -> [a]
[] ++ ys = ys
(x:xs) ++ ys = x : (xs ++ ys)
我们把他偷过来用在我们的 List 上,把函数命名成 .++
infixr 5 .++
(.++) :: List a -> List a -> List a
Empty .++ ys = ys
(x :-: xs) .++ ys = x :-: (xs .++ ys