proposal-array-equality icon indicating copy to clipboard operation
proposal-array-equality copied to clipboard

Consider `Object.isEqual(a, b)` or similar instead

Open dead-claudia opened this issue 3 years ago β€’ 17 comments

I feel very strongly that deep equality should be performed with a static function, not an array method or anything similar. Library and language precedent strongly argues in favor of this model:

dead-claudia avatar Mar 02 '21 03:03 dead-claudia

BTW, Java's java.util.Arrays.equals delegates to a[i] != null ? a[i].equals(b[i]) : b[i] == null as its comparison, and coincidentally, this is literally what Kotlin desugars a[i] == b[i] to, relegating Java's equality operator's behavior to ===.

dead-claudia avatar Mar 02 '21 03:03 dead-claudia

The array method isn’t that useful without a protocol, and a protocol without a generic method (like on Object) is also not that useful. It seems reasonable to me to provide this kind of static method.

ljharb avatar Mar 02 '21 04:03 ljharb

I think that static method should also have a 3rd argument which is for deepness, true by default. This approach would be similar to that of cloneNode. Would be useful because sometimes one wants to test for any data changes, like in JSON.stringify(a) === JSON.stringify(B) and in most web frameworks, things like props and state objects have to be compared where it would make much more sense to not deeply compare these objects, due to performance reasons.

L3P3 avatar Mar 02 '21 08:03 L3P3

@L3P3 That could be done with an Object.isShallowEqual, but I could see the utility of it - React uses shallow equality IIRC for its React.memo.

dead-claudia avatar Mar 02 '21 08:03 dead-claudia

I also agree with #6 so Object.isEqual should use that implementation for the used type, early-returning false when their type does not match up. Edit: Might not work like that. Then, I don't see much of a point for this static method when == could also be used. The only upside of this static method approach is that deepness can be specified in my opinion. Or should there be another, new operator for shallow equality?

L3P3 avatar Mar 02 '21 08:03 L3P3

@L3P3

Then, I don't see much of a point for this static method when == could also be used

The chances of that operator being modifiable in any way is basically zero. Plus, its semantics are not useful - I don't want Object.isShallowEqual(1, "1") to evaluate to true, and I don't think anyone else here wants that, either (that's the semantics it provides). Plus, {} == {} evaluates to false, which is explicitly not what anyone wants. So let's not go that route. πŸ™‚

dead-claudia avatar Mar 02 '21 08:03 dead-claudia

As for any operator, I feel it's a little premature for that. The proposal's very, very stage 1, and even my #6 is probably premature. (I wasn't paying close enough attention to how close it was to stage 2, or I would've probably worded that a little differently.)

dead-claudia avatar Mar 02 '21 08:03 dead-claudia

Yes, lets abandon == into legacy. :grin: But what about Object.isEqual and Object.isShallowEqual being just a shim for returning false on type mismatch and then calling type[Symbol.equals](a, b, bool deep)?

L3P3 avatar Mar 02 '21 08:03 L3P3

Have you...tried using that kind of function in any sort of demo code? I highly recommend it - you might be enlightened. πŸ˜‰

dead-claudia avatar Mar 02 '21 08:03 dead-claudia

Actually no and I have no idea what function you mean. None of these are existing in the spec so far, right? And in case my definition was too short, I think of these:

Object.isEqual = (a, b) => {
  if (typeof a !== typeof b) return false;
  if (a === b) return true;
  if (a === null || b === null) return false;
  if (a.constructor !== b.constructor) return false;
  return Boolean(a.constructor.prototype[Symbol.equals].call(a, b, true));
}
Object.isShallowEqual = (a, b) => {
  if (typeof a !== typeof b) return false;
  if (a === b) return true;
  if (a === null || b === null) return false;
  if (a.constructor !== b.constructor) return false;
  return Boolean(a.constructor.prototype[Symbol.equals].call(a, b, false));
}

L3P3 avatar Mar 02 '21 08:03 L3P3

I might totally miss something huge. I have no experience with JS spec drafts. :wink:

L3P3 avatar Mar 02 '21 08:03 L3P3

In my example, I think there is too much redundancy so I propose to ditch isShallowEqual and instead have it like this:

Object.isEqual = (a, b, deep = true) => {
  if (typeof a !== typeof b) return false;
  if (a === b) return true;
  if (a === null || b === null) return false;
  if (a.constructor !== b.constructor) return false;
  return Boolean(a.constructor.prototype[Symbol.equals].call(a, b, deep));
}

L3P3 avatar Mar 02 '21 08:03 L3P3

The problem this proposal is trying to solve requires deep - and only deep - equality. There won't be any way to do "shallow" equality beyond a user object defining a protocol method that has those semantics.

ljharb avatar Mar 02 '21 20:03 ljharb

It's also recommended that this method should employ detection of circular references, or else it will run into an infinite loop when it finds a self-referential object.

In my example, I think there is too much redundancy so I propose to ditch isShallowEqual and instead have it like this:

Object.isEqual = (a, b, deep = true) => {
  if (typeof a !== typeof b) return false;
  if (a === b) return true;
  if (a === null || b === null) return false;
  if (a.constructor !== b.constructor) return false;
  return Boolean(a.constructor.prototype[Symbol.equals].call(a, b, deep));
}

I thought of replacing the checks if an object is null with loose equality because null == undefined is true (but null === undefined is false), but since undefined and null have a different output from the typeof operator (typeof undefined is "undefined", while typeof null is "object"), this isn't necessary.

C-Ezra-M avatar Jul 26 '22 10:07 C-Ezra-M

It's also recommended that this method should employ detection of circular references, or else it will run into an infinite loop when it finds a self-referential object.

I actually doubt if it's necessary because the following example Python program throws a RecursionError in the end:

mydict1 = {'this': 'that'}
mydict2 = {**mydict1}
print(mydict1 == mydict2) # true
print(mydict1 is mydict2) # false, `is` keyword checks memory references

mydict1['self'] = mydict1
mydict2['self'] = mydict2

print(mydict1 == mydict2) # throws a RecursionError due to circular references

Object.isEqual() could then look like this:

Object.isEqual = (a, b, deep = true) => {
  if (Object.is(a, b)) return true;
  if (a === b) return true;
  if (typeof a !== typeof b) return false;
  if (a === null || b === null) return false;
  if (a.constructor !== b.constructor) return false;
  return Boolean(a.constructor.prototype[Symbol.equals].call(a, b, deep));
}

This was supposed to call for Number.isNaN() as a more robust alternative to the global function isNaN(), which I initially considered instead of Object.is(), which does treat NaN as equal to itself and Number.NaN.

The === equality check is still required because Object.is(0, -0) is false, even though Object.is(0n, -0n) is true.

~~Also, checking typeof a !== typeof b was removed because === already performs the type check.~~ EDIT: Added it back.

C-Ezra-M avatar Aug 02 '22 14:08 C-Ezra-M

This is interesting discussion, but I just want to point out the current README completely fails to explain what kind of comparison is to be done on the elements. If it's still fuzzy, I recommend saying so, and listing required properties to clarify the design space being explored...

Properties that I might currently guess from the README:

  1. It should support deep equality on arrays containing Arrays. β€” mentioned in Motivation, plus [1, [2, [3,4]]] example)
  2. It should support deep equality on arrays containing Objects. β€” [{ foo: 'bar' }] example (technically ambiguous β€” is it false because different, or false because doesn't recurse on objects?).
  3. It works by calling a[i].equals(b[i])?? A natural guess given it's available as an Array method, but would not support Objects, Maps etc. unless you give them same method?
    • Do Strings, Numbers, undefined etc. get same method, or do you specify a fallback algorithm when method is missing (e.g. fallback to === would cover strings and numbers, though NaN is a nuisance).

The last point has a critical consequence β€” can this comparison be redefined by providing your own method? (This I guess is what @ljharb meant by "protocol")

@dead-claudia I'm not sure if your opening question here was merely about how end-user accesses this β€”Β f(a1, a2) vs a1.equals(a2) β€” or also about hard-coded algorithm vs. extensible protocol?
It is tempting but not mandatory for these two questions to correlate!

  • It's possible (though arguably surprising) for a1.equals(a2) to use a hard-coded algorithm, or to call a method named differently than equals (e.g. some Symbol is probably safer, to avoid issues with data containing objects with a key named 'equals').
  • It's possibly for a static function to invoke a method on its arguments. This is not useless, especially if it has any fallback logic (call the method on the 2nd object? fallback to === or other hard-wired behavior for primitives?).
    • Prior art: Python does this a lot. Most built-in operators, including == (aka operator.eq()) invoke overridable methods but the indirection is useful for adding fallback logic: a.__eq__(b), with fallback to b.__eq__(a) and then pointer comparison.

cben avatar Jan 11 '23 13:01 cben

@dead-claudia I'm not sure if your opening question here was merely about how end-user accesses this β€”Β f(a1, a2) vs a1.equals(a2) β€” or also about hard-coded algorithm vs. extensible protocol?

It is tempting but not mandatory for these two questions to correlate!

@cben I think you misread my intent completely. I was saying that 1. any array equality method should be shallow and 2. any deep equality should use a static method. Nothing here is about the precise algorithm of the latter, only about the way end users invoke it.

dead-claudia avatar Jan 11 '23 14:01 dead-claudia