Python的浮点数与Odoo的float_repr函数

Odoo最近的一个Issue中有很有 趣的关于Python浮点数的讨论。

问题的起点是说使用Odoo提供的float_round函数对浮点数做舍入操作得到的结果并不 正确,比如:

>>> from odoo.tools import float_round
>>> float_round(993.42, precision_digits=3)
>>> 993.4200000000001
>>> float_round(993.42, precision_rounding=0.001)
>>> 993.4200000000001

不管如何都不能得到需要的993.42这样的期望结果。发帖者认为Odoo的舍入函数有问题。事 实上是所有Python的浮点数都“有问题”。(当然Odoo在对于财务相关的数字采用浮点数Float而非 普遍认可的Decimal对象来处理是颇具争议的。这属于历史遗留问题,在此不做讨论)

要弄清楚这个问题,我们就首先要了解浮点数在计算机中的表示方式,比如十进制小数

0.125

在计算机中会表示为二进制小数:

0.001

即:0/2+0/4+1/8

但不幸的是,大多数的十进制小数不能象上例这样一对一保存为一个对应的二进制数。其实 这样的问题在十进制小数中也存在,比如要用十进制小数形式表示分数1/3,我们就只 能用0.3或更精确一点的近似数0.3333这样形式的十进制小数表示。

同样的十进制的小数转换为二进制小数就有可能是无限循环的,比如十进制小数0.1就是一 个无限循环的二进制小数:

0.0001100110011001100110011001100110011001100110011...

所以十进制的0.1在电脑中表示的就是一个近似数:

    >>> f = 0.1
    >>> "%.20f" % f
    '0.10000000000000000555'
    # 电脑内部将浮点数存储为:(sign, mantisssa, exponennt)
    >>> f.hex()
    '0x1.999999999999ap-4'
    # sign = 0 (+)
    # mantissa = 1 + (0x999999999999a / (2**52))
    # exponennt = -4
    >>> m = 1 + int("999999999999a", 16) / float(2**52)
    >>> m
    1.6
    >>> 1.6 * 2**-4
    0.1

因为是近似数,所以三个0.1相加也得不到精确的0.3,这样的结果也不会太意外了吧:

>>> .1 + .1 + .1 == .3
False

先舍入再相加,也无济于事:

>>> round(.1, 1) + round(.1, 1) + round(.1, 1) == round(.3, 1)
False

先计算后舍入,则可以将结果进行比较了:

>>> round(.1 + .1 + .1, 10) == round(.3, 10)
True

虽然搞明白了浮点数在计算机中并非精确数,但也不必太过恐慌,其每次的计算误差不超过 253分之一。当然也可以考虑使用 Python decimal模块

在Odoo中,让舍入结果显得“合理”的方法是使用函数float_repr

>>> f = 993.42
>>> rounded = float_utils.float_round(f, 3)
>>> rounded
993.4200000000001
>>> print float_utils.float_repr(rounded, 3)
993.420
>>> print float_utils.float_repr(rounded, 2)
993.42

Odoo框架在保存浮点数到数据库时也使用了 float_repr, 参见代码