为什么 0.1 + 0.2 = 0.3
0.1 + 0.2 = 0.3
这个等式的成立看起来是理所当然的,然而前面的文章 为什么 0.1 + 0.2 = 0.300000004 分析了为什么这个等式在绝大多数的编程语言中都不成立,标准的浮点数可以通过 32 位单精度浮点数或者 64 位的双精度浮点数保证有限的精度,所有正确实现浮点数的编程语言都会遇到如下所示的『错误』:
> 0.1 + 0.2
0.30000000000000004
浮点数作为编程语言中必不可少的概念,需要在性能和精度方面做出的权衡,过高的精度需要更多的位数以及更多的计算,过低的精度也无法满足常见的计算需求,这种重要的决策会影响上层千千万万的应用和服务,然而这个决策需要面对的问题与软件工程中需要解决的问题也没有太多区别 — 如何尽可能地利用有限地资源实现特定目的。
图 1 - 性能和精度的权衡虽然浮点数提供了相对优异的性能,但是在金融系统中使用精度低的浮点数会有非常严重的后果。假设我们在交易所或者银行使用 64 位的双精度浮点数存储账户的余额,这时就存在被用户攻击的可能,用户可以利用双精度浮点数的精度限制造出更多余额:
图 2 - 金融系统与浮点数当用户分别先向账户中充值 0.1
单位和 0.2
单位的资产后,使用双精度浮点数在计算时会得到 0.30000000000000004
,用户将这些资产全部提现可以得到 0.00000000000000004
的意外之财[^1],如果用户重复的次数足够多,就可以把银行提破产,大家加油,下面是一段使用浮点数处理充值和提现的代码:
var balance float64 = 0
func main() {
deposit(.1)
deposit(.2)
if balance, ok := withdraw(0.30000000000000004); ok {
fmt.Println(balance)
}
}
func deposit(v float64) {
balance += v
}
func withdraw(v float64) (float64, bool) {
if v <= balance {
balance -= v
return v, true
}
return 0, false
}
上面的代码也只是理想的情况,今天的成熟金融系统不可能~~(其实不一定)~~犯这种低级的错误,但是一些新兴的交易所中仍然存在这种可能,不过想要真正实施上述操作还是非常困难。如果我们可以控制的资源是无限的,自然就可以实现无限精度的小数,然而资源永远都是有限的,一些编程语言或者库会通过下面的两种方法提供精度更高的小数保证 0.1 + 0.2 = 0.3
这个等式的成立:
- 使用具有 128 位的高精度定点数或者无限精度的定点数;
- 使用有理数类型和分数系统保证计算的精度;
上述这两种方法都可以实现精度更高的小数系统,但是两者的原理却略有不同,接下来我们将分析它们的设计原理。
十进制小数
在很多时候浮点数的精度损失都是因为不同进制的数据相关转换造成的,正如我们在 为什么 0.1 + 0.2 = 0.300000004 一文中提到的,我们无法使用有限的二进制位数准确地表示十进制中的 0.1
和 0.2
,这就造成了精度的损失,这些精度损失不断累加在最后就可能累积成较大的错误:
如下图所示,因为 0.25
和 0.5
两个十进制的小数都可以用二进制的浮点数准确表示,所以使用浮点数计算 0.25 + 0.5
的结果也一定是准确的[^2]:
为了解决浮点数的精度问题,一些编程语言引入了十进制的小数 Decimal
。Decimal
在不同社区中都十分常见,如果编程语言没有原生支持 Decimal
,我们在开源社区也一定能够找到使用特定语言实现的 Decimal
库。Java 通过 BigDecimal
提供了无限精度的小数,该类中包含三个关键的成员变量 intVal
、scale
和 precision
[^3]:
public class BigDecimal extends Number implements Comparable
{ private BigInteger intVal;
private int scale;
private int precision = 0;
...
}
当我们使用 BigDecimal
表示 1234.56
时,BigDecimal
中的三个字段会分别以下的内容:
intVal
中存储的是去掉小数点后的全部数字,即123456
;scale
中存储的是小数的位数,即2
;prevision
中存储的是全部的有效位数,小数点前 4 位,小数点后 2 位,即6
;
BigDecimal
这种使用多个整数的方法避开了二进制无法准确表示部分十进制小数的问题,因为 BigInteger
可以使用数组表示任意长度的整数,所以如果机器的内存资源是无限的,BigDecimal
在理论上也可以表示无限精度的小数。
虽然部分编程语言实现了理论上无限精度的 BigDecimal
,但是在实际应用中我们大多不需要无限的精度保证,C# 等编程语言通过 16 字节的 Decimal
提供的 28 ~ 29 位的精度,而在金融系统中使用 16 字节的 Decimal
一般就可以保证数据计算的准确性了[^4]。
有理数
使用 Decimal
和 BigDecimal
虽然可以在很大程度上解决浮点数的精度问题,但是它们在遇到无限小数时仍然无能为力,使用十进制的小数永远无法准确地表示 1/3
,无论使用多少位小数都无法避免精度的损失:
当我们遇到这种情况时,使用有理数(Rational)是解决类似问题的最好方法,部分编程语言因为科学计算的需求会将有理数作为标准库的一部分,例如:Julia[^5] 和 Haskell[^6]。分数是有理数的重要组成部分,使用分数可以准确的表示 1/10
、1/5
和 1/3
,Julia 作为科学计算中的常用编程语言,我们可以使用如下所示的方式表示分数:
julia> 1//3
1//3
julia> numerator(1//3)
1
julia> denominator(1//3)
3
这种解决精度问题的方法更接近原始的数学公式,分数的分子和分母是有理数结构体中的两个变量,多个分数的加减乘除操作与数学中对分数的计算没有任何区别,自然也就不会造成精度的损失,我们可以简单了解一下 Java 中有理数的实现[^7]:
public class Rational implements Comparable
{ private int num; // the numerator
private int den; // the denominator
public double toDouble() {
return (double) num / den;
}
...
}
上述类中的 num
和 den
分别表示分数的分子和分母,它提供的 toDouble
方法可以将当前有理数转换成浮点数,因为浮点数在软件工程中虽然更加常用,当我们需要严密的科学计算时,可以使用有理数完成绝大多数的计算,并在最后转换回浮点数以减少可能出现的误差。
然而需要注意的是,这种使用有理数计算的方式不仅在使用上相对比较麻烦,它在性能上也无法与浮点数进行比较,一次常见的加减法就需要使用几倍于浮点数操作的汇编指令,所以在非必要的场景中一定要尽量避免。
总结
想要保证 0.1 + 0.2 = 0.3
这个公式的成立并不是一件复杂的事情,作者相信除了文中介绍的这些方案之外,我们还会有其他的实现方式,但是文中介绍的方案是最为常见的两种,我们再来回顾一下如何使 0.1 + 0.2 = 0.3
这个公式成立:
- 使用十进制的两个整数 — 整数值和指数表示有限精度或者无限精度的小数,一些编程语言使用 128 位的
Decimal
表示具有 28 ~ 29 位精度的数字,而一些编程语言使用BigDecimal
表示无限精度的数字; - 使用十进制的两个整数 — 分子和分母表示准确的分数,可以减少浮点数计算带来的精度损失;
有理数和小数是数学中的概念,数学是一门非常严谨和精确的学科,通过引入大量的概念和符号,数学中的计算可以实现绝对的准确;但是软件工程作为一门工程,它需要在复杂的物理世界,利用有限的资源解决有限的问题,所以我们需要在多个方案之间做出权衡和选择,数学中的有理数和无理数其实都可以在软件中实现,但是在使用时一定要想清楚 — 为了得到这些我们牺牲了什么?到最后,我们还是来看一些比较开放的相关问题,有兴趣的读者可以仔细思考一下下面的问题:
- 你最常用的编程语言中小数的结构体是什么样的,包含了哪些字段?
- 浮点数、小数和有理数三种不同的策略在加减乘除四则运算上的性能如何?