由 0.1+0.2 到 JavaScript 中的数字实现

402 阅读22分钟

本文是学习笔记,可能会有不严谨之处。

零、指数运算符(或求幂赋值运算符)

  • 两个星号 ** 是 ES6 新增的求幂赋值运算符,操作符右边为指数,左边为底数

    2 ** 2;
    // 4
    
  • 多个指数连用时,从最右边开始计算,即右结合

    2 ** 3 ** 2 === 2 ** (3 ** 2); // true
    
  • 与等号结合

    let a = 2;
    a **= 2;
    // a = a ** 2
    
  • V8 引擎的指数运算符与 Math.pow 的实现不相同,特别大的运算结果会不相同

  • V8 应该已经修复了结果不一致的问题

    Math.pow(299, 299) === 299**299
    

一、原码、反码和补码

1. 机器数

机器数:将符号"数字化"的数,是数字在计算机中的二进制表示形式

  • 符号数值化

    • 用一位二进制的 0 或 1 区分实际数的正负
    • 通常这个符号放在二进制数的最高位,称符号位,以 0 代表“+”,以 1 代表“-”
    • 因为符号位的存在,数的形式值不再等于真正的数值
  • 二进制的位数受计算机机器字长的限制

    • 机器内部设备一次能表示的二进制位数叫机器的字长
    • 机器字长一般是字节(Byte)的整倍数,如:16 位,32 位等。一个字节是 8 位字长(1Byte = 8bit
    // 实数 +3,计算机的字长8位
    0 0000011
    // 实数 -3,计算机的字长8位
    1 0000011
    

2. 真值

  • 因为机器数第一位是符号位,所以机器数的形式值就不等于真正的数值

  • 将带符号位的机器数对应的真正数值并加上符号+-称为机器数的真值

    0000 0001的真值为 +000 0001

3. 原码

  • 原码就是不做更改的机器数

  • 表示有符号数时,可以表示的范围

    -(2n-1-1) ~ +(2n-1-1)

    • n 为计算机的字长,例如 8 位,则原码表数范围-127~+127
    • 在原码表示中存在+0 (00000000)-0 (10000000),所以 256 个数
  • 原码可以表示无符号数,即最高位也是数值,不存在符号位(反码和补码则不能,因为反码补码肯定有符号位

    0 ~ 2n - 1

    • n 为计算机的字长,例如 8 位,则表数范围 0~255
  • 原码不能直接参与运算,因为符号位的存在

    // 数学上
    1 + (-1) = 0
    // 二进制原码
    00000001 + 1000001 = 100000010
    // 10000010 转为十进制是 -2
    

4. 反码

  • 正数的反码和原码相同

  • 负数的反码,符号位保持为1,数值位根据原码中的数值按位取反

  • 反码中存在多余的负零和其他问题,所以减法运算使用补码而不是反码

  • 反码系统的表数范围和原码一样,存在+0-0

    -(2n-1-1) ~ +(2n-1-1)

5. 补码

  • 计算机系统中,数值一律用补码来表示和存储

    • 正数的补码和原码相同
    • 负数的补码,符号位保持为1,数值位根据原码中的数值按位取反(反码),最后再加上 1,不考虑溢出比特
  • 补码系统可以在加法或减法处理中,不需因为数字的正负而使用不同的计算方式

  • 只要一种加法电路就可以处理各种有号数加法,减法可以用一个数加上另一个数的补码来表示

    // 实数
    -3
    // 原码
    1,000 0011
    // 反码
    1,111 1100
    // 补码
    1,000 1101
    <!-- 用逗号将符号位和数值分开 -->
    
  • 简单来说,数字a(无论正负)的补码就是-a(除0 和最小负数)
  • 补码的表示形式中,0 只有一种形式,但是有该字长内可表示的最大的负值。例如 8 位字长,表示范围则为-128~127

    -2n-1 ~ +(2n-1-1)

a. 特别的数字: 0 和最小负数(补码等于本身)

下面都以 8 位为例

  • 字长 8 位,则补码可以表示的最小负数为-2**(8-1) = -128

  • 0

    // 原码
    0,000 0000
    // 反码
    0,111 1111
    // 补码
    0,111 1111 + 1 = 0,000 0000(溢出位忽略)
    
  • -128

    // 原码
    1,000 0000
    // 反码
    1,111 1111
    // 补码
    1,111 1111 + 1 = 1,000 0000
    

b. n 位(bit) 的补码系统中几个特殊值

补码实际数字含义
0 111...1112n-1-1有符号位的最大正数
0 000...0000补码中只有这一种 0 的表示形式
1 000...001-2n-1+1比如是 8 位的系统,但是现在 n 取 5 位
1 000...000-2n-1比如 8 位的补码系统,这个值就表示-128(最小的负数)

c. 补码的工作原理

  • 用钟表作为例子,假设当前时针指向 10 点,将时针顺时针拨动 8 个小时,指向 6 点,或者将时针逆时针拨动 4 个小时,也指向 6 点

    • 上述钟表就是一个以 12 模的系统
    • 8 和 4 互为补数,上面的操作,凡是减 4 操作都可以用加 8 来代替。特点就是两个数值想加等于模
  • 指记数范围,n 位的计算机的计量范围 0~2n-1,模 = 2n。“模”实质上是计量器产生溢出的量

    • 假设n=8位的计算机
    • 表示的最大数是11111111,若再加 1,即11111111+1=100000000,溢出的位舍去,又回到00000000,所以 8 位二进制系统的模为 2**8=256
    • 模 256 下的加减法,用0,1,...,254,255表示值和用-128,-127,...,0,...,127等价。例如,(126+125) ≡ 251 ≡ -5 (mod 256)
  • 机器中的二进制数

    • 在 8 位硬件中1000 0011 是实际存在的一个电气状态
    • 给这个状态起一个名字叫+131,也可以给它起个名字叫-3
      • 如果叫+131,那么结合实际数字,可以将0000000011111111起上相应的名字 0 到+255
      • 如果叫-3,可以给这些的状态定义成符合某中规律的名字含义。例如补码系统中,根据 a 的补码等于-a,可以发现用最高位表示符号后,256 个状态可以拆分成两组,即-128~-1 和 0~127
  • 结论:

    • 一个负数可用它的正补数(模加上负数本身得到的数)代替
    • 互为补数的两个数的绝对值之和为模
    • 正数的补码等于该正数的原码

d. 定点小数的补码

  • 1 > x ≥ 0,[x] = x

    x = 0.1001
    [x]原 = [x]补 = 0.1001
    
  • 0 > x > ≥ -1 (mod 2),[x] = 2 + x

    x = -0.0110
    [x]补 = 2 + x = 10.0000 - 0.0110 = 1.1010
    

e. 定点加减运算

  • 减法运算可以看做被减数加上一个减数的负值,所以采用补码作加减运算
  • 基本公式
    • 整数:[A] + [B] = [A+B] (mod 2n+1)
    • 小数:[A] + [B] = [A+B] (mod 2)
  • 对于减法 [A-B] = [A + (-B)]
    • 整数:[A-B] = [A] + [-B] (mod 2n+1)
    • 小数:[A-B] = [A] + [-B] (mod 2)
  A = 0.1011, B= -0.0101
  [A]补 = 0.1011, [B]补 = 1.1011
  [A+B]补 = 0.1011
          + 1.1011
         = 10.0110
  -> 纯小数的补码模mod为2,也就是说整数不能超过1,否则溢出
  -> 符号位的进位舍去,即最高位1舍去
  [A+B]补 = 0.0110
  • 判断溢出

    • 使用一位符号位判断,无论加减,如果两数的符号相同,但是运算结果的符号和这两个数的符号不同,则为溢出
    • 使用两位符号位(此时 mod 不再是 2,而是 4)判断是否溢出,当两位符号位不同,则溢出,否则无溢出,无论是否溢出,最高位表示真正的符号
    x = -0.1011; y = -0.0111
    [x]补' = 11.0101; [y]补' = 11.1001
    [x]补' + [y]补' = 11.0101
                 +  11.1001
                 = 110.1110
    
    • 符号位的进位丢掉,就是最左边的 1,符号位只有两位
    • 结果的符号位01表示正溢出,10表示负溢出
    • 第一个符号位表示实际的符号

6. 移码

  • 将补码的符号位取反得到(仅符号位相反)

  • 一般用做浮点数的阶码 E,阶码 = 移码 - 1

    (00000011)[原] = (01111101)[补] = (11111101)[移];
    (10000011)[原] = (11111101)[补] = (01111101)[移];
    

二、 进制转换

整数

0. 基础

  • 基数:每个进制的基数 R。二进制就是 2,十六进制是 16
  • 权值:固定位置上的计数单位。例如,2**2,2**1,2**0
  • 位权:多位数,处在某一位上的“1”所表示的数值的大小,称为该位的位权
    • N 进制数,整数部分第 i 位的位权为 N**(i-1)。2 进制整数第 4 位为2**(4-1)
    • N 进制数,小数部分第 j 位的位权为 N**(-j)

1. 十进制转为二进制(除 2 取余法,逆序读取)

  • 每次将整数部分除以 2,余数为该位权上的数
  • 商如果不为 0,则继续做除以 2 的操作,直到商为 0
  • 从最后一个余数开始读,直到第一个余数
(23)10 = (10111)2
2 | 23
  |________
    2 | 11              ···· 1
      |_______
        2 | 5           ···· 1
          |_______
            2 | 2       ···· 1
              |______
               2 | 1    ···· 0
                 |____
                   0    ···· 1

2. 二进制转十进制

  • 根据权值,计算该位的值,所有位的值想加
  • 从 0 开始,第 0 位 X0 * 2**0,第 1 位 X1 * 2**1 …… 最后所有值想加
(10111)2 = (23)10

( 1 * 24 ) + ( 0 * 23 ) + ( 1 * 22 ) + ( 1 * 21 ) + ( 1 * 20 ) = 23

3. 八进制和十六进制

  • (8 或 16 ----> 10)八进制或十六进制转十进制同二进制,将 2 改成 8 或者 16 即可
  • (10 ----> 8 或 16)十进制转为八进制或十六进制,可以使用除 8 取余除 16 取余
  • 计算量小的方法:先将十进制转为二进制,再通过二进制转为八或十六进制
(23)10 = (10111)2

010111

(2 7)8

00010111

(1 7)16

小数

1. 十进制转二进制(乘 2 取整法,顺序读取)

  • 用 2 乘十进制小数得积,取积的整数部分为该位的位权上的数
  • 取整后余下的小数部分再乘 2 取整,直到积中的小数部分为 0(或达到所要的精度)
  • 将整数部分按顺序读取
(0.8125)10 = (0.1101)2
0.8125 x 2 = 1.625 ···· 1
0.625  x 2 = 1.25  ···· 1
0.25   x 2 = 0.5   ···· 0
0.5    X 2 = 1.0   ···· 1

2. 二进制转为十进制

  • 根据权值,计算该位的值,所有位的值想加
  • 从小数点后第 1 位开始,小数点后第 1 位 X0 * 2**-1,小数点后第 2 位 X1 * 2**-2 …… Xn * 2**-n最后所有想加
(0.1101)2 = (0.8125)10

( 1 * 2-1 ) + ( 1 * 2-2 ) + ( 0 * 2-3 ) + ( 1 * 2-4 ) ) = 0.8125

3. 其他进制

  • 八(十六)进制就是讲基数换成 8(16),方法不变
  • 可以先将十进制(二进制)转换为二进制(十进制),再与八进制(十六进制)转换,使用划线方法

⚡⚡ 在 JavaScript 中使用 toString 进行进制转换

Number(0.1).toString(2);
// "0.0001100110011001100110011001100110011001100110011001101"
Number(0x1a).toString(8);
// 0o32
  • 实际上 v8 引擎会自动转换进制
0o32 === 0x1a; // true
0x1a === 26; // true

三、 浮点数

1. 一般浮点数定义

  • 浮点数:小数点可以浮动的数,表示为 N = M · R**E

    • M 尾数,可正可负
    • E 阶码,可正可负
    • R 基数

    以 R=2 为例,数 N 由于小数点的的浮动可以有不同的形式

    N = 11.0101
      = 0.110101 x 2**10
      = 0.00110101 x 2**100
      = 1.10101 x 2**1
      = 1101.01 x 2**-10
      ...
    
  • 为提高精度以及便于比较,规定浮点数尾数用纯小数,上例中0.110101 x 2**100.00110101 x 2**100符合

    • 尾数最高位为 1 的浮点数称为规格化数0.110101 x 2**10就是规格化形式
    • 浮点数表示成规格化后,精度最高

2. IEEE754 标准中的双精度浮点数

a. double 双精度浮点数由 64 位固定长度表示,分为三部分

  • 符号位 S:第 1 位是正、负数符号位(sign),0 代表正数,1 代表负数
  • 指数位 E(阶码):用移码表示,中间 11 位存储指数(exponent),表示次方数
  • 尾数位 M:用原码表示,最后的 52 位是尾数(mantissa),超出的部分自动1 进 0 舍

b. 尾数规格化形式

1.fff...fff
  • 小数点其实是不存在的,只是为了区分

  • 小数点左边的数(整数位)在标准中总是 1,规定中自动省略,称为隐藏位

    // 原始十进制
    13.125
    // 二进制
    1101.001
    // 二进制浮点表示
    1.101001 x 2**11
    // IEEE754 双精度浮点表示
    -> 符号位 0
    -> 阶码偏移 000 0000 0011 + 011 1111 1111 = 100 0000 0010
    -> 尾数规格化
    // 最高位的1被隐藏,还原成普通二进制浮点数的时候要注意将1还原
    1010 0100 0000 ... 0000(共52位)
    

c. IEEE754 中的指数偏移

IEEE754 规定了一个偏移量,指数部分每次都加上这个偏移量,这样即使指数为负数,加上偏移量保证为正数(正数方便比较大小)

  • double 双精度的指数部分为二进制的 11 位,表示的数据范围是 0~2047(2**11 - 1)

  • IEEE754 规定1023为双精度的偏移量Bias,所以指数 E 取值[0,1022]表示阶码为负,[1024,2047]表示阶码为正

    • 当指数不全是 0 也不全是 1 时(规格化的数值),指数 E 可以表示范围[1, 2046],阶码公式为E-Bias,所以实际取值范围[-1022, 1023]
    • 当指数全为 0(非规格化数值),阶码公式为1-Bias,即1-1023 = -1022
    • 当指数位全为 1 的时候(特殊值),可用来表示三个特殊值
      • 尾数 M ≠ 0,表示NaN
      • 尾数 M = 0,符号位 S=0,表示正无穷
      • 尾数 M = 0,符号位 S=1,表示负无穷

按照 IEEE754 标准计算 78.735 的存储方式

(78.375)10 = (1001110.011)2 = 1.001110011 x 26 => 1.001110011 x 21029
  • S = 0
  • E = 1029(1029 - 1023= 6),转为二进制 10000000101
  • M = 001110011
0(sign) 10000000101(exponent) 0011 1001 1000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000

d. 用移码表示阶码(指数)

  • 阶码 = 移码 - 1

    // 上述中78.735用双精度浮点数表示,指数是6
    E = 6 + 1023 = 1029
    // 1029
    100000000101
    // 6
    00000000110[原] = 00000000110[补] = 100000000101[移]
    // 0.1用浮点数表示,指数是-4
    E = -4 + 1023 = 1019
    // 1019
    01111111011
    // -4
    10000000100[原] = 11111111100[补] = 01111111011[移]
    

e. 计算公式

V = (-1)S × 2E-1023 × (M + 1)
V = (-1)sign × 2exponent-0x3ff × 1.mantissa

3. 双精度浮点数的加、减法运算

💡 先将 IEEE754 形式转为普通二进制数或者普通浮点表示形式进行加减运算

  • IEEE754 有一个隐藏位,不能直接进行加减运算
  • 转为普通浮点数形式,进行浮点数加减运算

a. 0 操作数的检查,如果判断两个操作数中有一个数是 0,则可以直接返回结果

b. 比较阶码大小并完成对阶

  • 先将 IEEE754 表示的双精度浮点数转为一般浮点数形式
  • △E = Ex-Ey
    • △E = 0,则表示对齐,尾数直接进行加减运算
    • △E ≠ 0,则不对齐,则需要对齐操作
  • 通过尾数的移动来改变阶码,达到对齐的效果
    • 对阶操作规定使 尾数右移。因为尾数左移会造成最高有效位的丢失,误差较大;右移最低有效位会丢失,但是误差较小(右移丢失的位要判断舍入)
    • 小阶向大阶看齐
      • 阶码小的浮点数的尾数向右移动(相当于小数点左移)
      • 尾数每右移一位,其阶码加 1

c. 尾数求和运算,按照“定点数加减规则”进行运算

  • 用补码进行加减法运算
  • 使用两位符号位的形式,最高位为实际符号

d. 结果规格化

  • 当 R = 2,尾数规格化形式 1/2 ≤ |S| < 1

    • 当 S > 0,尾数的补码规格化形式:[S]补 = 00.1xxx...x
    • 当 S < 0,尾数的补码规格化形式:[S]补 = 11.0xxx...x
    • 当尾数的最高数值位于符号位不同时,即为规格化形式
  • 左规:当出现00.0xxx...x或者11.1xxx...x时,尾数左移,阶码减小,直到符合规格化格式

    // 阶码和尾数都用两位符号位
    // 用分号将符号位和数值位分开
    [x+y]补 = 00,11; 11.1001
    // 左规
    [x+y]补 = 00,10; 11.0010
    //
    x+y = (-0.1110) x 2**10
    
  • 右规:当出现01.xxx...x10.xxx...x时,表示溢出(定点运算中不允许),浮点运算中可以将尾数向右移,阶码增加,直到符合规格化形式

e. 舍入和溢出处理

  • 两种舍入方法舍0进1恒置1
  • 先将尾数规格化,再判断是否溢出

四、JavaScript 中的一道奇怪的加法(浮点数精度问题)

0.1 + 0.2 !== 0.3;
0.1 + 0.2;
// 0.30000000000000004

在 JS 中,(x + y) + z = x +(y + z)不一定成立

0.1 + 0.2 + 0.3;
// 0.60000000000000001
0.1 + (0.2 + 0.3);
// 0.6

1. 计算机存储的数据都是二进制

  • 在计算 0.1 + 0.2 的时候,计算机其实计算的是存储的二进制数
  • 0.1 转为二进制是0.000110011001100...,(1100 循环)
  • 0.2 转为二进制是0.001100110011001...,(1100 循环)

2. JavaScript 如何存储数字

  • JavaScript 内部,整数和浮点数都只有一种类型Number,采用的是同样的储存方法,所以 25 和 25.0 被视为同一个值
  • 遵循 IEEE754 标准,使用 64 位固定长度表示,即标准的double双精度浮点数
  • 数值精度最多可以达到 53 个二进制位(1 个隐藏位与 52 个有效位)。如果数值的精度超过这个限度,第 54 位及后面的位就会被丢弃
  • 归一化处理整数和小数,节省存储空间

3. 0.1 + 0.2

  • 0.10.2在计算机中不可能按照无限循环方式存储,所以就要保留小数点后一定的位数,JS 采用 IEEE754 标准

    • 0.1

      // 二进制表示 Number(0.1).toString(2)
      0.000110011001100...(1100无限循环)
      // 规格化二进制浮点数
      😏 0.11001100...(1100无限循环) x 2**-11
      // IEEE754 双精度表示
      -> 符号位 0
      -> 阶码偏移 -4 + 1023 = 1019 = 01111111011
      -> 规格化尾数 1001...1010
      -> 0;1001...1010 x 2**01111111011
      // 转成十进制数
      0.100000000000000005551115123126
      因此就出现了浮点误差
      
    • 0.2

      // 二进制表示
      0.00110011001100...(1100无限循环)
      // 规格化二进制浮点数
      😏 0.11001100...(1100无限循环) x 2**-10
      // IEEE754 双精度表示
      -> 符号位 0
      -> 阶码偏移 -3 + 1023 = 1020 = 01111111100
      -> 规格化尾数 1001...1010
      -> 0;1001...1010 x 2**01111111100
      
  • 在计算时,先将 IEEE754 表示的浮点数形式转为规格化的二进制浮点数 😏

  • 两个数的阶码不相等,需要进行对阶

    • 小阶向大阶对齐
    • 0.1 的阶码小,0.1 的尾数右移一位,阶码增加 1,则阶码对齐
    // 0.1对阶后尾数
    01100110011001100110011001100110011001100110011001101;
    // 0.2尾数
    11001100110011001100110011001100110011001100110011010;
    
  • 尾数求和

    • 和定点数的加减运算一样
    • 采用两位符号位的补码运算,[A] + [B] = [A+B]
    • 0.1 和 0.2 都是正数,补码等于原码
    00.01100110011001100110011001100110011001100110011001101 +
    00.11001100110011001100110011001100110011001100110011010 =
    01.00110011001100110011001100110011001100110011001101000
    -> 结果右规
    0.100110011001100110011001100110011001100110011001101 x 2**-1
    -> 真值
    +0.0100110011001100110011001100110011001100110011001101
    -> 转为十进制
    0.30000000000000004
    


  • 1-0.9=0.09999999999999998同样是浮点数精度问题

4. 为什么 JavaScript 可以表示 0.1

a. 0.1 转为浮点数储存后,再转为十进制数,由于精度问题,已经不再等于 0.1

0;1001...1010 x 2**01111111011
// 转成十进制数
0.100000000000000005551115123126

b. 在使用时依然显示的准确的 0.1

  • IEEE754 标准中的双精度浮点数的尾数固定长度是 52 位,还有 1 位隐藏位,一共有 53 位,最多可以表示的数2**53 = 9007199254740992

  • 9007199254740992用科学计数法表示9.007199254740992 x 10**1616是 JS 最多能表示的精度长度

  • 超过精度长度的数会做凑整处理,或使用toPrecision(16)做精度运算

    var n = 0.1;
    n.totoPrecision(16);
    // "0.1000000000000000"
    n.totoPrecision(25);
    // "0.1000000000000000055511151"
    

5. 如何表示 0.1 + 0.2 和 0.3 的关系

使用 JavaScript 提供的最小精度值 Number.EPSILON

Math.abs(0.1 + 0.2 - 0.3) <= Number.EPSILON;

JavaScript 认为运算结果的误差小于这个值,则认为没有误差

五、如何解决浮点误差

1. toPrecision

  • 以指定的精度返回该数值对象的字符串表示

  • 精度是从左到右第一个不为 0 的数起算,和小数点位置无关

  • 被舍去的最高位会做四舍五入

    (1.005).toPrecision(17); // "1.0049999999999999"
    (123.624).toPrecision(3); // "124"
    

1.005 的精度放大后为什么小于原来的值了?

  • JavaScript 中所有的数只有一种Number类型,符合 IEEE754 标准,所以就有浮点数精度问题
  • 由于 JS 能准确表示一个数的最大精度是 16,所以数值在表示时默认取 16 位精度,超出部分做取整
  • 当放大精度后,原来位置的数值被逐渐还原,所以发现不在再准确等于这个值

2. toFixed

  • 使用定点表示法来格式化一个数,也就是保留小数点后多少位

  • 保留的位数一般选择 0~20,这个范围内不会出现RangeError。如果不加参数则默认使用 0

  • 如果数值大于1e+21,该方法会简单的调用Number.prototype.toString(),返回指数计数格式的字符串

    const num = 12345.6789;
    num.toFixed(); // "12346"
    num.toFixed(6); // "12346.678900"
    (1.23e10).toFixed(2); // "12300000000.00"
    (1.23e30).toFixed(); // "1.23e+30"
    
  • 在做四舍五入时,有一个不可避免的 bug

    (1.005).toFixed(2); // "1.00",而不是"1.01"
    
    • 1.005 在实际显示时,是默认选择 16 为精度取整了的,而实际存储的是双精度浮点数,尾数精度要大于 16 位的
    • 当使用toFixed(包括toPrecision)时,实际上使用的是存储的双精度浮点数"1.00499999999999989341858963598497211933135986328125",进行定点数格式化
    • 因为小数点后保留 2 位,所以要看第 3 位的取舍,由于第 3 位是4,根据四舍五入舍去,所以最终为1.00

3. 数据展示类的解决

  • 建议使用toPrecision凑整并使用parseFloat转成数字后显示

    function displayNum(num, precision = 12) {
      return +parseFloat(num, toPrecision(precision));
    }
    
    • 选择12的精度,是经验选择,该精度可以解决大部分00010009的问题

4. 数据运算类

  • 进行四则运算,不应通过toPrecision进行精度凑整,这样最终结果误差会很大
  • 正确做法:将需要计算的数字升级(乘以 10 的 n 次幂)成计算机能够精确识别的整数,等计算完毕再降级(除以 10 的 n 次幂)
  • 遇到科学计数法如2.3e+1当数字精度大于 21 时,数字会强制转为科学计数法形式显示)时还需要特别处理一下
  • 具体做法可以看这篇关于 javascript 中对浮点加,减,乘,除的精度分析

六、最大数值、安全整数和大数危机

1. 最大数值 Number.MAX_VALUE和最小正数

  • 符号位 0,阶码取 1023,尾数全为 1,可表示最大值,在 JavaScript 中,可以使用Number静态属性Number.MAX_VALUE表示

    const max =
      (-1) ** 0 * 2 ** 1023 * (Number.parseInt('1'.repeat(53), 2) * 2 ** -52);
    // 1.7976931348623157e+308
    Number.MAX_VALUE;
    // 1.7976931348623157e+308
    
  • 符号位为 0,阶码取 0(当指数全为 0,即非规格化数值,阶码公式为1-Bias,即1-1023 = -1022),尾数仅最后一位为 1,表示最小正数

    const min =
      (-1) ** 0 *
      2 ** -1022 *
      (Number.parseInt('0'.repeat(52) + '1', 2) * 2 ** -52);
    // 5e-324
    Number.MIN_VALUE;
    // 5e-324
    
  • 理论上最大值是Math.pow(2, 1024) - 1,但是Math.pow(2, 1024)已经是Infinity,所以不能用这样的表示法

    Infinity - 1;
    // Infinity
    Infinity - Number.MAX_VALUE;
    // Infinity
    Infinity - Infinity;
    // NaN
    

2. 最大安全整数Number.MAX_SAFE_INTEGER 和最小安全整数Number.MIN_SAFE_INTEGER

  • 安全整数表示在这个范围内,所有的整数都有唯一的浮点数表示,范围之外的数由于精度问题无法准确表示

  • 最大安全整数Number.MAX_SAFE_INTEGER2**53 -1,这个数字表明 JS 中 number 的最大精度不超过 16 位

    // 2**53 - 1
    符号位:0,指数:52,尾数:111...1(一共521,不包含隐藏位)
    // 2**53
    符号位:0,指数:53,尾数:000...00(一共530,不包含隐藏位)
    // 2**53 + 1
    符号位:0,指数:53,尾数:000...01(一共520,第53位为1,不包含隐藏位)
    
    • 由于 IEEE754 标准中尾数位数是 52 位,从第 53 位往后全都舍去
    • 2**532**53 + 1尾数都有 53 位,按照标准会将第 53 位舍去,所以这些数是不能准确表示的
      • (2**53, 2**54)只能精确表示偶数
      • (2**54, 2**55)只能精确表示 4 的倍数,因为会损失第 53、54 位共两位精度
      • 以此类推
  • 最小安全整数,就是最大安全整数的负值

3. Number.EPSILON

  • 1 与大于 1 的最小浮点数之差
  • JavaScript 能够表示的最小精度
  • 认为运算结果的误差小于这个值,则认为没有误差

4. 如何处理大数危机

💡 目前大数问题一般是转为字符串,虽然保证了精度,但是运算效率就会降低

  • TC39 已经有第 3 阶段草案proposal bigint,但是目前需要使用 Babel7.0 实现

    Chrome 69+ 的浏览器已经支持该类型,其他浏览器还未支持

    • 生成任意精度的整数

    • 草案中提到,ECMAScript 会有两个内置的数字类型:NumberBigint,所以Bigint是全新的原始类型

    • 使用 BigInt(number),将Number转为Bigint,标志是以整数后以n结尾

      BigInt(123);
      // 123n
      typeof 123n;
      // "bigint"
      typeof 123;
      // "number"
      
    • Bigint严格不等于Number,但是在用两个等号判断时,则有可能相等,并且0nif条件语句中为假

      123n === 123; // false
      123n == 123; // true
      0n === false; // false
      0n == false; // true
      
    • BigInt顾名思义为大整数,所以如果用它来转换一个小数,会抛出异常;如果传入科学计数法的数值,则会将其展开

      1.2n;
      // Uncaught SyntaxError: Invalid or unexpected token
      BigInt(1.23);
      // Uncaught RangeError: The number 1.23 cannot be converted to a BigInt because it is not an integer
      BigInt(1.23e10);
      // 12300000000n
      
    • 不允许BigIntNumber混合运算,因为BigInt不会发生隐式类型转换,必须显示调用来转换为其他类型

      1 + 1n;
      // Uncaught TypeError: Cannot mix BigInt and other types, use explicit conversions
      BigInt(123) + 123n;
      // 246n
      
    • BigInt可以和Number进行比较,不过当数值部分相等时,判断会有奇怪的结果

      123 < 125n; // true
      123 == 123n; // true
      123 === 123n; // false
      123 < 123n; // false
      123 > 123n; // false
      123 <= 123n; // true
      123 >= 123n; // true
      
    • 传递给 BigInt 超过 64 位整数范围的值(例如,63 位数值 + 1 位符号位),就会发生溢出

  • 使用第三方库 bignumber.js

    const BN = require('bignumber.js');
    const big = new BN('8182931235402704882');
    big;
    // ->
    {
      c: [81829, 31235402704882];
      e: 18;
      s: 1;
    }
    

5. 面试题:两个大数求和

  • 描述:对于两个超过了 JS 安全范围的整数,用加法求和并不能得到准确的结果,现在希望写一种方式能够对两个超过了 JS 安全范围的整数求和得到准确的结果
/**
 * JavaScript安全范围数之外的数求和
 * 思路:
 * 1.将大数以字符串表示
 * 2.将字符串转为数组,并反转数组,按照低位到高位(个、十、百、千...)
 * 3.按位相加,设置进位标志
 * 4.结果任以字符串输出
 * @param {string} a
 * @param {string} b
 * @returns {string} 返回结果为字符串
 */
function bnSum(a, b) {
  // 通过交换确保参数a的长度最大
  if (a.length < b.length) {
    [a, b] = [b, a];
  }
  // 转为数组
  let [arr1, arr2] = [[...a].reverse(), [...b].reverse()];
  // 进位标志
  let num = 0;
  for (let i = 0; i < arr1.length; i++) {
    // 判断位数少的数在该位置上是否还有值
    if (arr2[i]) {
      arr1[i] = Number.parseInt(arr1[i]) + Number.parseInt(arr2[i]) + num;
    } else {
      arr1[i] = Number.parseInt(arr1[i]) + num;
    }
    // 判断该位置的和有没有进位
    if (arr1[i] >= 10) {
      [arr1[i], num] = [arr1[i] % 10, 1];
    } else {
      num = 0;
    }
  }
  // 判断最高位有没有进位
  if (num > 0) {
    arr1[arr1.length] = num;
  }
  return arr1.reverse().join('');
}

参考