第六章:类型类

2018-02-24 15:49 更新

第六章:类型类

类型类(typeclass)是 Haskell 最强大的功能之一:它用于定义通用接口,为各种不同的类型提供一组公共特性集。

类型类是某些基本语言特性的核心,比如相等性测试和数值操作符。

在讨论如何使用类型类之前,先来看看它能做什么。

类型类的作用

假设这样一个场景:我们想对 Color 类型的值进行对比,但 Haskell 的语言设计者却没有实现 == 操作。

要解决这个问题,必须亲自实现一个相等性测试函数:

-- file: ch06/colorEq.hs

data Color = Red | Green | Blue

colorEq :: Color -> Color -> Bool
colorEq Red   Red   = True
colorEq Green Green = True
colorEq Blue  Blue  = True
colorEq _     _     = False

在 ghci 里测试:

Prelude> :load colorEq.hs
[1 of 1] Compiling Main             ( colorEq.hs, interpreted )
Ok, modules loaded: Main.

*Main> colorEq Green Green
True

*Main> colorEq Blue Red
False

过了一会,程序又添加了一个新类型 —— 职位:它对公司中的各个员工进行分类。

在执行像是工资计算这类任务是,又需要用到相等性测试,所以又需要再次为职位类型定义相等性测试函数:

-- file: ch06/roleEq.hs

data Role = Boss | Manager | Employee

roleEq :: Role -> Role -> Bool
roleEq Employee Employee = True
roleEq Manager  Manager  = True
roleEq Boss     Boss     = True
roleEq _        _        = False

测试:

Prelude> :load roleEq.hs
[1 of 1] Compiling Main             ( roleEq.hs, interpreted )
Ok, modules loaded: Main.

*Main> roleEq Boss Boss
True

*Main> roleEq Boss Employee
False

colorEq 和 roleEq 的定义揭示了一个问题:对于每个不同的类型,我们都需要为它们专门定义一个对比函数。

这种做法非常低效,而且烦人。如果同一个对比函数(比如 == )可以用于对比任何类型的值,这样就会方便得多。

另一方面,一般来说,如果定义了相等测试函数(比如 == ),那么不等测试函数(比如 /= )的值就可以直接对相等测试函数取反(使用 not )来计算得出。因此,如果可以通过相等测试函数来定义不等测试函数,那么会更方便。

通用函数还可以让代码变得更通用:如果同一段代码可以用于不同类型的输入值,那么程序的代码量将大大减少。

还有很重要的一点是,如果在之后添加通用函数对新类型的支持,那么原来的代码应该不需要进行修改。

Haskell 的类型类可以满足以上提到的所有要求。

什么是类型类?

类型类定义了一系列函数,这些函数对于不同类型的值使用不同的函数实现。它和其他语言的接口和多态方法有些类似。

[译注:这里原文是将“面向对象编程中的对象”和 Haskell 的类型类进行类比,但实际上这种类比并不太恰当,类比成接口和多态方法更适合一点。]

我们定义一个类型类来解决前面提到的相等性测试问题:

class BasicEq a where
    isEqual :: a -> a -> Bool

类型类使用 class 关键字来定义,跟在 class 之后的 BasicEq 是这个类型类的名字,之后的 a 是这个类型类的实例类型(instance type)。

BasicEq 使用类型变量 a 来表示实例类型,说明它并不将这个类型类限定于某个类型:任何一个类型,只要它实现了这个类型类中定义的函数,那么它就是这个类型类的实例类型。

实例类型所使用的名字可以随意选择,但是它和类型类中定义函数签名时所使用的名字应该保持一致。比如说,我们使用 a 来表示实例类型,那么函数签名中也必须使用 a 来代表这个实例类型。

BasicEq 类型类只定义了 isEqual 一个函数 —— 它接受两个参数作为输入,并且这两个参数都指向同一种实例类型:

Prelude> :load BasicEq_1.hs
[1 of 1] Compiling Main             ( BasicEq_1.hs, interpreted )
Ok, modules loaded: Main.

*Main> :type isEqual
isEqual :: BasicEq a => a -> a -> Bool

作为演示,以下代码将 Bool 类型作为 BasicEq 的实例类型,实现了 isEqual 函数:

instance BasicEq Bool where
    isEqual True  True  = True
    isEqual False False = True
    isEqual _     _     = False

在 ghci 里验证这个程序:

*Main> isEqual True True
True

*Main> isEqual False True
False

如果试图将不是 BasicEq 实例类型的值作为输入调用 isEqual 函数,那么就会引发错误:

*Main> isEqual "hello" "moto"

<interactive>:5:1:
    No instance for (BasicEq [Char])
          arising from a use of `isEqual'
    Possible fix: add an instance declaration for (BasicEq [Char])
    In the expression: isEqual "hello" "moto"
    In an equation for `it': it = isEqual "hello" "moto"

错误信息提醒我们, [Char] 并不是 BasicEq 的实例类型。

稍后的一节会介绍更多关于类型类实例的定义方式,这里先继续前面的例子。这一次,除了 isEqual 之外,我们还想定义不等测试函数 isNotEqual :

class BasicEq a where
    isEqual    :: a -> a -> Bool
    isNotEqual :: a -> a -> Bool

同时定义 isEqual 和 isNotEqual 两个函数产生了一些不必要的工作:从逻辑上讲,对于任何类型,只要知道 isEqual 或 isNotEqual 的任意一个,就可以计算出另外一个。因此,一种更省事的办法是,为 isEqual 和 isNotEqual 两个函数提供默认值,这样 BasicEq 的实例类型只要实现这两个函数中的一个,就可以顺利使用这两个函数:

class BasicEq a where
    isEqual :: a -> a -> Bool
    isEqual x y = not (isNotEqual x y)

    isNotEqual :: a -> a -> Bool
    isNotEqual x y = not (isEqual x y)

以下是将 Bool 作为 BasicEq 实例类型的例子:

instance BasicEq Bool where
    isEqual False False = True
    isEqual True  True  = True
    isEqual _     _     = False

我们只要定义 isEqual 函数,就可以“免费”得到 isNotEqual :

Prelude> :load BasicEq_3.hs
[1 of 1] Compiling Main             ( BasicEq_3.hs, interpreted )
Ok, modules loaded: Main.

*Main> isEqual True True
True

*Main> isEqual False False
True

*Main> isNotEqual False True
True

当然,如果闲着没事,你仍然可以自己亲手定义这两个函数。但是,你至少要定义两个函数中的一个,否则两个默认的函数就会互相调用,直到程序崩溃。

定义类型类实例

定义一个类型为某个类型类的实例,指的就是,为某个类型实现给定类型类所声明的全部函数。

比如在前面, BasicEq 类型类定义了两个函数 isEqual 和 isNotEqual :

class BasicEq a where
    isEqual :: a -> a -> Bool
    isEqual x y = not (isNotEqual x y)

    isNotEqual :: a -> a -> Bool
    isNotEqual x y = not (isEqual x y)

在前一节,我们成功将 Bool 类型实现为 BasicEq 的实例类型,要使 Color 类型也成为 BasicEq 类型类的实例,就需要另外为 Color 类型实现 isEqual 和 isNotEqual :

instance BasicEq Color where
    isEqual Red Red = True
    isEqual Blue Blue = True
    isEqual Green Green = True
    isEqual _ _ = True

注意,这里的函数定义和之前的 colorEq 函数定义实际上没有什么不同,唯一的区别是,它使得 isEqual 不仅可以对 Bool 类型进行对比测试,还可以对 Color 类型进行对比测试。

更一般地说,只要为相应的类型实现 BasicEq 类型类中的定义,那么 isEqual 就可以用于对比任何我们想对比的类型。

不过在实际中,通常并不使用 BasicEq 类型类,而是使用 Haskell Report 中定义的 Eq 类型类:它定义了 == 和 /= 操作符,这两个操作符才是 Haskell 中最常用的测试函数。

以下是 Eq 类型类的定义:

class  Eq a  where
    (==), (/=) :: a -> a -> Bool

-- Minimal complete definition:
--     (==) or (/=)
x /= y     =  not (x == y)
x == y     =  not (x /= y)

稍后会介绍更多使用 Eq 类型类的信息。

几个重要的内置类型类

前面两节分别介绍了类型类的定义,以及如何让某个类型成为给定类型类的实例类型。

正本节会介绍几个 Prelude 库中包含的类型类。如本章开始时所说的,类型类是 Haskell 语言某些特性的奠基石,本节就会介绍几个这方面的例子。

更多信息可以参考 Haskell 的函数参考,那里一般都给出了类型类的详细介绍,并且说明,要成为这个类型类的实例,需要实现那些函数。

Show

Show 类型类用于将值转换为字符串,它最重要的函数是 show 。

show 函数使用单个参数接收输入数据,并返回一个表示该输入数据的字符串:

Main> :type show
show :: Show a => a -> String

以下是一些 show 函数调用的例子:

Main> show 1
"1"

Main> show [1, 2, 3]
"[1,2,3]"

Main> show (1, 2)
"(1,2)"

Ghci 输出一个值,实际上就是对这个值调用 putStrLn 和 show :

Main> 1
1

Main> show 1
"1"

Main> putStrLn (show 1)
1

因此,如果你定义了一种新的数据类型,并且希望通过 ghci 来显示它,那么你就应该将这个类型实现为 Show 类型类的实例,否则 ghci 就会向你抱怨,说它不知道该怎样用字符串的形式表示这种数据类型:

Main> data Color = Red | Green | Blue;

Main> show Red

<interactive>:10:1:
    No instance for (Show Color)
        arising from a use of `show'
    Possible fix: add an instance declaration for (Show Color)
    In the expression: show Red
    In an equation for `it': it = show Red

Prelude> Red

<interactive>:5:1:
    No instance for (Show Color)
        arising from a use of `print'
    Possible fix: add an instance declaration for (Show Color)
    In a stmt of an interactive GHCi command: print it

通过实现 Color 类型的 show 函数,让 Color 类型成为 Show 的类型实例,可以解决以上问题:

instance Show Color where
    show Red   = "Red"
    show Green = "Green"
    show Blue  = "Blue"

当然, show 函数的打印值并不是非要和类型构造器一样不可,比如 Red 值并不是非要表示为 "Red" 不可,以下是另一种实例化 Show 类型类的方式:

instance Show Color where
    show Red   = "Color 1: Red"
    show Green = "Color 2: Green"
    show Blue  = "Color 3: Blue"

Read

Read 和 Show 类型类的作用正好相反,它将字符串转换为值。

Read 最有用的函数是 read :它接受一个字符串作为参数,对这个字符串进行处理,并返回一个值,这个值的类型为 Read 实例类型的成员(所有实例类型中的一种)。

Prelude> :type read
read :: Read a => String -> a

以下代码展示了 read 的用法:

Prelude> read "3"

<interactive>:5:1:
    Ambiguous type variable `a0' in the constraint:
          (Read a0) arising from a use of `read'
    Probable fix: add a type signature that fixes these type variable(s)
    In the expression: read "3"
    In an equation for `it': it = read "3"

Prelude> (read "3")::Int
3

Prelude> :type it
it :: Int

Prelude> (read "3")::Double
3.0

Prelude> :type it
it :: Double

注意在第一次调用 read 的时候,我们并没有显式地给定类型签名,这时对 read"3" 的求值会引发错误。这是因为有非常多的类型都是 Read 的实例,而编译器在 read 函数读入 "3" 之后,不知道应该将这个值转换成什么类型,于是编译器就会向我们发牢骚。

因此,为了让 read 函数返回正确类型的值,必须给它指示正确的类型。

使用 Read 和 Show 进行序列化

很多时候,程序需要将内存中的数据保存为文件,又或者,反过来,需要将文件中的数据转换为内存中的数据实体。这种转换过程称为序列化反序列化 .

通过将类型实现为 Read 和 Show 的实例类型, read 和 show 两个函数可以成为非常好的序列化工具。

作为例子,以下代码将一个内存中的列表序列化到文件中:

Prelude> let years = [1999, 2010, 2012]

Prelude> show years
"[1999,2010,2012]"

Prelude> writeFile "years.txt" (show years)

writeFile 将给定内容写入到文件当中,它接受两个参数,第一个参数是文件路径,第二个参数是写入到文件的字符串内容。

观察文件 years.txt 可以看到, (showyears) 所产生的文本被成功保存到了文件当中:

$ cat years.txt
[1999,2010,2012]

使用以下代码可以对 years.txt 进行反序列化操作:

Prelude> input <- readFile "years.txt"

Prelude> input                  -- 读入的字符串
"[1999,2010,2012]"

Prelude> (read input)::[Int]    -- 将字符串转换成列表
[1999,2010,2012]

readFile 读入给定的 years.txt ,并将它的内存传给 input 变量,最后,通过使用 read ,我们成功将字符串反序列化成一个列表。

数字类型

Haskell 有一集非常强大的数字类型:从速度飞快的 32 位或 64 位整数,到任意精度的有理数,包罗万有。

除此之外,Haskell 还有一系列通用算术操作符,这些操作符可以用于几乎所有数字类型。而对数字类型的这种强有力的支持就是建立在类型类的基础上的。

作为一个额外的好处(side benefit),用户可以定义自己的数字类型,并且获得和内置数字类型完全平等的权利。

以下表格显示了 Haskell 中最常用的一些数字类型:

表格 6.1 : 部分数字类型

类型 介绍
Double 双精度浮点数。表示浮点数的常见选择。
Float 单精度浮点数。通常在对接 C 程序时使用。
Int 固定精度带符号整数;最小范围在 -2^29 至 2^29-1 。相当常用。
Int8 8 位带符号整数
Int16 16 位带符号整数
Int32 32 位带符号整数
Int64 64 位带符号整数
Integer 任意精度带符号整数;范围由机器的内存限制。相当常用。
Rational 任意精度有理数。保存为两个整数之比(ratio)。
Word 固定精度无符号整数。占用的内存大小和 Int 相同
Word8 8 位无符号整数
Word16 16 位无符号整数
Word32 32 位无符号整数
Word64 64 位无符号整数

大部分算术操作都可以用于任意数字类型,少数的一部分函数,比如 asin ,只能用于浮点数类型。

以下表格列举了操作各种数字类型的常见函数和操作符:

表格 6.2 : 部分数字函数和

类型 模块 描述
(+) Num a => a -> a -> a Prelude 加法
(-) Num a => a -> a -> a Prelude 减法
(*) Num a => a -> a -> a Prelude 乘法
(/) Fractional a => a -> a -> a Prelude 份数除法
(**) Floating a => a -> a -> a Prelude 乘幂
(^) (Num a, Integral b) => a -> b -> a Prelude 计算某个数的非负整数次方
(^^) (Fractional a, Integral b) => a -> b -> a Prelude 分数的任意整数次方
(%) Integral a => a -> a -> Ratio a Data.Ratio 构成比率
(.&.) Bits a => a -> a -> a Data.Bits 二进制并操作
(. .) Bits a => a -> a -> a Data.Bits 二进制或操作
abs Num a => a -> a Prelude 绝对值操作
approxRational RealFrac a => a -> a -> Rational Data.Ratio 通过分数的分子和分母计算出近似有理数
cos Floating a => a -> a Prelude 余弦函数。另外还有 acos 、 cosh 和 acosh ,类型和 cos 一样。
div Integral a => a -> a -> a Prelude 整数除法,总是截断小数位。
fromInteger Num a => Integer -> a Prelude 将一个 Integer 值转换为任意数字类型。
fromIntegral (Integral a, Num b) => a -> b Prelude 一个更通用的转换函数,将任意 Integral 值转为任意数字类型。
fromRational Fractional a => Rational -> a Prelude 将一个有理数转换为分数。可能会有精度损失。
log Floating a => a -> a Prelude 自然对数算法。
logBase Floating a => a -> a -> a Prelude 计算指定底数对数。
maxBound Bounded a => a Prelude 有限长度数字类型的最大值。
minBound Bounded a => a Prelude 有限长度数字类型的最小值。
mod Integral a => a -> a -> a Prelude 整数取模。
pi Floating a => a Prelude 圆周率常量。
quot Integral a => a -> a -> a Prelude 整数除法;商数的分数部分截断为 0 。
recip Fractional a => a -> a Prelude 分数的倒数。
rem Integral a => a -> a -> a Prelude 整数除法的余数。
round (RealFrac a, Integral b) => a -> b Prelude 四舍五入到最近的整数。
shift Bits a => a -> Int -> a Bits 输入为正整数,就进行左移。如果为负数,进行右移。
sin Floating a => a -> a Prelude 正弦函数。还提供了 asin 、 sinh 和 asinh ,和 sin 类型一样。
sqrt Floating a => a -> a Prelude 平方根
tan Floating a => a -> a Prelude 正切函数。还提供了 atan 、 tanh 和 atanh ,和 tan 类型一样。
toInteger Integral a => a -> Integer Prelude 将任意 Integral 值转换为 Integer
toRational Real a => a -> Rational Prelude 从实数到有理数的有损转换
truncate (RealFrac a, Integral b) => a -> b Prelude 向下取整
xor Bits a => a -> a -> a Data.Bits 二进制异或操作

数字类型及其对应的类型类列举在下表:

表格 6.3 : 数字类型的类型类实例

类型 Bits Bounded Floating Fractional Integral Num Real RealFrac
Double     X X   X X X
Float     X X   X X X
Int X X     X X X  
Int16 X X     X X X  
Int32 X X     X X X  
Int64 X X     X X X  
Integer X       X X X  
Rational or any Ratio       X   X X X
Word X X     X X X  
Word16 X X     X X X  
Word32 X X     X X X  
Word64 X X     X X X  

表格 6.2 列举了一些数字类型之间进行转换的函数,以下表格是一个汇总:

表格 6.4 : 数字类型之间的转换

源类型目标类型
Double, FloatInt, WordIntegerRational
Double, FloatInt, WordIntegerRationalfromRational . toRationalfromIntegralfromIntegralfromRationaltruncate *fromIntegralfromIntegraltruncate *truncate *fromIntegralN/Atruncate *toRationalfromIntegralfromIntegralN/A
  • 除了 truncate 之外,还可以使用 round 、 ceiling 或者 float 。

第十三章会说明,怎样用自定义数据类型来扩展数字类型。

相等性,有序和对比

除了前面介绍的通用算术符号之外,相等测试、不等测试、大于和小于等对比操作也是非常常见的。

其中, Eq 类型类定义了 == 和 /= 操作,而 >= 和 <= 等对比操作,则由 Ord 类型类定义。

需要将对比操作和相等性测试分开用两个类型类来定义的原因是,对于某些类型,它们只对相等性测试和不等测试有兴趣,比如 Handle 类型,而部分有序操作(particular ordering, 大于、小于等)对它来说是没有意义的。

所有 Ord 实例都可以使用 Data.List.sort 来排序。

几乎所有 Haskell 内置类型都是 Eq 类型类的实例,而 Ord 实例的类型也不在少数。

自动派生

对于简单的数据类型, Haskell 编译器可以自动将类型派生(derivation)为 Read 、 Show 、 Bounded 、 Enum 、 Eq 和 Ord 的实例。

以下代码将 Color 类型派生为 Read 、 Show 、 Eq 和 Ord 的实例:

data Color = Red | Green | Blue
    deriving (Read, Show, Eq, Ord)

测试:

*Main> show Red
"Red"

*Main> (read "Red")::Color
Red

*Main> (read "[Red, Red, Blue]")::[Color]
[Red,Red,Blue]

*Main> Red == Red
True

*Main> Data.List.sort [Blue, Green, Blue, Red]
[Red,Green,Blue,Blue]

*Main> Red < Blue
True

注意 Color 类型的排序位置由定义类型时值构造器的排序决定。

自动派生并不总是可用的。比如说,如果定义类型 dataMyType=MyType(Int->Bool) ,那么编译器就没办法派生 MyType 为 Show 的实例,因为它不知道该怎么将 MyType 函数的输出转换成字符串,这会造成编译错误。

除此之外,当使用自动推导将某个类型设置为给定类型类的实例时,定义这个类型时所使用的其他类型,也必须是给定类型类的实例(通过自动推导或手动添加的都可以)。

举个例子,以下代码不能使用自动推导:

data Book = Book

data BookInfo = BookInfo Book
                deriving (Show)

Ghci 会给出提示,说明 Book 类型也必须是 Show 的实例, BookInfo 才能对 Show 进行自动推导:

Prelude> :load cant_ad.hs
[1 of 1] Compiling Main             ( cant_ad.hs, interpreted )

ad.hs:4:27:
    No instance for (Show Book)
          arising from the 'deriving' clause of a data type declaration
    Possible fix:
        add an instance declaration for (Show Book)
        or use a standalone 'deriving instance' declaration,
        so you can specify the instance context yourself
    When deriving the instance for (Show BookInfo)
Failed, modules loaded: none.

相反,以下代码可以使用自动推导,因为它对 Book 类型也使用了自动推导,使得 Book 类型变成了 Show 的实例:

data Book = Book
            deriving (Show)

data BookInfo = BookInfo Book
                deriving (Show)

使用 :info 命令在 ghci 中确认两种类型都是 Show 的实例:

Prelude> :load ad.hs
[1 of 1] Compiling Main             ( ad.hs, interpreted )
Ok, modules loaded: Main.

*Main> :info Book
data Book = Book    -- Defined at ad.hs:1:6
instance Show Book -- Defined at ad.hs:2:23

*Main> :info BookInfo
data BookInfo = BookInfo Book   -- Defined at ad.hs:4:6
instance Show BookInfo -- Defined at ad.hs:5:27

类型类实战:让 JSON 更好用

我们在 在 Haskell 中表示 JSON 数据 一节介绍的 JValue 用起来还不够简便。这里是一段由搜索引擎返回的实际 JSON 数据。删除重整之后:

{
    "query": "awkward squad haskell",
    "estimatedCount": 3920,
    "moreResults": true,
    "results":
    [{
        "title": "Simon Peyton Jones: papers",
        "snippet": "Tackling the awkward squad: monadic input/output ...",
        "url": "http://research.microsoft.com/~simonpj/papers/marktoberdorf/",
    },
    {
        "title": "Haskell for C Programmers | Lambda the Ultimate",
        "snippet": "... the best job of all the tutorials I've read ...",
        "url": "http://lambda-the-ultimate.org/node/724",
    }]
}

进一步简化之,并用 Haskell 表示:

-- file: ch06/SimpleResult.hs
import SimpleJSON

result :: JValue
result = JObject [
    ("query", JString "awkward squad haskell"),
    ("estimatedCount", JNumber 3920),
    ("moreResults", JBool True),
    ("results", JArray [
        JObject [
        ("title", JString "Simon Peyton Jones: papers"),
        ("snippet", JString "Tackling the awkward ..."),
        ("url", JString "http://.../marktoberdorf/")
        ]])
    ]

由于 Haskell 不原生支持包含不同类型值的列表,我们不能直接表示包含不同类型值的 JSON 对象。我们需要把每个值都用 JValue 构造器包装起来。但这样我们的灵活性就受到了限制:如果我们想把数字 3920 转换成字符串 "3,920",我们就必须把 JNumber 构造器换成 JString 构造器。

Haskell 的类型类提供了一个诱人的解决方案:

-- file: ch06/JSONClass.hs
type JSONError = String

class JSON a where
    toJValue :: a -> JValue
    fromJValue :: JValue -> Either JSONError a

instance JSON JValue where
    toJValue = id
    fromJValue = Right

现在,我们无需再用 JNumber 等构造器去包装值了,直接使用 toJValue 函数即可。如果我们更改值的类型,编译器会自动选择相应的 toJValue 实现。

我们也提供了 fromJValue 函数,它把 JValue 值转换成我们希望的类型。

让错误信息更有用

fromJValue 函数的返回类型为 Either。跟 Maybe 一样,这个类型是预定义的。我们经常用它来表示可能会失败的计算。

虽然 Maybe 也用作这个目的,但它在错误发生时没有给我们足够有用的信息:我们只得到一个 Nothing。Either 类型的结构相同,但它在错误发生时会调用 Left 构造器,并且还接受一个参数。

-- file: ch06/DataEither.hs
data Maybe a = Nothing
             | Just a
               deriving (Eq, Ord, Read, Show)

data Either a b = Left a
                | Right b
                  deriving (Eq, Ord, Read, Show)

我们经常使用 String 作为 a 参数的类型,以便在出错时提供有用的描述。为了说明在实际中怎么使用 Either 类型,我们来看一个简单实例。

-- file: ch06/JSONClass.hs
instance JSON Bool where
    toJValue = JBool
    fromJValue (JBool b) = Right b
    fromJValue _ = Left "not a JSON boolean"

[译注:读者若想在 ghci 中尝试 fromJValue,需要为其提供类型标注,例如 (fromJValue(toJValueTrue))::EitherJSONErrorBool。]

使用类型别名创建实例

Haskell 98标准不允许我们用下面的形式声明实例,尽管它看起来没什么问题:

-- file: ch06/JSONClass.hs
instance JSON String where
    toJValue               = JString

    fromJValue (JString s) = Right s
    fromJValue _           = Left "not a JSON string"

String 是 [Char] 的别名,因此它的类型是 [a],并用 Char 替换了类型变量 a。根据 Haskell 98的规则,我们在声明实例的时候不能用具体类型替代类型变量。也就是说,我们可以给 [a] 声明实例,但给 [Char] 不行。

尽管 GHC 默认遵守 Haskell 98标准,但是我们可以在文件顶部添加特殊格式的注释来解除这个限制。

-- file: ch06/JSONClass.hs
{-# LANGUAGE TypeSynonymInstances #-}

这条注释是一条编译器指令,称为编译选项(pragma),它告诉编译器允许这项语言扩展。上面的代码因为TypeSynonymInstances 这项语言扩展而合法。我们在本章(本书)还会碰到更多的语言扩展。

[译注:作者举的这个例子实际上牵涉到了两个问题。第一,Haskell 98不允许类型别名,这个问题可以通过上述方法解决。第二,Haskell 98不允许 [Char] 这种形式的类型,这个问题需要通过增加另外一条编译选项 {-#LANGUAGEFlexibleInstances#-} 来解决。]

生活在开放世界

Haskell 的设计允许我们任意创建类型类实例。

-- file: ch06/JSONClass.hs
doubleToJValue :: (Double -> a) -> JValue -> Either JSONError a
doubleToJValue f (JNumber v) = Right (f v)
doubleToJValue _ _ = Left "not a JSON number"

instance JSON Int where
    toJValue = JNumber . realToFrac
    fromJValue = doubleToJValue round

instance JSON Integer where
    toJValue = JNumber . realToFrac
    fromJValue = doubleToJValue round

instance JSON Double where
    toJValue = JNumber
    fromJValue = doubleToJValue id

我们可以在任意地方创建新实例,而不仅限于在定义了类型类的模块中。类型类系统的这个特性被称为开放世界假设(open world assumption)。如果有方法表示“这个类型类只存在这些实例”,那我们将得到一个封闭的世界。

我们希望把列表转为 JSON 数组。现在先不用关心实现细节,暂时用 undefined 替代函数内容即可。

-- file: ch06/BrokenClass.hs
instance (JSON a) => JSON [a] where
    toJValue = undefined
    fromJValue = undefined

我们也希望能将键/值对列表转为 JSON 对象。

-- file: ch06/BrokenClass.hs
instance (JSON a) => JSON [(String, a)] where
    toJValue = undefined
    fromJValue = undefined

什么时候重叠实例(Overlapping instances)会出问题?

如果我们把这些定义放进文件中并在 ghci 里载入,初看起来没什么问题。

*JSONClass> :l BrokenClass.hs
[1 of 2] Compiling JSONClass        ( JSONClass.hs, interpreted )
[2 of 2] Compiling BrokenClass      ( BrokenClass.hs, interpreted )
Ok, modules loaded: JSONClass, BrokenClass

然而,当我们使用序对列表实例时,麻烦来了。

*BrokenClass> toJValue [("foo","bar")]

<interactive>:10:1:
    Overlapping instances for JSON [([Char], [Char])]
        arising from a use of ‘toJValue’
    Matching instances:
        instance JSON a => JSON [(String, a)]
            -- Defined at BrokenClass.hs:13:10
        instance JSON a => JSON [a] -- Defined at BrokenClass.hs:8:10
    In the expression: toJValue [("foo", "bar")]
    In an equation for ‘it’: it = toJValue [("foo", "bar")]

重叠实例问题是由 Haskell 的开放世界假设造成的。 这里有一个更简单的例子来说明发生了什么。

-- file: ch06/Overlap.hs
class Borked a where
    bork :: a -> String

instance Borked Int where
    bork = show

instance Borked (Int, Int) where
    bork (a, b) = bork a ++ ", " ++ bork b

instance (Borked a, Borked b) => Borked (a, b) where
    bork (a, b) = ">>" ++ bork a ++ " " ++ bork b ++ "<<"

对于序对,我们有两个 Borked 类型类实例:一个是 Int 序对,另一个是任意类型的序对,只要这个类型是 Borked 类型类的实例。

假设我们想把 bork 应用于 Int 序对。编译器必须选择一个实例来用。由于这两个实例都能用,所以看上去它好像只要选那个更相关(specific)的实例就可以了。

但是,GHC 默认是保守的。它坚持只能有一个可用实例。这样,当我们试图使用 bork 时,它就会报错。

Note

重叠实例什么时候会出问题?

之前我们提到,我们可以把某个类型类的实例分散在几个模块中。GHC 并不会在意重叠实例的存在。相反,只有当我们使用受影响的类型类的函数,GHC 被迫要选择使用哪个实例时,它才会报错。

取消类型类的一些限制

通常,我们不能给多态类型(polymorphic type)的特化版本(specialized version)写类型类实例。[Char] 类型就是多态类型 [a] 特化成 Char 的结果。因此我们禁止声明 [Char] 为某个类型类的实例。这非常不方便,因为字符串在代码中无处不在。

FlexibleInstances 语言扩展取消了这个限制,它允许我们写这样的实例。

GHC 支持另外一个有用的语言扩展,OverlappingInstances,它解决了重叠实例带来的问题。如果存在重叠实例,编译器会选择最相关的(specific)那一个。

我们经常把这个扩展和 TypeSynonymInstances 放在一起使用。下面是一个例子。

-- file: ch06/SimpleClass.hs
{-# LANGUAGE TypeSynonymInstances, OverlappingInstances #-}

import Data.List

class Foo a where
    foo :: a -> String

instance Foo a => Foo [a] where
    foo = concat . intersperse ", " . map foo

instance Foo Char where
    foo c = [c]

instance Foo String where
    foo = id

如果我们对 String 应用 foo,编译器会选择 String 的特定实现。即使 [a] 和 Char 都是 Foo 的实例,但由于 String 实例更相关,因此 GHC 选择了它。

即使开了 OverlappingInstances 扩展,如果 GHC 发现了多个同样相(equally specific)关的实例,它仍然会拒绝代码。

何时使用 OverlappingInstances 扩展(to be added)

字符串的 show 是如何工作的?

OverlappingInstances 和 TypeSynonymInstances 语言扩展是 GHC 特有的,Haskell 98 并不支持。然而,Haskell 98 中的 Show 类型类在转化 Char 列表和 Int 列表时却用了不同的方法。它用了一个聪明但简单的小技巧。

Show 类型类定义了转换单个值的 show 方法和转换列表的 showList 方法。showList 默认使用中括号和逗号转换列表。

[a] 的 Show 实例使用 showList 实现。Char 的 Show 实例提供了一个特殊的 showList 实现,它使用双引号,并转义非 ASCII 打印字符。

结果是,如果有人想对 [Char] 应用 show,编译器会选择 showList 的实现,并使用双引号正确转换这个字符串。

这样,换个角度看问题,我们就能避免 OverlappingInstances 扩展了。

如何给类型定义新身份(Identity)

除了熟悉的 data 关键字外,Haskell 还允许我们用 newtype 关键字来创建新类型。

-- file: ch06/Newtype.hs
data DataInt = D Int
    deriving (Eq, Ord, Show)

newtype NewtypeInt = N Int
    deriving (Eq, Ord, Show)

newtype 声明的作用是重命名现有类型,并给它一个新身份。可以看出,它的用法和使用 data 关键字进行类型声明看起来很相似。

Note

type 和 newtype 关键字

尽管名字类似,type 和 newtype 关键字的作用却完全不同。type 关键字给了我们另一种指代某个类型的方法,类似于给朋友起的绰号。我们和编译器都知道 [Char] 和 String 指的是同一个类型。

相反,newtype 关键字的存在是为了隐藏类型的本性。考虑这个 UniqueID 类型。

-- file: ch06/Newtype.hs
newtype UniqueID = UniqueID Int
    deriving (Eq)

编译器会把 UniqueID 当成和 Int 不同的类型。作为 UniqueID 的用户,我们只知道它是一个唯一标识符;我们并不知道它是用 Int 来实现的。

在声明 newtype 时,我们必须决定暴露被重命名类型的哪些类型类实例。这里,我们让 NewtypeInt 提供 Int 类型的 Eq, Ord 和 Show 实例。这样,我们就可以比较和打印 NewtypeInt 类型的值了。

*Main> N 1 < N 2
True

由于我们没有暴露 Int 的 Num 或 Integral 实例,NewtypeInt 类型的值并不是数字。例如,我们不能做加法。

*Main> N 313 + N 37

<interactive>:9:7:
    No instance for (Num NewtypeInt) arising from a use of ‘+’
    In the expression: N 313 + N 37
    In an equation for ‘it’: it = N 313 + N 37

跟用 data 关键字一样,我们可以用 newtype 的值构造器创建新值,或者对现有值进行模式匹配。 如果 newtype 没用自动派生来暴露对应类型的类型类实现的话,我们可以自己写一个新实例或者干脆不实现那个类型类。 data 和 newtype 的区别 newtype 关键字给现有类型一个不同的身份,相比起 data,它使用时的限制更多。具体来讲,newtype 只能有一个值构造器, 并且这个构造器只能有一个字段。

-- file: ch06/NewtypeDiff.hs
-- 可以:任意数量的构造器和字段
data TwoFields = TwoFields Int Int

-- 可以:一个字段
newtype Okay = ExactlyOne Int

-- 可以:使用类型变量
newtype Param a b = Param (Either a b)

-- 可以:使用记录语法
newtype Record = Record {
        getInt :: Int
    }

-- 不可以:没有字段
newtype TooFew = TooFew

-- 不可以:多于一个字段
newtype TooManyFields = Fields Int Int

-- 不可以:多于一个构造器
newtype TooManyCtors = Bad Int
                     | Worse Int

除此之外,data 和 newtype 还有一个重要区别。由 data 关键字创建的类型在运行时有一个簿记开销,如记录某个值是用哪个构造器创建的。而 newtype 只有一个构造器,所以不需要这个额外开销。这使得它在运行时更省时间和空间。

由于 newtype 的构造器只在编译时使用,运行时甚至不存在,用 newtype 定义的类型和用 data 定义的类型在匹配 undefined 时会有不同的行为。

为了理解它们的不同点,我们首先回顾一下普通数据类型的行为。我们已经非常熟悉,在运行时对 undefined 求值会导致崩溃。

Prelude> undefined
*** Exception: Prelude.undefined

我们把 undefined 放进 D 构造器创建一个 DataInt,然后对它进行模式匹配。

*Main> case (D undefined) of D _ -> 1
1

由于我们的模式匹配只匹配构造器而不管里面的值,undefined 未被求值,因而不会抛出异常。

下面的例子没有使用 D 构造器,因而模式匹配时 undefined 被求值,异常抛出。

*Main> case undefined of D _ -> 1
*** Exception: Prelude.undefined

当我们用 N 构造器创建 NewtypeInt 值时,它的行为与使用 DataInt 类型的 D 构造器相同:没有异常。

*Main> case (N undefined) of N _ -> 1
1

但当我们把表达式中的 N 去掉,并对 undefined 进行模式匹配时,关键的不同点来了。

*Main> case undefined of N _ -> 1
1

没有崩溃!由于运行时不存在构造器,匹配 N 实际上就是在匹配通配符 :由于通配符总可以被匹配,所以表达式是不需要被求值的。

命名类型的三种方式

这里简要回顾一下 haskell 引入新类型名的三种方式。

  • data 关键字定义一个真正的代数数据类型。
  • type 关键字给现有类型定义别名。类型和别名可以通用。
  • newtype 关键字给现有类型定义一个不同的身份(distinct identity)。原类型和新类型不能通用。

JSON typeclasses without overlapping instances

可怕的单一同态限定(monomorphism restriction)

Haskell 98 有一个微妙的特性可能会在某些意想不到的情况下“咬”到我们。下面这个简单的函数展示了这个问题。

-- file: ch06/Monomorphism.hs
myShow = show

如果我们试图把它载入 ghci,会产生一个奇怪的错误:

Prelude> :l Monomorphism.hs

[1 of 1] Compiling Main             ( Monomorphism.hs, interpreted )

Monomorphism.hs:2:10:
    No instance for (Show a0) arising from a use of ‘show’
    The type variable ‘a0’ is ambiguous
    Relevant bindings include
        myShow :: a0 -> String (bound at Monomorphism.hs:2:1)
    Note: there are several potential instances:
        instance Show a => Show (Maybe a) -- Defined in ‘GHC.Show’
        instance Show Ordering -- Defined in ‘GHC.Show’
        instance Show Integer -- Defined in ‘GHC.Show’
        ...plus 22 others
    In the expression: show
    In an equation for ‘myShow’: myShow = show
    Failed, modules loaded: none.

[译注:译者得到的输出和原文有出入,这里提供的是使用最新版本 GHC 得到的输出。] 错误信息中提到的 “monomorphism” 是 Haskell 98 的一部分。 单一同态是多态(polymorphism)的反义词:它表明某个表达式只有一种类型。 Haskell 有时会强制使某些声明不像我们预想的那么多态。 我们在这里提单一同态是因为尽管它和类型类没有直接关系,但类型类给它提供了产生的环境。 Note 在实际代码中可能很久都不会碰到单一同态,因此我们觉得你没必要记住这部分的细节, 只要在心里知道有这么回事就可以了,除非 GHC 真的报告了跟上面类似的错误。 如果真的发生了,记得在这儿曾读过这个错误,然后回过头来看就行了。 我们不会试图去解释单一同态限制。Haskell 社区一致同意它并不经常出现;它解释起来很棘手(tricky); 它几乎没什么实际用处;它唯一的作用就是坑人。举个例子来说明它为什么棘手:尽管上面的例子违反了这个限制, 下面的两个编译起来却毫无问题。

-- file: ch06/Monomorphism.hs
myShow2 value = show value

myShow3 :: (Show a) => a -> String
myShow3 = show

上面的定义表明,如果 GHC 报告单一同态限制错误,我们有三个简单的方法来处理。

  • 显式声明函数参数,而不是隐性。
  • 显式定义类型签名,而不是依靠编译器去推导。
  • 不改代码,编译模块的时候用上 NoMonomorphismRestriction 语言扩展。它取消了单一同态限制。

没人喜欢单一同态限制,因此几乎可以肯定的是下一个版本的 Haskell 会去掉它。但这并不是说加上 NoMonomorphismRestriction 就可以一劳永逸:有些编译器(包括一些老版本的 GHC)识别不了这个扩展,但用另外两种方法就可以解决问题。如果这种可移植性对你不是问题,那么请务必打开这个扩展。

结论

在这章,你学到了类型类有什么用以及怎么用它们。我们讨论了如何定义自己的类型类,然后又讨论了一些 Haskell 库里定义的类型类。最后,我们展示了怎么让 Haskell 编译器给你的类型自动派生出某些类型类实例。

以上内容是否对您有帮助:
在线笔记
App下载
App下载

扫描二维码

下载编程狮App

公众号
微信公众号

编程狮公众号