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

assert: Add support for Map and Set in deepEqual #12142

Closed
wants to merge 13 commits into from

Conversation

josephg
Copy link
Contributor

@josephg josephg commented Mar 31, 2017

assert.deepEqual and assert.deepStrictEqual currently return true for
any pair of Maps and Sets regardless of content. This patch adds
support in deepEqual and deepStrictEqual to verify the contents of Maps
and Sets.

Unfortunately because there's no way to pairwise fetch set values or map
values which are equivalent but not reference-equal, this change
currently only supports reference equality checking in set values and
map keys. Equivalence checking could be done, but it would be an
O(n^2) operation, and worse, it would get slower exponentially if maps
and sets were nested.

Update: Based on conversation below, the implementation now incurs an O(n^2) cost if the sets or maps are not equal, because to be correct we need to check all pairs of values in sets, and all pairs of keys in a map. This may become exponentially more expensive with input which does deep equality checking on nested maps. But given these checks are almost always only done during testing, and on small objects its probably the right approach.

Update 2: Based on a clever suggestion by @TimothyGu the O(n^2) cost is now only paid when you're either in non-strict mode or when you're using set values & map keys which have typeof object.

Note that this change breaks compatibility with previous versions of
deepEqual and deepStrictEqual if consumers were depending on all maps
and sets to be seen as equivalent. The old behaviour was never
documented, but nevertheless there are certainly some tests out there somewhere
which depend on it.

Support had been stalled because the assert API was frozen, but assert was recently
unfrozen in CTC#63

Fixes: #2309
Refs: tape-testing/tape#342
Refs: #2315
Refs: nodejs/CTC#63

Checklist
  • make -j4 test (UNIX), or vcbuild test (Windows) passes
  • tests and/or benchmarks are included
  • commit message follows commit guidelines
Affected core subsystem(s)
  • Assert

assert.deepEqual and assert.deepStrictEqual currently return true for
any pair of Maps and Sets regardless of content. This patch adds
support in deepEqual and deepStrictEqual to verify the contents of Maps
and Sets.

Unfortunately because there's no way to pairwise fetch set values or map
values which are equivalent but not reference-equal, this change
currently only supports reference equality checking in set values and
map key values. Equivalence checking could be done, but it would be an
O(n^2) operation, and worse, it would get slower exponentially if maps
and sets were nested.

Note that this change breaks compatibility with previous versions of
deepEqual and deepStrictEqual if consumers were depending on all maps
and sets to be seen as equivalent. The old behaviour was never
documented, but nevertheless there are certainly some tests out there
which depend on it.

Support has stalled because the assert API was frozen, but was recently
unfrozen in CTC#63

Fixes: nodejs#2309
Refs: tape-testing/tape#342
Refs: nodejs#2315
Refs: nodejs/CTC#63
@nodejs-github-bot nodejs-github-bot added the assert Issues and PRs related to the assert subsystem. label Mar 31, 2017
@Trott Trott added the semver-minor PRs that contain new features and should be released in the next minor version. label Mar 31, 2017
@Trott
Copy link
Member

Trott commented Mar 31, 2017

CI: https://ci.nodejs.org/job/node-test-pull-request/7134/

lib/assert.js Outdated

function isSet(object) {
return object.constructor === Set;
}
Copy link
Member

Choose a reason for hiding this comment

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

Can you use isMap and isSet from process.binding('util') instead? These checks won’t work for subclasses of Map/Set or Maps/Sets from other VM contexts.

lib/assert.js Outdated
// it would require scanning each pairwise set of not strict-equal items,
// which is O(n^2), and would get exponentially worse if sets are nested. So
// for now we simply won't support deep equality checking set items or map
// keys.
Copy link
Member

Choose a reason for hiding this comment

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

I think I would actually prefer the awful performance over always using strict equality… @nodejs/collaborators thoughts?

Copy link
Member

Choose a reason for hiding this comment

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

In this case, I think I'd value correct over performant, so yeah, I agree with @addaleax.

Copy link
Contributor Author

@josephg josephg Mar 31, 2017

Choose a reason for hiding this comment

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

For tests, I agree. I'm nervous people might be using assert.deepEqual inside runtime code, although I just checked the node_modules directory of a project I'm working on with ~400 transitive dependancies and only jsprim calls assert.deepEqual outside of a test, and thats a in a very minor way. I'll wait for some more feedback, but unless there's any strong objections I'll change the behaviour to be correct and slow.

Copy link
Contributor Author

@josephg josephg Mar 31, 2017

Choose a reason for hiding this comment

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

... Another strong argument in favor of making it correct > fast is that it'll be more compatible with the current implementation. If someone currently has a test that reads assert.deepStrictEqual(new Set([{x:5}]), new Set([{x:5}])) that test will currently pass because the sets won't be compared. With the PR in its current state, that test will start to fail.

Copy link
Member

Choose a reason for hiding this comment

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

@josephg Maybe just update this PR and see if somebody objects? If we need to go back, the current HEAD is at f051840.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

... Done.

Copy link
Member

Choose a reason for hiding this comment

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

#14258 just landed and reduces the complexity to O(n log n). Therefore the performance should not be of much concern anymore.

Copy link
Member

@Trott Trott left a comment

Choose a reason for hiding this comment

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

LGTM if CI comes back green and with or without addression @addaleax's comments (but preferably with!)

This change updates the checks for deep equality checking on Map and Set
to check all set values / all map keys to see if any of them match the
expected result.

This change is much slower, but based on the conversation in the pull
request its probably the right approach.

Ref: nodejs#12142
Copy link
Member

@mcollina mcollina left a comment

Choose a reason for hiding this comment

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

LGTM

@josephg
Copy link
Contributor Author

josephg commented Mar 31, 2017

I've updated the PR to do the quadratic pairwise checking based on the conversation above.

Copy link
Member

@addaleax addaleax left a comment

Choose a reason for hiding this comment

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

Awesome PR. 👍

lib/assert.js Outdated
// deepEqual. The implementation currently returns false, which is a simpler
// and faster implementation.
if (a.size !== b.size)
return false;
Copy link
Member

Choose a reason for hiding this comment

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

👍 I think deepEqual should be correct to reject sets of different sizes.

lib/assert.js Outdated

// This check is not strictly necessary, but its here to improve
// performance of the common case when reference-equal keys exist (which
// includes all primitive valued keys).
Copy link
Member

Choose a reason for hiding this comment

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

nit: primitive-valued

@addaleax
Copy link
Member

@nodejs/ctc semver-major or semver-minor or semver-minor but all dont-land labels?

lib/assert.js Outdated
@@ -307,6 +387,22 @@ function objEquiv(a, b, strict, actualVisitedObjects) {
return false;
}

// Sets and maps don't have their entries accessable via normal object
Copy link
Member

Choose a reason for hiding this comment

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

s/accessable/accessible/

@targos
Copy link
Member

targos commented Mar 31, 2017

I think it's semver-major. This can and probably will break something.

@vsemozhetbyt
Copy link
Contributor

@josephg A nit: traditionally, commit title should use lower case imperative verb after subsystem prefix. Buth this can be fixed by anybody who will land the commits.

lib/assert.js Outdated
return false;

var val1, val2;
outer: for (val1 of a) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you use const here?

lib/assert.js Outdated
// hunting for something thats deep-equal to it. Note that this is O(n^2)
// complexity, and will get slower if large, very similar sets / maps are
// nested inside. Unfortunately there's no real way around this.
for (val2 of b) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you use const here?

lib/assert.js Outdated
return false;

var key1, key2, item1, item2;
outer: for ([key1, item1] of a) {
Copy link
Contributor

Choose a reason for hiding this comment

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

can you use const here as well?

lib/assert.js Outdated

// Hunt for keys which are deep-equal to key1 in b. Just like setEquiv
// above, this hunt makes this function O(n^2).
for ([key2, item2] of b) {
Copy link
Contributor

Choose a reason for hiding this comment

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

can you use const here as well?

const m2 = new Map();
m1.x = 5;
assertNotDeepOrStrict(m1, m2);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe add a case where there is a circular reference?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done.

Cleaned up as per comments in issue

Ref: nodejs#6416
lib/assert.js Outdated
// The value doesn't exist in the second set by reference, so we'll go
// hunting for something thats deep-equal to it. Note that this is O(n^2)
// complexity, and will get slower if large, very similar sets / maps are
// nested inside. Unfortunately there's no real way around this.
Copy link
Member

@TimothyGu TimothyGu Mar 31, 2017

Choose a reason for hiding this comment

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

Is it possible to optimize this by only starting the additional search if !strict || typeof val1 === 'object'?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes. Thats really clever, and very obvious now that you've pointed it out. Fixed.

lib/assert.js Outdated
}

// Hunt for keys which are deep-equal to key1 in b. Just like setEquiv
// above, this hunt makes this function O(n^2).
Copy link
Member

Choose a reason for hiding this comment

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

Ditto.

Based on comments in the PR, this change restricts an O(n^2) to only
happen when your set contains object-like objects, your map contains
object-like keys or you're not in strict mode.

ref: nodejs#12142 (review)
lib/assert.js Outdated
for (const val2 of b) {
if (_deepEqual(val1, val2, strict, actualVisitedObjects)) {
continue outer;
if (!strict || typeof val1 === 'object') {
Copy link
Member

Choose a reason for hiding this comment

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

(!strict || (typeof val1 === 'object' && val1 !== null) || typeof val1 === 'function')?

Copy link
Contributor Author

@josephg josephg Mar 31, 2017

Choose a reason for hiding this comment

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

We don't need to search for functions because the only way deepEqual will consider functions equivalent is through reference equality. The null check makes some sense, although I think it'll make a very small difference in practice because putting null in a set and using null as a key are rare. (In my code, null popping up in either of those places is usually an indication of a bug)

Copy link
Contributor Author

@josephg josephg Apr 1, 2017

Choose a reason for hiding this comment

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

Its a minor point, but do you think its still worth putting in the null check? I'm indifferent either way, but happy to add it if you think the marginal speed improvement is worth the marginal extra complexity of that check.

Copy link
Member

Choose a reason for hiding this comment

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

@josephg Yes, I’d say it’s worth it… there might be cases where the difference is not so marginal, and I think it’s okay for Node core to sacrifice a tiny bit of readability for a small performance gain.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

@addaleax addaleax added semver-major PRs that contain breaking changes and should be released in the next major version. and removed semver-minor PRs that contain new features and should be released in the next minor version. labels Mar 31, 2017
@addaleax addaleax added this to the 8.0.0 milestone Mar 31, 2017
@TimothyGu
Copy link
Member

TimothyGu commented Apr 1, 2017

Not sure how important this is to the discussion. There are also some subtle differences in SameValueZero (the set of comparison semantics .has() uses), abstract equality (==, the operator assert.equal uses), and strict equality (===, the operator assert.strictEqual uses). Concretely, this means code such as the following may yield surprising results:

assert.equal(NaN, NaN);
    // AssertionError: NaN === NaN
assert.strictEqual(NaN, NaN);
    // AssertionError: NaN === NaN

const map1 = new Map([[NaN, 0]]);
const map2 = new Map([[NaN, 0]]);
assert.deepEqual(map1, map2);
     // No error
assert.strictDeepEqual(map1, map2);
     // No error

@josephg
Copy link
Contributor Author

josephg commented Apr 1, 2017

@joyeecheung I've cleaned up the documentation. Left the little inline YAML changelog alone for now.

lib/assert.js Outdated
@@ -280,9 +282,89 @@ function _deepEqual(actual, expected, strict, memos) {
memos.actual.push(actual);
memos.expected.push(expected);


Copy link
Member

Choose a reason for hiding this comment

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

Just noticed..unrelated white space change?

@joyeecheung
Copy link
Member

@josephg I believe the format for a not-yet-released changelog is...

changes:
  - version: REPLACEME
    pr-url: https://github.com/nodejs/node/pull/12142
    description: ....

- Added changes: entries in assert API documentation
- Refactored setEquiv based on @joyeecheung's comments

ref: nodejs#12142
@@ -148,6 +157,21 @@ assert.deepStrictEqual(date, fakeDate);
// Different type tags
```

An exception is made to the strict equality comparison rule for [`Map`][] keys
Copy link
Member

@joyeecheung joyeecheung Apr 1, 2017

Choose a reason for hiding this comment

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

This should probably go to the item 1 above...also the NaN case can be demonstrated by linking to the existing caveats section, something like

 1. For Sets and Maps, primitive values are compared using the [SameValueZero][] comparison
    (which means they are free of the [caveats][]).
    For other objects, primitive values are compared using the [Strict Equality Comparison][] ( `===` )

@@ -18,6 +18,9 @@ An alias of [`assert.ok()`][].
<!-- YAML
added: v0.1.21
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/12142
description: Sets and Maps are handled correctly.
Copy link
Member

Choose a reason for hiding this comment

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

"handled correctly" is a bit vague..maybe "elements in Sets and Maps are compared during deep comparison" or something like that?

Copy link
Member

@joyeecheung joyeecheung left a comment

Choose a reason for hiding this comment

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

minus some nits, I think this can go ahead now...thanks for bearing with me and for the contribution, your hard work is defnitely appreciated :D

@josephg
Copy link
Contributor Author

josephg commented Apr 1, 2017

Cheers :)

Refactored setEquiv and mapEquiv based on @joyeecheung's stylistic
preference to avoid labels.
@josephg
Copy link
Contributor Author

josephg commented Apr 1, 2017

@joyeecheung I've rewritten mapEquiv and setEquiv based on our conversation above. The tight if() statement logic we were talking about turned out to be completely unreadable. As proof that it was a bad idea neither of us was able to produce working code of that form. Even my final attempt had a small mistake and I completely confused myself trying to fix it, so I abandoned that style completely. The current code passed all tests on its first run.

I think its all very readable now - although using ancillary functions makes it more wordy than it was using loop labels. I'm not sure the code is any better for it. But, we're enough opinions deep that I'm getting exhausted from all this churn on such a minor patch. If anyone wants to make more purely stylistic changes I'm happy to add you as a contributor to josephg/node. You're welcome to modify the PR directly.

Otherwise I don't know the process, but I'm happy for it to be merged.

@joyeecheung
Copy link
Member

@josephg Sorry if my reviews are disheartening and thanks for sticking with this PR!

CI: https://ci.nodejs.org/job/node-test-pull-request/7153/
CITGM since it's semver-major: https://ci.nodejs.org/view/Node.js-citgm/job/citgm-smoker/683/

@addaleax
Copy link
Member

addaleax commented Apr 3, 2017

Landed in 6481c93, and again thanks for the PR!

@addaleax addaleax closed this Apr 3, 2017
addaleax pushed a commit that referenced this pull request Apr 3, 2017
assert.deepEqual and assert.deepStrictEqual currently return true for
any pair of Maps and Sets regardless of content. This patch adds
support in deepEqual and deepStrictEqual to verify the contents of Maps
and Sets.

Deeo equivalence checking is currently an
O(n^2) operation, and worse, it gets slower exponentially if maps
and sets were nested.

Note that this change breaks compatibility with previous versions of
deepEqual and deepStrictEqual if consumers were depending on all maps
and sets to be seen as equivalent. The old behaviour was never
documented, but nevertheless there are certainly some tests out there
which depend on it.

Support has stalled because the assert API was frozen, but was recently
unfrozen in CTC#63.

---

Later squashed in:

This change updates the checks for deep equality checking on Map and Set
to check all set values / all map keys to see if any of them match the
expected result.

This change is much slower, but based on the conversation in the pull
request its probably the right approach.

Fixes: #2309
Refs: tape-testing/tape#342
Refs: #2315
Refs: nodejs/CTC#63
PR-URL: #12142
Reviewed-By: Anna Henningsen <anna@addaleax.net>
Reviewed-By: Rich Trott <rtrott@gmail.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Joyee Cheung <joyeec9h3@gmail.com>
@josephg
Copy link
Contributor Author

josephg commented Apr 4, 2017

Yay! Thanks for helping me get my first PR in node everyone!

@jasnell jasnell mentioned this pull request Apr 4, 2017
@josephg josephg deleted the assert-sets-and-maps branch June 2, 2017 23:53
josephg added a commit to josephg/node that referenced this pull request Jun 3, 2017
This fixes a bug where deepEqual and deepStrictEqual would have
incorrect behaviour in sets and maps containing multiple equivalent
keys.

Fixes: nodejs#13347
Refs: nodejs#12142
refack pushed a commit to refack/node that referenced this pull request Jun 5, 2017
This fixes a bug where deepEqual and deepStrictEqual would have
incorrect behaviour in sets and maps containing multiple equivalent
keys.

PR-URL: nodejs#13426
Fixes: nodejs#13347
Refs: nodejs#12142
Reviewed-By: Refael Ackermann <refack@gmail.com>
jasnell pushed a commit that referenced this pull request Jun 7, 2017
This fixes a bug where deepEqual and deepStrictEqual would have
incorrect behaviour in sets and maps containing multiple equivalent
keys.

PR-URL: #13426
Fixes: #13347
Refs: #12142
Reviewed-By: Refael Ackermann <refack@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
assert Issues and PRs related to the assert subsystem. semver-major PRs that contain breaking changes and should be released in the next major version.
Projects
None yet
Development

Successfully merging this pull request may close these issues.