浮点数运算不精准的解决办法

最近在红包小程序中,遇见一个很神奇的问题。在控制输入的数字最多只能包含两位小数的时候,输入 2.3 竟然会得到 2.29 。具体代码如下:

1
2
3
4
5
6
let temp = e.detail.value > parseInt(e.detail.value * 100) / 100 ? parseInt(e.detail.value * 100) / 100 : e.detail.value;
let tips = Number(temp) > parseInt(Number(temp) * 10) / 10 ? parseInt(Number(temp) * 10) / 10 : Number(temp);
this.setData({
orderValue: e.detail.value == '' ? '0.00' : temp,
tip: e.detail.value == '' ? '0.00' : parseFloat(tips*10, 10) / 100
})

这段代码的核心是判断 e.detail.value 的值是否多于两位小数,如果是的话,就舍去后面输入的数字,保持两位小数,并且把这个数赋值为 temp;再判断 temp 是否是两位小数,如果是,则舍去第二位小数后小数点左移一位,使得 tipstemp 的十分之一且最多只有两位小数。我的处理方式是,用 parseInt(e.detail.value * 100) 使得获得小数点右移两位的值,再将小数点左移两位或者截取到两位小数的数。然而问题在于,当 parseInt(2.3 * 100) 的时候,会得到 229 ,而不是总所周知的 230 ;而且,对于几乎做了一模一样操作的 tips 却没有任何影响。

查阅资料之后,发现,这是浮点数运算的问题。如果改用 parseFloat(e.detail.value * 100) 就能得到预期的 2.3 ,然而, parseFloat(e.detail.value * 100) 并不能解决保留两位小数的问题。

最终,我的解决方法是,用字符串截取的方式,解决了这个问题。代码如下:

1
2
3
4
5
6
7
8
9
let temp = e.detail.value;
// 保证 temp 的值是输入数字最多保留前两位小数的形式
temp = e.detail.value > parseInt(e.detail.value * 100) / 100 ? temp.split('.')[0] + '.' + temp.split('.')[1].substr(0,2) : e.detail.value;
// 使得 tips 为输入的数字最多保留一位小数的形式
let tips = Number(temp) > parseInt(Number(temp) * 10) / 10 ? parseInt(Number(temp) * 10) / 10 : Number(temp);
this.setData({
orderValue: e.detail.value == '' ? '0.00' : temp,
tip: e.detail.value == '' ? '0.00' : parseFloat(tips * 10, 10) / 100
})

然而,这并没有结束……

浮点数计算不精准的由来

遇见问题不能因为避开原有的陷阱解决了问题就以为万事已经解决妥当了。我们得看看,是什么导致 2.3 / 10 离奇变成 2.29,是什么让 2.3 * 0.1 也失去了梦想,又是什么让 2.3 * 10 / 100 坚守初心,究竟是什么让四则运算偏离公知轨道。让我们走进这一期的《走近科学》——

浮点数计算不精准,其实是 JavaScript 内部的问题。因为 Javascript 中所有的数字都是以 IEEE-754 标准格式表示的,其中的小数用的是二进制表示,这就导致了有些小数以二进制表示位数是无穷的。比如说,上述说的 0.3 ,它的二进制表示是:0.0100 1100 1100 1100 …

因此,对于某些浮点数的表示, JavaScript 只能保证尽量的接近,而不能保证完全等于,更何况说对于浮点数的计算了。

解决办法

所幸的是,尽管 JavaScript 对于某些浮点数的表示并不能完全等于,可是它的计算是可靠的。这点在于 JavaScript 对于整数的计算都是精准的可以看出。我们再来看看上述例子的解决方案:

1
2
3
4
5
6
7
8
9
let temp = e.detail.value;
// 保证 temp 的值是输入数字最多保留前两位小数的形式
temp = e.detail.value > parseInt(e.detail.value * 100) / 100 ? temp.split('.')[0] + '.' + temp.split('.')[1].substr(0,2) : e.detail.value;
// 使得 tips 为输入的数字最多保留一位小数的形式
let tips = Number(temp) > parseInt(Number(temp) * 10) / 10 ? parseInt(Number(temp) * 10) / 10 : Number(temp);
this.setData({
orderValue: e.detail.value == '' ? '0.00' : temp,
tip: e.detail.value == '' ? '0.00' : parseInt(tips * 10, 10) / 100
})

对于 tips ,它依然是用了 parseInt() 这个方法,和一开始对 temp 的操作不同在于,tips 最多只有一位小数,而在而 parseInt(tips * 10, 10) 函数参数中,正好将 parseInt(tips * 10, 10) / 100 的被除数转化成整数,从而使得整个式子变成精准的整数之间的计算。

因此,对于浮点数计算不精准的解决方案,已经有了一个思路了——那就是先让浮点数先乘以 10 的 n 次方,使得浮点数能够化为整数,在进行运算,最终将运算结果除以 10 的 n 次方,使得运算过程都是整数间的运算,保证了运算的精准。

在小项目中,可以通过这个思路来避免 JavaScript 浮点数运算不精准的问题,而在大项目中,当然更适合封装好的方法。

以下是封装好的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85

// 实现得到精确乘法的方法
function newMul(arg1, arg2) {
let t1 = 0, t2 = 0, num1, num2;
console.log(arg1, arg2);
// 防止没有小数部分等意外情况
// 得到两数小数部分的长度
try{
t1 = arg1.toString().split(".")[1].length;
} catch(e) {}
try{
t2 = arg2.toString().split(".")[1].length;
} catch(e) {}
// 使这两位数等同于分别乘他们小数的位数,得到一个整数
// 比如 2.33 就乘 100 得到 233
num1 = Number(arg1.toString().replace(".",""));
num2 = Number(arg2.toString().replace(".",""));
// 返回运算后的值
// 其中,除去了之前乘上的数
return num1 * num2 / Math.pow(10, t1 + t2);
}

// 实现得到精确除法的方法
function newDiv(arg1, arg2) {
let t1 = 0, t2 = 0, num1, num2;
try{
t1 = arg1.toString().split(".")[1].length;
} catch(e) {}
try{
t2 = arg2.toString().split(".")[1].length;
} catch(e) {}
num1 = Number(arg1.toString().replace(".",""));
num2 = Number(arg2.toString().replace(".",""));
return num1 / num2 * Math.pow(10, t1 - t2);
}

// 实现得到精确加法的方法
function newAdd(arg1, arg2) {
let t1 = 0, t2 = 0;
try{
t1 = arg1.toString().split(".")[1].length;
} catch(e) {}
try{
t2 = arg2.toString().split(".")[1].length;
} catch(e) {}
// 得到能让两数小数点右移乘整数的最小值
e = Math.pow(10, Math.max(t1, t2));
// 两数都乘这个值,使得两数都是整数方便运算,再除以这个值
return (arg1 * e + arg2 * e) / e;
}

// 实现得到精确减法的方法
function newSub(arg1, arg2) {
let t1 = 0, t2 = 0;
try{
t1 = arg1.toString().split(".")[1].length;
} catch(e) {}
try{
t2 = arg2.toString().split(".")[1].length;
} catch(e) {}
e = Math.pow(10, Math.max(t1, t2));
return Number(((arg1 * e - arg2 *e) / e));
}

// 减法函数也可以表示成
function anotherSub(arg1, arg2) {
return newAdd(arg1, -arg2);
}

// 将上述方法封装为 Number 类型的方法,方便调用
Number.prototype.mul = function(arg1, arg2) {
return newMul.call(this, arg1, arg2);
}

Number.prototype.div = function(arg1, arg2) {
return newDiv.call(this, arg1, arg2);
}

Number.prototype.add = function(arg1, arg2) {
return newAdd.call(this, arg1, arg2);
}

Number.prototype.sub = function(arg1, arg2) {
return newSub.call(this, arg1, arg2);
}

总结

本篇是对红包小程序某个 bug 的后续思考以及解决的过程。本篇的重点:JavaScript 某些浮点数运算不精准是因为 JavaScript 对于浮点数的表示本身就不精准导致的。解决办法就是:用把浮点数转化成整数的形式,进行运算,得到结果后除去所增加的倍数,最终得到原来运算的精准结果。