在 JavaScript 中实现 BigDecimal

2026-02-06 14:55:37  阅读 409 次 评论 0 条

最近项目要求,在前端计算单价,由于 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 };
    }
}



微信扫码查看本文
本文地址:https://www.yangguangdream.com/?id=2270
版权声明:本文为原创文章,版权归 编辑君 所有,欢迎分享本文,转载请保留出处!

发表评论


表情

还没有留言,还不快点抢沙发?