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 中,只有当变量/字面量是多态值时才会发生。这个区别很快就会变得更清楚,当我们展示一个反例时。
- ↑ 一个合理的场景——想想计算列表中值的平均值。
- ↑ 比较两个函数的相等性被认为是棘手的