Blog icon indicating copy to clipboard operation
Blog copied to clipboard

JavaScript 深入之浮点数精度

Open mqyqingfeng opened this issue 4 years ago • 25 comments

前言

0.1 + 0.2 是否等于 0.3 作为一道经典的面试题,已经广外熟知,说起原因,大家能回答出这是浮点数精度问题导致,也能辩证的看待这并非是 ECMAScript 这门语言的问题,今天就是具体看一下背后的原因。

数字类型

ECMAScript 中的 Number 类型使用 IEEE754 标准来表示整数和浮点数值。所谓 IEEE754 标准,全称 IEEE 二进制浮点数算术标准,这个标准定义了表示浮点数的格式等内容。

在 IEEE754 中,规定了四种表示浮点数值的方式:单精确度(32位)、双精确度(64位)、延伸单精确度、与延伸双精确度。像 ECMAScript 采用的就是双精确度,也就是说,会用 64 位来储存一个浮点数。

浮点数转二进制

我们来看下 1020 用十进制的表示:

1020 = 1 * 10^3 + 0 * 10^2 + 2 * 10^1 + 0 * 10^0

所以 1020 用十进制表示就是 1020……(哈哈)

如果 1020 用二进制来表示呢?

1020 = 1 * 2^9 + 1 * 2^8 + 1 * 2^7 + 1 * 2^6 + 1 * 2^5 + 1 * 2^4 + 1 * 2^3 + 1 * 2^2 + 0 * 2^1 + 0 * 2^0

所以 1020 的二进制为 1111111100

那如果是 0.75 用二进制表示呢?同理应该是:

0.75 = a * 2^-1 + b * 2^-2 + c * 2^-3 + d * 2^-4 + ...

因为使用的是二进制,这里的 abcd……的值的要么是 0 要么是 1。

那怎么算出 abcd…… 的值呢,我们可以两边不停的乘以 2 算出来,解法如下:

0.75 = a * 2^-1 + b * 2^-2 + c * 2^-3 + d * 2^-4...

两边同时乘以 2

1 + 0.5 = a * 2^0 + b * 2^-1 + c * 2^-2 + d * 2^-3... (所以 a = 1)

剩下的:

0.5 = b * 2^-1 + c * 2^-2 + d * 2^-3...

再同时乘以 2

1 + 0 = b * 2^0 + c * 2^-2 + d * 2^-3... (所以 b = 1)

所以 0.75 用二进制表示就是 0.ab,也就是 0.11

然而不是所有的数都像 0.75 这么好算,我们来算下 0.1:

0.1 = a * 2^-1 + b * 2^-2 + c * 2^-3 + d * 2^-4 + ...

0 + 0.2 = a * 2^0 + b * 2^-1 + c * 2^-2 + ...   (a = 0)
0 + 0.4 = b * 2^0 + c * 2^-1 + d * 2^-2 + ...   (b = 0)
0 + 0.8 = c * 2^0 + d * 2^-1 + e * 2^-2 + ...   (c = 0)
1 + 0.6 = d * 2^0 + e * 2^-1 + f * 2^-2 + ...   (d = 1)
1 + 0.2 = e * 2^0 + f * 2^-1 + g * 2^-2 + ...   (e = 1)
0 + 0.4 = f * 2^0 + g * 2^-1 + h * 2^-2 + ...   (f = 0)
0 + 0.8 = g * 2^0 + h * 2^-1 + i * 2^-2 + ...   (g = 0)
1 + 0.6 = h * 2^0 + i * 2^-1 + j * 2^-2 + ...   (h = 1)
....

然后你就会发现,这个计算在不停的循环,所以 0.1 用二进制表示就是 0.00011001100110011……

浮点数的存储

虽然 0.1 转成二进制时是一个无限循环的数,但计算机总要储存吧,我们知道 ECMAScript 使用 64 位来储存一个浮点数,那具体是怎么储存的呢?这就要说回 IEEE754 这个标准了,毕竟是这个标准规定了存储的方式。

这个标准认为,一个浮点数 (Value) 可以这样表示:

Value = sign * exponent * fraction

看起来很抽象的样子,简单理解就是科学计数法……

比如 -1020,用科学计数法表示就是:

-1 * 10^3 * 1.02

sign 就是 -1,exponent 就是 10^3,fraction 就是 1.02

对于二进制也是一样,以 0.1 的二进制 0.00011001100110011…… 这个数来说:

可以表示为:

1 * 2^-4 * 1.1001100110011……

其中 sign 就是 1,exponent 就是 2^-4,fraction 就是 1.1001100110011……

而当只做二进制科学计数法的表示时,这个 Value 的表示可以再具体一点变成:

V = (-1)^S * (1 + Fraction) * 2^E

(如果所有的浮点数都可以这样表示,那么我们存储的时候就把这其中会变化的一些值存储起来就好了)

我们来一点点看:

(-1)^S 表示符号位,当 S = 0,V 为正数;当 S = 1,V 为负数。

再看 (1 + Fraction),这是因为所有的浮点数都可以表示为 1.xxxx * 2^xxx 的形式,前面的一定是 1.xxx,那干脆我们就不存储这个 1 了,直接存后面的 xxxxx 好了,这也就是 Fraction 的部分。

最后再看 2^E

如果是 1020.75,对应二进制数就是 1111111100.11,对应二进制科学计数法就是 1 * 1.11111110011 * 2^9,E 的值就是 9,而如果是 0.1 ,对应二进制是 1 * 1.1001100110011…… * 2^-4, E 的值就是 -4,也就是说,E 既可能是负数,又可能是正数,那问题就来了,那我们该怎么储存这个 E 呢?

我们这样解决,假如我们用 8 位来存储 E 这个数,如果只有正数的话,储存的值的范围是 0 ~ 254,而如果要储存正负数的话,值的范围就是 -127~127,我们在存储的时候,把要存储的数字加上 127,这样当我们存 -127 的时候,我们存 0,当存 127 的时候,存 254,这样就解决了存负数的问题。对应的,当取值的时候,我们再减去 127。

所以呢,真到实际存储的时候,我们并不会直接存储 E,而是会存储 E + bias,当用 8 位的时候,这个 bias 就是 127。

所以,如果要存储一个浮点数,我们存 S 和 Fraction 和 E + bias 这三个值就好了,那具体要分配多少个位来存储这些数呢?IEEE754 给出了标准:

IEEE754

在这个标准下:

我们会用 1 位存储 S,0 表示正数,1 表示负数。

用 11 位存储 E + bias,对于 11 位来说,bias 的值是 2^(11-1) - 1,也就是 1023。

用 52 位存储 Fraction。

举个例子,就拿 0.1 来看,对应二进制是 1 * 1.1001100110011…… * 2^-4, Sign 是 0,E + bias 是 -4 + 1023 = 1019,1019 用二进制表示是 1111111011,Fraction 是 1001100110011……

对应 64 位的完整表示就是:

0 01111111011 1001100110011001100110011001100110011001100110011010

同理, 0.2 表示的完整表示是:

0 01111111100 1001100110011001100110011001100110011001100110011010

所以当 0.1 存下来的时候,就已经发生了精度丢失,当我们用浮点数进行运算的时候,使用的其实是精度丢失后的数。

浮点数的运算

关于浮点数的运算,一般由以下五个步骤完成:对阶、尾数运算、规格化、舍入处理、溢出判断。我们来简单看一下 0.1 和 0.2 的计算。

首先是对阶,所谓对阶,就是把阶码调整为相同,比如 0.1 是 1.1001100110011…… * 2^-4,阶码是 -4,而 0.2 就是 1.10011001100110...* 2^-3,阶码是 -3,两个阶码不同,所以先调整为相同的阶码再进行计算,调整原则是小阶对大阶,也就是 0.1 的 -4 调整为 -3,对应变成 0.11001100110011…… * 2^-3

接下来是尾数计算:

  0.1100110011001100110011001100110011001100110011001101
+ 1.1001100110011001100110011001100110011001100110011010
————————————————————————————————————————————————————————
 10.0110011001100110011001100110011001100110011001100111

我们得到结果为 10.0110011001100110011001100110011001100110011001100111 * 2^-3

将这个结果处理一下,即结果规格化,变成 1.0011001100110011001100110011001100110011001100110011(1) * 2^-2

括号里的 1 意思是说计算后这个 1 超出了范围,所以要被舍弃了。

再然后是舍入,四舍五入对应到二进制中,就是 0 舍 1 入,因为我们要把括号里的 1 丢了,所以这里会进一,结果变成

1.0011001100110011001100110011001100110011001100110100 * 2^-2

本来还有一个溢出判断,因为这里不涉及,就不讲了。

所以最终的结果存成 64 位就是

0 01111111101 0011001100110011001100110011001100110011001100110100

将它转换为10进制数就得到 0.30000000000000004440892098500626

因为两次存储时的精度丢失加上一次运算时的精度丢失,最终导致了 0.1 + 0.2 !== 0.3

其他

// 十进制转二进制
parseFloat(0.1).toString(2);
=> "0.0001100110011001100110011001100110011001100110011001101"

// 二进制转十进制
parseInt(1100100,2)
=> 100

// 以指定的精度返回该数值对象的字符串表示
(0.1 + 0.2).toPrecision(21)
=> "0.300000000000000044409"
(0.3).toPrecision(21)
=> "0.299999999999999988898"

参考

  1. why is 0.1+0.2 not equal to 0.3 in most programming languages
  2. IEEE-754标准与浮点数运算

深入系列

JavaScript深入系列目录地址:https://github.com/mqyqingfeng/Blog

JavaScript深入系列预计写十五篇左右,旨在帮大家捋顺JavaScript底层知识,重点讲解如原型、作用域、执行上下文、变量对象、this、闭包、按值传递、call、apply、bind、new、继承等难点概念。

如果有错误或者不严谨的地方,请务必给予指正,十分感谢。如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

mqyqingfeng avatar Mar 16 '20 02:03 mqyqingfeng

大神好久没更新了 先占楼慢慢看~

cbxcbx avatar Mar 16 '20 12:03 cbxcbx

看到更新了,很惊喜~

KidUncle avatar Mar 17 '20 12:03 KidUncle

大佬终于更新了 深入系列又刷一遍 写的真是太好了

abstain23 avatar Mar 18 '20 08:03 abstain23

哇,大佬又开始更新了,沙发!!!

hanqizheng avatar Mar 19 '20 07:03 hanqizheng

1.tcp三次握手的原理和过程,tcp与udp有什么区别 2.http和https有什么区别,加密方式是什么,传输原理是什么 3.防抖和节流有什么用,一般的使用场景,原理是什么 4.手写判断一个字符串是不是回文字符串,如果能使用js中的方法,你会使用哪一个方法 5.跨域是什么?怎么解决跨域? 6.状态码都有哪些?304是指什么意思 7.浏览器的缓存机制是什么,怎么实现缓存,怎么想让特定文件进行缓存 8.函数和对象的区别是什么 9.redux是什么,为什么要使用redux,工作原理是什么 10.js中的事件循环是什么原理

suwu150 avatar Mar 26 '20 01:03 suwu150

这两天刚刚遇到了这个问题,这波跟新来的太及时了

A-cabbage avatar Mar 30 '20 00:03 A-cabbage

JavaScript深入系列目录地址:https://github.com/mqyqingfeng/Blog。 👈这里多了个句号 点进去是 404

hubingliang avatar Apr 16 '20 09:04 hubingliang

突然晕了,既然0.1在存储的时候就已经精度丢失了,那么1 - 0.1 == 0.9是怎么计算的

Iurmy avatar May 29 '20 10:05 Iurmy

image

lishihong avatar Jun 04 '20 09:06 lishihong

用 52 位存储 Fraction,那Fraction最大能存储多大的数字呢?

xsfxtsxxr avatar Jun 23 '20 09:06 xsfxtsxxr

突然晕了,既然0.1在存储的时候就已经精度丢失了,那么1 - 0.1 == 0.9是怎么计算的

可能是0.1精度丢失舍入处理向上进位了,0.2也是向上进位的,所以加起来比0.3大

JiaJiaJiayi avatar Jun 28 '20 22:06 JiaJiaJiayi

64位跟64位字节好像有歧义


冴羽回复:感谢你的指正,犯了很低级的错误,64位是 8 字节,目前已经更正了

xueyida avatar Jul 21 '20 00:07 xueyida

我们这样解决,假如我们用 8 位字节来存储 E 这个数,如果只有正数的话,储存的值的范围是 0 ~ 254,

这里没有理解,为什么是0~254呢,2^8的256,去掉一个0,是255.

2位的话,2^2 = 4, 二进制分别是,00,01,10,11,表达范围是,0,1,2,3 也就是 闭区间[0,3]

这里是遗漏了什么细节么?麻烦各位大佬解答下。

我这算了下,不知道公式对不对。 n位的无符号二进制表达范围公式是不是 [0,(2^n)-1] ? n位的有符号二进制表达范围公式是不是 [-{(2^n)-2}/2,{(2^n)-2}/2]? 因为有-0和+0所以要在个数上-2.组成原理都快忘完了。

CapDuan avatar Dec 09 '20 17:12 CapDuan

这个文章解释的不错的 https://www.cnblogs.com/zhangycun/p/7880580.html

Alfxjx avatar Feb 26 '21 05:02 Alfxjx

64位跟64位字节好像有歧义

确实,位是bit,字节是byte,1byte = 8bit

CristoMonte avatar Apr 09 '21 09:04 CristoMonte

强啊,思路很清晰,正好前几天也在研究 0.1 + 0.2,正好帮大神补充一下,关于 JS 的 double 我看了 v8 相关源码:

  • 原来在 V8 里 double 存储是用十进制存储的,比如0.1,依次读入字符串,然后除以 10,0 * 0 + 0.1 * 1
  • V8 直接 把 char cast 为 double(都是 c++ 的数据类型)

pfan8 avatar Apr 14 '21 06:04 pfan8

突然晕了,既然0.1在存储的时候就已经精度丢失了,那么1 - 0.1 == 0.9是怎么计算的 尝试解释一下:

1 表示成科学计数法: 1 * 2^0 * 1 0.1 为1 * 2^-4 * 1.10011001100110011001100... 两者相减时,先进行对阶,1变成1 * 2^-4 * 10000,然后执行相减操作

  10000.000000000000000000000000000000
-     1.100110011001100110011001100110
————————————————————————————————————————————————————
   1110.011001100110011...                                                                 

即结果是 0.11100110011001100110011... 将0.9转换成2进制为0.11100110011001100110011001100110011001100110011001101,两者恰好是相等的,所以1-0.1==0.9

Hquestion avatar Apr 15 '21 07:04 Hquestion

突然晕了,既然0.1在存储的时候就已经精度丢失了,那么1 - 0.1 == 0.9是怎么计算的

我也有点晕,既然0.1在存储的时候就已经精度丢失了,那为么为什么字面量的0.1 在控制台打印出来还是0.1

hanzc0106 avatar Apr 23 '21 11:04 hanzc0106

我们这样解决,假如我们用 8 位字节来存储 E 这个数,如果只有正数的话,储存的值的范围是 0 ~ 254,

这里没有理解,为什么是0~254呢,2^8的256,去掉一个0,是255.

2位的话,2^2 = 4, 二进制分别是,00,01,10,11,表达范围是,0,1,2,3 也就是 闭区间[0,3]

这里是遗漏了什么细节么?麻烦各位大佬解答下。

我这算了下,不知道公式对不对。 n位的无符号二进制表达范围公式是不是 [0,(2^n)-1] ? n位的有符号二进制表达范围公式是不是 [-{(2^n)-2}/2,{(2^n)-2}/2]? 因为有-0和+0所以要在个数上-2.组成原理都快忘完了。

我的理解是 2进制的八位字节 也就是11111111 你转换为 十进制 看是不是 255

chengazhen avatar Apr 24 '21 08:04 chengazhen

八位字节 无符号的情况下 不应该是0~255 吗 请大佬解答一下

chengazhen avatar Apr 24 '21 08:04 chengazhen

提一下十一位的指数位,虽然十一位表示的范围是 0 到 2047,实际上 0 和 2047 会被作为特殊的数解析(0,NaN,Inifinity),因此实际用来表示正常数的范围是 1-2046,算上基数,对应的就是 -1022 ~ 1023,所以 Number.MAX_VALUE 的计算方法是 1.11111(二进制数,小数点后 52 个1) * (2 ** 1023),最后是 1023 而不是 1024 次方。参考链接

ominus3 avatar May 26 '21 15:05 ominus3

八位字节 无符号的情况下 不应该是0~255 吗 请大佬解答一下

有规定的,浮点数E全1和全0有特殊意义,不能取全1,不久最大只能是11111110吗?

kay2890706289 avatar Jul 07 '21 07:07 kay2890706289

@ominus3 非常感谢你的回复和补充

mqyqingfeng avatar Nov 29 '21 02:11 mqyqingfeng

我们这样解决,假如我们用 8 位字节来存储 E 这个数,如果只有正数的话,储存的值的范围是 0 ~ 254,

这里没有理解,为什么是0~254呢,2^8的256,去掉一个0,是255.

2位的话,2^2 = 4, 二进制分别是,00,01,10,11,表达范围是,0,1,2,3 也就是 闭区间[0,3]

这里是遗漏了什么细节么?麻烦各位大佬解答下。

我这算了下,不知道公式对不对。 n位的无符号二进制表达范围公式是不是 [0,(2^n)-1] ? n位的有符号二进制表达范围公式是不是 [-{(2^n)-2}/2,{(2^n)-2}/2]? 因为有-0和+0所以要在个数上-2.组成原理都快忘完了。

以下解释来自百度百科“规格化浮点数

特殊情形 当阶码E为全0且尾数也为全0时,表示的真值x为零,结合符号位S为0或1,有正零和负零之分。当阶码E为全1且尾数M为全0时,表示的真值x为无穷大,结合符号位S为0或1,也有+∞和-∞之分。这样在32位浮点数表示中,要除去E用全0和全1(十进制的255)表示零和无穷大的特殊情况,指数的偏移值不选128(10000000),而选127(01111111)。对于规格化浮点数,E的范围变为1到254,真正的指数值e则为-126到+127.因此32位浮点数表示的绝对值的范围是10ˉ³⁸~10³⁸(以10的幂表示)。

RichardPear avatar Feb 16 '22 02:02 RichardPear

0.1 image

xiaomoumou avatar Apr 28 '23 02:04 xiaomoumou