从javascript的0.1 + 0.2说起

众所周知,在Javascript中,0.1 + 0.2 不等于0.3,但是如果让你把这个问题解释清楚,或者再举出其他类似的例子时,可能只能缓缓打出三个字:

在入门经典书籍红宝书中,在介绍基础类型Number类型 时,是这样对这个问题作出解释的:

浮点值的精确度最高可达 17 位小数,但在算术计算中远不如整数精确。例如,0.1 加 0.2 得到的不是 0.3,而是 0.300 000 000 000 000 04。由于这种微小的舍入错误,导致很难测试特定的浮点值。

之所以存在这种舍入错误,是因为使用了IEEE754数值,这种错误并非ECMAScript 所独有。其他使用相同格式的语言也有这个问题。

上面的红宝书引用里,提到了一个关键词:IEEE754。那么什么是IEEE754?JavaScript又是如何处理浮点数据的呢?

IEEE754

扔上维基百科的链接,自行了解一下。

这里用一句话概述,IEEE754是一种二进制浮点数算术标准。它规定了四种表示浮点数值的方式:单精确度(32位)、双精确度(64位)、延伸单精确度(43比特以上,很少使用)与延伸双精确度(79比特以上,通常以80位实现)。

这里出现了我们熟悉的关键词:单精度双精度。没学过Java的都知道,在Java里用float类型定义单精度浮点数,用double类型定义双精度浮点数。

Javascript的Number类型,使用的就是IEEE754标准中的64位的双精度浮点数

浮点数的二进制转换

我们在计算数学问题的时候,使用的是十进制,计算0.1 + 0.2的结果等于0.3,没有任何问题。但在计算机中,存储数据使用的是二进制,数据由0和1组成。所以在对数据进行计算时,需要将数据全部转换成二进制,再进行数据计算。

十进制转二进制

  • 十进制整数转换为二进制整数

    • 除2取余,逆序排列
  • 十进制小数转换为二进制小数

    • 乘2取整,顺序排列

这里拿十进制的浮点数78.375转换成二进制举例:

78.375的整数部分计算:


小数部分计算:


所以,78.375的二进制形式就是1001110.011

然后,使用二进制科学记数法,可以得到:

注意,转换后用二进制科学记数法表示的这个数,有底、有指数、有小数部分,这个就称之为浮点数

浮点数在计算机中的存储

还是使用上面的78.375的二进制换算浮点数 1.001110011×2^6 来举例:

在计算机中,保存这个数使用的是双精度浮点表示法,分为三大部分:

第一部分用来存储符号位(sign),占用1位,用来区分正负数这里是0,表示正数。

第二部分用来存储指数(exponent),占用11位,用来表示指数,这里的指数是十进制的6。

第三部分用来存储小数(mantissa),占用52位,用来表示小数。这里的小数部分是001110011。

如下图所示:

可以看出: 指数位决定了大小范围小数位决定了计算精度

有两个点需要注意:

  • IEEE 754标准规定,在保存小数mantissa时,第一位默认是1,因此可以被舍去,只存储后边的部分。例如,1.01001保存的时候,只保存01001,等到用的时候再把1加上去。这样,就可以节省一个位的有效数字。

  • 指数E在存储的时候也有些特殊。为64位浮点数时,指数占11位,范围为0-2047 。但是,指数是有正有负的,因此实际值需要在此基础上减去一个中间数。对于64位,中间数为1023 。

故78.375最后保存在计算机里,成为了以下形式(可以使用这个网站来验证一下计算结果):

符号位: 0

指数位: 6+1023 = 1029,二进制表示为:10000000101

小数位:1.001110011 ,舍弃第一位的1,不足补0,表示为:0011100110000000000000000000000000000000000000000000

0  10000000101  0011100110000000000000000000000000000000000000000000
S    E指数             M尾数

[图片上传失败...(image-ab2cc6-1639234718723)]

0.1 + 0.2时,到底发生了什么?

在了解了计算机对浮点数的转换及存储的基础之后,我们再来看0.1 + 0.2 这个问题。

首先我们将十进制数0.1换算成二进制:

十进制0.1转为二进制为0.0001100110011(0011循环),即 1.100110011(0011)2^-4*。

符号位: 0

指数位: -4,实际存储为 -4 + 1023 = 1019 的二进制01111111011。

小数位:1.100110011(0011循环),由于IEEE 754尾数位数限制,需要将后面多余的位截掉(0舍1入,精度损失的原因之一),舍弃掉首位后为1001100110011001100110011001100110011001100110011010

0  01111111011  1001100110011001100110011001100110011001100110011010
S    E指数             M尾数

十进制0.2转为二进制为0.001100110011(0011循环),即 1.100110011(0011)2^-3* ,存储时:

符号位: 0

指数位:-3,实际存储为 -3 + 1023 = 1020 的二进制 01111111100

小数位: 1.100110011(0011循环),舍弃首位,截掉多余位后为1001100110011001100110011001100110011001100110011010

0  01111111100  1001100110011001100110011001100110011001100110011010
S     E指数          M尾数
image-20211123220607989.png

对阶运算

接下来,计算 0.1 + 0.2 。

浮点数进行计算时,需要对阶。即把两个数的指数阶码设置为一样的值,然后再计算小数部分。其实对阶很好理解,就和我们十进制科学记数法加法一个道理,先把指数部分化成一样,再计算小数。

另外,需要注意一下,对阶时需要小阶对大阶。因为,这样相当于,小阶指数乘以倍数,小数部分相对应的除以倍数,在二进制中即右移倍数位。这样,不会影响到小数的高位,只会移出低位,损失相对较少的精度。

因此,0.1的指数阶码为 -4 , 需要对阶为 0.2的指数阶码 -3 。尾数部分整体右移一位。

1.100110011(0011)2^-4* 变成 0.1100110011(0011)2^-3*

符号位: 0

指数位:-3,实际存储为 -3 + 1023 = 1020 的二进制 1111111100

小数位: 0.1100110011(0011循环),截掉多余位(0舍1入)后为1100110011001100110011001100110011001100110011001101

原来的0.1
0  01111111011  1001100110011001100110011001100110011001100110011010
对阶后的0.1
0  01111111100  1100110011001100110011001100110011001100110011001101

然后进行尾数部分相加 ,做加法时我们带上整数位进行计算:

  0 01111111100   0.1100110011001100110011001100110011001100110011001101

+ 0 01111111100   1.1001100110011001100110011001100110011001100110011010

= 0 01111111100  10.0110011001100110011001100110011001100110011001100111

可以看到,产生了进位。因此,阶码需要 +1,即为 -2,尾数部分进行低位0舍1入处理(精度损失的原因之二)。因尾数最低位为1,需要进位。所以存储为:

0  1111111101  0011001100110011001100110011001100110011001100110100

最后把二进制转换为十进制,计算结果的二进制表示为:1.0011001100110011001100110011001100110011001100110100 * 2^-2

转为十进制,最终结果为:

0.30000000000000004

所以 0.1 + 0.2 !== 0.3 这个问题就这样产生了...

提问

这算是Bug吗?

这不是bug,原因在与十进制到二进制的转换导致的精度问题。其次这几乎出现在很多的编程语言中:C、C++、Java、Javascript、Python中。

准确来说:“使用了IEEE754浮点数格式”来存储浮点类型(float 32,double 64)的任何编程语言都有这个问题

如何解决这个问题?

number-precision

function plus(...nums: numType[]): number {
  if (nums.length > 2) {
    return iteratorOperation(nums, plus);
  }

  const [num1, num2] = nums;
  // 取最大的小数位
  const baseNum = Math.pow(10, Math.max(digitLength(num1), digitLength(num2)));
  // 把小数都转为整数然后再计算
  return (times(num1, baseNum) + times(num2, baseNum)) / baseNum;
}

取小数点最长的位数,得出等比放大的基数,将小数放大成整数,相加后除以这个基数

舍入

  1. 首先是向最近的有效数舍入
  2. 如果它与两个相邻的有效数距离一样时(即它是中间数,halfway),那么舍入到最近的偶数有效数。

以二进制数为例,假定有效数位(也称保留位)为 4. 那舍入举例如下: 1.001 011 舍入结果为 1.001, 因为 1.001 011 与 1.001 的距离是 0.000 011,而与 1.010 距离是 0.000 101,显示与前者近 1.001 101 舍入结果为 1.010, 因为 1.001 101 与 1.001 的距离是 0.000 101,而与 1.010 的距离是 0.000 010, 显示与后者近 其实从二进制上可以发现规律,当有效位的后一位是 0 时,那么即将被舍去的值 小于最后一位有效位数值的一半(说得有点拗口) ,那么应该向下舍入;而当有效位的后一位是 1 时,而且后面数位不全为零,那即将被舍去的值 大于最后一位有效位数值的一半 ,那么应该向上舍入。 但有一种殊情况就是,有效位后一位是 1,后面数位全是零,刚好是最后一位有效位数值的一半,这种情况下是向最近的偶数舍入。比如: 1.001 100 : 它相近两个偶数分别是 1.000 和 1.010,显然是 1.010 离它近一些,故舍入到 1.010 1.100 100 : 它相近两个偶数分别是 1.100 和 1.110,显然是 1.100 离它近一些,故舍入到 1.100 这里也可以发现规律:如果即将被舍的值刚好等于一半,如果最低有效位为奇,则向上舍入,如果为偶,则向下舍入。 综上所述,如果以形式 1.RR..RDD..D 表示浮点数(R 表示有效位,或保留位,而 D 表示舍去位),Roundings to nearest even 舍入规则就是:

  1. 如果 DD..D < 10..0,则向下舍入
  2. 如果 DD..D > 10..0,则向上舍入
  3. 如要 DD..D = 10..0,则向最近偶数舍入,细则如下 : a. 如果 RR..R = XX..0 (X 表示任意值,0 或 1),则向下舍入 b. 如果 RR..R = XX..1,则向上舍入

规格化与非规格化,以及 Bias (CSAPP 2.4.2)

规格化的值,阶码被解释为以偏置形式表示的有符号整数,阶码 = E - Bias,Bias 为 2^(k-1) -1,其中 k 为阶码的尾数,这样 E 就可以存储为无符号数,但可以表示正负值,有符号数比较复杂

非规格化的值,阶码全是 0,阶码固定= 1 - Bias,小数不默认补开头的 1,这样可以平滑过渡到规格化的值 考虑如下场景,Bias = 2^(4-1) - 1 = 7

# 非规格化,阶码固定为 -6(1-7)
0 0000 001 = 2^(-6) * 1/8
...
0 0000 110 = 2^(-6) * 6/8
0 0000 111 = 2^(-6) * 7/8
# 规格化
0 0001 000 = 2^(1 - 7) * 1
0 0001 001 = 2^(1 - 7) * (1 + 1/8)
...

还有哪些类似0.1 + 0.2的场景

换算成二进制后无穷循环的都可能出现这种场景。下面举几个例子:

参考链接

https://juejin.cn/post/6844903680362151950

https://www.cnblogs.com/starry-skys/p/11824852.html

https://zh.wikipedia.org/wiki/IEEE_754

https://zh.wikipedia.org/wiki/%E9%9B%99%E7%B2%BE%E5%BA%A6%E6%B5%AE%E9%BB%9E%E6%95%B8

https://www.zhihu.com/question/46432979/answer/221485161

https://baike.baidu.com/item/%E5%8D%81%E8%BF%9B%E5%88%B6%E8%BD%AC%E4%BA%8C%E8%BF%9B%E5%88%B6/393189#2

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容