前言
前段时间, 在群里跟 Peter 说到JS的浮点数问题。
他问我, 为什么 0.1 + 0.2 !== 0.3, 而 0.05 + 0.25 === 0.3 ?
当时也大概解释了下是精度丢失
, 周末又回想到这个问题,看了一些资料, 觉得回答不够具体(文末给出了参考链接)。
所以,今天就再说说这个问题。
为什么 0.1+0.2 === 0.30000000000000004?
要搞懂这个问题, 首先还是要先要搞清楚 JavaScript 如何存储小数
的。
和其它语言如 Java 和 Python 不同,JavaScript 中所有数字包括整数和小数都只有一种类型:Number
。
它的实现遵循 IEEE 754 标准
.
使用 64 位固定长度来表示,也就是标准的double 双精度浮点数
。
这样的存储结构优点是可以归一化处理整数和小数,节省存储空间
。

这64
个比特又可分为三个部分,即:
第1位
: 是符号的标志位(S), 0代表正数,1代表负数
第1-11位
: 指数位(E), 存储指数(exponent),用来表示次方数
第12-63位
: 尾数(M), 这52 位是尾数,超出的部分自动进一舍零
实际数字就可以用以下公式来计算:
以0.1
为例。
0.1
的二进制是0.00011001100110011001100110011001100110011001100110011001100...
那么, 首先, 该数是正数, 标志位 sign = 0
.
其次, 将小数转化为科学计数法, 指数位-4, 即exponent
= 2 ^10 - 4 = 1019 1.1001100110011001100110011001100110011001100110011001100... * 2^-4
由于科学计数法, 第一个数始终是1, 所以可以忽略存储, 只要存后面的52位就可以了.
如果超过了52位, 就是对第53位舍0进1, 结果也就是100110011001100110011001100110011001100110011001101
了。
注意以上的公式遵循科学计数法的规范,在十进制是为0<M<10
,到二进行就是0<M<2
。
也就是说整数部分只能是1,所以可以被舍去,只保留后面的小数部分。
比如 4.5
转换成二进制就是 100.1
,科学计数法表示是 1.001*2^2
,舍去1后 M = 001
。
E是一个无符号整数
,因为长度是11
位,取值范围是0~2047
。
但是科学计数法中的指数是可以为负数
的,所以再减去一个中间数1023
,[0,1022]表示为负,[1024,2047] 表示为正。
如4.5
的指数E = 1025,尾数M为 001
。
最终的公式变成:
所以 4.5
最终表示为(M=001、E=1025):
[图片生成工具: http://www.binaryconvert.com/convert_double.html]
下面再以 0.1
例解释浮点误差的原因, 0.1
转成二进制表示为 0.0001100110011001100
(1100循环),1.100110011001100x2^-4
,所以 E=-4+1023=1019
;M 舍去首位的1,得到 100110011...
。
最终就是:

转化成十进制后为 0.100000000000000005551115123126
,因此就出现了浮点误差。
看到这, 我们再回到开头的问题。
0.1+0.2=0.30000000000000004
?这其中的计算步骤为:
// 0.1 和 0.2 都转化成二进制后再进行运算
0.00011001100110011001100110011001100110011001100110011010 +
0.0011001100110011001100110011001100110011001100110011010 =
0.0100110011001100110011001100110011001100110011001100111
// 转成十进制正好是 0.30000000000000004
x = 0.1
能得到 0.1
?因为尾数固定长度是 52 位
,再加上省略的一位,最多可以表示的数是 2^53=9007199254740992
,对应科学计数尾数是9.007199254740992
,这也是 JS 最多能表示的精度。它的长度是16,所以可以使用 toPrecision(16)
来做精度运算,超过的精度会自动做凑整处理
。
于是就有:
0.10000000000000000555.toPrecision(16)
// 返回 0.1000000000000000,去掉末尾的零后正好为 0.1
// 但你看到的 `0.1` 实际上并不是 `0.1`。不信你可用更高的精度试试:
0.1.toPrecision(21) = 0.100000000000000005551
另外,我们也知道, 整数十进制转二进制时, 是除以二去余数, 这是可以除尽的。
但是, 小数十进制转化为二进制的计算方法是, 小数部分*2, 取整数部分, 直至小数部分为0, 如果永远不为零, 在超过精度时的最后一位时0舍入1。
/* 0.1 转化为二进制的计算过程 */
0.1 * 2 = 0.2 > 取0
0.2 * 2 = 0.4 > 取0
0.4 * 2 = 0.8 > 取0
0.8 * 2 = 1.6 > 取1
0.6 * 2 = 1.2 > 取1
0.2 * 2 = 0.4 > 取0
...
到这里, 我们就可以发现一些端倪了
// 使用toString(2), 将10进制输出为二进制的字符串
0.1.toString(2);
// "0.00011001100110011001100110011001100110011001100110011001100..."
0.2.toString(2);
// "0.001100110011001100110011001100110011001100110011001100110011..."
// 二进制相加结果, 由于超过精度, 取52位, 第53位舍0进1
> "0.010011001100110011001100110011001100110011001100110011,1"
// 最后存储下来的结果是
const s = "0.010011001100110011001100110011001100110011001100110100"
// 用算法处理一下。
a = 0;
s.split('').forEach((i, index) => { a += (+i/Math.pow(2, index+1))});
a >> 0.30000000000000004
到这里, 0.1 + 0.2 === 0.30000000000000004
大概就说完了。
为什么 0.57 * 100 === 56.99999999999999 而 0.57 * 1000 === 570 ?
【这个例子引用自[2]】
0.57这个数值在存储时, 本身的精度不是很准确, 我们用toPrecision
这个方法可以获取小数的精度。
0.57.toPrecision(55)
// "0.5699999999999999511501869164931122213602066040039062500"
0.57
的实际值是0.56999..
, 那0.57 * 100
也就是0.56999... * 100
, 那结果就是56.99999999999999
啦。
而此时, 路总问了我一个问题, 为什么0.57 * 1000 === 570
而不是 569.99999...
?
不求甚解的我只能先回答,应该是精度丢失
吧.
后来想了下, 其实我们都知道, 计算机的乘法
实际上是累加计算
, 并不是我们想的按位相乘
。
// 伪代码
(0.57) * 100
= (0.57) * (64 + 32 + 4)
= (0.57二进制) * (2^6 + 2^5 + 2^2)
= 0.57二进制 * 2^6 + 0.57二进制 * 2^5 + 0.57 * 2^2
由于精度丢失
, 这个是真的丢失啦, 在二进制转十进制时, 结果就是56.99999…
了 同理, (0.57 * 1000)
也不是简单的乘, 也是累加
起来的, 只是最后精度丢失时,舍0进1了, 所以结果就是570
而已。
toPrecision vs toFixed
数据处理时,这两个函数很容易混淆。
它们的共同点是把数字转成字符串
供展示使用。
注意: 在计算的中间过程不要使用,只用于最终结果。
不同点就需要注意一下:
toPrecision
是处理精度
,精度是从左至右第一个不为0的数开始数起。
toFixed
是小数点后指定位数取整,从小数点开始数起。
两者都能对多余数字做凑整处理
,也有些人用 toFixed 来做四舍五入,但一定要知道它是有 Bug 的。
如:1.005.toFixed(2)
返回的是 1.00
而不是 1.01
。
原因:1.005 实际对应的数字是 1.00499999999999989,在四舍五入时全部被舍去!
解法:使用专业的四舍五入函数 Math.round()
来处理。
但 Math.round(1.005 * 100) / 100
还是不行,因为 1.005 * 100 = 100.49999999999999
。
还需要把乘法和除法精度误差都解决后再使用Math.round
。
可以使用后面介绍的 number-precision#round
方法来解决。
解决方案
对于大部分业务来讲, 确定数字精度后, 使用Math.round
就可以了。
例如本文最初遇到的BUG:0.57 * 100 === 56.99999999999999
const value = Math.round(0.57 * 100);
而我们不太确定精度的浮点数运算时, 通用的解决方案都是:
将小数转化为整数, 进行计算后, 再转化为小数就好了
以下是引用[1]
/**
* 精确加法
*/
function add(num1, num2) {
const num1Digits = (num1.toString().split('.')[1] || '').length;
const num2Digits = (num2.toString().split('.')[1] || '').length;
const baseNum = Math.pow(10, Math.max(num1Digits, num2Digits));
return (num1 * baseNum + num2 * baseNum) / baseNum;
}
以上方法能适用于大部分
场景, 不过也是有些问题,num1 * baseNum 这一步还是会有浮点数问题
, 所以仅供参考
。
遇到科学计数法如 2.3e+1
(当数字精度大于21时,数字会强制转为科学计数法形式显示)时还需要特别处理
一下。
当然已经有成熟的工具库可以使用了, 例如Math.js, BigDecimal.js, number-precision
等等.
能读到这里,说明你非常有耐心,那我就再放个福利
吧。
遇到浮点数误差问题时可以直接使用: https://github.com/dt-fe/number-precision
完美支持浮点数的加减乘除、四舍五入等运算。
而且体积非常小
, 只有1K
,远小于绝大多数同类库,100%测试全覆盖,代码可读性强,不妨在你的应用里用起来!
总结
今天我们说了Javascript 浮点数的问题,也解释了为什么0.1 + 0.2 !== 0.3。
参考
[1]JavaScript 浮点数陷阱及解法(https://github.com/camsong/blog/issues/9)
[2] 0.57 * 100 === 56.99999999999999 之谜