专栏首页腾讯IVWEB团队的专栏0.57 * 100 === 56.99999999999999 之谜

0.57 * 100 === 56.99999999999999 之谜

为什么 0.1 + 0.2 === 0.30000000000000004, 0.3 - 0.2 === 0.09999999999999998 ?前言

在最近业务开发中, 作者偶遇到了一个与 JavaScript 浮点数相关的 Bug。

这里就简单描述下背景: 在提现相关业务时, 会将展示给用户以元为单位的数值转化为以分为单位的数值。 例如, 0.57元 转化为 57 分

转化方法很简单

// 小程序代码 onInput: 监听Input事件
onInput(e) {
    let value = e.target.value;
    //限制除数字和小数点以外的字符输入
    if (!/^\d*\.{0,2}\d{0,2}$/.test(value)) {
        value = value
            .replace(/[^\d.]/g, '')
            .replace(/^\./g, '')
            .replace(/\.{2,}/g, '.')
            // 保留数字小数点后两位
            .replace(/^(.*\..{2}).*$/, '$1');
    }
    //...
    this.setData({
        cash: +value * 100  // 乘100, 将元转化为分
    })
}

这段看似没有问题的代码, 提交给后台时, 接口却返回参数值格式不正确。

最初, 怀疑是正则表达式有疏漏, 但测试了一下没有问题, 然后就尝试了用户输入的数值 0.57, 却发现计算值却出人意料, 也就是题目中的 0.57 * 100 === 56.99999999999999

前端开发同学或多或少都应该看到过0.1 + 0.2 === 0.30000000000000004这个经典问题。 作者当初也抱着好奇的态度看了相关文章, 说来惭愧, 想到自己无论如何也不会开发0.1 + 0.2的业务, 也只是了解到了为什么会是这样的结果就浅尝辄止了。

如今踩了坑, 只能说是自己跳进了当年挖的坑, 那今天就将这个坑填上。

本文文章会讲述以下几个问题, 已经熟悉同学就可以不用看啦。

  1. 为什么 0.1 + 0.2 === 0.30000000000000004
  2. 为什么 0.57 * 100 === 56.99999999999999
  3. 为什么 0.57 * 1000 === 570

Why 0.1 + 0.2 === 0.30000000000000004 ?

要解答这个问题始终绕不过JavaScript中最基础也是最核心的浮点数的格式存储。 在JS中, 无论整数还是小数都是Number类型, 它的实现遵循IEEE 754, 是标准的Double双精度浮点数, 使用固定的64位来表示。

看到这里你可能就不想看下去了。好好好, 那就后面再说, 这里就用大白话简单讲解, 详细内容在文章后面阅读。

实际上, JS中的数字都会转化为二进制存储下来, 由于数字存储限定了64位, 但现实世界中, 数字是无穷的, 所以一定会有数字超出这个存储范围。超出这个范围的数字在存储时就会丢失精度。

同时, 我们都知道, 整数十进制转二进制时, 是除以二去余数, 这是可以除尽的! 但我们可能不知道的是, 小数十进制转化为二进制的计算方法是, 小数部分*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;

// 计算从小数点后开始, 感谢bethon纠正
s.split('.')[1].split('').forEach((i, index) => { a += (+i/Math.pow(2, index+1))});
// a >> 0.30000000000000004

到这里, 0.1 + 0.2 === 0.30000000000000004

以上论述过程仍有一些疑惑之处

  1. 为什么小数转化为二进制后, 52位以后就超过精度了?

这些都与64位双精度浮点数是如何存储的有关, 我们放到最后再说。

Why 0.57 * 100 === 56.99999999999999 ?

Why 0.57 * 1000 === 570 ?

阅读完上面一节, 对小数的乘法我们也可以有一些自己的猜测了。

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而已。

解决问题

对于大部分业务来讲, 确定数字精度后, 使用Math.round就可以了。

例如本文最初遇到的BUG

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;
}

当然已经有成熟的工具库可以使用了, 例如Math.js, BigDecimal.js, number-precision等等, 使用哪个任君挑选

IEEE754标准下的浮点数存储

其实下面这段内容来自于Wiki

64位如图进行划分

64位Double浮点数存储格式

第0位: 是符号的标志位

第1-11位: 指数位

第12-63位: 尾数

计算公式

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了。

Double精度的浮点数存储大概就是这个样子了, 这也解答了上述的疑惑。

以上就是本文的全部内容了。

题外: 好读书,不求甚解;每有会意,便欣然忘食

参考

[1]JavaScript 浮点数陷阱及解法

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

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 前端弹幕实现

    目前视频播放平台弹幕几乎都是使用js操作dom的方式实现,由于篇幅的原因这次只展示js操作dom的实现方案。

    腾讯IVWEB团队
  • 2019TLC大会精彩回顾—大前端·信息流

    2019年8月17日,第三届TLC大会在科兴科学园国际会议中心完美落下帷幕。作为一年一度的技术盛会,本次大会云集国内外19名技术专家,现场参会人数超过600人,...

    腾讯IVWEB团队
  • 重构代码的Tricks

    js的设计模式是针对于整体代码的设计是否合理,给出了一些具体的解决办法。 而重构代码就是依赖于设计模式而实现的一个必要手段,可以说设计模式就是重构代码的目标,但...

    腾讯IVWEB团队
  • 一个函数让你看懂 'Why 0.1+0.2!=0.3'

    由于 JavaScript中没有将小数的 二进制转换成 十进制的方法,于是手动实现了一个。

    ConardLi
  • 大数据对信息安全:“惹祸”同时有巨额价值

      如果担心被发现,最好不要去做这件事。”谷歌公司高管这样回答关于防止隐私泄露的询问。   在《第一财经日报》记者参加的2014年中国计算机大会(CNCC...

    腾讯研究院
  • DevOps Workshop | 代码管理入门:基于代码扫描实现团队效率提升

    在这里,你可以轻松实践 DevOps 全流程、体验高效的云端开发、赢取精美礼品——第二期大奖「戴尔 U2718Q 显示器」将于 12 月 3 日开奖,请尽快前往...

    CODING
  • SQL Server 多表数据增量获取和发布 1

    子公司统一门户系统已完成开发,安全运行一年。接到通知,总部也开发了一套统一门户,要求各子公司使用总部开发的平台,子公司领导讨论决定使用总公司开发的平台,但是也不...

    小狐狸
  • HTML5+CSS3高级动画的应用实践

    这个动画实现所用到的3D盒子模型是现在3D模型中最常用的一个 —— 不过我们先拿其中两个面分析:

    winty
  • 精确到地级市的疫情图,数据准确,实时掌控疫情发展

    近日,国内有开发者根据腾讯的数据自动生成新冠肺炎地级市疫情图,其十分钟自动更新一次,并且部署到国内服务器以加快网页访问速度。

    机器之心
  • Lodop打印样式问题与解决方法 原

    1、<h3 class="printTitle">工商客户抄表收费通知单</h3> 标题,在打印预览时最后一个字会变小

    tianyawhl

扫码关注云+社区

领取腾讯云代金券