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
}
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342