skip to content
usubeni fantasy logo Usubeni Fantasy

为什么会精度丢失?教你看懂 IEEE-754!

/ 10 min read

上图来自维基百科,黑夜模式会导致文字看不清楚,麻烦大家使用日间模式阅读啦

IEEE-754 标准是一个浮点数标准,存在 32、64、128 bit 三种格式(上面两幅图分别是 32 bit 和 64 bit 的情况,结构是一致的)。无论看起来是整数还是小数,JavaScript 中的 number 都是 64 位浮点数(也就是常说的“双精度”)。

本文将以 64 位举例讲解 IEEE-754 标准,从上图可以看出,IEEE-754 标准将 64 位分为三部分:

  • sign,1 bit 的标识位,0 为正数,1 为负数
  • exponent,指数,11 bit
  • fraction,小数部分,52 bit

为了举例方便,我们使用下面这串数字介绍 IEEE-754 标准

0100000001101101001000000000000000000000000000000000000000000000

不多不少 64 位,不信的数一数

sign

第 63 位(也是从左到右看的第一个数),在举例中,**sign(符号)**的值是 0,也就代表着这是一个正数。

fraction

之所以说 0 到 51 位(共 52 位)是 “fraction(小数)”,是因为这段数字在处理时会置于 1.(会有特例,后面会说)之后。

在举例中,属于 fraction 的 52 位是:

1101001000000000000000000000000000000000000000000000

这 52 位数字在本文中简称为 f(f 代指 fraction),加上前面提到需要添加的 1.,所谓的 1.f 是这样的:

1.1101001000000000000000000000000000000000000000000000

如果你问为什么要塞个 1 在前面,我也没查,总之就是这么规定的,这确实是名副其实的“小数”

但是拿到这一长串 1.f 要怎么用呢?就得结合 exponent 部分。

exponent

为更清晰地说明 exponent(指数) 从二进制到十进制的转换,借用此文的一个“表格”:

%00000000000 0 → −1023 (lowest number)
%01111111111 1023 → 0
%11111111111 2047 → 1024 (highest number)
%10000000000 1024 → 1
%01111111110 1022 → −1

请特别注意,01111111111 代表的是 0,往上是正数,往下是负数

抽离出上面例子的 52 到 62 位(共 11 位),得到:10000000110,再转为十进制数 1030,因为 1023 才是 0,所以减去 1023 算出真正结果,即是 7。

要使用这个 exponent(指数,下面用字母 e 指代指数),我们将上面得到的 1.f 乘上 2 的 7 次方(为了节省位置,省略掉后面的 0):

1.f × 2e−1023 = 1.1101001 × 27 = 11101001

注意了,这是二!进!制! 类比成十进制就是类似:1.3828171 × 107 = 13828171)

这就是“浮点数”的所谓浮点(Floating Point),小数点的位置可以随着指数的值左右漂移,这样可以更精细地表示一个数字;

与之相对的是定点(Fixed Point),例如一个数最大是 1111111111.1111111111,小数点永远固定在中间,这时候要表示绝对值小于或大于 1111111111.1111111111 的数就变得完全没有办法了。

在组合“fraction(小数)”和“exponent(指数)”得到 11101001 后,转为十进制即可,再加上没什么好解释的正负号 sign(标志位)(0 即为正数)

所以举例的

0100000001101101001000000000000000000000000000000000000000000000

其实就是以 IEEE-754 标准储存的 233

特殊情况

当 exponent(指数)为 -1023(也就是最小值,二进制表示为 7 个 0)时,是一种名为 denormalized 的特殊情况。

其表现为当前值的计算公式改为:

0.f × 2−1022

这就是 f 前不为 1 的特殊情况,这种情况可以用于表示极小的数字

总结

这位大佬的总结过于精辟,浮点数可以分为五种情况:

表达式取值
(−1)s × %1.f × 2e−1023normalized, 0 < e < 2047
(−1)s × %0.f × 2e−1022denormalized, e = 0, f > 0
(−1)s × 0e = 0, f = 0
NaNe = 2047, f > 0
(−1)s × ∞ (infinity)e = 2047, f = 0

第一行正常情况,第二行是上面说的 0.f denormalized,第三行其实就是全 0。

第四第五行就是 e 的 11 位为全 1,如果 f 大于 0 就是 NaN,f 等于 0 就是无限大。

动手转换 IEEE-754

使用上面总结的公式,将 IEEE-754 算回十进制应该不难,但是自己动手,如何通过十进制数算出 IEEE-754 呢?

我们整一个看起来还挺简单的数字:-432.1,再贴一下 64 bit 的组成图,免得大家翻来翻去

step1

看到负号,毫无疑问地,sign 就是 1 了,我们获得了第一块拼图,s = 1

step2

第二步,将 432.1 转为二进制。

正数部分转换,直到结果为 0 时停止:

计算结果余数
432/22160
216/21080
108/2540
54/2270
27/2131
13/261
6/230
3/211
1/201

由下往上写出结果:110110000

负数部分转换,直到结果为 0 时停止:

计算结果个位
0.1*20.20
0.2*20.40
0.4*20.80
0.8*21.61
0.6*21.21
0.2*20.40
0.4*20.80
0.8*21.61
0.6*21.21
0.2*20.40
0.4*20.80
0.8*21.61
0.6*21.21

没完没了,聪明的大家应该看出来了,这已经进入了无限循环状态。

就像十进制的三分一等于 0.33333333……,二进制的“十”分一等于 0.00011001100110011……,都是无限循环小数。

接着组合整数与小数部分:110110000.0[0011]

step3

转换为 1.f × 2e−1023 的格式

1.1011000000011001100110011001100110011001100110011010 × 28

用无限循环小数填满 f 的 52 位,

f = 1011000000011001100110011001100110011001100110011010

8 = e−1023,则 e 为 1031,转为二进制,

e = 10000000111

step4

拼图都凑齐了,组合在一起吧!s + e + f!

1100000001111011000000011001100110011001100110011001100110011010

这就是 IEEE-754 双精度浮点数 -432.1 的真身。

为什么算不准

程序员们因为精度丢失苦不堪言,这个问题不仅仅发生在 JavaScript 里,只是可怜的 JavaScript 奇怪的设定更多,大家就经常把 0.1 + 0.2 的问题绑定到 JavaScript 身上,其实 Java 等使用 IEEE-754 标准的语言都会有这个问题(然而 Java 还有 BigDecimal,JavaScript 只能哭哭 )。

那么到底为什么会算不准呢?

情况一

先说最常见的一种情况:

0.1 + 0.2; // 0.30000000000000004
1 - 0.9; // 0.09999999999999998
0.0532 * 100; // 5.319999999999999

曾经我也以为乘 100 变成整数再进行加减计算就不会丢精度,但事实是,某些情况下(例如 0.0532 * 100)乘法本身算出来的数就已经走样了。

说回产生的原因吧,其实跟上面算 0.1 一样,就是因为 除 不 尽

但是为什么?!明明直接打印出来他就是正常的 0.1 啊!为什么 1 - 0.9 出来的 0.1 就不是了 0.1 了!

下面我只是肤浅地推测一下:

console.log((0.1).toFixed(30));
// 输出 '0.100000000000000005551115123126'
console.log((1.1 - 1).toFixed(30));
// 输出 '0.100000000000000088817841970013'

通过 toFixed 我们可以看到更精确的 0.1 到底是个什么数字,而且也能清楚看到 0.11.1 - 1 出来的根本不是同一个数字,尽管在十进制看来这就是 0.1,但是在二进制看来这就是除不尽的数,所以进行计算后就会有轻微的不同。

那到底什么情况下的“0.1”才会被当成“0.1”呢?答案是:

  • 小于 0.1000000000000000124(等等等等)
  • 大于 0.0999999999999999987(等等等等)

至于要准确知道 IEEE-754 怎么进行“估值”,这里或许能找到答案,好奇宝宝们可以钻研一下

总之,因为除不尽,再加上计算中带来的误差,超过一定的值,某个数就变成另一个数了。

情况二

第二种算不准的情况就是因为实在太大了

我们已知双精度浮点数有 52 位小数,算上前面的 1,那么最大且能准确表示的整数,就是 Math.pow(2,53)

console.log(Math.pow(2, 53));
// 输出 9007199254740992
console.log(Math.pow(2, 53) + 1);
// 输出 9007199254740992
console.log(Math.pow(2, 53) + 2);
// 输出 9007199254740994

为什么 +2 又准了呢?,因为在这个范围内 2 的倍数仍可以被准确表示。再往上,当数字到达 Math.pow(2,54) 之后,就只能准确表示 4 的倍数了,55 次方是 8 的倍数,以此类推。

console.log(Math.pow(2, 54));
// 输出 18014398509481984
console.log(Math.pow(2, 53) + 2);
// 输出 18014398509481984
console.log(Math.pow(2, 53) + 4);
// 输出 18014398509481988

所以浮点数虽然可以表示极大和极小的数字,但是不那么准确,不过,也总比定点数完全没法表示要好一点吧。

实用链接

评论组件加载中……