💎一站式轻松地调用各大LLM模型接口,支持GPT4、智谱、星火、月之暗面及文生图 广告
# 第六章:类型类 类型类(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 : 数字类型之间的转换** <table border="1" class="docutils"><colgroup><col width="15%"/><col width="29%"/><col width="15%"/><col width="16%"/><col width="24%"/></colgroup><tbody valign="top"><tr class="row-odd"><td rowspan="2">源类型</td><td colspan="4">目标类型</td></tr><tr class="row-even"><td>Double, Float</td><td>Int, Word</td><td>Integer</td><td>Rational</td></tr><tr class="row-odd"><td>Double, FloatInt, WordIntegerRational</td><td>fromRational . toRationalfromIntegralfromIntegralfromRational</td><td>truncate *fromIntegralfromIntegraltruncate *</td><td>truncate *fromIntegralN/Atruncate *</td><td>toRationalfromIntegralfromIntegralN/A</td></tr></tbody></table> * 除了 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 编译器给你的类型自动派生出某些类型类实例。