Haskell的`Ratio`类型的隐患

99 阅读4分钟

这里有一个新的Haskell WAT?!

Haskell有一个类型Rational ,用于处理精确取值的小数,它模拟了有理数的数学概念。尽管与Double 相比,它的速度相对较慢,但它不会受到浮点运算中固有的四舍五入的影响。在编写测试时,它非常有用,因为可以提前预测准确的结果。例如,一个应该产生0的计算将产生精确的0,而不是一个必须要确定的范围内的小值。

Rational 实际上是更普遍的(多态)类型的(单态)特化 (来自于Ratio Data.Ratio)。Ratio 允许你指定用于分子和分母的基础类型。例如,使用Int 作为基础类型来处理有理数,你可以使用Ratio Int 。对于使用Integer 作为基础类型的常见情况,提供了类型同义词Rational

type Rational = Ratio Integer

使用Ratio 和一个固定宽度的类型如Int 是很诱人的,因为IntInteger 快得多。然而,让我们看看如果你这样做会发生什么。

λ> import Data.Int
λ> import Data.Ratio
λ> let r = 1 % 12 :: Rational   in r - r == 0
True
λ> let r = 1 % 12 :: Ratio Int8 in r - r == 0
False

WAT?!

让我们看看这些被减去的值是什么。

λ> let r = 1 % 12 :: Rational   in r - r
0 % 1
λ> let r = 1 % 12 :: Ratio Int8 in r - r
0 % (-1)

嗯,让我们看看那个Ratio Int8 的值是否被认为等于0

λ> let r = 0 % (-1) :: Ratio Int8 in r == 0
True

WAT?!

让我们看看那些手动输入的值是什么。

λ> 0 % (-1) :: Ratio Int8
0 % 1
λ> 0 :: Ratio Int8
0 % 1

好的,所以这些值确实是相等的,但为什么减法中的值会不同呢?解释是两方面的。

首先,0 % (-1) ,对于Ratio ,是一个变性的状态,不应该出现。(正如你可能已经怀疑的那样,它是由整数溢出引起的。稍后会有更多关于这个问题的内容)。那么,它不等于0 ,也就不太奇怪了。

但为什么当我们直接输入时,它等于0 ?这是因为% 是一个函数,而不是一个构造函数,它在构造值之前将分子和分母的符号归一化。

x % y = reduce (x * signum y) (abs y)

其基本假设(不变性)是分母总是正的。

reduce 是一个函数,通过除以最大公因子,将分子和分母减少到它们的最低项。

reduce x y = (x `quot` d) :% (y `quot` d)
  where d = gcd x y

在这里你可以看到实际从它们的组件中创建数值的构造函数,即:% 。它没有从Data.Ratio ,而是使用 "智能构造函数 "% ,以确保新的Ratio 的数值总是满足不变量。

第二,加法和减法的实现没有试图将整数溢出的可能性降到最低。比如说。

(x :% y) - (x' :% y') = reduce (x * y' - x' * y) (y * y')

如果y * y' 溢出到一个负值,reduce 将不对符号进行规范化处理。gcd 的结果总是非负的,所以符号不会改变,而去规范化的值也不会被重新规范化。这只发生在% ,当构建Ratio 值时。

让我们看看在我们的例子中会发生什么。

λ> x = 1; y = 12; x' = 1; y' = 12
λ> x * y' - x' * y :: Int8
0
λ> y * y' :: Int8
-112
λ> gcd 0 (-112)
112
λ> 0 `quot` 112
0
λ> (-112) `quot` 112
-1

因此,1 % 12 - 1 % 12 的减少结果是反正化的值0 :% (-1) ,它不被认为等于正化的值0 % 1

尽管12 远远小于maxBound :: Int8 ,但当它被平方时,会造成整数溢出。Num 对于Ratio 的实现并不是为了避免溢出,在分子和分母远小于该类型的maxBound 的情况下,溢出很容易发生。

该实现可以使用一个稍微不同的方法。

(x :% y) - (x' :% y') = reduce (x * z' - x' * z) (y * z')
  where z = y `quot` d
        z' = y' `quot` d
        d = gcd y y'

然而,使用reduce 仍然是必要的(考虑到3 % 10 - 2 % 15 ),所以与实际实现相比,这需要多两个除数和一个gcd

使用像Int8 这么小的类型可能看起来有点不现实,但是这个问题可以发生在任何固定宽度的积分类型上,我使用Int8 来进行说明,因为在处理小数值时更容易理解这个问题。我最初是在使用Ratio Int 时遇到的,尽管Int 有一个非常大的maxBound 。我当时正在使用QuickCheck为一些多态算术代码编写属性测试,这些代码应该产生一个零和作为结果。测试成功的是Rational ,失败的是Ratio Int ,我不明白为什么,因为测试框架生成的随机值的分子和分母远远小于maxBound :: Int 。然而,它们大于其平方根。

Ratio 的文档中说:

请注意,Ratio的实例继承了类型参数的缺陷。例如,Ratio Natural'的Num 实例与Natural'的问题相似。

然而,这并不能让你对其他类型参数可能发生的情况有真正的准备!这个故事的寓意是,Ratio 本身没有什么用处,你应该总是使用Rational ,除非你真的明白你要做什么。