decimal: 为什么 0.1 + 0.2 不等于 0.3?

639 阅读7分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 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显然出现了误差,如果允许的话,有几个笨办法可以处理。

  1. 转为整形再计算

    >>> (0.1*100 + 0.2*100)/100
    0.3
    
  2. 四舍五入

    >>> round(0.1+0.22)
    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, (314), -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官方文档