前言
众所周知,计算机进行数字运算的时候会把十进制转化为二进制,因此会产生0.1+0.2=0.30000000004 精度丢失的问题。一般来说,我们会采用math.js等第三方库的方式解决,不过我之前也没去了解过它们的原理。
有一天,我就想,如果我们就像小学学数学一样把数字放在十进制层面进行运算,不就不会出现这个问题了吗,然后我简单地写了一个demo
function plus (a, b) {
const str1 = String(a).split('.')
let dot1 = str1[1]?.split('') ?? [ 0 ]
dot1 = dot1.map(item => Number(item))
const str2 = String(b).split('.')
let dot2 = str2[1]?.split('') ?? [ 0 ]
dot2 = dot2.map(item => Number(item))
let count = Number(str1[0]) + Number(str2[0])
const newArr = []
const length = Math.max(dot1.length, dot2.length)
for (let i = length - 1; i >= 0; i--) {
let num = (dot1[i] ?? 0) + (dot2[i] ?? 0) + (newArr[i] ?? 0)
if (num >= 10) {
num -= 10
if (i > 0) {
newArr[i - 1] = 1
} else {
count++
}
}
newArr[i] = num
}
return Number(count + '.' + newArr.join(''))
}
输入plus(0.1,0.2),先将数字转字符串解析,进行十进制加减法,最后转数字输出0.3
当然,我有很多边界条件没有考虑,这仅仅是个demo
big.js解析整体流程
然后我就去看了一下big.js的源码,确实就是这种解决方案,不过这个库并不是像我一样只是去解决加法问题,它通过构造函数创建了一个Big对象,然后这个对象中包含了很多数学信息,原型上也挂了很多方法,用于进行各种运算。
这是它的整体结构,首先闭包全局注入或在导出了一个Big构造函数
(function (GLOBAL) {
'use strict';
var Big
function _Big_ () {
function Big (n) {
// 构造函数,下文会进行分析
}
Big.prototype = P
// 等一些原型方法
return Big
}
// Export
Big = _Big_()
Big['default'] = Big.Big = Big
//AMD.
if (typeof define === 'function' && define.amd) {
define(function () {
return Big
})
// Node and other CommonJS-like environments that support module.exports.
} else if (typeof module !== 'undefined' && module.exports) {
module.exports = Big
// Browser.
} else {
GLOBAL.Big = Big
}
})(this)
全局注入后,当我们运行Big()或new Big()时
function _Big_() {
/*
* The Big constructor and exported function.
* Create and return a new instance of a Big number object.
*
* n {number|string|Big} A numeric value.
*/
function Big(n) {
var x = this; // new Big()的this是Big(),Big()的this是Window
// Enable constructor usage without new.
// 可以不通过构造函数使用,它会帮你转化为new
if (!(x instanceof Big)) return n === UNDEFINED ? _Big_() : new Big(n);
// Duplicate.
// 如果传进来的就是Big对象,拷贝一份
if (n instanceof Big) {
x.s = n.s;
x.e = n.e;
x.c = n.c.slice();
} else {
if (typeof n !== 'string') {
// 严格模式只能使用字符串和bigInt,否则报错
if (Big.strict === true && typeof n !== 'bigint') {
throw TypeError(INVALID + 'value');
}
// Minus zero?
// -0的情况也考虑到了,因为String(-0) => '0'
n = n === 0 && 1 / n < 0 ? '-0' : String(n);
}
// 前置校验结束后,解析字符串
parse(x, n);
}
// Retain a reference to this Big constructor.
// Shadow Big.prototype.constructor which points to Object.
x.constructor = Big;
}
Big.prototype = P;
Big.DP = DP;
Big.RM = RM;
Big.NE = NE;
Big.PE = PE;
Big.strict = STRICT;
Big.roundDown = 0;
Big.roundHalfUp = 1;
Big.roundHalfEven = 2;
Big.roundUp = 3;
return Big;
}
在非常细致地前置考虑了如何处理特殊入参之后,big.js调用了parse的方法进行解析
function parse(x, n) {
// 此时的x是Big()构造函数产生的对象,n是字符串
var e, i, nl;
// NUMERIC = /^-?(\d+(\.\d*)?|\.\d+)(e[+-]?\d+)?$/i
// 首先会正则校验字符串是否合法
if (!NUMERIC.test(n)) {
throw Error(INVALID + 'number');
}
// Determine sign. 正负
x.s = n.charAt(0) == '-' ? (n = n.slice(1), -1) : 1;
// Decimal point? 小数点位置
if ((e = n.indexOf('.')) > -1) n = n.replace('.', '');
// Exponential form? 科学计数法,如 5e3 => 5000
if ((i = n.search(/e/i)) > 0) {
// Determine exponent.
if (e < 0) e = i;
e += +n.slice(i + 1);
n = n.substring(0, i);
} else if (e < 0) {
// Integer.
e = n.length;
}
nl = n.length;
// 将0123.1230转化为123.123
// Determine leading zeros.
for (i = 0; i < nl && n.charAt(i) == '0';) ++i;
if (i == nl) {
// Zero.
x.c = [x.e = 0];
} else {
// Determine trailing zeros.
for (; nl > 0 && n.charAt(--nl) == '0';);
x.e = e - i - 1;
x.c = [];
// Convert string to array of digits without leading/trailing zeros.
for (e = 0; i <= nl;) x.c[e++] = +n.charAt(i++);
}
return x;
}
通过这一系列的解析,最终的产生的对象如图
c是去前后0后的数字数组
e是小数点所在位置
s是正负,正为1,负为-1
这个思路整体是和我上面写的demo是相似的,我们来看一下原型上的方法来验证一下
原型上的方法举例:加法和乘法
/*
* Return a new Big whose value is the value of this Big plus the value of Big y.
*/
P.plus = P.add = function (y) {
var e, k, t,
x = this,
Big = x.constructor;
y = new Big(y);
// Signs differ?
if (x.s != y.s) {
y.s = -y.s;
return x.minus(y);
}
var xe = x.e,
xc = x.c,
ye = y.e,
yc = y.c;
// Either zero?
if (!xc[0] || !yc[0]) {
if (!yc[0]) {
if (xc[0]) {
y = new Big(x);
} else {
y.s = x.s;
}
}
return y;
}
xc = xc.slice();
// Prepend zeros to equalise exponents.
// Note: reverse faster than unshifts.
if (e = xe - ye) {
if (e > 0) {
ye = xe;
t = yc;
} else {
e = -e;
t = xc;
}
t.reverse();
for (; e--;) t.push(0);
t.reverse();
}
// Point xc to the longer array.
if (xc.length - yc.length < 0) {
t = yc;
yc = xc;
xc = t;
}
e = yc.length;
// Only start adding at yc.length - 1 as the further digits of xc can be left as they are.
for (k = 0; e; xc[e] %= 10) k = (xc[--e] = xc[e] + yc[e] + k) / 10 | 0;
// No need to check for zero, as +x + +y != 0 && -x + -y != 0
if (k) {
xc.unshift(k);
++ye;
}
// Remove trailing zeros.
for (e = xc.length; xc[--e] === 0;) xc.pop();
y.c = xc;
y.e = ye;
return y;
};
通过上述的代码中,我们可以看到,加法操作其实就是在符号相同的情况下,对齐两个数字的小数点,然后对数组中的每一对数据进行加法操作,得到结果后再保存下来。
/*
* Return a new Big whose value is the value of this Big times the value of Big y.
*/
P.times = P.mul = function (y) {
var c,
x = this,
Big = x.constructor,
xc = x.c,
yc = (y = new Big(y)).c,
a = xc.length,
b = yc.length,
i = x.e,
j = y.e;
// Determine sign of result.
y.s = x.s == y.s ? 1 : -1;
// Return signed 0 if either 0.
if (!xc[0] || !yc[0]) {
y.c = [y.e = 0];
return y;
}
// Initialise exponent of result as x.e + y.e.
y.e = i + j;
// If array xc has fewer digits than yc, swap xc and yc, and lengths.
if (a < b) {
c = xc;
xc = yc;
yc = c;
j = a;
a = b;
b = j;
}
// Initialise coefficient array of result with zeros.
for (c = new Array(j = a + b); j--;) c[j] = 0;
// Multiply.
// i is initially xc.length.
for (i = b; i--;) {
b = 0;
// a is yc.length.
for (j = a + i; j > i;) {
// Current sum of products at this digit position, plus carry.
b = c[j] + yc[i] * xc[j - i - 1] + b;
c[j--] = b % 10;
// carry
b = b / 10 | 0;
}
c[j] = b;
}
// Increment result exponent if there is a final carry, otherwise remove leading zero.
if (b) ++y.e;
else c.shift();
// Remove trailing zeros.
for (i = c.length; !c[--i];) c.pop();
y.c = c;
return y;
};
上面是乘法,其实乘法的本质和加法也是类似的,每一位数字进行运算后再保存回原数组即可。也就是我们小学学过的乘法计算方式。
其他
最后提一下Number()、String()等强制转换的操作
回忆一下我们学过的知识,如何重写强制转换?就是通过重写toString(),搜一下源码中的toString,确实是如此实现的
/*
* Return a string representing the value of this Big.
* Return exponential notation if this Big has a positive exponent equal to or greater than
* Big.PE, or a negative exponent equal to or less than Big.NE.
* Omit the sign for negative zero.
*/
P.toJSON = P.toString = function () {
var x = this,
Big = x.constructor;
return stringify(x, x.e <= Big.NE || x.e >= Big.PE, !!x.c[0]);
};
结语
总结了几个点
在big.js的源码中,big.js将数字转换为三个属性:正负、数字的字符串、小数点位置,然后对每一位进行十进制运算
在每一个运算函数中,big.js都会先进行异常检测,然后对数据进行处理,如果遇到不符合处理逻辑的数值,都会转化为符合要求的情况。这样,代码看起来条理清晰,思路明确,不需要通过不同的逻辑处理代码来处理不同类型的数据
在es10中,也有新的数据类型BigInt,相比big.js等数学库来说,优势肯定是性能更好(不过可以忽略不计),缺点就是兼容性问题并且BigInt和Number类型不可一起运算,BigInt也不支持Math对象中的方法
非常建议在大数运算或小数运算的时候使用math.js或big.js等第三方库
github链接:https://github.com/MikeMcl/big.js/blob/9c6c959c92dc9044a0f98c31f60322fd91243468/big.js#L261
参考:https://blog.csdn.net/weixin_42899690/article/details/120557100