diff --git a/src/index.ts b/src/index.ts index d7b418e..fd988a9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,5 @@ +import { LRUCache } from "./lru-cache"; + export type CompareResult = -1 | 0 | 1; const MAX_SIGNIFICANT_DIGITS = 17; //Maximum number of digits of precision to assume in Number @@ -14,6 +16,8 @@ const NUMBER_EXP_MIN = -324; //The smallest exponent that can appear in a Number const MAX_ES_IN_A_ROW = 5; //For default toString behaviour, when to swap from eee... to (e^n) syntax. +const DEFAULT_FROM_STRING_CACHE_SIZE = (1 << 10) - 1; // The default size of the LRU cache used to cache Decimal.fromString. + const IGNORE_COMMAS = true; const COMMAS_ARE_DECIMAL_POINTS = false; @@ -348,6 +352,8 @@ export default class Decimal { public static readonly dNumberMax = FC(1, 0, Number.MAX_VALUE); public static readonly dNumberMin = FC(1, 0, Number.MIN_VALUE); + private static fromStringCache = new LRUCache(DEFAULT_FROM_STRING_CACHE_SIZE); + public sign = 0; public mag = 0; public layer = 0; @@ -485,7 +491,22 @@ export default class Decimal { * is required. */ public static fromValue_noAlloc(value: DecimalSource): Readonly { - return value instanceof Decimal ? value : new Decimal(value); + if (value instanceof Decimal) { + return value; + } else if (typeof value === "string") { + const cached = Decimal.fromStringCache.get(value); + if (cached !== undefined) { + return cached; + } + return Decimal.fromString(value); + } else if (typeof value === "number") { + return Decimal.fromNumber(value); + } else { + // This should never happen... but some users like Prestige Tree Rewritten + // pass undefined values in as DecimalSources, so we should handle this + // case to not break them. + return Decimal.dZero; + } } public static abs(value: DecimalSource): Decimal { @@ -1152,6 +1173,11 @@ export default class Decimal { } public fromString(value: string): Decimal { + const originalValue = value; + const cached = Decimal.fromStringCache.get(originalValue); + if (cached !== undefined) { + return this.fromDecimal(cached); + } if (IGNORE_COMMAS) { value = value.replace(",", ""); } else if (COMMAS_ARE_DECIMAL_POINTS) { @@ -1176,6 +1202,9 @@ export default class Decimal { this.sign = result.sign; this.layer = result.layer; this.mag = result.mag; + if (Decimal.fromStringCache.maxSize >= 1) { + Decimal.fromStringCache.set(originalValue, Decimal.fromDecimal(this)); + } return this; } } @@ -1198,6 +1227,9 @@ export default class Decimal { this.sign = result.sign; this.layer = result.layer; this.mag = result.mag; + if (Decimal.fromStringCache.maxSize >= 1) { + Decimal.fromStringCache.set(originalValue, Decimal.fromDecimal(this)); + } return this; } } @@ -1212,6 +1244,9 @@ export default class Decimal { this.sign = result.sign; this.layer = result.layer; this.mag = result.mag; + if (Decimal.fromStringCache.maxSize >= 1) { + Decimal.fromStringCache.set(originalValue, Decimal.fromDecimal(this)); + } return this; } } @@ -1237,6 +1272,9 @@ export default class Decimal { this.sign = result.sign; this.layer = result.layer; this.mag = result.mag; + if (Decimal.fromStringCache.maxSize >= 1) { + Decimal.fromStringCache.set(originalValue, Decimal.fromDecimal(this)); + } return this; } } @@ -1257,6 +1295,9 @@ export default class Decimal { this.sign = result.sign; this.layer = result.layer; this.mag = result.mag; + if (Decimal.fromStringCache.maxSize >= 1) { + Decimal.fromStringCache.set(originalValue, Decimal.fromDecimal(this)); + } return this; } } @@ -1268,13 +1309,21 @@ export default class Decimal { if (ecount === 0) { const numberAttempt = parseFloat(value); if (isFinite(numberAttempt)) { - return this.fromNumber(numberAttempt); + this.fromNumber(numberAttempt); + if (Decimal.fromStringCache.size >= 1) { + Decimal.fromStringCache.set(originalValue, Decimal.fromDecimal(this)); + } + return this; } } else if (ecount === 1) { //Very small numbers ("2e-3000" and so on) may look like valid floats but round to 0. const numberAttempt = parseFloat(value); if (isFinite(numberAttempt) && numberAttempt !== 0) { - return this.fromNumber(numberAttempt); + this.fromNumber(numberAttempt); + if (Decimal.fromStringCache.maxSize >= 1) { + Decimal.fromStringCache.set(originalValue, Decimal.fromDecimal(this)); + } + return this; } } @@ -1296,6 +1345,9 @@ export default class Decimal { this.layer = parseFloat(layerstring); this.mag = parseFloat(newparts[1].substr(i + 1)); this.normalize(); + if (Decimal.fromStringCache.maxSize >= 1) { + Decimal.fromStringCache.set(originalValue, Decimal.fromDecimal(this)); + } return this; } } @@ -1305,6 +1357,9 @@ export default class Decimal { this.sign = 0; this.layer = 0; this.mag = 0; + if (Decimal.fromStringCache.maxSize >= 1) { + Decimal.fromStringCache.set(originalValue, Decimal.fromDecimal(this)); + } return this; } const mantissa = parseFloat(parts[0]); @@ -1312,6 +1367,9 @@ export default class Decimal { this.sign = 0; this.layer = 0; this.mag = 0; + if (Decimal.fromStringCache.maxSize >= 1) { + Decimal.fromStringCache.set(originalValue, Decimal.fromDecimal(this)); + } return this; } let exponent = parseFloat(parts[parts.length - 1]); @@ -1346,6 +1404,9 @@ export default class Decimal { this.sign = result.sign; this.layer = result.layer; this.mag = result.mag; + if (Decimal.fromStringCache.maxSize >= 1) { + Decimal.fromStringCache.set(originalValue, Decimal.fromDecimal(this)); + } return this; } else { //at eee and above, mantissa is too small to be recognizable! @@ -1354,6 +1415,9 @@ export default class Decimal { } this.normalize(); + if (Decimal.fromStringCache.maxSize >= 1) { + Decimal.fromStringCache.set(originalValue, Decimal.fromDecimal(this)); + } return this; } diff --git a/src/lru-cache.ts b/src/lru-cache.ts new file mode 100644 index 0000000..ec78e6a --- /dev/null +++ b/src/lru-cache.ts @@ -0,0 +1,139 @@ +/** + * A LRU cache intended for caching pure functions. + */ +export class LRUCache { + private map = new Map>(); + // Invariant: Exactly one of the below is true before and after calling a + // LRUCache method: + // - first and last are both undefined, and map.size() is 0. + // - first and last are the same object, and map.size() is 1. + // - first and last are different objects, and map.size() is greater than 1. + private first: ListNode | undefined = undefined; + private last: ListNode | undefined = undefined; + maxSize: number; + + /** + * @param maxSize The maximum size for this cache. We recommend setting this + * to be one less than a power of 2, as most hashtables - including V8's + * Object hashtable (https://crsrc.org/c/v8/src/objects/ordered-hash-table.cc) + * - uses powers of two for hashtable sizes. It can't exactly be a power of + * two, as a .set() call could temporarily set the size of the map to be + * maxSize + 1. + */ + constructor(maxSize: number) { + this.maxSize = maxSize; + } + + get size(): number { + return this.map.size; + } + + /** + * Gets the specified key from the cache, or undefined if it is not in the + * cache. + * @param key The key to get. + * @returns The cached value, or undefined if key is not in the cache. + */ + get(key: K): V | undefined { + const node = this.map.get(key); + if (node === undefined) { + return undefined; + } + // It is guaranteed that there is at least one item in the cache. + // Therefore, first and last are guaranteed to be a ListNode... + // but if there is only one item, they might be the same. + + // Update the order of the list to make this node the first node in the + // list. + // This isn't needed if this node is already the first node in the list. + if (node !== this.first) { + // As this node is DIFFERENT from the first node, it is guaranteed that + // there are at least two items in the cache. + // However, this node could possibly be the last item. + if (node === this.last) { + // This node IS the last node. + this.last = node.prev; + // From the invariants, there must be at least two items in the cache, + // so node - which is the original "last node" - must have a defined + // previous node. Therefore, this.last - set above - must be defined + // here. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.last!.next = undefined; + } else { + // This node is somewhere in the middle of the list, so there must be at + // least THREE items in the list, and this node's prev and next must be + // defined here. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + node.prev!.next = node.next; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + node.next!.prev = node.prev; + } + node.next = this.first; + // From the invariants, there must be at least two items in the cache, so + // this.first must be a valid ListNode. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.first!.prev = node; + this.first = node; + } + return node.value; + } + + /** + * Sets an entry in the cache. + * + * @param key The key of the entry. + * @param value The value of the entry. + * @throws Error, if the map already contains the key. + */ + set(key: K, value: V): void { + // Ensure that this.maxSize >= 1. + if (this.maxSize < 1) { + return; + } + if (this.map.has(key)) { + throw new Error("Cannot update existing keys in the cache"); + } + const node = new ListNode(key, value); + // Move node to the front of the list. + if (this.first === undefined) { + // If the first is undefined, the last is undefined too. + // Therefore, this cache has no items in it. + this.first = node; + this.last = node; + } else { + // This cache has at least one item in it. + node.next = this.first; + this.first.prev = node; + this.first = node; + } + this.map.set(key, node); + + while (this.map.size > this.maxSize) { + // We are guaranteed that this.maxSize >= 1, + // so this.map.size is guaranteed to be >= 2, + // so this.first and this.last must be different valid ListNodes, + // and this.last.prev must also be a valid ListNode (possibly this.first). + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const last = this.last!; + this.map.delete(last.key); + this.last = last.prev; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.last!.next = undefined; + } + } +} + +/** + * A node in a doubly linked list. + */ +class ListNode { + key: K; + value: V; + next: ListNode | undefined = undefined; + prev: ListNode | undefined = undefined; + + constructor(key: K, value: V) { + this.key = key; + this.value = value; + } +}