Haskell/类与类型
在类型基础II中,我们简要地接触了类型类,它是用于数字类型的机制。然而,正如我们当时暗示的那样,类还有许多其他用途。
广义地说,类型类的目的是确保某些操作可用于所选类型的值。例如,如果我们知道一个类型属于(或者,用行话来说,实例化)类Fractional,那么我们保证能够使用其值执行实数除法。[1]
到目前为止,我们已经看到了现有类型类如何在签名中出现,例如
(==) :: (Eq a) => a -> a -> Bool
现在是时候转换视角了。首先,我们引用Prelude中Eq类的定义
class  Eq a  where
   (==), (/=) :: a -> a -> Bool
       -- Minimal complete definition:
       --      (==) or (/=)
   x /= y     =  not (x == y)
   x == y     =  not (x /= y)
该定义指出,如果要使类型a成为类Eq的实例,则它必须支持函数(==)和(/=) - 类方法 - 它们都具有类型a -> a -> Bool。此外,该类提供(==)和(/=)的默认定义相互之间。因此,Eq中的类型无需提供这两个定义 - 给定其中一个,另一个将自动生成。
定义了类之后,我们继续使现有类型成为其实例。这是一个将代数数据类型通过实例声明变为Eq实例的任意示例
data Foo = Foo {x :: Integer, str :: String}
 
instance Eq Foo where
   (Foo x1 str1) == (Foo x2 str2) = (x1 == x2) && (str1 == str2)
现在我们可以像往常一样对Foo值应用(==)和(/=)
*Main> Foo 3 "orange" == Foo 6 "apple" False *Main> Foo 3 "orange" /= Foo 6 "apple" True
一些重要的说明
- 类Eq在标准Prelude中定义。此代码示例定义了类型Foo,然后将其声明为Eq的实例。这三个定义(类、数据类型和实例)是完全独立的,并且没有关于它们如何分组的规则。这双向有效:您可以同样轻松地创建一个新的类Bar,然后声明类型Integer成为其实例。
- 类不是类型,而是类型的类别,因此类的实例是类型而不是值。[2]
- Foo的- (==)定义依赖于其字段(即- Integer和- String)的值也是- Eq的成员这一事实。事实上,Haskell 中几乎所有类型都是Eq的成员(最显着的例外是函数)。
- 使用type关键字定义的类型别名不能成为类的实例。
由于值之间的相等性测试很常见,因此在任何实际程序中创建的大多数数据类型都应该是Eq的成员。其中许多也将是Prelude中其他类的成员,例如Ord和Show。为了避免为每个新类型编写大量样板代码,Haskell 提供了一种方便的方法来使用关键字声明“明显的”实例定义deriving。所以,Foo将写成
data Foo = Foo {x :: Integer, str :: String}
    deriving (Eq, Ord, Show)
这使得Foo成为Eq的实例,并自动生成==的定义,与我们刚刚编写的完全相同,并且也使其成为Ord和Show的实例。
你只能使用deriving与一组有限的内置类一起使用,这些类在下面非常简要地描述
- Eq
- 相等运算符==和/=
- Ord
- 比较运算符< <= > >=; min, max,和compare.
- Enum
- 仅用于枚举。允许使用列表语法,例如[Blue .. Green].
- Bounded
- 也用于枚举,但也可用于只有一个构造函数的类型。提供minBound和maxBound作为类型可以取的最低和最高值。
- Show
- 定义函数show,它将值转换为字符串,以及其他相关函数。
- Read
- 定义函数read,它将字符串解析为该类型的值,以及其他相关函数。
语言报告中给出了派生相关函数的精确规则。但是,它们通常可以被认为在大多数情况下是“正确的事情”。数据类型内部元素的类型也必须是您正在派生的类的实例。
为一组有限的预定义类提供特殊的“魔法”函数合成违反了 Haskell 的普遍理念“内置事物并不特殊”,但这确实节省了很多输入。除此之外,派生实例可以阻止我们以错误的方式编写它们(例如,Eq的一个实例,使得x == y不等于y == x将是完全错误的)。[3]
类可以从其他类继承。例如,以下是Prelude中Ord的主要部分定义
class  (Eq a) => Ord a  where
    compare              :: a -> a -> Ordering
    (<), (<=), (>=), (>) :: a -> a -> Bool
    max, min             :: a -> a -> a
实际定义要长得多,并且包括大多数函数的默认实现。这里的重点是Ord继承自Eq。这由第一行中的=>表示法表示,它反映了类在类型签名中出现的方式。在这里,这意味着对于一个类型要成为Ord的实例,它也必须是Eq的实例,因此需要实现==和/=操作。[4]
一个类可以从多个其他类继承:只需将所有其超类放在=>之前的括号中即可。让我们用另一个Prelude引用来说明这一点
class  (Num a, Ord a) => Real a  where
    -- | the rational equivalent of its real argument with full precision
    toRational          ::  a -> Rational
此图改编自 Haskell 报告,显示了标准 Prelude 中类和类型之间的关系。粗体名称是类,而非粗体文本代表每个类的实例类型((->)指的是函数,而[]指的是列表)。连接类的箭头表示继承关系,指向继承类。

准备好所有部分后,我们可以通过返回本书中涉及类的第一个示例来完整地循环一遍
(+) :: (Num a) => a -> a -> a
(Num a) =>是类型约束,它将类型a限制为类Num的实例。事实上,(+)是Num的一个方法,以及其他一些函数(特别是(*)和(-);但不是(/))。
您可以在这样的类型签名中设置多个限制
foo :: (Num a, Show a, Show b) => a -> a -> b -> String
foo x y t = 
   show x ++ " plus " ++ show y ++ " is " ++ show (x+y) ++ ".  " ++ show t
这里,参数x和y必须是相同的类型,并且该类型必须同时是Num和Show的实例。此外,最终参数t必须是某种(可能不同的)类型,该类型也是Show此示例也清晰地展示了约束如何从定义中使用的函数(在本例中为(+)和show)传播到正在定义的函数。
除了简单的类型签名之外,类型约束还可以引入到许多其他地方
- instance声明(通常用于参数化类型);
- class声明(约束可以像往常一样在方法签名中引入,用于除定义类的类型变量之外的任何类型变量[5]);
- data声明,[6] 它们充当构造函数签名的约束。
注意
data声明中的类型约束不像乍看起来那么有用。考虑一下
data (Num a) => Foo a = F1 a | F2 a String
这里,Foo是一种具有两个构造函数的类型,这两个构造函数都接受类型为a的参数,该参数必须在Num中。但是,(Num a) =>约束仅对F1和F2构造函数有效,而不是对涉及Foo的其他函数有效。因此,在以下示例中...
fooSquared :: (Num a) => Foo a -> Foo a
fooSquared (F1 x)   = F1 (x * x)
fooSquared (F2 x s) = F2 (x * x) s
... 即使构造函数确保a将是Num中的某种类型,我们也无法避免在fooSquared的签名中重复约束。[7]
为了更好地了解类型、类和约束之间的相互作用,我们将提供一个非常简单且有些牵强的示例。我们将定义一个Located类,一个继承自它的Movable类,以及一个具有Movable约束的函数,该函数使用父类的(即Located)方法实现。
-- Location, in two dimensions.
class Located a where
    getLocation :: a -> (Int, Int)
class (Located a) => Movable a where
    setLocation :: (Int, Int) -> a -> a
-- An example type, with accompanying instances.
data NamedPoint = NamedPoint
    { pointName :: String
    , pointX    :: Int
    , pointY    :: Int
    } deriving (Show)
instance Located NamedPoint where
    getLocation p = (pointX p, pointY p)
instance Movable NamedPoint where
    setLocation (x, y) p = p { pointX = x, pointY = y }
-- Moves a value of a Movable type by the specified displacement.
-- This works for any movable, including NamedPoint.
move :: (Movable a) => (Int, Int) -> a -> a
move (dx, dy) p = setLocation (x + dx, y + dy) p
    where
    (x, y) = getLocation p
不要对上面提到的Movable示例过度解读;它仅仅是类相关语言特性的一个演示。认为每个可能被概括的功能,例如setLocation,都需要一个它自己的类型类,这是一个错误。特别是,如果所有Located实例都应该能够移动,那么Movable就是不必要的——如果只有一个实例,则根本不需要类型类!当有多个类型实例化它(或者您期望其他人编写其他实例)并且您不希望用户知道或关心类型之间的差异时,最好使用类。一个极端的例子是Show:由大量类型实现的通用功能,在调用show之前,您不需要了解任何有关这些类型的信息。在接下来的章节中,我们将探讨库中的一些重要类型类;它们提供了适合于类的功能类型的良好示例。
注释
- ↑ 对于来自面向对象语言的程序员:Haskell 中的类很可能不是您期望的——不要让术语混淆您。虽然类型类的一些用途类似于抽象类或 Java 接口的用途,但存在一些根本的区别,随着我们的深入,这些区别将变得清晰。
- ↑ 这是与大多数面向对象语言的主要区别,在面向对象语言中,类本身也是一种类型。
- ↑ 有一些方法可以使魔法适用于其他类。GHC 扩展允许为一些其他常用类deriving,这些类只有一种编写实例的正确方法,并且 GHC 通用机制可以自动为自定义类生成实例。
- ↑ 如果您查看Prelude规范中的完整定义,原因就会变得很清楚:默认实现涉及将(==)应用于正在比较的值。
- ↑ 定义类的类型的约束应通过类继承设置。
- ↑ 以及newtype声明,但不包括type。
- ↑ 好奇者的额外说明:此问题与高级跟踪的“类型乐趣”章节中讨论的一些高级功能解决的问题相关。