前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >[第34期] 彻底搞懂Javascript 浮点数

[第34期] 彻底搞懂Javascript 浮点数

作者头像
皮小蛋
发布2020-03-02 10:53:00
1.4K0
发布2020-03-02 10:53:00
举报
文章被收录于专栏:前端皮小蛋前端皮小蛋

前言

前段时间, 在群里跟 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

这其中的计算步骤为:

代码语言:javascript
复制
// 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) 来做精度运算,超过的精度会自动做凑整处理

于是就有:

代码语言:javascript
复制
0.10000000000000000555.toPrecision(16)
// 返回 0.1000000000000000,去掉末尾的零后正好为 0.1

// 但你看到的 `0.1` 实际上并不是 `0.1`。不信你可用更高的精度试试:
0.1.toPrecision(21) = 0.100000000000000005551

另外,我们也知道, 整数十进制转二进制时, 是除以二去余数, 这是可以除尽的。

但是, 小数十进制转化为二进制的计算方法是, 小数部分*2, 取整数部分, 直至小数部分为0, 如果永远不为零, 在超过精度时的最后一位时0舍入1。

代码语言:javascript
复制
/* 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
...

到这里, 我们就可以发现一些端倪了

代码语言:javascript
复制
// 使用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这个方法可以获取小数的精度。

代码语言:javascript
复制
0.57.toPrecision(55)
// "0.5699999999999999511501869164931122213602066040039062500"

0.57的实际值是0.56999.., 那0.57 * 100也就是0.56999... * 100, 那结果就是56.99999999999999啦。

而此时, 路总问了我一个问题, 为什么0.57 * 1000 === 570 而不是 569.99999...?

不求甚解的我只能先回答,应该是精度丢失吧.

后来想了下, 其实我们都知道, 计算机的乘法实际上是累加计算, 并不是我们想的按位相乘

代码语言:javascript
复制
// 伪代码
(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

代码语言:javascript
复制
const value = Math.round(0.57 * 100);

而我们不太确定精度的浮点数运算时, 通用的解决方案都是:

将小数转化为整数, 进行计算后, 再转化为小数就好了

以下是引用[1]

代码语言:javascript
复制
/**
 * 精确加法
 */
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 之谜

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2019-12-29,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 前端皮小蛋 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 为什么0.1+0.2=0.30000000000000004?
  • 那为什么 x = 0.1 能得到 0.1?
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档