在我们的日常js项目中,我们不免会碰到需要进行前端计算的场景。而大家都知道,计算机进行计算时存在精度问题,且数值有值域,偶尔会碰到溢出问题。在最近的一个项目中,由于遇到了一个超过20位的数,因此,又不得不再来对js中数值的运算进行研究。
在js中,一旦遇到非常大的数,例如一个超过Number.MAX_SAFE_VALUE的值,在运算时就会出现问题,而且在展示时(toString)会出现把大数指数化后展示(12.3221124e18),最后导致展示问题。针对这些问题,我们目前已知ES标准已经将BigInt类型作为下一阶段标准的一个方案,基于BigInt,未来实现BigFloat也是可行的。但对于开发者而言,在不可知的未来,BigInt也可能遇到问题,例如,不可以和普通Number进行直接的混合运算。因此,在这些年的经验中,前端总结出来一套方案,就是以字符串的形式进行数值运算。
解决数值溢出问题
解决小数精度问题
字符串数值运算听上去很简单,但是,实际上在实现过程中,会遇到不少问题,一旦你开始去写代码,就会遇到这些坑。由于我自己实现了一套加减乘除算法,所以,现在把这个过程写下来,以为后来的同学借鉴。
竖式
在实现字符串数值的运算时,我们不能按照计算机语言的思维方式去做,我们要回归到数学的本质,什么是加?什么是减?什么是乘?什么是除?数学的奇妙,让我们即使笨拙,也可以找到门路。我所发现的实现方式,就是我们小学的时候学到的“竖式”。它是一种能让小学生理解的运算方式。让我们来看一个最简单的竖式:
132
+ 79
------
`1
`1
2
------
= 211
这是一个加法竖式的例子。而且两个竖式可以串联,例如,上面的竖式结束之后,你需要继续计算211+64,还可以继续在下面进行下一轮运算。
基于竖立的理解,我实现了一套js的演算。其中加法和减法是完完全全按照竖式实现的,而乘法和除法则是在加法和减法基础上的继续叠加实现的。下面,我就会对每一个实现进行详细的讲解。
数的拆解
在真正开始进行算法讲解之前,我需要做一件事,就是讲清楚,我在每一个算法中,如何对待一个数值。因为我们用于运算的所有数值在js中的本质是一个字符串,因此,我们需要对数进行拆解,我们需要对算法进行叠加,对数的每一部分进行分析,然后综合得到最后的结果。
一个数,在表达中分为三个部分:整数部分,小数部分,正负。
对于字符串而言,这三部分都非常容易得到,不需要太多的处理和识别。
在算法中,我基本上遵循这样的规律:
整数部分和小数部分分开运算,最后叠加起来
创建一个完全正整数的元运算算法
正负号影响处理方式,但不对运算本身造成影响,因此,一般在运算开始之前考虑正负号带来的影响
某些特殊值具有快速响应能力,例如一个数乘以0,永远得到0,诸如此类的运算,都可以不用走复杂的运算流程,以此增加性能
对于字符串处理结果而言,我们还需要注意一个事实,就是,字符串不会自动处理数值前后的00,例如我们用两个数相减,得到-007.1200,我们应该在代码中自动把开头的00和末尾的00都去掉,以一个正确正常的值返回运算结果。
加法运算
两个数相加的竖式已经在上面的例子中提到了。我们现在来再研究一下。两个数相加,无论多少位,它的规则都是从末位开始,依次按位相加,最后将相加结果串联起来。但是,在这个过程中需要注意的是,当两个单位相加结果大于10时,需要向前进一位,也就是在下一位相加时,需要再加1.这个进位的规则使得整个运算难了很多。但是,我们还是可以通过各种手段,判断出在计算时是否应该进位。
加法元
所谓元运算,在本文中是指,对两个正整数进行的某项运算算法。加法的元运算相对比较简单,主要考虑的是进位规则:
// 创建一个整数相加函数
const plus = (x, y) => {
let xr = x.split('').reverse()
let yr = y.split('').reverse()
let len = Math.max(xr.length, yr.length)
let items = []
for (let i = 0; i < len; i ++) {
let xv = xr[i] || '0'
let yv = yr[i] || '0'
items[i] = ((+xv) + (+yv)) + ''
}
let sum = items.reduce((sum, item, index) => {
let sumlen = sum.length
// 如果之前相加进了一位
if (sumlen > index) {
let borrow = sum.substring(0, 1)
let placed = sum.substring(1)
let next = (+borrow + +item) + ''
return next + placed
}
else {
return item + sum
}
}, '')
return sum
}
上边这个函数解决的是两个正整数相加的元运算。无论这两个正整数位数有多大,都能正常计算结果。
加法变形
本文中所谓运算变形,是指一些场景下,在元运算基础上进行的叠加处理。加法的运算变形包括:
a, b任何一个值为0,则返回另外一个值,无需进行深入运算
如果a为正数,b为负数,相当于a-b,直接调用minusby(a, b.substring(1))
如果a为负数,b为正数,则反过来调用minusby(b, a.substring(1))
小数相加时,先补齐小数位数,再去掉小数点,元运算之后,再把小数点加回来
a, b都是负数,相当于plusby(a.substring(1), b.substring(1))后,结果再取负
以上就是加法的运算变形。
基于这些变形,我们得到最终的加法运算如下:
/**
* 基于字符串数值的a+b
* @param {string} a
* @param {string} b
*/
export function plusby(a, b) {
a = numerify(a)
b = numerify(b)
if (a === '0') {
return b
}
else if (b === '0') {
return a
}
var [ ia, da = '0' ] = a.split('.')
var [ ib, db = '0' ] = b.split('.')
// 是否为负数
var na = false
var nb = false
if (ia.indexOf('-') === 0) {
ia = ia.substring(1)
na = true
}
if (ib.indexOf('-') === 0) {
ib = ib.substring(1)
nb = true
}
// 一正一负相当于相减
if (na && !nb) {
return minusby(b, a.substring(1))
}
if (nb && !na) {
return minusby(a, b.substring(1))
}
// 创建一个整数相加函数
const plus = (x, y) => {
let xr = x.split('').reverse()
let yr = y.split('').reverse()
let len = Math.max(xr.length, yr.length)
let items = []
for (let i = 0; i < len; i ++) {
let xv = xr[i] || '0'
let yv = yr[i] || '0'
items[i] = ((+xv) + (+yv)) + ''
}
let sum = items.reduce((sum, item, index) => {
let sumlen = sum.length
// 如果之前相加进了一位
if (sumlen > index) {
let borrow = sum.substring(0, 1)
let placed = sum.substring(1)
let next = (+borrow + +item) + ''
return next + placed
}
else {
return item + sum
}
}, '')
return sum
}
// 补齐位数用以相加
const dalen = da.length
const dblen = db.length
const dlen = Math.max(dalen, dblen)
if (dalen < dlen) {
da = padRight(da, dlen, '0')
}
if (dblen < dlen) {
db = padRight(db, dlen, '0')
}
const ta = ia + da
const tb = ib + db
var sum = plus(ta, tb)
// 还原小数位数
var sumr = sum.split('')
var sumlen = sumr.length
var index = sumlen - dlen
sumr.splice(index, 0, '.')
sum = sumr.join('')
sum = clearNumberZero(sum)
sum = sum === '' ? '0' : sum
// 都是负数
if (sum !== '0' && na && nb) {
sum = '-' + sum
}
return sum
}
上面代码中有几个函数,他们的源码如下:
/**
* 对字符串向右补位
* @param {*} str
* @param {*} len
* @param {*} pad
*/
export function padRight(str, len, pad) {
if (str.length >= len) {
return str
}
let diff = len - str.length
let letters = createArray(pad, diff)
return str + letters.join('')
}
/**
* 移除数字首尾的00
* @param {*} input
*/
export function clearNumberZero(input) {
input = input.toString()
var [ integerPart, decimalPart = '' ] = input.split('.')
var isNegative = false
if (integerPart.indexOf('-') === 0) {
isNegative = true
integerPart = integerPart.substring(1)
}
integerPart = integerPart.replace(/^0+/, '') // 去除开头的000
decimalPart = decimalPart.replace(/0+$/, '') // 去除末尾的000
var value = (isNegative && (integerPart || decimalPart) ? '-' : '') + (integerPart ? integerPart : '0') + (decimalPart ? '.' + decimalPart : '')
return value
}
/**
* 创建一个特定位数,且每个元素值相同的数组
* @param {*} count
* @param {*} value
*/
export function createArray(value, count = 1) {
return [].fill.call(new Array(count), value);
}
/**
* 获取数字的字符串形式
* @param {*} num
*/
export function numerify(num) {
if (isString(num)) {
if (!isNumeric(num)) {
return ''
}
let value = clearNumberZero(num)
return value
}
else if (isNumber(num)) {
let value = num.toString()
// num.toString()之后得到的指数有两种,一种是精确的小值指数形式,另一种是非精确的大值指数形式
// 但无论哪一种,这里都是最终的办法,非精确的情况下,无法做到数值转化
if (value.indexOf('e')) {
return convertNumberWithExponential(value)