起因
最近在項(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ù)字的地方都有專門的方案用來(lái)解決精度問(wèn)題,但是無(wú)法解決大數(shù)的問(wèn)題)。所以我就想寫個(gè)方法專門用來(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類型即可。
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ò)重寫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以內(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
先寫一個(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類型或者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類型或者null時(shí),轉(zhuǎn)換為其對(duì)應(yīng)的數(shù)值
if (typeof y === 'boolean' || y === null) y = Number(y).toString() // 當(dāng)y為boolean類型或者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é)果??梢詫懸粋€(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ù)減法
減法與加法類似,且在上面的過(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)寫好的加法上稍作改造,加以下幾行代碼。
/**
*
* @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
}