第 6 章:使用类型类

优质
小牛编辑
130浏览
2023-12-01

类型类(typeclass)跻身于 Haskell 最强大功能之列: 它们(typeclasses)允许你定义通用接口,而其(这些接口)为各种不同的类型(type)提供一组公共特性集。 类型类是某些基本语言特性的核心,比如相等性测试(equality testing)和数值操作符(numeric operators)。 在讨论到底类型类是什么之前,我想解释下他们的作用(the need for them)。

类型类的作用

假设因为某个原因, Haskell 语言的设计者拒绝实现相等性测试 == , 因此我们决定实现自己的 == 操作。 你的应用由一个简单的 Color 类型组成。 首先你尝试一下,像这样:

-- file: ch06/naiveeq.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> :l naiveeq.hs
[1 of 1] Compiling Main             ( naiveeq.hs, interpreted )
Ok, modules loaded: Main.
*Main> colorEq Green Green
True
*Main> colorEq Red Red
True
*Main> colorEq Red Green
False

现在,假设你想要添加 String 的相等性测试(equality testing)。 因为一个 HaskellString 其实是字符们(characters)的列表(即[char]),所以我们可以写一个小函数来运行那个测试(相等性测试)。 为了简单(偷懒)起见,我们作一下弊:使用 == 操作符。

-- file: ch06/naiveeq.hs
stringEq :: [Char] -> [Char] -> Bool

-- Match if both are empty
stringEq [] [] = True

-- If both start with the same char, check the rest
stringEq (x:xs) (y:ys) = x == y && stringEq xs ys

-- Everything else doesn't match
stringEq _ _ = False

让我们运行一下:

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

*Main> stringEq "" ""
True

*Main> stringEq "" []
True

*Main> stringEq "" [""]
<interactive>:5:14:
Couldn't match expected type `Char' with actual type `[Char]'
In the expression: ""
In the second argument of `stringEq', namely `[""]'
In the expression: stringEq "" [""]

现在你应该能看出一个问题了吧:我们不得不为各个不同类型(type)实现一坨带有不同名字的函数(function),以便我们有能力用其进行比较。 这种做法非常低效,而且烦人。 如果我们能用 == 对比任何类型的值,就再方便不过了。

同时,我们能定义一些通用(generic)函数,比如基于 ==/= , 其能对几乎任何东西(anything)合法。 通过写一个通用函数,其能比较所有的东西,也能使我们的代码一般化(generic):如果一段代码仅需要比较(compare)一些东西,然后他应该就能够接受任何数据类型,而对其(这些类型)编译器是知道如何比较的。

而且更进一步,如果以后新类型被添加进来,现有的代码不应该被修改。 而Haskell 的类型类(typeclass)就是被设计成处理上面的这些破事的。

什么是类型类?

类型类定义了一系列函数,而这些函数对于不同类型的值使用不同的函数实现。 它和面向对象(object-oriented)语言的对象(objects)有些类似,但是他们是完全不同的。

[huangz,labyrlnth,YenvY等译者注:这里原文是将“面向对象编程中的对象”和 Haskell 的类型类进行类比,但实际上这种类比并不太恰当,类比成接口和多态方法更适合一点。] [sancao2译注:我觉得作者不是不知道类型类应该与接口和多态方法类比,他这么说的原因是下面他自己的注释”When is a class not a class?”里面说的,因为类型类的关键词是class,传统面向对象编程里面的关键词也是class。]

让我们使用类型类来解决我们章节前面相等性测试的困局。 首先,我们定义类型类本身。 我们需要一个函数,其接受两个参数。 每个参数拥有相同的类型,然后返回一个 Bool 类型以指示他们是否相等。 我们不关心这些类型到底是什么,但我需要的是同一个类型的两项(items)。

下面是我们的类型的初定义:

-- file: ch06/eqclasses.hs
class BasicEq a where
    isEqual :: a -> a -> Bool

这个定义说,我们申明(使用 class 关键字)了一个类型类(typeclass),其名字叫 BasicEq 。 接着我们将引用(refer to)实例类型(instance types),带着字母 a 作名字。 一个类型类的实例类型可以是任何类型,只要其(实例类型)实现了类型类中定义的函数。 这个类型类定义了一个函数(isEqual),而这个函数接受两个参数,他们(这俩参数)对应于实例类型即 a ,并且返回一个 Bool 型。

在定义的第一行,参数(实例类型)的名字是任选的。 就是说,我们能使用任意名字。 关键之处在于,当我们列出函数的类型时,我们必须使用相同的名字引用实例类型们(instance types)。 比如说,我们使用 a 来表示实例类型,那么函数签名中也必须使用 a 来代表这个实例类型。

让我们在 ghci 看一下 isEqual 的类型。 回想一下,在 ghci 我们能用 :type (简写 :t )来查看某些东西的类型。

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

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

这种方式让我们读出:"对于所有的类型 a ,只要 aBasicEq 的一个实例, isEqual 就能接受两个类型为 a 的参数,并返回一个 Bool 。" 让我们快速地浏览一遍为某个特定类型定义的 isEqual 吧。

-- file: ch06/eqclasses.hs
instance BasicEq Bool where
    isEqual True  True  = True
    isEqual False False = True
    isEqual _     _     = False

你能用 ghci 来验证我们基于 Bool 类的 isEqual , 而不是基于其他实例类型的。

*Main> isEqual True True
True

*Main> isEqual False True
False

*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"

注意,当我们试图比较两个字符串,ghci抱怨到,“我们没有提供基于 [Char] 实例类型的 BasicEq ,所以他不知道如何去比较 [Char] 。” 并且其建议(”Possible fix“)我们可以通过定义基于 [Char] 实例类型的 BasicEq

稍后的一节我们将会详细介绍定义实例(instances)。 不过,首先让我们继续看定义类型类(typeclass)。 在这个例子中,一个"不相等"(not-equal-to)函数可能很有用。 这里我们可以做的是,定义一个带两个函数的类型类(typeclass):

-- file: ch06/eqclasses.hs
class BasicEq2 a where
    isEqual2    :: a -> a -> Bool
    isNotEqual2 :: a -> a -> Bool

如果有人要提供一个 BasicEq2 的实例(instance),那么他将要定义两个函数: isEqual2isNotEqual2 。 当我们定义好以上的 BasicEq2 , 看起来我们为自己制造了额外的工作。 从逻辑上讲,如果我们知道 isEqual2isNotEqual2 返回的是什么,那么我们就可以知道另外一个函数的返回值,对于所有(输入)类型来说。 为了避免让类型类的用户为所有类型都定义两个函数,我们可以提供他们(两个函数)的默认实现。 然后,用户只要自己实现其中一个就可以了。 这里的例子展示了如何实现这种手法。

-- file: ch06/eqclasses.hs
class BasicEq3 a where
    isEqual3 :: a -> a -> Bool
    isEqual3 x y = not (isNotEqual3 x y)

    isNotEqual3 :: a -> a -> Bool
    isNotEqual3 x y = not (isEqual3 x y)

人们实现这个类型类必须提供至少一个函数的实现。 当然他们可以实现两个,如果他们乐意,但是他们不必被强制(这么做)。 虽然我们提供两个函数的默认实现,每个函数取决于另外一个来计算答案。 如果我们不指定至少一个,所产生的代码将是一个无尽循环。 因此,至少得有一个函数总是要被实现。

以下是将 Bool 作为 BasicEq3 实例类型的例子。

-- file: ch06/eqclasses.hs
instance BasicEq3 Bool where
    isEqual3 False False = True
    isEqual3 True  True  = True
    isEqual3 _     _     = False

我们只要定义 isEqual3 函数,就可以“免费”得到 isNotEqual3

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

*Main> isEqual True True
True

*Main> isEqual False False
True

*Main> isNotEqual False True
True

BasicEq3 ,我们提供了一个类型类(class),其行为类似于 Haskell 原生的 ==/= 操作符。 事实上,这些操作符本来就是被一个类型类定义的,其看起来几乎等价于 BasicEq3 。 “Haskell 98 Report”定义了一个类型类,它实现了相等性比较(equality comparison)。 这是内建类型类 Eq 的代码。 注意到他和我们的 BasicEq3 类型类多么相似呀。

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

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

定义类型类实例

现在你知道了怎么定义一个类型类,是时候学习一下怎么定义某个类型类的实例(instance)。 回忆一下那些用于创造某个特定类型类的实例的类型们(types),他们是通过实现对那个类型类必须的函数来实现的。 回忆一下我们位于章节前面的尝试(attemp),针对 Color 类型创造的相等性测试。

那么让我们看看我们要怎样创造同样的 Color 类型,作为 BasicEq3 类型类的一员。

-- file: ch06/naiveeq.hs
instance BasicEq3 Color where
    isEqual3 Red Red = True
    isEqual3 Blue Blue = True
    isEqual3 Green Green = True
    isEqual3 _ _ = False

注意,这里的函数定义和之前 “类型类的作用” 章节的 colorEq 函数定义实际上没有什么不同。 事实上,它的实现就是等价的。 然而,在本例中,我们能将 isEqual3 使用于*任何*类型上,只要其(该类型)声明成 BasicEq3 的一个实例(instance), 而不仅仅限于 Color 一类。 我们能定义相等性测试,针对任何东西,从数值到图形,通过采用相同的基本模式(basic pattern)的方式。 事实上,我们将会在 “相等性,有序和对比” 章节中看到,这就是你能使Haskell的 == 操作符作用于你自己的类型的方式。

还要注意到,虽然 BasicEq3 类型类定义了两个函数 isEqualisNotEqual , 但是我们只实现了其中的一个,在 Color 的例子中。 那得归功于包含于 BasicEq3 中的默认实现。 即使我们没有显式地定义 isNotEqual3 , 编译器也会自动地使用 BasicEq3 声明中的默认实现。

重要的内置类型类

前面两节我们分别讨论了(如何)定义你自己的类型类(typeclass),以及如何创造你自己的类型类实例(type instance)。

是时候介绍几个作为 Prelude 库一部分的类型类。 如本章开始时所说的,类型类处于 Haskell 语言某些重要特性的中心。 我们将讨论最常见的几个。 更多细节,”Haskell library reference” 是一个很好的资源。 其将给你介绍类型类,并且将一直告诉你什么函数是你必须要实现的以获得一份完整的定义。

Show

Show 类型类用于将值(values)转换为字符串(Strings),其最常用的(功能)可能是将数值(numbers)转换成字符串,但是他被定义成如此多类型以至于能转化相当多东西。 如果你已经定义了你自己的类型们(types),创造他们(types) Show 的实例,将会使他们能够在 ghci 中展示或者在程序中打印出来。 Show 类型类中最重要的函数是 show 。 其接受一个参数,以用于数据(data)转换,并返回一个 String ,以代表这个数据(data)。

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

让我们看看一些例子,关于转化数值到字符串的。

Main> show 1
"1"

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

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

记住 ghci 显示出结果,就像你进入一个Haskell的程序。 所以表达式 show 1 返回一个包含数字 1 的单字符的字符串。 即引号不是字符串本身的一部分。 我们将使用 putStrLn 明确这一点。

ghci> putStrLn (show 1)
1
ghci> putStrLn (show [1,2,3])
[1,2,3]

你也可以将 show 用在 String 上面。

ghci> show "Hello!"
"\"Hello!\""
ghci> putStrLn (show "Hello!")
"Hello!"
ghci> show ['H', 'i']
"\"Hi\""
ghci> putStrLn (show "Hi")
"Hi"
ghci> show "Hi, \"Jane\""
"\"Hi, \\\"Jane\\\"\""
ghci> putStrLn (show "Hi, \"Jane\"")
"Hi, \"Jane\""

运行 showString 之上,可能使你感到困惑。 因为 show 生成了一个结果,其相配(suitable)于Haskell的字面值(literal), 或者说, show 添加了引号和转义符号(“”),其适用于Haskell程序内部。 ghci 也用 show 来显示结果,所以引号和转义符号被添加了两次。 使用 putStrLn 能帮助你明确这种差异。

你能轻易地定义你自己的 Show 实例,如下。

-- file: ch06/naiveeq.hs
instance Show Color where
    show Red   = "Red"
    show Green = "Green"
    show Blue  = "Blue"

上面的例子定义了 Show 类型类的实例,其针对我们章节前面的定义的类型 Color

Note

Show 类型类

show 经常用于定义数据(data)的字符串(String)表示,其非常有利于机器使用用 Read 类型类解析回来。 Haskell程序员经常写自己的函数去格式化(format)数据以漂亮的方式为终端用户呈现,如果这种表示方式有别于 Show 预期的输出。

因此,如果你定义了一种新的数据类型,并且希望通过 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 的类型实例,可以解决以上问题:

-- file: ch06/naiveeq.hs
instance Show Color where
    show Red   = "Red"
    show Green = "Green"
    show Blue  = "Blue"

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

-- file: ch06/naiveeq.hs
instance Show Color where
    show Red   = "Color 1: Red"
    show Green = "Color 2: Green"
    show Blue  = "Color 3: Blue"

Read

Read 类型类,本质上 和 Show 类型类相反: 其(Read)最有用的函数是 read ,它接受一个字符串作为参数,对这个字符串进行解析(parse),并返回一个值。 这个值的类型为 Read 实例类型的成员(所有实例类型中的一种)。

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

这是一个例子,展示了 readshow 函数的用法:

-- file: ch06/read.hs
main = do
  putStrLn "Please enter a Double:"
  inpStr <- getLine
  let inpDouble = (read inpStr)::Double
  putStrLn ("Twice " ++ show inpDouble ++ " is " ++ show (inpDouble * 2))

测试结果如下:

Prelude> :l read.hs
[1 of 1] Compiling Main             ( read.hs, interpreted )
Ok, modules loaded: Main.
*Main> main
Please enter a Double:
123.213
Twice 123.213 is 246.426

这是一个简单的例子,关于 readshow。 请注意,我们给出了一个显式的 Double 类型,当运行 read 函数的时候。

那是因为 read 会返回任意类型的值(a value of type) Read a => a , 并且 show 期望任意类型的值 Show a => a 。 存在着许许多多类型(type),其拥有定义于 ReadShow 之上的实例(instance)。

不知道一个特定的类型,编译器必须从许多类型中猜出那个才是必须的(needed)。 在上面的这种情况下,他可能会经常选择 Integer 类型。 如果我们想要接受的是浮点输入,他就不会正常工作,所以我们提供了一个显式的类型。

Note

关于默认值的笔记

在大多数情况下,如果显式的 Double 类型标记被忽略了,编译器会拒绝猜测一个通用的类型,并仅仅返回一个错误。 他能默认以 Integer 类型这件事请是个特例。 他起因于以下事实:字面值 2 (在程序中 inpDouble * 2)被当成 Integer 除非他得到一个不同类型的期望。]

你能看到相同的效果在起作用,如果你试着在 ghci 命令行中使用 readghci 内部使用 show 来展示结果, 意味着你可能同样会碰到一样会碰到模棱两可的类型问题。 你将须要显式地指定类型于 read 的结果在 ghci 当中,如下。

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 函数的类型签名: (Read a) => String -> aa 在这里是 Read 类型类的任何实例类型。 其特定的解析函数被调用取决于 read 返回值的期望类型。 让我们看看他是怎么工作的。

ghci> (read "5.0")::Double
5.0
ghci> (read "5.0")::Integer
*** Exception: Prelude.read: no parse

注意到错误(将发生)当你试图解析 5.0 作为一个整数 Integer 。 解释器选择了一个不同的 Read 实例: 当返回值的期望是 Integer ,而他做的却是期望得到一个 DoubleInteger 的解析器不能接受小数点,从而抛出一个异常。

Read 类型提供了一些相当复杂的解析器。 你可以定义一个简单的解析器,通过提供 readsPrec 函数的实现。 你的实现能返回一个列表(list):该列表在解析成功时包含一个元组(tuple),在解析失败时为空。 下面是一个实现的例子。

-- file: ch06/naiveeq.hs
instance Read Color where
    -- readsPrec is the main function for parsing input
    readsPrec _ value =
        -- We pass tryParse a list of pairs.  Each pair has a string
        -- and the desired return value.  tryParse will try to match
        -- the input to one of these strings.
        tryParse [("Red", Red), ("Green", Green), ("Blue", Blue)]
        where tryParse [] = []    -- If there is nothing left to try, fail
              tryParse ((attempt, result):xs) =
                   -- Compare the start of the string to be parsed to the
                   -- text we are looking for.
                   if (take (length attempt) value) == attempt
                      -- If we have a match, return the result and the
                      -- remaining input
                      then [(result, drop (length attempt) value)]
                      -- If we don't have a match, try the next pair
                      -- in the list of attempts.
                      else tryParse xs

运行测试一下:

*Main> :l naiveeq.hs
[1 of 1] Compiling Main             ( naiveeq.hs, interpreted )
Ok, modules loaded: Main.
*Main> (read "Red")::Color
Color 1: Red
*Main> (read "Green")::Color
Color 2: Green
*Main> (read "Blue")::Color
Color 3: Blue
*Main> (read "[Red]")::Color
*** Exception: Prelude.read: no parse
*Main> (read "[Red]")::[Color]
[Color 1: Red]
*Main> (read "[Red,Green,Blue]")::[Color]
[Color 1: Red,Color 2: Green,Color 3: Blue]
*Main> (read "[Red, Green, Blue]")::[Color]
*** Exception: Prelude.read: no parse

注意到最后的尝试产生了错误。 那是因为我们的编译器没有聪明到可以处理置位(leading,包括前置和后置)的空格。 你可以改进他,通过些改你的 Read 实例以忽略任何置位的空格。 这在Haskell程序中是常见的做法。

使用 ReadShow 进行序列化

很多时候,程序需要将内存中的数据保存为硬盘上的文件以备将来获取,或者通过网络发送出去。 把内存中的数据转化成为,为存储目的,序列的过程,被称为 序列化

通过将类型实现为 ReadShow 的实例类型, readshow 两个函数可以成为非常好的序列化工具。 show 函数生成的输出是人类和机器皆可读的。 大部分 show 输出也是对Haskell语法合法的,虽然他取决于人们如何写 Show 实例来达到这个结果。

Note

解析超大(large)字符串

字符串处理在Haskell中通常是惰性的,所以 readshow 能被无意外地用于很大的数据结构。 Haskell中内建的 readshow 实例被实现成高效的纯函数。 如果想知道怎么处理解析的异常,请参考”19章 错误处理”。

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

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

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

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

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

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

$ 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 位整数,到任意精度的有理数,无所不包。 你可能知道操作符(比如 (+))能作用于所有的这些类型。 这个特性是用类型(typeclass)类实现的。 作为附带的好处, 他(Haskell)允许你定义自己的数值类型,并且把他们当做Haskell的一等公民(first-class citizens)。

让我们开始讨论,关于围绕在数值类型(numberic types)周围的类型类们(typeclass),用以类型们(type)本身的检查(examination)。 以下表格显示了 Haskell 中最常用的一些数值类型。 请注意,存在这更多数值类型用于特定的目的,比如提供接口给 C


表格 6.1 : 部分数值类型

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

这是相当多的数值类型。 存在这某些操作符,比如加号 (+) ,其能在他们中的所有之上工作。 另外的一部分函数,比如 asin ,只能用于浮点数类型。

以下表格汇总了操作(operate)于不同类型的不同函数。 当你读到表,记住,Haskell操作符们(operators)只是函数。 你可以通过 (+) 2 3 或者 2 + 3 得到相同的结果。 按照惯例,当讲操作符当做函数时,他们被写在括号中,如下表 6.2。


表格 6.2 : 部分数值函数和常量

类型模块描述
(+)Num a => a -> a -> aPrelude加法
(-)Num a => a -> a -> aPrelude减法
(*)Num a => a -> a -> aPrelude乘法
(/)Fractional a => a -> a -> aPrelude份数除法
(**)Floating a => a -> a -> aPrelude乘幂
(^)(Num a, Integral b) => a -> b -> aPrelude计算某个数的非负整数次方
(^^)(Fractional a, Integral b) => a -> b -> aPrelude分数的任意整数次方
(%)Integral a => a -> a -> Ratio aData.Ratio构成比率
(.&.)Bits a => a -> a -> aData.Bits二进制并操作
(.|.)Bits a => a -> a -> aData.Bits二进制或操作
absNum a => a -> aPrelude绝对值操作
approxRationalRealFrac a => a -> a -> RationalData.Ratio通过分数的分子和分母计算出近似有理数
cosFloating a => a -> aPrelude余弦函数。另外还有 acos 、 cosh 和 acosh ,类型和 cos 一样。
divIntegral a => a -> a -> aPrelude整数除法,总是截断小数位。
fromIntegerNum a => Integer -> aPrelude将一个 Integer 值转换为任意数值类型。
fromIntegral(Integral a, Num b) => a -> bPrelude一个更通用的转换函数,将任意 Integral 值转为任意数值类型。
fromRationalFractional a => Rational -> aPrelude将一个有理数转换为分数。可能会有精度损失。
logFloating a => a -> aPrelude自然对数算法。
logBaseFloating a => a -> a -> aPrelude计算指定底数对数。
maxBoundBounded a => aPrelude有限长度数值类型的最大值。
minBoundBounded a => aPrelude有限长度数值类型的最小值。
modIntegral a => a -> a -> aPrelude整数取模。
piFloating a => aPrelude圆周率常量。
quotIntegral a => a -> a -> aPrelude整数除法;商数的分数部分截断为 0 。
recipFractional a => a -> aPrelude分数的倒数。
remIntegral a => a -> a -> aPrelude整数除法的余数。
round(RealFrac a, Integral b) => a -> bPrelude四舍五入到最近的整数。
shiftBits a => a -> Int -> aBits输入为正整数,就进行左移。如果为负数,进行右移。
sinFloating a => a -> aPrelude正弦函数。还提供了 asin 、 sinh 和 asinh ,和 sin 类型一样。
sqrtFloating a => a -> aPrelude平方根
tanFloating a => a -> aPrelude正切函数。还提供了 atan 、 tanh 和 atanh ,和 tan 类型一样。
toIntegerIntegral a => a -> IntegerPrelude将任意 Integral 值转换为 Integer
toRationalReal a => a -> RationalPrelude从实数到有理数的有损转换
truncate(RealFrac a, Integral b) => a -> bPrelude向着零截断
xorBits a => a -> a -> aData.Bits二进制异或操作

“数值类型及其对应的类型类” 列举在下表 6.3。


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

类型BitsBoundedFloatingFractionalIntegralNumRealRealFrac
Double  XX XXX
Float  XX XXX
IntXX  XXX 
Int16XX  XXX 
Int32XX  XXX 
Int64XX  XXX 
IntegerX   XXX 
Rational or any Ratio   X XXX
WordXX  XXX 
Word16XX  XXX 
Word32XX  XXX 
Word64XX  XXX 

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


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

源类型目标类型
Double, FloatInt, WordIntegerRational
Double, Float Int, Word Integer RationalfromRational . toRational fromIntegral fromIntegral fromRationaltruncate * fromIntegral fromIntegral truncate *truncate * fromIntegral N/A truncate *toRational fromIntegral fromIntegral N/A

6.4 表中 * 代表除了 truncate (向着零截断) 之外,还可以使用 round (最近整数)、 ceiling (上取整)或者 floor (下取整)的类型。

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

相等性,有序和对比

我们已经讨论过了算术符号比如 (+) 能用到不同数字的所有类型。 但是Haskell中还存在着某些甚至更加广泛使用的操作符。 最显然地,当然,就是相等性测试: (==)(/=) ,这两操作符们都定义于 Eq 类(class)中。

存在着其他的比较操作符, 如 >=<= ,其则由 Ord 类型类定义。 他们(Ord)是放在于单独类中是因为存在着某些类型,比如 Handle ,使在这些地方相等性测试有意义(make sense),而表达特定的序(ording)一点意义都没有。

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

几乎所有 Haskell 内置类型都是 Eq 类型类的实例,而 Ord 类的实例类型也几乎一样多。

Tip

Ord 产生的排列顺序在某些时候是非常随意的, 比如对于 Maybe 而言, Nothing 就排在 Just x 之前, 这些都是随意决定的, 并没有什么特殊的意义。

自动派生

对于许多简单的数据类型, Haskell 编译器可以自动将类型派生(derivation)为 ReadShowBoundedEnumEqOrd 的实例(instance)。 这节省了我们大量的精力用于手动写代码进行比较或者显示他们的类型。

以下代码将 Color 类型派生为 ReadShowEqOrd 的实例:

-- file: ch06/colorderived.hs
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

Note

什么类型(types)能被自动派生?

Haskell标准要求编译器能自动派生这些指定类型类的实例。

注意 Color 类型的排序位置由定义类型时值构造器的排序决定,即对应上面例子就是 Red | Green | Blue 的顺序。

自动派生并不总是可用的。 比如说,如果定义类型 data MyType = MyType (Int -> Bool) ,那么编译器就没办法派生 MyTypeShow 的实例,因为它不知道该怎么渲染(render)一个函数。 在上面这种情况下,我们会得到一个编译错误。

当我们自动派生某个类型类的一个实例时,在我们利用 data 关键词声明参考这个实例的类型时,也必须是给定类型类的实例(手动或自动地)。

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

-- file: ch06/cant_ad.hs
data Book = Book

data BookInfo = BookInfo Book
                deriving (Show)

ghci 会给出提示,说明 Book 类型也必须是 Show 的实例, BookInfo 才能对 Show 进行自动派生(driving):

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 的实例:

-- file: ch06/ad.hs
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 用起来还不够简便。 这里是一段由的经过截断(truncate)和整齐化(tidy)之后的实际 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" ,我们就必须改变构造器,即我们使用它(JValue构造器)从 JNumber 构造器到 JString 构造器包装(wrap)数据。

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 类型的结构相同,但是不同于 Nothing (相对于 Maybe), “坏事情发生”构造器命名为 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(toJValue True))::Either JSONError Bool 。]

使用类型别名创建实例

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] 这种形式的类型,这个问题需要通过增加另外一条编译选项 {-# LANGUAGE FlexibleInstances #-} 来解决。]

[sancao2译注,若没有 {-# LANGUAGE FlexibleInstances #-} 这条编译选项,就会产生下面的结果。 其实编译器的 fix 提示给大家了。

Prelude> :l JSONClass.hs  ../ch05/SimpleJSON.hs
[1 of 2] Compiling SimpleJSON       ( ../ch05/SimpleJSON.hs, interpreted )
[2 of 2] Compiling Main             ( JSONClass.hs, interpreted )

JSONClass.hs:16:10:
   Illegal instance declaration for `JSON String'
      (All instance types must be of the form (T a1 ... an)
      where a1 ... an are *distinct type variables*,
      and each type variable appears at most once in the instance head.
      Use -XFlexibleInstances if you want to disable this.)
   In the instance declaration for `JSON String'
Failed, modules loaded: SimpleJSON.

]

[Forec译注:在 Haskell 8.0.1 中,即使不添加 {-# LANGUAGE TypeSynonymInstances #-} 也不会出现问题,但 {-# LANGUAGE FlexibleInstances #-} 这条编译选项仍然需要。]

生活在开放世界

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)。 如果我们有方法表示“这个类型类只存在这些实例”,那我们将得到一个封闭的世界。

我们希望把列表(list)转为 JSON 数组(array)。 我们现在还不用关心实现细节,所以让我们暂时使用 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

然而,一旦我们使用序对列表实例时,我们就”跑”(不是get,体会一下)进麻烦里面了(run in trouble)。

*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")]

[sancao2译注:上面的抱怨说的是匹配了两个实例,编译器不知道选择哪一个。 Matching instances: instance xxx, instance xxx 。]

重叠实例问题是由 Haskell 的”开放世界假设”的一个后果(a consequence)。 以下这个例子可以把问题展现得更清楚一些。

-- file: ch06/Overlap.hs
{-# LANGUAGE FlexibleInstances #-}
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 类型类实例应用于序对(for pairs):一个是 Int 序对,另一个是任意类型的序对,只要这个类型是 Borked 类型类的实例。

假设我们想把 bork 应用于 Int 序对。 为了这样做,编译器必须选择一个实例来用。 因为这些实例都是正确地紧挨着(right next to each other),所以它似乎可以选择更相关的(specific)的实例。

但是, GHC 在默认情况下是保守的,且坚持(insist)只有一个可能的GHC 能使用的实例 。 因此如果我们尝试使用 bork 的话, 那么它将报错。

Note

什么时候重叠实例要紧(matter)?

就像我们之前提到的,我们可以分散一个类型类的实例横跨于(across)几个模块中。 GHC 不会抱怨重叠实例的单单存在(mere existence)。 取而代之地,他会抱怨,只有当我们试图使用受影响的类型类的函数时,只有他被迫要去做决定采用哪个实例时。

放松(relex)类型类的一些限制

通常,我们不能写一个类型类实例,(仅)为了一个多态类型(polymorphic type)的特化版本(specialized version)。 [Char] 类型就是多态类型 [a] (其中的 a)特化成类型 Char 。 我们就这样被禁止声明 [Char] 为某个类型类的实例。 这”高度地”(highly)不方便,因为字符串无处不在于实际的代码中。

TypeSynonymInstances (“同义类型的实例”)语言扩展取消了这个限制,并允许我们写这样的实例。

GHC 支持另外一个有用的语言扩展, OverlappingInstances (覆盖实例)。 它解决(原文为address)了在处理重叠实例时候我们碰到的问题。 如果存在多个重叠的实例去从中选择,这个扩展会”采摘”(pick)最相关的(specific)那一个。

[Forec译注:在 Haskell 8.0 后, OverlappingInstances 已被抛弃,可替代的方法是在实例中加上 {-# OVERLAPPABLE #-} ,如:

instance {-# OVERLAPPABLE -#} Foo a => Foo [a] where
    foo = concat . intersperse ", " . map foo

]

我们经常使用这个扩展,同 TypeSynonymInstances 一起。 这里是一个例子。

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

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

如果我们应用(apply) fooString ,编译器会选择 String 相关的(specific)实现。 虽然我们有一个 Foo 的实例关于 [a]Char ,但关于 String 的实例更相关,所以 GHC 选择它。

即使 OverlappingInstances (覆盖实例)扩展出于使能状态(enabled),GHC仍将拒绝代码,若他找到一个以上等价地相关的(equally specific)实例。

Note

何时去使用 OverlappingInstances 扩展?

这是一个重要的点:GHC认为 OverlappingInstances 会影响一个实例的声明,而不是一个位置,于此(位置)我们使用一个实例。 换句话说,当我们定义一个实例,其(这个实例)我们希望能(被)允许覆盖(overlap)于其他实例的时候,我们必须使能(enable)该扩展(OverlappingInstances)为这个模块,而其(这个模块)包含着定义。 当他编译这个模块的时候,GHC会记录那个实例为"能被覆盖(overlap)以其他的模块"的。 一旦我们引入(import)这个模块而使用他的实例,我们将不需要使能(enable) OverlappingInstances 编译选项在引入模块的时候:GHC将已经知道这个实例是被标记为"对覆盖友好的"(okay to overlap),当他被定义的时候。 这种行为是很有用的,当我们在写一个库(library)的时候:我们能选择去创造可覆盖的(overlappable)实例,但是库的用户不必须使能(enable)任何特殊的语言扩展。

show是如何处理String的?

OverlappingInstances (覆盖实例)和 TypeSynonymInstances (“同义类型的实例”)语言扩展是特定于GHC的,而在定义上过去没有出现(present)于“Haskell 98”。 然而,大家熟悉的 Show 类型类,来自“Haskell 98”,以某种方法区别地”渲染”(render) Char 列表(list)和 Int 列表。 它达成这个(”区别地渲染”)通过一个聪明但简单的把戏(trick)。

Show 类型类定义了两个方法:一个 show 方法,用于渲染单值(one value)和一个 showList 方法,用于渲染值的列表。 而 showList 的默认实现,渲染一个列表,以使用中括号们和逗号们的方式。

Show 的实例对于 [a] 是使用 showList 实现的。 Show 的实例为 [Char] 提供一个特殊的 showList 实现。 其(该实现)使用双引号,并转义”非ASCII可打印”(non-ASCII-printable)的字符们。

[sancao2译注:上面那句 [Char] 原文没有 [] ,应该是错了。]

作为结果,如果有人对 [Char] 应用 show 函数,那么 showList 的实现会被选上,并且将会正确地渲染字符串,通过使用括号们。

至少有时,因而,我们就能克制对 OverlappingInstances (覆盖实例)扩展的需要,带着一点点(时间维度的)横向思维(lateral thinking)。

如何给类型以新身份(new 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 声明的目的是重命名一个存在着的类型,来给它一个独特的身份(id)。 像我们能看到的,它的用法和采用 data 关键字进行声明,在表面上很相似。

Note

type 和 newtype 关键字

尽管他们的名字是类似的, typenewtype 关键字有不同的目的。 type 关键字给了我们另一种方式以引用(refer to)某个类型,就像昵称之于一个朋友。 我们和编译器都知道 [Char]String 引用的是同一个类型。

比较起来(与 type), newtype 关键字存在,以隐藏一个类型的本性(nature)。 考虑一个 UniqueID 类型。

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

编译器会视 UniqueID 为 一个不同的类型于 Int 。 作为一个 UniqueID 的用户,我们只知道它有一个”唯一标识符”(Unique ID,英语字面意思);我们并不知道它被实现为一个 Int

当我们声明一个 newtype 时,我们必须选择哪个潜在类型的类型类实例,而对其(该实例)我们想要暴露。 在这里,我们决定让 NewtypeInt 提供 IntEqOrdShow 实例。 作为一个结果,我们可以比较和打印 NewtypeInt 类型的值。

*Main> N 1 < N 2
True

由于我们没有暴露 IntNumIntegral 实例, 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 没用自动派生(deriving)来暴露一个类型类的潜在(underlying)类型实现的话,我们是自由的,或者去写一个新实例,或者干脆留那个类型类处于不实现状态。

data 和 newtype 声明之间的区别

newtype 关键字存在着(exists)为了给现有类型以一个新的身份(id)。 它有更多的限制于其使用上,比起 data 关键字。 说白了, newtype 只能有一个值构造器,并且那个构造器须恰有一个字段(field)。

-- file: ch06/NewtypeDiff.hs
-- 可以:任意数量的构造器和字段(这里的两个Int为两个字段(fields))
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

在此之上,还有另一个重要的区别于 datanewtype 之间。 一个类型,由 data 关键字创建,有一个簿记保持(book-keeping)的开销在运行时。 例如,追踪(track)某个值是由哪个值构造器创造的。 而另一方面, newtype 只能有一个构造器,所以不需要这个额外开销。 这使得它在运行时更省时间和空间。

因为 newtype 的构造器只在编译时使用,运行时甚至不存在,所以对于用 newtype 定义的类型和那些用 data 定义的类型来说,类型匹配在 undefined 上的表现不同。

为了理解这个不同点,让我们首先回顾一下,我们可能期望一个普通类型的什么行为。 我们已经非常熟悉,如果在运行时 undefined 被求值会导致崩溃。

Prelude> undefined
*** Exception: Prelude.undefined

这里有一个类型匹配,在其(类型匹配)中我们采用 “D 构造器” 构造一个 DataInt ,然后放 undefined 在内部。

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

[sancao2译注:做这个实验要先加载“Newtype.hs”,其中定义了 。]

由于我们的模式匹配只对构造器而不检查载荷(payload), undefined 保持未被求值状态,因而不会导致一个异常被抛出。

在这个例子中,我们没有同时使用 D 构造器,因而未被保护的 undefined 会被求值。 当模式匹配发生时,我们抛出异常。

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

当我们使用 N 构造器以得到 NewtypeInt 值时,我们看到相同的行为:没有异常,就像使用 DataInt 类型的 D 构造器。

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

决定性的(crucial)差异发生了,当我们从表达式中去掉 N ,并匹配于一个未保护的 undefined 时。

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

我们没有崩溃!由于不存在构造器于运行时,对 N _ 的匹配实际上等效于对空白通配符 _ 的匹配:由于这个通配符( _ )总可以匹配,所以表达式不需要被求值。

Note

关于 newtype 构造器的另一种看法

虽然,我们使用值(value)构造器,以得到一个 newtype ,其方式等同于一个类型被定义而其采用 data 关键词。 两者所做的是强迫一个值(value)处于(between)他的“正常”(normal)类型和他的 newtype 类型之间。

换句话说,当我们应用(apply) N 于一个表达式,我们强迫一个表达式从 Int 类型到 NewtypeInt 类型,对我们(we)和编译器(compiler)而言,但是,完全地(absolutely),没有事情发生于运行时(runtime)。

类似地,当我们匹配 N 构造器于一个模式中,我们强制一个表达式从 NewtypeIntInt ,但是再次地不存在开销于运行时。

总结:三种命名类型的方式

这是一份简要重述(recap),关于 Haskell 的三种方式用来为类型提出(introduce)新名。

  • data 关键字提出(introduce)一个真正的代数(albegraic)数据类型。
  • type 关键字给我们一个别名(synonym)去用,为一个存在着的(existing)类型。 我们可以交换地(interchangeably)使用这个类型和他的别名,
  • newtype 关键字给予一个存在着的类型以一个独特的身份(distinct identity)。 这个原类型和这个新类型是不可交换的(interchangeable)。

JSON类型类,不带有重叠实例

启用GHC的重叠实例支持是一个让我们的JSON库工作的既有效又快速的方法。 在更复杂的场景中,我们有时被迫面对这样一种情况:某个类型类有多个相关程度相同(equally good)实例。 在这种情况下,重叠实例们将不会帮助我,而我们将需要代之以几处 newtype 声明。 为了弄明白这涉及到了什么,让我们重构(rework)我们的JSON类型类实例们以使用 newtype 代替重叠实例。

我们的第一个任务,是帮助编译器区分 [a][(String,[a])] 。 前者( [a] )我们用来表示JSON数组们(arrays),而后者( [(String, [a])] )用来表示JSON对象们(objects)。 他们是这些类型们,其给我们制造了麻烦于我们学会 OverlappingInstances (覆盖实例)之前。 我们包装了(wrap up)列表(list)类型,以至于编译器不会视其为一个列表。

-- file: ch06/JSONClass.hs
newtype JAry a = JAry {
      fromJAry :: [a]
      } deriving (Eq, Ord, Show)

当我们从自己的模块导出这个类型时,我们会导出该类型完整的细节。 我们的模块头部将看起来像这样:

-- file: ch06/JSONClassExport.hs
module JSONClass
    (
      JAry(..)
    ) where

紧跟着 Jary 的” (..) “,意思是“导出这个类型的所有细节”。

Note

一点稍微的偏差,相比于正常使用

通常地,当我们导出一个 newtype 的时候,我们 不会 导出这个类型的数据构造器,为了保持其细节的抽象(abstract)。 取而代之,我们会定义一个函数为我们应用(apply)该数据构造器。

-- file: ch06/JSONClass.hs
jary :: [a] -> JAry a
jary = JAry

于是,我们会导出类型构造器、解构函数和我们的构造函数,除了数据构造器。

-- file: ch06/JSONClassExport.hs
module JSONClass
    (
      JAry(fromJAry)
    , jary
    ) where

当我们没有导出一个类型的数据构造器,我们库的顾客们就只能使用我们提供的函数们去构造和解构该类型的值。 这个特性为我们,这些库作者们,提供了自由去改变类型的内部表示形式(represention),如果我们需要去(这么做)。

如果我们导出数据构造器,顾客们很可能开始依赖于它,比方说使用它(数据构造器)在一些模式中。 如果哪天我们希望去修改这个类型的内部构造,我们将冒险打破任意代码,而其(这些代码)使用着该数据构造器。

在我们这里的情况下,我们得不到什么额外的好处,通过让数组的包装器保持抽象,所以我们就干脆地导出该类型的整个定义。

我们提供另一个包装类型,而其隐藏了一个JSON对象的我们的表示形式(represention)。

-- file: ch06/JSONClass.hs
newtype JObj a = JObj {
      fromJObj :: [(String, a)]
    } deriving (Eq, Ord, Show)

带着这些定义好的类型,我们制造一些小改动到我们的 JValue 类型的定义。

-- file: ch06/JSONClass.hs
data JValue = JString String
            | JNumber Double
            | JBool Bool
            | JNull
            | JObject (JObj JValue)   -- was [(String, JValue)]
            | JArray (JAry JValue)    -- was [JValue]
              deriving (Eq, Ord, Show)

这个改动不会影响到 JSON 类型类的实例们,而那些我们已经写完。 但是我们还要为我们新的 JAryJObj 类型编写实例。

-- file: ch06/JSONClass.hs
jaryFromJValue :: (JSON a) => JValue -> Either JSONError (JAry a)

jaryToJValue :: (JSON a) => JAry a -> JValue

instance (JSON a) => JSON (JAry a) where
    toJValue = jaryToJValue
    fromJValue = jaryFromJValue

让我们缓慢地走过各个步骤,而这些步骤会转换一个 JAry a 到一个 JValue 。 给定一个列表,其中内部每一个元素都是一个 JSON 实例,转换它(前面的列表)到一个 JValue s 组成的列表是简单的。

-- file: ch06/JSONClass.hs
listToJValues :: (JSON a) => [a] -> [JValue]
listToJValues = map toJValue

取得这个值并包装他来得到一个 JAry JValue 的过程,实际上就是对其应用 newtype 的类型构造器。

-- file: ch06/JSONClass.hs
jvaluesToJAry :: [JValue] -> JAry JValue
jvaluesToJAry = JAry

(记住,这种做法没有任何性能代价。我们只是告诉编译器隐藏这个事实:我们正在使用一个列表。) 为了转化这个值成为一个 JValue ,我们应用另一个类型构造器。

::
– file: ch06/JSONClass.hs jaryOfJValuesToJValue :: JAry JValue -> JValue jaryOfJValuesToJValue = JArray

组装这些代码片段,通过使用函数组合(function composition),而我们得到一个简洁的单行(代码),用于转换得到一个 JValue

-- file: ch06/JSONClass.hs
jaryToJValue = JArray . JAry . map toJValue . fromJAry

我们有更多的工作去做来实现从 JValueJAry a 的转换,但是我们把它“碎裂”(break)成一些可重用的部分。 基本函数一目了然(straightforward)。

-- file: ch06/JSONClass.hs
jaryFromJValue (JArray (JAry a)) =
    whenRight JAry (mapEithers fromJValue a)
jaryFromJValue _ = Left "not a JSON array"

whenRight 函数会检查传给它的参数:如果第二个参数是用 Right 构造器创建的,以它为参数调用第一个参数指定的函数;如果第二个参数是 Left 构造器创建的,则将它保持原状返回,其它什么也不做。

-- file: ch06/JSONClass.hs
whenRight :: (b -> c) -> Either a b -> Either a c
whenRight _ (Left err) = Left err
whenRight f (Right a) = Right (f a)

mapEithers 函数要更复杂一些。 它的行为就像 map 函数,但如果它遇到一个 Left 值,会直接返回该值,而不会继续积累 Right 值构成的列表。

-- file: ch06/JSONClass.hs
mapEithers :: (a -> Either b c) -> [a] -> Either b [c]
mapEithers f (x:xs) = case mapEithers f xs of
                        Left err -> Left err
                        Right ys -> case f x of
                                      Left err -> Left err
                                      Right y -> Right (y:ys)
mapEithers _ _ = Right []

由于隐藏在 JObj 类型中的列表元素有更细碎的结构,相应的,在它和 JValue 类型之间互相转换的代码就会有点复杂。 万幸的是,我们可以重用刚刚定义过的函数。

-- file: ch06/JSONClass.hs
import Control.Arrow (second)

instance (JSON a) => JSON (JObj a) where
    toJValue = JObject . JObj . map (second toJValue) . fromJObj

    fromJValue (JObject (JObj o)) = whenRight JObj (mapEithers unwrap o)
      where unwrap (k,v) = whenRight ((,) k) (fromJValue v)
    fromJValue _ = Left "not a JSON object"

练习题

  1. ghci 中加载 Control.Arrow 模块,弄清 second 函数的功能。
  2. (,) 是什么类型?在 ghci 中调用它时,它的行为是什么? (,,) 呢?

可怕的单一同态限定(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.

错误信息中提到的 “monomorphism restriction” 是 Haskell 98 的一部分。 单一同态是多态(polymorphism)的反义词:它表明某个表达式只有一种类型。 Haskell 有时会强制使某些声明不像我们预想的那么多态。

我们在这里提单一同态是因为尽管它和类型类没有直接关系,但类型类给它提供了产生的环境。

Tip

在实际代码中可能很久都不会碰到单一同态,因此我们觉得你没必要记住这部分的细节, 只要在心里知道有这么回事就可以了,除非 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 编译器给你的类型自动派生出某些类型类实例。