最近项目要求,在前端计算单价,由于 JavaScript 是二进制浮点,十进制小数(比如:0.1、0.01、19.99)在二进制表示不精确,直接运算会得到一个很接近但是不等于的结果。而价格这个东西对于精度又极其敏感。
看到 Java 中是用BigDecimal进行计算的,所以,我这在 JavaScript 中实现了相近的功能
实现代码(ES2020以上):
/* BigDecimal.ts
*
* https://www.yangguangdream.com/?id=2270
*
* 精確十進位運算(BigInt + scale 定點小數)
* Precise decimal arithmetic using BigInt + scale (fixed-point).
*
* 建議 TS target / lib: ES2020 以上(需要 BigInt)
* Recommended TS target/lib: ES2020+ (BigInt required).
*/
export type RoundingMode =
| "DOWN" // toward zero
| "UP" // away from zero
| "FLOOR" // toward -∞
| "CEILING" // toward +∞
| "HALF_UP" // ties away from zero
| "HALF_DOWN" // ties toward zero
| "HALF_EVEN"; // banker's rounding
export interface MathContext {
/** 有效位數(precision),必須 >= 1。Significant digits (precision), must be >= 1. */
precision: number;
/** 捨入模式。Rounding mode. */
roundingMode: RoundingMode;
}
export class BigDecimal {
/** 去掉小數點後的整數值(可能為負)。Unscaled integer value (may be negative). */
private readonly intVal: bigint;
/** 小數位數(>= 0)。Scale (digits to the right of the decimal point, >= 0). */
private readonly scale: number;
/**
* 建立 BigDecimal。
* - 建議使用字串輸入以確保「完全精確」。
* - number 會先轉字串,可能已經有二進位浮點誤差。
*
* Create a BigDecimal.
* - Prefer string input for exactness.
* - number is converted to string and may already contain IEEE-754 rounding artifacts.
*/
constructor(value: string | number | bigint | BigDecimal, scale?: number) {
if (value instanceof BigDecimal) {
this.intVal = value.intVal;
this.scale = value.scale;
return;
}
if (typeof value === "bigint") {
const s = scale ?? 0;
if (!Number.isInteger(s) || s < 0) throw new Error("scale must be a non-negative integer");
this.intVal = value === 0n ? 0n : value;
this.scale = s;
return;
}
if (typeof value === "number") {
if (!Number.isFinite(value)) throw new Error("Invalid number");
value = String(value);
}
const parsed = BigDecimal.parseDecimalString(value);
this.intVal = parsed.intVal === 0n ? 0n : parsed.intVal; // avoid -0
this.scale = parsed.scale;
}
/**
* 快速建立實例(同 constructor)。
* Convenience factory (same as constructor).
*/
static from(value: string | number | bigint | BigDecimal, scale?: number): BigDecimal {
return new BigDecimal(value as any, scale);
}
/**
* 取得 scale(小數位數)。
* Get scale (digits after decimal point).
*/
getScale(): number {
return this.scale;
}
/**
* 取得未縮放整數值(unscaled value)。
* Get unscaled value (BigInt).
*/
unscaledValue(): bigint {
return this.intVal;
}
/**
* 回傳數值的正負號(-1, 0, 1)。
* Returns signum of this value (-1, 0, 1).
*/
signum(): -1 | 0 | 1 {
if (this.intVal === 0n) return 0;
return this.intVal < 0n ? -1 : 1;
}
/**
* 取絕對值。
* Absolute value.
*/
abs(): BigDecimal {
return this.intVal < 0n ? new BigDecimal(-this.intVal, this.scale) : this;
}
/**
* 取相反數(negate)。
* Negation.
*/
negate(): BigDecimal {
return this.intVal === 0n ? this : new BigDecimal(-this.intVal, this.scale);
}
/**
* 加法(結果 scale = max(a.scale, b.scale),與 Java BigDecimal.add 類似)。
* Addition (result scale = max scales, Java-like).
*/
add(other: BigDecimal | string | number | bigint): BigDecimal {
const b = BigDecimal.from(other);
const [aAdj, bAdj, s] = BigDecimal.alignScales(this, b);
return new BigDecimal(aAdj + bAdj, s);
}
/**
* 減法(結果 scale = max(a.scale, b.scale),與 Java BigDecimal.subtract 類似)。
* Subtraction (result scale = max scales, Java-like).
*/
sub(other: BigDecimal | string | number | bigint): BigDecimal {
const b = BigDecimal.from(other);
const [aAdj, bAdj, s] = BigDecimal.alignScales(this, b);
return new BigDecimal(aAdj - bAdj, s);
}
/**
* 乘法(結果 scale = a.scale + b.scale,與 Java BigDecimal.multiply 類似)。
* Multiplication (result scale = sum of scales, Java-like).
*/
mul(other: BigDecimal | string | number | bigint): BigDecimal {
const b = BigDecimal.from(other);
return new BigDecimal(this.intVal * b.intVal, this.scale + b.scale);
}
/**
* 除法(Java 風格 overload):
* 1) divide(b) -> 精確除法;若結果為無限小數,會拋錯(需指定 scale/rounding)。
* 2) divide(b, s, rm) -> 指定結果 scale 與捨入模式。
*
* Division (Java-like overload):
* 1) divide(b) -> exact division; throws if non-terminating.
* 2) divide(b, s, rm) -> with specified result scale and rounding mode.
*/
divide(other: BigDecimal | string | number | bigint): BigDecimal;
divide(other: BigDecimal | string | number | bigint, resultScale: number, rounding: RoundingMode): BigDecimal;
divide(other: BigDecimal | string | number | bigint, resultScale?: number, rounding?: RoundingMode): BigDecimal {
const b = BigDecimal.from(other);
if (b.intVal === 0n) throw new Error("Division by zero");
if (resultScale === undefined) {
return this.divideExact(b);
}
const rm: RoundingMode = rounding ?? "HALF_UP";
return this.divideScaled(b, resultScale, rm);
}
/**
* 指定 scale(小數位)並捨入,等同 Java BigDecimal.setScale。
* Set scale with rounding (like Java BigDecimal.setScale).
*/
setScale(newScale: number, rounding: RoundingMode = "HALF_UP"): BigDecimal {
if (!Number.isInteger(newScale) || newScale < 0) throw new Error("newScale must be a non-negative integer");
if (newScale === this.scale) return this;
if (newScale > this.scale) {
const factor = BigDecimal.pow10(newScale - this.scale);
return new BigDecimal(this.intVal * factor, newScale);
}
const diff = this.scale - newScale;
const divisor = BigDecimal.pow10(diff); // positive
const { q, r } = BigDecimal.divmodPosDenom(this.intVal, divisor);
const roundedQ = BigDecimal.roundQuotient(q, r, divisor, this.intVal, rounding);
return new BigDecimal(roundedQ, newScale);
}
/**
* 移除尾端多餘的 0(會改變 scale),等同 Java BigDecimal.stripTrailingZeros。
* Strip trailing zeros (changes scale), like Java BigDecimal.stripTrailingZeros.
*/
stripTrailingZeros(): BigDecimal {
if (this.intVal === 0n) return this; // keep scale as-is (Java keeps representation)
let v = this.intVal;
let s = this.scale;
while (s > 0 && (v % 10n) === 0n) {
v /= 10n;
s -= 1;
}
return (v === this.intVal && s === this.scale) ? this : new BigDecimal(v, s);
}
/**
* 比較大小(忽略 scale 差異;數值相等則回傳 0),等同 Java compareTo 語義。
* Compare numerically (ignores scale differences), like Java compareTo.
*/
compareTo(other: BigDecimal | string | number | bigint): -1 | 0 | 1 {
const b = BigDecimal.from(other);
const [aAdj, bAdj] = BigDecimal.alignScales(this, b);
if (aAdj < bAdj) return -1;
if (aAdj > bAdj) return 1;
return 0;
}
/**
* equals(Java 語義:數值與 scale 都必須相同)。
* equals (Java semantics: both value AND scale must match).
*/
equals(other: BigDecimal | string | number | bigint): boolean {
const b = BigDecimal.from(other);
return this.scale === b.scale && this.intVal === b.intVal;
}
/**
* 只比「數值是否相等」(忽略 scale),相當於 compareTo(other) === 0。
* Numeric equality ignoring scale (compareTo(other) === 0).
*/
equalsValue(other: BigDecimal | string | number | bigint): boolean {
return this.compareTo(other) === 0;
}
/**
* 回傳有效位數(precision),與 Java BigDecimal.precision 類似。
* Returns precision (count of significant digits), Java-like.
*/
precision(): number {
// Java: precision of 0 is 1
if (this.intVal === 0n) return 1;
const abs = this.intVal < 0n ? -this.intVal : this.intVal;
// NOTE: unscaled value may include trailing zeros; precision counts them too (Java does).
return abs.toString().length;
}
/**
* 以 MathContext(precision + rounding)做四捨五入(有效位數)。
* Round using MathContext (precision + rounding mode).
*/
round(mc: MathContext): BigDecimal {
if (!mc || !Number.isInteger(mc.precision) || mc.precision < 1) {
throw new Error("MathContext.precision must be an integer >= 1");
}
const rm = mc.roundingMode ?? "HALF_UP";
const curPrec = this.precision();
if (curPrec <= mc.precision) return this;
const drop = curPrec - mc.precision; // digits to drop from unscaled value
const divisor = BigDecimal.pow10(drop); // 10^drop
const { q, r } = BigDecimal.divmodPosDenom(this.intVal, divisor);
const roundedQ = BigDecimal.roundQuotient(q, r, divisor, this.intVal, rm);
// scale decreases by drop; if becomes negative, convert to scale=0 by shifting left.
let newScale = this.scale - drop;
let newIntVal = roundedQ;
if (newScale < 0) {
newIntVal = newIntVal * BigDecimal.pow10(-newScale);
newScale = 0;
}
return new BigDecimal(newIntVal, newScale);
}
/**
* 小數點左移 n 位(相當於 / 10^n)。
* Move decimal point left by n (equivalent to / 10^n).
*/
movePointLeft(n: number): BigDecimal {
if (!Number.isInteger(n)) throw new Error("n must be an integer");
if (n === 0) return this;
if (n < 0) return this.movePointRight(-n);
return new BigDecimal(this.intVal, this.scale + n);
}
/**
* 小數點右移 n 位(相當於 * 10^n);若 scale 變成負數會自動轉為 scale=0 並擴大 intVal。
* Move decimal point right by n (equivalent to * 10^n). If scale becomes negative, it is normalized to scale=0 by expanding intVal.
*/
movePointRight(n: number): BigDecimal {
if (!Number.isInteger(n)) throw new Error("n must be an integer");
if (n === 0) return this;
if (n < 0) return this.movePointLeft(-n);
const newScale = this.scale - n;
if (newScale >= 0) return new BigDecimal(this.intVal, newScale);
// scale would be negative -> shift intVal and set scale=0
const factor = BigDecimal.pow10(-newScale);
return new BigDecimal(this.intVal * factor, 0);
}
/**
* 輸出「一般十進位字串」(不使用科學記號)。
* Plain decimal string (no scientific notation).
*/
toPlainString(): string {
const sign = this.intVal < 0n ? "-" : "";
const abs = this.intVal < 0n ? -this.intVal : this.intVal;
if (this.scale === 0) return sign + abs.toString();
const s = abs.toString();
const pointPos = s.length - this.scale;
if (pointPos > 0) {
const intPart = s.slice(0, pointPos);
const fracPart = s.slice(pointPos);
return sign + intPart + "." + fracPart;
} else {
const zeros = "0".repeat(-pointPos);
return sign + "0." + zeros + s;
}
}
/**
* toString(此實作等同 toPlainString,避免出現科學記號)。
* toString (this implementation equals toPlainString to avoid scientific notation).
*/
toString(): string {
return this.toPlainString();
}
/**
* 嘗試轉成 number(可能失去精度,不建議用於財務/精準計算)。
* Convert to number (may lose precision; not recommended for money/precise math).
*/
toNumber(): number {
return Number(this.toPlainString());
}
// -------------------- private core division helpers --------------------
/**
* 精確除法;若除不盡(無限小數),會拋錯。
* Exact division; throws if the decimal expansion is non-terminating.
*/
private divideExact(divisor: BigDecimal): BigDecimal {
// Build fraction: value = (A * 10^(sb-sa)) / B
let num = this.intVal;
let den = divisor.intVal;
// adjust by exponent = sb - sa
const exp = divisor.scale - this.scale;
if (exp >= 0) num = num * BigDecimal.pow10(exp);
else den = den * BigDecimal.pow10(-exp);
// normalize denominator to positive
if (den < 0n) {
den = -den;
num = -num;
}
// reduce fraction
const g = BigDecimal.gcd(BigDecimal.absBigInt(num), den);
num /= g;
den /= g;
// den must be 2^a * 5^b for terminating decimal
let d = den;
let p2 = 0;
let p5 = 0;
while (d % 2n === 0n) { d /= 2n; p2++; }
while (d % 5n === 0n) { d /= 5n; p5++; }
if (d !== 1n) {
throw new Error("Non-terminating decimal expansion; specify scale and rounding mode");
}
const s = Math.max(p2, p5);
// intVal = num * 10^s / den = num * 2^(s-p2) * 5^(s-p5)
const mul2 = BigDecimal.powBigInt(2n, s - p2);
const mul5 = BigDecimal.powBigInt(5n, s - p5);
const intVal = num * mul2 * mul5;
return new BigDecimal(intVal, s);
}
/**
* 指定結果小數位(scale)與捨入模式的除法。
* Division with specified result scale and rounding mode.
*/
private divideScaled(divisor: BigDecimal, resultScale: number, rounding: RoundingMode): BigDecimal {
if (!Number.isInteger(resultScale) || resultScale < 0) throw new Error("resultScale must be a non-negative integer");
// Compute q = round( numerator / denominator ) where result has `resultScale`.
// exp = resultScale + sb - sa
const exp = resultScale + divisor.scale - this.scale;
let numerator: bigint;
let denominator: bigint;
if (exp >= 0) {
numerator = this.intVal * BigDecimal.pow10(exp);
denominator = divisor.intVal;
} else {
numerator = this.intVal;
denominator = divisor.intVal * BigDecimal.pow10(-exp);
}
// normalize denominator to positive
if (denominator < 0n) {
denominator = -denominator;
numerator = -numerator;
}
const { q, r } = BigDecimal.divmodPosDenom(numerator, denominator);
const roundedQ = BigDecimal.roundQuotient(q, r, denominator, numerator, rounding);
return new BigDecimal(roundedQ, resultScale);
}
// -------------------- private parsing & math helpers --------------------
/**
* 解析十進位字串(支援科學記號 e/E)。
* Parse decimal string (supports scientific notation e/E).
*/
private static parseDecimalString(input: string): { intVal: bigint; scale: number } {
const str = input.trim();
if (!str) throw new Error("Empty decimal");
// Allow: [+|-]? (digits? "." digits? | digits ".") ( [eE][+|-]?digits )?
const m = /^([+-])?(?:(\d+)?(?:\.(\d*)?)?|\.(\d+))(?:[eE]([+-]?\d+))?$/.exec(str);
if (!m) throw new Error(`Invalid decimal string: ${input}`);
const sign = m[1] === "-" ? -1n : 1n;
// Two possible captures:
// - m[2] digits before dot (optional)
// - m[3] digits after dot (optional, can be empty when "12.")
// - or m[4] digits after dot when string starts with "."
const intPart = (m[2] ?? "");
const fracPart = (m[4] ?? m[3] ?? "");
const expStr = m[5] ?? "0";
const exp = Number(expStr);
if (!Number.isInteger(exp)) throw new Error(`Invalid exponent: ${expStr}`);
// Digits without dot
let digits = (intPart + fracPart);
// remove leading zeros for BigInt parsing (keep at least one digit if all zeros)
digits = digits.replace(/^0+(?=\d)/, "");
if (digits === "") digits = "0";
// scale = fracLen - exp (moving decimal right by exp reduces scale)
let scale = fracPart.length - exp;
let intVal = BigInt(digits) * sign;
if (intVal === 0n) intVal = 0n; // avoid -0
if (scale < 0) {
// shift left if scale would be negative
intVal = intVal * BigDecimal.pow10(-scale);
scale = 0;
}
if (!Number.isInteger(scale) || scale < 0) throw new Error("Invalid scale after parsing");
return { intVal, scale };
}
/**
* 將兩個 BigDecimal 對齊到相同 scale(用於加減比大小)。
* Align scales of two BigDecimals (used for add/sub/compare).
*/
private static alignScales(a: BigDecimal, b: BigDecimal): [bigint, bigint, number] {
const s = Math.max(a.scale, b.scale);
const aFactor = BigDecimal.pow10(s - a.scale);
const bFactor = BigDecimal.pow10(s - b.scale);
return [a.intVal * aFactor, b.intVal * bFactor, s];
}
/**
* 10 的 n 次方(BigInt)。
* 10^n as BigInt.
*/
private static pow10(n: number): bigint {
if (!Number.isInteger(n) || n < 0) throw new Error("pow10 expects non-negative integer");
return BigDecimal.powBigInt(10n, n);
}
/**
* base 的 exp 次方(BigInt,快速冪)。
* base^exp using fast exponentiation (BigInt).
*/
private static powBigInt(base: bigint, exp: number): bigint {
if (!Number.isInteger(exp) || exp < 0) throw new Error("powBigInt exp must be non-negative integer");
let r = 1n;
let b = base;
let e = exp;
while (e > 0) {
if (e & 1) r *= b;
b *= b;
e >>= 1;
}
return r;
}
/**
* BigInt 絕對值。
* BigInt absolute value.
*/
private static absBigInt(x: bigint): bigint {
return x < 0n ? -x : x;
}
/**
* 最大公因數 gcd(BigInt)。
* Greatest common divisor (BigInt).
*/
private static gcd(a: bigint, b: bigint): bigint {
let x = a;
let y = b;
while (y !== 0n) {
const t = x % y;
x = y;
y = t;
}
return x;
}
/**
* BigInt 除法與餘數(保證除數為正數)。
* divmod with positive denominator.
*/
private static divmodPosDenom(numer: bigint, denomPos: bigint): { q: bigint; r: bigint } {
if (denomPos <= 0n) throw new Error("denominator must be positive");
const q = numer / denomPos; // trunc toward zero
const r = numer % denomPos; // same sign as numer
return { q, r };
}
/**
* 依照捨入模式把 q + r/denom 進行捨入,denom 必須為正。
* Round quotient for q + r/denom using rounding mode; denom must be positive.
*/
private static roundQuotient(
q: bigint,
r: bigint,
denomPos: bigint,
numer: bigint,
mode: RoundingMode
): bigint {
if (r === 0n) return q;
if (denomPos <= 0n) throw new Error("denominator must be positive");
const sign = numer < 0n ? -1n : 1n; // sign of exact value since denom > 0
const awayFromZero = (x: bigint) => (sign >= 0n ? x + 1n : x - 1n);
switch (mode) {
case "DOWN":
// toward zero
return q;
case "UP":
// away from zero
return awayFromZero(q);
case "FLOOR":
// toward -∞
return (numer < 0n) ? (q - 1n) : q;
case "CEILING":
// toward +∞
return (numer > 0n) ? (q + 1n) : q;
case "HALF_UP":
case "HALF_DOWN":
case "HALF_EVEN": {
const rAbs = BigDecimal.absBigInt(r);
const twiceR = rAbs * 2n;
if (twiceR > denomPos) return awayFromZero(q);
if (twiceR < denomPos) return q;
// exactly half
if (mode === "HALF_UP") return awayFromZero(q);
if (mode === "HALF_DOWN") return q;
// HALF_EVEN: round to make result even
const isOdd = (q & 1n) !== 0n;
return isOdd ? awayFromZero(q) : q;
}
default: {
const _never: never = mode;
return q;
}
}
}
}使用方法(由于JavaScript 精度问题,请直接把数字转为字符串):
const a = new BigDecimal("0.1");
const b = new BigDecimal("0.2");
console.log(a.add(b).toString()); // 0.3
console.log(new BigDecimal("1").divide("3", 10, "HALF_UP").toString()); // 0.3333333333
console.log(new BigDecimal("1").divide("8").toString()); // 0.125(精确可除)
try {
console.log(new BigDecimal("1").divide("3").toString()); // 无限小数会报错
} catch (e) {
console.log(String(e));
}
console.log(new BigDecimal("1.2300").stripTrailingZeros().toString()); // 1.23
console.log(new BigDecimal("1.2300").equals(new BigDecimal("1.23"))); // false(Java equals)
console.log(new BigDecimal("1.2300").equalsValue(new BigDecimal("1.23"))); // true(比较数值)ES2020以下版本兼容(ES2020以下版本不支持 bigint)(AI按照我上面写的跑出来的)
/* BigDecimal.ts (Safari 13 compatible)
*
* - No BigInt, no bigint literals.
* - Internal integer is stored as a decimal string (abs digits) + sign + scale.
* - Works in Safari 13.
*
* Tradeoff:
* - Slower than BigInt version for very large numbers.
* - unscaledValue() returns string (typed as any for drop-in tolerance).
*/
export type RoundingMode =
| "DOWN" // toward zero
| "UP" // away from zero
| "FLOOR" // toward -∞
| "CEILING" // toward +∞
| "HALF_UP" // ties away from zero
| "HALF_DOWN" // ties toward zero
| "HALF_EVEN"; // banker's rounding
export interface MathContext {
precision: number;
roundingMode: RoundingMode;
}
type Sign = -1 | 0 | 1;
function isInt(n: number): boolean {
return typeof n === "number" && isFinite(n) && Math.floor(n) === n;
}
function stripLeadingZeros(d: string): string {
// keep at least one digit
d = d.replace(/^0+(?=\d)/, "");
return d === "" ? "0" : d;
}
function isZeroDigits(d: string): boolean {
return d === "0";
}
function cmpAbs(a: string, b: string): -1 | 0 | 1 {
a = stripLeadingZeros(a);
b = stripLeadingZeros(b);
if (a.length < b.length) return -1;
if (a.length > b.length) return 1;
if (a === b) return 0;
return a < b ? -1 : 1; // lexicographic works for same length digits
}
function addAbs(a: string, b: string): string {
let i = a.length - 1;
let j = b.length - 1;
let carry = 0;
const out: number[] = [];
while (i >= 0 || j >= 0 || carry) {
const da = i >= 0 ? (a.charCodeAt(i) - 48) : 0;
const db = j >= 0 ? (b.charCodeAt(j) - 48) : 0;
const s = da + db + carry;
out.push(s % 10);
carry = Math.floor(s / 10);
i--; j--;
}
out.reverse();
return stripLeadingZeros(out.join(""));
}
function subAbs(a: string, b: string): string {
// assumes a >= b (abs)
let i = a.length - 1;
let j = b.length - 1;
let borrow = 0;
const out: number[] = [];
while (i >= 0) {
let da = (a.charCodeAt(i) - 48) - borrow;
const db = j >= 0 ? (b.charCodeAt(j) - 48) : 0;
if (da < db) {
da += 10;
borrow = 1;
} else {
borrow = 0;
}
out.push(da - db);
i--; j--;
}
out.reverse();
return stripLeadingZeros(out.join(""));
}
function mulAbs(a: string, b: string): string {
a = stripLeadingZeros(a);
b = stripLeadingZeros(b);
if (a === "0" || b === "0") return "0";
const al = a.length;
const bl = b.length;
const res = new Array(al + bl).fill(0);
for (let i = al - 1; i >= 0; i--) {
const da = a.charCodeAt(i) - 48;
for (let j = bl - 1; j >= 0; j--) {
const db = b.charCodeAt(j) - 48;
const idx = i + j + 1;
res[idx] += da * db;
}
}
// carry
for (let k = res.length - 1; k > 0; k--) {
const carry = Math.floor(res[k] / 10);
res[k] = res[k] % 10;
res[k - 1] += carry;
}
return stripLeadingZeros(res.join(""));
}
function mulSmallAbs(a: string, k: number): string {
a = stripLeadingZeros(a);
if (a === "0" || k === 0) return "0";
if (k === 1) return a;
let carry = 0;
const out: number[] = [];
for (let i = a.length - 1; i >= 0; i--) {
const da = a.charCodeAt(i) - 48;
const p = da * k + carry;
out.push(p % 10);
carry = Math.floor(p / 10);
}
while (carry) {
out.push(carry % 10);
carry = Math.floor(carry / 10);
}
out.reverse();
return stripLeadingZeros(out.join(""));
}
function divmodAbs(a: string, b: string): { q: string; r: string } {
a = stripLeadingZeros(a);
b = stripLeadingZeros(b);
if (b === "0") throw new Error("Division by zero");
if (a === "0") return { q: "0", r: "0" };
if (cmpAbs(a, b) < 0) return { q: "0", r: a };
let rem = "0";
const qChars: string[] = [];
for (let i = 0; i < a.length; i++) {
const digit = a.charAt(i);
// rem = rem * 10 + digit
rem = rem === "0" ? digit : (rem + digit);
rem = stripLeadingZeros(rem);
if (cmpAbs(rem, b) < 0) {
qChars.push("0");
continue;
}
// find qd in [1..9]
let lo = 0, hi = 9;
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
const prod = mulSmallAbs(b, mid);
const c = cmpAbs(prod, rem);
if (c <= 0) lo = mid;
else hi = mid - 1;
}
const qd = lo;
qChars.push(String(qd));
const sub = mulSmallAbs(b, qd);
rem = subAbs(rem, sub);
}
const q = stripLeadingZeros(qChars.join(""));
const r = stripLeadingZeros(rem);
return { q, r };
}
function divmodSmallAbs(a: string, small: number): { q: string; r: number } {
a = stripLeadingZeros(a);
if (small <= 0) throw new Error("small divisor must be positive");
if (a === "0") return { q: "0", r: 0 };
let rem = 0;
const out: number[] = [];
for (let i = 0; i < a.length; i++) {
const d = a.charCodeAt(i) - 48;
const x = rem * 10 + d;
const qd = Math.floor(x / small);
rem = x % small;
out.push(qd);
}
return { q: stripLeadingZeros(out.join("")), r: rem };
}
function powAbs(base: string, exp: number): string {
if (!isInt(exp) || exp < 0) throw new Error("exp must be non-negative integer");
if (exp === 0) return "1";
let result = "1";
let b = stripLeadingZeros(base);
let e = exp;
while (e > 0) {
if (e & 1) result = mulAbs(result, b);
e >>= 1;
if (e) b = mulAbs(b, b);
}
return stripLeadingZeros(result);
}
function appendZeros(absDigits: string, n: number): string {
if (n <= 0) return absDigits;
if (absDigits === "0") return "0";
return absDigits + new Array(n + 1).join("0");
}
function isOddAbs(absDigits: string): boolean {
const last = absDigits.charCodeAt(absDigits.length - 1) - 48;
return (last & 1) === 1;
}
function roundQuotientAbs(
qAbs: string,
rAbs: string,
denomAbs: string,
signExact: Sign,
mode: RoundingMode
): string {
qAbs = stripLeadingZeros(qAbs);
rAbs = stripLeadingZeros(rAbs);
denomAbs = stripLeadingZeros(denomAbs);
if (rAbs === "0") return qAbs;
const add1 = () => addAbs(qAbs, "1");
switch (mode) {
case "DOWN":
return qAbs;
case "UP":
return add1();
case "FLOOR":
return signExact < 0 ? add1() : qAbs;
case "CEILING":
return signExact > 0 ? add1() : qAbs;
case "HALF_UP":
case "HALF_DOWN":
case "HALF_EVEN": {
const twiceR = mulSmallAbs(rAbs, 2);
const c = cmpAbs(twiceR, denomAbs);
if (c > 0) return add1();
if (c < 0) return qAbs;
// tie
if (mode === "HALF_UP") return add1();
if (mode === "HALF_DOWN") return qAbs;
// HALF_EVEN
return isOddAbs(qAbs) ? add1() : qAbs;
}
default:
return qAbs;
}
}
function gcdAbs(a: string, b: string): string {
a = stripLeadingZeros(a);
b = stripLeadingZeros(b);
while (b !== "0") {
const r = divmodAbs(a, b).r;
a = b;
b = r;
}
return a;
}
function divExactAbs(a: string, b: string): string {
const dm = divmodAbs(a, b);
if (dm.r !== "0") throw new Error("Not divisible exactly");
return dm.q;
}
export class BigDecimal {
private sign!: Sign; // -1 / 0 / 1
private digits!: string; // abs(unscaled int), decimal string, no leading zeros except "0"
private scale!: number; // >= 0
private static fromParts(sign: Sign, digits: string, scale: number): BigDecimal {
const bd = Object.create(BigDecimal.prototype) as BigDecimal;
bd.scale = scale;
bd.digits = stripLeadingZeros(digits);
bd.sign = sign;
// normalize -0
if (bd.digits === "0") bd.sign = 0;
return bd;
}
constructor(value: string | number | bigint | BigDecimal, scale?: number) {
// clone
if (value instanceof BigDecimal) {
this.sign = value.sign;
this.digits = value.digits;
this.scale = value.scale;
return;
}
// bigint input (won't exist on Safari13, but kept for compatibility on modern engines)
if (typeof value === "bigint") {
const s = (scale === undefined ? 0 : scale);
if (!isInt(s) || s < 0) throw new Error("scale must be a non-negative integer");
const str = String(value); // e.g. "-123"
const neg = str.charAt(0) === "-";
const abs = neg ? str.slice(1) : str;
this.sign = abs === "0" ? 0 : (neg ? -1 : 1);
this.digits = stripLeadingZeros(abs);
this.scale = s;
return;
}
if (typeof value === "number") {
if (!Number.isFinite(value)) throw new Error("Invalid number");
value = String(value);
}
if (typeof value !== "string") {
throw new Error("Unsupported value type");
}
const parsed = BigDecimal.parseDecimalString(value);
this.sign = parsed.sign;
this.digits = parsed.digits;
this.scale = parsed.scale;
}
static from(value: string | number | bigint | BigDecimal, scale?: number): BigDecimal {
return new BigDecimal(value as any, scale);
}
getScale(): number {
return this.scale;
}
/** Safari13 版:没有 BigInt,返回“未缩放整数”的字符串(带符号)。 */
unscaledValue(): any {
if (this.sign === 0) return "0";
return (this.sign < 0 ? "-" : "") + this.digits;
}
signum(): -1 | 0 | 1 {
return this.sign;
}
abs(): BigDecimal {
if (this.sign >= 0) return this;
return BigDecimal.fromParts(1, this.digits, this.scale);
}
negate(): BigDecimal {
if (this.sign === 0) return this;
return BigDecimal.fromParts((this.sign === 1 ? -1 : 1), this.digits, this.scale);
}
add(other: BigDecimal | string | number | bigint): BigDecimal {
const b = BigDecimal.from(other);
const s = Math.max(this.scale, b.scale);
const aDigits = appendZeros(this.digits, s - this.scale);
const bDigits = appendZeros(b.digits, s - b.scale);
if (this.sign === 0) return BigDecimal.fromParts(b.sign, bDigits, s);
if (b.sign === 0) return BigDecimal.fromParts(this.sign, aDigits, s);
if (this.sign === b.sign) {
const sum = addAbs(aDigits, bDigits);
return BigDecimal.fromParts(this.sign, sum, s);
}
// different signs => subtraction
const c = cmpAbs(aDigits, bDigits);
if (c === 0) {
// result is 0, keep scale = s (like your BigInt version)
return BigDecimal.fromParts(0, "0", s);
}
if (c > 0) {
const diff = subAbs(aDigits, bDigits);
return BigDecimal.fromParts(this.sign, diff, s);
} else {
const diff = subAbs(bDigits, aDigits);
return BigDecimal.fromParts(b.sign, diff, s);
}
}
sub(other: BigDecimal | string | number | bigint): BigDecimal {
const b = BigDecimal.from(other);
return this.add(b.negate());
}
mul(other: BigDecimal | string | number | bigint): BigDecimal {
const b = BigDecimal.from(other);
if (this.sign === 0 || b.sign === 0) {
return BigDecimal.fromParts(0, "0", this.scale + b.scale);
}
const sign: Sign = (this.sign === b.sign ? 1 : -1);
const prod = mulAbs(this.digits, b.digits);
return BigDecimal.fromParts(sign, prod, this.scale + b.scale);
}
divide(other: BigDecimal | string | number | bigint): BigDecimal;
divide(other: BigDecimal | string | number | bigint, resultScale: number, rounding: RoundingMode): BigDecimal;
divide(other: BigDecimal | string | number | bigint, resultScale?: number, rounding?: RoundingMode): BigDecimal {
const b = BigDecimal.from(other);
if (b.sign === 0) throw new Error("Division by zero");
if (resultScale === undefined) {
return this.divideExact(b);
}
const rm: RoundingMode = rounding ? rounding : "HALF_UP";
return this.divideScaled(b, resultScale, rm);
}
setScale(newScale: number, rounding: RoundingMode = "HALF_UP"): BigDecimal {
if (!isInt(newScale) || newScale < 0) throw new Error("newScale must be a non-negative integer");
if (newScale === this.scale) return this;
if (newScale > this.scale) {
const diff = newScale - this.scale;
const newDigits = appendZeros(this.digits, diff);
return BigDecimal.fromParts(this.sign, newDigits, newScale);
}
// newScale < this.scale => divide by 10^(diff)
const diff = this.scale - newScale;
// qAbs / rAbs when dividing by 10^diff can be done by slicing
const denomAbs = "1" + new Array(diff + 1).join("0");
const len = this.digits.length;
let qAbs: string;
let rAbs: string;
if (len <= diff) {
qAbs = "0";
rAbs = this.digits;
} else {
qAbs = stripLeadingZeros(this.digits.slice(0, len - diff));
rAbs = stripLeadingZeros(this.digits.slice(len - diff));
}
const roundedQAbs = roundQuotientAbs(qAbs, rAbs, denomAbs, this.sign, rounding);
return BigDecimal.fromParts(this.sign, roundedQAbs, newScale);
}
stripTrailingZeros(): BigDecimal {
if (this.sign === 0) return this; // keep representation scale
let d = this.digits;
let s = this.scale;
while (s > 0 && d.length > 1 && d.charAt(d.length - 1) === "0") {
d = d.slice(0, -1);
s -= 1;
}
if (d === this.digits && s === this.scale) return this;
return BigDecimal.fromParts(this.sign, d, s);
}
compareTo(other: BigDecimal | string | number | bigint): -1 | 0 | 1 {
const b = BigDecimal.from(other);
if (this.sign < b.sign) return -1;
if (this.sign > b.sign) return 1;
if (this.sign === 0) return 0;
const s = Math.max(this.scale, b.scale);
const aDigits = appendZeros(this.digits, s - this.scale);
const bDigits = appendZeros(b.digits, s - b.scale);
const c = cmpAbs(aDigits, bDigits);
return this.sign > 0 ? c : (c === 0 ? 0 : (c === 1 ? -1 : 1));
}
equals(other: BigDecimal | string | number | bigint): boolean {
const b = BigDecimal.from(other);
return this.scale === b.scale && this.sign === b.sign && this.digits === b.digits;
}
equalsValue(other: BigDecimal | string | number | bigint): boolean {
return this.compareTo(other) === 0;
}
precision(): number {
if (this.sign === 0) return 1;
return this.digits.length;
}
round(mc: MathContext): BigDecimal {
if (!mc || !isInt(mc.precision) || mc.precision < 1) {
throw new Error("MathContext.precision must be an integer >= 1");
}
const rm: RoundingMode = mc.roundingMode ? mc.roundingMode : "HALF_UP";
const curPrec = this.precision();
if (curPrec <= mc.precision) return this;
const drop = curPrec - mc.precision; // digits to drop from unscaled
const denomAbs = "1" + new Array(drop + 1).join("0");
const len = this.digits.length;
let qAbs: string;
let rAbs: string;
if (len <= drop) {
qAbs = "0";
rAbs = this.digits;
} else {
qAbs = stripLeadingZeros(this.digits.slice(0, len - drop));
rAbs = stripLeadingZeros(this.digits.slice(len - drop));
}
const roundedQAbs = roundQuotientAbs(qAbs, rAbs, denomAbs, this.sign, rm);
let newScale = this.scale - drop;
let newDigits = roundedQAbs;
if (newScale < 0) {
newDigits = appendZeros(newDigits, -newScale);
newScale = 0;
}
return BigDecimal.fromParts(this.sign, newDigits, newScale);
}
movePointLeft(n: number): BigDecimal {
if (!isInt(n)) throw new Error("n must be an integer");
if (n === 0) return this;
if (n < 0) return this.movePointRight(-n);
return BigDecimal.fromParts(this.sign, this.digits, this.scale + n);
}
movePointRight(n: number): BigDecimal {
if (!isInt(n)) throw new Error("n must be an integer");
if (n === 0) return this;
if (n < 0) return this.movePointLeft(-n);
const newScale = this.scale - n;
if (newScale >= 0) {
return BigDecimal.fromParts(this.sign, this.digits, newScale);
}
// scale would become negative => expand digits and set scale=0
const newDigits = appendZeros(this.digits, -newScale);
return BigDecimal.fromParts(this.sign, newDigits, 0);
}
toPlainString(): string {
const signStr = this.sign < 0 ? "-" : "";
const abs = this.digits;
if (this.scale === 0) return signStr + abs;
const s = abs; // already a string digits
const pointPos = s.length - this.scale;
if (pointPos > 0) {
const intPart = s.slice(0, pointPos);
const fracPart = s.slice(pointPos);
return signStr + intPart + "." + fracPart;
} else {
const zeros = new Array((-pointPos) + 1).join("0");
return signStr + "0." + zeros + s;
}
}
toString(): string {
return this.toPlainString();
}
toNumber(): number {
return Number(this.toPlainString());
}
// -------------------- division helpers --------------------
private divideExact(divisor: BigDecimal): BigDecimal {
if (this.sign === 0) {
// 0 / x = 0 (scale 0 like BigInt version)
return BigDecimal.fromParts(0, "0", 0);
}
if (divisor.sign === 0) throw new Error("Division by zero");
const signExact: Sign = (this.sign === divisor.sign ? 1 : -1);
// num/den adjust for scales: num * 10^(sb-sa) / den
let numAbs = this.digits;
let denAbs = divisor.digits;
const exp = divisor.scale - this.scale;
if (exp >= 0) numAbs = appendZeros(numAbs, exp);
else denAbs = appendZeros(denAbs, -exp);
// reduce fraction by gcd
const g = gcdAbs(numAbs, denAbs);
numAbs = divExactAbs(numAbs, g);
denAbs = divExactAbs(denAbs, g);
// den must be 2^a * 5^b for terminating decimal
let d = denAbs;
let p2 = 0;
let p5 = 0;
while (d !== "0") {
const dm2 = divmodSmallAbs(d, 2);
if (dm2.r !== 0) break;
d = dm2.q;
p2++;
}
while (d !== "0") {
const dm5 = divmodSmallAbs(d, 5);
if (dm5.r !== 0) break;
d = dm5.q;
p5++;
}
if (stripLeadingZeros(d) !== "1") {
throw new Error("Non-terminating decimal expansion; specify scale and rounding mode");
}
const s = Math.max(p2, p5);
const mul2 = powAbs("2", s - p2);
const mul5 = powAbs("5", s - p5);
let intAbs = mulAbs(numAbs, mul2);
intAbs = mulAbs(intAbs, mul5);
return BigDecimal.fromParts(signExact, intAbs, s);
}
private divideScaled(divisor: BigDecimal, resultScale: number, rounding: RoundingMode): BigDecimal {
if (!isInt(resultScale) || resultScale < 0) throw new Error("resultScale must be a non-negative integer");
if (divisor.sign === 0) throw new Error("Division by zero");
if (this.sign === 0) {
return BigDecimal.fromParts(0, "0", resultScale);
}
const signExact: Sign = (this.sign === divisor.sign ? 1 : -1);
// exp = resultScale + sb - sa
const exp = resultScale + divisor.scale - this.scale;
let numeratorAbs: string;
let denominatorAbs: string;
if (exp >= 0) {
numeratorAbs = appendZeros(this.digits, exp);
denominatorAbs = divisor.digits;
} else {
numeratorAbs = this.digits;
denominatorAbs = appendZeros(divisor.digits, -exp);
}
// denominator is positive abs here
const dm = divmodAbs(numeratorAbs, denominatorAbs);
const qAbs = dm.q;
const rAbs = dm.r;
const roundedQAbs = roundQuotientAbs(qAbs, rAbs, denominatorAbs, signExact, rounding);
return BigDecimal.fromParts(signExact, roundedQAbs, resultScale);
}
// -------------------- parsing --------------------
private static parseDecimalString(input: string): { sign: Sign; digits: string; scale: number } {
const str = (input || "").trim();
if (!str) throw new Error("Empty decimal");
// [+|-]? (digits? "." digits? | "." digits) ( [eE][+|-]?digits )?
const m = /^([+-])?(?:(\d+)?(?:\.(\d*)?)?|\.(\d+))(?:[eE]([+-]?\d+))?$/.exec(str);
if (!m) throw new Error("Invalid decimal string: " + input);
const signChar = m[1] || "";
let sign: Sign = signChar === "-" ? -1 : 1;
const intPart = m[2] ? m[2] : "";
const fracPart = (m[4] ? m[4] : (m[3] ? m[3] : ""));
const expStr = m[5] ? m[5] : "0";
const exp = Number(expStr);
if (!isInt(exp)) throw new Error("Invalid exponent: " + expStr);
let digits = stripLeadingZeros((intPart + fracPart) || "0");
let scale = fracPart.length - exp;
if (scale < 0) {
digits = appendZeros(digits, -scale);
scale = 0;
}
// normalize sign for zero, but keep scale representation
if (digits === "0") sign = 0;
if (!isInt(scale) || scale < 0) throw new Error("Invalid scale after parsing");
return { sign, digits, scale };
}
}
微信扫码查看本文
发表评论