Haskell类型系统

2015/10/02 HasKell

HasKell类型

第 2 章 相信类型 强大的类型系统是Haskell的秘密武器。

在Haskell中,每个表达式都会在编译时得到明确的类型,从而提高代码的安全性。若你写的程序试图让布尔值与数相除,就不会通过编译。这样的好处就是与其让程序在运行时崩溃,不如在编译时捕获可能的错误。Haskell中一切皆有类型,因此编译器在编译时可以得到较多的信息来检查错误。

tu2-1.jpg

与Java和Pascal不同,Haskell支持类型推导 (type inference)。写下一个数,不必额外告诉Haskell说“它是个数”,Haskell自己就能推导出来。

在此之前,我们对Haskell类型的讲解还只是一扫而过而已,到这里不妨打起精神来,因为理解这套类型系统对于Haskell的学习是至关重要的。

2.1 显式类型声明 我们可以使用GHCi来检查表达式的类型。通过:t 命令,后跟任何合法的表达式,即可得到该表达式的类型。先试一下:

ghci> :t ‘a’ ‘a’ :: Char ghci> :t True True :: Bool ghci> :t “HELLO!” “HELLO!” :: [Char] ghci> :t (True, ‘a’) (True, ‘a’) :: (Bool, Char) ghci> :t 4 == 5 4 == 5 :: Bool

:: 读作“它的类型为”。凡是明确的类型,其首字母必为大写。’a’ 是Char 类型,意为字符(character)类型;True 是Bool 类型,意为布尔(Boolean)类型;而字符串”HELLO!” 的类型显示为[Char] ,其中的方括号表示这是个列表,所以我们可以将它读作“一组字符的列表”。元组与列表不同,每个不同长度的元组都有其独立的类型,于是(True, ‘a’) 的类型为(Bool, Char) ,而(‘a’, ‘b’, ‘c’) 的类型为(Char, Char, Char) 。4 == 5 必返回False ,因此它的类型为Bool 。

tu2-2.jpg

同样,函数也有类型。编写函数时,给它一个显式的类型声明是一个好习惯(至于比较短的函数就不必多此一举了)。从此刻开始,我们会对我们创建的所有函数都给出类型声明。

还记得第1章中我们写的那个过滤大写字母的列表推导式吗?给它加上类型声明便是这个样子:

removeNonUppercase :: [Char] -> [Char] removeNonUppercase st = [ c | c <- st, c elem [‘A’..’Z’]]

这里removeNonUppercase 的类型为[Char] -> [Char] ,意为它取一个字符串作为参数,返回另一个字符串作为结果。

不过,多个参数的函数该怎样指定其类型呢?下面便是一个将三个整数相加的简单函数。

addThree :: Int -> Int -> Int -> Int addThree x y z = x + y + z

参数与返回类型之间都是通过-> 分隔,最后一项就是返回值的类型了。(在第5章,我们将讲解为何统统采用-> 分隔,而非Int, Int, Int -> Int 之类“更好看”的方式。)

如果你打算给自己的函数加上类型声明,却拿不准它的类型是什么。那就先不管它,把函数先写出来,再使用:t命令测一下即可。因为函数也是表达式,所以:t对函数也是同样可用的。

2.2 Haskell的常见类型 接下来我们看几个Haskell中常见的基本类型,比如用于表示数、字符、布尔值的类型。

Int 意为整数。7 可以是Int ,但7.2 不可以。Int 是有界的(bounded),它的值一定界于最小值与最大值之间。 注意:

我们使用的 GHC 编译器规定 Int 的界限与机器相关。如果你的机器采用64位CPU,那么Int 的最小值一般为−263 ,最大值为263 −1。

Integer 也是用来表示整数的,但它是无界的。这就意味着可以用它存放非常非常大的数(真的非常非常大!),不过它的效率不如Int 高。拿下面的函数作为例子,可以将下面的函数保存到一个文件中: factorial :: Integer -> Integer factorial n = product [1..n]

然后通过:l 将它装载入GHCi并进行测试:

ghci> factorial 50 30414093201713378043612608166064768844377641568960512000000000000

Float 表示单精度浮点数。将下面的函数加入刚才的文件: circumference :: Float -> Float circumference r = 2 * pi * r

随后装载并测试:

ghci> circumference 4.0 25.132742

Double 表示双精度浮点数。双精度的数值类型中的位是一般的数值类型的两倍,这些多出来的位使它的精度更高,同时也占据更大的内存空间。继续将下面的这个函数加入文件: circumference’ :: Double -> Double circumference’ r = 2 * pi * r

装载并测试。可以特别留意circumference 与circumference’ 两者在精度上的差异。

ghci> circumference’ 4.0 25.132741228718345

Bool 表示布尔值,它只有两种值,即True 和False 。 Char 表示一个Unicode字符。一个字符由单引号括起,一组字符的列表即字符串。 元组也是类型,不过它们的类型取决于其中项的类型及数目,因而理论上可以有无限种元组类型(实际上元组中的项的数目最大为62——已经远大于我们日常的需求了)。注意,空元组同样也是个类型,它只有一种值,即()。 2.3 类型变量 有时让一些函数处理多种类型将更加合理。比如head 函数,它可以取一个列表作为参数,返回这一列表头部的元素。在这里列表中元素的类型不管是数值、字符还是列表,都不重要。不管它具体的类型是什么,只要是列表,head函数都能够处理。

猜猜head 函数的类型是什么呢?用:t 检查一下:

ghci> :t head head :: [a] -> a

这里的a 是什么?是类型吗?想想我们在前面说过,凡是类型其首字母必大写,所以它不是类型。它其实是个类型变量 (type variable),意味着a 可以是任何类型。

通过类型变量,我们可以在类型安全 (type safe))的前提下,轻而易举地编写能够处理多种类型的函数。这一点与其他语言中的泛型(generic)很相似,但在Haskell中要更为强大,更容易写出通用的函数。

tu2-3.jpg

使用了类型变量的函数被称作多态函数 (polymorphic function)。head函数即为此例,从它的类型声明中可以看出,它的参数类型为任意类型的元素组成的列表,返回的类型也正是该类型。

注意:

在命名上,类型变量使用多个字符是合法的,不过约定俗成,通常都是使用单个字符作为名字,如a,b,c,d…

还记得fst 吗?它可以返回一个序对中的首项。查一下它的类型:

ghci> :t fst fst :: (a, b) -> a

可以看出fst 取一个元组作为参数,且返回类型与元组中首项的类型相同。这便是fst 能够处理任何类型序对的原因。注意,a 和b 是不同的类型变量,并非特指二者表示的类型不同,这就意味着,在这段类型声明中元组首项的类型与返回值的类型可以相同。

2.4 类型类入门 类型类 (typeclass)是定义行为的接口。如果一个类型是某类型类的实例 (instance),那它必实现了该类型类所描述的行为。

说得更具体些,类型类是一组函数的集合,如果将某类型实现为某类型类的实例,那就需要为这一类型提供这些函数的相应实现。

tu2-4.jpg

可以拿定义相等性的类型类作为例子。许多类型的值都可以通过==运算符来判断相等性,我们先检查一下它的类型签名:

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”

:1:0: Ambiguous type variable 'a' in the constraint: 'Read a' arising from a use of 'read' at :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)。这一点不难明白,比如当我们比较两个值的顺序时,一定可以顺便得出这两个值是否相等。

Search

    微信好友

    博士的沙漏

    Table of Contents