你不是真正的四舍五入

4,237 阅读9分钟

前言

因为JavaScript采用IEEE-754标准表示浮点数,并不能精确表示许多实数,所以会有一些存在。本文就是对方面的问题做一个刨根揭底的探索以及摸索对应的解决方案。

1 问题

之前公司业务是跨境电商,会出现需要前端计算税费的业务,比如下面这种

65.00(商品价格)* 0.119(税费) = 7.734999999999999

当在这种计算的时候,会对精度进行控制,保持所有的数据都是2位小数,这时候本胖就会用toFixed这个函数

(65.00(商品价格)* 0.119(税费)).toFixed(2) = 7.73(不符合预期)

本胖一开始看到这个答案的时候天真地认为是浏览器抛锚了,于是用手机计算机算了一遍,答案是7.74,那么问题来了,到底是谁错了呢?于是本胖查阅了很多相关资料以及动手实验,最终有了本文的诞生。

2 浮点数

在解决问题之前,我们需要来了解一下什么是浮点数。

2.1 什么是浮点数

在计算机系统的发展过程中,曾经提出过多种方法表达实数。典型的比如相对于浮点数的定点数(Fixed Point Number)。在这种表达方式中,小数点固定的位于实数所有数字中间的某个位置。定点数表达法的缺点在于其形式过于僵硬,固定的小数点位置决定了固定位数的整数部分和小数部分,不利于同时表达特别大的数或者特别小的数。最终,绝大多数现代的计算机系统采纳了所谓的浮点数表达方式。这种表达方式利用科学计数法来表达实数,即用一个尾数(Mantissa,尾数有时也称为有效数字——Significand;尾数实际上是有效数字的非正式说法),一个基数(Base),一个指数(Exponent)以及一个表示正负的符号来表达实数。比如 123.45 用十进制科学计数法可以表达为 1.2345 × 102 ,其中 1.2345 为尾数,10 为基数,2 为指数。浮点数利用指数达到了浮动小数点的效果,从而可以灵活地表达更大范围的实数。

计算机中是用有限的连续字节保存浮点数的。在 IEEE 标准中,浮点数是将特定长度的连续字节的所有二进制位分割为特定宽度的符号域,指数域和尾数域三个域,其中保存的值分别用于表示给定二进制浮点数中的符号,指数和尾数。这样,通过尾数和可以调节的指数(所以称为"浮点")就可以表达给定的数值了。

2.2 IEEE 浮点数

计算机中是用有限的连续字节保存浮点数的。在 IEEE 标准中,浮点数是将特定长度的连续字节的所有二进制位分割为特定宽度的符号域,指数域和尾数域三个域,其中保存的值分别用于表示给定二进制浮点数中的符号,指数和尾数。这样,通过尾数和可以调节的指数(所以称为"浮点")就可以表达给定的数值了。很多语言都是用这种规范的浮点数表示法,javascript也不例外。

IEEE 754 指定:

两种基本的浮点格式:单精度和双精度。

IEEE 单精度格式具有 24 位有效数字精度,并总共占用 32 位。

IEEE 双精度格式具有 53 位有效数字精度,并总共占用 64 位。

2.3 范围和精度

从上面可以看到一个数在计算机里面的表示位数是有限的,儿Javascript 作为一门动态语言,其数字类型只有 number 一种。 nubmer 类型使用的就是 IEEE754 标准中的 双精度浮点数。也就是说在js里面一个数的存储空间都是固定死的。下面来看一下这个定死的空间到底有多大。

从上图可以看出 在js里面只有52位用来存放数据。

那么问题来了,如果一个数52位存储空间不够,也就是二进制也会出现想十进制一样的无限数的时候,会发生什么事情呢?

IEEE754采用的浮点数舍入规则有时被称为舍入到偶数(Round to Even)

这有点像我们熟悉的十进制的四舍五入,即不足一半则舍,一半以上(包括一半)则进。不过对于二进制浮点数而言,还多一条规矩,就是当需要舍入的值刚好是一半时,不是简单地进,而是在前后两个等距接近的可保存的值中,取其中最后一位有效数字为零者。

3 计算精度

在解决问题之前,我们还需要先理解计算过程 这里用一个最经典的例子先来说明一下js中数据计算的过程

0.1 + 0.2

很多人都看到过这个表达式,那么这个表达式背后究竟发生了什么过程呢,请看本胖一步步说来

A 十进制转二进制

第一步浏览器会将我们看到的十进制0.1以及0.2都转为二级制的0.1和0.2

对于十进制转二进制,大部分人都知道整数是除2取余,逆序排列 直到商为0时为止。但是小数呢,规则是和整数不一样的,规则如下

乘2取整,顺序排列 直到积中的小数部分为零

有了规则,我们现在来对0.1,0.2做一个转化

0.1转为二进制

 二进制0.00011001100110011…(循环0011)     
 尾数为1.1001100110011001100…1100(共52位,除了小数点左边的1),指数为-4(二进制移码为00000000010),符号位为0    
 计算机存储为:0 00000000100 10011001100110011…11001    
 因为尾数最多52位,所以实际存储的值为0.0001100110011001100110011001100110011001100110011001101

0.2转为二进制

 二进制0.0011001100110011…(循环0011)    
 尾数为1.1001100110011001100…1100(共52位,除了小数点左边的1),指数为-3(二进制移码为00000000011),符号位为0    
 存储为:0 00000000011 10011001100110011…11001    
 因为尾数最多52位,所以实际存储的值为0.001100110011001100110011001100110011001100110011001101

0.1的二进制 + 0.2的二进制 这里要先说一下二进制的加法规则

计算机计算二进制加法是分三部,第一步为将两个加数转换为二进制数,计算两个加数不需要进位的和,得出的结果。
第二部将两个加数进行与运算(&)。
第三部利用与运算得到结果进行左移运算(<<)(同时为计算两个加数需要进位的和),得出结果。将或异运算的结果和左移运算的结果作为两个新的加数,重复此操作。直到当与运算的结果为0,则异或运算的结果则为两个加数的和所对应的二进制数。

按照上述规则,我们已经将0.1,0.2都完成了第一步,现在要进行第二三步。最终得到如下结果

0.01001100110011001100110011001100110011001100110011001100 

然后将二进制所得的结果再转为十进制的表示,下面是二进制小数转为十进制的规则

整数部分是从右到左用二进制的每个数去乘以2的相应次方,小数部分则是从左往右开始计算

最终得到的结果就是0.30000000000000004

好了,一个0.1+0.2的计算过程大概就是这些过程。

4 四舍五入问题

4.1 问题

现在,我们就可以来问答一开始的问题了,为什么会出现文章一开始计算的问题情况了。

1 65.00(商品价格)* 0.119(税费) = 7.734999999999999 !== 7.735
2 (65.00(商品价格)* 0.119(税费)).toFixed(2) = 7.73 != 7.74

第一个相乘为什么不等于7.735呢,本胖在第三节就给出了解释,现在我们来说一下第二个四舍五入精度问题。

对一个数进行四舍五入操作的时候,也是需要先将这个我们理解的十进制数转为计算机理解的二进制数,然后用计算机的四舍五入规则(2.3 范围和精度中已经说明)进行对应的操作。

当65.00(商品价格)* 0.119(税费)的二进制存储的时候就已经是一个近似值了,然后再用二进制的四舍五入进行操作最后将得到的结果再转为十进制当然会存在一个误差。

看到这里是不是就明白了为什么在调用toFixed()方法的时候会存在误差,因为我们看到的都是十进制的世界,而真实运算的却是二进制的世界,不同的二个世界,不同的规则。

4.2 解决方案

前面说到十进制的小数在转为二进制的时候很容易出现无法精确表示的情况,但是十进制整数,在转为二进制的时候是都可以精确表示的,因为十进制整数转二进制的规则如下

除2取余,逆序排列,直到商为0时为止

所以机智的同学就会想到下面的办法

把小数放到位整数(乘倍数),再缩小回原来倍数(除倍数)

按照上面的办法可以写出下面的函数

function toFixed(num, s) {
  var times = Math.pow(10, s)
  var des = num * times
  des = Math.round(des) / times
  return des + ''
}

注意这里用了Math.round()这个方法将数字转为最接近的整数,如果用parseInt()的话需要手动加0.5。下面是这3个方法的主要区别。

1. Math.round

作用:四舍五入,返回参数+0.5后,向下取整。

Math.round(5.57)&emsp;&emsp;//返回6

Math.round(2.4) &emsp;&emsp;//返回2

Math.round(-1.5)&emsp;&emsp;//返回-1

Math.round(-5.8)&emsp;&emsp;//返回-6

2.parseInt

作用:解析一个字符串,并返回一个整数,这里可以简单理解成返回舍去参数的小数部分后的整数。

parseInt(5.57)&emsp;&emsp;//返回5

parseInt(2.4)&emsp;&emsp;//返回2

parseInt(-1.5)&emsp;&emsp;//返回-1

parseInt(-5.8)&emsp;&emsp;//返回-5

5 总结

1.看似有穷的数字, 在计算机的二进制表示里却是无穷的,由于存储位数限制因此存在“舍去”,精度丢失就发生了。

2.解决精度丢失的方法就是把小数放到位整数(乘倍数),再缩小回原来倍数(除倍数)