开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第1天,点击查看活动详情
decimal: 为什么 0.1 + 0.2 不等于 0.3?
Python 中关于 float 类型计算的一些背景
我们在工作中遇到 float 类型还是很普遍的,但Python 的十进制浮点运算中的精度并不准确,如下所示:
>>> 0.1 + 0.1
0.2
>>> 0.1 + 0.2
0.30000000000000004
简单来说,就是因为计算机无法准确计算浮点类型。因为在计算机中,计算数字是以二进制形式计算的,而浮点型有带有小数,计算机无法将小数全部转化为二进制的数字,在运算上也就出现了误差(可参阅为什么十进制浮点数常常无法用二进制精确表示?)。
如上代码中,0.1 + 0.1 符合预期,但 0.1 + 0.2显然出现了误差,如果允许的话,有几个笨办法可以处理。
-
转为整形再计算
>>> (0.1*100 + 0.2*100)/100 0.3 -
四舍五入
>>> round(0.1+0.2, 2) 0.3
但是,业务中这样的处理方法不仅留存了风险,这种写法也显得非常低级。如果对于一些金额计算,要求不能有丝毫的误差,那么浮点类型更会为整个项目埋下雷点。
因此我们需要使用高精度的计算,python 中的 decimal 模块,正好可以为高精度十进制运算提供支持。
在涉及金钱的计算中,必须保证精度的准确
decimal 的入门用法
Decimal 类型
我们最常用的时 decimal 中的 Decimal。Decimal 类型可以做到无偏差和精准:
>>> from decimal import Decimal
>>> Decimal(10)
Decimal('10')
>>> Decimal("3.14")
Decimal('3.14')
>>> Decimal(3.14)
Decimal('3.140000000000000124344978758017532527446746826171875')
最基本的功能,我们可以使用 Decimal 处理整数、字符串、浮点数
但是我们显然 Decimal(3.14) 返回的数据并不是我们想要的,因为它的精度并不准确。这里就需要提到第一个关键点
关键点 1:不要将浮点数据传递给 Decimal,因为浮点数本身就是不准确的。
遇到浮点数,我们最好将浮点数转化为字符串,再使用 Decimal 转化,避免精度丢失在传入 Decimal 前就已经发生。
>>> Decimal(str(3.14))
Decimal('3.14')
在使用 Decimal 运算时,也要注意这个问题,其中有一个数是 float 也会导致 Decimal 出现精度问题:
>>> Decimal(0.1) * Decimal(0.2)
Decimal('0.02000000000000000222044604925')
>>> Decimal(str(0.1)) * Decimal(str(0.2))
Decimal('0.02')
特殊值
Decimal 不仅可以处理整数、字符串、浮点数等,它还支持传入一些其他结构,其中有些颇为特殊。
-
元组
>>> Decimal((0, (3, 1, 4), -2)) Decimal('3.14')基于元组转化为 Decimal 不太直观,这里解释一下元组中 3 个元素各表示什么
- 第一位表示正负符号,0 表示正数,1 表示负数
- 第二位,数字元组,由这些数字组成一个完整数字
- 第三位,表示有几位小数
这种方式虽然不方便,但是它提供了一种可移植的方式。比如在一些地方确实要存储小数,但又无法保证精确的时候,可以使用元组存储,然后取值时再用 Decimal 转回。
-
无穷、非数字与零
无穷时数字中一个特殊的存在,非数字 NaN 是数据处理中的常客,0 特殊在它既不是正数也不是负数,而是正负数的分界点。
简单点,我们先看看以上三种在 Decimal 中的表现:
>>> Decimal('NaN') Decimal('NaN') >>> Decimal('Infinity') Decimal('Infinity') >>> Decimal('0') Decimal('0')更近一步,我们研究一下其正负值的影响,以及数字的比较
from decimal import Decimal # 正负值 test_list = ["NaN", "Infinity", "0"] for num in test_list: print("positive value: {}, negative value: {}".format(Decimal(num), Decimal("-" + num)))结果,它们是存在正负值的:
positive value: NaN, negative value: -NaN positive value: Infinity, negative value: -Infinity positive value: 0, negative value: -0为什么 NaN 存在负值? [1]
因为在 Decimal 中,格式是由三个字段确定的:符号位、整数、小数组成的。由于存在符号位,因此也就存在正负值。这是由符号位确定的,并不是 NaN 本身有正负之分。
# 比较 print(Decimal('NaN') == Decimal('Infinity')) # False print(Decimal('NaN') != Decimal(1)) # True与 NaN 比较相等性总会返回 false,而比较不等性总会返回 true。
# 测试运算 a1 = Decimal('Infinity') + Decimal(1) a2 = Decimal('-Infinity') + Decimal(1) print(a1, a2) # Infinity -Infinity b1 = Decimal('NaN') + Decimal(1) b2 = Decimal('-NaN') + Decimal(1) print(b1, b2) # NaN -NaN c1 = Decimal('-0') + Decimal(1) print(c1) # print(c1)与无穷大值相加会返回另一个无穷大值。NaN 同理。
此时,我很好奇,随便传入一个英文字符串,会出现什么
test1 = Decimal("test1") print(test1)返回了:
InvalidOperation Traceback (most recent call last) Input In [7], in <cell line: 1>() ----> 1 test1 = Decimal("test1") 2 print(test1) InvalidOperation: [<class 'decimal.ConversionSyntax'>]显然,这也证明了 Infinity / NaN 是可以参与运算的,不是随便写的字符串。另外,Infinity 也可以简写位 Inf 或 inf
d1 = Decimal("inf") d2 = Decimal("-inf") d3 = Decimal("Inf") d4 = Decimal("-Inf") print(d1, d2, d3, d4) # Infinity -Infinity Infinity -Infinity
上下文
上下文(context)可以用于覆盖一些默认的设置,比如数字精度、舍入规则、错误处理等。我们可以使用 getcontext() 来获取当前全局上下文:
import decimal
ctx = decimal.getcontext()
print(ctx)
返回了一个上下文对象:
Context(prec=28, rounding=ROUND_HALF_EVEN, Emin=-999999, Emax=999999, capitals=1, clamp=0, flags=[InvalidOperation], traps=[InvalidOperation, DivisionByZero, Overflow])
- 我们主要关注 prec,表示精度
- 其他的参数可以前往官方文档中了解
# 控制精度
ctx.prec = 3
## 它们并不会受到 prec 的影响,原本是多少就是多少,位数不会变化
d1 = Decimal('1.2345678')
d2 = Decimal('8.7654321')
print(d1, d2) # 1.2345678 8.7654321
## 计算结果受 prec 影响
d3 = d1 * d2
print(d3) # 10.8
从结果也可以看出来,prec 控制的是 Decimal 返回的长度(如果个位数是0,不会计入长度中)。也就是说使用 prec 确定的实际上是整体的长度,如果整数部分很长,或者变动大,那么势必会影响到小数部分的准确性,所以手动设置 prec 时,也要考虑业务中数值的波动。
关键点2:注意 prec 的设置,否则也有精度丢失的可能
一般情况下我们按照默认值是足够的,如果我们设置的 prec 很小,那么在一些计算中就可能出现错误。
# prec 过小导致精度丢失
ctx.prec = 5
num = "888.888"
t1 = Decimal(num)
print("t1 = ", t1) # t1 = 888.888
t2 = t1 * Decimal("100")
print("t2 = ", t2) # t2 = 88889
prec 无法控制小数点后面的精度,当然有另一种方式可以确定,那就是 decimal.quantize
取整
使用 decimal.quantize 方法将数字进行舍入,这样就能保证小数的位数了
ctx.prec = 10
t1 = Decimal(1) / Decimal(3)
print(t1) # 0.3333333333
t1 = t1.quantize(Decimal("0.0000"))
print(t1) # 0.3333
print(Decimal('7.325').quantize(Decimal(".01"))) # 7.32
此外,还可以通过 rounding 参数,传入一些 decimal 库设置好的常量,来确定舍入的规则:
ROUND_CEILING: 总是趋向无穷大向上取整。ROUND_DOWN: 总是趋向0取整。ROUND_FLOOR: 总是趋向无穷大向下取整。ROUND_HALF_DOWN: 如果最后一个有效数字大于或等于5则朝0反方向取整;否则,趋向0取整。ROUND_HALF_EVEN: 类似于ROUND_HALF_DOWN,不过如果最后一个有效数字为5,则值检查前一位。偶数值会导致结果向下取整,奇数值导致结果向上取整。ROUND_HALF_UP: 类似于ROUND_HALF_DOWN,不过如果最后一个有效数字为5,则值会朝0的反方向取整。ROUND_UP: 朝0的反方向取整ROUND_05UP:如果最后一位是0或5,则朝0的反方向取整;否则向0取整。
from decimal import *
t1 = Decimal('7.325').quantize(Decimal('.01'), rounding=ROUND_DOWN)
t2 = Decimal('7.325').quantize(Decimal('1.'), rounding=ROUND_UP)
print(t1, " ",t2) # 7.32 8
数学运算
除了基本运算,decimal 也支持一些数学函数
# 数学函数
n1 = Decimal(2).sqrt() # 平方根
n2 = Decimal(1).exp() # 指数
n3 = Decimal('10').ln() # 自然对数
n4 = Decimal('10').log10() # 以 10 为底的对数
print(n1, n2, n3, n4, sep="\n")
# 结果
# 1.414213562
# 2.718281828
# 2.302585093
# 1
一些常用函数
-
compare() : 该函数用于比较 Decimal。如果参数1大于参数2,则返回 1;参数1小于参数2,则返回 -1;如果两者相等,则返回 0
-
compare_total_mag(): 忽略符号,其他同 compare()
a = Decimal(0.07) b = Decimal(-9.96) print(a.compare(b)) # 1 print(a.compare_total_mag(b)) # -1 -
copy_abs() : 取绝对值
-
copy_negate():取反
c = Decimal("-3.33") d = Decimal("5.55") print(c.copy_abs(), d.copy_abs()) print(c.copy_negate(), d.copy_negate()) -
逻辑运算:logical_and() 、 logical_or()、 logical_xor() 、 logical_invert()
- 使用方式:
decimal_val1.logical_method(decimal_val2)
- 使用方式:
-
判断
- is_zero(): 如果参数是 0,则返回 True,否则返回 False.
- is_signed(): 如果参数带有负号,则返回为 True,否则返回 False。
- is_infinite() : 如果参数为正负无穷大,则返回为 True,否则返回 False。
- is_nan() : 检查 Decimal 值是否为 NaN 值, 是则返回为 True,否则返回 False。
参考文献
[1] Why is there a negative NaN in python Decimal type
[2] decimal官方文档