language icon indicating copy to clipboard operation
language copied to clipboard

(3.14,) == (3.14,) constant?

Open eernstg opened this issue 5 months ago • 9 comments

See https://github.com/dart-lang/sdk/issues/54638 for details.

The expression (3.14,) == (3.14,) is not constant, because (3.14,) does not have primitive equality, because it isn't true that every component has primitive equality, because double objects do not have primitive equality.

So the given expression is not constant.

However, 3.14 == 3.14 is a constant expression, because we have added double objects as receivers of operator == in constant expressions as a special exception.

Should we generalize this to allow double components in records with == in constants? We probably don't want to say that (3.14,) has primitive equality, but we could say that e1 == e2 is (also) a constant expression in the case where e1 is a constant expression that evaluates to a record whose components are double objects or objects with primitive equality.

@dart-lang/language-team, WDYT?

eernstg avatar Jan 16 '24 11:01 eernstg

Could you give me the reason why double should not have primitive equality?

Cat-sushi avatar Jan 16 '24 13:01 Cat-sushi

Basically because double.nan != double.nan, which makes == on doubles a very radical exception: Equality should be an equivalence relation, and in particular it should be reflexive, and this means that v == v should be true for every value v, no exceptions. But double.== doesn't satisfy this basic requirement. Hence, double doesn't get to join the fine clubs. ;-)

eernstg avatar Jan 16 '24 13:01 eernstg

I agree that primitive equality is generally designed to agree with identity. The types that do override == are also canonicalized, so that == and identical agrees on all constants.

However == is not defined entirely in terms of primitive equality, because it does allow doubles. Specifically by saying "either e1 evaluates to an instance of double or an instance that that has primitive equality". So we step outside of primitive equality just to allow equality checks on double. It would not be inconsistent to generalize that to record fields.

The distinction ensures that we can do 1.5 == 1.5 as a constant (which is trivial), but can't use 1.5 as a constant set element or map key (and previously couldn't be a switch case value), where being non-reflexive really hurts.

We could also say that non-NaN values of type double have primitive equality. A NaN value does not. (We already define "primitive equality" on specific objects, not just types, so it would fit right into the current framework.)

I'd in favor of allowing (3.14,) == (3.14,) as a constant equality too. It feels inconsistent to allow 3.14 == 3.14 and not (3.14,) == (3.14,).

I'd be fine with double.nan == double.nan being an error instead of false, even if it's currently breaking. (Analyzer currently considers a constant identical(double.nan, double.nan) to be false. It's a little hampered by identical(nan, nan) being true on native and false on the web, because web code uses === for identical instead of Object.is, so they have different results for nan and identical(0.0, -0.0).)

Say:

An object allows constant equality if and only if either:

  • It has primitive equality,
  • It is an instance of the type double, or
  • It is a record whose fields all allow constant equality.

An expression of the form e1 == e2 or e1 != e2 is a valid constant expression if e1 is a constant expression evaluating to the value c1, e2 is a constant expression evaluating to the value c2, and either c2 is the value null, or c1 allows constant equality.

Then any values where you can do c1 == c2 and c3 == c4, you can also do (c1, c3) == (c2, c4), and records are treated like just pairs of normal values.

lrhn avatar Jan 17 '24 10:01 lrhn

Perhaps we should just say, after all, that double does have primitive equality? That'll handle the recursive cases with no further concepts and no further rules.

Does it break anything that we really don't want to break?

eernstg avatar Jan 17 '24 12:01 eernstg

The problem with giving double primitive equality is that it'll be allowed in constant sets and maps. A set of {NaN, NaN} should be invalid because it contains "the same" value twice, but we have to decide whether "the same" means equal or identical when the two things are not the same. (And identical differs on the web, which would mean it's not the same value under either criterion). If it was just NaN, I wouldn't worry, we can just make that not have primitive equality. It's also {0.0, -0.0}, which is valid on native if we use identical, invalid on web.

So, that suggests using == for deciding equality of objects with primitive equality, and not allowing NaN.

But then {1, 1.0} will be valid on native and invalid on web.

"The only way to win is not to play."

There is a reason we don't want doubles in constant collections, and while NaN is the classical reason, it's also easily contained. It's the difference between native and web semantics that really makes things tricky, and disallowing doubles avoids a lot of trouble.

(And if running on some CPU which decides to do double computations using extended precision, like Intel x87 FPU instructions using 80 bits, a constant computation might have a different value than a runtime computation on a different CPU, or a different compilation on the same CPU. Having collections with elements that depend on double equality might mean that it'll be a different element than the one used to look it up. That might still happen if we compute a double at compile time, and add it to a collection at runtime, but at least we can access the value directly then, and check if it is what we expect. Easier to debug, even if only slightly.)

lrhn avatar Jan 17 '24 13:01 lrhn

OK, so we probably want constant equality, or no changes. Constant equality seems to improve on the consistency of the language, but I'm worried that we will drown in inessential complexity if we keep adding new language concepts for reasons that are this narrow. Not an easy choice!

eernstg avatar Jan 18 '24 08:01 eernstg

We already have the concept, in the definition of constant ==, it's that "can c1 and c2 be valid operands for a constant ==" concept. We just didn't give it a name, since we use it only once, so we can't apply it recursively.

What we have today is essentially:

c1 == c2 is a constant expression if c1 and c2 are constant expressions evaluating to v1 and v2 respectively, and (v1, v2) allows constant equality. A pair of constant values, (v1, v2), allows constant equality if v2 is the null value, or if v1 has primitive equality, or v1 is an instance of double.

I'm suggesting we give it that name, and add:

..., or v1 and v2 are records with the same shape where each pair of corresponding field values allow constant equality.

We can also do it (less elegantly) without a name. Take the current definition and add:

or c1 and c2 are records with the same shape where for each pair of corresponding field values, v1 and v2, v1 == v2 would be a constant expression.

(But I think I've mentioned how little I like introducing pseudo syntax that isn't in the original program, which is why I'd prefer introducing a semantic concept on pairs of values, to a syntactic concept on an expression, where we then have to introduce new syntax to invoke it.)

lrhn avatar Jan 20 '24 08:01 lrhn

It's the difference between native and web semantics that really makes things tricky, and disallowing doubles avoids a lot of trouble.

I mean, we have that problem with all numbers because of the lack of integers on the web, but we haven't tried to say that "ints whose value is greater than 1<<53" don't have primitive equality.

Honestly, I think we should just make doubles support primitive equality, let you use them as constant map keys, and if you stuff NaNs in there, you get exactly the pain that you have brought upon yourself.

munificent avatar Mar 08 '24 23:03 munificent

and if you stuff NaNs in there, you get exactly the pain that you have brought upon yourself.

The issue is that we have to decide which pain that is, and I don't particularly want to do that.

  • Is const {double.nan, double.nan} an error? In either case, why?
  • Is const {0.0, -0.0} an error when compiling for the web? In either case, why?
  • Is const {0.0, -0.0} an error when compiling for native? In either case, why?
  • Is const {0, -0} an error when compiling for the web? In either case, why?

I'd go with "yes, because we will disallow NaN specifically for set elements and map keys", and "no" to the {0.0, -0.0} cases, because those values are not the same, even if identical returns true for them. That's the bug. The values are distinguishable.

I'd really, really want to make {0, -0} an error on the web, but since the values are the same as {0.0, -0.0}, we probably can't do something different. Web integers just have two different zeros, native integers do not. So error on native, not on web.

We can do this. But I don't see "constant sets of doubles" as a big advantage, worth adding this complexity. And it still won't be enough to handle constant equality of records optimally.

We'll probably want to have a recursive "allows constant equality" anyway, because of the "or v2 is null' clause, which we want to apply recursively too: const [([42], 2.5) == (null, 2.5)] should be allowed. It won't be if the requirement is "v1 has primitive equality or v2 is null", because const [42] does not have primitive equality. But with a recursive constant equality definition it would be allowed: (([42], 2.5), (null, 2.5)) allows constant equality because ([42],null) does (second operand is null) and (2.5,2.5) does (first operand is double).

That is: We should want a definition of:

c1 == c2 is a constant expression if c1 and c2 are constant expressions evaluating to v1 and v2 respectively, and (v1, v2) allows constant equality. A pair of constant values, (v1, v2), allows constant equality:

  • if v2 is the null value,
  • if v1 has primitive equality,
  • if v1 is an instance of double,
  • or if v1 is a record, and either:
    • v2 is not a record of the same shape (in which case they are not equal), or
    • v2 is a record of the same shape, and every pair of corresponding field values allows constant equality.

This correctly and consistently extends primitive equality to record types for the cases that we can safely answer (where we know the == implementation of the first operand, or know that it won't be called because the second operand is null).

Making double have primitive equality doesn't do that. We can still choose to give double primitive equality, but for constant equals, that just removes one line of the above, it shouldn't change whether we want a recursive definition.

lrhn avatar Mar 09 '24 12:03 lrhn