-
Notifications
You must be signed in to change notification settings - Fork 29.6k
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
Conversation
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
lib/assert.js
Outdated
|
||
function isSet(object) { | ||
return object.constructor === Set; | ||
} |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
... Done.
There was a problem hiding this comment.
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.
Updated PR based on comments Ref: nodejs#12142
There was a problem hiding this 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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM
I've updated the PR to do the quadratic pairwise checking based on the conversation above. |
There was a problem hiding this 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; |
There was a problem hiding this comment.
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). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: primitive-valued
@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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
s/accessable/accessible/
I think it's semver-major. This can and probably will break something. |
@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) { |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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); | ||
} |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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'
?
There was a problem hiding this comment.
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). |
There was a problem hiding this comment.
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') { |
There was a problem hiding this comment.
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')
?
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
Not sure how important this is to the discussion. There are also some subtle differences in SameValueZero (the set of comparison semantics 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 |
@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); | |||
|
|||
|
There was a problem hiding this comment.
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?
@josephg I believe the format for a not-yet-released changelog is...
|
- Added changes: entries in assert API documentation - Refactored setEquiv based on @joyeecheung's comments ref: nodejs#12142
doc/api/assert.md
Outdated
@@ -148,6 +157,21 @@ assert.deepStrictEqual(date, fakeDate); | |||
// Different type tags | |||
``` | |||
|
|||
An exception is made to the strict equality comparison rule for [`Map`][] keys |
There was a problem hiding this comment.
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][] ( `===` )
doc/api/assert.md
Outdated
@@ -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. |
There was a problem hiding this comment.
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?
There was a problem hiding this 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
Cheers :) |
Refactored setEquiv and mapEquiv based on @joyeecheung's stylistic preference to avoid labels.
@joyeecheung I've rewritten 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. |
@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/ |
Landed in 6481c93, and again thanks for the PR! |
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>
Yay! Thanks for helping me get my first PR in node everyone! |
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
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>
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 mapvalues 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), orvcbuild test
(Windows) passesAffected core subsystem(s)