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 实例)? 最重要的数值类型是 Int、Integer 和 Double
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;例如,Int 和 Integer 不是 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
重新加载它并检查 x 和 y 的类型。 最后,将 y 修改为
x = 2
y = x + 3.1
看看两个变量的类型会发生什么。
数值类型和类的复杂性有时会导致一些问题。例如,考虑常见的除法运算符(/)。它具有以下类型签名
(/) :: (Fractional a) => a -> a -> a
将a限制为分数类型是必须的,因为两个整数的除法通常会导致非整数。然而,我们仍然可以写类似的东西
Prelude> 4 / 3 1.3333333333333333
因为字面量4和3是多态值,因此在(/)的支配下,它们假设类型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类型的参数(如Int或Integer),并将其转换为多态值。通过将其与length结合,我们可以使列表的长度符合(/)的签名
Prelude> 4 / fromIntegral (length [1,2,3]) 1.3333333333333333
在某些方面,这个问题很烦人且乏味,但它是严格处理数字的必然结果。在Haskell中,如果你定义了一个带有Int参数的函数,它永远不会转换为Integer或Double,除非你明确使用fromIntegral之类的函数。作为其完善的类型系统的直接结果,Haskell 在处理数字方面具有令人惊讶的多样化类别和函数。
Haskell 拥有超越算术的类型类。例如,(==)的类型签名是
(==) :: (Eq a) => a -> a -> Bool
与(+)或(/)类似,(==)是一个多态函数。它比较两个相同类型的的值,这些值必须属于Eq类,并返回一个Bool。Eq只是用于可以比较相等性的值的类型的类,它包含所有基本非函数类型。 [6]
我们已经忽略的一个完全不同的类型类示例出现在length的类型中。鉴于length接受一个列表并返回一个Int,我们可能期望它的类型为
length :: [a] -> Int
然而,实际类型有点复杂
length :: (Foldable t) => t a -> Int
除了列表之外,还有其他类型的结构可以用来以不同的方式对值进行分组。许多这些结构(以及列表本身)属于一个称为Foldable的类型类。length的类型签名告诉我们,它不仅适用于列表,也适用于所有其他Foldable结构。在本书的后面,我们将看到这种结构的示例,并详细讨论Foldable。在此之前,每当你在类型签名中看到类似Foldable t => t a的内容时,可以随意将其在脑海中替换为[a]。
类型类为类型系统增添了巨大的力量。我们将在后面回到这个主题,看看如何在自定义方式中使用它们。
注意
- ↑ 如果你按照我们在“类型基础”中的建议,你可能已经通过使用
:t测试看到了相当奇特的答案...... 如果是这样,请将以下分析视为理解该签名的含义的途径。 - ↑ 除了其他问题,在任何两个实数之间,有不可数的实数——无论我们做什么,这个事实都无法直接映射到内存中的表示形式。
- ↑ 这是一个宽松的定义,但在我们更详细地讨论类型类之前足够了。
- ↑ 对于经验丰富的程序员:这似乎具有与 C(以及许多其他语言)中的程序使用隐式转换(其中整数字面量被静默转换为双精度浮点数)相同的效果。然而,在 C 中,转换是在你的背后完成的,而在 Haskell 中,只有当变量/字面量是多态值时才会发生。这个区别很快就会变得更清楚,当我们展示一个反例时。
- ↑ 一个合理的场景——想想计算列表中值的平均值。
- ↑ 比较两个函数的相等性被认为是棘手的