专栏首页前端e进阶[第34期] 彻底搞懂Javascript 浮点数

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

前言

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

本文分享自微信公众号 - 前端e进阶(gh_ffbd834383c0),作者:南山皮小蛋

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2019-12-29

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • [第20期] 全面了解 ES6 Modules

    当下, 模块化已经深入到我们日常开发中。即:把一个大的 Javascript 程序分割成不同的部分, 哪个部分要被用到,就取那一部分, 按需取用。

    用户6900878
  • [第36期]手把手教你写几个实用的AST插件

    代码地址:https://github.com/reactjs/react-codemod

    用户6900878
  • [第15期] 2019前端面试不完全指南

    2019年情况又有所不同, 我就结合去年的一些经验和今年观察到的一些情况再总结一篇, 在这里分享给大家,有需要面试的朋友可以参考下。

    用户6900878
  • c语言基础学习10_关于文件操作的复习

    ============================================================================= 如果...

    黑泽君
  • 让你的Python程序在用户面前以小概率崩溃

    有些软件在大部分情况下都能正常工作,而有时候则会莫名其妙的崩溃。当然这有可能是因为代码没有写好或没有考虑一些特殊情况,也有可能是系统本身就是这么设计的,目的是要...

    Python小屋屋主
  • 戴口罩人脸识别,是不是伪命题

    昨天,雷锋网AI掘金志其中的一个安防社群因为一个话题引发了不小的争论:“AI产品能否高效地实时识别出戴口罩的人是谁?”

    AI掘金志
  • 机器学习人工学weekly-2018/8/12

    链接:https://distill.pub/2018/differentiable-parameterizations/#section-aligned-in...

    windmaple
  • 微信小程序调用json数据接口并解析

    开始写js,用request请求接口url,当请求成功的时候,在控制台打印一下返回的res.data数据,在控制台可以看到打印了接口数据了,在请求接口成功之后,...

    祈澈菇凉
  • .Net Core 权限验证与授权(AuthorizeFilter、ActionFilterAttribute)

    在.Net Core 中使用AuthorizeFilter或者ActionFilterAttribute来实现登录权限验证和授权

    小世界的野孩子
  • 11月最佳机器学习开源项目Top10!

    过去一个月,我们从近 250 个机器学习开源项目中挑选出了最受大家关注的前十名。这些项目在 GitHub 上平均 Stars 数为 2713。这些项目涉及由 G...

    磐创AI

扫码关注云+社区

领取腾讯云代金券