表面工作
在日常的工作和学习中,经常会探测自己的底线,计算机基础好与不好,完全能够决定一个人的代码水平和bug出现率。相信大家对这些知识都学过,只是长时间不用就忘记了,今天带大家来回顾一下。
本着通俗易懂的原则,今天把这个题目讲明白。
我们来聊聊这个非常常规的问题,为什么 0.1 + 0.2 !== 0.3
.在正式介绍这个问题之前,需要了解下面几
个前置知识。
- 计算机二进制的表现形式以及二进制的计算方式?
- 什么是原码、补码、反码、移码,都是用来做什么的?
差不多这几个就够理解这个常规的 0.1 + 0.2 !== 0.3
问题了。
第一个前置知识,二进制
我们知道在日常中,有很多种数据的展现,包括我们日常生活中常规使用的10进制、css中表示颜色的16进制、计算机中进行运算的二进制。
二进制的表现形式
在计算机中的计算都是以二进制的形式进行计算的,也就是全都是0或1来表示数字的,我们拿10进制进行举例,如:
- 10进制的 1 在计算机中表示为 1
- 10进制的 2 在计算机中表示为 10
- 10进制的 8 在计算机中表示为 1000
- 10进制的 15 在计算机中表示为 1111
二进制的计算方式
对于二进制的计算方式,我们分为两种情况来说,一种是整数的计算,一种为小数的计算。
整数部分的二进制计算
我们先说明10进制如何转化为二进制。10进制转化为二进制的方式称为“除 2 取余法”,即把一个10进制数,一直除以2取其余数位。举两个例子
30 % 2 ········· 0
15 % 2 ········· 1
7 % 2 ········· 1
3 % 2 ········· 1
1 % 2 ········· 1
0
整数的二进制转换是从下往上读的,所以30的二进制表示即为11110
.
100 % 2 ········· 0
50 % 2 ········· 0
25 % 2 ········· 1
12 % 2 ········· 0
6 % 2 ········· 0
3 % 2 ········· 1
1 % 2 ········· 1
0
整数的二进制转换是从下往上读的,所以100的二进制表示即为1100100
.
我还专门写了一个函数来转换这个二进制。
function getBinary(number) {
const binary = [];
function execute(bei) {
if (bei === 0) {
return ;
}
const next = parseInt(bei / 2, 10);
const yu = bei % 2;
binary.unshift(yu);
execute(next);
}
execute(number);
return binary.join('');
}
console.log(getBinary(30)); // 11110
console.log(getBinary(100)); // 1100100
接下来,我们再看看怎么把二进制转换成10进制。通俗点讲就是从右到左用二进制的每个数去乘以2的相应次方并递增。举个例子,拿上面的100举例子吧。100的二进制表示为1100100
,我们需要做的是:
1100100
= 1 * 2^6 + 1 * 2^5 + 0 * 2^4 + 0 * 2^3 + 0 * 2^2 + 0 * 2^1 + 0 * 2^0
= 100
简单明了,不用多说,看下实现代码:
function getDecimal(binary) {
let number = 0;
for (let i = binary.length - 1; i >= 0; i--) {
const num = parseInt(binary[binary.length - i - 1]) * Math.pow(2, i);
number += num;
}
return number;
}
console.log(getDecimal('11110')); // 30
console.log(getDecimal('1100100')); // 100
小数部分的二进制计算
小数部分的二进制计算与整数部分的二进制计算不同,十进制的小数转化为二进制的小数的计算方式称为“乘二取整法”,即把一个十进制的小数乘以2然后取其整数部分,直到其小数部分为0为止。看个例子:
0.0625 * 2 = 0.125 ········· 0
0.125 * 2 = 0.25 ········· 0
0.25 * 2 = 0.5 ········· 0
0.5 * 2 = 1.0 ········· 1
且小数部分的读取方向也不一样。小数的二进制转换是从上往下读的,所以0.0625的二进制表示即为0.0001
,这个是正好能够除尽的情况,很多情况下是除不尽的,例如题目中的0.1和0.2。写个函数转换下:
function getBinary(number) {
const binary = [];
function execute(num) {
if (num === 0) {
return ;
}
const next = num * 2;
const zheng = parseInt(next, 10);
binary.push(zheng);
execute(next - zheng);
}
execute(number);
return '0.' + binary.join('');
}
console.log(getBinary(0.0625)); // 0.0001
再尝试把二进制的小数转换为十进制的小数,因为上面是乘,所以在这边就是除法了,二进制的除法也是可以表示为负指数幂的乘法的,比如1/2 = 2^-1
;我们来看下0.0001怎么转换为0.0625:
0.0001
= 0 * 2^-1 + 0 * 2^-2 + 0 * 2^-3 + 1 * 2^-4
= 0.0625
用函数来实现下这个形式吧。
function getDecimal(binary) {
let number = 0;
let small = binary.slice(2);
for (let i = 0; i < small.length; i++) {
const num = parseInt(small[i]) * Math.pow(2, 0 - i - 1);
number += num;
}
return number;
}
console.log(getDecimal('0.0001')); // 0.0625
二进制转换这一部分我们就先了解到这里,对于 0.1 + 0.2 !== 0.3
这个问题,上面的二进制部分,基本是足够了。当然代码部分仅作参考,边界等问题没有做处理...
做个题巩固一下:
<details>
<summary> 18.625 的二进制表示是什么 ??? => 点击查看详情</summary>
<pre>
18的二进制表示为: 100010
0.625的二进制表示为: 0.101
所以18.625的二进制表示为:100010.101
</pre>
</details>
<sammry></sammry>
第二个前置知识,计算机码
我们知道,计算机中是使用二进制来进行计算的,讲到计算机码,就不得不提 IEEE标准,而涉及到小数部分的运算就不得不提到 IEEE二进位浮点数算术标准的标准编号(IEEE 754)。其标准的二进制表示为
V = (-1)^s * M * 2^E
- 其中s为符号位,0为正数,1为负数;
- M为尾数,是一个二进制小数,其中规定第一位只能是1,1和小数点省略;
- E为指数,或者称为阶码
为什么1和小数位要省略呢?因为所有的第一位都为1,省略后可以再末尾再多一位,增加精确度。如果第一位为0的话,那没有任何意义。
一般来说,现在的计算机都支持两种精度的计算浮点格式。一种为单精度(float),一种为双精度(double)。
格式 | 符号位 | 尾数 | 阶码 | 总位数 | 偏移值 |
---|---|---|---|---|---|
单精度 | 1 | 8 | 23 | 32 | 127 |
双精度 | 1 | 11 | 52 | 64 | 1023 |
以JavaScript为例,js中使用的是双精度格式来进行计算的,其浮点数是64位。
原码
什么是原码,原码是最简单的,就是符号位加上真值的绝对值, 即用第一位表示符号, 其余位表示值。我们用11位表示如下:
- +1 = [000 0000 0001]原
-1 = [100 0000 0001]原
因为第一位是符号位,所以其取值区间为[111 1111 1111, 011 1111 1111] = [-1023, 1023];反码
什么是反码,反码是在原码的基础上进行反转。正数的反是其本身;负数的反码是符号位不变,其余位取反。
- +1 = [000 0000 0001]原 = [000 0000 0001]反
- -1 = [100 0000 0001]原 = [111 1111 1110]反
补码
什么是补码,补码是在反码的基础上补位。正数的补码是其本身,负数的补码是在其反码的基础上,再加1.
- +1 = [000 0000 0001]原 = [000 0000 0001]反 = [000 0000 0001]补
- -1 = [100 0000 0001]原 = [111 1111 1110]反 = [111 1111 1111]补
为什么会有补码这玩意呢? - 首先在计算机中是没有减法的,都是加法,比如 1 - 1 在计算机中是 1 + (-1).
如果使用原码进行减法运算:
1 + (-1) = [000 0000 0001]原 + [100 0000 0001]原 = [100 0000 0010]原 = -2
===>>> 结论:不对
为解决这个不对的问题于是就有了反码去做减法:
1 + (-1) = [000 0000 0001]反 + [111 1111 1110]反 = [111 1111 1111]反 = [100 0000 0000]原 = -0
发现值是正确的,只是符号位不对;虽然+0和-0在理解上是一样的,但是0带符号是没有意义的,况且会出现 [000 0000 0000]原 和 [100 0000 0000]原 两种编码方式。
===>>> 结论:不大行
为解决上面这个符号引起的问题,就出现了补码去做减法:
1 + (-1) = [000 0000 0001]补 + [111 1111 1111]补 = [000 0000 0000]补 = [000 0000 0000]原 = 0
这样得到的结果就是完美的了,0用 [000 0000 0000] 表示,不会出现上面 [100 0000 0000]。
===>>> 结论:完美
移码
移码,是由补码的符号位取反得到的,一般用做浮点数的阶码,引入的目的是为了保证浮点数的机器零为全0。这个不分正负。
- +1 = [000 0000 0001]原 = [000 0000 0001]反 = [000 0000 0001]补 = [100 0000 0001]移
- -1 = [100 0000 0001]原 = [111 1111 1110]反 = [111 1111 1111]补 = [011 1111 1111]移
细心一点可以发现规律: - +1 = [000 0000 0001]原 = [100 0000 0001]移
- -1 = [100 0000 0001]原 = [011 1111 1111]移
为什么 0.1 + 0.2 !== 0.3 ?
回到我们的题目,我们来看下为什么 0.1 + 0.2 !== 0.3
.来看下0.1和0.2的二进制表示。
0.1 = 0.0 0011 0011 0011 0011 0011 0011 0011 0011 0011 .... 0011无限循环
0.2 = 0. 0011 0011 0011 0011 0011 0011 0011 0011 0011 .... 0011无限循环
可以得知0.1和0.2都是一个0011无限循环的二进制小数。
我们由上面知道,JavaScript中的浮点数是64位来进行表示的,那么0.1和0.2是在计算机中又是如何表示的呢?
0.1 = (-1)^ 0 * 1.1 0011 0011 0011 * 2^(-4)
-4 = 10 0000 0100
根据IEEE 754标准可以得知:
V = (-1)^S * M * 2^E
S = 0 // 1位,正数为0,负数为1
E = [100 0000 0100]原 // 11位
= [111 1111 1011]反
= [111 1111 1100]补
= [011 1111 1100]移
M = 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 // 52位(其中1小数点省略)
同理可知0.2的表示:
0.2 = (-1)^ 0 * 1.1 0011 0011 0011 * 2^(-3)
-4 = 100 0000 0011
V = (-1)^S * M * 2^E
S = 0 // 1位,正数为0,负数为1
E = [100 0000 0011]原 // 11位
= [111 1111 1100]反
= [111 1111 1101]补
= [011 1111 1101]移
M = 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 // 52位(其中1小数点
两者相加,阶码不相同,我们需要进行对阶操作。
对阶
对阶就会存在尾数移动的情况。
- 大的阶码向小的阶码看齐,就需要把大的阶码的数的尾数向左移动,此时就有可能在移位过程中把尾数的高位部分移掉,这样就引发了数据的错误。这是不可取的
- 小的阶码向大的阶码看齐,就需要把小的阶码的数向右移动,高位补0;这样就会把右边的数据给挤掉,这样也就导致了会影响数据的精度,但是不会影响数据的整体大小。
计算机采取的是后者,小看大的办法。这也就是今天这个问题产生的原因,丢失了精度。
那么接下来,我们就看看上面的这个移动。
// 0.1
E = [100 0000 0011]原 = [111 1111 1100]反 = [111 1111 1101]补 // 11位, 对阶后
M = 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 // 移动前
M = 0100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 // 移动后
// 0.2 保持不变
E = [100 0000 0011]原 = [111 1111 1100]反 = [111 1111 1101]补 // 11位,不变
M = 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 // 52位,不变
如上就是二进制中0.1和0.2的对阶后的结果,我们对这个数字进行运算比较麻烦,所以我们直接拿0.1和0.2的真值进行计算吧。
真值计算
0.1 = 0.0 0011 0011 0011 0011 0011 0011 0011 0011 0011 .... 0011无限循环
0.2 = 0. 0011 0011 0011 0011 0011 0011 0011 0011 0011 .... 0011无限循环
0.1 + 0.2
= 0.0001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 (1001...舍弃)
+ 0.0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 (0011...舍弃)
= 0.0100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 (1100...舍弃)
= 0.2999999999999998
这特么不对啊!!!
我们在浏览器运行的时候得到的值是:
0.1 + 0.2 = 0.30000000000000004
产生上面问题的原因,是在于计算机计算的时候,还会存在舍入的处理
如上面来看,真值计算后的值舍弃的值是1100,在计算机中还会存在舍0入1,即如下:
0.1 + 0.2
= 0.0001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 (1001...舍弃)
+ 0.0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 (0011...舍弃)
= 0.0100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 (1100...舍弃)
= 0.0100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1101 (入1)
= 0.30000000000000004
到此,我们就把这部分聊明白了,如有不对之处,欢迎指出。感谢阅读。
关注
欢迎大家关注我的公众号[德莱问前端]
,文章首发在公众号上面。
除每日进行社区精选文章收集外,还会不定时分享技术文章干货。
希望可以一起学习,共同进步。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。