Skip to content
On this page

前言

浮点数在我们的生活中处处可见,如你在清空购物车时,就需要结算所支付的金额。但是如果你真正在计算机上使用过浮点数计算,就能知道有挺多坑的,本文会介绍下浮点数与二进制相关的知识,希望能解开你心中浮点数的迷惑,主要围绕以下几点展开:

  1. 浮点数的误区,如:0.1 + 0.2 不等于 0.3
  2. 浮点数在计算机的存储方式,即 IEEE 754 标准介绍
  3. 特殊数字的由来,如 NaN、Number.MAX_SAFE_INTEGER 等
  4. 正确执行浮点数运算的方法

浮点数的误区

令人困惑的浮点数:

js
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 位格式

image-20220301195257453

该标准定义了五种基本格式,其中最常用的便是单精度 32 位浮点数与双精度 64 位浮点数,下面主要讲解双精度浮点数(因为 JavaScript 中使用的是双精度浮点数,并且双精度浮点数与单精度浮点数也很类似)

该标准定义:浮点数由三个字段组成:符号位(sign)、指数(exponent)和尾数(fraction)

image-20220301195235285

**符号位:**占 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)

至此,其实已经能解释这段代码了:

js
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],标准中也有定义,故:

js
(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 的二进制值

valuesed
0.1001111111011
1019 -> -4
1001100110011001100110011001100110011001100110011010
0.2001111111100
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 的值到底是怎么来的 🎉

参考链接

JavaScript 中精度问题以及解决方案

双精度特殊值示例

IEEE_754-1985 标准

[IEEE 名称解释](https://www.ieee.org/about/ieee-history.html#:~:text=Related links-,Meaning of I-E-E-E,is the full legal name.)

decimal.js JavaScript 中的任意精度运算

HOW TO: Adding IEEE-754 Floating Point Numbers

为什么 0.1 + 0.2 不等于 0.3 ?来自:图解系统-小林 coding-v1.0 pdf