Skip to content

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

Closed
josephg wants to merge 13 commits into
nodejs:masterfrom
josephg:assert-sets-and-maps
Closed

assert: Add support for Map and Set in deepEqual#12142
josephg wants to merge 13 commits into
nodejs:masterfrom
josephg:assert-sets-and-maps

Conversation

@josephg

@josephgjosephg commented Mar 31, 2017

Copy link
Copy Markdown
Contributor

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-botnodejs-github-bot added the assert Issues and PRs related to the assert subsystem. label Mar 31, 2017
@TrottTrott 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
Copy Markdown
Member

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

Comment threadlib/assert.js Outdated

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

Copy link
Copy Markdown
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.

Comment threadlib/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
Copy Markdown
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
Copy Markdown
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.

@josephgjosephgMar 31, 2017

Copy link
Copy Markdown
ContributorAuthor

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.

@josephgjosephgMar 31, 2017

Copy link
Copy Markdown
ContributorAuthor

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
Copy Markdown
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
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

... Done.

Copy link
Copy Markdown
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.

@TrottTrott left a comment

Copy link
Copy Markdown
Member

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

@mcollinamcollina left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM

@josephg

Copy link
Copy Markdown
ContributorAuthor

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

@addaleaxaddaleax left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Awesome PR. 👍

Comment threadlib/assert.js
// deepEqual. The implementation currently returns false, which is a simpler
// and faster implementation.
if (a.size !== b.size)
return false;

Copy link
Copy Markdown
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.

Comment threadlib/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
Copy Markdown
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
Copy Markdown
Member

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

Comment threadlib/assert.js Outdated
return false;
}

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

Copy link
Copy Markdown
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
Copy Markdown
Member

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

@vsemozhetbyt

Copy link
Copy Markdown
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.

evanlucas
evanlucas previously requested changes Mar 31, 2017
Comment threadlib/assert.js Outdated
return false;

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

Copy link
Copy Markdown
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?

Comment threadlib/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
Copy Markdown
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?

Comment threadlib/assert.js Outdated
return false;

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

Copy link
Copy Markdown
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?

Comment threadlib/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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
ContributorAuthor

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
Comment threadlib/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.

@TimothyGuTimothyGuMar 31, 2017

Copy link
Copy Markdown
Member

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
Copy Markdown
ContributorAuthor

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.

Comment threadlib/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
Copy Markdown
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)
Comment threadlib/assert.js Outdated
for (const val2 of b) {
if (_deepEqual(val1, val2, strict, actualVisitedObjects)) {
continue outer;
if (!strict || typeof val1 === 'object') {

Copy link
Copy Markdown
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')?

@josephgjosephgMar 31, 2017

Copy link
Copy Markdown
ContributorAuthor

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)

@josephgjosephgApr 1, 2017

Copy link
Copy Markdown
ContributorAuthor

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
Copy Markdown
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
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Done

@addaleaxaddaleax 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
@addaleaxaddaleax added this to the 8.0.0 milestone Mar 31, 2017
@TimothyGu

TimothyGu commented Apr 1, 2017

Copy link
Copy Markdown
Member

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 === NaNassert.strictEqual(NaN,NaN);// AssertionError: NaN === NaNconstmap1=newMap([[NaN,0]]);constmap2=newMap([[NaN,0]]);assert.deepEqual(map1,map2);// No errorassert.strictDeepEqual(map1,map2);// No error

@josephg

josephg commented Apr 1, 2017

Copy link
Copy Markdown
ContributorAuthor

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

Comment threadlib/assert.js Outdated
memos.actual.push(actual);
memos.expected.push(expected);


Copy link
Copy Markdown
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
Copy Markdown
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
Comment threaddoc/api/assert.md Outdated
// Different type tags
```

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

@joyeecheungjoyeecheungApr 1, 2017

Copy link
Copy Markdown
Member

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][] ( `===` )

Comment threaddoc/api/assert.md Outdated
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/12142
description: Sets and Maps are handled correctly.

Copy link
Copy Markdown
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?

@joyeecheungjoyeecheung left a comment

Copy link
Copy Markdown
Member

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
Copy Markdown
ContributorAuthor

Cheers :)

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

josephg commented Apr 1, 2017

Copy link
Copy Markdown
ContributorAuthor

@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
Copy Markdown
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
Copy Markdown
Member

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

@addaleaxaddaleax 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
Copy Markdown
ContributorAuthor

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

@jasnelljasnell mentioned this pull request Apr 4, 2017
@TrottTrott mentioned this pull request Apr 11, 2017
3 tasks
@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#13426Fixes: 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: #13426Fixes: #13347
Refs: #12142
Reviewed-By: Refael Ackermann <refack@gmail.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

assertIssues and PRs related to the assert subsystem.semver-majorPRs 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.

11 participants

@josephg@Trott@addaleax@targos@vsemozhetbyt@TimothyGu@joyeecheung@mcollina@evanlucas@BridgeAR@nodejs-github-bot