一次性讲清楚IEEE 754浮点数标准
一次性讲清楚IEEE 754浮点数标准缘起
下面是一个典型的 JavaScript 精度误差:
> 0.1 + 0.2
0.30000000000000004
类似 0.1 + 0.2 结果不完全等于 0.3 的编程语言不仅仅限于 JavaScript。
实际上,大多数现代编程语言在处理浮点数时都会遇到类似的问题,C、C++、JAVA、Python、Go…… 都如此,谁也别笑谁~
究其原因,是因为这些语言通常都使用 IEEE 754 标准来表示浮点数,而这个标准在表示某些十进制小数时存在精度限制问题。
借此机会,小甲鱼顺便给大家一次性讲清楚 IEEE 754 浮点数标准的前世今生,及具体的实现原理。
注意:本篇文章非必学的内容,不掌握也不会影响你成为 JavaScript 大拿!
IEEE 754 的前世今生
在 IEEE 754 标准出现之前,不同的计算机系统使用各自不同的方法来处理浮点数。
不难想象,这种 “你用你的标准,我用我的标准” 的情况必将导致程序的浮点数处理混乱和不一致。
为了解决这个问题,国际电气与电子工程师协会(Institute of Electrical and Electronics Engineers,IEEE)启动了一个标准化项目,旨在制定一个通用的浮点数运算标准。
于是,IEEE 754 标准在 1985 年被正式采纳,这个标准不仅规定了浮点数的格式,还涵盖了运算规则、异常处理、舍入方式等多个方面。
不要小看这个标准,该标准的主要贡献者威廉·卡汉(William Kahan)因此直接获得了图灵奖(计算机界的诺贝尔奖)。
浮点数的表示
在 IEEE 754 标准中,浮点数可以通过下面公式来描述:
$V = (-1)^{S} \times M \times 2^{E}$
该公式包括三个主要部分:符号位(S)、指数位(E,也成为阶码位)和尾数位(M,也称为小数位或效数字位)。
符号位(Sign bit)
符号位决定了该浮点数的正负值,0 代表正数,1 代表负数:
[*]$(-1)^{0} = 1$
[*]$(-1)^{1} = -1$
指数位(Exponent)
指数位用于表示浮点数的指数部分,但它存储的不是直接的指数值,而是一个偏移量(或称为偏置)表示的值。
[*]在单精度(32 位浮点数)中,指数是 8 位,偏移量为 127。
[*]在双精度(64 位浮点数)中,指数是 11 位,偏移量为 1023。
真实的指数值为存储的值减去偏移量的结果(请参考下面 “规格化数” 中的计算演示)。
尾数位(Mantissa)
尾数部分存储浮点数的实际数字(或称为有效数字),不包括数字序列中最左边的 1(对于规格化数)。
这意味着浮点数的精度可以得到增加,因为存储空间用于表示更多的有效数字。
在单精度中,尾数位占 23 位,在双精度中占 52 位。
由于 JavaScript 采用的是双精度浮点数格式(64 位),所以在内存中浮点数的封装如下:
燃鹅,这里并不是直接用二进制表示各个字段的值,不然小甲鱼就不用写这篇文章了……
下面内容比较复杂,请做好心理准备,再次强调:非必学的内容,看不懂也没关系。
{:10_248:}
浮点数的分类
根据指数位和尾数位的不同组合,IEEE 754 定义了 4 种类型的值:规格化数、非规格化数、无穷大和 NaN。
规格化数(Normalized Numbers)
绝大多数浮点数在 IEEE 754 标准中确实是以规格化数的形式存储的。
当指数位不全为 0 或不全为 1 时,浮点数被称为规格化数。
这种数有一个隐含的最高位 1(即 1.XXXXX 的形式)。
对于规格化的数,浮点数的表示遵循以下形式:
$V = (-1)^{S} \times 1.XXXXX \times 2^{E}$
这里使用 1.XXXXX 尾数部分所表示的小数(在二进制下),例如尾数位为 101100...0(总共 52 位),则 M = 1.101100...0(二进制)
假设我们有一个双精度浮点数,其位模式如下:
0100000001101000000000000000000000000000000000000000000000000000
让我们来分解这 64 位的值:
[*]符号位(S)-> 0 -> 正数
[*]指数位(E)-> 10000000110 -> 二进制转换为十进制得到 1030 -> 实际指数是 1030 - 1023 = 7
[*]尾数位(M)-> 1000000000000000000000000000000000000000000000000000 -> 二进制小数为 1.1000000000000000000000000000000000000000000000000000(这里有一个隐含的最高位 1)-> 1.1 转换为十进制数 -> $2^{0} + 2^{-1} = 1.5$
提示 1:指数位它存储的不是直接的指数值,而是一个偏移量(或称为偏置)表示的值,64 位浮点数的偏移量为 1023。
提示 2:关于进制转换可以看下这篇 -> 深入浅出进制转换。
因此,这个数值是:
$V = (-1)^{0} \times 1.5 \times 2^{7} = 1 \times 1.5 \times 128 = 192.0$
非规格化数(Denormalized Numbers)
在 IEEE 754 标准中引入非规格化数的主要目的是为了填补 0 和最小的规格化数之间的数值空白。
非规格化数能够显著扩展浮点数系统可以表示的数值范围,尤其是在数值非常非常小的场景下。
非规格化数的特点是指数位全为 0(即二进制表示为 00000000000),而尾数位不全为 0。
假设我们有一个双精度浮点数,其位模式如下:
0000000000000000000000000000000000000000000000000000000000000001
让我们来分解这 64 位的值:
[*]符号位(S)-> 0 -> 正数
[*]指数位(E)-> 00000000000 -> 非规格化数的标记
[*]尾数位(M)-> 0000000000000000000000000000000000000000000000000001
对于非规格化数来讲,指数计算是:$E = 1 - 偏移量$,
64 位偏移量是 1023,所以指数部分为 $2^{1 - 1023} = 2^{1022}$。
尾数位只有最低位是 1,所以尾数值是 $2^{-52}$。
因此,最终结果是:
$V = (-1)^{0} \times 2^{-52} \times 2^{-1022} = 2^{-1074}$
这个值是极其接近于零的正数,展示了非规格化数能够表示非常小的数值,从而增加了浮点数系统的动态范围。
无穷大(Infinity)
无穷大在双精度浮点数中的表示如下:
[*]符号位(S)-> 0 表示正无穷大(Infinity),1 表示负无穷大(-Infinity)
[*]指数位(E)-> 11111111111(全部为 1)
[*]尾数位(M)-> 0000000000000000000000000000000000000000000000000000(全部为 0)
正无穷大:
0111111111110000000000000000000000000000000000000000000000000000
负无穷大:
1111111111110000000000000000000000000000000000000000000000000000
非数值(NaN)
NaN 用于表示未定义的或无法表示的数值结果。
它有两种形式:静默 NaN(quiet NaN)和信号 NaN(signaling NaN)。
[*]符号位(S)-> 可以是 0 或 1,但通常对 NaN 的解释不依赖于符号位
[*]指数位(E)-> 11111111111(全部为 1)
[*]尾数位(M)-> 不全为 0(至少有一个位是 1)
静默 NaN 通常在尾数的最高位设为 1(确保它是静默的):
0111111111111000000000000000000000000000000000000000000000000000
信号 NaN 的尾数位通常不同,具体取决于系统的实现:
0111111111110100000000000000000000000000000000000000000000000000
实战
1. 如何使用 IEEE 754 保存 3 这个整数?
对于正整数 3,符号位的值是 0。
将 3 转换为二进制是 $3_{(10)} = 11_{(2)}$,由于规格化数有一个隐含的 1(即 1.XXXXX),因此:
$3_{(10)} = 11_{(2)} = 1.1 \times 2^{1}$
从而得到尾数位的值是 1000000000000000000000000000000000000000000000000000
指数是 1,但指数位的计算需要考虑偏移量,64 位的偏移量是 1023,所以指数位存放的值则应该是:
$1 + 1023 = 1024_{(10)} = 10000000000_{(2)}$
最终的二进制表示是:
0100000000001000000000000000000000000000000000000000000000000000
2. 如何使用 IEEE 754 保存 3.14 这个小数?
对于正数 3.14,符号位的值是 0。
将 3.14 转换为二进制表示涉及到整数部分和小数部分的转换~
整数部分 3 转换为二进制是:
$3_{(10)} = 11_{(2)}$
小数部分 0.14 转换为二进制:
[*]0.14 * 2 = 0.28 取整数部分 0
[*]0.28 * 2 = 0.56 取整数部分 0
[*]0.56 * 2 = 1.12 取整数部分 1
[*]0.12 * 2 = 0.24 取整数部分 0
[*]0.24 * 2 = 0.48 取整数部分 0
[*]0.48 * 2 = 0.96 取整数部分 0
[*]0.96 * 2 = 1.92 取整数部分 1
[*]0.92 * 2 = 1.84 取整数部分 1
[*]...
如果继续算下去,会发现 0.14 的二进制表示实际上是一个无限循环的小数(小甲鱼:得算到第 41 次,这里就列长龙了……)
将整数和小数部分的二进制结果合并后得到:
$3.14_{(10)} \approx 11.0010001111010111000010..._{(2)} = 1.10010001111010111000010... \times 2^{1}$
指数是 1,但指数位的计算需要考虑偏移量,64 位的偏移量是 1023,所以指数位存放的值则应该是:
$1 + 1023 = 1024_{10} = 10000000000_{2}$
由于是无线循环,尾数位需要截断到 52 位:
1001000111101011100001010001111010111000010100011110101110000100
最终的二进制表示是:
0100000000001001000111101011100001010001111010111000010100011110101110000100
灵魂拷问
1. 为什么 0.1 + 0.2 不等于 0.3 呢?
**** Hidden Message *****
2. Number.MAX_VALUE 和 Number.MIN_VALUE 的值是为什么是 1.7976931348623157e+308 和 5e-324?
**** Hidden Message *****
3. Number.MAX_SAFE_INTEGER 和 Number.MIN_SAFE_INTEGER 的值是为什么是 9007199254740991 和 -9007199254740991?
**** Hidden Message *****
{:10_264:} 似懂非懂 又学到新知识了 深奥的知识又增加了{:10_329:} 学习学习 又学到了 本来会学完了感觉不会了 好好学习 IEEE国际电气与电子工程师协会。前几天刚知道这个协会,全英文的网站看着是真的懵。虽然这篇文章看着也有点懵吧,但是至少能看懂那么一点点 新知识有点太深奥了 比较深奥 打卡成功了 又学到了 学习新知识,不断进步,加油! 加油!继续学习!前进! 很深奥,需要好好钻研一下 又涨新知识了 虽然还不是很懂但是感觉涨姿势了 所以金融类数据存储要注意