【源码分析系列】number-precision和bignumber.js

01_JS精度

好久前在公司分享的文章,现在才发…本文阐述了为什么0.1 + 0.2 != 0.3,并分析了number-precision和bignumber.js的解决原理

被JS精度问题小坑了一把,所以系统来 复习 学习一波~

背景

在实际业务开发中,可能会遇到一下问题:

// 加法
0.1 + 0.2      // 0.30000000000000004

// 减法
1.5 - 1.2      // 0.30000000000000004

// 乘法
19.9 * 100     // 1989.9999999999998

// 除法
0.3 / 0.1      // 2.9999999999999996

toFixed()toPrecision() 在必要时进行四舍五入

有时候我们会用toFixed() 来解决这个问题,但是其实这个方法有时候会出现不希望的结果:

2.54.toFixed(1)         // 2.5
2.56.toFixed(1)         // 2.6

2.55.toFixed(1)             // error: 2.5
2.55.toPrecision(1)         // error: 2.5

业界内也诞生了一道经典的面试题:0.1 + 0.2 为什么不等于0.3?

因为 JS 采用IEEE 754 双精度版本(64位),并且只要采用 IEEE 754 的语言都有该问题。

IEEE754

前置知识

  • 计算机内部都是采用二进制进行表示,即0 1编码组成;

  • 十进制数转为二进制:

    • 正整数转二进制:将正整数除以2,得到的商继续再除以2,直到商为0或1为止,然后将余数倒着链接起来即可;
      在这里插入图片描述
      然后高位补0,如果是8位,那么在前面补2个0,所以最后结果是 :

    00100110 0010 0110 00100110

    • 负数转二进制:先将正整数转为二进制之后,对二进制取反,然后对结果再加1;

      -38 为例子,38的二进制是0010 0110 ,则取反后的结果是1101 1001 ,加1之后结果为:1101 1010

    • 小数转二进制:对小数点以后的数乘以2,取整数部分,再用小数部分乘以2,依次来推,直到小数部分为0或者位数已经OK了,再把整数部分依次排列就是小数的二进制结果了:

      以0.125为例子:

      0.125 * 2 = 0.25 --------------- 取整数 0,小数 0.25
      0.25 * 2 = 0.5 ----------------- 取整数 0,小数 0.5
      0.5 * 2 = 1 -------------------- 取整数 1
      
      

    所以结果是 0.001,可以按需低位补0。

    • 小数的整数部分大于0时,将整数、小数部分依次转为二进制,然后加在一起就OK。所以 38.125的二进制就是:0010 0110.001
  • 科学计数法,首先以10进制科学计数法为例子:

    • 23.32 => 0.2332 => 小数点向左移动了2位置,所以最终的结果是

      0.2332 ∗ 1 0 2 0.2332 * 10^2 0.2332102
      二进制在存储的时候是以二进制的科学计数法来存储的,如果是二进制科学计数法,则:

    • 10111=> 1.0111=> 小数点向左移动了4位,4转为2进制是100,所以最终的结果是
      1.0111 ∗ 2 ( 100 ) 1.0111 * 2^(100) 1.01112(100)

根据二进制科学技术法,小数点前必须有一个非0

什么是IEEE754

IEEE754标准中规定:

  • float单精度浮点数在机器中表示用 1 位表示数字的符号,用 8 位来表示指数,用23 位来表示尾数,即小数部分。
  • 对于double双精度浮点数,用 1 位表示符号,用 11 位表示指数,52 位表示尾数,其中指数域称为阶码。所有数值的计算和比较,都是这样以64个bit的形式来进行的

在这里插入图片描述

JS中,所有Number都是以 64bit的双精度浮点数存储的。

符号S

由于计算机万物都是以二进制表示,为了理解符号,一般将最高位当作符号位来理解,0代表+,1代表-。

指数E

它占了11位,所以取值范围是0~2的11次方,即0~1024位,即可以代表1024个数字。但是IEEE 754 标准规定指数偏移值的固定值 2 e − 1 − 1 2^{e-1}-1 2e11,以双精度浮点数为例: 2 11 − 1 − 1 = 1023 2^{11-1}-1=1023 21111=1023

为什么 IEE754浮点数标准中64位浮点数的指数偏移量是1023?

在这里插入图片描述

以32位浮点数为例子,指数占8位,即0-2的8次方,也就是256。由于指数也有正负,所以从中间劈开,-128 ~ +128,但是中间有个0,所以表示-128到127这256个数字。

怎么记录正负?一种作法是把高位置1,这样我们只要看到高位是1的就知道是负数了,所谓高位置1就是说把0到255这么多个数字劈成两半,从0到127表示正数,从128到255表示负数。但是这种作法会带来一个问题:当你比较两个数的时候,比如130和30,谁更大呢?机器会觉得130更大,但实际上130是个负数,它应该比30小才对啊。

所以后来有人提出了,将所有数字加上128,这样-128 + 128 = 0, 127 + 128 = 255这样比较,就不存在负数比正数大的情况。

所以如果你读到0,就减去128,则得到负指数-128,读到255,减去128,就得到127。

那为什么最终指数偏移量是127,不是128,因为不允许使用0和255两个数字代表指数。少了2个数字,所以只能采用127。

同理,64位,指数11位,即2^11 = 2048,对半1024,去掉0和2048,所以偏移量用1023

尾数M

对于尾数M,只保存后面的小数部分。这是由于1≤M<2,在计算机内部保存M时,默认这个数的第一位总是1,因此可以被舍去,这样做的好处是可以节省一位有效数字。对于双精度 64 位浮点数,M 为 52 位,将第一位的 1 舍去,可以保存的有效数字为 52 + 1 = 53 位。

根据二进制科学技术法,小数点前必须有一个非0,那么有效域即1.xxxx,小数点前的1默认存在,但是默认不占坑,尾数部分就存储小数点后的部分

十进制转为IEEE754

当我们使用 Number的时候,计算机底层会自动将我们输入的十进制数字自动转为 IEEE754标准的浮点数。

以0.1为例子,它转为二进制科学计数法是这个:

0.1001100110011001100110011001100110011001100110011001 ∗ 2 − 4 0.1001100110011001100110011001100110011001100110011001*2^{-4} 0.100110011001100110011001100110011001100110011001100124

  • 由于0.1是正数,所以符号位是0;
  • 指数是-4,则-4 +1023 = 1019,转为二进制为1111111011,共10位。由于指数E为11位,所以高位补0.最终得出 01111111011;
  • 尾数最多存储52位,所以会采取进1舍0的情况:
    11001100110011001100110011001100110011001100110011001 // M 舍去首位的 1,得到如下
    1001100110011001100110011001100110011001100110011001  // 01 入,得到如下:
    1001100110011001100110011001100110011001100110011010  // 最终存储
    

0舍1入法:尾数右移时,被移去的最高位数值为0,则舍去;被移去的最高位数值为1,则在末位加1

所以0.1的最终转换结果为:

S  E            M
0  01111111011  1001100110011001100110011001100110011001100110011010 

那么同理,0.2的最终转换结果为:

S  E            M
0  01111111100  1001100110011001100110011001100110011001100110011010 // 0.2

浮点数的运算

对阶

在进行结算前,需要判断两个数的指数位是否相同,即小数点位置是否一致。0.1的阶码是-4,而0.2的阶码是-3,根据 小阶向大阶看齐原则,需要将0.1进行移码操作:尾数向右移动1位,指数位+1:

// 0.1 移动之前
0  01111111011  1001100110011001100110011001100110011001100110011010 

// 0.1 右移 1 位之后尾数最高位空出一位,(0 舍 1 入,此处舍去末尾 00  01111111100   100110011001100110011001100110011001100110011001101(0) 

// 0.1 右移 1 位完成
0  01111111100  1100110011001100110011001100110011001100110011001101

p.s. 不改变最高位值,是 1 补 1,是 0 补 0。尾数部分我们是有隐藏掉最高位是 1 的

尾数求和

  0  01111111100   1100110011001100110011001100110011001100110011001101 // 0.1 
+ 0  01111111100   1001100110011001100110011001100110011001100110011010 // 0.2
= 0  01111111100 100110011001100110011001100110011001100110011001100111 // 产生进位,待处理

规格化和舍入

由于产生进位,阶码需要 + 1, 所以为 01111111101,对应的十进制为 1021,1021 - 1023 = -2,所以:

  S  E
= 0  01111111101

尾部进位 2 位,去除最高位默认的 1,因最低位为 1 需进行舍入操作(在二进制中是以 0 结尾的),舍入的方法就是在最低有效位上加 1,若为 0 则直接舍去,若为 1 继续加 1:

  100110011001100110011001100110011001100110011001100111 // + 1
=  00110011001100110011001100110011001100110011001101000 // 去除最高位默认的 1
=  00110011001100110011001100110011001100110011001101000 // 最后一位 0 舍去
=  0011001100110011001100110011001100110011001100110100  // 尾数最后结果

IEEE 754 中最终存储如下:

S  E           M
0  01111111101 0011001100110011001100110011001100110011001100110100

IEEE754转为十进制

根据公式:

n = ( − 1 ) s ∗ 2 ( e − 1023 ) ∗ ( 1 + f ) n = (-1)^s * 2^(e-1023)*(1+f) n=(1)s2(e1023)(1+f)

( − 1 ) 0 ∗ 2 ( − 2 ) ∗ ( 1 + 0011001100110011001100110011001100110011001100110100 ) (-1)^0 * 2(-2) * (1 + 0011001100110011001100110011001100110011001100110100) (1)02(2)(1+0011001100110011001100110011001100110011001100110100)

最终答案为:

0.30000000000000004

当你打印的时候,其实发生了二进制转为十进制,十进制转为字符串,最后输出的。而十进制转为二进制会发生近似,那么二进制转为十进制也会发生近似,打印出来的值其实是近似过的值,并不是对浮点数存储内容的精确反映。

How does javascript print 0.1 with such accuracy?

精度丢失点

  • 十进制转二进制,如果遇到小数是无限循环,超过52位,那么就会被舍入;
  • 浮点数参与计算的时候需要对阶,以加法为例,要把小的指数域转化为大的指数域,也就是左移小指数浮点数的小数点,一旦小数点左移,必然会把52位有效域的最右边的位给挤出去,这个时候挤出去的部分也会发生“舍入”。这就又会发生一次精度丢失。

解决思路

  • 借助 parseFloat 对结果进行指定精度的四舍五入,但是并不保守
    210000 * 10000  * 1000 * 8.2                   // 17219999999999.998
    parseFloat(17219999999999.998.toFixed(12));    // 17219999999999.998
    parseFloat(17219999999999.998.toFixed(2));     // 而正确结果为 17220000000000
    
    
  • 将浮点数转为整数运算,再对结果做除法,目前足够应付大多数场景的思路就是,将小数转化为整数,在整数范围内计算结果,再把结果转化为小数,因为存在一个范围,这个范围内的整数是可以被IEEE754浮点形式精确表示的
    0.1 + 0.2                        // 0.30000000000000004
    (0.1 * 100 + 0.2 * 100) / 100    // 0.3
    
  • 将浮点数转为字符串,模拟实际运算的过程。

常见的轮子

number-precision

https://github.com/nefe/number-precision

用法
import NP from 'number-precision'
NP.strip(0.09999999999999998); // = 0.1
NP.plus(0.1, 0.2);             // = 0.3, not 0.30000000000000004
NP.plus(2.3, 2.4);             // = 4.7, not 4.699999999999999
NP.minus(1.0, 0.9);            // = 0.1, not 0.09999999999999998
NP.times(3, 0.3);              // = 0.9, not 0.8999999999999999
NP.times(0.362, 100);          // = 36.2, not 36.199999999999996
NP.divide(1.21, 1.1);          // = 1.1, not 1.0999999999999999
NP.round(0.105, 2);            // = 0.11, not 0.1
原理

主要就是结合了 parseFloat() 将小数转为了整数。以加法为例子:

function plus(...nums: numType[]): number {
  // 如果是多个参数,则递归相加
  if (nums.length > 2) {
    return iteratorOperation(nums, plus);
  }

  const [num1, num2] = nums;
  // 取两个数当中,小数位长度最大的值的长度
  const baseNum = Math.pow(10, Math.max(digitLength(num1), digitLength(num2)));
  // 把小数都转为整数然后再计算
  return (times(num1, baseNum) + times(num2, baseNum)) / baseNum;
}
  • 取两数之中小数最大的小数长度作为基数;
  • 将两个数转为整数相加之后然后除以基数。

其中:

function times(...nums: numType[]): number {
  // 如果是多个参数,则递归相乘
  if (nums.length > 2) {
    return iteratorOperation(nums, times);
  }
  
  // 将每个变量转为整数并相乘
  const [num1, num2] = nums;
  const num1Changed = float2Fixed(num1);
  const num2Changed = float2Fixed(num2);
  const leftValue = num1Changed * num2Changed;
  
  // 检查是否越界,如果越界就报错
  checkBoundary(leftValue);
  
  // 获得分母,即Math.pow(10,小数长度的数量)
  const baseNum = digitLength(num1) + digitLength(num2);
  return leftValue / Math.pow(10, baseNum);
}

float2Fixed 将小数转为整数:

function float2Fixed(num: numType): number {
  // 如果不是科学计数法,直接去掉小数点
  if (num.toString().indexOf('e') === -1) {
    return Number(num.toString().replace('.', ''));
  }
  
  // 如果是科学计数法,获得小数的长度
  const dLen = digitLength(num);
  return dLen > 0 ? strip(Number(num) * Math.pow(10, dLen)) : Number(num);
}

digitLength 计算小数的长度:

// 常见的数字:1、0.1、2.2e-7
// 其中 2.2e-7 实际上就是指 0.00000022
            
function digitLength(num: numType): number {
  // 获取指数前后的数字
  const eSplit = num.toString().split(/[eE]/);
  // 如果 e 之前是小数,获取小数的数量 + e之后的数量
  const len = (eSplit[0].split('.')[1] || '').length - +(eSplit[1] || 0);
  // 返回小数的长度
  return len > 0 ? len : 0;
}

借助 parseFloat

function strip(num: numType, precision = 15): number {
  return +parseFloat(Number(num).toPrecision(precision));
} 

console.log(strip(0.1 + 0.2));    // 0.3

bignumber.js

https://github.com/MikeMcl/bignumber.js

这位大佬同时还写了 big.jsdecimal.js等跟计算有关的库。

在这里插入图片描述

第一感觉:为啥这么多???

在这里插入图片描述

用法
0.3 - 0.1                           // 0.19999999999999998
x = new BigNumber(0.3)
x.minus(0.1)                        // "0.2"
x                                   // "0.3"
原理

先看一下构造函数,emmm…看源码的时候,我其实是这样的:

在这里插入图片描述

在这里插入图片描述

let x = new BigNumber(123.4567);
console.log(x);
// { c: (2) [123, 45670000000000], e: 2, s: 1 }

let y = BigNumber('123456.7e-3');
console.log(y);
// { c: (2) [123, 45670000000000], e: 2, s: 1 }

加法的实现:

  • 先将两个数都转为 BigNumber 类型;以 0.1 和 1.1为例子:
    { c: [10000000000000], e: -1, 1 }     // 0.1
    { c: [1, 25000000000000], e: 0, 1 }   // 1.1
    
  • 判断两个数之中是否是 NaN的,有的话直接返回new BigNumber(NaN);
  • 如果有一方是负数,则调用减法的计算结果;
  • 记录 x.e、y.e、x.c、y.c
    var xe = x.e / LOG_BASE,
            ye = y.e / LOG_BASE,
            xc = x.c,
            yc = y.c;
    
    console.log(xe, ye, xc, yc);      
    // -0.07142857142857142 0 [10000000000000] (2) [1, 25000000000000]
    // 其中LOG_BASE = 14;
    
  • 判断 xe、ye中是否有一个为0的时候,根据条件返回不同的值:
    if (!xe || !ye) {
    
      // ±Infinity
      if (!xc || !yc) return new BigNumber(a / 0);
    
      // Either zero?
      // Return y if y is non-zero, x if x is non-zero, or zero if both are zero.
      if (!xc[0] || !yc[0]) return yc[0] ? y : new BigNumber(xc[0] ? x : a * 0);
    }
    
  • 处理一下 xeye, 浅拷贝xc :
    xe = bitFloor(xe);    // -0.07142857142857142 => -1
    ye = bitFloor(ye);    // 0 => 0
    xc = xc.slice();
    
    // n | 0 有省略小数的作用
    function bitFloor(n) {
        var i = n | 0;
        return n > 0 || n === i ? i : i - 1;
     }
     
    console.log( 104.249834 | 0 ); //104
    console.log( 9.999999 | 0 );   // 9
    
  • 根据yexe,对xcyc中比较短的一方进行补0操作,所以此时变为:
    // [10000000000000]
    xc: [0, 10000000000000] 
    // [1, 25000000000000]
    yc: [1, 25000000000000]
    
  • 比较 xc yc 的长度,确保长度较长的数值放在 xc中;
  • 遍历相加:
    // Only start adding at yc.length - 1 as the further digits of xc can be ignored.
    for (a = 0; b;) {
       a = (xc[--b] = xc[b] + yc[b] + a) / BASE | 0;
       xc[b] = BASE === xc[b] ? 0 : xc[b] % BASE;
    }
    
  • 最后通过 normalise 整合一下最后的结果,返回一个新的 BigNumber 对象。

学习到的几行感觉很有用的代码:

// 将v转为整数比较的最快方法(当v < 2**31 时,比较是否是整数)
v === ~~v

// |0 直接取整数部分
function bitFloor(n) {
  var i = n | 0;
  return n > 0 || n === i ? i : i - 1;
}
console.log(0.6 | 0);     // 0
console.log(1.1 | 0);     // 1
console.log(3.6555 | 0);   // 3
console.log(-3.6555 | 0);   // -3

这个库跟 big.js的差别在于,后者的API没有前者多,而且不支持十进制以外的计算。

总结

  • 当我们在使用 Number 类型时,计算机底层会自动将我们输入的十进制数字自动转为 IEEE754标准的浮点数;
  • 转浮点数的时候会出现精度丢失的情况,一般是发生在十进制转二进制的时候,或者是浮点数参与计算的时候需要对阶;
  • 解决这个问题可以考虑使用parseFloat、浮点转整数、浮点转字符串;
  • 业界出名的轮子有 number-precision、bignumber.js等,前者主要借助了parseFloat和浮点转整数的思想,后者是先将数值转为特定的对象,然后进行整数计算。

参考


如果错误,欢迎指出,感谢阅读~

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值