在软件开发中,老鸟程序员常常告诫新鸟程序员,金额不要用Float类型,因为Float不精确,会产生误差,涉及到钱可能产生资损。但为什么Float类型不精确呢?这要从信息在计算机中的表示说起,请听我慢慢道来。
众所周知,计算机里的信息都是用二进制的 0 和 1 表示的,我们先看整数(Integer)在计算机里的表示,然后再看浮点数(Float)的表示。
整数的编码
计算机里存储整数要区分无符号整数(即正整数和0) 或有符合整数(即全部整数)。
无符号整数的编码
无符号整数的二进制数学表达式为:
其中 w 为位长(即一共有几位), xi为 0 或 1。例如:
[0101]2 = 0✖️23 + 1✖️22 + 0✖️21 + 1✖️20 = 5
[1011]2 = 1✖️23 + 0✖️22 + 1✖️21 + 1✖️20 = 11
有符号整数的编码
一般有符号整数的二进制表示使用补码方式,第一位为符号位(0表示正数,1表示负数)。数学表达式为:
其中 w 为位长,xw-1为第一位,其为 0 表示正数,为 1 表示负数。xi为 0 或 1。例如:
[0101]2 = -0✖️23 + 1✖️22 + 0✖️21 + 1✖️20 = 5
[1011]2 = -1✖️23 + 0✖️22 + 1✖️21 + 1✖️20 = -5
采用补码表示有符号整数的优点:可以用统一的加法运算方式处理无符号和有符号整数。
综上可知,用二进制表示整数不存在误差,都可以精确表示。
只要位数足够多就可以表示足够大的整数,但实际计算机的字长有限(通常32位或64位),所以计算过程中可能产生特别大的数(例如两个大数相乘,或者无符号大数转为有符号类型),超过了可表示的范围会产生溢出,变成另外一个数,这是在编程的时候需要特别注意的。
浮点数的编码
我们先看10进制表示小数的形式:其中小数点 (“.”) 在 i=0 和 i=-1 之间。例如:
123.45 = 1✖️102 + 2✖️101 + 3✖️100 + 4✖️10-1 + 5✖️10-2 = 100 + 20 + 3 + 4/10 + 5/100 = 123 + 45/100 = 123.45
那么10进制小数这种表示方式有误差吗?答案是有的,例如表示 1/3 时,是个无限循环小数,必须进行舍入。
接下来我们看二进制小数的表示形式:其中小数点 (“.”) 在 i=0 和 i=-1 之间。例如:
101.112 = 1✖️22 + 0✖️21 + 1✖️20 + 1✖️2-1 + 1✖️2-2 = 4 + 0 + 1 + 0.5 + 0.25 = 5.75
二进制小数只能表示可以被写成 x2y 的数,其他的数只能找离其最近的数代替,称之为舍入(rounding),例如我们常见的 10 进制舍入称为四舍五入。例如 10 进制的 0.2 (1/5) 就无法使用二进制小数精确表示,因为根号 5 是个无理数,这种情况只能按照精度要求舍入。下面是 Python 中令人困惑的浮点数运算(其他编程语言也类似)。
>>> 0.1 + 0.1 == 0.2
True
>>> 0.1 + 0.2 == 0.3
False
为什么 0.1 + 0.1 等于 0.2,但 0.1 + 0.2 不等于 0.3 呢?我们先看看小数求和运算后的结果:
>>> 0.1 + 0.1
0.2
>>> 0.1 + 0.2
0.30000000000000004
发现 0.1 + 0.1 是 0.2 ,但 0.1 + 0.2 却是 0.30000000000000004 。原因就是舍入造成的,0.1、0.2 和 0.3 都无法用二进制精确表示,这 3 个数在计算机存储的都是舍入后的结果,都是近似值。
所以上面诡异结果的原因是:近似的 0.1 加上舍入的 0.1 再舍入恰好等于近似的 0.2 ,但近似的 0.1 加上近似的 0.2 之后再舍入却运气没那么好,不等于近似的 0.3。也就是数值表示本身有误差,求和的过程中又加大了这种误差,造成了不等。
所以二进制不能精确表示10进制小数,有很多数都存在误差。因此软件开发中避免使用 Float 表示金额,避免用 == 来判断 Float 的相等性。
浮点数 IEEE 表示
最早的计算机厂商都是自己设计自己的浮点数表示规则,后来 IEEE 出了统一标准。标准都是对一类问题的经典解决方案,我们有必要了解下。
IEEE浮点标准用 V = (-1)s✖️M✖️2E 的形式表示一个数:
- 符号 s=1 表示负数,s=0 表示正数;
- 尾数 M 是一个二进制小数,范围是区间 [1, 2) ,或区间 [0, 1) ;
- 阶码 E 是加权值。
在计算机中,一个字长的浮点数称为单精度数,两个字长的浮点数称为双精度数。表示浮点数的字节会分成三部分:
- 位 s 与符号位 s 相同
- 指数 e 与阶码 E相关,当 e 为全 1,表示无穷大。当 e 为 0 时,E = 1 - Bias ;当 e 大于 0 小于全 1 时, E = e - Bias,其中 Bias 为 2k-1 - 1 的偏移量 (k为e所占位长)
- 小数 f 与 M 相关,当 e 为 0 时,M = f/2k-1 ; 当 e 大于 0 小于全 1 时,M = f/2k-1 + 1
下面我们以字长为 8 来举例,其中第1位为 s,第 2~5 为 e,第 6~8 为 f,偏移量 Bias 为 24-1 - 1 = 7。
0 0000 010
上例中 s 为 0, e 为 0,f 为 2,E = 1-7 = -6,M = f = 2/8,所以 V=(-1)0✖️2/8✖️2-6=1/256
0 0001 110
上例中 s 为 0, e 为 1,f 为 6,E = 1-7 = -6,M = 6/8+1 = 14/8,所以 V=(-1)0✖️14/8✖️2-6=7/256
0 1110 111
上例中 s 为 0, e 为 14,f 为 7,E = 14-7 = 7,M = 7/8+1 = 15/8,所以 V=(-1)0✖️15/8✖️27=1920/8
0 1111 000
上例中 s 为 0, e 为 全 1,表示无穷大。
金额应该使用什么类型?
至此我们知道了金额为什么不用 Float 类型,那么应该用什么类型呢?通常有两个方案选择:
1、使用 int 类型
如果金额计算精度要求不高,最小到分就行,而且最大金额也不超过 int 的范围,那么选 int 类型是最佳方案,存储金额的单位为分。int 可以精确表示,而且无论从存储还是计算,都更节省资源。
2、使用 decimal 类型
decimal 类型用来精确保存10进制小数,decimal 默认精确到小数点后 28 位,可以满足财务计算对精确的要求。
下面是Python里使用 decimal 的示例,可以看到不会出现浮点数的不等式。
>>> from decimal import Decimal
>>> Decimal('0.1') + Decimal('0.2') == Decimal('0.3')
True
>>>
那么为什么 decimal 没有浮点数的问题呢? 这取决于它的存储方式,decimal 把整数和小数分开存储了,分开后小数按整数存储就可以用二进制精确表示了。
decimal 使用注意事项
构造 Decimal 类型可以使用 int 或 字符串,不要使用 float 构造 Decimal,否则构造出来的是精确的近似值。不过使用 float 构造decimal 可以回答上文中 “ 0.1 + 0.1 等于 0.2,但 0.1 + 0.2 不等于 0.3 ” 的诡异问题,详见以下示例:
>>> from decimal import *
>>> Decimal(0.1+0.1)
Decimal('0.200000000000000011102230246251565404236316680908203125')
>>> Decimal(0.2)
Decimal('0.200000000000000011102230246251565404236316680908203125')
>>> Decimal(0.1+0.1) == Decimal(0.2)
True
>>> Decimal(0.1+0.2)
Decimal('0.3000000000000000444089209850062616169452667236328125')
>>> Decimal(0.3)
Decimal('0.299999999999999988897769753748434595763683319091796875')
>>> Decimal(0.1+0.2) == Decimal(0.3)
False
>>>
总结
由于二进制的特殊性,我们没办法使用二进制精确的表示十进制的小数,所以为了不产生资损,金额尽量不使用 Float 类型表示,而按需求改为 Int 或 Decimal 类型。