Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

JavaScript 深入之浮点数精度 #155

Open
mqyqingfeng opened this issue Mar 16, 2020 · 25 comments
Open

JavaScript 深入之浮点数精度 #155

mqyqingfeng opened this issue Mar 16, 2020 · 25 comments

Comments

@mqyqingfeng
Copy link
Owner

mqyqingfeng commented Mar 16, 2020

前言

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 mqyqingfeng changed the title JavaScript 深入系列之浮点数精度 JavaScript 深入之浮点数精度 Mar 16, 2020
@cbxcbx
Copy link

cbxcbx commented Mar 16, 2020

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

@KidUncle
Copy link

看到更新了,很惊喜~

@abstain23
Copy link

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

@hanqizheng
Copy link

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

@suwu150
Copy link

suwu150 commented Mar 26, 2020

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

@A-cabbage
Copy link

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

@hubingliang
Copy link

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

@Iurmy
Copy link

Iurmy commented May 29, 2020

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

@lishihong
Copy link

image

@xsfxtsxxr
Copy link

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

@JiaJiaJiayi
Copy link

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

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

@xueyida
Copy link

xueyida commented Jul 21, 2020

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


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

@CapDuan
Copy link

CapDuan commented Dec 9, 2020

我们这样解决,假如我们用 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.组成原理都快忘完了。

@Alfxjx
Copy link

Alfxjx commented Feb 26, 2021

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

@CristoMonte
Copy link

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

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

@pfan8
Copy link

pfan8 commented Apr 14, 2021

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

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

@Hquestion
Copy link

Hquestion commented Apr 15, 2021

突然晕了,既然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

@hanzc0106
Copy link

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

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

@chengazhen
Copy link

我们这样解决,假如我们用 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
Copy link

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

@ominus3
Copy link

ominus3 commented May 26, 2021

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

@kay2890706289
Copy link

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

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

@mqyqingfeng
Copy link
Owner Author

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

@RichardPear
Copy link

我们这样解决,假如我们用 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的幂表示)。

@xiaomoumou
Copy link

0.1
image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests