跳转至内容

Haskell/类与类型

来自Wikibooks,开放世界中的开放书籍

类型基础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(==)定义依赖于其字段(即IntegerString)的值也是Eq的成员这一事实。事实上,Haskell 中几乎所有类型都是Eq的成员(最显着的例外是函数)。
  • 使用type关键字定义的类型别名不能成为类的实例。

由于值之间的相等性测试很常见,因此在任何实际程序中创建的大多数数据类型都应该是Eq的成员。其中许多也将是Prelude中其他类的成员,例如OrdShow。为了避免为每个新类型编写大量样板代码,Haskell 提供了一种方便的方法来使用关键字声明“明显的”实例定义deriving。所以,Foo将写成

data Foo = Foo {x :: Integer, str :: String}
    deriving (Eq, Ord, Show)

这使得Foo成为Eq的实例,并自动生成==的定义,与我们刚刚编写的完全相同,并且也使其成为OrdShow的实例。

你只能使用deriving与一组有限的内置类一起使用,这些类在下面非常简要地描述

Eq
相等运算符==/=
Ord
比较运算符< <= > >=; min, max,和compare.
Enum
仅用于枚举。允许使用列表语法,例如[Blue .. Green].
Bounded
也用于枚举,但也可用于只有一个构造函数的类型。提供minBoundmaxBound作为类型可以取的最低和最高值。
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

这里,参数xy必须是相同的类型,并且该类型必须同时是NumShow的实例。此外,最终参数t必须是某种(可能不同的)类型,该类型也是Show此示例也清晰地展示了约束如何从定义中使用的函数(在本例中为(+)show)传播到正在定义的函数。

其他用途

[编辑 | 编辑源代码]

除了简单的类型签名之外,类型约束还可以引入到许多其他地方

  • instance声明(通常用于参数化类型);
  • class声明(约束可以像往常一样在方法签名中引入,用于除定义类的类型变量之外的任何类型变量[5]);
  • data声明,[6] 它们充当构造函数签名的约束。

注意

data声明中的类型约束不像乍看起来那么有用。考虑一下

data (Num a) => Foo a = F1 a | F2 a String

这里,Foo是一种具有两个构造函数的类型,这两个构造函数都接受类型为a的参数,该参数必须在Num中。但是,(Num a) =>约束仅对F1F2构造函数有效,而不是对涉及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之前,您不需要了解任何有关这些类型的信息。在接下来的章节中,我们将探讨库中的一些重要类型类;它们提供了适合于类的功能类型的良好示例。

注释

  1. 对于来自面向对象语言的程序员:Haskell 中的类很可能不是您期望的——不要让术语混淆您。虽然类型类的一些用途类似于抽象类或 Java 接口的用途,但存在一些根本的区别,随着我们的深入,这些区别将变得清晰。
  2. 这是与大多数面向对象语言的主要区别,在面向对象语言中,类本身也是一种类型。
  3. 有一些方法可以使魔法适用于其他类。GHC 扩展允许为一些其他常用类deriving,这些类只有一种编写实例的正确方法,并且 GHC 通用机制可以自动为自定义类生成实例。
  4. 如果您查看Prelude规范中的完整定义,原因就会变得很清楚:默认实现涉及将(==)应用于正在比较的值。
  5. 定义类的类型的约束应通过类继承设置。
  6. 以及newtype声明,但不包括type
  7. 好奇者的额外说明:此问题与高级跟踪的“类型乐趣”章节中讨论的一些高级功能解决的问题相关。
华夏公益教科书