0.57 * 100 === 56.99999999999999 之谜

avatar
@腾讯科技(深圳)有限公司

导语 为什么 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;
s.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 浮点数陷阱及解法


如果觉得文章对您有帮助,那就关注一下【IVWEB社区】公众号吧~