前言
浮点数在我们的生活中处处可见,如你在清空购物车时,就需要结算所支付的金额。但是如果你真正在计算机上使用过浮点数计算,就能知道有挺多坑的,本文会介绍下浮点数与二进制相关的知识,希望能解开你心中浮点数的迷惑,主要围绕以下几点展开:
- 浮点数的误区,如:0.1 + 0.2 不等于 0.3
- 浮点数在计算机的存储方式,即 IEEE 754 标准介绍
- 特殊数字的由来,如 NaN、Number.MAX_SAFE_INTEGER 等
- 正确执行浮点数运算的方法
浮点数的误区
令人困惑的浮点数:
console.log(0.1 + 0.2);
// 0.30000000000000004
// 0.1 + 0.2 真的只等于 0.30000000000000004 吗?
console.log(
0.1 + 0.2 === 0.30000000000000004,
0.1 + 0.2 === 0.30000000000000003,
0.1 + 0.2 === 0.30000000000000002
);
// true true true
// toFixed 函数
// https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Number/toFixed
console.log((0.1).toFixed(100));
// 0.1000000000000000055511151231257827021181583404541015625000000000000000000000000000000000000000000000
console.log((2.55).toFixed(1), (1.335).toFixed(2));
// 2.5 1.33
console.log(Math.pow(2, 53) - 1 === Number.MAX_SAFE_INTEGER);
// true
下面我将为你解答这些奇怪现象背后的原因
IEEE 754 标准
现代计算机中,IEEE 754 标准(IEEE 电气电子工程师学会 Institute of Electrical and Electronics Engineers)是使用最广泛的浮点数标准(JavaScript、C、Java、Python 等等很多编程语言都使用了此标准)
该标准以发布年份又区分 3 个版本,分别是 IEEE 754-1985、IEEE 754-2008、IEEE 754-2019,在 ECMAScript 2021 标准 中可以发现,JavaScript 使用的正是 IEEE 754-2019 标准中的双精度 64 位格式
该标准定义了五种基本格式,其中最常用的便是单精度 32 位浮点数与双精度 64 位浮点数,下面主要讲解双精度浮点数(因为 JavaScript 中使用的是双精度浮点数,并且双精度浮点数与单精度浮点数也很类似)
该标准定义:浮点数由三个字段组成:符号位(sign)、指数(exponent)和尾数(fraction)
符号位: 占 1 位,0 代表正数,1 代表负数
指数: 占 11 位,能表示 2048 个数,即 [0, 2047],但实际场景下指数的值可以是正数也可以是负数,所以引入了一个指数偏移基准值(Exponent bias):1023。举例来说:当指数的二进制值为 1025 时,代表指数的实际值为 2,因为 1023 + 2 = 1025,指数的二进制值为 1020 时,代表指数的实际值为 -3,因为 1023 - 3 = 1020。那么你会觉得指数实际值的范围是 [-1023, +1024],但实际是 [-1022, +1023],因为二进制全0、二进制全1,用于表示特殊值了
**尾数:**占 52 位,但可以表示 53 位,因为开头开头第一位的值总为 1,为了节约存储空间,所以省略掉了
浮点数转为二进制
以十进制 14.34375 为例,它是如何以二进制存储的?首先分别把整数部分与小数部分的二进制值计算出来:
1 4 . 3 4 3 7 5
10^1 10^0 . 10^-1 10^-2 10^-3 10^-4 10^-5
1 1 1 0 . 0 1 0 1 1
2^3 2^2 2^1 2^0 . 2^-1 2^-2 2^-3 2^-4 2^-5
8 2 4 0 . 0 0.25 0 0.0625 0.03125
小数点后面的二进制是如何计算出的?
0.34375 * 2 为 0.6875 第 1 位记 0
0.6875 * 2 为 1.375 第 2 位记 1
0.375 * 2 为 0.75 第 3 位记 0
0.75 * 2 为 1.5 第 4 位记 1
0.5 * 2 为 1 第 5 位记 1
最终没有小数位了,计算结束,结果为 01011
聪明的你肯定在这里发现了:有可能出现无限循环的情况呀,这个后面会讲
定点数与科学记数法
现在拿到的值 1110.01011 叫做定点数,即小数点是固定位置不能改变的,那么如何拿到浮点数呢?这就要讲下科学计数法了:
给一个⼗进制数 1230000,可以表示为 1.23 x 10^6 或 12.3 x 10^5 等等,这种⽅式就称为科学记数法。该方法有一种格式叫做规格化,即⼩数点左边有且仅有⼀位非零数字,如 1.23 x 10^6 是规格化的,⽽ 12.3 x 10^5 和 0.123 x 10^7 就不是规格化的
定点数转浮点数
1110.01011 可转换为规格化的科学计数法,即 1.11001011 x 2^3,这里的 3 就是指数,11001011 就是尾数,不对,尾数前面有个 1 为什么没存呀?
因为这里使用了规格化的科学计数法,左边有且仅有⼀位非零数字,又因为使用的是二进制数据,第一位还必须是非零,那么就只有一种可能,第一位一定是 1,为了节省存储空间,所以不会存储这个值,这也就是上面说的:「尾数:占 52 位,但可以表示 53 位」
十进制浮点数 0.1 转换为二进制的值是多少?
先转换为二进制定点数,整数部分就是0,算一下小数部分
0.1 * 2 === 0.2 记 0
0.2 * 2 === 0.4 记 0
0.4 * 2 === 0.8 记 0
0.8 * 2 === 1.6 记 1
0.6 * 2 === 1.2 记 1
0.2 * 2 === 0.4 记 0 这里开始无限循环了
0.4 * 2 === 0.8 记 0
0.8 * 2 === 1.6 记 1
0.6 * 2 === 1.2 记 1
0.2 * 2 === 0.4 记 0 又继续无限循环
...
定点数为 0.0 0011 0011 0011 ... 这里出现了无限循环,但浮点数位数是有限的,不可能存下那么多的值,所以无法用二进制精确表示 0.1,只能用近似值来表示,于是便造成了精度缺失的情况,其实这里你并不用惊讶,因为二进制不能正确表示 0.1 就和十进制不能表示 1/3 一样普通,对于这种情况,我们选择的是表示其近似值,就像十进制中,我们用 0.33 来代表 1/3 一样。
转换为规格化的科学计数法为:1.1 0011 0011 0011 ... * 2^-4
指数应该为 1019
符号位:0
指数:01111111011
尾数:1001100110011001100110011001100110011001100110011010
也可以使用 binaryconvert 工具验证一下,结果是正确的
二进制转为浮点数
现在可以反向得出二进制转为浮点数的换算公式了:
十进制 = (-1)^符号位 x (1 + 尾数) x 2^(指数 - 1023)
下面这个二进制转换为十进制浮点数的值是多少?
符号位:0
指数:10000000010
尾数:0100110000000000000000000000000000000000000000000000
指数的值是 1026,因为 1023 + 3 = 1026,所以指数是 3
可得规格化的科学计数法为 1.010011 * 2^3
可得定点数是 1010.011
可得十进制值为 10.375
(0.1).toFixed(100)
至此,其实已经能解释这段代码了:
console.log((0.1).toFixed(100));
// 0.1000000000000000055511151231257827021181583404541015625000000000000000000000000000000000000000000000
浮点数转成二进制,之后再用二进制转为浮点数,结果就是这个值(手算计算量很大,可使用后面介绍的 decimal.js 精确计算)
虽然你定义的是 0.1 但实际上计算机在二进制存的值是 0.1000000000000000055511151231257827021181583404541015625,可通过 toFixed 方法获取到这个真实值
特殊数字的由来
上面说过,指数的二进制全0、二进制全1,用于表示特殊值了,如下:
正负零
符号位:0代表正数,1代表负数
指数:全是0
尾数:全是0
正负无穷
符号位:0代表正数,1代表负数
指数:全是1
尾数:全是0
NaN
符号位:0或1都可以,无所谓
指数:全是1
尾数:任何非零值
(typeof NaN === 'number' 的原因找到了)
Number.MAX_SAFE_INTEGER Number.MIN_SAFE_INTEGER
符号位:0 代表正数,1 代表负数
指数:1023 + 52 = 1075
尾数:全是 1
其实就是 53 个 1,即 2^53 - 1
Number.MAX_VALUE Number.MIN_VALUE
其实上述的 [-1022, 1023] 只是简单化处理了,如果深究的话,会更复杂(与 Subnormal number 有关),但可以简单理解为能表示的范围是 [-1074, 971],标准中也有定义,故:
(Math.pow(2, 53) - 1) * Math.pow(2, 971) === Number.MAX_VALUE // true
Math.pow(2, -1074) === Number.MIN_VALUE // true
正确执行浮点数运算的方法
浮点数奇怪的本质
个人认为,浮点数奇怪的本质是:不能存储完整准确的二进制表示,但这是合理的,因为有无限循环的情况,是存储不完的
当遇到存储不完的的时候会怎样?会溢出,溢出分为上溢和下溢,这又是个复杂的问题,本文暂不讨论
如何正确执行浮点数运算
所有的数据计算都是准确的,当出现无限循环时,明确给出精度定义,超出精度的值舍弃掉,按照人脑的十进制方法去计算
缺点:自己实现起来费力并且也没必要,一般都要引入三方库,如 decimal.js
总结
本文我介绍了浮点数的标准,即 IEEE 754 标准,还解释了一些特殊值以及常见误区的原因,最后简单分析了 decimal.js 的源码,希望能让你有所收获
现在回顾最初的问题,你都知道答案了吗?
这里使用到了浮点数的加法运算,在做加法前,首先要对齐指数,对齐指数时指数小的值会改为指数大的
如下是 0.1 与 0.2 的二进制值
value | s | e | d |
---|---|---|---|
0.1 | 0 | 01111111011 1019 -> -4 | 1001100110011001100110011001100110011001100110011010 |
0.2 | 0 | 01111111100 1020 -> -3 | 1001100110011001100110011001100110011001100110011010 |
指数对齐:把指数小的变为大的,即 0.1 的 e 由 -4 变为 -3
1.1001100110011001100110011001100110011001100110011010 * 2^-4
0.11001100110011001100110011001100110011001100110011010 * 2^-3
指数对齐后就是二进制的加法,没有的位置补0
0.2 1.10011001100110011001100110011001100110011001100110100
0.1 0.11001100110011001100110011001100110011001100110011010
ans 10.0110011001100110011001100110011001100110011001100111
10.0110011001100110011001100110011001100110011001100111 * 2^-3
1.00110011001100110011001100110011001100110011001100111 * 2^-2
符号位:0
指数:-2 即 1021 即 1111111101(二进制)
尾数:00110011001100110011001100110011001100110011001100111
尾数超长了,现在是 53 位,应该改为 52 位,丢弃的部分要使用舍入规则
尾数:0011001100110011001100110011001100110011001100110100
1.0011001100110011001100110011001100110011001100110100 * 2^-2
0.010011001100110011001100110011001100110011001100110100 是定点数,那么计算值是
Decimal.set({ precision: 99 }) // 替换到默认的 20 位精度,改为 99 位精度,因为这里计算的值太长了
new Decimal('0b0.010011001100110011001100110011001100110011001100110100').equals((0.1+0.2).toFixed(100))
// true
最终,完美解释了 0.1 + 0.2 的值到底是怎么来的 🎉
参考链接
decimal.js JavaScript 中的任意精度运算
HOW TO: Adding IEEE-754 Floating Point Numbers
为什么 0.1 + 0.2 不等于 0.3 ?来自:图解系统-小林 coding-v1.0 pdf