JavaScript的数值存储的探析与应用

news/2024/7/19 16:33:36 标签: 前端, js

文章目录

  • JavaScript的数值存储探析
    • 1. 浮点数的存储规则
    • 2. Number对象上的特殊值
        • MAX_SAFE_INTEGER
        • MAX_VALUE
    • 3. 特殊值的存储
  • 学以致用
    • 案例分析
      • 1.1 精度丢失
      • 1.2 大数危机
      • 1.3 toFixed()对于小数最后一位为5时进位不正确问题
    • 2. 解决方案
      • “修复” 0.1+0.2 == 0.3
      • 2.2 修复数据展示
      • 修复 toFixed()
      • 2.4 修复数据运算(+-*/)
  • 参考文章:

JavaScript的数值存储探析

1. 浮点数的存储规则

JavaScript中的所有数字包括证书和小数只有一种类型:Numbr。它的实现遵循IEEE 754 标准,使用64位固定长度来表示,即标准的双精度浮点数double(单精度浮点数float则是32位)。

双精度的的精度比单精度的要高(因为存储所用的空间不同)。

IEEE754的标准,如图所示:

64 bit allocation

64位比特可分为三个部分:

  • 符号位S:第一位是正负数符号位(sign),0表正数,1表负数

  • 指数位E:中间的11位存储指数(exponent)

    指数e可以为正可以为负,其表示小数点的位置。如 1.1011 ∗ 2 4 1.1011 * 2^4 1.101124​ ,e就是正数4,小数点在第一个1右侧再后移4位的位置;而 0.101 用指数表示就是 1.01 ∗ 2 − 1 1.01*2^{-1} 1.0121​ ,e为-1。同时,IEEE754 标准要求,将此处的e+指数偏移量,得到的结果再化为二进制,就得到了我们的指数位E。如图所示:

    img

    指数偏移量公式(k为指数位个数):

    X = x k − 1 x^k-1 xk1

    如上图所示,双精度浮点数的指数位为11,即 X = 2 11 − 1 2^{11-1} 2111 =1023。

    为什么要偏移1023?

    ”如果你知道为什么32位浮点数的指数偏移量是127,你就能知道为什么64位浮点数的指数偏移量是1023。

    在32位浮点数中,指数位有8位,它能表示的数字是从0到2的8次方,也就是256。但是指数有正有负,所以我们需要把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,这样的话,再比较大小,就不存在负数比正数大的情况了。

    但是我要得到原来的数字怎么办呢?这好办,你只要再把指数减去128就得到了原来的数字,不是吗?比如说你读到0,那么减去128,就得到了负指数-128,读到255,减去128,就得到了127。

    那为什么指数偏移是127,不是128呢?因为人们为了特殊用处,不允许使用0和255这两个数字表示指数,少了2个数字,自然就只好采用127了。

    同理,64位浮点数,指数位有11位之多,2的11次方是2048,劈一半作偏移,可不就是1024吗?同理,去掉0和2048这两个数字,所以就用1023作偏移了。“

  • 尾数位M:最后的52位是尾数(mantissa),用来表示小数部分,位数不够用0补齐,超出部分进1舍0。

    尾数位决定精度。因此 JavaScript 中能精准表示的最大整数就是Math.pow(2, 53),十进制即9007199254740992。

因此,计算机存储二进制构成即为:符号位+指数位+尾数位

举个栗子:

29.5转换为二进制是11101.1

11101.1转换为科学计数法: 1.11011 ∗ 2 4 1.11011*2^4 1.1101124

符号位 为0(正数)

指数位 为4,加上指数偏移量1023,即1027,转为二进制即 10000000011

尾数位 为11011,补满52位即: 1101 1000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000

所以29.5存储为计算机的二进制标准格式为

符号位+指数位+尾数位

0+10000000011+1101 1000 0000 0000 0000 0000 0000 0000 0000 0000

0000 0000 0000 ,即

0100 0000 0011 1101 1000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000

正好64位

好,现在整理一下步骤

计算机想存储一个数字

①首先将数字转换为二进制

②再把二进制转换为科学计数法表示

③分析科学计数表示法,得出 符号位【1】+(指数位+偏移量)【11】+尾数位【52】

④拼接成64位的二进制数

2. Number对象上的特殊值

MAX_SAFE_INTEGER

表示最大的安全整数。

由上一节可知,双精度浮点数的可准确表示的最大整数是 2 53 − 1 2^{53}-1 2531

js">Number.MAX_SAFE_INTEGER === Math.pow(2,53) - 1  // true

相对的,单精度浮点数因尾数位只有23位,对应的最大安全整数为 2 24 − 1 2^{24}-1 2241​ 。

MAX_VALUE

表示JS里内能表示的最大的值。

你或许以为就是64位全部拉满的情况:

0 11111111111 1111111111111111111111111111111111111111111111111111

但实际上,前文引用中提到过:“那为什么指数偏移是127,不是128呢?因为人们 为了特殊用处,不允许使用0和255这两个数字表示指数 ,少了2个数字,自然就只好采用127了。” 相对应的,64位存储时,11位的指数位, 2 11 2 \frac{2^{11}}{2} 2211即1024也会用于特殊用途。

因此,最大值的64位应该是指数位对应十进制为拉满的情况下-1,64位即:

0 11111111110 1111111111111111111111111111111111111111111111111111

计算过程是:

0 11111111110 1111111111111111111111111111111111111111111111111111

转换成二进制的科学计数法表示如下:

1.1111111111111111111111111111111111111111111111111111 ∗ 2 2046 − 1023 1.1111111111111111111111111111111111111111111111111111 * 2^{2046 - 1023} 1.1111111111111111111111111111111111111111111111111111220461023

= 1.1111111111111111111111111111111111111111111111111111 ∗ 2 1023 1.1111111111111111111111111111111111111111111111111111 * 2^{1023} 1.111111111111111111111111111111111111111111111111111121023

= 11111111111111111111111111111111111111111111111111111 ∗ 2 971 11111111111111111111111111111111111111111111111111111 * 2^{971} 111111111111111111111111111111111111111111111111111112971

= ( 2 53 − 1 ) ∗ 2 971 (2^{53} - 1) * 2^{971} (2531)2971

= 1.7976931348623157 e + 308 1.7976931348623157e+308 1.7976931348623157e+308

验证一下:

js">(Math.pow(2, 53) - 1) * Math.pow(2, 971) // 1.7976931348623157e+308
Number.MAX_VALUE === (Math.pow(2, 53) - 1) * Math.pow(2,971) // true

到此,我们就可以很容易地理解下面精度相关的问题了。

3. 特殊值的存储

前文提到,某些指数有特殊用途,即:

  1. 如果指数是0并且尾数的小数部分是0,这个数±0(和符号位相关)
  2. 如果指数 = 2 e − 1 2^{e}-1 2e1​并且尾数的小数部分是0,这个数是±[∞](无穷)同样和符号位相关)
  3. 如果指数 = 2 e − 1 2^{e}-1 2e1并且尾数的小数部分非0,这个数表示为非数(NaN)。

以上规则,总结如下:

形式指数小数部分
00
非规约形式0大于0小于1
规约形式1到 2 e − 2 2^{e}-2 2e2大于等于1小于2
无穷 2 e − 1 2^{e}-1 2e10
NaN 2 e − 1 2^{e}-1 2e1非0

学以致用

我们先用前面学到的知识点来分析以下常见场景的误差产生的根本原因,最后来总结解决方案。

案例分析

1.1 精度丢失

js">// 加法 =====================
0.1 + 0.2 // 0.30000000000000004
0.7 + 0.1 // 0.7999999999999999
0.2 + 0.4 // 0.6000000000000001

// 减法 ====================
1.5 - 1.2 // 0.30000000000000004
0.3 - 0.2 // 0.09999999999999998
 
// 乘法 ====================
19.9 * 100 // 1989.9999999999998
0.8 * 3 // 2.4000000000000004
35.41 * 100 // 3540.9999999999995

// 除法 ====================
0.3 / 0.1 // 2.9999999999999996
0.69 / 10 // 0.06899999999999999

为什么0.1+0.2 === 0.30000000000000004?

0.1转换为64位下的存储格式:

0.1

>>> 0.0001100110011001100110011001100110011001100110011001101 >>> 1.100110011001100110011001100110011001100110011001101 * 2 − 4 2^{-4} 24

>>> 0011111110111001100110011001100110011001100110011001100110011010

同理,转换0.2

0.2

>>> 0.001100110011001100110011001100110011001100110011001101

>>> 1.100110011001100110011001100110011001100110011001101 * 2 − 3 2^{-3} 23

>>> 0011111111001001100110011001100110011001100110011001100110011010

可以看出来在转换为二进制时

0.1 >>> 0.0001 1001 1001 1001...(1001无限循环)
0.2 >>> 0.0011 0011 0011 0011...(0011无限循环)

“就像一些无理数不能有限表示,如 圆周率 3.1415926…,1.3333… 等,在转换为二进制的科学记数法的形式时只保留64位有效的数字,此时只能模仿十进制进行四舍五入了,但是二进制只有 0 和 1 两个,于是变为 0 舍 1 入。在这一步出现了错误,那么一步错步步错,那么在计算机存储小数时也就理所应当的出现了误差。这即是计算机中部分浮点数运算时出现误差,这就是丢失精度的根本原因

将0.1和0.2的二进制形式按实际展开,末尾补零相加,结果如下

0.00011001100110011001100110011001100110011001100110011010

+0.00110011001100110011001100110011001100110011001100110100

=0.01001100110011001100110011001100110011001100110011001110

0.1+0.2 >> 0.0100 1100 1100 1100...(1100无限循环)

则0.1 + 0.2的结果的二进制数科学记数法表示为为1.001100110011001100110011001100110011001100110011010 * 2^(-2), 省略尾数最后的0,即 1.00110011001100110011001100110011001100110011001101 * 2^(-2), 因此(0.1+0.2)实际存储时的形式是 0011111111010011001100110011001100110011001100110011001100110100
因计算机存储位数的限制而截断的二进制数字,再转换为十进制,就成了0.30000000000000004,刚好符合控制台里打印0.1+0.2的结果

所以,我们可以得出结论:十进制的浮点数在转换为二进制时,若出现了无限循环,会造成二进制的舍入操作,再转换为十进制时就会造成了计算误差。

1.2 大数危机

9999999999999999 == 10000000000000001===true ?

大整数的精度丢失和浮点数本质上是一样的,存储二进制时小数点的偏移量最大为52位,超出就会有舍入操作,因此JavaScript中能精准表示的最大整数是Math.pow(2, 53),十进制即9007199254740992,大于9007199254740992就可能会丢失精度。

使用parseInt()时也会有这种问题。

1.3 toFixed()对于小数最后一位为5时进位不正确问题

js">//firefox/chrome中toFixed 兼容性问题
1.35.toFixed(1) // 1.4 正确
1.335.toFixed(2) // 1.33  错误
1.3335.toFixed(3) // 1.333 错误
1.33335.toFixed(4) // 1.3334 正确
1.333335.toFixed(5)  // 1.33333 错误
1.3333335.toFixed(6) // 1.333333 错误

根本原因还是浮点数精度丢失问题:

如 1.005.toFixed(2) 返回的是 1.00 而不是 1.01

js">1.005.toPrecision(21) //1.00499999999999989342

2. 解决方案

“修复” 0.1+0.2 == 0.3

ES6在Number对象上新增了一个极小的常量——Number.EPSILON

js">Number.EPSILON
// 2.220446049250313e-16

引入这么一个小的数的值,目的在于为浮点数的计算设置一个误差范围,如果误差能够小于Number.EPSILON,我们就可以认为结果是可靠的。

测试是否相等

js">function equal(x, y){
  return Math.abs(x - y) < Number.EPSILON
}
equal(0.1+0.2,0.3) // true

2.2 修复数据展示

当你拿到可能有精度丢失的数据(如0.1+0.2),要展示时可以这样:

js">// Q: 为什么选12做默认精度?
// A: 经验选择
function strip(num, precision = 12) {
  return parseFloat(num.toPrecision(precision));
}
strip(0.1+0.2) // 0.3

但此方法仅用于最终结果的展示,在运算前这样处理是无意义的(计算中仍会丢失精度)。

修复 toFixed()

方案1

js">// 将小数末位的5改成6再调用toFixed()
function toFixed(number, precision) {
    var str = number + ''
    var len = str.length
    var last = str.substring(len - 1, len)
    if (last == '5') {
        last = '6'
        str = str.substring(0, len - 1) + last
        return (str - 0).toFixed(precision)
    } else {
        return number.toFixed(precision)
    }
}
console.log(toFixed(1.333335, 5))

方案2

js">// 先扩大再缩小
function toFixed(num, s) {
    var times = Math.pow(10, s)
    // 因为乘法同样存在精度问题,加上0.5保证不会扩大后尾数过多而parseInt后丢失精度
    var des = num * times + 0.5
    // 去除小数
    des = parseInt(des, 10) / times
    return des + ''
}
console.log(toFixed(1.333335, 5))

2.4 修复数据运算(±*/)

修复常用算数运算符的方法原理都是扩大缩小法,但也有些细节要注意。

js">/**
* floatObj 包含加减乘除四个方法,能确保浮点数运算不丢失精度
*
* 精度丢失问题(或称舍入误差,其根本原因是二进制和实现位数限制有些数无法有限表示
* 以下是十进制小数对应的二进制表示
*      0.1 >> 0.0001 1001 1001 1001…(1001无限循环)
*      0.2 >> 0.0011 0011 0011 0011…(0011无限循环)
* 计算机里每种数据类型的存储是一个有限宽度,比如 JavaScript
  使用 64 位存储数字类型,因此超出的会舍去。舍去的部分就是精度丢失的部分。
*
* ** method **
*  add / subtract / multiply /divide
*
* ** explame **
*  0.1 + 0.2 == 0.30000000000000004 (多了 0.00000000000004)
*  0.2 + 0.4 == 0.6000000000000001  (多了 0.0000000000001)
*  19.9 * 100 == 1989.9999999999998 (少了 0.0000000000002)
*
* floatObj.add(0.1, 0.2) === 0.3
* floatObj.multiply(19.9, 100) === 1990
*
*/
var floatObj = (function () {
  /*
   * 判断obj是否为一个整数 整数取整后还是等于自己。利用这个特性来判断是否是整数
   */
  function isInteger(obj) {
    // 或者使用 Number.isInteger()
    return Math.floor(obj) === obj
  }
  /*
   * 将一个浮点数转成整数,返回整数和倍数。如 3.14 >> 314,倍数是 100
   * @param floatNum {number} 小数
   * @return {object}
   *   {times:100, num: 314}
   */
  function toInteger(floatNum) {
    // 初始化数字与精度 times精度倍数  num转化后的整数
    var ret = { times: 1, num: 0 }
    var isNegative = floatNum < 0 //是否是小数
    if (isInteger(floatNum)) {
      // 是否是整数
      ret.num = floatNum
      return ret //是整数直接返回
    }
    var strfi = floatNum + '' // 转换为字符串
    var dotPos = strfi.indexOf('.')
    var len = strfi.substr(dotPos + 1).length // 拿到小数点之后的位数
    var times = Math.pow(10, len) // 精度倍数
    /* 为什么加0.5?
          前面讲过乘法也会出现精度问题
          假设传入0.16344556此时倍数为100000000
          Math.abs(0.16344556) * 100000000=0.16344556*10000000=1634455.5999999999 
          少了0.0000000001
          加上0.5 0.16344556*10000000+0.5=1634456.0999999999 parseInt之后乘法的精度问题得以矫正
      */
    var intNum = parseInt(Math.abs(floatNum) * times + 0.5, 10)
    ret.times = times
    if (isNegative) {
      intNum = -intNum
    }
    ret.num = intNum
    return ret
  }

  /*
   * 核心方法,实现加减乘除运算,确保不丢失精度
   * 思路:把小数放大为整数(乘),进行算术运算,再缩小为小数(除)
   * @param a {number} 运算数1
   * @param b {number} 运算数2
   */
  function operation(a, b, op) {
    var o1 = toInteger(a)
    var o2 = toInteger(b)
    var n1 = o1.num // 3.25+3.153
    var n2 = o2.num
    var t1 = o1.times
    var t2 = o2.times
    var max = t1 > t2 ? t1 : t2
    var result = null
    switch (op) {
      // 加减需要根据倍数关系来处理
      case 'add':
        if (t1 === t2) {
          // 两个小数倍数相同
          result = n1 + n2
        } else if (t1 > t2) {
          // o1 小数位 大于 o2
          result = n1 + n2 * (t1 / t2)
        } else {
          // o1小数位小于 o2
          result = n1 * (t2 / t1) + n2
        }
        return result / max
      case 'subtract':
        if (t1 === t2) {
          result = n1 - n2
        } else if (t1 > t2) {
          result = n1 - n2 * (t1 / t2)
        } else {
          result = n1 * (t2 / t1) - n2
        }
        return result / max
      case 'multiply':
        // 325*3153/(100*1000) 扩大100倍 ==>缩小100倍
        result = (n1 * n2) / (t1 * t2)
        return result
      case 'divide':
        // (325/3153)*(1000/100)  缩小100倍 ==>扩大100倍
        result = (n1 / n2) * (t2 / t1)
        return result
    }
  }

  // 加减乘除的四个接口
  function add(a, b) {
    return operation(a, b, 'add')
  }
  function subtract(a, b) {
    return operation(a, b, 'subtract')
  }
  function multiply(a, b) {
    return operation(a, b, 'multiply')
  }
  function divide(a, b) {
    return operation(a, b, 'divide')
  }
  return {
    add: add,
    subtract: subtract,
    multiply: multiply,
    divide: divide,
  }
})()


console.log(0.1 + 0.2) // 0.30000000000000004
console.log(floatObj.add(0.1, 0.2)) // 0.3

console.log(0.3 - 0.1) // 0.19999999999999998
console.log(floatObj.subtract(0.3, 0.1)) // 0.2

console.log(35.41 * 100) // 3540.9999999999995
console.log(floatObj.multiply(35.41, 100)) // 3541

console.log(0.3 / 0.1) // 2.9999999999999996
console.log(floatObj.divide()) // 3

当然,也可以用成熟的库来解决此类问题,如math.jsnumber-precision 等。

参考文章:

https://github.com/camsong/blog/issues/9

https://zhuanlan.zhihu.com/p/100353781

https://segmentfault.com/q/1010000016401244/a-1020000016446375###

https://zh.wikipedia.org/wiki/IEEE_754#%E7%89%B9%E6%AE%8A%E5%80%BC


http://www.niftyadmin.cn/n/913947.html

相关文章

mysql8 Expression #16 of SELECT list is not in GROUP BY clause and contains nonaggregated column

出错 Expression #16 of SELECT list is not in GROUP BY clause and contains nonaggregated column xxx.xxxx.xxxid which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_modeonly_full_group_by /etc/my.conf sql_modeSTR…

跨站脚本攻击漏洞

漏洞资料:目前所涉及版本的论坛短信内容处没有做好细致的过滤,导致跨站脚本漏洞攻击的产生,使得用户可以得到管理员的COOKIE 信息.从而进一步危害论坛.漏洞利用:在撰写短消息处正文内容上填上如下利用代码:一,网页木马:<script>document.write(<iframe src"http:…

铺地毯

原题链接&#xff1a;https://www.luogu.org/problem/show?pid1003 从讲义上的离散化部分看到的这个题&#xff0c;还以为用啥高级操作才能过的呢。。其实就是离散化 仔细一看。。诶我怎么做过这道题&#xff1f; 其实很简单。首先这个数据范围要模拟铺地毯二维数组肯定开不了…

Docker在容器中获取当前实例内存限制、CPU限制

启动一个受限制的容器 [yeqianglocalhost testproj]$ docker run --rm -it -m 1G --cpus3 centos /bin/bash 查看内存限制 [roota2b39516cbf6 /]# cat /sys/fs/cgroup/memory/memory.limit_in_bytes 1073741824计算 1073741824102410241024 1&#xff08;G&#xff09; 查…

Vite2+Vue3的h5开发模板——开箱即用

h5-vite-vue3-template 开箱即用的 h5 开发模板。技术栈&#xff1a;Vite/Vue3/Scss/ github仓库地址 目录简介 ├── index.html # index.html 模板 ├── jsconfig.json # JavaScript 配置 ├── package.json # package.json ├…

周杰伦赴日展中国风自弹自唱

周杰伦日前赴日本宣传最新国语专辑《依然范特西》&#xff0c;以实际行动宣扬华人音乐。他一抵达大阪机场就受到数百位歌迷的热情接机&#xff0c;轰动机场的程度更胜玛丹娜日前赴日举办演唱会时歌迷的接机盛况。而四天的日本宣传行程中&#xff0c;最令人瞩目的莫过于NHK电视台…

sqlalchemy中文问题解决方案

原创作品&#xff0c;允许转载&#xff0c;转载时请务必以超链接形式标明文章 原始出处 、作者信息和本声明。否则将追究法律责任。http://firefish.blog.51cto.com/298258/112794sqlalchemy是python下一个很强大的ORM&#xff0c;最近刚刚开始使用。当然由于只是刚刚接触&…

uniapp 开发小记

uniapp 开发小记 简单回顾下&#xff0c;如有纰漏后续更新。 建议vue-cli的脚手架模式&#xff0c;统一IDE为vscode降低切换成本。 建立脚手架&#xff1a; npm i -g vue/cli vue create -p dcloudio/uni-preset-vue uni-templatenpm run dev:platform npm run build:platfor…