起因
最近在項(xiàng)目上涉及到大數(shù)的展示,不僅是個(gè)大數(shù)垃喊,還是個(gè)小數(shù)猾普。然后我們對(duì)數(shù)字進(jìn)行驗(yàn)證的時(shí)候,發(fā)現(xiàn)數(shù)字太大了本谜,前端這邊根本無(wú)法算出正確的結(jié)果初家,而且小數(shù)部分還存在精度誤差問(wèn)題。這時(shí)候想到了利用 bignumber.js 來(lái)解決這個(gè)問(wèn)題乌助;但是我們的系統(tǒng)已經(jīng)基本進(jìn)入了后期優(yōu)化階段溜在,因?yàn)楦鞣N原因,這個(gè)時(shí)候再引入一個(gè)新的庫(kù)有些得不償失他托,而且用到的地方就這一個(gè)(其他涉及到數(shù)字的地方都有專(zhuān)門(mén)的方案用來(lái)解決精度問(wèn)題掖肋,但是無(wú)法解決大數(shù)的問(wèn)題)。所以我就想寫(xiě)個(gè)方法專(zhuān)門(mén)用來(lái)解決這個(gè)地方的精度問(wèn)題以及計(jì)算問(wèn)題赏参。
過(guò)程
精度問(wèn)題
造成精度丟失的原因目前我見(jiàn)過(guò)的常見(jiàn)的可能有以下幾種:
- 后臺(tái)傳過(guò)來(lái)的就是浮點(diǎn)型志笼,數(shù)字太大了,在傳輸?shù)斤@示的過(guò)程中把篓,哪怕不加任何運(yùn)算纫溃,精度也會(huì)丟失;
-
toFixed()
方法造成的精度丟失; - 浮點(diǎn)數(shù)加減法造成的精度丟失。
下面我們來(lái)分別討論下這三種問(wèn)題產(chǎn)生的原因以及解決方法韧掩。
大數(shù)精度
我們發(fā)現(xiàn)在js中紊浩,數(shù)字一旦超過(guò)安全值,就開(kāi)始變得不再精準(zhǔn)疗锐,哪怕是簡(jiǎn)單的加法運(yùn)算坊谁。產(chǎn)生這種問(wèn)題的原因是js采用的是 IEEE 754 即IEEE二進(jìn)制浮點(diǎn)數(shù)算術(shù)標(biāo)準(zhǔn)中的雙精度浮點(diǎn)數(shù)。何為 IEEE 754滑臊?網(wǎng)上已經(jīng)又很多詳細(xì)的解釋了口芍,這里不再贅述。
js的安全值范圍是(-9007199254740991 ~ 9007199254740991)简珠。也就是 -(Math.pow(2, 53) - 1) ~ (Math.pow(2, 53) - 1)
阶界。為了避免超出安全值范圍導(dǎo)致精度丟失,只需要讓后端傳String類(lèi)型即可聋庵。
toFixed()
我們先看以下幾個(gè)toFixed結(jié)果膘融。
(1.345).toFixed(2) // 1.34 -- 錯(cuò)誤
(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 -- 正確
經(jīng)過(guò)幾次試探,我們發(fā)現(xiàn)x.toFixed(f)
偶爾會(huì)發(fā)生精度丟失的問(wèn)題祭玉。
現(xiàn)在看看為什么會(huì)出現(xiàn)這樣的問(wèn)題氧映。研究了一下ECMA 262中對(duì)Number.prototype.toFixed9(fractionDigits)
指定的規(guī)則。純英文的脱货,我就不翻譯了岛都。涉及到精度的步驟大概是下面這樣律姨。
// (1.345).toFixed(2)
// 步驟10.a
134 / Math.pow(10, 2) - 1.345 // -0.004999999999999893
135 / Math.pow(10, 2) - 1.345 // 0.0050000000000001155
// 我們?nèi)∽罱咏?的值為 -0.004999999999999893,然后根據(jù)步驟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
// 兩個(gè)值的絕對(duì)值大小相同臼疫,所以我們?nèi)≥^大的值 0.004999999999999893择份,然后根據(jù)步驟10.c得到值為 1.38
*為什么1.345對(duì)應(yīng)的步驟10.a要用134和135?
在規(guī)范中沒(méi)有解釋這個(gè)n的來(lái)源烫堤,我根據(jù)上下文理解應(yīng)該是 n = (x * Math.pow(10, f)).toString().split('.')[0]
荣赶,其中x為原值,f為參數(shù)鸽斟;然后又因?yàn)樗纳嵛迦胫豢赡転楫?dāng)前值或者當(dāng)前值加1拔创,所以用的是134和135。
顯然富蓄,根據(jù)內(nèi)部的運(yùn)算規(guī)則剩燥,toFixed的精度丟失是不可避免的,所以我們可以通過(guò)重寫(xiě)toFixed方法來(lái)解決這個(gè)問(wèn)題立倍。
// 未優(yōu)化
Number.prototype.toFixed = function (f) {
let params = Number(f)
const num = this
if (isNaN(num)) return `${num}` // 處理NaN返回
if (isNaN(params)) params = 0 // 處理參數(shù)NaN情況
if (params > 100 || params < 0) throw new RangeError('toFixed() digits argument must be between 0 and 100') // 處理參數(shù)大小問(wèn)題
let temp = num * Math.pow(10, params) // 這里是為了使得需要保留的放在整數(shù)位灭红,需要舍去的放在小數(shù)位
const tempInteger = temp.toString().split('.')[0] // temp的整數(shù)位
const judgeInteger = (temp + 0.5).toString().split('.')[0] // temp + 0.5的整數(shù)位
const tempArr = tempInteger.split('')
tempArr.splice(tempArr.length - f, 0, '.')
const judgeArr = judgeInteger.split('')
judgeArr.splice(judgeArr.length - f, 0, '.')
// 判斷temp + 0.5之后是否大于temp,大于則說(shuō)明尾數(shù)需要進(jìn)位帐萎,相等則代表不需要
return judgeInteger > tempInteger ? `${judgeArr.join('')}` : `${tempArr.join('')}`
}
浮點(diǎn)數(shù)加減
我們經(jīng)常會(huì)遇到這種問(wèn)題比伏,0.1 + 0.2 !== 0.3
。這是因?yàn)閖s在運(yùn)算的時(shí)候會(huì)先把數(shù)字轉(zhuǎn)換為二進(jìn)制疆导,但是一些小數(shù)轉(zhuǎn)為二進(jìn)制是無(wú)限循環(huán)的,所以會(huì)造成結(jié)果的誤差葛躏〕憾危看以下代碼。
(0.1).toString(2) // 0.0001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 101 --> 對(duì)于后三位:1001 最后一個(gè)1進(jìn)位得到 101,即 101
(0.2).toString(2) // 0.0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 01 --> 對(duì)于后三位:0011 最后一個(gè)1進(jìn)位得到 010,即 01
(0.3).toString(2) // 0.0100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 11 --> 對(duì)于后三位:1100 最后兩個(gè)0舍去得到 11
(0.1 + 0.2).toString(2) // 0.0100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1101 --> 轉(zhuǎn)換為十進(jìn)制為 0.30000000000000004
小數(shù)轉(zhuǎn)換二進(jìn)制時(shí)的無(wú)限循環(huán)不可避免舰攒。所以我有個(gè)想法就是將其轉(zhuǎn)換為字符串败富,然后按小數(shù)點(diǎn)分割成兩部分,每部分都一位一位算摩窃,最后再將兩部分和小數(shù)點(diǎn)拼接起來(lái)兽叮,因?yàn)橛?jì)算的時(shí)候都是18以?xún)?nèi)(為何是18?單位最大為9猾愿,9 + 9 = 18)的整數(shù)加減法鹦聪,所以這樣可以避免因?yàn)樾?shù)轉(zhuǎn)二進(jìn)制而造成的誤差。下一節(jié)蒂秘,詳細(xì)介紹一下這個(gè)思路的實(shí)現(xiàn)過(guò)程泽本。
大數(shù)運(yùn)算(浮點(diǎn)數(shù)運(yùn)算)
現(xiàn)在我們?cè)敿?xì)介紹一下上一節(jié)所說(shuō)的思路的實(shí)現(xiàn)過(guò)程。首先我們看加法姻僧。
大數(shù)加法
在開(kāi)始以前规丽,我們先做一些準(zhǔn)備蒲牧,考慮一下都有哪些可能性,以及可能出現(xiàn)的BUG赌莺。
符號(hào)及NaN
先寫(xiě)一個(gè)簡(jiǎn)單的add(x, y)
方法冰抢。
const add = (x, y) => x + y
通過(guò)傳不同的參數(shù),可能會(huì)出現(xiàn)以下幾種情況:
- 傳入兩個(gè)非負(fù)數(shù)艘狭,正常計(jì)算挎扰;
- 一正一負(fù),加法變減法缓升;
- 均為負(fù)數(shù)鼓鲁,絕對(duì)值加法運(yùn)算,然后取負(fù)港谊;
- 一個(gè)或多個(gè)為非數(shù)字骇吭,即為NaN,會(huì)導(dǎo)致結(jié)果出錯(cuò)歧寺;
- 一個(gè)或多個(gè)為Boolean類(lèi)型或者null時(shí)燥狰,需先轉(zhuǎn)換為其對(duì)應(yīng)的數(shù)值再進(jìn)行計(jì)算;
然后我們?cè)赼dd方法里面處理一下這幾種情況斜筐。
/**
*
* @param {String} x
* @param {String} y
*/
const add = (x = '', y = '') => {
if (Number.isNaN(Number(x)) || Number.isNaN(Number(y))) return x + y // 當(dāng)一個(gè)或多個(gè)為非數(shù)字龙致,直接拼接字符串
if (typeof x === 'boolean' || x === null) x = Number(x).toString() // 當(dāng)x為boolean類(lèi)型或者null時(shí),轉(zhuǎn)換為其對(duì)應(yīng)的數(shù)值
if (typeof y === 'boolean' || y === null) y = Number(y).toString() // 當(dāng)y為boolean類(lèi)型或者null時(shí)顷链,轉(zhuǎn)換為其對(duì)應(yīng)的數(shù)值
let calMethood = true // 運(yùn)算方式目代,true為加法運(yùn)算,false為減法運(yùn)算(一正一負(fù)時(shí)需要減法運(yùn)算)
let allAegative = false // 是否需要給結(jié)果添加負(fù)號(hào)嗤练,true需要榛了,false不需要
let sum = '' // 和,字符串加減煞抬,所以定義為空串
let flag = 0 // 進(jìn)位標(biāo)志霜大,加法:當(dāng)當(dāng)前位計(jì)算大于9時(shí),需要進(jìn)位革答,加法進(jìn)位只可能為0或1战坤,減法:當(dāng)當(dāng)前位計(jì)算被減數(shù)不夠減時(shí),需要借位残拐,減法借位只可能為0或-1
// 為了方便一正一負(fù)時(shí)的減法計(jì)算途茫,將x和y存為默認(rèn)的減數(shù)與被減數(shù)
let subtracted = x // 被減數(shù),默認(rèn)為x
let minus = y // 減數(shù)蹦骑,默認(rèn)為y
if (x.includes('-') && y.includes('-')) { // 全是負(fù)數(shù)時(shí)慈省,計(jì)算方法同全正數(shù)計(jì)算,只需要在最后的結(jié)果將負(fù)號(hào)加上即可,所以在此處將負(fù)號(hào)刪去
allAegative = true
calMethood = true
subtracted = x.split('-')[1]
minus = y.split('-')[1]
} else if (x.includes('-') || y.includes('-')) { // x為負(fù)數(shù)或y為負(fù)數(shù)時(shí),執(zhí)行減法運(yùn)算,絕對(duì)值小的為減數(shù)
// 減法運(yùn)算總是大的減小的
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 { // 默認(rèn)為x - y边败,如果改為y - x需要給結(jié)果添加負(fù)號(hào)
subtracted = tempY
minus = tempX
allAegative = y.includes('-')
}
}
// todo:計(jì)算過(guò)程
return Number(x) + Number(y)
}
核心計(jì)算過(guò)程
處理完了符號(hào)袱衷,以及可能出現(xiàn)的報(bào)錯(cuò),下面就開(kāi)始計(jì)算部分了笑窜。這里采用的是先將字符串用split轉(zhuǎn)換為數(shù)組致燥,然后反轉(zhuǎn)數(shù)組,使得數(shù)組從第零位到最后一位分別對(duì)應(yīng)數(shù)字的個(gè)位到最大位排截,最后一位一位計(jì)算得到結(jié)果嫌蚤。可以寫(xiě)一個(gè)方法用來(lái)計(jì)算断傲。整個(gè)實(shí)現(xiàn)過(guò)程也非常簡(jiǎn)單
/**
* 數(shù)組求和
* @param {Array} arr1 被減數(shù)轉(zhuǎn)換的數(shù)組
* @param {Array} arr2 減數(shù)轉(zhuǎn)換的數(shù)組
* @param {String} sum 和
* @param {Number} flag 進(jìn)位標(biāo)志
*/
const arrSum = (arr1, arr2, sum, flag) {
// 以位數(shù)大的數(shù)的長(zhǎng)度為標(biāo)準(zhǔn)遍歷脱吱,其中用到的未定義變量均為上一節(jié)中定義的變量
for(let i = 0; i < Math.max(arr1.length, arr2.length); i ++) {
if (calMethood) { // 加法
// 當(dāng)前位計(jì)算,沒(méi)有則為0认罩,同時(shí)加上進(jìn)位
const temp = (+arr1[i] || 0) + (+arr2[i] || 0) + flag
if (temp < 10) { // 判斷是否需要進(jìn)位
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)) { // 被減數(shù)太小,需要借位
temp += 10
flag = -1
} else {
flag = 0
}
sum = `${temp}${sum}`
}
}
// 返回flag是為了判斷是否有溢出的進(jìn)位
return {
sum,
flag,
}
}
然后我們?cè)谔砑右幌伦址D(zhuǎn)換數(shù)組的過(guò)程箱蝠。需要注意的是,我們需要特殊考慮一下小數(shù)垦垂,因?yàn)樾?shù)的字符串在分割時(shí)會(huì)將小數(shù)點(diǎn)也作為一位分割宦搬,所以我們先按小數(shù)點(diǎn)分割,將字符串分割為整數(shù)和小數(shù)兩部分劫拗。
let integerA = subtracted.split('.')[0].split('').reverse() // 被減數(shù)的整數(shù)部分的反轉(zhuǎn)數(shù)組间校,方便遍歷時(shí)從個(gè)位開(kāi)始計(jì)算
let decimalA = [] // 被減數(shù)的小數(shù)部分的反轉(zhuǎn)數(shù)組
let integerB = minus.split('.')[0].split('').reverse() // 減數(shù)的整數(shù)部分的反轉(zhuǎn)數(shù)組
let decimalB = [] // 減數(shù)的小數(shù)部分的反轉(zhuǎn)數(shù)組
if (x.includes('.')) { // 是小數(shù)再去計(jì)算小數(shù)部分的數(shù)組
decimalA = subtracted.split('.')[1].split('')
}
if (y.includes('.')) {
decimalB = minus.split('.')[1].split('')
}
// 根據(jù)小數(shù)的特殊性,需要根據(jù)兩個(gè)數(shù)字的最長(zhǎng)長(zhǎng)度去給另一個(gè)填充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()
然后進(jìn)行計(jì)算页慷,先算小數(shù)后算整數(shù)
decimalA = decimalA.reverse()
decimalB = decimalB.reverse()
const decimalAns = arrSum(decimalA, decimalB, sum, flag)
sum = decimalAns.sum.replace(/0*$/, '') // 去除小數(shù)部分末尾的0
flag = decimalAns.flag
// 小數(shù)部分計(jì)算不為空,則添加小數(shù)點(diǎn)
if (sum !== '') sum = `.${sum}`
const integerAns = arrSum(integerA, integerB, sum, flag)
sum = integerAns.sum
flag = integerAns.flag
// 進(jìn)位溢出憔足,前面再添加一位
if (flag !== 0) {
sum = `${flag}${sum}`
}
sum = sum.replace(/^0*/, '') // 去除最左側(cè)的0
然后最后只需要將最后的sum和符號(hào)拼起來(lái)就是最終的結(jié)果。
return allAegative ? `-${sum}` : sum
大數(shù)減法
減法與加法類(lèi)似酒繁,且在上面的過(guò)程中四瘫,已經(jīng)有了一個(gè)雛形。
比如說(shuō) x - y
可以看成是 x + (-y)
欲逃,所以就有了一個(gè)思路是,增加一個(gè)參數(shù)用來(lái)判斷是否是減法饼暑,如果是減法就給y值取反稳析,然后仍然進(jìn)行加法運(yùn)算。
/**
*
* @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
參照這個(gè)思路弓叛,我們可以在已經(jīng)寫(xiě)好的加法上稍作改造彰居,加以下幾行代碼。
/**
*
* @param {String} x
* @param {String} y
* @param {String} methood
*/
const add = (x = '', y = '', methood = '+') => {
if (methood === '-') {
b = b.includes('-') ? b.split('-')[1] : `-$撰筷`
}
// ---
}
總結(jié)
市面上已經(jīng)有非常成熟的解決方案了陈惰,我這就是屬于重復(fù)造輪子了,純當(dāng)學(xué)習(xí).
參考
雙精度浮點(diǎn)數(shù)
ECMAScript (ECMA-262)
源碼
/**
* 計(jì)算大數(shù)
* @param {String} a
* @param {String} b
* @param {String} mthood 運(yùn)算方式
*/
const addLargeNumber = (a = '', b = '', methood = '+') => {
// 傳小數(shù)進(jìn)行計(jì)算在toString的時(shí)候就會(huì)丟失精度毕籽,太大的時(shí)候一拿到就已經(jīng)沒(méi)有精度了抬闯。井辆。
if (Number.isNaN(Number(a)) || Number.isNaN(Number(b))) return a + b
if (methood === '-') {
b = b.includes('-') ? b.split('-')[1] : `-$`
}
let calMethood = true // 運(yùn)算方式,true為加法運(yùn)算,false為減法運(yùn)算
let allAegative = false // 是否需要加負(fù)號(hào)
let subtracted = a // 被減數(shù),默認(rèn)為a
let minus = b // 減數(shù),默認(rèn)為b
if (a.includes('-') && b.includes('-')) { // 全是負(fù)數(shù)時(shí)溶握,計(jì)算方法同全正數(shù)計(jì)算杯缺,只需要在最后的結(jié)果將負(fù)號(hào)加上即可,所以在此處將負(fù)號(hào)刪去
allAegative = true
calMethood = true
subtracted = a.split('-')[1]
minus = b.split('-')[1]
} else if (a.includes('-') || b.includes('-')) { // a為負(fù)數(shù)或b為負(fù)數(shù)時(shí),執(zhí)行減法運(yùn)算,絕對(duì)值小的為減數(shù)
// 減法運(yùn)算總是大的減小的
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 { // 默認(rèn)為x - y睡榆,如果改為y - x需要給結(jié)果添加負(fù)號(hào)
subtracted = tempY
minus = tempX
allAegative = b.includes('-')
}
}
let integerA = subtracted.split('.')[0].split('').reverse() // 被減數(shù)的整數(shù)部分的反轉(zhuǎn)數(shù)組萍肆,方便遍歷時(shí)從個(gè)位開(kāi)始計(jì)算
let decimalA = [] // 被減數(shù)的小數(shù)部分的反轉(zhuǎn)數(shù)組
let integerB = minus.split('.')[0].split('').reverse() // 減數(shù)的整數(shù)部分的反轉(zhuǎn)數(shù)組
let decimalB = [] // 減數(shù)的小數(shù)部分的反轉(zhuǎn)數(shù)組
let flag = 0 // 進(jìn)位標(biāo)志,當(dāng)當(dāng)前位計(jì)算大于9時(shí)胀屿,需要進(jìn)位塘揣,加法進(jìn)位只可能為0或1
let sum = '' // 和
if (a.includes('.')) { // 是小數(shù)再去計(jì)算小數(shù)部分的數(shù)組
decimalA = subtracted.split('.')[1].split('')
}
if (b.includes('.')) {
decimalB = minus.split('.')[1].split('')
}
// 根據(jù)小數(shù)的特殊性,需要根據(jù)兩個(gè)數(shù)字的最長(zhǎng)長(zhǎng)度去給另一個(gè)填充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*$/, '') // 去除小數(shù)部分末尾的0
flag = decimalAns.flag
// 小數(shù)部分計(jì)算不為空,則添加小數(shù)點(diǎn)
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' // 去除最左側(cè)的0宿崭,同時(shí)避免因結(jié)果是0而產(chǎn)生空串
/**
*
* @param {Array} arr1 被減數(shù)轉(zhuǎn)換的數(shù)組
* @param {Array} arr2 減數(shù)轉(zhuǎn)換的數(shù)組
* @param {String} sum 和
* @param {Number} flag 進(jìn)位標(biāo)志
*/
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)) { // 被減數(shù)太小,需要借位
temp += 10
flag = -1
} else {
flag = 0
}
sum = `${temp}${sum}`
}
}
return {
sum,
flag,
}
}
return allAegative ? `-${sum}` : sum
}