2.4 类型类入门
类型类(typeclass)是定义行为的接口。如果一个类型是某类型类的实例(instance),那它必实现了该类型类所描述的行为。
说得更具体些,类型类是一组函数的集合,如果将某类型实现为某类型类的实例,那就需要为这一类型提供这些函数的相应实现。
可以拿定义相等性的类型类作为例子。许多类型的值都可以通过==运算符来判断相等性,我们先检查一下它的类型签名:
ghci> :t (==)
(==) :: (Eq a) => a -> a -> Bool
注意,判断相等性的==运算符实际上是一个函数,+、-、*、/之类的运算符也是同样。如果一个函数的名字皆为特殊字符,则默认为中缀函数。若要检查它的类型、传递给其他函数调用或者作为前缀函数调用,就必须得像上面的例子那样,用括号将它括起来。
在这里我们见到了一个新东西,即=>符号。它的左侧叫做类型约束(type constraint)。我们可以这样读这段类型声明:“相等性函数取两个相同类型的值作为参数并返回一个布尔值,而这两个参数的类型同为Eq类的实例。”
Eq这一类型类提供了判断相等性的接口,凡是可比较相等性的类型必属于Eq类。Haskell中所有的标准类型都是Eq类的实例(除与输入输出相关的类型和函数之外)。
注意:
千万不要将Haskell的类型类与面向对象语言中类(Class)的概念混淆。
接下来我们将观察几个Haskell中最常见的类型类,比如判断相等性的类型类、判断次序的类型类、打印为字符串的类型类等。
2.4.1 Eq类型类
前面已提到,Eq类型类用于可判断相等性的类型,要求它的实例必须实现==和/=两个函数。如果函数中的某个类型变量声明了属于Eq的类型约束,那么它就必然定义了==和/=。也就是说,对于这一类型提供了特定的函数实现。下面即是操作Eq类型类的几个实例的例子:
ghci> 5 == 5
True
ghci> 5 /= 5
False
ghci> 'a' == 'a'
True
ghci> "Ho Ho" == "Ho Ho"
True
ghci> 3.432 == 3.432
True
2.4.2 Ord类型类
Ord类型类用于可比较大小的类型。作为一个例子,我们先看看大于号也就是>运算符的类型声明:
ghci> :t (>)
(>) :: (Ord a) => a -> a -> Bool
运算符的类型与==很相似。取两个参数,返回一个Bool类型的值,告诉我们这两个参数是否满足大于关系。
除了函数以外,我们目前所谈到的所有类型都是Ord的实例。Ord类型类中包含了所有标准的比较函数,如<、>、<=、>=等。
compare函数取两个Ord中的相同类型的值作为参数,返回一个Ordering类型的值。Ordering类型有GT、LT和 EQ三种值,分别表示大于、小于和等于。
ghci> "Abrakadabra" < "Zebra"
True
ghci> "Abrakadabra" `compare` "Zebra"
LT
ghci> 5 >= 2
True
ghci> 5 `compare` 3
GT
ghci> 'b' > 'a'
True
2.4.3 Show类型类
Show类型类的实例为可以表示为字符串的类型。目前为止,我们提到的除函数以外的所有类型都是Show的实例。操作Show类型类的实例的函数中,最常用的是show。它可以取任一Show的实例类型作为参数,并将其转为字符串:
ghci> show 3
"3"
ghci> show 5.334
"5.334"
ghci> show True
"True"
2.4.4 Read类型类
Read类型类可以看做是与Show相反的类型类。同样,我们提到的所有类型都是Read的实例。read函数可以取一个字符串作为参数并转为Read的某个实例的类型。
ghci> read "True" || False
True
ghci> read "8.2" + 3.8
12.0
ghci> read "5" - 2
3
ghci> read "[1,2,3,4]" ++ [3]
[1,2,3,4,3]
至此一切良好。但是,尝试read "4"又会怎样?
ghci> read "4"
<interactive >:1:0:
Ambiguous type variable 'a' in the constraint:
'Read a' arising from a use of 'read' at <interactive>:1:0-7
Probable fix: add a type signature that fixes these type variable(s)
GHCi跟我们抱怨,搞不清楚我们想要的返回值究竟是什么类型。注意前面我们调用 read之后,都利用所得的结果进行了进一步运算,GHCi也正是通过这一点来辨认类型的。如果我们的表达式的最终结果是一个布尔值,它就知道read的返回类型应该是Bool。在这里它只知道我们要的类型属于Read类型类,但不能明确到底是哪个类型。看一下read函数的类型签名吧:
ghci> :t read
read :: (Read a) => String -> a
注意:
String只是[Char]的一个别名。String与[Char]完全等价、可以互换,不过从现在开始,我们将尽量多用String了,因为String更易于书写,可读性也更高。
可见,read的返回值属于Read类型类的实例,但我们若用不到这个值,它就永远都不会知道返回值的类型。要解决这一问题,我们可以使用类型注解(type annotation)。
类型注解跟在表达式后面,通过::分隔,用来显式地告知Haskell某表达式的类型。
ghci> read "5" :: Int
5
ghci> read "5" :: Float
5.0
ghci> (read "5" :: Float) * 4
20.0
ghci> read "[1,2,3,4]" :: [Int]
[1,2,3,4]
ghci> read "(3, 'a')" :: (Int, Char)
(3, 'a')
编译器通常可以辨认出大部分表达式的类型,但也不是万能的。比如,遇到 read "5" 时,编译器就会无法分辨这个类型究竟是Int还是Float了。只有经过运算,Haskell才能明确其类型;同时由于Haskell是一门静态类型语言,它必须在编译之前(或者在GHCi的解释之前)搞清楚所有表达式的类型。所以我们最好提前给它打声招呼:“嘿,这个表达式应该是这个类型,免得你认不出来!”
要Haskell辨认出read的返回类型,我们只需提供最少的信息即可。比如,我们将read的结果放到一个列表中,Haskell即可通过这个列表中的其他元素的类型来分辨出正确的类型。
ghci> [read "True", False, True, False]
[True, False, True, False]
在这里我们将read "True"作为由Bool值组成的列表中的一个元素,Haskell看到了这里的Bool类型,就知道read "True"的类型一定是Bool了。
2.4.5 Enum类型类
Enum的实例类型都是有连续顺序的——它们的值都是可以枚举的。Enum类型类的主要好处在于我们可以在区间中使用这些类型:每个值都有相应的后继(successer)和前趋(predecesor),分别可以通过succ函数和pred函数得到。该类型类包含的类型主要有()、Bool、Char、Ordering、Int、Integer、Float和Double。
ghci> ['a'..'e']
"abcde"
ghci> [LT .. GT]
[LT,EQ,GT]
ghci> [3 .. 5]
[3,4,5]
ghci> succ 'B'
'C'
2.4.6 Bounded类型类
Bounded类型类的实例类型都有一个上限和下限,分别可以通过maxBound和minBound两个函数得到。
ghci> minBound :: Int
-2147483648
ghci> maxBound :: Char
'\1114111'
ghci> maxBound :: Bool
True
ghci> minBound :: Bool
False
minBound与maxBound两个函数很有趣,类型都是(Bounded a) => a。可以说,它们都是多态常量(polymorphic constant)。
注意,如果元组中项的类型都属于Bounded类型类的实例,那么这个元组也属于Bounded的实例了。
ghci> maxBound :: (Bool, Int, Char)
(True,2147483647,'\1114111')
2.4.7 Num类型类
Num是一个表示数值的类型类,它的实例类型都具有数的特征。先检查一个数的类型:
ghci> :t 20
20 :: (Num t) => t
看样子所有的数都是多态常量,它可以具有任何Num类型类中的实例类型的特征,如Int、Integer、Float或Double。
ghci> 20 :: Int
20
ghci> 20 :: Integer
20
ghci> 20 :: Float
20.0
ghci> 20 :: Double
20.0
作为例子,我们检查一下*运算符的类型:
ghci> :t (*)
(*) :: (Num a) => a -> a -> a
可见取两个相同类型的数值作为参数,并返回同一类型的数值。由于类型约束,所以(5 :: Int) (6 :: Integer)会导致一个类型错误,而5 * (6 :: Integer)就不会有问题。5既可以是Int类型也可以是Integer类型,但Integer类型与Int类型不能同时用。
只有已经属于Show与Eq的实例类型,才可以成为Num类型类的实例。
2.4.8 Floating类型类
Floating类型类仅包含Float和Double两种浮点类型,用于存储浮点数。
使用Floating类型类的实例类型作为参数类型或者返回类型的函数,一般是需要用到浮点数来进行某种计算的,如sin、cos与sqrt。
2.4.9 Integeral类型类
Integral是另一个表示数值的类型类。Num类型类包含了实数和整数在内的所有的数值相关类型,而Intgeral仅包含整数,其实例类型有Int和Integer。
有一个函数在处理数字时会非常有用,它便是fromIntegral。其类型声明为:
fromIntegral :: (Integral a, Num b) => a -> b
注意:
留意fromIntegral的类型签名中用到了多个类型约束,这是合法的,只要将多个类型约束放到括号里用逗号隔开即可。
从这段类型签名中可以看出,fromIntegeral函数取一个整数作为参数并返回一个更加通用的数值,这在同时处理整数和浮点数时尤为有用。举例来说,length函数的类型声明为:
length :: [a] -> Int
这就意味着,如果取了一个列表的长度,再给它加3.2就会报错(因为这是将Int类型与浮点数类型相加)。面对这种情况,我们即可通过fromIntegral来解决,具体如下:
ghci> fromIntegral (length [1,2,3,4]) + 3.2
7.2
2.4.10 有关类型类的最后总结
由于类型类定义的是一个抽象的接口,一个类型可以作为多个类型类的实例,一个类型类也可以含有多个类型作为实例。比如,Char类型就是多个类型类的实例,其中包括Ord和Eq,我们可以比较两个字符是否相等,也可以按照字母表顺序来比较它们。
有时,一个类型必须在成为某类型类的实例之后,才能成为另一个类型类的实例。比如,某类型若要成为Ord的实例,那它必须首先成为Eq的实例才行。或者说,成为Eq的实例,是成为Ord的实例的先决条件(prerequisite)。这一点不难明白,比如当我们比较两个值的顺序时,一定可以顺便得出这两个值是否相等。