你不知道的一个奇葩知识点:0.1+0.2!=0.3,无论什么语言


写在前面

之前在项目中遇到过金额类型需要四舍五入保留2为小数的问题,体会到了go的float64类型相加不精确的问题,今天看到为什么 0.1 + 0.2 = 0.300000004,于是学习了一下。

现状

废话不多说,直接看效果

javascript

go

func main() {
   f1 := float64(0.1)
   f2 := float64(0.2)
   f3 := f1 + f2
   fmt.Println(f3) // 0.30000000000000004
}

有没有很神奇,这到底是为什么?今天就来一探究竟。

分析

几乎所有现代的编程语言都会遇到上述问题,包括 Java、Ruby、Python、Swift 和 Go 等等。这不是因为它们在计算时出现了错误,而是因为浮点数计算标准的要求。

在数学上我们总有办法通过额外的符号表示更复杂的数字,但是从工程的角度来看,表示无限精度的数字是不经济的,我们期望通过更小和更快的系统表示范围更大和精度更高的实数。浮点数系统是在工程上面做的权衡,IEEE 754 就是在 1985 年建立的浮点数计算标准,它定义了浮点数的算术格式、交换格式、舍入规则、操作和异常处理

二进制与十进制

我们日常生活中使用的数字基本都是 10 进制的,然而计算机使用二进制的 0 和 1 表示整数和小数,所有有限的十进制整数都可以无损的转换成有限长度的二进制数字,但是要在二进制的计算机中表示十进制的小数相对就很麻烦了,我们以 0.375 为例介绍它在二进制下的表示

$(0.375)10=(0.011)2$

小数点后面的位数依次表示十进制中的 0.5、0.25、0.125 和 0.0625 等等,这个表示方法非常好理解,每一位都是前一位的一半。0.375在计算机看来是个「整数」,因为它可以用有限位数来表示出来。那什么数字不能用有限位数表示出来呢?还真有,比如今天的主人翁0.1和0.2

包括0.3,计算机都不能用有限位数的小数来表示出来,只能在后面无限循环1100来无限接近。

精度上限

编程语言中的浮点数一般都是 32 位的单精度浮点数 float 和 64 位的双精度浮点数 double,部分语言会使用 float32 或者 float64 区分这两种不同精度的浮点数。想要使用有限的位数表示全部的实数是不可能的,不用说无限长度的小数和无理数,因为长度的限制,有限小数在浮点数中都无法精确的表示

  • 单精度浮点数 float 总共包含 32 位,其中 1 位表示符号、8 位表示指数,最后 23 位表示小数;
  • 双精度浮点数 double 总共包含 64 位,其中 1 位表示符号,11 位表示指数,最后 52 位表示小数;

我们看到32位或者64位表示一个浮点数精度十分有限,因为单精度浮点数的小数位为 23,双精度的小数位为 52,同时都隐式地包含首位的 1,所以它们的精度在十进制中分别是 $𝑙𝑜𝑔_{10}(2^{24})≈7.22$位 和 $𝑙𝑜𝑔_{10}(2^{53})≈15.95$ 位。

如果只能用32位或者64位表示一个浮点数,那上面说的0.1,0.2和0.3就不能无限循环1100了,到后面必然要截取,有截取那肯定就不准了。到这里就好理解了题目中说的0.1+0.2!=0.3,因为计算机无法精确的表示出0.1,0.2和0.3,更不用说能精确计算了。

我们来看看计算机表示的0.1+0.2

如何真正解决这个问题

看起来0.30000000000000004和0.3没什么区别,但是在有些领域区别是非常大的,比如医疗、机械制造、金融等等。尤其是金融行业,如果不解决这个问题。那某个用户今天存0.1个单位,明天存0.2个单位,就得到了0.30000000000000004个单位,然后取出来。会得到一笔意外之财,如果他循环这样做,如果银行每个用户这样做,那银行立马破产了。

解决这个问题一般有两种方法:

  • 使用具有 128 位的高精度定点数或者无限精度的定点数;
  • 使用有理数类型和分数系统保证计算的精度;

为了解决浮点数的精度问题,一些编程语言引入了十进制的小数 DecimalDecimal 在不同社区中都十分常见,如果编程语言没有原生支持 Decimal,我们在开源社区也一定能够找到使用特定语言实现的 Decimal 库

使用 Decimal 和 BigDecimal 虽然可以在很大程度上解决浮点数的精度问题,但是它们在遇到无限小数时仍然无能为力,使用十进制的小数永远无法准确地表示 1/3,无论使用多少位小数都无法避免精度的损

当我们遇到这种情况时,使用有理数(Rational)是解决类似问题的最好方法,部分编程语言因为科学计算的需求会将有理数作为标准库的一部分,例如:Julia5 和 Haskell6。分数是有理数的重要组成部分,使用分数可以准确的表示 1/101/5 和 1/3,Julia 作为科学计算中的常用编程语言,我们可以使用如下所示的方式表示分数:

julia> 1//3
1//3

julia> numerator(1//3)
1

julia> denominator(1//3)
3

这种解决精度问题的方法更接近原始的数学公式,分数的分子和分母是有理数结构体中的两个变量,多个分数的加减乘除操作与数学中对分数的计算没有任何区别,自然也就不会造成精度的损失

然而需要注意的是,这种使用有理数计算的方式不仅在使用上相对比较麻烦,它在性能上也无法与浮点数进行比较,一次常见的加减法就需要使用几倍于浮点数操作的汇编指令,所以在非必要的场景中一定要尽量避免

总结

当我们在不同编程语言中看到 0.300000004 或者 0.30000000000000004 时不应该感到惊讶,这其实说明编程语言正确实现了 IEEE 754 标准中描述的浮点数系统,在使用单精度和双精度浮点数时也应该牢记它们只有 7 位和 15 位的有效位数。

要想更精确的表示小数可以使用 Decimal 和 BigDecimal,但是并不是所有编程语言都实现了,有的可能需要自己实现。

有理数可以精确表示无限循环小数,但是计算成本大,慎用。

参考

[1]为什么 0.1 + 0.2 = 0.300000004

2]为什么 0.1 + 0.2 = 0.3


文章作者: Alex
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Alex !
  目录