Back
Featured image of post Haskell 学习笔记07 - 类型类

Haskell 学习笔记07 - 类型类

导航页

想要理解 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

DayOfWeekEq 实例写起来比较无聊:

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

模式匹配,穷举可能相同的输入,剩下的都为不相同的。

DateEq 实现就比较有意思了:

instance Eq Date where
  (==)
    (Date weekday dayOfMonth)
    (Date weekday' dayOfMonth') =
      weekday == weekday' && dayOfMonth == dayOfMonth'

这里解构声明了输入的两个形参,直接比较成员。

偏函数

先前我们介绍了函数的部分应用,但偏函数(partial function)虽然名字相似,却是完全不同的东西,务必不要混淆。偏函数是指,一个函数没有处理完全所有的可能输入。

在 Haskell 中我们通常要避免偏函数。如果我们这样实现 DayOfWeekEq(==) 即为偏函数,因为没有考虑所有情况:

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 之前,必须也先实现 RealEnum。同样的,Real 要求先实现 Num。因此整数类型必须是实数、且可枚举。由于 Real不能覆盖 Num 的方法。因此这种类型类关系是仅附加的,避免了某些编程语言中菱形继承的问题。

Fractional

NumFractional 的超类(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
divideThenAdd x y = (x / y) + 1

因为 Num 的实例不能使用 Fractional 中定义的方法 (/)。同时显然的,Fractional 可以使用 Num 定义的方法(如此处的 (+)),而不用显式在函数处要求。

divideThenAdd :: Fractional a => a -> a -> a
divideThenAdd x y = (x / y) + 1

这样的类型签名是不必要的:

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 的标准)说明了这些数字类型类的默认类型:

default Num Integer
default Real Integer
default Enum Integer
default Integral Integer
default Fractional Double
default RealFrac Double
default Floating Double
default RealFloat Double

对于 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。值得注意的是,TrueFalse 大。这是因为 Bool 的定义为 False | TrueTrue 位于 False 后面。

还有 maxmin 函数,都接受两个实参,并返回一个值:

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

所以应当注意:

  1. 应当确保 Ord 实例和 Eq 实例在等价性上的判断一致。即若 x == y(compare x y) == EQ
  2. Ord 应当处理所有的情况,避免偏函数,这在之前实现 Eq 已经说过一次。
  3. (compare x y) == LT(compare x y) == GT

Ord 暗含 Eq

以下函数不能通过编译

check' :: a -> a -> Bool
check' a a' = a == a'

因为 (==) 操作符需要 Eq 类型类。

但以下代码也可以通过编译:

check' :: Ord a => a -> a -> Bool
check' a a' = a == a'

因为 Ord 暗含 Eq

class Eq a => Ord a where
-- ...

我们可以说 EqOrd 的超类。

通常,我们想要最小的有效约束。所以在真实情况中,我们会使用 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 操作的结束。

StringIO String 之间的区别在于,后者是「产生一个字符串」,也就是可能会有副作用。

实现 Show

要实现 Show,至少需要实现 showshowPrec

data Mood = Blah

instance Show Mood where
  show _ = "Blah"

也可以用 deriving

data Mood = 
  Blah
  deriving Show

Read

ReadShow 的反面。它接收字符串,然后把它们转为具体类型。

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
    integerOfA      = toNumber a
    integerOfAPrime = toNumber a'
    summed =
      integerOfA + integerOfAPrime

Numberish 没有定义任何可以执行的代码,只有类型信息。这些代码存在于 AgeYear 的具体实现。那 Haskell 是如何找到它们的?

Prelude> sumNumberish (Age 10) (Age 10)
Age 20

以上例子中,Haskell 推断出 Age 10Numberish 的实例,并找到了对应的实现。

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
  fromNumber n = age n
  toNumber (Age n) = n
  defaultNumber = Age 65

instance Numberish Year where
  fromNumber n = Year n
  toNumber (Year n) = n
  defaultNumber = Year 1988

显然的,只使用 defaultNumber 会报错,必须注明具体的类型:

Prelude> defaultNumber :: Age
Age 65
Prelude> defaultNumber :: Year
Year 1988

以上即为类型类相关的内容了。

comments powered by Disqus