跳转至内容

Haskell/类型基础 II

来自维基教科书,开放世界中的开放书籍

在本章中,我们将展示 Haskell 如何处理数值类型,并介绍类型系统的一些重要特性。 不过,在深入文本之前,请暂停片刻,思考以下问题:(+) 函数的类型应该是什么?[1]

数学对我们可以加在一起的数字类型几乎没有限制。 例如,考虑 (两个自然数)、(一个负整数和一个有理数)或 (一个有理数和一个无理数)。 所有这些都是有效的。 事实上,任何两个实数都可以加在一起。 为了以最简单的方式尽可能地捕捉这种通用性,我们需要在 Haskell 中使用通用的 Number 类型,这样 (+) 的签名就简单地为

(+) :: Number -> Number -> Number

然而,这种设计与计算机执行算术的方式很不相符。 虽然计算机可以将整数作为内存中的一系列二进制数字来处理,但这种方法不适用于实数,[2] 因此需要更复杂的编码来处理它们:浮点数。 虽然浮点数提供了一种处理一般实数的合理方法,但它也有一些不便之处(最值得注意的是精度损失),这使得使用更简单的编码处理整数值变得更有价值。 所以,我们至少有两种不同的存储数字的方法:一种用于整数,另一种用于一般实数。 每个方法都应该对应不同的 Haskell 类型。 此外,计算机只有在两个数字格式相同的情况下才能执行像 (+) 这样的运算。

所以,拥有一个通用的 Number 类型就更不用说了——似乎我们甚至不能让 (+) 混合整数和浮点数。 然而,Haskell 可以至少使用相同的 (+) 函数来处理整数或浮点数。 在 GHCi 中自己验证一下

Prelude> 3 + 4
7
Prelude> 4.34 + 3.12
7.46

在讨论列表和元组时,我们看到,如果函数是多态的,它们可以接受不同类型的参数。 本着这种精神,这里有一个可能的 (+) 类型签名,它将说明上述事实

(+) :: a -> a -> a

有了这个类型签名,(+) 将接受两个相同类型 a 的参数(可以是整数或浮点数),并计算出一个类型为 a 的结果(只要两个参数类型相同即可)。 但是,这个类型签名表示任何类型,我们知道我们不能对两个 Bool 值或两个 Char 值使用 (+)。 添加两个字母或两个真值是什么意思? 所以,(+)实际类型签名使用了一种语言特性,允许我们表达语义限制,即 a 可以是任何类型,只要它是数字类型

(+) :: (Num a) => a -> a -> a

Num 是一个类型类——一组类型——它包含所有被视为数字的类型。 [3] 签名中的 (Num a) => 部分将 a 限制为数字类型——或者,用 Haskell 的术语来说,是 Num实例

数值类型

[编辑 | 编辑源代码]

那么,实际的数字类型有哪些(也就是说,签名中的 a 可以代表的 Num 实例)? 最重要的数值类型是 IntIntegerDouble

  • Int 对应于大多数语言中常见的普通整数类型。 它具有固定的最大值和最小值,这些值取决于计算机的处理器。 (在 32 位机器中,范围从 -2147483648 到 2147483647)。
  • Integer 也用于整数,但它支持任意大的值——以牺牲一些效率为代价。
  • Double 是双精度浮点数类型,在绝大多数情况下,它是实数的良好选择。 (Haskell 还有 Float,它是 Double 的单精度对应物,通常由于精度进一步损失而不太有吸引力。)

还有其他几种数值类型可用,但这些类型涵盖了日常任务中的大多数数值类型。

多态推测

[编辑 | 编辑源代码]

如果你已经仔细阅读了到目前为止的内容,你就知道我们不需要总是指定类型,因为编译器可以推断类型。 你还知道,当函数需要匹配类型时,我们不能混合类型。 将此与我们对数字的新理解结合起来,了解 Haskell 如何处理像这样的基本算术

Prelude> (-7) + 5.12
-1.88

这似乎添加了两种不同类型的数字——一个整数和一个非整数。 让我们看看我们输入的数字的实际类型是什么

Prelude> :t (-7)
(-7) :: (Num a) => a

所以,(-7) 不是 Int 也不是 Integer! 相反,它是一个多态的值,可以“变形”为任何数字类型。 现在,让我们看一下另一个数字

Prelude> :t 5.12
5.12 :: (Fractional t) => t

5.12 也是一个多态的值,但它是 Fractional 类的值,Fractional 类是 Num 的子集(每个 Fractional 都是 Num,但并非每个 Num 都是 Fractional;例如,IntInteger 不是 Fractional)。

当 Haskell 程序计算 (-7) + 5.12 时,它必须为数字确定一个实际的匹配类型。 类型推断考虑了类规范:(-7) 可以是任何 Num,但 5.12 有额外的限制,所以它是限制因素。 在没有其他限制的情况下,5.12 将假定 Double 的默认 Fractional 类型,因此 (-7) 也将成为 Double。 然后,加法运算将正常进行,并返回一个 Double[4]

以下测试将让你更好地了解这个过程。 在一个源文件中,定义

x = 2

然后在 GHCi 中加载文件并检查 x 的类型。 然后,将文件更改为添加一个 y 变量,

x = 2
y = x + 3

重新加载它并检查 xy 的类型。 最后,将 y 修改为

x = 2
y = x + 3.1

看看两个变量的类型会发生什么。

单态问题

[编辑 | 编辑源代码]

数值类型和类的复杂性有时会导致一些问题。例如,考虑常见的除法运算符(/)。它具有以下类型签名

(/) :: (Fractional a) => a -> a -> a

a限制为分数类型是必须的,因为两个整数的除法通常会导致非整数。然而,我们仍然可以写类似的东西

Prelude> 4 / 3
1.3333333333333333

因为字面量43是多态值,因此在(/)的支配下,它们假设类型Double。然而,假设我们想要将一个数字除以列表中的元素数量。[5] 很明显应该使用length函数,它接受一个列表并返回其中的元素数量

Prelude> length [1,2,3]
3
Prelude> 4 / length [1,2,3]

不幸的是,它会爆炸

<interactive>:1:0:
    No instance for (Fractional Int)
      arising from a use of `/' at <interactive>:1:0-17
    Possible fix: add an instance declaration for (Fractional Int)
    In the expression: 4 / length [1, 2, 3]
    In the definition of `it': it = 4 / length [1, 2, 3]

像往常一样,可以通过查看length的类型签名来理解问题

length :: (Foldable t) => t a -> Int

现在,让我们关注length结果的类型。它是Int;结果不是多态的。由于Int不是Fractional,Haskell不会让我们将其与(/)一起使用。

为了解决这个问题,我们有一个特殊的函数。在继续文本之前,尝试根据名称和签名猜测它的功能

fromIntegral :: (Integral a, Num b) => a -> b

fromIntegral接受某个Integral类型的参数(如IntInteger),并将其转换为多态值。通过将其与length结合,我们可以使列表的长度符合(/)的签名

Prelude> 4 / fromIntegral (length [1,2,3])
1.3333333333333333

在某些方面,这个问题很烦人且乏味,但它是严格处理数字的必然结果。在Haskell中,如果你定义了一个带有Int参数的函数,它永远不会转换为IntegerDouble,除非你明确使用fromIntegral之类的函数。作为其完善的类型系统的直接结果,Haskell 在处理数字方面具有令人惊讶的多样化类别和函数。

超出数字的类

[编辑 | 编辑源代码]

Haskell 拥有超越算术的类型类。例如,(==)的类型签名是

(==) :: (Eq a) => a -> a -> Bool

(+)(/)类似,(==)是一个多态函数。它比较两个相同类型的的值,这些值必须属于Eq类,并返回一个BoolEq只是用于可以比较相等性的值的类型的类,它包含所有基本非函数类型。 [6]

我们已经忽略的一个完全不同的类型类示例出现在length的类型中。鉴于length接受一个列表并返回一个Int,我们可能期望它的类型为

length :: [a] -> Int

然而,实际类型有点复杂

length :: (Foldable t) => t a -> Int

除了列表之外,还有其他类型的结构可以用来以不同的方式对值进行分组。许多这些结构(以及列表本身)属于一个称为Foldable的类型类。length的类型签名告诉我们,它不仅适用于列表,也适用于所有其他Foldable结构。在本书的后面,我们将看到这种结构的示例,并详细讨论Foldable。在此之前,每当你在类型签名中看到类似Foldable t => t a的内容时,可以随意将其在脑海中替换为[a]

类型类为类型系统增添了巨大的力量。我们将在后面回到这个主题,看看如何在自定义方式中使用它们。

注意

  1. 如果你按照我们在“类型基础”中的建议,你可能已经通过使用:t测试看到了相当奇特的答案...... 如果是这样,请将以下分析视为理解该签名的含义的途径。
  2. 除了其他问题,在任何两个实数之间,有不可数的实数——无论我们做什么,这个事实都无法直接映射到内存中的表示形式。
  3. 这是一个宽松的定义,但在我们更详细地讨论类型类之前足够了。
  4. 对于经验丰富的程序员:这似乎具有与 C(以及许多其他语言)中的程序使用隐式转换(其中整数字面量被静默转换为双精度浮点数)相同的效果。然而,在 C 中,转换是在你的背后完成的,而在 Haskell 中,只有当变量/字面量是多态值时才会发生。这个区别很快就会变得更清楚,当我们展示一个反例时。
  5. 一个合理的场景——想想计算列表中值的平均值。
  6. 比较两个函数的相等性被认为是棘手的
华夏公益教科书