JavaScript 中的浮点数

JavaScript 中的浮点数

浮点数

根据 IEEE-754 标准,浮点数主要由符号位、指数位和尾数位构成,按长度可划分为以下几类:

img

img

img

任意一个二进制浮点数 V 都可以表示成以下形式(二进制浮点数科学计数法):

​ $$V=(-1)^S×2^E×M$$

  • 符号位(sign):长度为 1 bit,其中 0 表示正数,1 表示负数

  • 指数位(exponent):表示 2 的次方数

    指数为无符号整数(unsigned int),单精度类型的取值范围为 [0, 255],双精度类型的取值范围为 [0, 2047]。在科学计数法中,指数位是允许为负数的,因而标准规定,指数位的真实值必须减去一个指数偏移量(Exponent bias),单精度类型的偏移量为 127,双精度类型的偏移量为 1023。如:

    ​ $$2^{10} = 2^{1033 - 1023}$$

    • 指数位全为 0 时,指数位为 1-1271-1023,表示正负 0 或者无限接近于 0 的小数

    • 指数全为 1 时,如果尾数位全为 1,则表示正负无穷大;尾数位不全为 0 时,表示 NaN

    • 指数不全为 0 或 1 时,采用上面的规则表示

  • 尾数位(mantissa):范围为 [1, 2),即 1.xxxxxxxxx 为小数部分

    IEEE 754 规定存储尾数时,默认尾数的第一位总是 1,因此可以省略 1,只保存后面的小数部分;如:1.23,只保存 23,获取的时候再把整数位 1 加上。这种存储方式可以节约 1 位长度。

Number 类型

在 JavaScript 中,Number 类型都是按照 64 位双精度浮点型格式进行储存。

安全整数

安全整数指整数有且仅有一个双精度浮点数表示,该浮点数表示有且仅有一个对应的整数。JavaScript 中安全整数的范围为 [$-2^{53}+1$, $2^{53}-1$],最大安全整数和最小安全整数分别为:

​ $$Number.MAX_SAFE_INTEGER = 2^{53} - 1 = 9007199254740991$$

​ $$Number.MIN_SAFE_INTEGER = -2^{53} + 1 = -9007199254740991$$

超过这个范围,会有两个或更多整数的双精度数表示是相同的;即有的整数是无法精确表示的,只能 round 到与它相近的浮点数(二进制科学计数法)表示,这种情况下叫做不安全整数

十进制数实际值二进制数二进制科学计数法指数和尾数描述
Math.pow(2, 53)90071992547409921{53 个 0}$个1.{53个0}*2^{53}$exponent: 52 + 1023
mantissa: 000…0000 (53 个 0)
尾数位最大长度为 52,最后一个 0 被舍去,导致 $2^{53}$ 和 $2^{53} + 1$ 对应同一个浮点数,不是安全整数
Math.pow(2, 53) + 190071992547409921{52 个 0}1$个1.{52个0}1*2^{53}$exponent: 52 + 1023
mantissa: 000…0001 (52 个 0 + 1)
尾数位最大长度为 52,最后一个 1 被舍去,导致 $2^{53}$ 和 $2^{53} + 1$ 对应同一个浮点数,不是安全整数
Math.pow(2, 53) + 290071992547409941{51 个 0}10$个1.{51个0}10*2^{53}$exponent: 52 + 1023
mantissa: 000…0010 (51 个 0 + 10)
最后一个 0 被舍去
Math.pow(2, 53) + 390071992547409961{51 个 0}11$个1.{51个0}11*2^{53}$exponent: 52 + 1023
mantissa: 000…0011 (51 个 0 + 11)
mantissa的第 52 位和第 53 位都为 1 时进了 1 位,实际 mantissa: 50 个 0 + 10
Math.pow(2, 53) + 490071992547409961{50 个 0}100$个1.{50个0}100*2^{53}$exponent: 52 + 1023
mantissa: 000…0100 (50 个 0 + 100)
最后一个 0 被舍去
Math.pow(2, 53) + 590071992547409961{50 个 0}101$个1.{50个0}101*2^{53}$exponent: 52 + 1023,mantissa: 000…0101 (50 个 0 + 101)最后一个 1 被舍去
Math.pow(2, 53) + 690071992547409981{50 个 0}110$个1.{50个0}110*2^{53}$exponent: 52 + 1023,mantissa: 000…0110 (50 个 0 + 110)最后一个 0 被舍去
Math.pow(2, 53) + 790071992547410001{50 个 0}111$个1.{50个0}111*2^{53}$exponent: 52 + 1023,mantissa: 000…0111 (50 个 0 + 111)mantissa的第 52 位和第 53 位都为 1 时进了 1 位,实际 mantissa: 49 个 0 + 100
Math.pow(2, 53) + 890071992547410001{49 个 0}1000$个1.{49个0}1000*2^{53}$exponent: 52 + 1023,mantissa: 000…1000 (49 个 0 + 1000)最后一个 0 被舍去
Math.pow(2, 53) + 990071992547410001{49 个 0}1001$个1.{49个0}1001*2^{53}$exponent: 52 + 1023,mantissa: 000…1001 (49 个 0 + 1001)最后一个 1 被舍去
Math.pow(2, 53) + 1090071992547410021{49 个 0}1010$个1.{49个0}1010*2^{53}$exponent: 52 + 1023,mantissa: 000…1010 (49 个 0 + 1010)最后一个 0 被舍去

尾数的第 52 位和第 53 位均为 1 时,会进 1 位,而其他情况则舍去末尾超过部分。

1
2
3
4
5
6
7
Object.is(Math.pow(2, 53) + 1, Math.pow(2, 53)); // true

Object.is(Math.pow(2, 53) + 3, Math.pow(2, 53) + 4) && Object.is(Math.pow(2, 53) + 4, Math.pow(2, 53) + 5); // true

Object.is(Math.pow(2, 53) + 7, Math.pow(2, 53) + 8) && Object.is(Math.pow(2, 53) + 8, Math.pow(2, 53) + 9); // true

Object.is(Math.pow(2, 53) + 11, Math.pow(2, 53) + 12) && Object.is(Math.pow(2, 53) + 12, Math.pow(2, 53) + 13); // true
Number.EPSILON

尾数位最大长度是 52,因而尾数位最小为二进制的 {51 个 0}1,转换为十进制就是 $2^{-52}$。

​ $$Math.pow(2, -52) = 2.220446049250313e-16$$

ES6 在 Number 中新增了一个常量 Number.EPSILON,即 $2^{-52}$。

在将双精度浮点数转换为十进制的数字字符串时,小数点后要保留多少位有效数字呢?

根据双精度浮点数格式标准规定,对双精度浮点数来说,17 位的十进制字符串即可保存对应的二进制值。另外,双精度浮点数转为十进制的数字时,只要再次转回来的双精度浮点数不变,那么可以取精度最短的十进制字符串。如:0.10.10000000000000001 转换为双精度浮点数存储时对应的存储结构是一样的,因此取长度较短的 0.1 即可。

BigInt 类型

上面提到,JavaScript 中最大的安全整数是 $2^{53}-1 = 9007199254740991$,但指数位最大长度为 11,即最大值为 $2^{11}-1=2046$,指数最大值为 $2046-1023=1023$。因而,最大可以表示的证书是 $2^{1024} - 1$。

1
2
3
Math.pow(2, 1023); // 8.98846567431158e+307

Math.pow(2, 1024); // Infinity

如果需要表示更大的数,ES11 新增了 BigInt 类型。对于不支持的浏览器,可以使用第三方库 bignumber.js

怪异问题

0.1 + 0.2

JavaScript 中有个奇怪的现象 $0.1 + 0.2 = 0.30000000000000004$,我们来解释下具体原因:

JavaScript 中所有关于数字的运算都会先转换为二进制,再转成二进制的科学计数法,按照双精度浮点格式进行存储,然后取出取出的值并转换为二进制,最后再进行运算。

  • 0.1 转换为二进制

    0.1 转换为二进制后是个无限循环的数

    1
    2
    3
    4
    0.0001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1010

    // 保留 52 位后对应的二进制科学计数法
    1.1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1010 * 2^(-4)

    由于尾数位只能存储 53 位,超出部分会舍弃,但 53 位数字为 1,因此会进位,最终存储为

  • 0.2 转换为二进制

    0.2 转换为二进制后也是个无限循环的数,超出部分同样舍弃

    1
    2
    3
    4
    0.0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 01

    // 保留 52 位后对应的二进制科学计数法
    1.1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1010* 2^(-3)

    由于尾数位只能存储 53 位,超出部分会舍弃,但 53 位数字为 1,因此会进位,最终存储为

  • 二进制相加

    从存储中取出两个数进行相加

    1
    2
    3
    4
    5
    6
    7
    8
    9
    // 保留 52 位
    0.0001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1010
    +
    0.0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 010
    -----------------------------------------------------------------------
    =
    0.0100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1110
    =
    0.30000000000000004

    由于在存储过程中存在舍弃和进位的原因,导致精度缺失,出现了浮点数误差。

144.7 - 94.9

之前线上碰到的问题,可退款金额为 144.7,已退款 94.9,剩余可退款应该是 49.8,但再操作退款时却无法正常退 49.8,原因如下:

  • 144.7 转换为二进制存储

    1
    2
    3
    4
    5
    6
    7
    8
    // 144.7 对应的二进制
    10010000.1011001100110011001100110011001100110011001100110011...

    // 二进制科学计数法
    1.00100001011001100110011001100110011001100110011001100110011 * 2^7

    // 尾数位保留 52 位
    1.0010 0001 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110*2^(1030 - 1023)

  • 94.9 转换为二进制存储

    1
    2
    3
    4
    5
    6
    7
    8
    // 94.9 对应的二进制
    1011110.111001100110011001100110011001100110011001101

    // 二进制科学计数法表示
    1.011110111001100110011001100110011001100110011001101 * 2^6

    // 尾数位保留 52 位
    1.0111 1011 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1010 * 2^(1029 - 1023)
  • 二进制相减

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    1.0010 0001 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 * 2^7
    -
    1.0111 1011 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1010 * 2^6
    ------------------------------------------------------------------
    1001 0000.1011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0
    -
    0101 1110.1110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 10
    -------------------------------------------------------------------
    0011 0001.1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 10
    =
    49.799999999999983
toFiexed()
1
2
3
(1.35).toFixed(1); // 1.4

(6.35).toFixed(1); // 1.4

第二位小数同样是 5,但 toFixed() 在四舍五入时 1.35 有进位,但 6.35 缺没有。

我们来看下 6.35 的存储

1
2
3
4
5
6
7
8
9
10
11
12
13
// 对应的二进制
110.0101 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001

// 二进制科学计数法
1.1001 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 01 * 2^2

// 尾数位保留 52 位
1.1001 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 * 2^2

// 读取时
110.0101 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1000
=
6.34999999999999964

同理,1.35

总结

在日常开发中很少会碰到整数溢出的情况,更多的是浮点数计算的场景,可以通过 mathjsnumber-percision 来解决精度缺失问题。

参考