先看两个简单但诡异的代码:
0.1 + 0.2 > 0.3 // true
0.1 * 0.1 = 0.010000000000000002
0.1 加 0.2 为什么就不等于 0.3 呢?要回答这个问题,得先了解计算机内部是如何表示数的。
计算机内部如何表示数
我们都知道,计算机用位来储存及处理数据。每一个二进制数(二进制串)都一一对应一个十进制数。
1. 计算机内部如何表示整数
**
这里以十进制数 13 来展示“按位计数法”如何表示整数:
十进制值 | 进制 | 按位格式 | 描述 |
---|---|---|---|
13 | 10 | 13 | 1x10^1 + 3x10^0 = 10 + 3 |
13 | 2 | 1101 | 1x2^3 + 1x2^2 + 0x2^1 + 1x2^0 = 8 + 4 + 0 + 1 |
2. 计算机内部如何表示小数
**
再看小数怎么用按位计数法表示,以十进制数 0.625 为例:
十进制值 | 进制 | 按位格式 | 描述 |
---|---|---|---|
0.625 | 10 | 0.625 | 6x10^-1 + 2x10^-2 + 5x10^-3 = 0.6 + 0.02 + 0.005 |
0.625 | 2 | 0.101 | 1x2^-1 + 0 x2^-2 + 1x2^-3 = 1/2 + 0 + 1/8 |
3. 如何用二进制表示 0.1
**
关于十进制与二进制间如何转换,这里不细说,直接给出结论:
十进制整数转二进制方法:除 2 取余;十进制小数转二进制方法:乘 2 除去整
**
用 2 乘十进制小数,可以得到积,将积的整数部分取出,再用 2 乘余下的小数部分,又得到一个积,再将积的整数部分取出,如此进行,直到积中的小数部分为零,或者达到所要求的精度为止
十进制 0.1 转换成二进制,乘 2 取整过程:
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.1 的二进制格式是:0.0001100011....
,这是一个二进制无限循环小数,但计算机内存有限,我们不能用储存所有的小数位数。那么在精度与内存间如何取舍呢?
答案是:在某个精度点直接舍弃。当然,代价就是,0.1 在计算机内部根本就不是精确的 0.1,而是一个有舍入误差的 0.1。当代码被编译或解释后,0.1 已经被四舍五入成一个与之很接近的计算机内部数字,以至于计算还没开始,一个很小的舍入错误就已经产生了。这也就是 0.1 + 0.2 不等于 0.3 的原因。
有误差的两个数,其计算的结果,当然就很可能与我们期望的不一样了。注意前面的这句话中的“很可能”这三个字?为啥是很可能昵?
0.1 + 0.1 为什么等于0.2
答案是:两个有舍入误差的值在求和时,相互抵消了,但这种“负负得正,相互抵消”不一定是可靠的,当这两个数字是用不同长度数位来表示的浮点数时,舍入误差可能不会相互抵消。
又如,对于 0.1 + 0.3 ,结果其实并不是0.4,但 0.4 是最接近真实结果的数,比其它任何浮点数都更接近。许多语言也就直接显示结果为 0.4 了,而不展示一个浮点数的真实结果了。
另外要注意,二进制能精确地表示位数有限且分母是 2 的倍数的小数,比如 0.5,0.5 在计算机内部就没有舍入误差。所以 0.5 + 0.5 === 1
计算机这样胡乱舍入,能满足所有的计算需求吗
我们看两个现实的场景:
- 对于一个修建铁路的工程师而言,10 米宽,还是 10.0001 米宽并没有什么不同。铁路工程师就不需要这么高 0.x 这样的精度
- 对于芯片设计师,0.0001米就会是一个巨大不同,他也永远不用处理超过 0.1 米距离
不同行业,要求的精度不是线性的,我们允许(对结果无关紧要的)误差存在。10.0001 与 10.001在铁路工程师看来都是合格的。
虽然允许误差存在,但程序员在使用浮点数进行计算或逻辑处理时,不注意,就可能出问题。记住,永远不要直接比较两个浮点的大小:
var a = 0.1
var b = 0.2
if (a + b === 0.3) {
// doSomething
}
JS中如何进入浮点数运算
1.将浮点运算转换成整数计算
整数是完全精度的,不存在舍入误差。例如,一些关于人民币的运算,都会以分为基本单位,计算采用分,展示再转换成元。当然,这样也有一些问题,会带来额外的工作量,如果那天人民币新增了一个货币单位,对系统的扩展性也会有考验。
2.使用 Number.EPSILON
可以设置一个误差范围值,通常称为”机器精度“,而对于 Javascript 来说,这个值通常是 2^-52
,而在 ES6 中,已经为我们提供了这样一个属性:Number.EPSILON
,而这个值正等于 2^-52
。这个值非常非常小,在底层计算机已经帮我们运算好,并且无限接近 0,但不等于 0,这个时候我们只要判断 (0.1+0.2)-0.3
小于Number.EPSILON
,在这个误差的范围内就可以判定 0.1+0.2===0.3
为 true。
function numbersequal(a,b){
return Math.abs(a - b) < Number.EPSILON;
}
var a = 0.1 + 0.2, b = 0.3;
console.log(numbersequal(a,b)); //true
但是这里要考虑兼容性的问题了,在 chrome 中支持这个属性,但是 IE 并不支持(笔者的版本是IE10不兼容),所以我们还要解决 IE 的不兼容问题。
Number.EPSILON = (function(){
//解决兼容性问题
return Number.EPSILON ? Number.EPSILON : Math.pow(2, -52);
})();
// 上面是一个自调用函数,当JS文件刚加载到内存中,就会去判断并返回一个结果
function numbersequal(a,b){
return Math.abs(a - b) < Number.EPSILON;
}
// 接下来再判断
var a = 0.1 + 0.2, b = 0.3;
console.log(numbersequal(a,b)); //这里就为 true 了
3.使用bignumber进行运算
bignumber.js 会在一定精度内,让浮点数计算结果符合我们的期望。
{
let x = new BigNumber(0.1);
let y = new BigNumber(0.2)
let z = new BigNumber(0.3)
console.log(z.equals(x.add(y))) // 0.3 === 0.1 + 0.2, true
console.log(z.minus(x).equals(y)) // true
console.log(z.minus(y).equals(x)) // true
}
{
let x = 0.2
console.log(x * x === 0.04) // false
let y = new BigNumber(0.2)
let r = y.mul(y) // 0.04
console.log(r.equals(new BigNumber(0.04))) // true
}
更多例子,可以看 bignumber.js 官方示例。
小结
本文主要介绍了浮点数计算问题,简单回答了为什么以及怎么办两个问题:
- 为什么 0.1 + 0.2 不等于 0.3。因为计算机不能精确表示 0.1, 0.2 这样的浮点数,计算时使用的是带有舍入误差的数
- 并不是所有的浮点数在计算机内部都存在舍入误差,比如 0.5 就没有舍入误差
- 具有舍入误差的运算结可能会符合我们的期望,原因可能是“负负得正”
- 怎么办?一是使用整型代替浮点数计算;二是用 Number.EPSILON;三是不要直接比较两个浮点数,而应该使用 bignumber.js 这样的浮点数运算库