UNPKG

@rimbu/hashed

Version:

Immutable HashMap and HashSet implementations for TypeScript

743 lines (644 loc) 21 kB
import { Eq } from '@rimbu/common'; import { Stream, type StreamSource } from '@rimbu/stream'; /** * Interface used to hash objects for hashed collections. * @typeparam UK - the upper type limit for which the hasher is assumed to be valid * @note A hashcode is a 32-bit JS integer and therefore signed. * You can obtain the 32-bit version of any JS number by applying a * bitwise operator to it, e.g. if x is a number, use x | 0. */ export interface Hasher<UK> { /** * Returns true if this hasher can be applied to the given `obj` object. * @param obj - the object to check * @example * ```ts * const h = Hasher.numberHasher() * console.log(h.isValid(5)) * // => true * console.log(h.isValid('a')) * // => false * ``` */ isValid(obj: unknown): obj is UK; /** * Returns the 32-bit hash code for the given `value`. * @param value - the value to hash * @note it is assumed that the caller has verified that the given object * is valid, either by knowing the types up front, or by using the `isValid` function. * @example * ```ts * const h = Hasher.anyHasher() * h.hash([1, 3, 2]) * ``` */ hash(value: UK): number; } export namespace Hasher { const MAX_STEP_BITS = 6; const STRING_INIT = 21; const BOOL_TRUE = -37; const BOOL_FALSE = -73; const OBJ_INIT = 91; const UNDEF_VALUE = -31; const NULL_VALUE = -41; const _anyFlatHasher: Hasher<any> = createAnyHasher('FLAT'); const _anyShallowHasher: Hasher<any> = createAnyHasher('SHALLOW'); const _anyDeepHasher: Hasher<any> = createAnyHasher('DEEP'); export function defaultHasher(): Hasher<any> { return _anyShallowHasher; } function createStringHasher(maxStepBits: number): Hasher<string> { const maxSteps = 1 << maxStepBits; return Object.freeze({ isValid(obj: unknown): obj is string { return typeof obj === 'string'; }, hash(value): number { const length = Math.min(value.length, maxSteps); const stepSize = Math.max(1, value.length >>> maxStepBits); let result = STRING_INIT; // implement times 31 = 32 - 1 for (let i = 0; i < length; i += stepSize) { result = ((result << 5) - result + value.charCodeAt(i)) | 0; } return result; }, }); } const _stringHasher: Hasher<string> = createStringHasher(MAX_STEP_BITS); /** * Returns a Hasher instance for string values. * @example * ```ts * const h = Hasher.stringHasher() * h.hash('abc') * ``` */ export function stringHasher(): Hasher<string> { return _stringHasher; } const _anyToStringHasher: Hasher<any> = Object.freeze({ isValid(obj: unknown): obj is any { return true; }, hash(value) { return _stringHasher.hash(Eq.convertAnyToString(value)); }, }); /** * Returns a Hasher instance that hashes the string representation of any value * @param maxStepBits - the maximum amount of samples to take from the string * @example * ```ts * const h = Hasher.anyToStringHasher() * h.hash([1, 3, 'a']) * ``` */ export function anyToStringHasher(maxStepBits?: number): Hasher<any> { if (undefined === maxStepBits) return _anyToStringHasher; return createStringHasher(maxStepBits); } const _anyJsonStringHasher: Hasher<any> = Object.freeze({ isValid(obj: unknown): obj is any { return true; }, hash(value) { return _stringHasher.hash(JSON.stringify(value)); }, }); /** * Returns a Hasher instance that hashes any value by hashing the string resulting from * applying JSON.stringify to the value. * @example * ```ts * const h = Hasher.anyJsonStringHasher() * console.log(h.hash({ a: 1, b: 2 }) === h.hash({ b: 2, a: 1 })) * // => false * ``` */ export function anyJsonStringHasher(): Hasher<any> { return _anyJsonStringHasher; } function createStringCaseInsensitiveHasher( maxStepBits: number ): Hasher<string> { const maxSteps = 1 << maxStepBits; return Object.freeze({ isValid(obj: unknown): obj is string { return typeof obj === 'string'; }, hash(value): number { const length = Math.min(value.length, maxSteps); const stepSize = Math.max(1, value.length >>> maxStepBits); let result = STRING_INIT; // implement times 31 = 32 - 1 for (let i = 0; i < length; i += stepSize) { const char = value.charAt(i).toUpperCase(); result = ((result << 5) - result + char.charCodeAt(0)) | 0; } return result; }, }); } const _stringCaseInsensitiveHasher: Hasher<string> = createStringCaseInsensitiveHasher(MAX_STEP_BITS); export function stringCaseInsensitiveHasher(): Hasher<string> { return _stringCaseInsensitiveHasher; } function createArrayHasher<T>( itemHasher: Hasher<T>, maxStepBits: number ): Hasher<readonly T[]> { const maxSteps = 1 << maxStepBits; return Object.freeze({ isValid(obj: unknown): obj is readonly T[] { return Array.isArray(obj); }, hash(value): number { const length = Math.min(value.length, maxSteps); const stepSize = Math.max(1, value.length >>> maxStepBits); let result = value.length | 0; // implement times 31 = 32 - 1 for (let i = 0; i < length; i += stepSize) { result = ((result << 5) - result + (i + 1) * itemHasher.hash(value[i])) | 0; } return result; }, }); } const _arrayAnyHasher: Hasher<readonly any[]> = createArrayHasher( defaultHasher(), MAX_STEP_BITS ); /** * Returns a Hasher that hashes arrays of elements by sampling the array and using * the given `itemHasher` to hash the sampled elements. * @typeparam T - the array element type * @param options - (optional) an object containing the following items:<br/> * - itemHasher: (optional) a Hasher instance used to hash elements in the array<br/> * - maxStepBits: (optional) the amount of bits to determine the maximum amount of array * elements to process * @example * ```ts * const h = Hasher.arrayHasher() * console.log(h.hash([1, 2, 3] === h.hash([1, 3, 2]))) * // => false * ``` */ export function arrayHasher<T = any>(options?: { itemHasher?: Hasher<T>; maxStepBits?: number; }): Hasher<readonly T[]> { if (undefined === options) return _arrayAnyHasher; return createArrayHasher( options.itemHasher ?? anyFlatHasher(), options.maxStepBits ?? MAX_STEP_BITS ); } function createStreamSourceHasher<T>( itemHasher: Hasher<T> = defaultHasher(), maxStepBits = MAX_STEP_BITS ): Hasher<StreamSource<T>> { const maxSteps = 1 << maxStepBits; return Object.freeze({ isValid(obj: unknown): obj is StreamSource<T> { return ( typeof obj === 'object' && obj !== null && Symbol.iterator in obj ); }, hash(source): number { const iter = Stream.from(source)[Symbol.iterator](); let hashItems: T[] = []; const storeItems: T[] = []; let skipAmount = 1; let skipCount = 0; const done = Symbol('done'); let value: T | typeof done; let length = 0; while (done !== (value = iter.fastNext(done))) { length++; skipCount++; if (skipCount >= skipAmount) { storeItems.push(value); skipCount = 0; } if (storeItems.length >= maxSteps) { hashItems = storeItems.slice(); // remove elements from store items const newLength = maxSteps >>> 1; for (let index = 0; index < newLength; index += 1) { storeItems[index] = storeItems[index * 2]; } storeItems.length = newLength; skipAmount *= 2; } } if (hashItems.length === 0) { hashItems = storeItems; } const itemHash = createArrayHasher(itemHasher, maxStepBits).hash( hashItems ); // include length in hash return ((itemHash << 5) - itemHash + length) | 0; }, }); } const _streamSourceAnyHasher: Hasher<StreamSource<any>> = createStreamSourceHasher(defaultHasher(), MAX_STEP_BITS); /** * Returns a Hasher instance that hashes any StreamSource limited to a certain amount * of elements to prevent haning on infinite streams. * @typeparam T - the StreamSource element type * @param options - (optional) an object containing the following items:<br/> * - itemHasher: (optional) a Hasher instance used to hash elements in the array<br/> * - maxStepBits: (optional) the amount of bits to determine the maximum amount of array * elements to process * @example * ```ts * const h = Hasher.streamSourceHasher() * h.hash(Stream.random()) * // infinite stream but will not hang due to the max step limit * ``` */ export function streamSourceHasher<T = any>(options?: { itemHasher?: Hasher<T>; maxStepBits?: number; }): Hasher<StreamSource<T>> { if (undefined === options) return _streamSourceAnyHasher; return createStreamSourceHasher(options.itemHasher, options.maxStepBits); } // bitwise operators only work on 32-bit integers const MIN_HASH = -Math.pow(2, 31); const MAX_HASH = Math.pow(2, 31) - 1; const _numberHasher: Hasher<number> = Object.freeze({ isValid(obj: unknown): obj is number { return typeof obj === 'number'; }, hash(value) { if (Number.isInteger(value)) { return value | 0; } if (Number.isNaN(value)) { return MAX_HASH - 1; } if (value === Number.POSITIVE_INFINITY) { return MAX_HASH; } if (value === Number.NEGATIVE_INFINITY) { return MIN_HASH; } // not sure what else it could be return _anyToStringHasher.hash(value); }, }); /** * Returns a Hasher instance that hashes numbers, including 'special' values like NaN and infinities. * @example * ```ts * const h = Hasher.numberHasher() * console.log(h.hash(Number.POSITIVE_INFINITY) === h.hash(Number.NEGATIVE_INFINITY)) * // => false * console.log(h.hash(Number.NaN) === h.hash(Number.NaN)) * // => true * ``` */ export function numberHasher(): Hasher<number> { return _numberHasher; } const _booleanHasher: Hasher<boolean> = Object.freeze({ isValid(obj: unknown): obj is boolean { return typeof obj === 'boolean'; }, hash(value) { return value ? BOOL_TRUE : BOOL_FALSE; }, }); /** * Returns a Hasher instance that hashes booleans. * @example * ```ts * const h = Hasher.booleanHasher() * console.log(h.hash(true) === h.hash(false)) * // => false * ``` */ export function booleanHasher(): Hasher<boolean> { return _booleanHasher; } const _bigintHasher: Hasher<bigint> = Object.freeze({ isValid(obj: unknown): obj is bigint { return typeof obj === 'bigint'; }, hash: _anyToStringHasher.hash, }); /** * Returns a Hasher instance that hashes bigints. * @example * ```ts * const h = Hasher.bigintHasher() * console.log(h.hash(BigInt(5)) === h.hash(BigInt(10))) * // => false * ``` */ export function bigintHasher(): Hasher<bigint> { return _bigintHasher; } /** * Returns a Hasher instance that hashes the `.valueOf` value of the given * object using the given `valueHasher` for instances of given `cls` class. * @typeparam T - the input object type * @typeparam V - the .valueOf property type * @param cls - the class containing the contructur to check for validity of a given object * @param valueHasher - the Hasher instance to use for the `.valueOf` values * @example * ```ts * const h = Hasher.createValueOfHasher(Date) * console.log(h.isValid(new Boolean(true))) * // => false * const d1 = new Date() * const d2 = new Date(d1) * console.log(h.hash(d1) === h.hash(d2)) * // => true * ``` */ export function createValueOfHasher<T extends { valueOf(): V }, V>( cls: { new (): T; }, valueHasher: Hasher<V> = anyFlatHasher() ): Hasher<T> { return Object.freeze({ isValid(obj): obj is T { return obj instanceof cls; }, hash(value): number { return valueHasher.hash(value.valueOf()); }, }); } const _BooleanHasher: Hasher<Boolean> = createValueOfHasher( Boolean, _booleanHasher ); const _DateHasher: Hasher<Date> = createValueOfHasher(Date, _numberHasher); /** * Returns a Hasher instance that hashes Dates. * @example * ```ts * const h = Hasher.dateHasher() * const d1 = new Date() * const d2 = new Date(d1) * console.log(h.hash(d1) === h.hash(d2)) * // => true * ``` */ export function dateHasher(): Hasher<Date> { return _DateHasher; } const _NumberHasher: Hasher<Number> = createValueOfHasher( Number, _numberHasher ); const _StringHasher: Hasher<String> = createValueOfHasher( String, _stringHasher ); const _wrappedHashers: Hasher<unknown>[] = [ _BooleanHasher, _DateHasher, _NumberHasher, _StringHasher, ]; function tryWrappedHasher(value: any): number | undefined { let i = -1; const len = _wrappedHashers.length; while (++i < len) { const hasher = _wrappedHashers[i]; if (hasher.isValid(value)) { return hasher.hash(value); } } return undefined; } function createObjectHasher( keyHasher: Hasher<unknown>, valueHasher: Hasher<unknown> ): Hasher<Record<any, any>> { return Object.freeze({ isValid(obj): obj is Record<any, any> { return typeof obj === 'object'; }, hash(value): number { if (value === null) return NULL_VALUE; let result = OBJ_INIT; // implement order independent hash for (const key in value) { const keyValue = value[key]; const keyHash = keyHasher.hash(key); const valueHash = valueHasher.hash(keyValue); result = result ^ (keyHash + valueHash); } return result; }, }); } const _objectShallowHasher: Hasher<Record<any, any>> = createObjectHasher( anyFlatHasher(), anyFlatHasher() ); const _objectDeepHasher: Hasher<Record<any, any>> = createObjectHasher( anyFlatHasher(), anyDeepHasher() ); /** * Returns a Hasher instance that hashes objects of key type K and value type V. * @typeparam K - the key type * @typeparam V - the value type * @param options - (optional) an object containing:<br/> * - keyHasher: (optional) a Hasher instance that is used to hash object keys<br/> * - valueHasher: (optional) a Hasher instance that is used to hash object values * @example * ```ts * const h = Hasher.objectHasher() * console.log(h.hash({ a: 1, b: 2 }) === h.hash({ b: 2, a: 1 })) * // => true * ``` */ export function objectHasher< K extends string | number | symbol, V = any, >(options?: { keyHasher: Hasher<K>; valueHasher: Hasher<V>; }): Hasher<Record<K, V>> { if (undefined === options) return _objectShallowHasher; return createObjectHasher(options.keyHasher, options.valueHasher); } /** * Returns a Hasher instance that hashes objects of key type K and value type V. * If a value if an object or array, it will convert those values to a string. * @typeparam K - the key type * @typeparam V - the value type * @example * ```ts * const h = Hasher.objectShallowHasher() * console.log(h.hash({ a: 1, b: 2 }) === h.hash({ b: 2, a: 1 })) * // => true * ``` */ export function objectShallowHasher< K extends string | number | symbol, V = any, >(): Hasher<Record<K, V>> { return _objectShallowHasher; } /** * Returns a Hasher instance that hashes objects of key type K and value type V. * If a value if an object or array, it will recursively hash its values. * @note be careful with circular structures, they can cause an infinite loop * @typeparam K - the key type * @typeparam V - the value type * @example * ```ts * const h = Hasher.objectDeepHasher() * console.log(h.hash({ a: 1, b: 2 }) === h.hash({ b: 2, a: 1 })) * // => true * ``` */ export function objectDeepHasher< K extends string | number | symbol, V = any, >(): Hasher<Record<K, V>> { return _objectDeepHasher; } function createAnyHasher( mode: 'FLAT' | 'SHALLOW' | 'DEEP', maxStepBits = MAX_STEP_BITS ): Hasher<any> { return Object.freeze({ isValid(obj): obj is any { return true; }, hash(value): number { const valueType = typeof value; switch (valueType) { case 'undefined': return UNDEF_VALUE; case 'bigint': return _bigintHasher.hash(value); case 'boolean': return _booleanHasher.hash(value); case 'number': return _numberHasher.hash(value); case 'string': return _stringHasher.hash(value); case 'function': case 'symbol': return _anyToStringHasher.hash(value); case 'object': { if (null === value) return NULL_VALUE; const result = tryWrappedHasher(value); if (undefined !== result) return result; if (mode !== 'FLAT') { if (Array.isArray(value)) { if (mode === 'SHALLOW') { return createArrayHasher(_anyFlatHasher, MAX_STEP_BITS).hash( value ); } return createArrayHasher(this, maxStepBits).hash(value); } if (_streamSourceAnyHasher.isValid(value)) { if (mode === 'SHALLOW') { return createStreamSourceHasher( _anyFlatHasher, maxStepBits ).hash(value); } return createStreamSourceHasher(this, maxStepBits).hash(value); } if (_objectShallowHasher.isValid(value)) { if (mode === 'SHALLOW') return _objectShallowHasher.hash(value); return createObjectHasher(_anyFlatHasher, this).hash(value); } } return _anyToStringHasher.hash(value); } } }, }); } /** * Returns a Hasher instance that hashes any value, but never traverses into an object * or array to hash its elements. In those cases it will use toString. * @example * ```ts * const h = Hasher.anyFlatHasher() * console.log(h.hash({ a: 1, b: 2 }) === h.hash({ b: 2, a: 1 })) * // => false * ``` */ export function anyFlatHasher(): Hasher<any> { return _anyFlatHasher; } /** * Returns a Hasher instance that hashes any value, but only traverses into an object * or array to hash its elements one level deep. After one level, it will use toString. * @example * ```ts * const h = Hasher.anyShallowHasher() * console.log(h.hash({ a: 1, b: 2 }) === h.hash({ b: 2, a: 1 })) * // => true * console.log(h.hash([{ a: 1, b: 2 }]) === h.hash([{ b: 2, a: 1 }])) * // => false * ``` */ export function anyShallowHasher(): Hasher<any> { return _anyShallowHasher; } /** * Returns a Hasher instance that hashes any value, and traverses into an object * or array to hash its elements. * @example * ```ts * const h = Hasher.anyDeepHasher() * console.log(h.hash({ a: 1, b: 2 }) === h.hash({ b: 2, a: 1 })) * // => true * console.log(h.hash([{ a: 1, b: 2 }]) === h.hash([{ b: 2, a: 1 }])) * // => true * ``` */ export function anyDeepHasher(): Hasher<any> { return _anyDeepHasher; } /** * Returns a Hasher that will return equal hash values for values in a tuple regardless * of their order, and uses the given `hasher` function to hash the tuple elements. * @param hasher - the Hasher instance to use for tuple elements * @example * ```ts * const h = Hasher.tupleSymmetric() * console.log(h.hash(['abc', 'def']) === h.hash(['def', 'abc'])) * ``` */ export function tupleSymmetric<T>( hasher: Hasher<T> = anyShallowHasher() ): Hasher<readonly [T, T]> { return Object.freeze({ isValid(obj: unknown): obj is readonly [T, T] { return ( Array.isArray(obj) && obj.length === 2 && hasher.isValid(obj[0]) && hasher.isValid(obj[1]) ); }, hash(value: readonly [T, T]): number { return (hasher.hash(value[0]) + hasher.hash(value[1])) | 0; }, }); } }