From 8b204d693bfef3e5dc541e0848acc6b9cff4e16e Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 30 Jul 2021 18:21:19 -0400 Subject: [PATCH 01/27] Tailor array comparison logic to arrays. --- packages/equality/src/equality.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/equality/src/equality.ts b/packages/equality/src/equality.ts index 00b03c43..aab113f0 100644 --- a/packages/equality/src/equality.ts +++ b/packages/equality/src/equality.ts @@ -36,10 +36,11 @@ function check(a: any, b: any): boolean { switch (aTag) { case '[object Array]': - // Arrays are a lot like other objects, but we can cheaply compare their - // lengths as a short-cut before comparing their elements. - if (a.length !== b.length) return false; - // Fall through to object case... + return previouslyCompared(a, b) || ( + a.length === b.length && + (a as any[]).every((child, i) => check(child, b[i])) + ); + case '[object Object]': { if (previouslyCompared(a, b)) return true; From e3296cb18a0df6b35fe3573c6539810eb5704dff Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 30 Jul 2021 18:28:34 -0400 Subject: [PATCH 02/27] Reject !== objects with custom prototypes. --- packages/equality/src/equality.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/equality/src/equality.ts b/packages/equality/src/equality.ts index aab113f0..7bef85e2 100644 --- a/packages/equality/src/equality.ts +++ b/packages/equality/src/equality.ts @@ -1,7 +1,14 @@ -const { toString, hasOwnProperty } = Object.prototype; const fnToStr = Function.prototype.toString; const previousComparisons = new Map>(); +const { + getPrototypeOf, + prototype: { + toString, + hasOwnProperty, + }, +} = Object; + /** * Performs a deep equality check on two JavaScript values, tolerating cycles. */ @@ -16,6 +23,11 @@ export function equal(a: any, b: any): boolean { // Allow default imports as well. export default equal; +function isPlainObject(obj: any): obj is Record { + const proto = getPrototypeOf(obj); + return proto === null || proto === Object.prototype; +} + function check(a: any, b: any): boolean { // If the two values are strictly equal, our job is easy. if (a === b) { @@ -42,6 +54,11 @@ function check(a: any, b: any): boolean { ); case '[object Object]': { + if (!isPlainObject(a) || + !isPlainObject(b)) { + return false; + } + if (previouslyCompared(a, b)) return true; const aKeys = definedKeys(a); From a331e489740168921a4d14d7578d220ee7fb6bd1 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Sun, 1 Aug 2021 14:28:58 -0400 Subject: [PATCH 03/27] Support a.equals(b) && b.equals(a) equality. --- packages/equality/src/equality.ts | 36 ++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/equality/src/equality.ts b/packages/equality/src/equality.ts index 7bef85e2..1f60404a 100644 --- a/packages/equality/src/equality.ts +++ b/packages/equality/src/equality.ts @@ -23,9 +23,35 @@ export function equal(a: any, b: any): boolean { // Allow default imports as well. export default equal; +function isNonNullObject(obj: any): obj is Record { + return obj !== null && typeof obj === "object"; +} + +function hasEqualsMethod(obj: any): obj is { + equals(that: any): boolean; +} { + return ( + isNonNullObject(obj) && + typeof obj.equals === "function" && + obj.equals(obj) === true + ); +} + function isPlainObject(obj: any): obj is Record { - const proto = getPrototypeOf(obj); - return proto === null || proto === Object.prototype; + if (isNonNullObject(obj)) { + const proto = getPrototypeOf(obj); + return proto === null || proto === Object.prototype; + } + return false; +} + +function tryEqualsMethod(a: any, b: any): boolean { + return ( + hasEqualsMethod(a) && + hasEqualsMethod(b) && + a.equals(b) && + b.equals(a) + ); } function check(a: any, b: any): boolean { @@ -56,7 +82,7 @@ function check(a: any, b: any): boolean { case '[object Object]': { if (!isPlainObject(a) || !isPlainObject(b)) { - return false; + return tryEqualsMethod(a, b); } if (previouslyCompared(a, b)) return true; @@ -190,6 +216,10 @@ function check(a: any, b: any): boolean { } } + if (isNonNullObject(a) && isNonNullObject(b)) { + return tryEqualsMethod(a, b); + } + // Otherwise the values are not equal. return false; } From 481396e8ba9ed3f78c39d6dc7043827547e23fd4 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 4 Aug 2021 17:53:56 -0400 Subject: [PATCH 04/27] Pass check helper function to a.equals(b, check). --- packages/equality/src/equality.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/equality/src/equality.ts b/packages/equality/src/equality.ts index 1f60404a..6fa6b056 100644 --- a/packages/equality/src/equality.ts +++ b/packages/equality/src/equality.ts @@ -27,13 +27,15 @@ function isNonNullObject(obj: any): obj is Record { return obj !== null && typeof obj === "object"; } -function hasEqualsMethod(obj: any): obj is { - equals(that: any): boolean; -} { +interface Equatable { + equals(that: any, helper: typeof check): boolean; +} + +function hasEqualsMethod(obj: any): obj is Equatable { return ( isNonNullObject(obj) && typeof obj.equals === "function" && - obj.equals(obj) === true + obj.equals(obj, check) === true ); } @@ -49,8 +51,8 @@ function tryEqualsMethod(a: any, b: any): boolean { return ( hasEqualsMethod(a) && hasEqualsMethod(b) && - a.equals(b) && - b.equals(a) + a.equals(b, check) && + b.equals(a, check) ); } From 7f6ce25d71ddb3e6ee385516719bc043d01d6779 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 4 Aug 2021 19:00:20 -0400 Subject: [PATCH 05/27] Switch to a more object-oriented DeepChecker#check style. We can't get away with having only one previousComparisons Map any more, now that we're allowing user-provided code to run during the recursive comparison, because that user-provided code could call the top-level equal(a, b) function reentrantly. --- packages/equality/src/equality.ts | 380 +++++++++++++++--------------- 1 file changed, 193 insertions(+), 187 deletions(-) diff --git a/packages/equality/src/equality.ts b/packages/equality/src/equality.ts index 6fa6b056..33b1a848 100644 --- a/packages/equality/src/equality.ts +++ b/packages/equality/src/equality.ts @@ -1,5 +1,4 @@ const fnToStr = Function.prototype.toString; -const previousComparisons = new Map>(); const { getPrototypeOf, @@ -13,30 +12,18 @@ const { * Performs a deep equality check on two JavaScript values, tolerating cycles. */ export function equal(a: any, b: any): boolean { - try { - return check(a, b); - } finally { - previousComparisons.clear(); - } + return new DeepChecker().check(a, b); } // Allow default imports as well. export default equal; -function isNonNullObject(obj: any): obj is Record { - return obj !== null && typeof obj === "object"; +export interface Equatable { + equals(that: T, helper: DeepChecker["check"]): boolean; } -interface Equatable { - equals(that: any, helper: typeof check): boolean; -} - -function hasEqualsMethod(obj: any): obj is Equatable { - return ( - isNonNullObject(obj) && - typeof obj.equals === "function" && - obj.equals(obj, check) === true - ); +function isNonNullObject(obj: any): obj is Record { + return obj !== null && typeof obj === "object"; } function isPlainObject(obj: any): obj is Record { @@ -47,183 +34,223 @@ function isPlainObject(obj: any): obj is Record { return false; } -function tryEqualsMethod(a: any, b: any): boolean { - return ( - hasEqualsMethod(a) && - hasEqualsMethod(b) && - a.equals(b, check) && - b.equals(a, check) - ); -} - -function check(a: any, b: any): boolean { - // If the two values are strictly equal, our job is easy. - if (a === b) { - return true; - } +class DeepChecker { + public check(a: any, b: any): boolean { + // If the two values are strictly equal, our job is easy. + if (a === b) { + return true; + } - // Object.prototype.toString returns a representation of the runtime type of - // the given value that is considerably more precise than typeof. - const aTag = toString.call(a); - const bTag = toString.call(b); + // Object.prototype.toString returns a representation of the runtime type of + // the given value that is considerably more precise than typeof. + const aTag = toString.call(a); + const bTag = toString.call(b); - // If the runtime types of a and b are different, they could maybe be equal - // under some interpretation of equality, but for simplicity and performance - // we just return false instead. - if (aTag !== bTag) { - return false; - } + // If the runtime types of a and b are different, they could maybe be equal + // under some interpretation of equality, but for simplicity and performance + // we just return false instead. + if (aTag !== bTag) { + return false; + } - switch (aTag) { - case '[object Array]': - return previouslyCompared(a, b) || ( - a.length === b.length && - (a as any[]).every((child, i) => check(child, b[i])) - ); - - case '[object Object]': { - if (!isPlainObject(a) || - !isPlainObject(b)) { - return tryEqualsMethod(a, b); - } + switch (aTag) { + case '[object Array]': + return this.previouslyCompared(a, b) || ( + a.length === b.length && + (a as any[]).every((child, i) => this.check(child, b[i])) + ); + + case '[object Object]': { + if (!isPlainObject(a) || + !isPlainObject(b)) { + return this.tryEqualsMethod(a, b); + } - if (previouslyCompared(a, b)) return true; + if (this.previouslyCompared(a, b)) return true; - const aKeys = definedKeys(a); - const bKeys = definedKeys(b); + const aKeys = definedKeys(a); + const bKeys = definedKeys(b); - // If `a` and `b` have a different number of enumerable keys, they - // must be different. - const keyCount = aKeys.length; - if (keyCount !== bKeys.length) return false; + // If `a` and `b` have a different number of enumerable keys, they + // must be different. + const keyCount = aKeys.length; + if (keyCount !== bKeys.length) return false; - // Now make sure they have the same keys. - for (let k = 0; k < keyCount; ++k) { - if (!hasOwnProperty.call(b, aKeys[k])) { - return false; + // Now make sure they have the same keys. + for (let k = 0; k < keyCount; ++k) { + if (!hasOwnProperty.call(b, aKeys[k])) { + return false; + } } - } - // Finally, check deep equality of all child properties. - for (let k = 0; k < keyCount; ++k) { - const key = aKeys[k]; - if (!check(a[key], b[key])) { - return false; + // Finally, check deep equality of all child properties. + for (let k = 0; k < keyCount; ++k) { + const key = aKeys[k]; + if (!this.check(a[key], b[key])) { + return false; + } } - } - - return true; - } - - case '[object Error]': - return a.name === b.name && a.message === b.message; - case '[object Number]': - // Handle NaN, which is !== itself. - if (a !== a) return b !== b; - // Fall through to shared +a === +b case... - case '[object Boolean]': - case '[object Date]': - return +a === +b; - - case '[object RegExp]': - case '[object String]': - return a == `${b}`; - - case '[object Map]': - case '[object Set]': { - if (a.size !== b.size) return false; - if (previouslyCompared(a, b)) return true; - - const aIterator = a.entries(); - const isMap = aTag === '[object Map]'; + return true; + } - while (true) { - const info = aIterator.next(); - if (info.done) break; + case '[object Error]': + return a.name === b.name && a.message === b.message; + + case '[object Number]': + // Handle NaN, which is !== itself. + if (a !== a) return b !== b; + // Fall through to shared +a === +b case... + case '[object Boolean]': + case '[object Date]': + return +a === +b; + + case '[object RegExp]': + case '[object String]': + return a == `${b}`; + + case '[object Map]': + case '[object Set]': { + if (a.size !== b.size) return false; + if (this.previouslyCompared(a, b)) return true; + + const aIterator = a.entries(); + const isMap = aTag === '[object Map]'; + + while (true) { + const info = aIterator.next(); + if (info.done) break; + + // If a instanceof Set, aValue === aKey. + const [aKey, aValue] = info.value; + + // So this works the same way for both Set and Map. + if (!b.has(aKey)) { + return false; + } + + // However, we care about deep equality of values only when dealing + // with Map structures. + if (isMap && !this.check(aValue, b.get(aKey))) { + return false; + } + } - // If a instanceof Set, aValue === aKey. - const [aKey, aValue] = info.value; + return true; + } - // So this works the same way for both Set and Map. - if (!b.has(aKey)) { - return false; + case '[object Uint16Array]': + case '[object Uint8Array]': // Buffer, in Node.js. + case '[object Uint32Array]': + case '[object Int32Array]': + case '[object Int8Array]': + case '[object Int16Array]': + case '[object ArrayBuffer]': + // DataView doesn't need these conversions, but the equality check is + // otherwise the same. + a = new Uint8Array(a); + b = new Uint8Array(b); + // Fall through... + case '[object DataView]': { + let len = a.byteLength; + if (len === b.byteLength) { + while (len-- && a[len] === b[len]) { + // Keep looping as long as the bytes are equal. + } } + return len === -1; + } - // However, we care about deep equality of values only when dealing - // with Map structures. - if (isMap && !check(aValue, b.get(aKey))) { + case '[object AsyncFunction]': + case '[object GeneratorFunction]': + case '[object AsyncGeneratorFunction]': + case '[object Function]': { + const aCode = fnToStr.call(a); + if (aCode !== fnToStr.call(b)) { return false; } - } - return true; + // We consider non-native functions equal if they have the same code + // (native functions require === because their code is censored). Note + // that this behavior is not entirely sound, since !== function objects + // with the same code can behave differently depending on their closure + // scope. However, any function can behave differently depending on the + // values of its input arguments (including this) and its calling + // context (including its closure scope), even though the function + // object is === to itself; and it is entirely possible for functions + // that are not === to behave exactly the same under all conceivable + // circumstances. Because none of these factors are statically decidable + // in JavaScript, JS function equality is not well-defined. This + // ambiguity allows us to consider the best possible heuristic among + // various imperfect options, and equating non-native functions that + // have the same code has enormous practical benefits, such as when + // comparing functions that are repeatedly passed as fresh function + // expressions within objects that are otherwise deeply equal. Since any + // function created from the same syntactic expression (in the same code + // location) will always stringify to the same code according to + // fnToStr.call, we can reasonably expect these repeatedly passed + // function expressions to have the same code, and thus behave "the + // same" (with all the caveats mentioned above), even though the runtime + // function objects are !== to one another. + return !endsWith(aCode, nativeCodeSuffix); + } } - case '[object Uint16Array]': - case '[object Uint8Array]': // Buffer, in Node.js. - case '[object Uint32Array]': - case '[object Int32Array]': - case '[object Int8Array]': - case '[object Int16Array]': - case '[object ArrayBuffer]': - // DataView doesn't need these conversions, but the equality check is - // otherwise the same. - a = new Uint8Array(a); - b = new Uint8Array(b); - // Fall through... - case '[object DataView]': { - let len = a.byteLength; - if (len === b.byteLength) { - while (len-- && a[len] === b[len]) { - // Keep looping as long as the bytes are equal. - } - } - return len === -1; + if (isNonNullObject(a) && isNonNullObject(b)) { + return this.tryEqualsMethod(a, b); } - case '[object AsyncFunction]': - case '[object GeneratorFunction]': - case '[object AsyncGeneratorFunction]': - case '[object Function]': { - const aCode = fnToStr.call(a); - if (aCode !== fnToStr.call(b)) { - return false; - } + // Otherwise the values are not equal. + return false; + } - // We consider non-native functions equal if they have the same code - // (native functions require === because their code is censored). - // Note that this behavior is not entirely sound, since !== function - // objects with the same code can behave differently depending on - // their closure scope. However, any function can behave differently - // depending on the values of its input arguments (including this) - // and its calling context (including its closure scope), even - // though the function object is === to itself; and it is entirely - // possible for functions that are not === to behave exactly the - // same under all conceivable circumstances. Because none of these - // factors are statically decidable in JavaScript, JS function - // equality is not well-defined. This ambiguity allows us to - // consider the best possible heuristic among various imperfect - // options, and equating non-native functions that have the same - // code has enormous practical benefits, such as when comparing - // functions that are repeatedly passed as fresh function - // expressions within objects that are otherwise deeply equal. Since - // any function created from the same syntactic expression (in the - // same code location) will always stringify to the same code - // according to fnToStr.call, we can reasonably expect these - // repeatedly passed function expressions to have the same code, and - // thus behave "the same" (with all the caveats mentioned above), - // even though the runtime function objects are !== to one another. - return !endsWith(aCode, nativeCodeSuffix); + private comparisons: Map> | undefined; + private previouslyCompared(a: any, b: any): boolean { + this.comparisons = this.comparisons || new Map; + // Though cyclic references can make an object graph appear infinite from + // the perspective of a depth-first traversal, the graph still contains a + // finite number of distinct object references. We use the cache to avoid + // comparing the same pair of object references more than once, which + // guarantees termination (even if we end up comparing every object in one + // graph to every object in the other graph, which is extremely unlikely), + // while still allowing weird isomorphic structures (like rings with + // different lengths) a chance to pass the equality test. + let bSet = this.comparisons.get(a); + if (bSet) { + // Return true here because we can be sure false will be returned + // somewhere else if the objects are not equivalent. + if (bSet.has(b)) return true; + } else { + this.comparisons.set(a, bSet = new Set); } + bSet.add(b); + return false; } - if (isNonNullObject(a) && isNonNullObject(b)) { - return tryEqualsMethod(a, b); + private boundCheck: DeepChecker["check"] = (a, b) => this.check(a, b); + + private hasEqualsMethod(obj: any): obj is Equatable { + return ( + isNonNullObject(obj) && + typeof obj.equals === "function" && + // Verify reflexivity. This should be cheap as long as obj.equals(obj) + // checks obj === obj first. + obj.equals(obj, this.boundCheck) + ); } - // Otherwise the values are not equal. - return false; + private tryEqualsMethod(a: any, b: any): boolean { + return ( + this.hasEqualsMethod(a) && + this.hasEqualsMethod(b) && + a.equals(b, this.boundCheck) && + // Verify symmetry. If a.equals is not exactly the same function as + // b.equals, b.equals(a) can legitimately disagree with a.equals(b), so we + // must check both. When a.equals === b.equals, the additional check should + // be redundant, unless that .equals method is somehow asymmetric. + (a.equals === b.equals || b.equals(a, this.boundCheck)) + ); + } } function definedKeys(obj: TObject) { @@ -245,24 +272,3 @@ function endsWith(full: string, suffix: string) { return fromIndex >= 0 && full.indexOf(suffix, fromIndex) === fromIndex; } - -function previouslyCompared(a: object, b: object): boolean { - // Though cyclic references can make an object graph appear infinite from the - // perspective of a depth-first traversal, the graph still contains a finite - // number of distinct object references. We use the previousComparisons cache - // to avoid comparing the same pair of object references more than once, which - // guarantees termination (even if we end up comparing every object in one - // graph to every object in the other graph, which is extremely unlikely), - // while still allowing weird isomorphic structures (like rings with different - // lengths) a chance to pass the equality test. - let bSet = previousComparisons.get(a); - if (bSet) { - // Return true here because we can be sure false will be returned somewhere - // else if the objects are not equivalent. - if (bSet.has(b)) return true; - } else { - previousComparisons.set(a, bSet = new Set); - } - bSet.add(b); - return false; -} From 03886b29aa15a47dafca7c5ada37dccf22742203 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 4 Aug 2021 19:13:28 -0400 Subject: [PATCH 06/27] Extract DeepChecker and helper utilities into separate modules. --- packages/equality/src/checker.ts | 232 ++++++++++++++++++++++++++ packages/equality/src/equality.ts | 265 +----------------------------- packages/equality/src/helpers.ts | 45 +++++ 3 files changed, 278 insertions(+), 264 deletions(-) create mode 100644 packages/equality/src/checker.ts create mode 100644 packages/equality/src/helpers.ts diff --git a/packages/equality/src/checker.ts b/packages/equality/src/checker.ts new file mode 100644 index 00000000..cf5dc8d6 --- /dev/null +++ b/packages/equality/src/checker.ts @@ -0,0 +1,232 @@ +import { + definedKeys, + fnToStr, + hasOwn, + isNativeCode, + isNonNullObject, + isPlainObject, + objToStr, +} from "./helpers"; + +export interface Equatable { + equals(that: T, helper: DeepChecker["check"]): boolean; +} + +export class DeepChecker { + private comparisons: Map> | undefined; + private boundCheck: DeepChecker["check"] = (a, b) => this.check(a, b); + + public check(a: any, b: any): boolean { + // If the two values are strictly equal, our job is easy. + if (a === b) { + return true; + } + + // Object.prototype.toString returns a representation of the runtime type of + // the given value that is considerably more precise than typeof. + const aTag = objToStr.call(a); + const bTag = objToStr.call(b); + + // If the runtime types of a and b are different, they could maybe be equal + // under some interpretation of equality, but for simplicity and performance + // we just return false instead. + if (aTag !== bTag) { + return false; + } + + switch (aTag) { + case '[object Array]': + return this.previouslyCompared(a, b) || ( + a.length === b.length && + (a as any[]).every((child, i) => this.check(child, b[i])) + ); + + case '[object Object]': { + if (!isPlainObject(a) || + !isPlainObject(b)) { + return this.tryEqualsMethod(a, b); + } + + if (this.previouslyCompared(a, b)) return true; + + const aKeys = definedKeys(a); + const bKeys = definedKeys(b); + + // If `a` and `b` have a different number of enumerable keys, they + // must be different. + const keyCount = aKeys.length; + if (keyCount !== bKeys.length) return false; + + // Now make sure they have the same keys. + for (let k = 0; k < keyCount; ++k) { + if (!hasOwn.call(b, aKeys[k])) { + return false; + } + } + + // Finally, check deep equality of all child properties. + for (let k = 0; k < keyCount; ++k) { + const key = aKeys[k]; + if (!this.check(a[key], b[key])) { + return false; + } + } + + return true; + } + + case '[object Error]': + return a.name === b.name && a.message === b.message; + + case '[object Number]': + // Handle NaN, which is !== itself. + if (a !== a) return b !== b; + // Fall through to shared +a === +b case... + case '[object Boolean]': + case '[object Date]': + return +a === +b; + + case '[object RegExp]': + case '[object String]': + return a == `${b}`; + + case '[object Map]': + case '[object Set]': { + if (a.size !== b.size) return false; + if (this.previouslyCompared(a, b)) return true; + + const aIterator = a.entries(); + const isMap = aTag === '[object Map]'; + + while (true) { + const info = aIterator.next(); + if (info.done) break; + + // If a instanceof Set, aValue === aKey. + const [aKey, aValue] = info.value; + + // So this works the same way for both Set and Map. + if (!b.has(aKey)) { + return false; + } + + // However, we care about deep equality of values only when dealing + // with Map structures. + if (isMap && !this.check(aValue, b.get(aKey))) { + return false; + } + } + + return true; + } + + case '[object Uint16Array]': + case '[object Uint8Array]': // Buffer, in Node.js. + case '[object Uint32Array]': + case '[object Int32Array]': + case '[object Int8Array]': + case '[object Int16Array]': + case '[object ArrayBuffer]': + // DataView doesn't need these conversions, but the equality check is + // otherwise the same. + a = new Uint8Array(a); + b = new Uint8Array(b); + // Fall through... + case '[object DataView]': { + let len = a.byteLength; + if (len === b.byteLength) { + while (len-- && a[len] === b[len]) { + // Keep looping as long as the bytes are equal. + } + } + return len === -1; + } + + case '[object AsyncFunction]': + case '[object GeneratorFunction]': + case '[object AsyncGeneratorFunction]': + case '[object Function]': { + const aCode = fnToStr.call(a); + if (aCode !== fnToStr.call(b)) { + return false; + } + + // We consider non-native functions equal if they have the same code + // (native functions require === because their code is censored). Note + // that this behavior is not entirely sound, since !== function objects + // with the same code can behave differently depending on their closure + // scope. However, any function can behave differently depending on the + // values of its input arguments (including this) and its calling + // context (including its closure scope), even though the function + // object is === to itself; and it is entirely possible for functions + // that are not === to behave exactly the same under all conceivable + // circumstances. Because none of these factors are statically decidable + // in JavaScript, JS function equality is not well-defined. This + // ambiguity allows us to consider the best possible heuristic among + // various imperfect options, and equating non-native functions that + // have the same code has enormous practical benefits, such as when + // comparing functions that are repeatedly passed as fresh function + // expressions within objects that are otherwise deeply equal. Since any + // function created from the same syntactic expression (in the same code + // location) will always stringify to the same code according to + // fnToStr.call, we can reasonably expect these repeatedly passed + // function expressions to have the same code, and thus behave "the + // same" (with all the caveats mentioned above), even though the runtime + // function objects are !== to one another. + return !isNativeCode(aCode); + } + } + + if (isNonNullObject(a) && isNonNullObject(b)) { + return this.tryEqualsMethod(a, b); + } + + // Otherwise the values are not equal. + return false; + } + + private previouslyCompared(a: any, b: any): boolean { + this.comparisons = this.comparisons || new Map; + // Though cyclic references can make an object graph appear infinite from + // the perspective of a depth-first traversal, the graph still contains a + // finite number of distinct object references. We use the cache to avoid + // comparing the same pair of object references more than once, which + // guarantees termination (even if we end up comparing every object in one + // graph to every object in the other graph, which is extremely unlikely), + // while still allowing weird isomorphic structures (like rings with + // different lengths) a chance to pass the equality test. + let bSet = this.comparisons.get(a); + if (bSet) { + // Return true here because we can be sure false will be returned + // somewhere else if the objects are not equivalent. + if (bSet.has(b)) return true; + } else { + this.comparisons.set(a, bSet = new Set); + } + bSet.add(b); + return false; + } + + private hasEqualsMethod(obj: any): obj is Equatable { + return ( + isNonNullObject(obj) && + typeof obj.equals === "function" && + // Verify reflexivity. This should be cheap as long as obj.equals(obj) + // checks obj === obj first. + obj.equals(obj, this.boundCheck) + ); + } + + private tryEqualsMethod(a: any, b: any): boolean { + return ( + this.hasEqualsMethod(a) && + this.hasEqualsMethod(b) && + a.equals(b, this.boundCheck) && + // Verify symmetry. If a.equals is not exactly the same function as + // b.equals, b.equals(a) can legitimately disagree with a.equals(b), so we + // must check both. When a.equals === b.equals, the additional check should + // be redundant, unless that .equals method is somehow asymmetric. + (a.equals === b.equals || b.equals(a, this.boundCheck)) + ); + } +} diff --git a/packages/equality/src/equality.ts b/packages/equality/src/equality.ts index 33b1a848..29c23294 100644 --- a/packages/equality/src/equality.ts +++ b/packages/equality/src/equality.ts @@ -1,12 +1,4 @@ -const fnToStr = Function.prototype.toString; - -const { - getPrototypeOf, - prototype: { - toString, - hasOwnProperty, - }, -} = Object; +import { DeepChecker } from "./checker"; /** * Performs a deep equality check on two JavaScript values, tolerating cycles. @@ -17,258 +9,3 @@ export function equal(a: any, b: any): boolean { // Allow default imports as well. export default equal; - -export interface Equatable { - equals(that: T, helper: DeepChecker["check"]): boolean; -} - -function isNonNullObject(obj: any): obj is Record { - return obj !== null && typeof obj === "object"; -} - -function isPlainObject(obj: any): obj is Record { - if (isNonNullObject(obj)) { - const proto = getPrototypeOf(obj); - return proto === null || proto === Object.prototype; - } - return false; -} - -class DeepChecker { - public check(a: any, b: any): boolean { - // If the two values are strictly equal, our job is easy. - if (a === b) { - return true; - } - - // Object.prototype.toString returns a representation of the runtime type of - // the given value that is considerably more precise than typeof. - const aTag = toString.call(a); - const bTag = toString.call(b); - - // If the runtime types of a and b are different, they could maybe be equal - // under some interpretation of equality, but for simplicity and performance - // we just return false instead. - if (aTag !== bTag) { - return false; - } - - switch (aTag) { - case '[object Array]': - return this.previouslyCompared(a, b) || ( - a.length === b.length && - (a as any[]).every((child, i) => this.check(child, b[i])) - ); - - case '[object Object]': { - if (!isPlainObject(a) || - !isPlainObject(b)) { - return this.tryEqualsMethod(a, b); - } - - if (this.previouslyCompared(a, b)) return true; - - const aKeys = definedKeys(a); - const bKeys = definedKeys(b); - - // If `a` and `b` have a different number of enumerable keys, they - // must be different. - const keyCount = aKeys.length; - if (keyCount !== bKeys.length) return false; - - // Now make sure they have the same keys. - for (let k = 0; k < keyCount; ++k) { - if (!hasOwnProperty.call(b, aKeys[k])) { - return false; - } - } - - // Finally, check deep equality of all child properties. - for (let k = 0; k < keyCount; ++k) { - const key = aKeys[k]; - if (!this.check(a[key], b[key])) { - return false; - } - } - - return true; - } - - case '[object Error]': - return a.name === b.name && a.message === b.message; - - case '[object Number]': - // Handle NaN, which is !== itself. - if (a !== a) return b !== b; - // Fall through to shared +a === +b case... - case '[object Boolean]': - case '[object Date]': - return +a === +b; - - case '[object RegExp]': - case '[object String]': - return a == `${b}`; - - case '[object Map]': - case '[object Set]': { - if (a.size !== b.size) return false; - if (this.previouslyCompared(a, b)) return true; - - const aIterator = a.entries(); - const isMap = aTag === '[object Map]'; - - while (true) { - const info = aIterator.next(); - if (info.done) break; - - // If a instanceof Set, aValue === aKey. - const [aKey, aValue] = info.value; - - // So this works the same way for both Set and Map. - if (!b.has(aKey)) { - return false; - } - - // However, we care about deep equality of values only when dealing - // with Map structures. - if (isMap && !this.check(aValue, b.get(aKey))) { - return false; - } - } - - return true; - } - - case '[object Uint16Array]': - case '[object Uint8Array]': // Buffer, in Node.js. - case '[object Uint32Array]': - case '[object Int32Array]': - case '[object Int8Array]': - case '[object Int16Array]': - case '[object ArrayBuffer]': - // DataView doesn't need these conversions, but the equality check is - // otherwise the same. - a = new Uint8Array(a); - b = new Uint8Array(b); - // Fall through... - case '[object DataView]': { - let len = a.byteLength; - if (len === b.byteLength) { - while (len-- && a[len] === b[len]) { - // Keep looping as long as the bytes are equal. - } - } - return len === -1; - } - - case '[object AsyncFunction]': - case '[object GeneratorFunction]': - case '[object AsyncGeneratorFunction]': - case '[object Function]': { - const aCode = fnToStr.call(a); - if (aCode !== fnToStr.call(b)) { - return false; - } - - // We consider non-native functions equal if they have the same code - // (native functions require === because their code is censored). Note - // that this behavior is not entirely sound, since !== function objects - // with the same code can behave differently depending on their closure - // scope. However, any function can behave differently depending on the - // values of its input arguments (including this) and its calling - // context (including its closure scope), even though the function - // object is === to itself; and it is entirely possible for functions - // that are not === to behave exactly the same under all conceivable - // circumstances. Because none of these factors are statically decidable - // in JavaScript, JS function equality is not well-defined. This - // ambiguity allows us to consider the best possible heuristic among - // various imperfect options, and equating non-native functions that - // have the same code has enormous practical benefits, such as when - // comparing functions that are repeatedly passed as fresh function - // expressions within objects that are otherwise deeply equal. Since any - // function created from the same syntactic expression (in the same code - // location) will always stringify to the same code according to - // fnToStr.call, we can reasonably expect these repeatedly passed - // function expressions to have the same code, and thus behave "the - // same" (with all the caveats mentioned above), even though the runtime - // function objects are !== to one another. - return !endsWith(aCode, nativeCodeSuffix); - } - } - - if (isNonNullObject(a) && isNonNullObject(b)) { - return this.tryEqualsMethod(a, b); - } - - // Otherwise the values are not equal. - return false; - } - - private comparisons: Map> | undefined; - private previouslyCompared(a: any, b: any): boolean { - this.comparisons = this.comparisons || new Map; - // Though cyclic references can make an object graph appear infinite from - // the perspective of a depth-first traversal, the graph still contains a - // finite number of distinct object references. We use the cache to avoid - // comparing the same pair of object references more than once, which - // guarantees termination (even if we end up comparing every object in one - // graph to every object in the other graph, which is extremely unlikely), - // while still allowing weird isomorphic structures (like rings with - // different lengths) a chance to pass the equality test. - let bSet = this.comparisons.get(a); - if (bSet) { - // Return true here because we can be sure false will be returned - // somewhere else if the objects are not equivalent. - if (bSet.has(b)) return true; - } else { - this.comparisons.set(a, bSet = new Set); - } - bSet.add(b); - return false; - } - - private boundCheck: DeepChecker["check"] = (a, b) => this.check(a, b); - - private hasEqualsMethod(obj: any): obj is Equatable { - return ( - isNonNullObject(obj) && - typeof obj.equals === "function" && - // Verify reflexivity. This should be cheap as long as obj.equals(obj) - // checks obj === obj first. - obj.equals(obj, this.boundCheck) - ); - } - - private tryEqualsMethod(a: any, b: any): boolean { - return ( - this.hasEqualsMethod(a) && - this.hasEqualsMethod(b) && - a.equals(b, this.boundCheck) && - // Verify symmetry. If a.equals is not exactly the same function as - // b.equals, b.equals(a) can legitimately disagree with a.equals(b), so we - // must check both. When a.equals === b.equals, the additional check should - // be redundant, unless that .equals method is somehow asymmetric. - (a.equals === b.equals || b.equals(a, this.boundCheck)) - ); - } -} - -function definedKeys(obj: TObject) { - // Remember that the second argument to Array.prototype.filter will be - // used as `this` within the callback function. - return Object.keys(obj).filter(isDefinedKey, obj); -} -function isDefinedKey( - this: TObject, - key: keyof TObject, -) { - return this[key] !== void 0; -} - -const nativeCodeSuffix = "{ [native code] }"; - -function endsWith(full: string, suffix: string) { - const fromIndex = full.length - suffix.length; - return fromIndex >= 0 && - full.indexOf(suffix, fromIndex) === fromIndex; -} diff --git a/packages/equality/src/helpers.ts b/packages/equality/src/helpers.ts new file mode 100644 index 00000000..cad102c0 --- /dev/null +++ b/packages/equality/src/helpers.ts @@ -0,0 +1,45 @@ +export const fnToStr = Function.prototype.toString; + +export const { + getPrototypeOf, + prototype: { + toString: objToStr, + hasOwnProperty: hasOwn, + }, +} = Object; + +export function isNonNullObject(obj: any): obj is Record { + return obj !== null && typeof obj === "object"; +} + +export function isPlainObject(obj: any): obj is Record { + if (isNonNullObject(obj)) { + const proto = getPrototypeOf(obj); + return proto === null || proto === Object.prototype; + } + return false; +} + +export function definedKeys(obj: TObject) { + // Remember that the second argument to Array.prototype.filter will be + // used as `this` within the callback function. + return Object.keys(obj).filter(isDefinedKey, obj); +} +function isDefinedKey( + this: TObject, + key: keyof TObject, +) { + return this[key] !== void 0; +} + +const nativeCodeSuffix = "{ [native code] }"; + +export function isNativeCode(code: string): boolean { + return endsWith(code, nativeCodeSuffix); +} + +export function endsWith(full: string, suffix: string) { + const fromIndex = full.length - suffix.length; + return fromIndex >= 0 && + full.indexOf(suffix, fromIndex) === fromIndex; +} From 1603d848b22210f8dbcbb1c71f2be1c488c503e8 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 4 Aug 2021 19:31:41 -0400 Subject: [PATCH 07/27] Rename hasEqualsMethod to isEquatable, matching Equatable. --- packages/equality/src/checker.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/equality/src/checker.ts b/packages/equality/src/checker.ts index cf5dc8d6..697071fa 100644 --- a/packages/equality/src/checker.ts +++ b/packages/equality/src/checker.ts @@ -207,7 +207,7 @@ export class DeepChecker { return false; } - private hasEqualsMethod(obj: any): obj is Equatable { + private isEquatable(obj: any): obj is Equatable { return ( isNonNullObject(obj) && typeof obj.equals === "function" && @@ -219,8 +219,8 @@ export class DeepChecker { private tryEqualsMethod(a: any, b: any): boolean { return ( - this.hasEqualsMethod(a) && - this.hasEqualsMethod(b) && + this.isEquatable(a) && + this.isEquatable(b) && a.equals(b, this.boundCheck) && // Verify symmetry. If a.equals is not exactly the same function as // b.equals, b.equals(a) can legitimately disagree with a.equals(b), so we From 3a1703a757d1d27656bcac4c83211176aefaa45d Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 4 Aug 2021 19:42:44 -0400 Subject: [PATCH 08/27] Decompose private DeepChecker#check{Array,Object,...}s helper methods. --- packages/equality/src/checker.ts | 236 +++++++++++++++++-------------- 1 file changed, 127 insertions(+), 109 deletions(-) diff --git a/packages/equality/src/checker.ts b/packages/equality/src/checker.ts index 697071fa..32b02841 100644 --- a/packages/equality/src/checker.ts +++ b/packages/equality/src/checker.ts @@ -36,44 +36,10 @@ export class DeepChecker { switch (aTag) { case '[object Array]': - return this.previouslyCompared(a, b) || ( - a.length === b.length && - (a as any[]).every((child, i) => this.check(child, b[i])) - ); + return this.checkArrays(a, b); - case '[object Object]': { - if (!isPlainObject(a) || - !isPlainObject(b)) { - return this.tryEqualsMethod(a, b); - } - - if (this.previouslyCompared(a, b)) return true; - - const aKeys = definedKeys(a); - const bKeys = definedKeys(b); - - // If `a` and `b` have a different number of enumerable keys, they - // must be different. - const keyCount = aKeys.length; - if (keyCount !== bKeys.length) return false; - - // Now make sure they have the same keys. - for (let k = 0; k < keyCount; ++k) { - if (!hasOwn.call(b, aKeys[k])) { - return false; - } - } - - // Finally, check deep equality of all child properties. - for (let k = 0; k < keyCount; ++k) { - const key = aKeys[k]; - if (!this.check(a[key], b[key])) { - return false; - } - } - - return true; - } + case '[object Object]': + return this.checkObjects(a, b); case '[object Error]': return a.name === b.name && a.message === b.message; @@ -91,34 +57,8 @@ export class DeepChecker { return a == `${b}`; case '[object Map]': - case '[object Set]': { - if (a.size !== b.size) return false; - if (this.previouslyCompared(a, b)) return true; - - const aIterator = a.entries(); - const isMap = aTag === '[object Map]'; - - while (true) { - const info = aIterator.next(); - if (info.done) break; - - // If a instanceof Set, aValue === aKey. - const [aKey, aValue] = info.value; - - // So this works the same way for both Set and Map. - if (!b.has(aKey)) { - return false; - } - - // However, we care about deep equality of values only when dealing - // with Map structures. - if (isMap && !this.check(aValue, b.get(aKey))) { - return false; - } - } - - return true; - } + case '[object Set]': + return this.checkMapsOrSets(a, b, aTag); case '[object Uint16Array]': case '[object Uint8Array]': // Buffer, in Node.js. @@ -127,54 +67,21 @@ export class DeepChecker { case '[object Int8Array]': case '[object Int16Array]': case '[object ArrayBuffer]': - // DataView doesn't need these conversions, but the equality check is - // otherwise the same. - a = new Uint8Array(a); - b = new Uint8Array(b); - // Fall through... - case '[object DataView]': { - let len = a.byteLength; - if (len === b.byteLength) { - while (len-- && a[len] === b[len]) { - // Keep looping as long as the bytes are equal. - } - } - return len === -1; - } + return this.checkBytes( + // DataView doesn't need these conversions, but the equality check is + // otherwise the same. + new Uint8Array(a), + new Uint8Array(b), + ); + + case '[object DataView]': + return this.checkBytes(a, b); case '[object AsyncFunction]': case '[object GeneratorFunction]': case '[object AsyncGeneratorFunction]': - case '[object Function]': { - const aCode = fnToStr.call(a); - if (aCode !== fnToStr.call(b)) { - return false; - } - - // We consider non-native functions equal if they have the same code - // (native functions require === because their code is censored). Note - // that this behavior is not entirely sound, since !== function objects - // with the same code can behave differently depending on their closure - // scope. However, any function can behave differently depending on the - // values of its input arguments (including this) and its calling - // context (including its closure scope), even though the function - // object is === to itself; and it is entirely possible for functions - // that are not === to behave exactly the same under all conceivable - // circumstances. Because none of these factors are statically decidable - // in JavaScript, JS function equality is not well-defined. This - // ambiguity allows us to consider the best possible heuristic among - // various imperfect options, and equating non-native functions that - // have the same code has enormous practical benefits, such as when - // comparing functions that are repeatedly passed as fresh function - // expressions within objects that are otherwise deeply equal. Since any - // function created from the same syntactic expression (in the same code - // location) will always stringify to the same code according to - // fnToStr.call, we can reasonably expect these repeatedly passed - // function expressions to have the same code, and thus behave "the - // same" (with all the caveats mentioned above), even though the runtime - // function objects are !== to one another. - return !isNativeCode(aCode); - } + case '[object Function]': + return this.checkFunctions(a, b); } if (isNonNullObject(a) && isNonNullObject(b)) { @@ -185,6 +92,117 @@ export class DeepChecker { return false; } + private checkArrays(a: any[], b: any[]): boolean { + return this.previouslyCompared(a, b) || ( + a.length === b.length && + a.every((child, i) => this.check(child, b[i])) + ); + } + + private checkObjects(a: any, b: any): boolean { + if (!isPlainObject(a) || + !isPlainObject(b)) { + return this.tryEqualsMethod(a, b); + } + + if (this.previouslyCompared(a, b)) return true; + + const aKeys = definedKeys(a); + const bKeys = definedKeys(b); + + // If `a` and `b` have a different number of enumerable keys, they + // must be different. + const keyCount = aKeys.length; + if (keyCount !== bKeys.length) return false; + + // Now make sure they have the same keys. + for (let k = 0; k < keyCount; ++k) { + if (!hasOwn.call(b, aKeys[k])) { + return false; + } + } + + // Finally, check deep equality of all child properties. + for (let k = 0; k < keyCount; ++k) { + const key = aKeys[k]; + if (!this.check(a[key], b[key])) { + return false; + } + } + + return true; + } + + private checkMapsOrSets(a: any, b: any, tag: string): boolean { + if (a.size !== b.size) return false; + if (this.previouslyCompared(a, b)) return true; + + const aIterator = a.entries(); + const isMap = tag === '[object Map]'; + + while (true) { + const info = aIterator.next(); + if (info.done) break; + + // If a instanceof Set, aValue === aKey. + const [aKey, aValue] = info.value; + + // So this works the same way for both Set and Map. + if (!b.has(aKey)) { + return false; + } + + // However, we care about deep equality of values only when dealing + // with Map structures. + if (isMap && !this.check(aValue, b.get(aKey))) { + return false; + } + } + + return true; + } + + private checkBytes(a: Uint8Array, b: Uint8Array): boolean { + let len = a.byteLength; + if (len === b.byteLength) { + while (len-- && a[len] === b[len]) { + // Keep looping as long as the bytes are equal. + } + } + return len === -1; + } + + private checkFunctions(a: any, b: any): boolean { + const aCode = fnToStr.call(a); + if (aCode !== fnToStr.call(b)) { + return false; + } + + // We consider non-native functions equal if they have the same code + // (native functions require === because their code is censored). Note + // that this behavior is not entirely sound, since !== function objects + // with the same code can behave differently depending on their closure + // scope. However, any function can behave differently depending on the + // values of its input arguments (including this) and its calling + // context (including its closure scope), even though the function + // object is === to itself; and it is entirely possible for functions + // that are not === to behave exactly the same under all conceivable + // circumstances. Because none of these factors are statically decidable + // in JavaScript, JS function equality is not well-defined. This + // ambiguity allows us to consider the best possible heuristic among + // various imperfect options, and equating non-native functions that + // have the same code has enormous practical benefits, such as when + // comparing functions that are repeatedly passed as fresh function + // expressions within objects that are otherwise deeply equal. Since any + // function created from the same syntactic expression (in the same code + // location) will always stringify to the same code according to + // fnToStr.call, we can reasonably expect these repeatedly passed + // function expressions to have the same code, and thus behave "the + // same" (with all the caveats mentioned above), even though the runtime + // function objects are !== to one another. + return !isNativeCode(aCode); + } + private previouslyCompared(a: any, b: any): boolean { this.comparisons = this.comparisons || new Map; // Though cyclic references can make an object graph appear infinite from From 7253021b50d5dffcd3bc1c1cd0b42cca2ee05731 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 5 Aug 2021 10:35:55 -0400 Subject: [PATCH 09/27] Switch to a more functional style for check helpers. --- packages/equality/src/checker.ts | 315 +++++++++++++++++-------------- 1 file changed, 169 insertions(+), 146 deletions(-) diff --git a/packages/equality/src/checker.ts b/packages/equality/src/checker.ts index 32b02841..e7650b64 100644 --- a/packages/equality/src/checker.ts +++ b/packages/equality/src/checker.ts @@ -14,7 +14,7 @@ export interface Equatable { export class DeepChecker { private comparisons: Map> | undefined; - private boundCheck: DeepChecker["check"] = (a, b) => this.check(a, b); + public readonly boundCheck: DeepChecker["check"] = (a, b) => this.check(a, b); public check(a: any, b: any): boolean { // If the two values are strictly equal, our job is easy. @@ -36,29 +36,26 @@ export class DeepChecker { switch (aTag) { case '[object Array]': - return this.checkArrays(a, b); + return checkArrays(this, a, b); case '[object Object]': - return this.checkObjects(a, b); + return checkObjects(this, a, b); case '[object Error]': - return a.name === b.name && a.message === b.message; + return checkErrors(this, a, b); case '[object Number]': - // Handle NaN, which is !== itself. - if (a !== a) return b !== b; - // Fall through to shared +a === +b case... case '[object Boolean]': case '[object Date]': - return +a === +b; + return checkNumbers(this, a, b); case '[object RegExp]': case '[object String]': - return a == `${b}`; + return checkStringsOrRegExps(this, a, b); case '[object Map]': case '[object Set]': - return this.checkMapsOrSets(a, b, aTag); + return checkMapsOrSets(this, a, b, aTag); case '[object Uint16Array]': case '[object Uint8Array]': // Buffer, in Node.js. @@ -67,184 +64,210 @@ export class DeepChecker { case '[object Int8Array]': case '[object Int16Array]': case '[object ArrayBuffer]': - return this.checkBytes( - // DataView doesn't need these conversions, but the equality check is - // otherwise the same. - new Uint8Array(a), - new Uint8Array(b), - ); - + return checkArrayBuffers(this, a, b); case '[object DataView]': - return this.checkBytes(a, b); + // DataView doesn't need the checkArrayBuffers conversions, but the + // equality check is otherwise the same. + return checkBytes(this, a, b); case '[object AsyncFunction]': case '[object GeneratorFunction]': case '[object AsyncGeneratorFunction]': case '[object Function]': - return this.checkFunctions(a, b); + return checkFunctions(this, a, b); } if (isNonNullObject(a) && isNonNullObject(b)) { - return this.tryEqualsMethod(a, b); + return tryEqualsMethod(this, a, b); } // Otherwise the values are not equal. return false; } - private checkArrays(a: any[], b: any[]): boolean { - return this.previouslyCompared(a, b) || ( - a.length === b.length && - a.every((child, i) => this.check(child, b[i])) - ); + public previouslyCompared(a: any, b: any): boolean { + this.comparisons = this.comparisons || new Map; + // Though cyclic references can make an object graph appear infinite from + // the perspective of a depth-first traversal, the graph still contains a + // finite number of distinct object references. We use the cache to avoid + // comparing the same pair of object references more than once, which + // guarantees termination (even if we end up comparing every object in one + // graph to every object in the other graph, which is extremely unlikely), + // while still allowing weird isomorphic structures (like rings with + // different lengths) a chance to pass the equality test. + let bSet = this.comparisons.get(a); + if (bSet) { + // Return true here because we can be sure false will be returned + // somewhere else if the objects are not equivalent. + if (bSet.has(b)) return true; + } else { + this.comparisons.set(a, bSet = new Set); + } + bSet.add(b); + return false; } +} - private checkObjects(a: any, b: any): boolean { - if (!isPlainObject(a) || - !isPlainObject(b)) { - return this.tryEqualsMethod(a, b); - } +function isEquatable(checker: DeepChecker, obj: any): obj is Equatable { + return ( + isNonNullObject(obj) && + typeof obj.equals === "function" && + // Verify reflexivity. This should be cheap as long as obj.equals(obj) + // checks obj === obj first. + obj.equals(obj, checker.boundCheck) + ); +} - if (this.previouslyCompared(a, b)) return true; +function tryEqualsMethod(checker: DeepChecker, a: any, b: any): boolean { + return ( + isEquatable(checker, a) && + isEquatable(checker, b) && + a.equals(b, checker.boundCheck) && + // Verify symmetry. If a.equals is not exactly the same function as + // b.equals, b.equals(a) can legitimately disagree with a.equals(b), so we + // must check both. When a.equals === b.equals, the additional check should + // be redundant, unless that .equals method is somehow asymmetric. + (a.equals === b.equals || b.equals(a, checker.boundCheck)) + ); +} - const aKeys = definedKeys(a); - const bKeys = definedKeys(b); +function checkArrays(checker: DeepChecker, a: any[], b: any[]): boolean { + return checker.previouslyCompared(a, b) || ( + a.length === b.length && + a.every((child, i) => checker.check(child, b[i])) + ); +} - // If `a` and `b` have a different number of enumerable keys, they - // must be different. - const keyCount = aKeys.length; - if (keyCount !== bKeys.length) return false; +function checkObjects(checker: DeepChecker, a: object, b: object): boolean { + if (!isPlainObject(a) || + !isPlainObject(b)) { + return tryEqualsMethod(checker, a, b); + } - // Now make sure they have the same keys. - for (let k = 0; k < keyCount; ++k) { - if (!hasOwn.call(b, aKeys[k])) { - return false; - } - } + if (checker.previouslyCompared(a, b)) return true; + + const aKeys = definedKeys(a); + const bKeys = definedKeys(b); - // Finally, check deep equality of all child properties. - for (let k = 0; k < keyCount; ++k) { - const key = aKeys[k]; - if (!this.check(a[key], b[key])) { - return false; - } + // If `a` and `b` have a different number of enumerable keys, they + // must be different. + const keyCount = aKeys.length; + if (keyCount !== bKeys.length) return false; + + // Now make sure they have the same keys. + for (let k = 0; k < keyCount; ++k) { + if (!hasOwn.call(b, aKeys[k])) { + return false; } + } - return true; + // Finally, check deep equality of all child properties. + for (let k = 0; k < keyCount; ++k) { + const key = aKeys[k]; + if (!checker.check(a[key], b[key])) { + return false; + } } - private checkMapsOrSets(a: any, b: any, tag: string): boolean { - if (a.size !== b.size) return false; - if (this.previouslyCompared(a, b)) return true; + return true; +} + +function checkErrors(_: DeepChecker, a: Error, b: Error): boolean { + return a.name === b.name && a.message === b.message; +} - const aIterator = a.entries(); - const isMap = tag === '[object Map]'; +function checkNumbers(_: DeepChecker, a: number, b: number): boolean { + return a !== a + ? b !== b // Handle NaN, which is !== itself. + : +a === +b; +} - while (true) { - const info = aIterator.next(); - if (info.done) break; +function checkStringsOrRegExps( + _: DeepChecker, + a: T, + b: T, +): boolean { + return a == `${b}`; +} - // If a instanceof Set, aValue === aKey. - const [aKey, aValue] = info.value; +function checkMapsOrSets | Set>( + checker: DeepChecker, + a: T, + b: T, + tag: string, +): boolean { + if (a.size !== b.size) return false; + if (checker.previouslyCompared(a, b)) return true; - // So this works the same way for both Set and Map. - if (!b.has(aKey)) { - return false; - } + const aIterator = a.entries(); + const isMap = tag === '[object Map]'; - // However, we care about deep equality of values only when dealing - // with Map structures. - if (isMap && !this.check(aValue, b.get(aKey))) { - return false; - } - } + while (true) { + const info = aIterator.next(); + if (info.done) break; - return true; - } + // If a instanceof Set, aValue === aKey. + const [aKey, aValue] = info.value; - private checkBytes(a: Uint8Array, b: Uint8Array): boolean { - let len = a.byteLength; - if (len === b.byteLength) { - while (len-- && a[len] === b[len]) { - // Keep looping as long as the bytes are equal. - } + // So this works the same way for both Set and Map. + if (!b.has(aKey)) { + return false; } - return len === -1; - } - private checkFunctions(a: any, b: any): boolean { - const aCode = fnToStr.call(a); - if (aCode !== fnToStr.call(b)) { + // However, we care about deep equality of values only when dealing + // with Map structures. + if (isMap && !checker.check(aValue, (b as Map).get(aKey))) { return false; } - - // We consider non-native functions equal if they have the same code - // (native functions require === because their code is censored). Note - // that this behavior is not entirely sound, since !== function objects - // with the same code can behave differently depending on their closure - // scope. However, any function can behave differently depending on the - // values of its input arguments (including this) and its calling - // context (including its closure scope), even though the function - // object is === to itself; and it is entirely possible for functions - // that are not === to behave exactly the same under all conceivable - // circumstances. Because none of these factors are statically decidable - // in JavaScript, JS function equality is not well-defined. This - // ambiguity allows us to consider the best possible heuristic among - // various imperfect options, and equating non-native functions that - // have the same code has enormous practical benefits, such as when - // comparing functions that are repeatedly passed as fresh function - // expressions within objects that are otherwise deeply equal. Since any - // function created from the same syntactic expression (in the same code - // location) will always stringify to the same code according to - // fnToStr.call, we can reasonably expect these repeatedly passed - // function expressions to have the same code, and thus behave "the - // same" (with all the caveats mentioned above), even though the runtime - // function objects are !== to one another. - return !isNativeCode(aCode); } - private previouslyCompared(a: any, b: any): boolean { - this.comparisons = this.comparisons || new Map; - // Though cyclic references can make an object graph appear infinite from - // the perspective of a depth-first traversal, the graph still contains a - // finite number of distinct object references. We use the cache to avoid - // comparing the same pair of object references more than once, which - // guarantees termination (even if we end up comparing every object in one - // graph to every object in the other graph, which is extremely unlikely), - // while still allowing weird isomorphic structures (like rings with - // different lengths) a chance to pass the equality test. - let bSet = this.comparisons.get(a); - if (bSet) { - // Return true here because we can be sure false will be returned - // somewhere else if the objects are not equivalent. - if (bSet.has(b)) return true; - } else { - this.comparisons.set(a, bSet = new Set); + return true; +} + +function checkArrayBuffers(checker: DeepChecker, a: ArrayBuffer, b: ArrayBuffer): boolean { + return checkBytes( + checker, + new Uint8Array(a), + new Uint8Array(b), + ); +} + +function checkBytes(_: DeepChecker, a: Uint8Array, b: Uint8Array): boolean { + let len = a.byteLength; + if (len === b.byteLength) { + while (len-- && a[len] === b[len]) { + // Keep looping as long as the bytes are equal. } - bSet.add(b); - return false; } + return len === -1; +} - private isEquatable(obj: any): obj is Equatable { - return ( - isNonNullObject(obj) && - typeof obj.equals === "function" && - // Verify reflexivity. This should be cheap as long as obj.equals(obj) - // checks obj === obj first. - obj.equals(obj, this.boundCheck) - ); +function checkFunctions(_: DeepChecker, a: any, b: any): boolean { + const aCode = fnToStr.call(a); + if (aCode !== fnToStr.call(b)) { + return false; } - private tryEqualsMethod(a: any, b: any): boolean { - return ( - this.isEquatable(a) && - this.isEquatable(b) && - a.equals(b, this.boundCheck) && - // Verify symmetry. If a.equals is not exactly the same function as - // b.equals, b.equals(a) can legitimately disagree with a.equals(b), so we - // must check both. When a.equals === b.equals, the additional check should - // be redundant, unless that .equals method is somehow asymmetric. - (a.equals === b.equals || b.equals(a, this.boundCheck)) - ); - } + // We consider non-native functions equal if they have the same code (native + // functions require === because their code is censored). Note that this + // behavior is not entirely sound, since !== function objects with the same + // code can behave differently depending on their closure scope. However, any + // function can behave differently depending on the values of its input + // arguments (including this) and its calling context (including its closure + // scope), even though the function object is === to itself; and it is + // entirely possible for functions that are not === to behave exactly the same + // under all conceivable circumstances. Because none of these factors are + // statically decidable in JavaScript, JS function equality is not + // well-defined. This ambiguity allows us to consider the best possible + // heuristic among various imperfect options, and equating non-native + // functions that have the same code has enormous practical benefits, such as + // when comparing functions that are repeatedly passed as fresh function + // expressions within objects that are otherwise deeply equal. Since any + // function created from the same syntactic expression (in the same code + // location) will always stringify to the same code according to fnToStr.call, + // we can reasonably expect these repeatedly passed function expressions to + // have the same code, and thus behave "the same" (with all the caveats + // mentioned above), even though the runtime function objects are !== to one + // another. + return !isNativeCode(aCode); } From 34aba7d2b8dbdd9a09a09aa09f23fdc28c2edf06 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 5 Aug 2021 11:01:54 -0400 Subject: [PATCH 10/27] Use Map rather than switch statement to look up check functions. --- packages/equality/src/checker.ts | 82 ++++++++++++++++---------------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/packages/equality/src/checker.ts b/packages/equality/src/checker.ts index e7650b64..410cdacc 100644 --- a/packages/equality/src/checker.ts +++ b/packages/equality/src/checker.ts @@ -12,6 +12,44 @@ export interface Equatable { equals(that: T, helper: DeepChecker["check"]): boolean; } +type Checker = ( + checker: DeepChecker, + a: T, + b: T, + tag: string, +) => boolean; + +const CHECKERS_BY_TAG = new Map>() + .set('[object Array]', checkArrays) + .set('[object Object]', checkObjects) + .set('[object Error]', checkErrors) + + .set('[object Number]', checkNumbers) + .set('[object Boolean]', checkNumbers) + .set('[object Date]', checkNumbers) + + .set('[object RegExp]', checkStringsOrRegExps) + .set('[object String]', checkStringsOrRegExps) + + .set('[object Map]', checkMapsOrSets) + .set('[object Set]', checkMapsOrSets) + + .set('[object Uint16Array]', checkArrayBuffers) + .set('[object Uint8Array]', checkArrayBuffers) + .set('[object Uint32Array]', checkArrayBuffers) + .set('[object Int32Array]', checkArrayBuffers) + .set('[object Int8Array]', checkArrayBuffers) + .set('[object Int16Array]', checkArrayBuffers) + .set('[object ArrayBuffer]', checkArrayBuffers) + // DataView doesn't need the checkArrayBuffers conversions, but the equality + // check is otherwise the same. + .set('[object DataView]', checkBytes) + + .set('[object AsyncFunction]', checkFunctions) + .set('[object GeneratorFunction]', checkFunctions) + .set('[object AsyncGeneratorFunction]', checkFunctions) + .set('[object Function]', checkFunctions); + export class DeepChecker { private comparisons: Map> | undefined; public readonly boundCheck: DeepChecker["check"] = (a, b) => this.check(a, b); @@ -34,47 +72,9 @@ export class DeepChecker { return false; } - switch (aTag) { - case '[object Array]': - return checkArrays(this, a, b); - - case '[object Object]': - return checkObjects(this, a, b); - - case '[object Error]': - return checkErrors(this, a, b); - - case '[object Number]': - case '[object Boolean]': - case '[object Date]': - return checkNumbers(this, a, b); - - case '[object RegExp]': - case '[object String]': - return checkStringsOrRegExps(this, a, b); - - case '[object Map]': - case '[object Set]': - return checkMapsOrSets(this, a, b, aTag); - - case '[object Uint16Array]': - case '[object Uint8Array]': // Buffer, in Node.js. - case '[object Uint32Array]': - case '[object Int32Array]': - case '[object Int8Array]': - case '[object Int16Array]': - case '[object ArrayBuffer]': - return checkArrayBuffers(this, a, b); - case '[object DataView]': - // DataView doesn't need the checkArrayBuffers conversions, but the - // equality check is otherwise the same. - return checkBytes(this, a, b); - - case '[object AsyncFunction]': - case '[object GeneratorFunction]': - case '[object AsyncGeneratorFunction]': - case '[object Function]': - return checkFunctions(this, a, b); + const checker = CHECKERS_BY_TAG.get(aTag); + if (checker) { + return checker(this, a, b, aTag); } if (isNonNullObject(a) && isNonNullObject(b)) { From bf94e6620025c562b312d1005505035015518658 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 5 Aug 2021 12:18:08 -0400 Subject: [PATCH 11/27] Test that objects with unusual prototypes are compared with ===. --- packages/equality/src/tests.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/equality/src/tests.ts b/packages/equality/src/tests.ts index 618efbc1..652177e2 100644 --- a/packages/equality/src/tests.ts +++ b/packages/equality/src/tests.ts @@ -1,5 +1,6 @@ import assert from "assert"; import defaultEqual, { equal } from "./equality"; +import { objToStr } from "./helpers"; function toStr(value: any) { try { @@ -120,6 +121,31 @@ describe("equality", function () { }, {}); }); + it("should not equate !== objects with custom prototypes", function () { + class Custom { + constructor(public readonly number: number) {} + } + + const c1 = new Custom(1234); + const c2 = new Custom(1234); + const c3 = new Custom(2345); + + assertEqual(Object.keys(c1), ["number"]); + assertEqual(Object.keys(c2), ["number"]); + assertEqual(Object.keys(c3), ["number"]); + + assert.strictEqual(objToStr.call(c1), "[object Object]"); + assert.strictEqual(objToStr.call(c2), "[object Object]"); + assert.strictEqual(objToStr.call(c3), "[object Object]"); + + assertEqual(c1, c1); + assertEqual(c2, c2); + assertEqual(c3, c3); + assertNotEqual(c1, c2); + assertNotEqual(c1, c3); + assertNotEqual(c2, c3); + }); + it("should work for Error objects", function () { assertEqual(new Error("oyez"), new Error("oyez")); assertNotEqual(new Error("oyez"), new Error("onoz")); From 859b48ca9eaf4f5a2fd41faae697fab0e7ac63ad Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 5 Aug 2021 12:46:43 -0400 Subject: [PATCH 12/27] Test that custom classes can define their own .equals methods. --- packages/equality/src/tests.ts | 71 ++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/packages/equality/src/tests.ts b/packages/equality/src/tests.ts index 652177e2..217cf407 100644 --- a/packages/equality/src/tests.ts +++ b/packages/equality/src/tests.ts @@ -1,4 +1,5 @@ import assert from "assert"; +import { Equatable } from "./checker"; import defaultEqual, { equal } from "./equality"; import { objToStr } from "./helpers"; @@ -146,6 +147,76 @@ describe("equality", function () { assertNotEqual(c2, c3); }); + it("should respect asymmetric a.equals(b) methods", function () { + type CheckFn = (a: any, b: any) => boolean; + + class Point2D implements Equatable { + constructor( + public readonly x: number, + public readonly y: number, + ) {} + + // It's a shame that we have to provide the parameter types explicitly. + equals(that: Point2D, check: CheckFn) { + return this === that || ( + check(this.x, that.x) && + check(this.y, that.y) + ); + } + } + + class Point3D extends Point2D implements Equatable { + constructor( + x: number, + y: number, + public readonly z: number, + ) { + super(x, y); + } + + equals(that: Point3D, check: CheckFn) { + return this === that || ( + super.equals(that, check) && + check(this.z, that.z) + ); + } + } + + const x1y2 = new Point2D(1, 2); + const x2y1 = new Point2D(2, 1); + const x1y2z0 = new Point3D(1, 2, 0); + const x1y2z3 = new Point3D(1, 2, 3); + + assertEqual(x1y2, x1y2); + assertEqual(x2y1, x2y1); + assertEqual(x1y2z0, x1y2z0); + assertEqual(x1y2z3, x1y2z3); + + assert.strictEqual(x1y2.equals(x1y2, equal), true); + assert.strictEqual(x2y1.equals(x2y1, equal), true); + assert.strictEqual(x1y2z0.equals(x1y2z0, equal), true); + assert.strictEqual(x1y2z3.equals(x1y2z3, equal), true); + + assertEqual(x1y2, new Point2D(1, 2)); + assertEqual(x2y1, new Point2D(2, 1)); + assertEqual(x1y2z0, new Point3D(1, 2, 0)); + assertEqual(x1y2z3, new Point3D(1, 2, 3)); + + assertNotEqual(x1y2, x2y1); + assertNotEqual(x1y2, x1y2z3); + assertNotEqual(x2y1, x1y2z0); + assertNotEqual(x2y1, x1y2z3); + assertNotEqual(x1y2z0, x1y2z3); + + // These are the most interesting cases, because x1y2 thinks it's equal to + // both x1y2z0 and x1y2z3, but the equal(a, b) function enforces symmetry. + assertNotEqual(x1y2, x1y2z0); + assert.strictEqual(x1y2.equals(x1y2z0, equal), true); + assert.strictEqual(x1y2.equals(x1y2z3, equal), true); + assert.strictEqual(x1y2z0.equals(x1y2 as Point3D, equal), false); + assert.strictEqual(x1y2z3.equals(x1y2 as Point3D, equal), false); + }); + it("should work for Error objects", function () { assertEqual(new Error("oyez"), new Error("oyez")); assertNotEqual(new Error("oyez"), new Error("onoz")); From 007068a0994893ebdfd4b342592105d79b38f364 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 5 Aug 2021 13:00:23 -0400 Subject: [PATCH 13/27] Test class with unknown Symbol.toStringTag and .equals method. --- packages/equality/src/tests.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/equality/src/tests.ts b/packages/equality/src/tests.ts index 217cf407..1613b59b 100644 --- a/packages/equality/src/tests.ts +++ b/packages/equality/src/tests.ts @@ -147,6 +147,34 @@ describe("equality", function () { assertNotEqual(c2, c3); }); + it("should respect custom a.equals(b) methods for unknown Symbol.toStringTag", function () { + class Tagged { + [Symbol.toStringTag] = "Tagged"; + + constructor(private value: any) {} + + equals(that: Tagged) { + return this.value === that.value; + } + } + + const t1a = new Tagged(1); + const t1b = new Tagged(1); + const t2a = new Tagged(2); + const t2b = new Tagged(2); + + assert.strictEqual(objToStr.call(t1a), "[object Tagged]"); + assert.strictEqual(objToStr.call(t2b), "[object Tagged]"); + + assertEqual(t1a, t1b); + assertEqual(t2a, t2b); + + assertNotEqual(t1a, t2a); + assertNotEqual(t1a, t2b); + assertNotEqual(t1b, t2a); + assertNotEqual(t1b, t2b); + }); + it("should respect asymmetric a.equals(b) methods", function () { type CheckFn = (a: any, b: any) => boolean; From cc4f5f44afc0e65711a03158e2d41afaa3e08424 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 10 Aug 2021 11:53:35 -0400 Subject: [PATCH 14/27] Rename .equals method to .deepEquals. --- packages/equality/src/checker.ts | 22 ++++++++++--------- packages/equality/src/tests.ts | 36 ++++++++++++++++---------------- 2 files changed, 30 insertions(+), 28 deletions(-) diff --git a/packages/equality/src/checker.ts b/packages/equality/src/checker.ts index 410cdacc..9be8e32e 100644 --- a/packages/equality/src/checker.ts +++ b/packages/equality/src/checker.ts @@ -9,7 +9,7 @@ import { } from "./helpers"; export interface Equatable { - equals(that: T, helper: DeepChecker["check"]): boolean; + deepEquals(that: T, helper: DeepChecker["check"]): boolean; } type Checker = ( @@ -111,10 +111,10 @@ export class DeepChecker { function isEquatable(checker: DeepChecker, obj: any): obj is Equatable { return ( isNonNullObject(obj) && - typeof obj.equals === "function" && - // Verify reflexivity. This should be cheap as long as obj.equals(obj) + typeof obj.deepEquals === "function" && + // Verify reflexivity. This should be cheap as long as obj.deepEquals(obj) // checks obj === obj first. - obj.equals(obj, checker.boundCheck) + obj.deepEquals(obj, checker.boundCheck) ); } @@ -122,12 +122,14 @@ function tryEqualsMethod(checker: DeepChecker, a: any, b: any): boolean { return ( isEquatable(checker, a) && isEquatable(checker, b) && - a.equals(b, checker.boundCheck) && - // Verify symmetry. If a.equals is not exactly the same function as - // b.equals, b.equals(a) can legitimately disagree with a.equals(b), so we - // must check both. When a.equals === b.equals, the additional check should - // be redundant, unless that .equals method is somehow asymmetric. - (a.equals === b.equals || b.equals(a, checker.boundCheck)) + a.deepEquals(b, checker.boundCheck) && + // Verify symmetry. If a.deepEquals is not exactly the same function as + // b.deepEquals, b.deepEquals(a) can legitimately disagree with + // a.deepEquals(b), so we must check both. When a.deepEquals === + // b.deepEquals, the additional check should be redundant, unless that + // .deepEquals method is somehow asymmetric. + (a.deepEquals === b.deepEquals || + b.deepEquals(a, checker.boundCheck)) ); } diff --git a/packages/equality/src/tests.ts b/packages/equality/src/tests.ts index 1613b59b..f5f56e39 100644 --- a/packages/equality/src/tests.ts +++ b/packages/equality/src/tests.ts @@ -147,13 +147,13 @@ describe("equality", function () { assertNotEqual(c2, c3); }); - it("should respect custom a.equals(b) methods for unknown Symbol.toStringTag", function () { + it("should respect custom a.deepEquals(b) methods for unknown Symbol.toStringTag", function () { class Tagged { [Symbol.toStringTag] = "Tagged"; constructor(private value: any) {} - equals(that: Tagged) { + deepEquals(that: Tagged) { return this.value === that.value; } } @@ -175,8 +175,8 @@ describe("equality", function () { assertNotEqual(t1b, t2b); }); - it("should respect asymmetric a.equals(b) methods", function () { - type CheckFn = (a: any, b: any) => boolean; + it("should respect asymmetric a.deepEquals(b) methods", function () { + type EqualFn = (a: any, b: any) => boolean; class Point2D implements Equatable { constructor( @@ -185,10 +185,10 @@ describe("equality", function () { ) {} // It's a shame that we have to provide the parameter types explicitly. - equals(that: Point2D, check: CheckFn) { + deepEquals(that: Point2D, equal: EqualFn) { return this === that || ( - check(this.x, that.x) && - check(this.y, that.y) + equal(this.x, that.x) && + equal(this.y, that.y) ); } } @@ -202,10 +202,10 @@ describe("equality", function () { super(x, y); } - equals(that: Point3D, check: CheckFn) { + deepEquals(that: Point3D, equal: EqualFn) { return this === that || ( - super.equals(that, check) && - check(this.z, that.z) + super.deepEquals(that, equal) && + equal(this.z, that.z) ); } } @@ -220,10 +220,10 @@ describe("equality", function () { assertEqual(x1y2z0, x1y2z0); assertEqual(x1y2z3, x1y2z3); - assert.strictEqual(x1y2.equals(x1y2, equal), true); - assert.strictEqual(x2y1.equals(x2y1, equal), true); - assert.strictEqual(x1y2z0.equals(x1y2z0, equal), true); - assert.strictEqual(x1y2z3.equals(x1y2z3, equal), true); + assert.strictEqual(x1y2.deepEquals(x1y2, equal), true); + assert.strictEqual(x2y1.deepEquals(x2y1, equal), true); + assert.strictEqual(x1y2z0.deepEquals(x1y2z0, equal), true); + assert.strictEqual(x1y2z3.deepEquals(x1y2z3, equal), true); assertEqual(x1y2, new Point2D(1, 2)); assertEqual(x2y1, new Point2D(2, 1)); @@ -239,10 +239,10 @@ describe("equality", function () { // These are the most interesting cases, because x1y2 thinks it's equal to // both x1y2z0 and x1y2z3, but the equal(a, b) function enforces symmetry. assertNotEqual(x1y2, x1y2z0); - assert.strictEqual(x1y2.equals(x1y2z0, equal), true); - assert.strictEqual(x1y2.equals(x1y2z3, equal), true); - assert.strictEqual(x1y2z0.equals(x1y2 as Point3D, equal), false); - assert.strictEqual(x1y2z3.equals(x1y2 as Point3D, equal), false); + assert.strictEqual(x1y2.deepEquals(x1y2z0, equal), true); + assert.strictEqual(x1y2.deepEquals(x1y2z3, equal), true); + assert.strictEqual(x1y2z0.deepEquals(x1y2 as Point3D, equal), false); + assert.strictEqual(x1y2z3.deepEquals(x1y2 as Point3D, equal), false); }); it("should work for Error objects", function () { From 90b7001e90b6b662fb72552bad9a165cbb00883f Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 15 Sep 2021 14:21:17 -0400 Subject: [PATCH 15/27] Move Equatable interface and isEquatable function into helpers.ts. --- packages/equality/src/checker.ts | 15 +-------------- packages/equality/src/helpers.ts | 16 ++++++++++++++++ packages/equality/src/tests.ts | 3 +-- 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/packages/equality/src/checker.ts b/packages/equality/src/checker.ts index 9be8e32e..86846478 100644 --- a/packages/equality/src/checker.ts +++ b/packages/equality/src/checker.ts @@ -2,16 +2,13 @@ import { definedKeys, fnToStr, hasOwn, + isEquatable, isNativeCode, isNonNullObject, isPlainObject, objToStr, } from "./helpers"; -export interface Equatable { - deepEquals(that: T, helper: DeepChecker["check"]): boolean; -} - type Checker = ( checker: DeepChecker, a: T, @@ -108,16 +105,6 @@ export class DeepChecker { } } -function isEquatable(checker: DeepChecker, obj: any): obj is Equatable { - return ( - isNonNullObject(obj) && - typeof obj.deepEquals === "function" && - // Verify reflexivity. This should be cheap as long as obj.deepEquals(obj) - // checks obj === obj first. - obj.deepEquals(obj, checker.boundCheck) - ); -} - function tryEqualsMethod(checker: DeepChecker, a: any, b: any): boolean { return ( isEquatable(checker, a) && diff --git a/packages/equality/src/helpers.ts b/packages/equality/src/helpers.ts index cad102c0..dea7a8b4 100644 --- a/packages/equality/src/helpers.ts +++ b/packages/equality/src/helpers.ts @@ -1,3 +1,19 @@ +import { DeepChecker } from "./checker"; + +export interface Equatable { + deepEquals(that: T, helper: DeepChecker["check"]): boolean; +} + +export function isEquatable(checker: DeepChecker, obj: any): obj is Equatable { + return ( + isNonNullObject(obj) && + typeof obj.deepEquals === "function" && + // Verify reflexivity. This should be cheap as long as obj.deepEquals(obj) + // checks obj === obj first. + obj.deepEquals(obj, checker.boundCheck) + ); +} + export const fnToStr = Function.prototype.toString; export const { diff --git a/packages/equality/src/tests.ts b/packages/equality/src/tests.ts index f5f56e39..55759aa1 100644 --- a/packages/equality/src/tests.ts +++ b/packages/equality/src/tests.ts @@ -1,7 +1,6 @@ import assert from "assert"; -import { Equatable } from "./checker"; import defaultEqual, { equal } from "./equality"; -import { objToStr } from "./helpers"; +import { Equatable, objToStr } from "./helpers"; function toStr(value: any) { try { From 451f6a4698781f13e55fa1d2aed88a1e0ed61d76 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 15 Sep 2021 14:44:16 -0400 Subject: [PATCH 16/27] Export DeepEqualsHelper type for second deepEquals parameter. --- packages/equality/src/helpers.ts | 2 ++ packages/equality/src/tests.ts | 8 +++----- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/equality/src/helpers.ts b/packages/equality/src/helpers.ts index dea7a8b4..c24d8ecf 100644 --- a/packages/equality/src/helpers.ts +++ b/packages/equality/src/helpers.ts @@ -1,5 +1,7 @@ import { DeepChecker } from "./checker"; +export type DeepEqualsHelper = DeepChecker["check"]; + export interface Equatable { deepEquals(that: T, helper: DeepChecker["check"]): boolean; } diff --git a/packages/equality/src/tests.ts b/packages/equality/src/tests.ts index 55759aa1..2bdfc906 100644 --- a/packages/equality/src/tests.ts +++ b/packages/equality/src/tests.ts @@ -1,6 +1,6 @@ import assert from "assert"; import defaultEqual, { equal } from "./equality"; -import { Equatable, objToStr } from "./helpers"; +import { Equatable, DeepEqualsHelper, objToStr } from "./helpers"; function toStr(value: any) { try { @@ -175,8 +175,6 @@ describe("equality", function () { }); it("should respect asymmetric a.deepEquals(b) methods", function () { - type EqualFn = (a: any, b: any) => boolean; - class Point2D implements Equatable { constructor( public readonly x: number, @@ -184,7 +182,7 @@ describe("equality", function () { ) {} // It's a shame that we have to provide the parameter types explicitly. - deepEquals(that: Point2D, equal: EqualFn) { + deepEquals(that: Point2D, equal: DeepEqualsHelper) { return this === that || ( equal(this.x, that.x) && equal(this.y, that.y) @@ -201,7 +199,7 @@ describe("equality", function () { super(x, y); } - deepEquals(that: Point3D, equal: EqualFn) { + deepEquals(that: Point3D, equal: DeepEqualsHelper) { return this === that || ( super.deepEquals(that, equal) && equal(this.z, that.z) From 4046f6a9c831b56970ee0549ccfd31ffa4d5ab62 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 15 Sep 2021 14:45:04 -0400 Subject: [PATCH 17/27] Test/fix cyclic structures with deepEquals methods. --- packages/equality/src/checker.ts | 4 +-- packages/equality/src/tests.ts | 47 ++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/packages/equality/src/checker.ts b/packages/equality/src/checker.ts index 86846478..138a142f 100644 --- a/packages/equality/src/checker.ts +++ b/packages/equality/src/checker.ts @@ -128,13 +128,13 @@ function checkArrays(checker: DeepChecker, a: any[], b: any[]): boolean { } function checkObjects(checker: DeepChecker, a: object, b: object): boolean { + if (checker.previouslyCompared(a, b)) return true; + if (!isPlainObject(a) || !isPlainObject(b)) { return tryEqualsMethod(checker, a, b); } - if (checker.previouslyCompared(a, b)) return true; - const aKeys = definedKeys(a); const bKeys = definedKeys(b); diff --git a/packages/equality/src/tests.ts b/packages/equality/src/tests.ts index 2bdfc906..ec35bae4 100644 --- a/packages/equality/src/tests.ts +++ b/packages/equality/src/tests.ts @@ -242,6 +242,53 @@ describe("equality", function () { assert.strictEqual(x1y2z3.deepEquals(x1y2 as Point3D, equal), false); }); + it("can check cyclic structures of objects with deepEquals methods", function () { + class Node implements Equatable> { + constructor( + public value: T, + public next?: Node, + ) {} + + static cycle(n: number) { + const head = new Node(n); + let node = head; + while (--n >= 0) { + node = new Node(n, node); + } + return head.next = node; + } + + deepEquals(that: Node, equal: DeepEqualsHelper) { + return this === that || ( + equal(this.value, that.value) && + equal(this.next, that.next) + ); + } + } + + const cycles = [ + Node.cycle(0), + Node.cycle(1), + Node.cycle(2), + Node.cycle(3), + Node.cycle(4), + ]; + + cycles.forEach((cycleToCheck, i) => { + const sameSizeCycle = Node.cycle(i); + assert.notStrictEqual(cycleToCheck, sameSizeCycle); + assertEqual(cycleToCheck, sameSizeCycle); + + cycles.forEach((otherCycle, j) => { + if (i === j) { + assert.strictEqual(cycleToCheck, otherCycle); + } else { + assertNotEqual(cycleToCheck, otherCycle); + } + }); + }); + }); + it("should work for Error objects", function () { assertEqual(new Error("oyez"), new Error("oyez")); assertNotEqual(new Error("oyez"), new Error("onoz")); From 3ac543272212b4e5942b71a7442d50e1525d6b16 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 15 Sep 2021 15:35:13 -0400 Subject: [PATCH 18/27] Fix assertEqual error message typo. --- packages/equality/src/tests.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/equality/src/tests.ts b/packages/equality/src/tests.ts index ec35bae4..611bd1e5 100644 --- a/packages/equality/src/tests.ts +++ b/packages/equality/src/tests.ts @@ -11,7 +11,7 @@ function toStr(value: any) { } function assertEqual(a: any, b: any) { - assert.strictEqual(equal(a, b), true, `unexpectedly not equal(${toStr(a)}}, ${toStr(b)})`); + assert.strictEqual(equal(a, b), true, `unexpectedly not equal(${toStr(a)}, ${toStr(b)})`); assert.strictEqual(equal(b, a), true, `unexpectedly not equal(${toStr(b)}, ${toStr(a)})`); } From f9cd26ad79ffc4f7a659fe3716339d946dcda78e Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 15 Sep 2021 14:53:06 -0400 Subject: [PATCH 19/27] Treat array holes the same as undefined elements. Since array equality checking no longer falls through to the object case, we can preserve the `definedKeys` behavior for objects (introduced in #21) for arrays, by treating any array holes as undefined elements, using an ordinary `for` loop. Using `a.every` doesn't work because `Array` iteration methods like `Array.prototyp.every` skip over holes. --- packages/equality/src/checker.ts | 16 ++++++++++++---- packages/equality/src/tests.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/packages/equality/src/checker.ts b/packages/equality/src/checker.ts index 138a142f..ae3a0fcc 100644 --- a/packages/equality/src/checker.ts +++ b/packages/equality/src/checker.ts @@ -121,10 +121,18 @@ function tryEqualsMethod(checker: DeepChecker, a: any, b: any): boolean { } function checkArrays(checker: DeepChecker, a: any[], b: any[]): boolean { - return checker.previouslyCompared(a, b) || ( - a.length === b.length && - a.every((child, i) => checker.check(child, b[i])) - ); + const aLen = a.length; + if (aLen !== b.length) return false; + + if (checker.previouslyCompared(a, b)) return true; + + for (let i = 0; i < aLen; ++i) { + if (!checker.check(a[i], b[i])) { + return false; + } + } + + return true; } function checkObjects(checker: DeepChecker, a: object, b: object): boolean { diff --git a/packages/equality/src/tests.ts b/packages/equality/src/tests.ts index 611bd1e5..bb26aef9 100644 --- a/packages/equality/src/tests.ts +++ b/packages/equality/src/tests.ts @@ -76,6 +76,32 @@ describe("equality", function () { ); }); + it("should treat array holes the same as undefined elements", function () { + assertEqual( + [void 0], + Array(1), + ); + + assertEqual( + [void 0], + [/*hole*/,], + ); + + assertNotEqual([void 0], []); + assertNotEqual(Array(1), []); + assertNotEqual([/*hole*/,], []); + + assertEqual( + [1, /*hole*/, 3], + [1, void 0, 3], + ); + + assertEqual( + [1, /*hole*/, 3, void 0], + [1, void 0, 3, /*hole*/,], + ); + }); + it("should work for objects", function () { assertEqual({ a: 1, From 0d399394aa7d9ff2bd228408b8f5bbf6655f65a1 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 15 Sep 2021 15:49:59 -0400 Subject: [PATCH 20/27] Remove Node.js v10 from test rotation. The `@wry/*` packages are all believed to work in Node 10, but that Node.js version has been end-of-life'd (even for security updates) since April 2021: https://endoflife.date/nodejs Removing v10 should fix the tests in PR #230, which are broken because Node 10 does not understand class property syntax in the `tests.cjs.js` bundle. The main `@wry/equality` library continues to be compiled to es2015, eliminating class syntax, but the tests need to be compiled to esnext to test generator and async function equality. There may be a way to satisfy both of these constraints, but the easiest solution right now is to avoid testing in Node 10. --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d08c14f1..e98951c2 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,7 +12,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - node_version: ['10', '12', '14', '15', '16'] + node_version: ['12', '14', '15', '16'] os: [ubuntu-latest] steps: From 1135e70bdce27aa9882bda1a07f8ddacc322955d Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 7 Oct 2021 17:37:57 -0400 Subject: [PATCH 21/27] Trivial a === b shortcut for top-level equal(a, b) function. --- packages/equality/src/equality.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/equality/src/equality.ts b/packages/equality/src/equality.ts index 29c23294..46fc1261 100644 --- a/packages/equality/src/equality.ts +++ b/packages/equality/src/equality.ts @@ -4,7 +4,7 @@ import { DeepChecker } from "./checker"; * Performs a deep equality check on two JavaScript values, tolerating cycles. */ export function equal(a: any, b: any): boolean { - return new DeepChecker().check(a, b); + return a === b || new DeepChecker().check(a, b); } // Allow default imports as well. From dfc8ee900ae9e8b0759983a6822134e97c18d8a8 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 1 Nov 2021 14:58:36 -0400 Subject: [PATCH 22/27] Inline previouslyCompared logic into DeepChecker#check method. --- packages/equality/package-lock.json | 21 ++++++++ packages/equality/package.json | 1 + packages/equality/rollup.config.js | 1 + packages/equality/src/checker.ts | 77 ++++++++++++++++------------- 4 files changed, 65 insertions(+), 35 deletions(-) diff --git a/packages/equality/package-lock.json b/packages/equality/package-lock.json index 7282a910..9e29dae6 100644 --- a/packages/equality/package-lock.json +++ b/packages/equality/package-lock.json @@ -9,12 +9,27 @@ "version": "0.5.2", "license": "MIT", "dependencies": { + "@wry/trie": "file:../trie", "tslib": "^2.3.0" }, "engines": { "node": ">=8" } }, + "../trie": { + "version": "0.3.1", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/trie": { + "resolved": "../trie", + "link": true + }, "node_modules/tslib": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", @@ -22,6 +37,12 @@ } }, "dependencies": { + "@wry/trie": { + "version": "file:../trie", + "requires": { + "tslib": "^2.3.0" + } + }, "tslib": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", diff --git a/packages/equality/package.json b/packages/equality/package.json index a93471b4..93cffa32 100644 --- a/packages/equality/package.json +++ b/packages/equality/package.json @@ -26,6 +26,7 @@ "test": "npm run build && npm run mocha" }, "dependencies": { + "@wry/trie": "file:../trie", "tslib": "^2.3.0" }, "engines": { diff --git a/packages/equality/rollup.config.js b/packages/equality/rollup.config.js index 159e9473..728cf0a5 100644 --- a/packages/equality/rollup.config.js +++ b/packages/equality/rollup.config.js @@ -5,6 +5,7 @@ const globals = { __proto__: null, tslib: "tslib", assert: "assert", + "@wry/trie": "wryTrie", }; function external(id) { diff --git a/packages/equality/src/checker.ts b/packages/equality/src/checker.ts index ae3a0fcc..42973494 100644 --- a/packages/equality/src/checker.ts +++ b/packages/equality/src/checker.ts @@ -1,3 +1,5 @@ +import { Trie } from "@wry/trie"; + import { definedKeys, fnToStr, @@ -48,9 +50,11 @@ const CHECKERS_BY_TAG = new Map>() .set('[object Function]', checkFunctions); export class DeepChecker { - private comparisons: Map> | undefined; - public readonly boundCheck: DeepChecker["check"] = (a, b) => this.check(a, b); + private comparisons = new Trie<{ + equal?: boolean; + }>(false); + public readonly boundCheck: DeepChecker["check"] = (a, b) => this.check(a, b); public check(a: any, b: any): boolean { // If the two values are strictly equal, our job is easy. if (a === b) { @@ -69,39 +73,47 @@ export class DeepChecker { return false; } - const checker = CHECKERS_BY_TAG.get(aTag); - if (checker) { - return checker(this, a, b, aTag); - } + const bothNonNullObjects = + isNonNullObject(a) && + isNonNullObject(b); - if (isNonNullObject(a) && isNonNullObject(b)) { - return tryEqualsMethod(this, a, b); - } - - // Otherwise the values are not equal. - return false; - } + const found = + bothNonNullObjects && + this.comparisons.lookup(a, b); - public previouslyCompared(a: any, b: any): boolean { - this.comparisons = this.comparisons || new Map; // Though cyclic references can make an object graph appear infinite from // the perspective of a depth-first traversal, the graph still contains a - // finite number of distinct object references. We use the cache to avoid - // comparing the same pair of object references more than once, which - // guarantees termination (even if we end up comparing every object in one - // graph to every object in the other graph, which is extremely unlikely), - // while still allowing weird isomorphic structures (like rings with - // different lengths) a chance to pass the equality test. - let bSet = this.comparisons.get(a); - if (bSet) { - // Return true here because we can be sure false will be returned - // somewhere else if the objects are not equivalent. - if (bSet.has(b)) return true; - } else { - this.comparisons.set(a, bSet = new Set); + // finite number of distinct object references. We use this.comparisons as a + // cache to avoid comparing the same pair of object references more than + // once, which guarantees termination (even if we end up comparing every + // object in one graph to every object in the other graph, which is + // extremely unlikely), while still allowing weird isomorphic structures + // (like rings with different lengths) a chance to pass the equality test. + if (found) { + if (typeof found.equal === "boolean") { + return found.equal; + } + // Although we don't know the actual answer yet, we are about to find out, + // so we can cheat by telling anyone else who asks that a equals b. This + // provisional found.equal trick is important to prevent infinite cycle + // traversals, but does not affect the final answer, since only one + // traversal should be necessary to visit/examine all comparable parts of + // the input objects and determine the correct result. + found.equal = true; } - bSet.add(b); - return false; + + const checker = CHECKERS_BY_TAG.get(aTag); + + const result: boolean = + checker ? checker(this, a, b, aTag) : + bothNonNullObjects ? tryEqualsMethod(this, a, b) : + false; + + if (found) { + found.equal = result; + } + + return result; } } @@ -124,8 +136,6 @@ function checkArrays(checker: DeepChecker, a: any[], b: any[]): boolean { const aLen = a.length; if (aLen !== b.length) return false; - if (checker.previouslyCompared(a, b)) return true; - for (let i = 0; i < aLen; ++i) { if (!checker.check(a[i], b[i])) { return false; @@ -136,8 +146,6 @@ function checkArrays(checker: DeepChecker, a: any[], b: any[]): boolean { } function checkObjects(checker: DeepChecker, a: object, b: object): boolean { - if (checker.previouslyCompared(a, b)) return true; - if (!isPlainObject(a) || !isPlainObject(b)) { return tryEqualsMethod(checker, a, b); @@ -194,7 +202,6 @@ function checkMapsOrSets | Set>( tag: string, ): boolean { if (a.size !== b.size) return false; - if (checker.previouslyCompared(a, b)) return true; const aIterator = a.entries(); const isMap = tag === '[object Map]'; From a50167efbc949c27f8ae93d7d2de6c9c4b4386ba Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 21 Jan 2022 15:48:08 -0500 Subject: [PATCH 23/27] Use unique Symbol instead of string "deepEquals" method. Using a Symbol should remove any uncertainty about whether the object in question truly intended to implement the Equatable interface, or just happens to define a method called "deepEquals", which might or might not have the same signature. --- packages/equality/src/checker.ts | 18 +++++++++------- packages/equality/src/helpers.ts | 12 ++++++----- packages/equality/src/tests.ts | 37 ++++++++++++++++++-------------- 3 files changed, 38 insertions(+), 29 deletions(-) diff --git a/packages/equality/src/checker.ts b/packages/equality/src/checker.ts index 42973494..cb113b0e 100644 --- a/packages/equality/src/checker.ts +++ b/packages/equality/src/checker.ts @@ -9,6 +9,7 @@ import { isNonNullObject, isPlainObject, objToStr, + deepEqualsMethod, } from "./helpers"; type Checker = ( @@ -121,14 +122,15 @@ function tryEqualsMethod(checker: DeepChecker, a: any, b: any): boolean { return ( isEquatable(checker, a) && isEquatable(checker, b) && - a.deepEquals(b, checker.boundCheck) && - // Verify symmetry. If a.deepEquals is not exactly the same function as - // b.deepEquals, b.deepEquals(a) can legitimately disagree with - // a.deepEquals(b), so we must check both. When a.deepEquals === - // b.deepEquals, the additional check should be redundant, unless that - // .deepEquals method is somehow asymmetric. - (a.deepEquals === b.deepEquals || - b.deepEquals(a, checker.boundCheck)) + a[deepEqualsMethod](b, checker.boundCheck) && + // Verify symmetry. If a[deepEqualsMethod] is not exactly the same function + // as b[deepEqualsMethod], b[deepEqualsMethod](a) can legitimately disagree + // with a[deepEqualsMethod](b), so we must check both. However, in the + // common case where a[deepEqualsMethod] === b[deepEqualsMethod], the + // additional check should be redundant, unless that method is itself + // somehow non-commutative/asymmetric. + (a[deepEqualsMethod] === b[deepEqualsMethod] || + b[deepEqualsMethod](a, checker.boundCheck)) ); } diff --git a/packages/equality/src/helpers.ts b/packages/equality/src/helpers.ts index c24d8ecf..6717069e 100644 --- a/packages/equality/src/helpers.ts +++ b/packages/equality/src/helpers.ts @@ -2,17 +2,19 @@ import { DeepChecker } from "./checker"; export type DeepEqualsHelper = DeepChecker["check"]; +export const deepEqualsMethod = + Symbol.for("@wry/equality:deepEquals"); + export interface Equatable { - deepEquals(that: T, helper: DeepChecker["check"]): boolean; + [deepEqualsMethod](that: T, helper: DeepChecker["check"]): boolean; } export function isEquatable(checker: DeepChecker, obj: any): obj is Equatable { return ( isNonNullObject(obj) && - typeof obj.deepEquals === "function" && - // Verify reflexivity. This should be cheap as long as obj.deepEquals(obj) - // checks obj === obj first. - obj.deepEquals(obj, checker.boundCheck) + // Using `in` instead of `hasOwn` because the method could be inherited from + // the prototype chain. + deepEqualsMethod in obj ); } diff --git a/packages/equality/src/tests.ts b/packages/equality/src/tests.ts index bb26aef9..2eb8d342 100644 --- a/packages/equality/src/tests.ts +++ b/packages/equality/src/tests.ts @@ -1,6 +1,11 @@ import assert from "assert"; import defaultEqual, { equal } from "./equality"; -import { Equatable, DeepEqualsHelper, objToStr } from "./helpers"; +import { + Equatable, + DeepEqualsHelper, + deepEqualsMethod, + objToStr, +} from "./helpers"; function toStr(value: any) { try { @@ -172,13 +177,13 @@ describe("equality", function () { assertNotEqual(c2, c3); }); - it("should respect custom a.deepEquals(b) methods for unknown Symbol.toStringTag", function () { + it("should respect custom a[deepEqualsMethod](b) methods for unknown Symbol.toStringTag", function () { class Tagged { [Symbol.toStringTag] = "Tagged"; constructor(private value: any) {} - deepEquals(that: Tagged) { + [deepEqualsMethod](that: Tagged) { return this.value === that.value; } } @@ -200,7 +205,7 @@ describe("equality", function () { assertNotEqual(t1b, t2b); }); - it("should respect asymmetric a.deepEquals(b) methods", function () { + it("should respect asymmetric a[deepEqualsMethod](b) methods", function () { class Point2D implements Equatable { constructor( public readonly x: number, @@ -208,7 +213,7 @@ describe("equality", function () { ) {} // It's a shame that we have to provide the parameter types explicitly. - deepEquals(that: Point2D, equal: DeepEqualsHelper) { + [deepEqualsMethod](that: Point2D, equal: DeepEqualsHelper) { return this === that || ( equal(this.x, that.x) && equal(this.y, that.y) @@ -225,9 +230,9 @@ describe("equality", function () { super(x, y); } - deepEquals(that: Point3D, equal: DeepEqualsHelper) { + [deepEqualsMethod](that: Point3D, equal: DeepEqualsHelper) { return this === that || ( - super.deepEquals(that, equal) && + super[deepEqualsMethod](that, equal) && equal(this.z, that.z) ); } @@ -243,10 +248,10 @@ describe("equality", function () { assertEqual(x1y2z0, x1y2z0); assertEqual(x1y2z3, x1y2z3); - assert.strictEqual(x1y2.deepEquals(x1y2, equal), true); - assert.strictEqual(x2y1.deepEquals(x2y1, equal), true); - assert.strictEqual(x1y2z0.deepEquals(x1y2z0, equal), true); - assert.strictEqual(x1y2z3.deepEquals(x1y2z3, equal), true); + assert.strictEqual(x1y2[deepEqualsMethod](x1y2, equal), true); + assert.strictEqual(x2y1[deepEqualsMethod](x2y1, equal), true); + assert.strictEqual(x1y2z0[deepEqualsMethod](x1y2z0, equal), true); + assert.strictEqual(x1y2z3[deepEqualsMethod](x1y2z3, equal), true); assertEqual(x1y2, new Point2D(1, 2)); assertEqual(x2y1, new Point2D(2, 1)); @@ -262,10 +267,10 @@ describe("equality", function () { // These are the most interesting cases, because x1y2 thinks it's equal to // both x1y2z0 and x1y2z3, but the equal(a, b) function enforces symmetry. assertNotEqual(x1y2, x1y2z0); - assert.strictEqual(x1y2.deepEquals(x1y2z0, equal), true); - assert.strictEqual(x1y2.deepEquals(x1y2z3, equal), true); - assert.strictEqual(x1y2z0.deepEquals(x1y2 as Point3D, equal), false); - assert.strictEqual(x1y2z3.deepEquals(x1y2 as Point3D, equal), false); + assert.strictEqual(x1y2[deepEqualsMethod](x1y2z0, equal), true); + assert.strictEqual(x1y2[deepEqualsMethod](x1y2z3, equal), true); + assert.strictEqual(x1y2z0[deepEqualsMethod](x1y2 as Point3D, equal), false); + assert.strictEqual(x1y2z3[deepEqualsMethod](x1y2 as Point3D, equal), false); }); it("can check cyclic structures of objects with deepEquals methods", function () { @@ -284,7 +289,7 @@ describe("equality", function () { return head.next = node; } - deepEquals(that: Node, equal: DeepEqualsHelper) { + [deepEqualsMethod](that: Node, equal: DeepEqualsHelper) { return this === that || ( equal(this.value, that.value) && equal(this.next, that.next) From 0d2002a90446715e4a2092ca9754bbaa01ec35b5 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 21 Jan 2022 16:16:49 -0500 Subject: [PATCH 24/27] Initialize members lazily to cheapen DeepChecker constructor. --- packages/equality/src/checker.ts | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/equality/src/checker.ts b/packages/equality/src/checker.ts index cb113b0e..0e7ef7f7 100644 --- a/packages/equality/src/checker.ts +++ b/packages/equality/src/checker.ts @@ -10,6 +10,7 @@ import { isPlainObject, objToStr, deepEqualsMethod, + DeepEqualsHelper, } from "./helpers"; type Checker = ( @@ -50,12 +51,20 @@ const CHECKERS_BY_TAG = new Map>() .set('[object AsyncGeneratorFunction]', checkFunctions) .set('[object Function]', checkFunctions); +function getBoundCheck(checker: DeepChecker): DeepEqualsHelper { + return checker["boundCheck"] || (checker["boundCheck"] = function (a, b) { + return checker.check(a, b); + }); +} + export class DeepChecker { - private comparisons = new Trie<{ - equal?: boolean; - }>(false); + // Initialized lazily because not always needed. + private comparisons: null | Trie<{ equal?: boolean; }> = null; + + // Initialized lazily because needed only when custom deepEqualsMethod methods + // are in use. + private boundCheck: null | DeepEqualsHelper = null; - public readonly boundCheck: DeepChecker["check"] = (a, b) => this.check(a, b); public check(a: any, b: any): boolean { // If the two values are strictly equal, our job is easy. if (a === b) { @@ -80,7 +89,9 @@ export class DeepChecker { const found = bothNonNullObjects && - this.comparisons.lookup(a, b); + (this.comparisons || ( + this.comparisons = new Trie(false) + )).lookup(a, b); // Though cyclic references can make an object graph appear infinite from // the perspective of a depth-first traversal, the graph still contains a @@ -122,7 +133,7 @@ function tryEqualsMethod(checker: DeepChecker, a: any, b: any): boolean { return ( isEquatable(checker, a) && isEquatable(checker, b) && - a[deepEqualsMethod](b, checker.boundCheck) && + a[deepEqualsMethod](b, getBoundCheck(checker)) && // Verify symmetry. If a[deepEqualsMethod] is not exactly the same function // as b[deepEqualsMethod], b[deepEqualsMethod](a) can legitimately disagree // with a[deepEqualsMethod](b), so we must check both. However, in the @@ -130,7 +141,7 @@ function tryEqualsMethod(checker: DeepChecker, a: any, b: any): boolean { // additional check should be redundant, unless that method is itself // somehow non-commutative/asymmetric. (a[deepEqualsMethod] === b[deepEqualsMethod] || - b[deepEqualsMethod](a, checker.boundCheck)) + b[deepEqualsMethod](a, getBoundCheck(checker))) ); } From 7918fe6b35f14f596e89091fd48769a0b62ee923 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 21 Jan 2022 16:32:22 -0500 Subject: [PATCH 25/27] Avoid expensive DeepChecker instance property initializations. --- packages/equality/src/checker.ts | 60 +++++++++++++++++++++++++------ packages/equality/src/equality.ts | 8 ++++- 2 files changed, 56 insertions(+), 12 deletions(-) diff --git a/packages/equality/src/checker.ts b/packages/equality/src/checker.ts index 0e7ef7f7..5a32c465 100644 --- a/packages/equality/src/checker.ts +++ b/packages/equality/src/checker.ts @@ -51,19 +51,59 @@ const CHECKERS_BY_TAG = new Map>() .set('[object AsyncGeneratorFunction]', checkFunctions) .set('[object Function]', checkFunctions); +type ComparisonTrie = Trie<{ + equal?: boolean; +}>; + +// Initializing checker.comparisons and checker.boundCheck as proper members of +// the DeepChecker class makes creating DeepChecker objects considerably more +// expensive in some environments, even if we initialize them to null and then +// upgrade them lazily, when needed. Instead, we store these two items of state +// in a separate Map, which gets cleaned up in the DeepChecker#release method. +const privateStateMap = new Map(); + +function getPrivateState(checker: DeepChecker) { + let state = privateStateMap.get(checker)!; + if (!state) privateStateMap.set(checker, state = Object.create(null)); + return state; +} + +function getComparisons(checker: DeepChecker): ComparisonTrie { + const state = getPrivateState(checker); + return state.comparisons || (state.comparisons = new Trie(false)); +} + function getBoundCheck(checker: DeepChecker): DeepEqualsHelper { - return checker["boundCheck"] || (checker["boundCheck"] = function (a, b) { - return checker.check(a, b); - }); + const state = getPrivateState(checker); + return state.boundCheck || ( + state.boundCheck = (a, b) => checker.check(a, b) + ); } +const checkerPool: DeepChecker[] = []; +const CHECKER_POOL_TARGET_SIZE = 5; + export class DeepChecker { - // Initialized lazily because not always needed. - private comparisons: null | Trie<{ equal?: boolean; }> = null; + // Use DeepChecker.acquire() instead of new DeepChecker. + protected constructor() {} - // Initialized lazily because needed only when custom deepEqualsMethod methods - // are in use. - private boundCheck: null | DeepEqualsHelper = null; + static acquire() { + return checkerPool.pop() || new DeepChecker(); + } + + public release() { + // If privateStateMap was a WeakMap, we wouldn't necessarily need to perform + // this cleanup, but not all environments have a (performant) implementation + // of WeakMap, and the cleanup is easy enough: + privateStateMap.delete(this); + + if (checkerPool.length < CHECKER_POOL_TARGET_SIZE) { + checkerPool.push(this); + } + } public check(a: any, b: any): boolean { // If the two values are strictly equal, our job is easy. @@ -89,9 +129,7 @@ export class DeepChecker { const found = bothNonNullObjects && - (this.comparisons || ( - this.comparisons = new Trie(false) - )).lookup(a, b); + getComparisons(this).lookup(a, b); // Though cyclic references can make an object graph appear infinite from // the perspective of a depth-first traversal, the graph still contains a diff --git a/packages/equality/src/equality.ts b/packages/equality/src/equality.ts index 46fc1261..c4158ad6 100644 --- a/packages/equality/src/equality.ts +++ b/packages/equality/src/equality.ts @@ -4,7 +4,13 @@ import { DeepChecker } from "./checker"; * Performs a deep equality check on two JavaScript values, tolerating cycles. */ export function equal(a: any, b: any): boolean { - return a === b || new DeepChecker().check(a, b); + if (a === b) return true; + const checker = DeepChecker.acquire(); + try { + return checker.check(a, b); + } finally { + checker.release(); + } } // Allow default imports as well. From e87f5e31f7f57dad9ba64aaa7fec94ef79887e7c Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 21 Jan 2022 17:04:07 -0500 Subject: [PATCH 26/27] Optimize definedKeys a bit. --- packages/equality/src/helpers.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/equality/src/helpers.ts b/packages/equality/src/helpers.ts index 6717069e..de9691de 100644 --- a/packages/equality/src/helpers.ts +++ b/packages/equality/src/helpers.ts @@ -40,16 +40,18 @@ export function isPlainObject(obj: any): obj is Record { return false; } -export function definedKeys(obj: TObject) { - // Remember that the second argument to Array.prototype.filter will be - // used as `this` within the callback function. - return Object.keys(obj).filter(isDefinedKey, obj); -} -function isDefinedKey( - this: TObject, - key: keyof TObject, -) { - return this[key] !== void 0; +export function definedKeys>(obj: TObject) { + const keys = Object.keys(obj); + const { length } = keys; + let definedCount = 0; + for (let k = 0; k < length; ++k) { + const key = keys[k]; + if (obj[key] !== void 0) { + keys[definedCount++] = key; + } + } + keys.length = definedCount; + return keys; } const nativeCodeSuffix = "{ [native code] }"; From 96f57ae959c4e30aaf47af9305eada0ca3847825 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 21 Jan 2022 17:20:00 -0500 Subject: [PATCH 27/27] Shorten deepEqualsMethod name and export related helpers. --- packages/equality/src/checker.ts | 24 +++++++++++------------ packages/equality/src/equality.ts | 6 ++++++ packages/equality/src/helpers.ts | 8 ++++---- packages/equality/src/tests.ts | 32 +++++++++++++++---------------- 4 files changed, 38 insertions(+), 32 deletions(-) diff --git a/packages/equality/src/checker.ts b/packages/equality/src/checker.ts index 5a32c465..a407d6b5 100644 --- a/packages/equality/src/checker.ts +++ b/packages/equality/src/checker.ts @@ -9,7 +9,7 @@ import { isNonNullObject, isPlainObject, objToStr, - deepEqualsMethod, + deepEquals, DeepEqualsHelper, } from "./helpers"; @@ -169,17 +169,17 @@ export class DeepChecker { function tryEqualsMethod(checker: DeepChecker, a: any, b: any): boolean { return ( - isEquatable(checker, a) && - isEquatable(checker, b) && - a[deepEqualsMethod](b, getBoundCheck(checker)) && - // Verify symmetry. If a[deepEqualsMethod] is not exactly the same function - // as b[deepEqualsMethod], b[deepEqualsMethod](a) can legitimately disagree - // with a[deepEqualsMethod](b), so we must check both. However, in the - // common case where a[deepEqualsMethod] === b[deepEqualsMethod], the - // additional check should be redundant, unless that method is itself - // somehow non-commutative/asymmetric. - (a[deepEqualsMethod] === b[deepEqualsMethod] || - b[deepEqualsMethod](a, getBoundCheck(checker))) + isEquatable(a) && + isEquatable(b) && + a[deepEquals](b, getBoundCheck(checker)) && + // Verify symmetry. If a[deepEquals] is not exactly the same function as + // b[deepEquals], b[deepEquals](a) can legitimately disagree with + // a[deepEquals](b), so we must check both. However, in the common case + // where a[deepEquals] === b[deepEquals], the additional check should be + // redundant, unless that method is itself somehow + // non-commutative/asymmetric. + (a[deepEquals] === b[deepEquals] || + b[deepEquals](a, getBoundCheck(checker))) ); } diff --git a/packages/equality/src/equality.ts b/packages/equality/src/equality.ts index c4158ad6..755c0df3 100644 --- a/packages/equality/src/equality.ts +++ b/packages/equality/src/equality.ts @@ -1,5 +1,11 @@ import { DeepChecker } from "./checker"; +export { + Equatable, + isEquatable, + deepEquals, +} from "./helpers"; + /** * Performs a deep equality check on two JavaScript values, tolerating cycles. */ diff --git a/packages/equality/src/helpers.ts b/packages/equality/src/helpers.ts index de9691de..cf8167b8 100644 --- a/packages/equality/src/helpers.ts +++ b/packages/equality/src/helpers.ts @@ -2,19 +2,19 @@ import { DeepChecker } from "./checker"; export type DeepEqualsHelper = DeepChecker["check"]; -export const deepEqualsMethod = +export const deepEquals = Symbol.for("@wry/equality:deepEquals"); export interface Equatable { - [deepEqualsMethod](that: T, helper: DeepChecker["check"]): boolean; + [deepEquals](that: T, helper: DeepChecker["check"]): boolean; } -export function isEquatable(checker: DeepChecker, obj: any): obj is Equatable { +export function isEquatable(obj: any): obj is Equatable { return ( isNonNullObject(obj) && // Using `in` instead of `hasOwn` because the method could be inherited from // the prototype chain. - deepEqualsMethod in obj + deepEquals in obj ); } diff --git a/packages/equality/src/tests.ts b/packages/equality/src/tests.ts index 2eb8d342..38098566 100644 --- a/packages/equality/src/tests.ts +++ b/packages/equality/src/tests.ts @@ -3,7 +3,7 @@ import defaultEqual, { equal } from "./equality"; import { Equatable, DeepEqualsHelper, - deepEqualsMethod, + deepEquals, objToStr, } from "./helpers"; @@ -177,13 +177,13 @@ describe("equality", function () { assertNotEqual(c2, c3); }); - it("should respect custom a[deepEqualsMethod](b) methods for unknown Symbol.toStringTag", function () { + it("should respect custom a[deepEquals](b) methods for unknown Symbol.toStringTag", function () { class Tagged { [Symbol.toStringTag] = "Tagged"; constructor(private value: any) {} - [deepEqualsMethod](that: Tagged) { + [deepEquals](that: Tagged) { return this.value === that.value; } } @@ -205,7 +205,7 @@ describe("equality", function () { assertNotEqual(t1b, t2b); }); - it("should respect asymmetric a[deepEqualsMethod](b) methods", function () { + it("should respect asymmetric a[deepEquals](b) methods", function () { class Point2D implements Equatable { constructor( public readonly x: number, @@ -213,7 +213,7 @@ describe("equality", function () { ) {} // It's a shame that we have to provide the parameter types explicitly. - [deepEqualsMethod](that: Point2D, equal: DeepEqualsHelper) { + [deepEquals](that: Point2D, equal: DeepEqualsHelper) { return this === that || ( equal(this.x, that.x) && equal(this.y, that.y) @@ -230,9 +230,9 @@ describe("equality", function () { super(x, y); } - [deepEqualsMethod](that: Point3D, equal: DeepEqualsHelper) { + [deepEquals](that: Point3D, equal: DeepEqualsHelper) { return this === that || ( - super[deepEqualsMethod](that, equal) && + super[deepEquals](that, equal) && equal(this.z, that.z) ); } @@ -248,10 +248,10 @@ describe("equality", function () { assertEqual(x1y2z0, x1y2z0); assertEqual(x1y2z3, x1y2z3); - assert.strictEqual(x1y2[deepEqualsMethod](x1y2, equal), true); - assert.strictEqual(x2y1[deepEqualsMethod](x2y1, equal), true); - assert.strictEqual(x1y2z0[deepEqualsMethod](x1y2z0, equal), true); - assert.strictEqual(x1y2z3[deepEqualsMethod](x1y2z3, equal), true); + assert.strictEqual(x1y2[deepEquals](x1y2, equal), true); + assert.strictEqual(x2y1[deepEquals](x2y1, equal), true); + assert.strictEqual(x1y2z0[deepEquals](x1y2z0, equal), true); + assert.strictEqual(x1y2z3[deepEquals](x1y2z3, equal), true); assertEqual(x1y2, new Point2D(1, 2)); assertEqual(x2y1, new Point2D(2, 1)); @@ -267,10 +267,10 @@ describe("equality", function () { // These are the most interesting cases, because x1y2 thinks it's equal to // both x1y2z0 and x1y2z3, but the equal(a, b) function enforces symmetry. assertNotEqual(x1y2, x1y2z0); - assert.strictEqual(x1y2[deepEqualsMethod](x1y2z0, equal), true); - assert.strictEqual(x1y2[deepEqualsMethod](x1y2z3, equal), true); - assert.strictEqual(x1y2z0[deepEqualsMethod](x1y2 as Point3D, equal), false); - assert.strictEqual(x1y2z3[deepEqualsMethod](x1y2 as Point3D, equal), false); + assert.strictEqual(x1y2[deepEquals](x1y2z0, equal), true); + assert.strictEqual(x1y2[deepEquals](x1y2z3, equal), true); + assert.strictEqual(x1y2z0[deepEquals](x1y2 as Point3D, equal), false); + assert.strictEqual(x1y2z3[deepEquals](x1y2 as Point3D, equal), false); }); it("can check cyclic structures of objects with deepEquals methods", function () { @@ -289,7 +289,7 @@ describe("equality", function () { return head.next = node; } - [deepEqualsMethod](that: Node, equal: DeepEqualsHelper) { + [deepEquals](that: Node, equal: DeepEqualsHelper) { return this === that || ( equal(this.value, that.value) && equal(this.next, that.next)