“0.1 + 0.2 = ?” 这个问题,你要是问小学生,他也许会立马告诉你 0.3。可是在计算机的世界里就没有这么简单了,作为一名程序开发者在你面试时若是有人这样问你,当心陷阱喽!html
你可能在哪里见过 “0.1 + 0.2 = 0.30000000000000004” 可是知道这背后真正的原理吗?是只有 JavaScript 中存在吗?带着这些疑问本文将重点梳理这背后的原理及浮点数在计算机中的存储机制。java
做者简介:五月君,Nodejs Developer,慕课网认证做者,热爱技术、喜欢分享的 90 后青年,欢迎关注 Nodejs技术栈 和 Github 开源项目 www.nodejs.rednode
如下是一些基础的,可能被你所忽略的知识,了解它颇有用,由于这些基础知识在咱们的下文讲解中都会应用到,若是你已掌握了它,能够跳过本节。python
1. 计算机的内部是如何存储的?一个浮点数 float a = 1 会存储成 1.0 吗?git
计算机内部都是采用二进制进行表示,即 0 1 编码组成。在计算机中是没有 1.0 的,它只认 0 1 编码。github
2. 1bit 能够存储多少个整数?8bit 能够存储多少个整数?面试
N 个 bit 能够存储的整数是 2 的 N 次方个。8bit 为 2 的 8 次方()。bash
3. 了解下科学计数法,下文讲解会用到ui
在平常生活中遇到一个比较的大的数字,例如全国总人口数、每秒光速等,在物理上用这些大数表达很不方便,一般能够采用科学计数法表达。编码
如下为 10 进制科学计数法表达式,底数为 10 ,其中 1≤|a|<10,n 为整数
例如,0.1 的科学计数法表示为 。(一个数的 -1 次方等于该数的倒数,例如
=
)
在 IEEE 754 标准中也相似,只不过它是以一个二进制数来表示,底数为 2,如下为 0.1 的二进制表达式:
4. 十进制小数如何转二进制?
十进制小数转二进制,小数部分,乘 2 取整数,若乘以后的小数部分不为 0,继续乘以 2 直到小数部分为 0 ,将取出的整数正向排序。
例如: 0.1 转二进制
0.1 * 2 = 0.2 --------------- 取整数 0,小数 0.2
0.2 * 2 = 0.4 --------------- 取整数 0,小数 0.4
0.4 * 2 = 0.8 --------------- 取整数 0,小数 0.8
0.8 * 2 = 1.6 --------------- 取整数 1,小数 0.6
0.6 * 2 = 1.2 --------------- 取整数 1,小数 0.2
0.2 * 2 = 0.4 --------------- 取整数 0,小数 0.4
0.4 * 2 = 0.8 --------------- 取整数 0,小数 0.8
0.8 * 2 = 1.6 --------------- 取整数 1,小数 0.6
0.6 * 2 = 1.2 --------------- 取整数 1,小数 0.2
...
复制代码
最终 0.1 的二进制表示为 0.00110011... 后面将会 0011 无限循环,所以二进制没法精确的保存相似 0.1 这样的小数。那这样无限循环也不是办法,又该保存多少位呢?也就有了咱们接下来要重点讲解的 IEEE 754 标准。
IEEE 754 是 IEEE 二进制浮点数算术标准的简称,在这以前各家计算机公司的各型号计算机,有着千差万别的浮点数表示方式,这对数据交换、计算机协同工做形成了极大不便,该标准的出现则解决了这一乱象,目前已成为业界通用的浮点数运算标准。
IEEE 754 经常使用的两种浮点数值的表示方式为:单精确度(32位)、双精确度(64位)。例如, C 语言中的 float 一般是指 IEEE 单精确度,而 double 是指双精确度。
这里重点讲解下双精确度(64位)(JS 中使用),单精确度(32 位)同理。
在 JavaScript 中不论小数仍是整数只有一种数据类型表示,这就是 Number 类型,其遵循 IEEE 754 标准,使用双精度浮点数(double)64 位(8 字节)来存储一个浮点数(因此在 JS 中 1 === 1.0)。其中可以真正决定数字精度的是尾部,即
根据 IEEE 754 标准,任意二进制数 V 均可用以下公式表示:
符号位的做用是什么?你可能会有此疑惑,在计算机中一切万物都以二进制表示,那么二进制中又以 0 1 存储,你可能想用负号(-)表示负数,对不起这是不支持的,为了表示负数一般把最高位看成符号位来表示,这个符号位就表示了正负数,0 表示正数(+),1 表示负数(-)。
顺便抛出几个问题
1. 计算机的世界中是否有减法?1 - 1 是如何实现的?
2. 十进制数 1 的二进制为 0000 0001,-1 对应的二进制是什么?用 1000 0001 表示 -1 对吗?
IEEE 754 规定,在计算机内部保存 M 时,默认这个数的第一位老是 1,所以能够被舍去,只保存后面部分,这样能够节省 1 位有效数字,对于双精度 64 位浮点数,M 为 52 位,将第一位的 1 舍去,能够保存的有效数字为 52 + 1 = 53 位。
在双精确度浮点数下二进制数公式 V 演变以下所示:
E 为一个无符号整数,在双精度浮点数中 E 为 11 位,取值范围为 ,即表示的范围为 0 ~ 2047。
中间值: 因为科学计数法中的 E 是能够出现负数的,IEEE 754 标准规定指数偏移值的固定值为 ,以双精度浮点数为例:
,这个固定值也能够理解为中间值。同理单精度浮点数为
。
正负范围: 双精确度 64 位中间值为 1023,负数为 [0, 1022] 正数为 [1024, 2047]。
双精确度浮点数下二进制数公式 V 最终演变以下所示:
不知道怎么转换的,参考上面 先修知识 的 十进制小数转二进制
0.000110011001100110011(0011) // 0011 将会无限循环
复制代码
任何一个数均可以用科学计数法表示,0.1 的二进制科学计数法表示以下所示:
以上结果相似于十进制科学计数法表示:
0.1 的二进制表示以下所示:
3.1 符号位
因为 0.1 为整数,因此符号位 S = 0
3.2 指数位
E = -4,实际存储为 -4 + 1023 = 1019,二进制为 1111111011,E 为 11 位,最终为 01111111011
3.3 尾数位
在 IEEE 754 中,循环位就不能在无限循环下去了,在双精确度 64 位下最多存储的有效整数位数为 52 位,会采用 就近舍入(round to nearest)模式(进一舍零) 进行存储
11001100110011001100110011001100110011001100110011001 // M 舍去首位的 1,获得以下
1001100110011001100110011001100110011001100110011001 // 0 舍 1 入,获得以下
1001100110011001100110011001100110011001100110011010 // 最终存储
复制代码
3.4 最终存储结果
0 01111111011 1001100110011001100110011001100110011001100110011010
复制代码
binaryconvert.com/convert_dou…
上面咱们讲解了浮点数 0.1 采用 IEEE 754 标准的存储过程,0.2 也同理,能够本身推理下,0.一、0.2 对应的二进制分别以下所示:
S E M
0 01111111011 1001100110011001100110011001100110011001100110011010 // 0.1
0 01111111100 1001100110011001100110011001100110011001100110011010 // 0.2
复制代码
浮点数加减首先要判断两数的指数位是否相同(小数点位置是否对齐),若两数指数位不一样,须要对阶保证指数位相同。
对阶时遵照小阶向大阶看齐原则,尾数向右移位,每移动一位,指数位加 1 直到指数位相同,即完成对阶。
本示例,0.1 的阶码为 -4 小于 0.2 的阶码 -3,故对 0.1 作移码操做
// 0.1 移动以前
0 01111111011 1001100110011001100110011001100110011001100110011010
// 0.1 右移 1 位以后尾数最高位空出一位,(0 舍 1 入,此处舍去末尾 0)
0 01111111100 100110011001100110011001100110011001100110011001101(0)
// 0.1 右移 1 位完成
0 01111111100 1100110011001100110011001100110011001100110011001101
复制代码
尾数右移 1 位以后最高位空出来了,如何填补呢?涉及两个概念:
两个尾数直接求和
0 01111111100 1100110011001100110011001100110011001100110011001101 // 0.1
+ 0 01111111100 1001100110011001100110011001100110011001100110011010 // 0.2
= 0 01111111100 100110011001100110011001100110011001100110011001100111 // 产生进位,待处理
复制代码
或者如下方式:
0.1100110011001100110011001100110011001100110011001101
+ 1.1001100110011001100110011001100110011001100110011010
10.0110011001100110011001100110011001100110011001100111
复制代码
因为产生进位,阶码须要 + 1,对应的十进制为 1021,此时阶码为 1021 - 1023(64 位中间值)= -2,此时符号位、指数位以下所示:
S E
= 0 01111111101
复制代码
尾部进位 2 位,去除最高位默认的 1,因最低位为 1 需进行舍入操做(在二进制中是以 0 结尾的),舍入的方法就是在最低有效位上加 1,若为 0 则直接舍去,若为 1 继续加 1
100110011001100110011001100110011001100110011001100111 // + 1
= 00110011001100110011001100110011001100110011001101000 // 去除最高位默认的 1
= 00110011001100110011001100110011001100110011001101000 // 最后一位 0 舍去
= 0011001100110011001100110011001100110011001100110100 // 尾数最后结果
复制代码
IEEE 754 中最终存储以下:
0 01111111101 0011001100110011001100110011001100110011001100110100
复制代码
最高位为 1,获得的二进制数以下所示:
2^-2 * 1.0011001100110011001100110011001100110011001100110100
复制代码
转换为十进制以下所示:
0.30000000000000004
复制代码
这显然不是的,这在大多数语言中基本上都会存在此问题(大都是基于 IEEE 754 标准),让咱们看下 0.1 + 0.2 在一些经常使用语言中的运算结果。
JavaScript
推荐一个用于任意精度十进制和非十进制算术的 JavaScript 库 github.com/MikeMcl/big…
console.log(.1 + .2); // 0.30000000000000004
// bignumber.js 解决方案
const BigNumber = require('bignumber.js');
const x = new BigNumber(0.1);
const y = 0.2
console.log(parseFloat(x.plus(y)));
复制代码
Python
Python2 的 print 语句会将 0.30000000000000004 转换为字符串并将其缩短为 “0.3”,可使用 print(repr(.1 + .2)) 获取所须要的浮点数运算结果。这一问题在 Python3 中已修复。
# Python2
print(.1 + .2) # 0.3
print(repr(.1 + .2)) # 0.30000000000000004
# Python3
print(.1 + .2) # 0.30000000000000004
复制代码
Java
Java 中使用了 BigDecimal 类内置了对任意精度数字的支持。
System.out.println(.1 + .2); // 0.30000000000000004
System.out.println(.1F + .2F); // 0.3
复制代码
推算 0.1 + 0.2 为何不等于 0.3 这个过程是乏味和有趣并存的,由于它很难理解,可是一旦你掌握了它,能让你更深入的认识到其中的存储、运算机制,从而理解结果为何是 0.30000000000000004。
最后作个总结,因为计算机底层存储都是基于二进制的,须要事先由十进制转换为二进制存储与运算,这整个转换过程当中,相似于 0.一、0.2 这样的数是无穷尽的,没法用二进制数精确表示。JavaScript 采用的是 IEEE 754 双精确度标准,可以有效存储的位数为 52 位,因此就须要作舍入操做,这无可避免的会引发精度丢失。另外咱们在 0.1 与 0.2 相加作对阶、求和、舍入过程当中也会产生精度的丢失。