事先说明,大数运算并不只是指特别大的数进行运算,而是广泛指运算超出了运算精度。
那么什么情况会超出运算精度呢,特别大的数也不常遇见,就 0.1 + 0.2 ,可以随便打开一个控制台,输入这个语句,可以发现返回值是 0.30000000000000004 ,但直接输入 0.3 并不会有这个问题。
这是因为 js 在进行小数运算的时候,可能会丢失一些精度,导致计算结果与真实值之间存在误差。
那么如何解决这个问题呢,框架自然是最好用的,但在分析框架源码之前,先提几句我个人的思路。
js 中,像 number、string、boolean 这种原生数据类型,都是存在原生对象的,可以使用原生对象做数据类型的转化。
let a = 0.1, b = 0.2;
a = String(a);
b = String(b);
将数字转化成字符串后,字符串是不会丢失精度的,而且 js 中字符串同样可以用 for in 遍历,要做的不过是像小学生一样,将小数点对齐,按位一一进行运算罢了。
但是要实现上述运算的话,我的办法就比框架愚拙了不少,所以现在放下这个思路,看看框架是如何解析数字的。
这里推荐的框架是 big.js ,这个框架还有姐妹框架,比如 bignumber.js 增加了对非十进制数的运算,以及 decimal.js 增加了对非整数幂的支持。
完整源码还请访问官方 cdn,内容太多我就不贴到博客里了。
(function (GLOBAL) {
// 内部定义的函数内容先省略
var Big;
Big = _Big_();
Big["default"] = Big.Big = Big;
if (typeof define === "function" && define.amd) {
define(function () {
return Big;
});
} else if (typeof module !== "undefined" && module.exports) {
module.exports = Big;
} else {
GLOBAL.Big = Big;
}
})(this);
首先源码是一个自调用函数,将 this 传入,并用参数 GLOBAL 接收,在浏览器中 this 指向全局的 window 对象,而在 node 中,this 则会指向 module.exports ,具体原因请看我的这篇博客《痛觉残留——Node 原理》。
在函数内,第一步是定义 Big 变量,赋值为 _Big_ 的执行结果,函数内容稍后再看,先知道函数返回了一个构造函数,然后在 Big 对象上 做了一层循环引用,即 Big 的 default 属性和 Big 属性都指向自己。
最后三个条件块兼容三种不同的导入框架。
- 第一种是 AMD 导入模块的规范,在 ES5 时浏览器本身不支持模块化,使用该规范就需要导入相应框架,而在这类框架中会实现 define 函数用来导入模块,现在 AMD 规范已经很少被使用了可以忽略。
- 第二种是 Node 或者遵循 CommonJS 规范的导入模块方式。
- 第三钟就是通过 script 标签导入,会在全局 window 对象上挂在 Big 属性,值是 Big 对象。
接下来看 Big 函数,返回值 Big 函数会挂载在 window 的 Big 属性上,这样每次使用相当于调用 Big 函数,同时将 Big 的 prototype 指向空对象 P,之后 Big 实例的方法将会定义在 P 上。
var P = {}, STRICT = false, UNDEFINED = void 0;
function _Big_() {
function Big(n) {
var x = this;
if (!(x instanceof Big)) return n === UNDEFINED ? _Big_() : new Big(n);
if (n instanceof Big) {
x.s = n.s;
x.e = n.e;
x.c = n.c.slice();
} else {
if (typeof n !== "string") {
if (Big.strict === true) {
throw TypeError(INVALID + "number");
}
n = n === 0 && 1 / n < 0 ? "-0" : String(n);
}
parse(x, n);
}
x.constructor = Big;
}
Big.prototype = P;
Big.DP = DP;
Big.RM = RM;
Big.NE = NE;
Big.PE = PE;
Big.strict = STRICT;
return Big;
}
首先说明,void 是一元运算符,它可以出现在任意类型的表达式前执行,却忽略表达式的返回值,返回一个 undefined。
官方文档中介绍,使用 big.js 有两种方法,一是作为类使用 new 操作符,二是作为函数使用。作为函数使用时,this 的值是 undefined,此时 x instanceof Big 为 false,同时如果传入实参,n === UNDEFINED 也为 false,于是返回 new Big(n),相当于即使通过函数使用,框架会重新替我们以 new 操作符初始化;而当我们通过 new 初始化时,this 指向 Big 的实例,x instanceof Big 则为 true。
下一步判断 n 是否也是 Big 的实例,如果是,会对 n 的属性做深拷贝,属性值所代表的意义之后介绍;而如果 n 不是 Big 的实例,则会判断 n 的类型,由于字符串类型不会丢失精度,所以如果 n 不是字符串类型,且 Big 是严格模式,则会抛出一个类型错误,而 Big 默认为非严格模式,所以 n 会被转化为字符串类型。特别说明 n 转化成字符串类型前特意对-0 做了单独处理,因为只有-0 === 0 且 1 / -0 = -Infinity < 0。
最后将 this 和 n 传入 parse 函数。
var NUMERIC = /^-?(\d+(\.\d*)?|\.\d+)(e[+-]?\d+)?$/i;
function parse(x, n) {
var e, i, nl;
if (!NUMERIC.test(n)) {
throw Error(INVALID + "number");
}
x.s = n.charAt(0) == "-" ? ((n = n.slice(1)), -1) : 1;
if ((e = n.indexOf(".")) > -1) n = n.replace(".", "");
if ((i = n.search(/e/i)) > 0) {
if (e < 0) e = i;
e += +n.slice(i + 1);
n = n.substring(0, i);
} else if (e < 0) {
e = n.length;
}
nl = n.length;
for (i = 0; i < nl && n.charAt(i) == "0"; ) ++i;
if (i == nl) {
x.c = [(x.e = 0)];
} else {
for (; nl > 0 && n.charAt(--nl) == "0"; );
x.e = e - i - 1;
x.c = [];
for (e = 0; i <= nl; ) x.c[e++] = +n.charAt(i++);
}
return x;
}
parse 函数首先用正则检验传入的 n 是否是数字,匹配包含正数负数、整数小数以及无整数部分小数(如.123,相当于 0.123),还会匹配超出数字表示范围采用科学计数法的数字(包含 e),若传入数字不合规范就会抛出错误。
charAt 是取到字符串对应下标的字符,也可直接用 string[index] 这种方式。判断第一个字符是否为-号,若是,删除原字符串的负号并将 Big 实例的 s 属性置为-1;若不是,则 s 属性置为 1。然后判断有无小数部分,若有,将 e 赋值为小数点所在小标并删除小数点,若无,e 的值赋为-1。继续判断数字中是否含 e,这种情况相对少见,如对科学计数法不熟悉可以忽略。
变量 e 的值域为[-1, +∞),用 e<0 判断相对 e===-1 严谨一些,如果 e 为-1,则说明数字只包含整数部分,因此小数点所在下标即为数字长度,并将无论是否包含小数的数字长度另存为 nl 变量。
考虑到传入小数时,存在整数位数和小数前几位都为 0 的情况,为方便后续运算,先将前置 0 的个数赋值给 i(注意:0.00102 中 i 的值为 3,.00102 中 i 值为 2)。
i==nl 的情况就是数字全是 0,相当于 n 数值为 0,框架考虑还挺全面的,我是想不到什么情况会传一串 0 进来。而 Big 实例属性 c 的数组则用来储存每一位数字,0 就只存一个 0。e 属性存 0 的原因稍后解释。
n 不为 0 时,需要过滤尾置 0,先将 nl 减去尾置 0 的个数。通过 for 循环将去除前置 0 和尾置 0 的数字部分存入属性 c 中,+号作用是将字符串类型转化为数字类型,x.e 存的是小数点下标减去前置 0 个数再减 1,这样当恢复数字的原数值时,只需将属性 c 中的数字乘以 10 的 x.e-index 次方(index 为数字再 c 中的下标),建议多举几个例子就明白了。
至此,Big 实例就通过这样一种非常简单的方式存储了 x 所有必要信息,并以此为基准实现了诸多计算与比较方法,虽然那些函数也很值得分析,但我写不动了。