Q-Logo 我的学习笔记分享

从Python 的round 函数的坑谈四舍五入

round 函数官方文档

Python 3提供了内置的round 函数(round()),其说明如下:

round(number[, ndigits])

返回 number 舍入到小数点后ndigits位精度的值。 如果 ndigits 被省略或为 None,则返回最接近输入值的整数。

对于支持 round() 的内置类型,值会被舍入到最接近的 10 的负 ndigits 次幂的倍数;如果与两个倍数的距离相等,则选择偶数 (因此,round(0.5) 和 round(-0.5) 均为 0 而 round(1.5) 为 2)。 任何整数值都可作为有效的 ndigits (正数、零或负数)。 如果 ndigits 被省略或为 None 则返回值将为整数。 否则返回值与 number 的类型相同。

对于一般的 Python 对象 number, round 将委托给 number.__round__。

紧跟着说明,还有如下注解:

注解

对浮点数执行 round() 的行为可能会令人惊讶:例如,round(2.675, 2) 将给出 2.67 而不是期望的 2.68。 这不算是程序错误:这一结果是由于大多数十进制小数实际上都不能以浮点数精确地表示。 请参阅 浮点算术:争议和限制 了解更多信息。

注解中所引用的参考文档中,对浮点运算中的一些问题和限制进行了更详细的解释。

round 函数使用

round 函数输入

通过上面官方文档可以知道,round 函数用于返回一个数的指定精度的近似值,它接收两个输入参数:必需的 number 和可选的 ndigits 。

  • number 就是要舍入得到近似值的数;
  • ndigits 则指定舍入后得到的近似值的小数点后的位数,其取值可以是任意整数(正数、零或负数都可以)。(注意, 不提供 ndigits 与 ndigits=0 时的返回值是不同的 。)

round函数返回值类型

如果number 的类型是支持round 函数的内置类型(例如float、int),返回值是将number按指定精度舍入后得到的近似值,其类型与number 本身的类型及ndigits 有关:

  • ndigits 被省略或为 None 时,返回与 number 最接近的整数;
  • 其他情况下,返回值与 number 的类型相同,这里需要注意的时,ndigits=0时,返回值与number类型相同,与ndigits被省略或为None时的返回值类型并不相同。

如果number 的类型不是支持round 函数的内置类型,调用round 函数将会委托给 number.__round__,其返回值就要看这个__round__ 方法中是怎样处理的了。

返回值类型( number 为内置的 float 或 int 类型)举例如下:

>>> round(123)

或

>>> round(123,None)

123 # ndigits 被省略或为None,返回整数

>>> round(123.1)

或

>>> round(123.1,None)

123 # ndigits 被省略或为None,返回整数

>>> round(123,0)

123 # number=123为整数,ndigits=0, 返回值为整数与number 类型相同

>>> round(123.1,0)

123.0 # number=123.1为浮点数,ndigits=0, 返回值为浮点数与number 类型相同,

# 这里显示的123.0,后面的小数点和 0 表明返回值类型为浮点数

深入探讨round函数的返回值

从上一节的说明可以看操,round 函数返回值的类型是比较好理解的。但正如注解中提到的:对浮点数执行round 的行为可能会令人惊讶,换句话说,当 number 是浮点数时,round 函数的返回值有可能不是我们想要的结果。以下的探讨都假定 number 是浮点数。

首先,对浮点数执行 round ,返回的不是四舍五入的值,而是按以下规则确定的:

  1. 值会被舍入到距离 number 最接近的 10 的负 ndigits 次幂的倍数
  2. 如果 number 与两个倍数的距离正好相等,则选择偶数,即正好在中间时,向偶取整

换成常用的话就是“四舍六入五成双”(用这种规则进行舍入,在处理数据时其实是比较科学的)。下面看几个例子,

>>> round(0.49)

0 # 最接近 0.49 的整数是0(10的0次幂的0倍),返回0

>>> round(0.5)

0 # 0.5 与 0 和 1 (10的0次幂的0倍和1倍)的距离正好相同,都是0.5,向偶取整,返回0

>>> round(0.51)

1 # 最接近 0.51 的整数是 1 (10的0次幂的1倍),返回1

>>> round(-0.5)

0 # -0.5 与 0 和 -1 (10的0次幂的0倍和-1倍)的距离正好相同,都是0.5,向偶取整,返回0

>>> round(1.49)

1 # 最接近 1.49 的整数是1(10的0次幂的1倍),返回1

>>> round(1.5)

2 # 1.5 与 1 和 2 (10的0次幂的1倍和2倍)的距离正好相同,都是0.5,向偶取整,返回2

然后,我们输入和看到的浮点数,与计算机中存储和运算时使用的浮点数未必完全相同,多数时候后者只是前者的近似值。

官方文档中的注解中提到了这个例子

>>> round(2.675, 2)

2.67 # 将给出 2.67 而不是期望的 2.68,说好的正好中间向偶取整呢?

官方注解中也提到了,这是因为大多数十进制小数实际上都不能以浮点数精确地表示。也就是说,虽然我们输入了 2.675 ,但计算机中存储的数并不是 2.675 ,而是一个非常接近它的近似值。我们先不谈理论,只是简单粗暴地看一下我们输入的 2.657 这个十进制浮点数,在计算机中存储的近似值到底是多少

format(2.675,'.50f')

'2.67499999999999982236431605997495353221893310546875'

这是一个精确到小数点后50位的数,再往后看就是0了

format(2.675,'.60f')

'2.674999999999999822364316059974953532218933105468750000000000'

这说明,计算机中存储的是一个比 2.675 略小一点点的浮点数,并非精确的 2.675 ,它距离2.67比距离2.68更近些,这就是我们round(2.675, 2) 时返回的时2.67而不是2.68的直接原因。

那么 round(1.5) 为什么能返回 2 呢?我们同样简单粗暴地看一下它在计算机中存储的近似值是多少

format(1.5,'.60f')

'1.500000000000000000000000000000000000000000000000000000000000'

可以看到,正好是精确的 1.5,这是因为1.5=1+1/2,也就是说1.5可以转换为有限位的二进制浮点数。

那么为什么 2.675 在计算机中被近似出来一长串的小数,而 1.5 就能精确存储呢?这就要简单理解一点理论了。

现代计算机几乎都采用了 IEEE-754 (IEEE Standard for Floating-Point Arithmetic )浮点算术标准,Python 中的 float 类型就对应其中的双精度(64位)浮点数。这个标准使用64位二进制表示双精度浮点数(这也是计算机的特点决定的)。我们输入到计算机中的十进制数值,在计算机中存储和运算时会被转换为二进制。但是,多数的十进制小数,并不能用有限位的二进制浮点数精确表示,而计算机中保存的精度再高也是有限位的,将十进制小数转换为二进制浮点数时就会出现误差。当然这个误差(相对误差)一般很小,多数情况下我们甚至感觉不到它的存在。但这个误差毕竟还是存在的,其影响在某些运算中会表现出来,像上面的 round(2.675, 2) 返回2.67 而不是 2.68 就属于这种情况。这并不是错误,也不是bug,而是使用计算机进行浮点运算必然会出现的现象。

浮点运算慎用 round()

通过以上的探讨可以看出,除非对精确度要求比较底,否则尽量避免用round()函数处理浮点小数。

就是要四舍五入怎么办?

如果确实需要使用四舍五入的方式进行舍入(学校里就是这么教的,不这么用不舒服),那么,不用犹豫也不用纠结,用 decimal 模块:

decimal 模块为快速正确舍入的十进制浮点运算提供支持。 与 float 数据类型相比,它的一个优点就是:

Decimal “基于一个浮点模型,它是为人们设计的,并且必然具有最重要的指导原则 —— 计算机必须提供与人们在学校学习的算法相同的算法。” —— 摘自十进制算术规范。

用Decimal模块进行四舍五入的方法

>>> import decimal

>>> D = decimal.Decimal

>>> decimal.getcontext().rounding = "ROUND_HALF_UP" # 设置舍入方式为四舍五入

>>> D('2.675').quantize(D('0.00'))

Decimal('2.68') # 四舍五入后的值