JavaScript 数学计算中的浮点数精度问题
在 JavaScript 中,使用 Number 类型进行数学计算时,常常会遇到浮点数不精确的问题。这一问题在涉及到金额计算、金融系统(如银行或比特币)时,会造成严重的错误,必须被妥善解决。
为什么在JavaScript浮点数计算不精确?
JavaScript 中的 Number 类型遵循 IEEE 754 双精度浮点数标准,即 64 位双精度格式(64-bit double-precision)。
IEEE 754 是一种二进制浮点数算术标准,规定了四种表示浮点数值的方式:单精度(32位)、双精度(64位)、扩展单精度和扩展双精度。JavaScript 中的 Number 类型遵循 IEEE 754 标准的双精度(64位)浮点数表示。
在浮点数运算中产生误差值的示例中,最出名应该是0.1 + 0.2 === 0.30000000000000004了。也就是说不仅是JavaScript会产生这种问题,只要是采用IEEE 754 Floating-point的浮点数编码方式来表示浮点数时,则会产生这类问题。
IEEE 754 存储方式
部分 | 位数 | 功能 |
---|---|---|
符号位 | 1 | 决定数字的正负 |
指数位 | 11 | 表示数值范围(2 的幂) |
尾数(有效位) | 52 | 用于存储数值的精确部分 |
由于浮点数是以二进制存储的,而许多十进制小数无法用有限的二进制位数精确表示。例如:
- 十进制的 0.1 表示为二进制是一个无限循环小数:
0.1 = 0.000110011001100110011001100...
这种有限精度存储导致了 舍入误差,从而造成不精确的问题。
计算公式
根据 IEEE 754 标准,双精度浮点数的值可以通过以下公式计算:
Value=(−1)sign×(1+fraction)×2(exponent−1023)
- sign:符号位的值,0 或 1。
- fraction:尾数部分的值,计算时需要加上省略的首位 1。
- exponent:指数部分的值,计算时需要减去偏移值 1023。
假设有一个双精度浮点数的二进制表示为:
0 10000000011 1011001100110011001100110011001100110011001100110011
根据存储格式,可以将其分为三部分:
- 符号位:0(正数)
- 指数位:10000000011(十进制为 1027)
- 尾数位:1011001100110011001100110011001100110011001100110011
根据计算公式:
- sign = 0 符号=0
- fraction = 1 + 0.101100110011001100110011001100110011001100110011(二进制小数转换为十进制)
- exponent = 1027 - 1023 = 4
因此,该浮点数的值为:
Value=(−1)0×(1+0.6999999999999999)×2(4)
=1.6999999999999999×16=27.199999999999996
浮点数计算中的常见问题
console.log(0.1 + 0.2); // 输出:0.30000000000000004
console.log(0.3 - 0.1); // 输出:0.19999999999999998
console.log(0.1 * 0.2); // 输出:0.020000000000000004
console.log(0.3 / 0.1); // 输出:2.9999999999999996
这在金融场景(如金额计算)中是不可接受的。
解决办法
1. 使用整数运算代替浮点数运算
思路:将小数转换为整数进行计算,再通过除法将结果还原 适合一些金额计算、普通的小数运算。
function addition(a, b) {
const factor = 10 ** Math.max(getDecimalPlaces(a), getDecimalPlaces(b));
return (a * factor + b * factor) / factor;
}
function subtraction(a, b) {
const factor = 10 ** Math.max(getDecimalPlaces(a), getDecimalPlaces(b));
return (a * factor - b * factor) / factor;
}
function multiplication(a, b) {
const factor = 10 ** Math.max(getDecimalPlaces(a), getDecimalPlaces(b));
return (a * factor) * (b * factor) / factor;
}
function division(a, b) {
const factor = 10 ** Math.max(getDecimalPlaces(a), getDecimalPlaces(b));
return (a * factor) / (b * factor) / factor;
}
function getDecimalPlaces(num) {
const parts = num.toString().split(".");
return parts[1] ? parts[1].length : 0;
}
console.log(addition(0.1,0.2)); // 输出:0.3
console.log(subtraction(0.3,0.1)); // 输出:0.2
console.log(multiplication(0.1,0.2)); // 输出:0.2
console.log(division(0.3,0.1)); // 输出:0.3
2. 使用第三方库
Big.js
提供了高精度的小数计算支持。 体积小(约 8KB),适合浏览器和 Node.js 环境
const Big = require("big.js");
let result = new Big(0.1).plus(0.2); // 精确加法
console.log(result.toString()); // 输出:0.3
result = new Big(0.3).minus(0.1); // 精确减法
console.log(result.toString()); // 输出:0.2
result = new Big(0.1).times(0.2); // 精确乘法
console.log(result.toString()); // 输出:0.02
Decimal.js
支持任意精度的十进制运算。 提供更多高级数学功能(如开方)。
const Decimal = require("decimal.js");
let result = new Decimal(0.1).plus(0.2);
console.log(result.toString()); // 输出:0.3
result = new Decimal(0.3).dividedBy(0.1);
console.log(result.toString()); // 输出:3
保留有效位数时的四舍五入问题
在处理金融保险等涉及金额,关系到经济利益、涉及排名保留几位小数进行四舍五入前端的toFixed是不准确的
oFixed() 方法在进行四舍五入时可能会产生一些不准确的结果,这是因为 toFixed() 方法在处理浮点数时,实际上使用的是舍入到最近的偶数(银行家舍入)策略,而不是标准的四舍五入。这种舍入策略是为了在大量运算时减小累积误差。
我们理解的四舍五入:
console.log((2.123).toFixed(2)) // 输出:'2.12'
console.log((2.125).toFixed(2)) // 输出:'2.13'
console.log((1.55).toFixed(1)) // 输出:'1.6'
其实得到的是
console.log((2.005).toFixed(2)) // 输出:'2.00'
console.log((1.45).toFixed(1)) // 输出:'1.4'
解决办法
自定义函数将结果四舍五入到指定的小数位,从而避免浮点数计算的误差影响。
function preciseRound(num, decimalPlaces) {
const factor = Math.pow(10, decimalPlaces);
return Math.round((num + Number.EPSILON) * factor) / factor;
}
const num = 1.335;
const rounded = preciseRound(num, 2); // 1.34
使用 toLocaleString
const num = 123.45678;
const formattedNum = num.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
console.log(formattedNum); // 输出 "123.46"