01_JS精度
好久前在公司分享的文章,现在才发…本文阐述了为什么0.1 + 0.2 != 0.3,并分析了number-precision和bignumber.js的解决原理
被JS精度问题小坑了一把,所以系统来
复习学习一波~
背景
在实际业务开发中,可能会遇到一下问题:
// 加法
0.1 + 0.2 // 0.30000000000000004
// 减法
1.5 - 1.2 // 0.30000000000000004
// 乘法
19.9 * 100 // 1989.9999999999998
// 除法
0.3 / 0.1 // 2.9999999999999996
toFixed()
和toPrecision()
在必要时进行四舍五入
有时候我们会用toFixed()
来解决这个问题,但是其实这个方法有时候会出现不希望的结果:
2.54.toFixed(1) // 2.5
2.56.toFixed(1) // 2.6
2.55.toFixed(1) // error: 2.5
2.55.toPrecision(1) // error: 2.5
业界内也诞生了一道经典的面试题:0.1 + 0.2 为什么不等于0.3?
因为 JS 采用IEEE 754
双精度版本(64位),并且只要采用 IEEE 754
的语言都有该问题。
IEEE754
前置知识
-
计算机内部都是采用二进制进行表示,即
0 1
编码组成; -
十进制数转为二进制:
- 正整数转二进制:将正整数除以2,得到的商继续再除以2,直到商为0或1为止,然后将余数倒着链接起来即可;
然后高位补0,如果是8位,那么在前面补2个0,所以最后结果是 :
00100110 0010 0110 00100110
-
负数转二进制:先将正整数转为二进制之后,对二进制取反,然后对结果再加1;
以
-38
为例子,38
的二进制是0010 0110
,则取反后的结果是1101 1001
,加1之后结果为:1101 1010
。 -
小数转二进制:对小数点以后的数乘以2,取整数部分,再用小数部分乘以2,依次来推,直到小数部分为0或者位数已经OK了,再把整数部分依次排列就是小数的二进制结果了:
以0.125为例子:
0.125 * 2 = 0.25 --------------- 取整数 0,小数 0.25 0.25 * 2 = 0.5 ----------------- 取整数 0,小数 0.5 0.5 * 2 = 1 -------------------- 取整数 1
所以结果是
0.001
,可以按需低位补0。- 小数的整数部分大于0时,将整数、小数部分依次转为二进制,然后加在一起就OK。所以 38.125的二进制就是:
0010 0110.001
- 正整数转二进制:将正整数除以2,得到的商继续再除以2,直到商为0或1为止,然后将余数倒着链接起来即可;
-
科学计数法,首先以10进制科学计数法为例子:
-
23.32 => 0.2332 => 小数点向左移动了2位置,所以最终的结果是
0.2332 ∗ 1 0 2 0.2332 * 10^2 0.2332∗102
二进制在存储的时候是以二进制的科学计数法来存储的,如果是二进制科学计数法,则: -
10111=> 1.0111=> 小数点向左移动了4位,4转为2进制是100,所以最终的结果是
1.0111 ∗ 2 ( 100 ) 1.0111 * 2^(100) 1.0111∗2(100)
-
根据二进制科学技术法,小数点前必须有一个非0
什么是IEEE754
IEEE754标准中规定:
float
单精度浮点数在机器中表示用 1 位表示数字的符号,用 8 位来表示指数,用23 位来表示尾数,即小数部分。- 对于
double
双精度浮点数,用 1 位表示符号,用 11 位表示指数,52 位表示尾数,其中指数域称为阶码。所有数值的计算和比较,都是这样以64个bit的形式来进行的。
在JS
中,所有Number
都是以 64bit
的双精度浮点数存储的。
符号S
由于计算机万物都是以二进制表示,为了理解符号,一般将最高位当作符号位来理解,0代表+,1代表-。
指数E
它占了11位,所以取值范围是0~2的11次方,即0~1024位,即可以代表1024个数字。但是IEEE 754 标准规定指数偏移值的固定值为 2 e − 1 − 1 2^{e-1}-1 2e−1−1,以双精度浮点数为例: 2 11 − 1 − 1 = 1023 2^{11-1}-1=1023 211−1−1=1023。
为什么 IEE754浮点数标准中64位浮点数的指数偏移量是1023?
以32位浮点数为例子,指数占8位,即0-2的8次方,也就是256。由于指数也有正负,所以从中间劈开,-128 ~ +128,但是中间有个0,所以表示-128到127这256个数字。
怎么记录正负?一种作法是把高位置1,这样我们只要看到高位是1的就知道是负数了,所谓高位置1就是说把0到255这么多个数字劈成两半,从0到127表示正数,从128到255表示负数。但是这种作法会带来一个问题:当你比较两个数的时候,比如130和30,谁更大呢?机器会觉得130更大,但实际上130是个负数,它应该比30小才对啊。
所以后来有人提出了,将所有数字加上128,这样-128 + 128 = 0, 127 + 128 = 255这样比较,就不存在负数比正数大的情况。
所以如果你读到0,就减去128,则得到负指数-128,读到255,减去128,就得到127。
那为什么最终指数偏移量是127,不是128,因为不允许使用0和255两个数字代表指数。少了2个数字,所以只能采用127。
同理,64位,指数11位,即2^11 = 2048,对半1024,去掉0和2048,所以偏移量用1023
尾数M
对于尾数M,只保存后面的小数部分。这是由于1≤M<2,在计算机内部保存M时,默认这个数的第一位总是1,因此可以被舍去,这样做的好处是可以节省一位有效数字。对于双精度 64 位浮点数,M 为 52 位,将第一位的 1 舍去,可以保存的有效数字为 52 + 1 = 53 位。
根据二进制科学技术法,小数点前必须有一个非0,那么有效域即1.xxxx,小数点前的1默认存在,但是默认不占坑,尾数部分就存储小数点后的部分
十进制转为IEEE754
当我们使用 Number
的时候,计算机底层会自动将我们输入的十进制数字自动转为 IEEE754标准的浮点数。
以0.1为例子,它转为二进制科学计数法是这个:
0.1001100110011001100110011001100110011001100110011001 ∗ 2 − 4 0.1001100110011001100110011001100110011001100110011001*2^{-4} 0.1001100110011001100110011001100110011001100110011001∗2−4
- 由于0.1是正数,所以符号位是0;
- 指数是-4,则-4 +1023 = 1019,转为二进制为1111111011,共10位。由于指数E为11位,所以高位补0.最终得出 01111111011;
- 尾数最多存储52位,所以会采取进1舍0的情况:
11001100110011001100110011001100110011001100110011001 // M 舍去首位的 1,得到如下 1001100110011001100110011001100110011001100110011001 // 0 舍 1 入,得到如下: 1001100110011001100110011001100110011001100110011010 // 最终存储
0舍1入法:尾数右移时,被移去的最高位数值为0,则舍去;被移去的最高位数值为1,则在末位加1
所以0.1的最终转换结果为:
S E M
0 01111111011 1001100110011001100110011001100110011001100110011010
那么同理,0.2的最终转换结果为:
S E M
0 01111111100 1001100110011001100110011001100110011001100110011010 // 0.2
浮点数的运算
对阶
在进行结算前,需要判断两个数的指数位是否相同,即小数点位置是否一致。0.1的阶码是-4,而0.2的阶码是-3,根据 小阶向大阶看齐原则,需要将0.1进行移码操作:尾数向右移动1位,指数位+1:
// 0.1 移动之前
0 01111111011 1001100110011001100110011001100110011001100110011010
// 0.1 右移 1 位之后尾数最高位空出一位,(0 舍 1 入,此处舍去末尾 0)
0 01111111100 100110011001100110011001100110011001100110011001101(0)
// 0.1 右移 1 位完成
0 01111111100 1100110011001100110011001100110011001100110011001101
p.s. 不改变最高位值,是 1 补 1,是 0 补 0。尾数部分我们是有隐藏掉最高位是 1 的
尾数求和
0 01111111100 1100110011001100110011001100110011001100110011001101 // 0.1
+ 0 01111111100 1001100110011001100110011001100110011001100110011010 // 0.2
= 0 01111111100 100110011001100110011001100110011001100110011001100111 // 产生进位,待处理
规格化和舍入
由于产生进位,阶码需要 + 1, 所以为 01111111101,对应的十进制为 1021,1021 - 1023 = -2,所以:
S E
= 0 01111111101
尾部进位 2 位,去除最高位默认的 1,因最低位为 1 需进行舍入操作(在二进制中是以 0 结尾的),舍入的方法就是在最低有效位上加 1,若为 0 则直接舍去,若为 1 继续加 1:
100110011001100110011001100110011001100110011001100111 // + 1
= 00110011001100110011001100110011001100110011001101000 // 去除最高位默认的 1
= 00110011001100110011001100110011001100110011001101000 // 最后一位 0 舍去
= 0011001100110011001100110011001100110011001100110100 // 尾数最后结果
IEEE 754 中最终存储如下:
S E M
0 01111111101 0011001100110011001100110011001100110011001100110100
IEEE754转为十进制
根据公式:
n = ( − 1 ) s ∗ 2 ( e − 1023 ) ∗ ( 1 + f ) n = (-1)^s * 2^(e-1023)*(1+f) n=(−1)s∗2(e−1023)∗(1+f)
( − 1 ) 0 ∗ 2 ( − 2 ) ∗ ( 1 + 0011001100110011001100110011001100110011001100110100 ) (-1)^0 * 2(-2) * (1 + 0011001100110011001100110011001100110011001100110100) (−1)0∗2(−2)∗(1+0011001100110011001100110011001100110011001100110100)
最终答案为:
0.30000000000000004
当你打印的时候,其实发生了二进制转为十进制,十进制转为字符串,最后输出的。而十进制转为二进制会发生近似,那么二进制转为十进制也会发生近似,打印出来的值其实是近似过的值,并不是对浮点数存储内容的精确反映。
How does javascript print 0.1 with such accuracy?
精度丢失点
- 十进制转二进制,如果遇到小数是无限循环,超过52位,那么就会被舍入;
- 浮点数参与计算的时候需要对阶,以加法为例,要把小的指数域转化为大的指数域,也就是左移小指数浮点数的小数点,一旦小数点左移,必然会把52位有效域的最右边的位给挤出去,这个时候挤出去的部分也会发生“舍入”。这就又会发生一次精度丢失。
解决思路
- 借助
parseFloat
对结果进行指定精度的四舍五入,但是并不保守210000 * 10000 * 1000 * 8.2 // 17219999999999.998 parseFloat(17219999999999.998.toFixed(12)); // 17219999999999.998 parseFloat(17219999999999.998.toFixed(2)); // 而正确结果为 17220000000000
- 将浮点数转为整数运算,再对结果做除法,目前足够应付大多数场景的思路就是,将小数转化为整数,在整数范围内计算结果,再把结果转化为小数,因为存在一个范围,这个范围内的整数是可以被IEEE754浮点形式精确表示的:
0.1 + 0.2 // 0.30000000000000004 (0.1 * 100 + 0.2 * 100) / 100 // 0.3
- 将浮点数转为字符串,模拟实际运算的过程。
常见的轮子
number-precision
https://github.com/nefe/number-precision
用法
import NP from 'number-precision'
NP.strip(0.09999999999999998); // = 0.1
NP.plus(0.1, 0.2); // = 0.3, not 0.30000000000000004
NP.plus(2.3, 2.4); // = 4.7, not 4.699999999999999
NP.minus(1.0, 0.9); // = 0.1, not 0.09999999999999998
NP.times(3, 0.3); // = 0.9, not 0.8999999999999999
NP.times(0.362, 100); // = 36.2, not 36.199999999999996
NP.divide(1.21, 1.1); // = 1.1, not 1.0999999999999999
NP.round(0.105, 2); // = 0.11, not 0.1
原理
主要就是结合了 parseFloat()
将小数转为了整数。以加法为例子:
function plus(...nums: numType[]): number {
// 如果是多个参数,则递归相加
if (nums.length > 2) {
return iteratorOperation(nums, plus);
}
const [num1, num2] = nums;
// 取两个数当中,小数位长度最大的值的长度
const baseNum = Math.pow(10, Math.max(digitLength(num1), digitLength(num2)));
// 把小数都转为整数然后再计算
return (times(num1, baseNum) + times(num2, baseNum)) / baseNum;
}
- 取两数之中小数最大的小数长度作为基数;
- 将两个数转为整数相加之后然后除以基数。
其中:
function times(...nums: numType[]): number {
// 如果是多个参数,则递归相乘
if (nums.length > 2) {
return iteratorOperation(nums, times);
}
// 将每个变量转为整数并相乘
const [num1, num2] = nums;
const num1Changed = float2Fixed(num1);
const num2Changed = float2Fixed(num2);
const leftValue = num1Changed * num2Changed;
// 检查是否越界,如果越界就报错
checkBoundary(leftValue);
// 获得分母,即Math.pow(10,小数长度的数量)
const baseNum = digitLength(num1) + digitLength(num2);
return leftValue / Math.pow(10, baseNum);
}
float2Fixed
将小数转为整数:
function float2Fixed(num: numType): number {
// 如果不是科学计数法,直接去掉小数点
if (num.toString().indexOf('e') === -1) {
return Number(num.toString().replace('.', ''));
}
// 如果是科学计数法,获得小数的长度
const dLen = digitLength(num);
return dLen > 0 ? strip(Number(num) * Math.pow(10, dLen)) : Number(num);
}
digitLength
计算小数的长度:
// 常见的数字:1、0.1、2.2e-7
// 其中 2.2e-7 实际上就是指 0.00000022
function digitLength(num: numType): number {
// 获取指数前后的数字
const eSplit = num.toString().split(/[eE]/);
// 如果 e 之前是小数,获取小数的数量 + e之后的数量
const len = (eSplit[0].split('.')[1] || '').length - +(eSplit[1] || 0);
// 返回小数的长度
return len > 0 ? len : 0;
}
借助 parseFloat
:
function strip(num: numType, precision = 15): number {
return +parseFloat(Number(num).toPrecision(precision));
}
console.log(strip(0.1 + 0.2)); // 0.3
bignumber.js
https://github.com/MikeMcl/bignumber.js
这位大佬同时还写了 big.js
、decimal.js
等跟计算有关的库。
第一感觉:为啥这么多???
用法
0.3 - 0.1 // 0.19999999999999998
x = new BigNumber(0.3)
x.minus(0.1) // "0.2"
x // "0.3"
原理
先看一下构造函数,emmm…看源码的时候,我其实是这样的:
let x = new BigNumber(123.4567);
console.log(x);
// { c: (2) [123, 45670000000000], e: 2, s: 1 }
let y = BigNumber('123456.7e-3');
console.log(y);
// { c: (2) [123, 45670000000000], e: 2, s: 1 }
加法的实现:
- 先将两个数都转为
BigNumber
类型;以 0.1 和 1.1为例子:{ c: [10000000000000], e: -1, 1 } // 0.1 { c: [1, 25000000000000], e: 0, 1 } // 1.1
- 判断两个数之中是否是
NaN
的,有的话直接返回new BigNumber(NaN)
; - 如果有一方是负数,则调用减法的计算结果;
- 记录
x.e、y.e、x.c、y.c
:var xe = x.e / LOG_BASE, ye = y.e / LOG_BASE, xc = x.c, yc = y.c; console.log(xe, ye, xc, yc); // -0.07142857142857142 0 [10000000000000] (2) [1, 25000000000000] // 其中LOG_BASE = 14;
- 判断 xe、ye中是否有一个为0的时候,根据条件返回不同的值:
if (!xe || !ye) { // ±Infinity if (!xc || !yc) return new BigNumber(a / 0); // Either zero? // Return y if y is non-zero, x if x is non-zero, or zero if both are zero. if (!xc[0] || !yc[0]) return yc[0] ? y : new BigNumber(xc[0] ? x : a * 0); }
- 处理一下
xe
和ye
, 浅拷贝xc
:xe = bitFloor(xe); // -0.07142857142857142 => -1 ye = bitFloor(ye); // 0 => 0 xc = xc.slice();
// n | 0 有省略小数的作用 function bitFloor(n) { var i = n | 0; return n > 0 || n === i ? i : i - 1; } console.log( 104.249834 | 0 ); //104 console.log( 9.999999 | 0 ); // 9
- 根据
ye
和xe
,对xc
和yc
中比较短的一方进行补0操作,所以此时变为:// [10000000000000] xc: [0, 10000000000000] // [1, 25000000000000] yc: [1, 25000000000000]
- 比较
xc
和yc
的长度,确保长度较长的数值放在xc
中; - 遍历相加:
// Only start adding at yc.length - 1 as the further digits of xc can be ignored. for (a = 0; b;) { a = (xc[--b] = xc[b] + yc[b] + a) / BASE | 0; xc[b] = BASE === xc[b] ? 0 : xc[b] % BASE; }
- 最后通过
normalise
整合一下最后的结果,返回一个新的BigNumber
对象。
学习到的几行感觉很有用的代码:
// 将v转为整数比较的最快方法(当v < 2**31 时,比较是否是整数)
v === ~~v
// |0 直接取整数部分
function bitFloor(n) {
var i = n | 0;
return n > 0 || n === i ? i : i - 1;
}
console.log(0.6 | 0); // 0
console.log(1.1 | 0); // 1
console.log(3.6555 | 0); // 3
console.log(-3.6555 | 0); // -3
这个库跟 big.js
的差别在于,后者的API没有前者多,而且不支持十进制以外的计算。
总结
- 当我们在使用
Number
类型时,计算机底层会自动将我们输入的十进制数字自动转为 IEEE754标准的浮点数; - 转浮点数的时候会出现精度丢失的情况,一般是发生在十进制转二进制的时候,或者是浮点数参与计算的时候需要对阶;
- 解决这个问题可以考虑使用parseFloat、浮点转整数、浮点转字符串;
- 业界出名的轮子有 number-precision、bignumber.js等,前者主要借助了parseFloat和浮点转整数的思想,后者是先将数值转为特定的对象,然后进行整数计算。
参考
- 该死的IEEE-754浮点数,说「约」就「约」,你的底线呢?以JS的名义来好好查查你
- IEEE 754浮点数标准中64位浮点数为什么指数偏移量是1023?
- 在线转IEEE-754工具
- How does javascript print 0.1 with such accuracy?
- MDN-toFixed
如果错误,欢迎指出,感谢阅读~