想要理解 Haskell 的类型系统就绕不开类型类。本节将介绍一些重要的预定义类型类,以及更通用的类型类运作方式。
何谓类型类
类型的定义告诉我们一个类型如何被消费(consume)或被用于计算。类型类的目标就是允许我们添加新函数,而无需重新编译现有代码,并且保持静态类型安全(没有强转)。
在其他编程语言中,有着类似接口(interface)的概念。为了更好理解,你可以先认为,类型类是可以跨越多种数据类型的接口。这也是为什么类型类是一种特设多态,因为类型类的代码是由类型指定的。
回到 Bool
再次查看 Bool
的类型信息:
-- :info Bool
data Bool = False | True
instance Bounded Bool
instance Enum Bool
instance Eq Bool
instance Ord Bool
instance Read Bool
instance Show Bool
GHCi 告诉我们 Bool
已经具有了一些类型类的实例。本文将在后面介绍一些。
值得一提的是,类型类也可以具有层次结构。如所有
Fractional
的成员都必须是 Num
的成员,但
Num
不必是 Fractional
。所有 Ord
都必须是 Eq
的成员,所有 Enum
也必须是
Ord
的成员。这很好理解,枚举是有顺序的,而顺序需要先能够判断等价性。
Eq
Eq
类型类要求实现两个操作符,(==)
和
(/=)
,即等于和不等于:
(==) :: a -> a -> Bool
(/=) :: a -> a -> Bool
很多类型都会实现 Eq
。
instance Eq a => Eq [a]
instance Eq Ordering
instance Eq Int
instance Eq Float
instance Eq Double
instance Eq Char
instance Eq Bool
instance (Eq a, Eq b) => Eq (a, b)
instance Eq ()
instance Eq a => Eq (Maybe a)
instance Eq Integer
Prelude> 132 == 132
True
Prelude> 132 /= 132
False
Prelude> (1, 2) == (1, 1)
False
Prelude> (1, 1) == (1, 2)
False
Prelude> "doge" == "doge"
True
Prelude> "doge" == "doggie"
False
当然了,两个形参必须是相同类型:
ghci> 1 == 'a'
<interactive>:5:1: error:
• No instance for (Num Char) arising from the literal ‘1’
• In the first argument of ‘(==)’, namely ‘1’
In the expression: 1 == 'a'
In an equation for ‘it’: it = 1 == 'a'
编写类型类实例
当然,有些类型可以使用 derive 自动生成,而不必手动实现。这在后面会提到。
不妨定义一个类型 Trivial
:
data Trivial =
Trivial
因为没有 deriving
子句,所以没有默认实现任何类型类。
所以 Trivial == Trivial
将会报错。不妨自己编写:
data Trivial =
Trivial'
instance Eq Trivial where
Trivial' == Trivial' = True
Prelude> Trivial' == Trivial'
True
我们再定义两个日期相关的类型:
data DayOfWeek = Mon | Tue | Weds | Thu | Fri | Sat | Sun
-- 星期几 和 一个月的某一天
-- 比如 Date Tue 12
data Date = Date DayOfWeek Int
DayOfWeek
的 Eq
实例写起来比较无聊:
instance Eq DayOfWeek where
==) Mon Mon = True
(==) Tue Tue = True
(==) Weds Weds = True
(==) Thu Thu = True
(==) Fri Fri = True
(==) Sat Sat = True
(==) Sun Sun = True
(==) _ _ = False (
模式匹配,穷举可能相同的输入,剩下的都为不相同的。
而 Date
的 Eq
实现就比较有意思了:
instance Eq Date where
==)
(Date weekday dayOfMonth)
(Date weekday' dayOfMonth') =
(== weekday' && dayOfMonth == dayOfMonth' weekday
这里解构声明了输入的两个形参,直接比较成员。
偏函数
先前我们介绍了函数的部分应用,但偏函数(partial function)虽然名字相似,却是完全不同的东西,务必不要混淆。偏函数是指,一个函数没有处理完全所有的可能输入。
在 Haskell 中我们通常要避免偏函数。如果我们这样实现
DayOfWeek
的 Eq
,(==)
即为偏函数,因为没有考虑所有情况:
instance Eq DayOfWeek where
==) Mon Mon = True
(==) Tue Tue = True
(==) Weds Weds = True
(==) Thu Thu = True
(==) Fri Fri = True
(==) Sat Sat = True
(==) Sun Sun = True
(-- (==) _ _ = True
Prelude> Mon == Mon
True
Prelude> Mon == Tue
*** Exception: Non-exhaustive patterns in function ==
绝对的 Shit code。我们不是因为运行时错误来学 Haskell
的。所以我们可以开启 -Wall
,这会开启所有的警告。
为特定类型实现 Eq
例如有这样一个类型:
data Identity a = Identity a
因为 a
是多态的,所以我们不能知道 a
是否实现了 Eq
,下面的代码就会报错:
instance Eq (Identity a) where
==) (Identity v) (Identity v') = v == v' (
No instance for (Eq a) arising from a use of ‘==’
Possible fix: add (Eq a) to the
context of the instance declaration
In the expression: v == v'
In an equation for ‘==’:
(==) (Identity v) (Identity v') = v == v'
In the instance declaration for ‘Eq (Identity a)’
当然,我们可以要求更多,对特定类型实现 Eq
:
instance Eq a => Eq (Identity a) where
==) (Identity v) (Identity v') = v == v' (
工作正常:
Identity 1 == Identity 1 -- True
Haskell 也会在编译时检查,a
是不是具有 Eq
的实例。
Identity NoEqInst == Identity NoEqInst
-- No instance for (Eq NoEqInst)
-- arising from a use of ‘==’
Num
不再赘述:
class Num a where
(+) :: a -> a -> a
(*) :: a -> a -> a
(-) :: a -> a -> a
negate :: a -> a
abs :: a -> a
signum :: a -> a
fromInteger :: Integer -> a
instance Num Integer
instance Num Int
instance Num Float
instance Num Double
Integral
class (Real a, Enum a) => Integral a where
quot :: a -> a -> a
rem :: a -> a -> a
div :: a -> a -> a
mod :: a -> a -> a
quotRem :: a -> a -> (a, a)
divMod :: a -> a -> (a, a)
toInteger :: a -> Integer
类型类限制 (Real a, Enum a) =>
说明在实现
Integral
之前,必须也先实现 Real
和
Enum
。同样的,Real
要求先实现
Num
。因此整数类型必须是实数、且可枚举。由于
Real
并不能覆盖 Num
的方法。因此这种类型类关系是仅附加的,避免了某些编程语言中菱形继承的问题。
Fractional
Num
是 Fractional
的超类(superclass,或父类),Fractional
如此定义:
class (Num a) => Fractional a where
(/) :: a -> a -> a
recip :: a -> a
fromRational :: Rational -> a
Fractional
要求类型参数 a
必须是
Num
,因此这是一种类型类继承。Fractional
拓展了
Num
实例的方法,因此 Fractional
的实例一定能够使用 Num
类型类定义的方法。
以下代码会报错:
divideThenAdd :: Num a => a -> a -> a
= (x / y) + 1 divideThenAdd x y
因为 Num
的实例不能使用 Fractional
中定义的方法 (/)
。同时显然的,Fractional
可以使用 Num
定义的方法(如此处的
(+)
),而不用显式在函数处要求。
divideThenAdd :: Fractional a => a -> a -> a
= (x / y) + 1 divideThenAdd x y
这样的类型签名是不必要的:
f :: (Num a, Fractional a) => a -> a -> a
默认类型的类型类
如果有一个特设多态值,并且需要计算,那么它必须具有具体的类型。具体类型必须实现所有的所需类型类实例(比如,Num
和 Fractional
需要实现,那么具体类型就不能为
Int
)。通常而言,具体类型来自手动指定或类型推断出的签名,若有类型约束
Num a => a
的值,同时一个函数需要
Integer
,那么该值就会具体化为
Integer
。但有时,特别是在 GHCi
中时,没有具体的类型要求。就会让类型类求值为默认的具体类型。
比如:
Prelude> 1 / 2
0.5
Prelude> 1 / 2 :: Float
0.5
Prelude> 1 / 2 :: Double
0.5
Prelude> 1 / 2 :: Rational
1 % 2
Haskell Report(实质上是 Haskell 的标准)说明了这些数字类型类的默认类型:
Num Integer
default Real Integer
default Enum Integer
default Integral Integer
default Fractional Double
default RealFrac Double
default Floating Double
default RealFloat Double default
对于 Fractional
而言,默认类型意味着:
(/) :: Fractional a => a -> a -> a
会变为
(/) :: Double -> Double -> Double
当类型是具体的时,类型类约束并没有什么意义。另一方便,当类型不是具体的,且没有默认规则时,GHC 会报错,因此必须手动指定类型实现。
Ord
class Eq a => Ord a where
compare :: a -> a -> Ordering
(<) :: a -> a -> Bool
(>=) :: a -> a -> Bool
(>) :: a -> a -> Bool
(<=) :: a -> a -> Bool
max :: a -> a -> a
min :: a -> a -> a
instance Ord a => Ord (Maybe a)
instance (Ord a, Ord b) => Ord (Either a b)
instance Ord Integer
instance Ord a => Ord [a]
instance Ord Ordering
instance Ord Int
instance Ord Float
instance Ord Double
instance Ord Char
instance Ord Bool
实现 Ord
需要先实现
Eq
,这不必再赘述了,比较自然需要先能判断相等。
Prelude> compare 7 8
LT
Prelude> compare 4 (-4)
GT
Prelude> compare 4 4
EQ
Prelude> compare "Julie" "Chris"
GT
Prelude> compare True False
GT
Prelude> compare True True
EQ
compare
函数返回一个 Ordering
值而非
Bool
。值得注意的是,True
比 False
大。这是因为 Bool
的定义为
False | True
,True
位于 False
后面。
还有 max
和 min
函数,都接受两个实参,并返回一个值:
Prelude> max 7 8
8
Prelude> min 10 (-10)
-10
Prelude> max (3, 4) (2, 3)
3,4)
(Prelude> min [2, 3, 4, 5] [3, 4, 5, 6]
2,3,4,5]
[Prelude> max "Julie" "Chris"
"Julie"
deriving
之前我们已经手动编写过类型类的实例,这次我们使用
deriving
自动实现:
data DayOfWeek =
Mon | Tue | Weds | Thu | Fri | Sat | Sun
deriving (Eq, Ord, Show)
使用 deriving
实现 Ord
时,按照左边的值比右边小的规则生成:
Prelude> Mon > Tue
False
Prelude> Sun > Mon
True
Prelude> compare Tue Weds
LT
但如果要自定义规则,则可以这样写:
data DayOfWeek =
Mon | Tue | Weds | Thu | Fri | Sat | Sun
deriving (Eq, Show)
instance Ord DayOfWeek where
compare Fri Fri = EQ
compare Fri _ = GT
compare _ Fri = LT
compare _ _ = EQ
当然,上面的实现并不合理。
Prelude> compare Sat Mon
EQ
Prelude> Sat == Mon
False
所以应当注意:
- 应当确保
Ord
实例和Eq
实例在等价性上的判断一致。即若x == y
,(compare x y) == EQ
Ord
应当处理所有的情况,避免偏函数,这在之前实现Eq
已经说过一次。- 若
(compare x y) == LT
则(compare x y) == GT
Ord
暗含 Eq
以下函数不能通过编译
check' :: a -> a -> Bool
= a == a' check' a a'
因为 (==)
操作符需要 Eq
类型类。
但以下代码也可以通过编译:
check' :: Ord a => a -> a -> Bool
= a == a' check' a a'
因为 Ord
暗含 Eq
:
class Eq a => Ord a where
-- ...
我们可以说 Eq
是 Ord
的超类。
通常,我们想要最小的有效约束。所以在真实情况中,我们会使用
Eq
而不是 Ord
。
Enum
Enum
类似 Ord
但有所不同。该类型类描述可枚举的类型,也就是已知先前和后继的类型。
type Enum :: * -> Constraint
class Enum a where
succ :: a -> a
pred :: a -> a
toEnum :: Int -> a
fromEnum :: a -> Int
enumFrom :: a -> [a]
enumFromThen :: a -> a -> [a]
enumFromTo :: a -> a -> [a]
enumFromThenTo :: a -> a -> a -> [a]
{-# MINIMAL toEnum, fromEnum #-}
instance Enum Word
instance Enum Ordering
instance Enum Integer
instance Enum Int
instance Enum Char
instance Enum Bool
instance Enum ()
instance Enum Float
instance Enum Double
数字和字符是已知先前和后继的,所以是典型的可枚举值:
Prelude> succ 4
5
Prelude> pred 'd'
'c'
Prelude> succ 4.5
5.5
以及一些构建集合的方法:
Prelude> enumFromTo 0 10
0,1,2,3,4,5,6,7,8,9,10]
[Prelude> enumFromTo 'a' 'z'
"abcdefghijklmnopqrstuvwxyz"
Prelude> enumFromThenTo 0 10 100
0,10,20,30,40,50,60,70,80,90,100]
[Prelude> enumFromThenTo 'a' 'c' 'z'
"acegikmoqsuwy"
Show
Show
类型类用于创建人类可读的字符串表达。该类型类并非用于序列化,不应该用于持久化或网络交换用途。
class Show a where
showsPrec :: Int -> a -> ShowS
show :: a -> String
showList :: [a] -> ShowS
instance Show a => Show [a]
instance Show Ordering
instance Show a => Show (Maybe a)
instance Show Integer
instance Show Int
instance Show Char
instance Show Bool
instance Show ()
instance Show Float
instance Show Double
各种数字类型、布尔、元组、字符都是 Show
实例。也有一个函数 show
,输入泛型 a
并返回用于打印的 String
。
打印和副作用
Haskell 是一门纯函数式编程语言。函数是指程序由数学意义上的函数编写,一旦应用了一些实参,就会产生一个结果。纯是指 Haskell 中的表达式可以由 lambda 演算法表示。
将结果打印到屏幕上,听起来不会有多少问题。但打印函数不仅仅应用于其范围内的参数,还要求以某种方式影响器范围外的世界,即在屏幕上显示结果。这也叫副作用(side effect)。Haskell 将有副作用的函数和纯计算分开,以保证函数求值的可预测性和安全性。
print
用于,将任何可打印的类型输出到标准输出。可打印的类型即为有
Show
实例的类型。该函数将值转换为字符串,输出并添加新行。
print :: Show a => a -> IO ()
可以看到,该函数返回 IO ()
,main
函数也必须返回该值,因为运行 main
函数只可能产生副作用。
简而言之,IO
操作一定会产生副作用,包括读取输入、向屏幕打印等操作。这些操作都会返回
IO ()
。()
表示一个空元组,也可称为单元类型(unit)。单元类型是一个值,也是一个类型,它有且仅有一个成员。打印字符串到终端,没有什么有意义的返回值。但
IO
操作必须和其他表达式一样,返回一个值。因此,这里使用单元类型表示 IO
操作的结束。
String
和 IO String
之间的区别在于,后者是「产生一个字符串」,也就是可能会有副作用。
实现 Show
要实现 Show
,至少需要实现 show
或
showPrec
:
data Mood = Blah
instance Show Mood where
show _ = "Blah"
也可以用 deriving
data Mood =
Blah
deriving Show
Read
Read
是 Show
的反面。它接收字符串,然后把它们转为具体类型。
read :: Read a => String -> a
问题是字符串的可能性是无限的。比如尝试读取
Integer
,不能保证输入值都是有效的。String
可能是任何文本。
Prelude> read "1234567" :: Integer
1234567
Prelude> read "BLAH" :: Integer
*** Exception: Prelude.read: no parse
该异常是一个运行时错误,也意味着 read
是一个偏函数,它不能返回一个对应所有输入的可能输出。所以,应当在代码中避免使用这样的函数。具体的解决方法会在后面提到。
实例由类型委派
类型类由一组操作和值定义,它所有的实例都必须满足该定义。类型类实例是类型类和具体类型的唯一配对。
下面的类型类只用于演示,不要真的这么写:
class Numberish a where
fromNumber :: Integer -> a
toNumber :: a -> Integer
-- 暂时将 newtype 当作 data
newtype Age =
Age Integer
deriving (Eq, Show)
instance Numberish Age where
fromNumber n = age n
toNumber (Age n) = n
newtype Year =
Year Integer
deriving (Eq, Show)
instance Numberish Year where
fromNumber n = Year n
toNumber (Year n) = n
并且在函数中使用:
sumNumberish :: Numberish a => a -> a -> a
sumNumberish :: a a' = fromNumber summed
where
= toNumber a
integerOfA = toNumber a'
integerOfAPrime =
summed + integerOfAPrime integerOfA
Numberish
没有定义任何可以执行的代码,只有类型信息。这些代码存在于
Age
和 Year
的具体实现。那 Haskell
是如何找到它们的?
Prelude> sumNumberish (Age 10) (Age 10)
Age 20
以上例子中,Haskell 推断出 Age 10
是
Numberish
的实例,并找到了对应的实现。
Prelude> :t sumNumberish
sumNumberish :: Numberish a => a -> a -> a
Prelude> :t sumNumberish (Age 10)
sumNumberish (Age 10) :: Age -> Age
当第一个参数被应用,类型就确定了。可以看到这里推断为了具体的类型。
让 Numberish
多加一个函数:
class Numberish a where
fromNumber :: Integer -> a
toNumber :: a -> Integer
defaultNumber :: a
instance Numberish Age where
= age n
fromNumber n Age n) = n
toNumber (= Age 65
defaultNumber
instance Numberish Year where
= Year n
fromNumber n Year n) = n
toNumber (= Year 1988 defaultNumber
显然的,只使用 defaultNumber
会报错,必须注明具体的类型:
Prelude> defaultNumber :: Age
Age 65
Prelude> defaultNumber :: Year
Year 1988
以上即为类型类相关的内容了。