JS基础-数字-0.1+0.2!=0.3(三)

前两章介绍了计算机如何使用2进制进行运算操作,下面将介绍JS与IEEE754的关系。

在JS中,有三个常会涉及,但却不是JS特有的玩意。

1. 正则表达式
2. 字符集编码

ASCII编码
欧洲系列编码 [ ISO 8859-1 至 ISO 8859-16 ]
中文系列编码 [ gb2312, gb18030, GBK, BIG5 ]
其他国家...
unicode编码 [ UTF-8,UTF-16,UTF-32]

字符集编码这一部分,之前已经简要概述过,有兴趣的可以查看
JS基础-字符-Unicode编码(上)
JS基础-字符-Unicode编码(中)
JS基础-字符-Unicode编码(下)

3. IEEE754双精度浮点数运算标准

这三个东西基本组成了所有编程语言的核心部件,作为一名程序员,都应该熟练掌握才行,但不得不承认,这三个“夜叉”真的很难搞定,不过老话说得好,水滴石穿,在黑暗中蹒跚前行,终会迎来曙光。

我们要做的,不正是这件事吗?盘他!

一、寻找IEEE754

首先,可以在权威书籍中寻找到IEEE754的蛛丝马迹,如果你留意过JS相关书籍的number数据类型,一定见过它。如果你再细心一点,就会发现这些书籍对于number类型的描述都是浅尝辄止:

红宝书第三版:5页
犀牛书:4页
你不知道的js中卷:11页

难道是number类型不重要吗?当然不是,描述的篇幅少,并不代表不重要,反而是它太重要了,以至于要描述清楚可能要再写一本书,就像正则表达式和字符集编码一样。

这些书籍猫叔的共通部分大概如下:

1. javascript是使用IEEE 754 64位格式存储。
2. 由于几乎所有编程语言都是用到了IEEE 754标准,计算精度误差不是js特有的诟病。
3. 阐述了0.1 + 0.2 不等于 0.3 的现象。

其他:红宝书中对于数字描述个人感觉有些地方有些矛盾
浮点数值描述中:
由于保存浮点数值需要的内存空间是保存整数值的两倍,因此 ECMAScript 会不失时机地将浮点数值转换为整数值。比如 1.0 保存成了 1
位操作符描述中:
位操作符用于在最基本的层次上,即按内存中表示数值的位来操作数值。ECMAScript 中的所有数值都以 IEEE-754 64 位格式存储,但位操作符并不直接操作 64 位的值。而是先将 64 位的值转换成 32 位 的整数,然后执行操作,最后再将结果转换回 64 位。

这两段描述都来自红宝书,一会说 ECMAScript会为了节省内存而把浮点型转化成整型,一会又说所有数值都以 IEEE-754 64 位格式存储,没办法,还是看看ECMAScript文档怎么说的吧

在ECMAScript文档中对于数字的描述是:
原始值,对应一个 64 位双精度二进制 IEEE754 值。
所以,鉴于红宝书中的描述只是个例,先忽略吧,如果你知道其中“隐情”,麻烦留言告诉我,不胜感激。

二、解释IEEE754字面意思

先把 IEEE754双精度浮点数运算标准 拆分成几个小部分,方便理解:

IEEE754双精度浮点数运算标准 = [ IEEE, 754, 双精度, 浮点数, 运算标准 ]

1. IEEE

全称:美国电气和电子工程师协会,1963年由无线电工程师协会(IRE,创立于1912年)和美国电气工程师协会(AIEE,创建于1884年)合并而成。
英语:Institute of Electrical and Electronics Engineers,简称为IEEE,英文读作“i triple e”[ai trɪpl I:]。
成员:大多数成员是电子工程师,计算机工程师和计算机科学家,不过因为组织广泛的兴趣也吸引了其它学科的工程师(例如:机械工程、土木工程、生物、物理和数学)【还包括 特斯拉,爱迪生,贝尔等】。
性质:面向电子电气工程、通讯、计算机工程、计算机科学理论和原理研究的组织。
领域:电能、能源、生物技术和保健、信息技术、信息安全、通讯、消费电子、运输、航天技术和纳米技术等。
贡献:制定了全世界电子和电气还有计算机科学领域30%的文献,另外它还制定了超过900个现行工业标准。(ps:你能想到的理工类行业他都要制定标准)

2. 754

这个数字其实就是标准的唯一标识,或者说是一个代号,和你的学生证上编号用途一样。
IEEE组织制定的标准多如牛毛,拥有编号也就很正常了。

  • IEEE 754 ── 浮点算法规范
  • IEEE 802 ── 局域网及城域网
  • IEEE 802.11 ── 无线网络
  • IEEE 802.16 ── 无线宽频网络
  • .....
3. 双精度

有双就得有单,不止如此,还有延伸单精确度和延伸双精确度,他们代表的其实就是位数,就是几位2进制数。

  • 单精确度(32位)
  • 双精确度(64位)
  • 延伸单精确度(43比特以上,很少使用)
  • 延伸双精确度(79比特以上,通常以80位实现)
4. 浮点数

浮点数是一种对于实数的近似值数值表现法,类似10进制科学计数法。注意,浮点数不是2进制专用的,任何进制的数都可以用浮点数表示。

10进制的科学计数法:
例如:19971400000000=1.99714×1013
优点:用科学记数法免去浪费很多空间和时间。

10进制的科学计数法有严格的标准:
把一个数表示成a与10的n次幂相乘的形式(1≤|a|<10,a不为分数形式,n为整数),这种记数法叫做科学记数法。
注意:a必须满足 1≤|a|<10,这一点和浮点数有本质区别。

浮点数表示法
还拿 19971400000000 这个数值举例,如果使用浮点数表示法,他将有多种结果:
0.199714 × 1014
1.99714 × 1013
19.9714 × 1012
199.714 × 1011
1997.14 × 1010
....
确实和科学计数法很像,但是注意他的小数点,是可以自由移动的,只要两个乘数(第二个乘数必须是以进制数为底数的)的结果和原数值相等,就满足浮点数表示法。

可以发现,1.99714 × 1013 也在范围中,这个不是科学计数法吗?怎么跑这来了?其实,科学计数法就是浮点数表示法的一个子集,但是,在浮点数中,这个“科学计数法值”有个新名字,叫做 规格化值

由于在IEEE754中的浮点数都代指2进制浮点数,所以还是拿2进制来说明一下,假设有如下2进制数:
1101.00011
使用浮点数表示法有多种形式:
110.100011 × 21
11.0100011 × 22
1.10100011 × 23
0.110100011 × 24
11010.0011 × 2-1
110100.011 × 2-2
....
这些都是代表 1101.00011 这个值的浮点数,用 m × 2e 指这些值,其中 1.10100011 × 23 就是特殊的 规格化值

在计算机中有 移位操作(联想JS中的 >> 、<<、 >>>) ,假设现在用两个卡片分别记录 m 和 e 的值(默认底数就是2,所以就不记录了):

如果 m中的小数点 左移一位 e 就 +1;
如果 m中的小数点 右移一位 e 就 -1;

把1101.00011左移3位时,两个卡片分别可以得到: 1.10100011 和 3,这个值其实就是 规格化值(1.10100011 × 23),而这个过程称之为 “规格化”

在计算机中,浮点数的对立面就是定点数,定点数又分为 定点整数定点小数,存储定点整数的寄存器成为 整数定点机(整数定点机默认小数点在寄存器最为右边), 存储定点小数的寄存器成为 小数定点机(小数定点机默认小数点在寄存器最为左边)。

定点整数案例: 11101、11、1
使用8位整数定点机存储分别为:00011101, 00000011, 00000001

定点小数案例: 0.11101、0.0011、0.1
使用8位小数定点机存储分别为:11101000,00110000,10000000

那么你会问像1101.00011这样的浮点数应该怎么存储到寄存器?别着急,现在只说到了浮点数表示法,还没说到计算机2进制浮点数存储,不过你可以先自己猜测一下它的存储方式。

5. 运算标准

运算标准代指存储和运算的标准。

在计算机中,如要运算一些值,那么这些值就必须先存储在内存中。所以运算标准已经隐含了存储标准。后文要说的正是存储和运算标准,也就是探究10进制的值到底是如何以IEEE754标准存储到内存当中和参与运算的。

三、为什么JS只使用IEEE754双精度浮点数运算标准,而不是像其他强类型语言(例如JAVA)那样用多种数字格式?

Java声明数字类型变量:
int 数据类型是32位、有符号的以二进制补码表示的整数 :int a = 10
long 数据类型是 64 位、有符号的以二进制补码表示的整数 : long b = 10L
float 数据类型是单精度、32位、符合IEEE 754标准的浮点数 : float c = 4.5f
double 数据类型是双精度、64 位、符合IEEE 754标准的浮点数 :double d = 4.5

JS声明数字类型变量:
let a = 10, c = 4.5 ;

这就涉及到了一些历史问题,或说布兰登·艾克在开发js前,他的上级给他下达了两个指标:

  1. 要和java足够的相似,因为公司要蹭java热点,以便推广,并且把原定的liveScript的名字改成了javaScript。
  2. 语言要足够的简单,使得非计算机专业的人员也能快速掌握。

怎么才算简单呢?
第一招,当然就是刚才提到的变量声明方式。

因为对于大多数非计算机专业的人来说,并不熟悉字节、内存、cpu运算硬件结构这些概念,所以布兰登·艾克就是用了这一种声明方式,使用者不用考虑这些底层细节,直接赋值使用就行了。

在编译阶段,也就是生成可执行代码时,再把变量编译成各种类型,以便执行时存数数据。那么数字就统一按照64位浮点数进行存储。

简单的设计带来的副作用
但是,这么做肯定是有缺陷,要不然其他语言也不用费劲巴力的把数字分成好几类。

1. 运算效率低下

比如现在要计算1 + 2,Java可以选择使用 int,也就是32位有符号整型补码运算,而Js却只能使用64位浮点数运算。(注意图2中并不是IEEE754的运算细节,但是大体流程一致)


左1为整型运算,右1为浮点型运算

你不用在意其中的细节,现在只需要直观的的感受一下就能判断,浮点型的加减运算要比整型加减运算的复杂程度要高得多。

*尝试解决运行效率问题
现在前端很多场景都需要大量运算,比如股票类的网站,每秒推送5,6条数据,而且数据还要在前端进行大量公式运算已得到最终展示数据。canvas复杂动画,也需要前端大量的运算来支撑,这可咋办?

好在w3c推出的HTML5中定义了web worker

在chrome中,每一个标签页都是一个进程,一个进程可包含了多个线程。

在主线程运行的同时,Worker 线程在后台运行,两者互不干扰。等到 Worker 线程完成计算任务,再把结果返回给主线程。这样的好处是,一些计算密集型或高延迟的任务,被 Worker 线程负担了,主线程(通常负责 UI 交互)就会很流畅,不会被阻塞或拖慢。

const worker = new Worker('work.js');

但是这也只是一种备用方案,因其会增加了开发、维护成本。但总比没有强。

2. 大整数运算失灵

如果现在要计算 9007199254740992(253) + 1

Java:可以使用 long a = 9007199254740992 + 1
结果为 9007199254740993

js :let a = 9007199254740992 + 1
结果为 9007199254740992

js类似问题还有:
9007199254740992 + 2 = 9007199254740994(正确)
9007199254740992 + 3 = 9007199254740996(多了)
9007199254740992 + 4 = 9007199254740996(正确)

这是因为Java的 long可以精确存储的最大整数值为
±(263 - 1),而JS可以精确存储的最大整数值为 ±(253 - 1)

JS数字的边界

3. 小数运算不准

再次强调,这不是JS特有的诟病,Java等其它编程语言的浮点运算也一样完蛋。因为它们的浮点数同样使用IEEE754标准,这不应该是其他编程语言的开发人员贬低JS的借口。

①计算误差
0.1 + 0.2 = 0.30000000000000004
0.7 + 0.6 = 1.2999999999999998
0.7 + 0.2 = 0.8999999999999999
0.07 + 0.4 = 0.47000000000000003
可是有的值却又是准确的。
0.1 + 0.3 = 0.4
0.1 + 0.4 = 0.5

②误差累积
这些误差并不是暂歇性的,而是可以累计的。
0.1 + 0.2 = 0.30000000000000004
(0.1 + 0.2) + (0.1 + 0.2) = 0.6000000000000001 [误差累积]

误差累计引发的事故:
在1991年波斯湾战争期间,美国依靠爱国者导弹跟踪和拦截了巡航导弹和飞毛腿导弹。其中一个导弹没有跟踪到一个进入领空的飞毛腿导弹,击中了美国军队营房,死亡28人,受伤人数更多。
经调查后确定爱国者导弹的失败是由于精度误差累计而不能让导弹准确地确定来袭的飞毛腿导弹的速度。
问题在时钟上,它以1/10s的时间来测量。但是从系统启动以来的时间都被存储为整数秒(由经过的时间乘以1/10确定,是的,你没有看错,又是这个0.1!)。当经历的时间较短时,该“截断误差”不明显,不会产生问题。问题是在海湾战争期间,导弹系统持续运行了几天。

③乘法不总是满足结合律

三个数相乘,先把前两个数相乘,再和另外一个数相乘,或先把后两个数相乘,再和另外一个数相乘,积不变。叫做乘法结合律。可化简为(ab)c=a(bc)、(a·b)·c=a·(b·c)

ECMAScript 第11版中描述(这些描述在ES各个版本中除了排版变化,内容几乎没变):

12.7.3.1 Applying the * Operator
*运算符表示乘法,产生操作数的乘积。乘法运算满足交换律。因为精度问题,乘法并不总是满足结合律。
浮点乘法的结果受IEEE 754-2008二进制双精度算法的规则支配

产生的问题案例:
假设购物车结算:一个商品单价17.45元,用户买了3个,赶上双11打9折,保留2位小数。

前端购物车结算计算逻辑为:
17.45 * 3 * 0.9 = 47.114999999999995
(47.114999999999995).toFixed(2) = 47.11

后端结算计算逻辑:
17.45 * 0.9 * 3 = 47.115
(47.115).toFixed(2) = 47.12

很明显,前后的数值对不上了,避免此类事件发生的办法为:
要想得到相同的值,不要改变运算值顺序。

尝试解决运算问题
1. 土办法:tofixed,但是仅限于不要求精度的场景。
乘以一个10的倍数把它转化成整数,但是不靠谱比如:
0.07 * 100 = 7.000000000000001
((0.07 * 10000) + (0.0471 * 10000)) / 10000 = 0.11710000000000002
(0.08 * 100) * (0.07 * 100) / 10000 = 0.005600000000000001

2. 使用第三方库
mathjs、number-precision、bigjs、BigDecimal(不推荐)。
鉴于上述插件都有非常多的数学方法,还有的使用new一个新对象的方式以实现链式调用,最佳实现方案还是自己动手写一个符合当前业务逻辑的mini插件。

*四、补充:

另外一个简化JS的就是隐式类型转换

最开始,布兰登·艾克开发JS的时候,隐式类型转换并没有像现在这样丧心病狂,直到有一天....
Netscape 的内部用户要求使用 == 来比较包含字符串值 "404" 的 HTTP 状态码与数字 404。他们还要求在数字上下文中将空字符串自动转换为 0,从而为 HTML 表单的空字段提供默认值。这些类型转换规则带来了一些意外,例如 1 == '1' 且 1 == '1.0',但 '1' != '1.0'。
尽管用户需求促生了 JavaScript 1.0 / 1.1 中 == 运算符的隐式类型转换规则,但一些用户仍发现该行为令人惊讶和混乱。布兰登·艾克 决定消除 JavaScript 的大多数隐式类型转换,以修复 ==。如果两个操作数都不是相同的原始类型(数字,字符串,布尔值,对象),那么 == 将返回 false。
布兰登·艾克 回忆说,他希望加入 JavaScript 1.2 中自己对 == 运算符语义的更改,以消除其类型转换问题。Shon Katzenberger 成功地说服了他,理由是鉴于会破坏大量现有 Web 页面,现在做这种更改已经为时已晚。

还是乔布斯乔老爷子说得好:消费者并不知道自己需要什么,我们得告诉他们需要什么。

篇幅有点长了,在此截断,下期再见。

你可能感兴趣的:(JS基础-数字-0.1+0.2!=0.3(三))