ljianshu / Blog

关注基础知识,打造优质前端博客,公众号[前端工匠]的作者
7.9k stars 1.19k forks source link

慎用Number.toFixed() #95

Open ljianshu opened 3 years ago

ljianshu commented 3 years ago

引言

最近在公司项目中碰到一个隐藏的bug,调试许久才发现竟然是toFixed函数精度问题引起的,从而引发了我一系列的思考。

我们都知道,计算机在二进制环境下,浮点数的计算精度会存在缺失问题,最经典的例子就是为什么0.1+0.2不等于0.3?

image.png

遇到上述问题,我们自然而然会想到toFixed方法来四舍五入,可结果却差强人意!

toFixed()的精度问题

我们来看一下toFixed在chrome、火狐、IE浏览器下的不同表现:

image.png

可以看到toFixed的四舍五入在chrome、火狐上并不准确。 而toFixed在chrome、火狐上也并不是网上流传甚广的用银行家舍入法来进行四舍五入的。

银行家舍入法的规则是“四舍六入五考虑,五后非零就进一,五后为零看奇偶,五前为偶应舍去,五前为奇要进一”。

例如银行家舍入法在 (2.55).toFixed(1) = 2.5、(3.55).toFixed(1) = 3.5 上就不符合了。

翻阅ecmascript规范toFixed的表述如下:

Number.prototype.toFixed

上面规范这段大概意思就是如果toFixed的入参小于10的21次方,那么就取一个整数n,让n*10^f - x 的精确值尽可能的趋近于0,如果存在两个这样的n,取较大的n。这段话可能有点晦涩难懂,我们举个例子比如 1.335.toFixed(2)

image.png

上图例子中1.335.toFixed(2)按照四舍六入五成双应该是1.34,但是实际情况确实1.33。这是因为n=133的时候让n*10^f - x更趋近于0,所以最后得到的结果是1.33。

解决方法

1.重写toFixed()

我们可以通过重写toFixed的方法,来实现四舍五入:

Number.prototype.toFixed = function (length) {
  var carry = 0 // 存放进位标志
  var num, multiple // num为原浮点数放大multiple倍后的数,multiple为10的length次方
  var str = this + '' // 将调用该方法的数字转为字符串
  var dot = str.indexOf('.') // 找到小数点的位置
  if (str.substr(dot + length + 1, 1) >= 5) carry = 1 // 找到要进行舍入的数的位置,手动判断是否大于等于5,满足条件进位标志置为1
  multiple = Math.pow(10, length) // 设置浮点数要扩大的倍数
  num = Math.floor(this * multiple) + carry // 去掉舍入位后的所有数,然后加上我们的手动进位数
  var result = num / multiple + '' // 将进位后的整数再缩小为原浮点数
  /*
   * 处理进位后无小数
   */
  dot = result.indexOf('.')
  if (dot < 0) {
    result += '.'
    dot = result.indexOf('.')
  }
  /*
   * 处理多次进位
   */
  var len = result.length - (dot + 1)
  if (len < length) {
    for (var i = 0; i < length - len; i++) {
      result += 0
    }
  }
  return result
}

该方法的大致思路是首先找到舍入位,判断该位置是否大于等于5,条件成立手动进一位,然后通过参数大小将原浮点数放大10的参数指数倍,然后再将包括舍入位后的位数利用Math.floor全部去掉,根据我们之前的手动进位来确定是否进位。

2.high-precision-four-fundamental-rules

在GitHub上找到一个高精度的基本四则运算npm包,用来弥补原生JS中toFixed方法计算精度缺失的不足,该作者用四舍五入算法重写了改方法,并封装成npm包!

// 安装
$ npm install high-precision-four-fundamental-rules --save
// 使用
import {add, subtract, multiply, divide} from 'high-precision-four-fundamental-rules';
add(1, 2, 4); // '3.0000'
subtract(1, 2, 3); // '-1.000';
multiply(1, 2, 2); // '2.00';
divide(1, 3, 7); // '0.3333333';

参考文章

gdutwyg commented 1 year ago

image 你好,这个怎么解释呢?如果n一样,取较大的那个,但是实际是取较小的那个

saradean90 commented 1 year ago

Thanks for the information.. https://www.mygeorgiasouthern.org/

xiaokunxu commented 1 year ago

 您好,您的来信已收到,我会尽快阅读且回复,谢谢! 

Kerry12342 commented 8 months ago

When I tried to use IEEE 754 MIPS single precision representation (1-bit sign, 8-bit exponent, 23-bit fraction) to store the floating point decimal into binary and then convert back to decimal, if the last 3 bits of the fraction field are recognized as guard, round, and sticky, the calculated result is 1.334, which would round down to 1.33 like it showed up in your example.

I haven't test it so this is just a thought but would creating an algorithm with threshold for deciding which precision representation to use (use a larger precision when needed) to store the decimal be another way to solve this problem?