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
。 - ↑ 好奇者的额外说明:此问题与高级跟踪的“类型乐趣”章节中讨论的一些高级功能解决的问题相关。