谁改了我的小数? 0.10000000000000008 === 0.10000000000000007?

185 阅读4分钟

今天遇到一个问题,前端请求后台保存了一个小数1.2,然后前端后续读取的时候,返回了1.200000000(n个0)...43(一些数字)。

哪呢,难道是跟0.1 + 0.2 !== 0.3一个道理?

经过我的实验,发现跟0.1 + 0.2 !== 0.3不是同一个问题。实验最后的结论是:

  1. 位数<=16位的小数的读写,无需考虑精度丢失问题
  2. 违反直觉的是,代码写的小数,与打印来的小数不一定相同。JavaScript/Python等语言会在第17小数四舍五入。

下面是我的分析过程:

问题提出:既然不能精确地存储小数,又为什么能够正确地打印?

后台同学表示,查询数据库显示的确实是1.2,但是实际上你知道的,小数无法在计算机中被精确存储,所以你将后台返回的小数round一下就好。

我觉得这样不妥,因为1.2是用户手工输入的数字,后台居然没有办法保证原样返回?而且连1位小数都无法精确存储。另外只是简单读写,又不是0.1 + 0.2之类的运算。自己之前也做过java full stack,之前怎么就没遇到过这种奇怪的问题呢。难道需要数据库必须要使用类似MySQL Decimal的类型吗?

我们写代码 a = 0.1, 是可以预期打印出来是0.1的,同样的道理,我把一个1.2小数存储进去数据库,虽然存进去的可能是1.1999999999999999555910790149937383830547332763671875,但是当数据库再次将小数读出来,然后序列化成可读字符之后,应该还是1.2。

这里有两个问题需要研究。

  1. 计算机虽然无法精确存储0.1,但是为什么能正确打印出0.1,而不是0.1000000000000000055511151231257827021181583404541015625?
  2. 有没有可能存在一个小数,代码写的是m, 打印出来却是n?

实验验证

这里,我通过chrome console是研究这两个问题。 首先,javascript有个函数是toPrecision,可以查看小数的精度。 例如

image.png

然后,我尝试找出一个小数,打印结果与代码不一样。结果,还真的找到了,但是小数数位比较多的情况才能满足这个情况。例如:

image.png

这里就有一个结论,计算机除了不能精确地存储小数,还是不一定能按照代码正确地打印小数。我们平时写代码console.log(0.1),预期能打印0.1,人觉得正常,但是从计算机角度,反而可能是一种特例。

我接着研究,为什么打印结果比代码输入的数字小 image.png

对齐下数字,很明显,就是四舍五入的结果

image.png 拿更多的数字来研究,我发现一个大致的规律:

1. 如果第17位数字 <=4,那么会舍弃,0.1, 0.2,0.10000000000000001 就是这种情况。

2. 如果第17位数字 >=5, 那么会保留,并且会从18位数字进位。

image.png

这里我想到一个问题,是不是小数数字位数 >= 17,才可能会代码输入的小数和打印的结果不一致的情况呢?

这里我利用穷举的方式去证明。 注意,不能使用+0.000000000...0000001的方式去循环穷举,因为精度问题会跳过一些数字,所以我使用了字符串方式去穷举。

另外由于javascript可能会使用科学记数法去打印小数,所以我使用0.1前缀作为穷举小数的前缀。 image.png

Run以下代码,

// find all value that's 'not equal to itself'.
// 0.10000000000000008.toString() === '0.10000000000000007' is true!

const n = 17;

const numbers = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];

function solution() {
  const result = [];
  backtrack([]);
  console.log(result)
  function backtrack(track) {
    // The prefix part of the number is always 0.1, so we only traverse n-1
    if (track.length === n-1) {
      const track2 = track.slice(0);
      while(track2[track2.length - 1] === '0'){
        track2.splice(track2.length - 1, 1);
      }
      track2.unshift('0.1')
      const code = `if ("${track2.join('')}" !== (${track2.join('')}).toString()) {
        console.log("${track2.join('')}",  "!==",  (${track2.join('')}).toString());
      }`;
      // console.log(code);
      eval(code);
      return;
    }
    for (let i=0; i<numbers.length; i++) {
      track.push(numbers[i]);
      backtrack(track);
      track.pop();
    }
  }
}

solution();

17位小数,代码输入的小数和打印结果不一致的,非常非常多。

image.png

但是我们将小数位数 n 改成16,发现就没有输出结果了。

所以只有小数数字位数 >= 17,才可能会出现,代码输入的小数和打印的结果不一致的情况

其实,在其他语言,应该也是类似的实现,例如我把

 0.10000000000000008

拷贝到python REPL,也是跟javascript一样的结果

image.png

所以,我有理由相信,是后台处理出现的问题,因为用户输入的数字仅仅是1位小数而已,远远没有达到17位小数。

最后,后台发现是C++代码出现了问题。后台使用C++ double类型写入数据库,但是却使用float类型读取数据库。

结论

  1. 位数<=16位的小数的读写,无需考虑精度丢失问题
  2. 违反直觉的是,代码写的小数,与打印来的结果不一定相同。JavaScript/Python等语言会在第17小数四舍五入。