在 Python 中如何正确地四舍五入?
用 Python 进行四舍五入是一个很常见的需求。但是经常有人用习惯性的思维去理解,导致出现与期望不同的现象,进而引发各种问题。
以下均以保留 2 位小数为例
round 函数的问题
这是网上的普遍的用法:
1
2
3
4
5In [3]: round(1.115, 2)
Out[3]: 1.11 # 错误,应该是 1.12
In [8]: round(0.375, 2)
Out[8]: 0.38 # 正确
可见,round 并不总是能返回我们想要的结果的。
一个容易发现的原因就是十进制小数转二进制时精度丢失的问题,即 Python 存储 1.115
的时候实际上是 1.1149...
:
1
2
3
4In [11]: from decimal import Decimal # decimal 是 Python 专门处理高精度的库
In [12]: Decimal(1.115)
Out[12]: Decimal('1.1149999999999999911182158029987476766109466552734375')
而对 1.1149
保留2位小数,当然是 1.11
而不是 1.12
了。
但是,十进制小数转二进制的时候也会出现能完全转换的,例如:1.125
:
1
2In [15]: Decimal(1.125)
Out[15]: Decimal('1.125')
那么这时 round 的结果会符合我们的预期吗?
1
2In [16]: round(1.125, 2)
Out[16]: 1.12
依然不行。这又是什么原因呢?
实际上,计算机语言都是这样处理的,这个方式叫:奇进偶舍
:
> 例如数 a.bcd,我们需要保留 2 位小数的话,要看小数点后第三位:
- 如果 d<5,直接舍去
- 如果 d<5,直接进位
- 如果 d == 5:
- d 后面还有非 0 数字,例如 a.bcdef,f 非 0,那么要进位
- d 后面没有数据,且 c 为偶数,那么不进位
- d 后面没有数据,且 c 为奇数,那么要进位
所以,我们可以总结一下,假如你要使用 round 来保留位数,那么你需要考虑 2 个问题:
- 这个数可能无法被计算机精确地表示,这个误差是否能接受?
- 奇进偶舍这个方法的误差是否能接受?
如果有一个答案是:不能,你就需要 decimal(其他的方法不考虑,见文章最后)。
decimal: 高精度解决方案
由我们自己去关注上面提到的问题显然是反人类的。于是 Python 提供了用于处理高精度数字的库:decimal
。
这个库直接解决了上面的第一个问题。
但是使用的时候需要注意给 Decimal
传字符串与浮点数的区别:
1
2
3
4
5In [24]: Decimal(1.115)
Out[24]: Decimal('1.1149999999999999911182158029987476766109466552734375')
In [25]: Decimal('1.115')
Out[25]: Decimal('1.115')
为什么两种不同呢?原因在于直接传浮点的时候,Python 没法存下 1.115
,将 1.115
转为 1.11499...
,所以在给 Decimal
的时候就已经丢失精度了。所以使用 decimal 的时候最好用字符串传参。并且,一定要全程使用 Decimal
类型,不要混用 float 与 Decimal
类型
接下来我们对 1.115
试试四舍五入,看看是不是我们需要的:
1
2In [27]: Decimal('1.115').quantize(Decimal('0.00'))
Out[27]: Decimal('1.12')
不错,是我们需要的。再试试 0.125
:
1
2In [33]: Decimal('0.125').quantize(Decimal('0.00'))
Out[33]: Decimal('0.12')
居然还是不行。问题又出在哪呢?其实和之前的 round
一样,也是 奇进偶舍
。为了证明这一点,我们可以去 Python 的官方文档查阅 quantize
的用法:
quantize
可以看到:
简单的翻译一下就是:quantize
有个可选参数 rounding
。如果不指定它的话,使用的是 context
的值。如果 context
也没指定值,那就用此线程的上下文中的 rounding
。
那么问题来了,rounding
到底有哪些值可选呢?文档也有:
那么我们显然没指定 rounding
,上下文用的是哪个呢?我们打印一下看看:
1
2
3
4In [42]: from decimal import getcontext
In [43]: getcontext().rounding
Out[43]: 'ROUND_HALF_EVEN'
ROUND_HALF_EVEN
即 Round to nearest with ties going to nearest even integer.
,就是我们上面提到的 奇进偶舍
。
而 ROUND_HALF_UP
才是我们想要的:
1
2
3
4
5
6
7In [65]: import decimal
In [66]: Decimal('1.115').quantize(Decimal('0.00'), rounding=decimal.ROUND_HALF_UP)
Out[66]: Decimal('1.12')
In [67]: Decimal('0.125').quantize(Decimal('0.00'), rounding=decimal.ROUND_HALF_UP)
Out[67]: Decimal('0.13')
这下第二个问题也解决了。
后记
感慨于这么简单的需求实际上有很多东西蕴含在里面。Python 尚且如此,其他语言呢?我特意问了一下擅长 C++ 的 Barriery,他说 C++里是这样四舍五入的:
1
int(0.115*100+0.5)/100
2 位就是 100,3 位就是 1000,以此类推。我拿到 Python 里试了一下,还真的可以:
1
2
3
4
5In [68]: int(0.115*100+0.5)/100
Out[68]: 0.12
In [69]: int(0.125*100+0.5)/100
Out[69]: 0.13
原理嘛,就是利用了 int 直接截断的特性。不得不说思路真是无奇不有啊。不过我还是推荐 decimal 这个库,专门处理高精度的数字,相当好用。
来呀快活呀