写在前面
之前在项目中遇到过金额类型需要四舍五入保留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 位的高精度定点数或者无限精度的定点数;
- 使用有理数类型和分数系统保证计算的精度;
为了解决浮点数的精度问题,一些编程语言引入了十进制的小数 Decimal
。Decimal
在不同社区中都十分常见,如果编程语言没有原生支持 Decimal
,我们在开源社区也一定能够找到使用特定语言实现的 Decimal
库
使用 Decimal
和 BigDecimal
虽然可以在很大程度上解决浮点数的精度问题,但是它们在遇到无限小数时仍然无能为力,使用十进制的小数永远无法准确地表示 1/3
,无论使用多少位小数都无法避免精度的损
当我们遇到这种情况时,使用有理数(Rational)是解决类似问题的最好方法,部分编程语言因为科学计算的需求会将有理数作为标准库的一部分,例如:Julia5 和 Haskell6。分数是有理数的重要组成部分,使用分数可以准确的表示 1/10
、1/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
,但是并不是所有编程语言都实现了,有的可能需要自己实现。
有理数可以精确表示无限循环小数,但是计算成本大,慎用。