Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor @wry/equality to support custom [deepEquals] methods #230

Open
wants to merge 27 commits into
base: main
Choose a base branch
from

Conversation

benjamn
Copy link
Owner

@benjamn benjamn commented Sep 15, 2021

The @wry/equality package currently uses only === equality to compare objects whose Object.prototype.toString tag is something other than [object Object], but it can mistakenly use deep equality for custom classes/objects that use the default [object Object] tag. This is a mistake because there's no guarantee iterating over the public enumerable properties of an arbitrary object is a good way to check for equality, so it's better to stay safe and use ===.

This PR fixes that mistake by comparing objects that have custom prototypes (other than Object.prototype or null) using only === by default. Since this restriction potentially leaves custom objects with no way to participate in deep equality checking, this PR allows objects to implement a [deepEquals] method if they want to participate, where deepEquals is a Symbol that must be imported from the @wry/equality package:

import {
  deepEquals,
  DeepEqualsHelper,
} from "@wry/equality"

class Point2D {
  constructor(
    public readonly x: number,
    public readonly y: number,
  ) {}

  [deepEquals](that: Point2D, equal: DeepEqualsHelper) {
    return this === that || (
      equal(this.x, that.x) &&
      equal(this.y, that.y)
    );
  }
}

This optional [deepEquals] method must be defined by both objects, and a[deepEquals](b) must agree with b[deepEquals](a) (if a[deepEquals] !== b[deepEquals]). If you need to perform nested comparisons, you should use the predicate function passed as the second parameter (equal in the Point2D example above), since it knows which objects have been compared previously, so cycles in the graph will not cause infinite loops.

In the process of supporting [deepEquals], I realized the big switch (aTag) {...} list within @wry/equality had gotten long enough to be slower than using a Map to look up checker functions (which should take constant time, regardless of how many types are supported by @wry/equality), so I refactored that system to use a Map instead of a switch.

@benjamn benjamn self-assigned this Sep 15, 2021
Comment on lines 6 to 7
export function equal(a: any, b: any): boolean {
try {
return check(a, b);
} finally {
previousComparisons.clear();
}
return new DeepChecker().check(a, b);
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For better or worse, since equality checking now potentially calls into user-provided deepEquals methods, we cannot be sure the check(a, b) function will return before other code calls equal(...), so the trick of using a single previousComparisons map and clearing it after each check no longer works, and we need to allocate a separate DeepChecker (with its own comparisons map) for each call to equal(a, b).

benjamn added a commit that referenced this pull request Sep 15, 2021
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.
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.
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.
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.
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.
@benjamn benjamn force-pushed the refactor-to-support-custom-deepEquals-methods branch from 93d405a to e87f5e3 Compare January 21, 2022 22:08
@benjamn benjamn changed the title Refactor @wry/equality to support custom deepEquals methods Refactor @wry/equality to support custom [deepEquals] methods Jan 21, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant