JS大数运算与精度

起因

最近在项目上涉及到大数的展示,不仅是个大数,还是个小数。然后我们对数字进行验证的时候,发现数字太大了,前端这边根本无法算出正确的结果,而且小数部分还存在精度误差问题。这时候想到了利用 bignumber.js 来解决这个问题;但是我们的系统已经基本进入了后期优化阶段,因为各种原因,这个时候再引入一个新的库有些得不偿失,而且用到的地方就这一个(其他涉及到数字的地方都有专门的方案用来解决精度问题,但是无法解决大数的问题)。所以我就想写个方法专门用来解决这个地方的精度问题以及计算问题。

过程

精度问题

造成精度丢失的原因目前我见过的常见的可能有以下几种:

  1. 后台传过来的就是浮点型,数字太大了,在传输到显示的过程中,哪怕不加任何运算,精度也会丢失;
  2. toFixed()方法造成的精度丢失;
  3. 浮点数加减法造成的精度丢失。

下面我们来分别讨论下这三种问题产生的原因以及解决方法。

大数精度

我们发现在js中,数字一旦超过安全值,就开始变得不再精准,哪怕是简单的加法运算。产生这种问题的原因是js采用的是 IEEE 754 即IEEE二进制浮点数算术标准中的双精度浮点数。何为 IEEE 754?网上已经又很多详细的解释了,这里不再赘述。

js的安全值范围是(-9007199254740991 ~ 9007199254740991)。也就是 -(Math.pow(2, 53) - 1) ~ (Math.pow(2, 53) - 1)。为了避免超出安全值范围导致精度丢失,只需要让后端传String类型即可。

toFixed()

我们先看以下几个toFixed结果。

(1.345).toFixed(2) // 1.34 -- 错误
(1.375).toFixed(2) // 1.38 -- 正确
(1.666).toFixed(2) // 1.67 -- 正确
(1.636).toFixed(2) // 1.64 -- 正确
(1.423).toFixed(2) // 1.42 -- 正确
(1.483).toFixed(2) // 1.48 -- 正确

经过几次试探,我们发现x.toFixed(f)偶尔会发生精度丢失的问题。
现在看看为什么会出现这样的问题。研究了一下ECMA 262中对Number.prototype.toFixed9(fractionDigits)指定的规则。纯英文的,我就不翻译了。涉及到精度的步骤大概是下面这样。

// (1.345).toFixed(2)
// 步骤10.a
134 / Math.pow(10, 2) - 1.345 // -0.004999999999999893
135 / Math.pow(10, 2) - 1.345 // 0.0050000000000001155
// 我们取最接近0的值为 -0.004999999999999893,然后根据步骤10.c得到值为 1.34

// (1.375).toFixed(2)
// 步骤10.a
137 / Math.pow(10, 2) - 1.375 // -0.004999999999999893
138 / Math.pow(10, 2) - 1.375 // 0.004999999999999893
// 两个值的绝对值大小相同,所以我们取较大的值 0.004999999999999893,然后根据步骤10.c得到值为 1.38

*为什么1.345对应的步骤10.a要用134和135?

在规范中没有解释这个n的来源,我根据上下文理解应该是 n = (x * Math.pow(10, f)).toString().split('.')[0],其中x为原值,f为参数;然后又因为四舍五入只可能为当前值或者当前值加1,所以用的是134和135。

显然,根据内部的运算规则,toFixed的精度丢失是不可避免的,所以我们可以通过重写toFixed方法来解决这个问题。

// 未优化
Number.prototype.toFixed = function (f) {
let params = Number(f)
const num = this
if (isNaN(num)) return `${num}` // 处理NaN返回
if (isNaN(params)) params = 0 // 处理参数NaN情况
if (params > 100 || params < 0) throw new RangeError('toFixed() digits argument must be between 0 and 100') // 处理参数大小问题
let temp = num * Math.pow(10, params) // 这里是为了使得需要保留的放在整数位,需要舍去的放在小数位
const tempInteger = temp.toString().split('.')[0] // temp的整数位
const judgeInteger = (temp + 0.5).toString().split('.')[0] // temp + 0.5的整数位
const tempArr = tempInteger.split('')
tempArr.splice(tempArr.length - f, 0, '.')
const judgeArr = judgeInteger.split('')
judgeArr.splice(judgeArr.length - f, 0, '.')
// 判断temp + 0.5之后是否大于temp,大于则说明尾数需要进位,相等则代表不需要
return judgeInteger > tempInteger ? `${judgeArr.join('')}` : `${tempArr.join('')}`
}

浮点数加减

我们经常会遇到这种问题,0.1 + 0.2 !== 0.3。这是因为js在运算的时候会先把数字转换为二进制,但是一些小数转为二进制是无限循环的,所以会造成结果的误差。看以下代码。

(0.1).toString(2)       // 0.0001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 101   --> 对于后三位:1001 最后一个1进位得到 101,即 101
(0.2).toString(2) // 0.0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 01 --> 对于后三位:0011 最后一个1进位得到 010,即 01
(0.3).toString(2) // 0.0100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 11 --> 对于后三位:1100 最后两个0舍去得到 11
(0.1 + 0.2).toString(2) // 0.0100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1101 --> 转换为十进制为 0.30000000000000004

小数转换二进制时的无限循环不可避免。所以我有个想法就是将其转换为字符串,然后按小数点分割成两部分,每部分都一位一位算,最后再将两部分和小数点拼接起来,因为计算的时候都是18以内(为何是18?单位最大为9,9 + 9 = 18)的整数加减法,所以这样可以避免因为小数转二进制而造成的误差。下一节,详细介绍一下这个思路的实现过程。

大数运算(浮点数运算)

现在我们详细介绍一下上一节所说的思路的实现过程。首先我们看加法。

大数加法

在开始以前,我们先做一些准备,考虑一下都有哪些可能性,以及可能出现的BUG。

符号及NaN

先写一个简单的add(x, y)方法。

const add = (x, y) => x + y

通过传不同的参数,可能会出现以下几种情况:

  1. 传入两个非负数,正常计算;
  2. 一正一负,加法变减法;
  3. 均为负数,绝对值加法运算,然后取负;
  4. 一个或多个为非数字,即为NaN,会导致结果出错;
  5. 一个或多个为Boolean类型或者null时,需先转换为其对应的数值再进行计算;

然后我们在add方法里面处理一下这几种情况。

/**
*
* @param {String} x
* @param {String} y
*/
const add = (x = '', y = '') => {
if (Number.isNaN(Number(x)) || Number.isNaN(Number(y))) return x + y // 当一个或多个为非数字,直接拼接字符串

if (typeof x === 'boolean' || x === null) x = Number(x).toString() // 当x为boolean类型或者null时,转换为其对应的数值
if (typeof y === 'boolean' || y === null) y = Number(y).toString() // 当y为boolean类型或者null时,转换为其对应的数值

let calMethood = true // 运算方式,true为加法运算,false为减法运算(一正一负时需要减法运算)
let allAegative = false // 是否需要给结果添加负号,true需要,false不需要
let sum = '' // 和,字符串加减,所以定义为空串
let flag = 0 // 进位标志,加法:当当前位计算大于9时,需要进位,加法进位只可能为0或1,减法:当当前位计算被减数不够减时,需要借位,减法借位只可能为0或-1

// 为了方便一正一负时的减法计算,将x和y存为默认的减数与被减数
let subtracted = x // 被减数,默认为x
let minus = y // 减数,默认为y

if (x.includes('-') && y.includes('-')) { // 全是负数时,计算方法同全正数计算,只需要在最后的结果将负号加上即可,所以在此处将负号删去
allAegative = true
calMethood = true
subtracted = x.split('-')[1]
minus = y.split('-')[1]
} else if (x.includes('-') || y.includes('-')) { // x为负数或y为负数时,执行减法运算,绝对值小的为减数
// 减法运算总是大的减小的
calMethood = false
let tempX = x.split('-')[0] ? x.split('-')[0] : x.split('-')[1]
let tempY = y.split('-')[0] ? y.split('-')[0] : y.split('-')[1]
if (+tempX > +tempY) {
subtracted = tempX
minus = tempY
allAegative = x.includes('-')
} else { // 默认为x - y,如果改为y - x需要给结果添加负号
subtracted = tempY
minus = tempX
allAegative = y.includes('-')
}
}

// todo:计算过程

return Number(x) + Number(y)
}

核心计算过程

处理完了符号,以及可能出现的报错,下面就开始计算部分了。这里采用的是先将字符串用split转换为数组,然后反转数组,使得数组从第零位到最后一位分别对应数字的个位到最大位,最后一位一位计算得到结果。可以写一个方法用来计算。整个实现过程也非常简单

/**
* 数组求和
* @param {Array} arr1 被减数转换的数组
* @param {Array} arr2 减数转换的数组
* @param {String} sum 和
* @param {Number} flag 进位标志
*/
const arrSum = (arr1, arr2, sum, flag) {
// 以位数大的数的长度为标准遍历,其中用到的未定义变量均为上一节中定义的变量
for(let i = 0; i < Math.max(arr1.length, arr2.length); i ++) {
if (calMethood) { // 加法
// 当前位计算,没有则为0,同时加上进位
const temp = (+arr1[i] || 0) + (+arr2[i] || 0) + flag
if (temp < 10) { // 判断是否需要进位
sum = `${temp}${sum}`
flag = 0
} else {
sum = `${temp - 10}${sum}`
flag = 1
}
} else { // 减法
let temp = (arr1[i] || 0) - (arr2[i] || 0) + flag
if ((+arr1[i] || 0) < (+arr2[i] || 0)) { // 被减数太小,需要借位
temp += 10
flag = -1
} else {
flag = 0
}
sum = `${temp}${sum}`
}
}
// 返回flag是为了判断是否有溢出的进位
return {
sum,
flag,
}
}

然后我们在添加一下字符串转换数组的过程。需要注意的是,我们需要特殊考虑一下小数,因为小数的字符串在分割时会将小数点也作为一位分割,所以我们先按小数点分割,将字符串分割为整数和小数两部分。

let integerA = subtracted.split('.')[0].split('').reverse() // 被减数的整数部分的反转数组,方便遍历时从个位开始计算
let decimalA = [] // 被减数的小数部分的反转数组
let integerB = minus.split('.')[0].split('').reverse() // 减数的整数部分的反转数组
let decimalB = [] // 减数的小数部分的反转数组

if (x.includes('.')) { // 是小数再去计算小数部分的数组
decimalA = subtracted.split('.')[1].split('')
}
if (y.includes('.')) {
decimalB = minus.split('.')[1].split('')
}

// 根据小数的特殊性,需要根据两个数字的最长长度去给另一个填充0
for(let i = 0; i < Math.max(decimalA.length, decimalB.length); i ++) {
decimalA[i] = +decimalA[i] || 0
decimalB[i] = +decimalB[i] || 0
}
decimalA = decimalA.reverse()
decimalB = decimalB.reverse()

然后进行计算,先算小数后算整数

decimalA = decimalA.reverse()
decimalB = decimalB.reverse()
const decimalAns = arrSum(decimalA, decimalB, sum, flag)
sum = decimalAns.sum.replace(/0*$/, '') // 去除小数部分末尾的0
flag = decimalAns.flag

// 小数部分计算不为空,则添加小数点
if (sum !== '') sum = `.${sum}`

const integerAns = arrSum(integerA, integerB, sum, flag)
sum = integerAns.sum
flag = integerAns.flag
// 进位溢出,前面再添加一位
if (flag !== 0) {
sum = `${flag}${sum}`
}
sum = sum.replace(/^0*/, '') // 去除最左侧的0

然后最后只需要将最后的sum和符号拼起来就是最终的结果。

return allAegative ? `-${sum}` : sum

大数减法

减法与加法类似,且在上面的过程中,已经有了一个雏形。
比如说 x - y可以看成是 x + (-y),所以就有了一个思路是,增加一个参数用来判断是否是减法,如果是减法就给y值取反,然后仍然进行加法运算。

/**
*
* @param {Number} x
* @param {Number} y
* @param {String} methood
*/
const add = (x, y, methood = '+') => {
y = methood === '-' ? -y : y
return x + y
}

add(2, 3) // 5
add(2, 3, '-') // -1
add(2, -3, '-') // 5

参照这个思路,我们可以在已经写好的加法上稍作改造,加以下几行代码。

/**
*
* @param {String} x
* @param {String} y
* @param {String} methood
*/
const add = (x = '', y = '', methood = '+') => {
if (methood === '-') {
b = b.includes('-') ? b.split('-')[1] : `-${b}`
}
// ---
}

总结

市面上已经有非常成熟的解决方案了,我这就是属于重复造轮子了,纯当学习.

参考

双精度浮点数
ECMAScript (ECMA-262)

源码

/**
* 计算大数
* @param {String} a
* @param {String} b
* @param {String} mthood 运算方式
*/
const addLargeNumber = (a = '', b = '', methood = '+') => {
// 传小数进行计算在toString的时候就会丢失精度,太大的时候一拿到就已经没有精度了。。
if (Number.isNaN(Number(a)) || Number.isNaN(Number(b))) return a + b
if (methood === '-') {
b = b.includes('-') ? b.split('-')[1] : `-${b}`
}
let calMethood = true // 运算方式,true为加法运算,false为减法运算
let allAegative = false // 是否需要加负号
let subtracted = a // 被减数,默认为a
let minus = b // 减数,默认为b

if (a.includes('-') && b.includes('-')) { // 全是负数时,计算方法同全正数计算,只需要在最后的结果将负号加上即可,所以在此处将负号删去
allAegative = true
calMethood = true
subtracted = a.split('-')[1]
minus = b.split('-')[1]
} else if (a.includes('-') || b.includes('-')) { // a为负数或b为负数时,执行减法运算,绝对值小的为减数
// 减法运算总是大的减小的
calMethood = false
let tempX = a.split('-')[0] ? a.split('-')[0] : a.split('-')[1]
let tempY = b.split('-')[0] ? b.split('-')[0] : b.split('-')[1]
console.log(+tempX, +tempY, +tempX > +tempY)
if (+tempX > +tempY) {
subtracted = tempX
minus = tempY
allAegative = a.includes('-')
} else { // 默认为x - y,如果改为y - x需要给结果添加负号
subtracted = tempY
minus = tempX
allAegative = b.includes('-')
}

}
let integerA = subtracted.split('.')[0].split('').reverse() // 被减数的整数部分的反转数组,方便遍历时从个位开始计算
let decimalA = [] // 被减数的小数部分的反转数组
let integerB = minus.split('.')[0].split('').reverse() // 减数的整数部分的反转数组
let decimalB = [] // 减数的小数部分的反转数组


let flag = 0 // 进位标志,当当前位计算大于9时,需要进位,加法进位只可能为0或1
let sum = '' // 和


if (a.includes('.')) { // 是小数再去计算小数部分的数组
decimalA = subtracted.split('.')[1].split('')
}
if (b.includes('.')) {
decimalB = minus.split('.')[1].split('')
}


// 根据小数的特殊性,需要根据两个数字的最长长度去给另一个填充0
for(let i = 0; i < Math.max(decimalA.length, decimalB.length); i ++) {
decimalA[i] = +decimalA[i] || 0
decimalB[i] = +decimalB[i] || 0
}
decimalA = decimalA.reverse()
decimalB = decimalB.reverse()
const decimalAns = arrSum(decimalA, decimalB, sum, flag)
sum = decimalAns.sum.replace(/0*$/, '') // 去除小数部分末尾的0
flag = decimalAns.flag
// 小数部分计算不为空,则添加小数点
if (sum !== '') sum = `.${sum}`
const integerAns = arrSum(integerA, integerB, sum, flag)
sum = integerAns.sum
flag = integerAns.flag
if (flag !== 0) {
sum = `${flag}${sum}`
}
sum = sum.replace(/^0*/, '') || '0' // 去除最左侧的0,同时避免因结果是0而产生空串
/**
*
* @param {Array} arr1 被减数转换的数组
* @param {Array} arr2 减数转换的数组
* @param {String} sum 和
* @param {Number} flag 进位标志
*/
function arrSum(arr1, arr2, sum, flag) {
for(let i = 0; i < Math.max(arr1.length, arr2.length); i ++) {
if (calMethood) { // 加法
const temp = (+arr1[i] || 0) + (+arr2[i] || 0) + flag
if (temp < 10) {
sum = `${temp}${sum}`
flag = 0
} else {
sum = `${temp - 10}${sum}`
flag = 1
}
} else { // 减法
let temp = (+arr1[i] || 0) - (+arr2[i] || 0) + flag
if ((arr1[i] || 0) < (arr2[i] || 0)) { // 被减数太小,需要借位
temp += 10
flag = -1
} else {
flag = 0
}
sum = `${temp}${sum}`
}
}
return {
sum,
flag,
}
}
return allAegative ? `-${sum}` : sum
}
文章作者: JaCo Wu
文章链接: https://jacokwu.cn/blog/2020/06/13/JS大数运算与精度/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 JaCo Wu的博客