相信大家在平常的 JavaScript 开发中, 都有遇到过浮点数运算精度误差的问题, 比如 console.log(0.1+0.2===0.3)// false. 在 JavaScript 中, 所有的数字包括整数和小数都是用 Number 类型来表示的. 本文通过介绍 Number 的二进制存储标准来理解浮点数运算精度问题, 和理解 Number 对象的 MAX_VALUE 等属性值是如何取值的, 最后介绍了一些常用的浮点数精度运算解决方案.
Number 的存储标准
JavaScript Number 采用的是 IEEE 754 定义的 64 位双精度浮点型来表示. 具体的字节分配可以先看一下引自维基百科的图:
从上图中可以看到, 从高到低, 64 位被分成 3 段, 分别是:
sign: 符号位, 占 1 位;
exponent: 指数位, 占 11 位;
fraction: 有效数字位, 占 52 位.
指数位有 11 位, 取值范围是 0 到 2047. 当指数位 e=0 或者 e=2017 时, 根据有效数字位 f 是否为 0 , 具有不同的特殊含义, 具体见下表:
对于常用的 normal number, 为了方便表示指数为负数的情况, 所以, 指数位数值大小做了一个 -1023 的偏移量. 对于一个非 0 数字而言,, 它的二进制的科学计数法里的第一位有效数字固定是 1. 这样, 一个双精度浮点型数字的值就是
对于 subnormal number, 它可以用来表示更加接近于 0 的数, 它特殊的地方是有效数字位的前面补充的是 0 而不是 1, 且指数为偏移量是 -1022, 所以值是:
Number 对象中的几个属性值
知道了 Number 是如何存储之后, Number 对象的属性是如何取值的就明朗了.
Number.MAX_VALUE: 可表示的最大的数, 显然 e 和 f 都取最大时能表示的数最大, 值为
Number.MIN_VALUE: 可表示的最小的正数, 用最小的 subnormal number 来表示. 当 e = 0 ,f 的最后一位为 1, 其他为 0 时最小, 值为
Number.EPSILON : 表示 1 与 Number 可表示的大于 1 的最小的浮点数之间的差值. 值为
Number.MAXSAFEINTEGER: 表示在 JavaScript 中最大的安全整数. 可以连续且精确被表示出来的整数成为安全整数, 比如 2^54 就不是个安全整数, 因为它和 2^54+1 两个数的表示是完全一样的, e=1077,f=0. Math.pow(2,54)===Math.pow(2,54)+1// true. 整数转化为二进制后, 小数点后是不会有数字的, 而用二进制的科学计数法表示时, 小数点后最多保留 52 位, 加上前置的一个 1, 有 53 位数字, 所以当一个数转化二进制时, 如果位数超过 53 位, 必然会截断末尾的部分, 即导致不能精确表示, 即为不安全整数. 所以最小的会被截断的整数是 100...001=2^53+1(中间有 52 个 0). 这个数设为 X, 则比 X 小的整数都能被精确表示出来, 再加上 "连续" 这个条件, 所以 X-1 不是我们要的答案, X-2 才是. Number.MAX_SAFE_INTEGER 最终值为
Number.MINSAFEINTEGER: 表示在 JavaScript 中最小的安全整数, 对 Number.MAX_SAFE_INTEGER 取负值即可, 值为 -9007199254740991
为什么 0.1+0.2 不等于 0.3
现在看看 console.log(0.1+0.2===0.3)// false 这个问题, 数字 0.1 转化成二进制是 0.0001100110011... 即 1.10011001...1001 * 2^-4 (小数部分有 52 位, 即有 13 个 1001 循环). 由于第 53 位是 1, 类似 10 进制的四舍五入, 二进制是 "零舍一入", 所以 0.1 的最终二进制科学计数法表示是 1.10011001...1010 * 2^-4, 即二进制数值大小实际上是 0.000110011001...10011010. 下面的代码验证了这个值 (打印出来的值, 把最末尾的 0 去掉了):
var a = 0.1;console.log(a.toString(2)); //0.0001100110011001100110011001100110011001100110011001101
同理十进制数字 0.2 转化为二进制的最终值是 1.10011001...1010 * 2^-3 即 0.00110011...100111010; 十进制 0.3 转化位二进制的最终值是 1.00110011...0011 * 2^-2
var b = 0.2;console.log(b.toString(2)); //0.001100110011001100110011001100110011001100110011001101var c = 0.3;console.log(c.toString(2)); //0.010011001100110011001100110011001100110011001100110011
所以, 0.1+0.2 的值即为上面 0.1 和 0.2 对应的二进制数值的相加, 如下图所示
上图中, 对所得的和,"零舍一入" 保留 52 位有效小数就是最终的值: 0.01001100...110100(第 53 位是 1 , 所以往前进了 1), 如下代码所示. 这个值与上文中的 0.3 的最终二进制表示的值明显不相同, 即解释了 0.1 + 0.2 不等于 0.3 的根本原因所在 (实际上, 这个值转化为 10 进制约等于 0.30000000000000004). 注: 打印出来的长度是 54, 因为有 52 位有效小数, 前面是'0.01', 长度是 4, 最后去掉末尾的 2 个 0, 所以最后打印出来的长度是 52+4-2 = 54.
var d = 0.1 + 0.2;console.log(d.toString(2)); //0.0100110011001100110011001100110011001100110011001101console.log(d.toString(2).length); // 54
浮点数精度运算解决方案
关于 JS 浮点数运算精度丢失的问题, 不同场景可以有不同的解决方案. 1, 如果只是用来展示一个浮点数的结果, 则可以借用 Number 对象的 toFixed 和 parseFloat 方法. 下面代码片段中, fixed 参数表示要保留几位小数, 可以根据实际场景调整精度.
function formatNum(num, fixed = 10) { return parseFloat(a.toFixed(fixed))}var a = 0.1 + 0.2;console.log(formatNum(a)); //0.3
2, 如果需要进行浮点数的加减乘除等运算, 由上文可知, 在小于 Number.MAXSAFEINTEGER 范围的整数是可以被精确表示出来的, 所以可以先把小数转化为整数, 运算得到结果后再转化为对应的小数. 比如两个浮点数的加法:
function add(num1, num2) { var decimalLen1 = (num1.toString().split('.')[1] || '').length; // 第一个参数的小数个数 var decimalLen2 = (num2.toString().split('.')[1] ||'').length; // 第二个参数的小数个数 var baseNum = Math.pow(10, Math.max(decimalLen1, decimalLen2)); return (num1 * baseNum + num2 * baseNum) / baseNum;}console.log(add(0.1 , 0.2)); //0.3
参考资料
- https://en.wikipedia.org/wiki/IEEE_754
- https://en.wikipedia.org/wiki/Denormal_number
来源: https://juejin.im/post/5c22fcbe6fb9a049ba419d4c