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

Typed equality operator #4176

Open
leafpetersen opened this issue Nov 25, 2024 · 6 comments
Open

Typed equality operator #4176

leafpetersen opened this issue Nov 25, 2024 · 6 comments

Comments

@leafpetersen
Copy link
Member

There has been a long-standing tension in Dart between the desire to have operator== be typed tightly to take the type of the receiver, and to have it typed loosely to avoid runtime errors when calling operator== on a receiver whose static type is more general than the runtime type. Examples:

class A {
  int x;
  A(this.x);
  bool operator ==(Object other) {
    if (other is! A) return false;
    return x == other.x;
  }
}
class B {
  int x;
  B(this.x);
  bool operator ==(covariant B other) {
    return x == other.x;
  }
}


void main() {
  Object a = A(3);
  Object b = B(3);
  assert(a == a);
  assert(a != b);
  assert(b == b);
  try {
    assert(b == a);
  } on TypeError {
    print("Oopsie");
  }
}

The above program, when run with asserts enabled, prints "Oopsie". Making the type of the argument to operator== on B be covariant gives nice static typing behavior, but interacts poorly with subsumption.

The current solution to this is to use the "unrelated_type_equality_checks" lint which is included in the core set of lints recommended by the Dart team.

Recently, @eernstg suggested adding a typed variant of equality: e1 =<T>= e2 which gives a static error if either e1 or e2 are not a subtype of T, and then treating the standard e1 == e2 syntax as syntactic sugar for e1 =<Object?> e2. It occurred to me that we could essentially get the same functionality using an extension method if we added a new equality operator. That is, if we added something like === (to pick a random example), then we could also add to the core libraries the following extension:

extension TypedEquals<T> on T {
  bool operator===(T x) => this == x;
}

Statically, this enforces that the argument has a type which is a subtype of the type of the receiver, without changing anything about the runtime behavior (essentially encoding the above mentioned lint into the type system). Unlike the lint above, this is an actual static type, and so it can interact with inference and other language mechanisms (e.g. #4149).

@dart-lang/language-team thoughts?

@natebosch
Copy link
Member

natebosch commented Nov 26, 2024

Would a class be able to implement the === operator, and would we recommend either way whether user classes should implement it?

Since operator == will still need to start with if (other is! A) return false; I suspect some folks will be tempted to write (perhaps trying to optimize by skipping the runtime type test for statically safe cases):

class A {
  // many fields

  bool operator ==(Object other) {
    if (other is! A) return false;
    return this === other;
  }

  bool operator ===(A other) {
    // compare many fields
  }
}

If anyone copy/pastes operator == without also implementing operator === they'll get a runtime error.

I don't know if I'd expect this to be a problem in practice and the anti-patterns are probably detectible in a lint. I do think that the way these operators are layered is opposite to what is intuitive given the types and that make me a little nervous.

@lrhn
Copy link
Member

lrhn commented Nov 26, 2024

Another option is to allow operator== to be implicitly safely covariant, so that you can give its parameter any type that is subtype or supertype of the parent class's parameter type.
Rather than throwing if up-cast and called with an incompatible argument, it'll just return false. The calling convention of the syntax e1 == e2 is to check if the value of e2 has a type that is a subtype of the parameter type of e1's values operator ==, and if not, return false. (And if so, call it.)

That can actually be seen as a generalization of the current null special casing, if Object and Null both have ==s that only accept their own type.

It'll be a runtime type check, but if every == starts with a type check anyway, it'll just be a part of the declaration instead of the body.

A non-up-cast invocation would be statically checked against the actual declared parameter type. (And that parameter type can be useful as a context type.)

@lrhn
Copy link
Member

lrhn commented Nov 26, 2024

Not sure I buy the === idea.

I don't see anybody actually switching to it, or some might, but most won't, and then we have two ways to do things, one that's too lenient and one that's too strict.

It doesn't work for, fx, numbers, or sealed classes, where you may want to compare to something of the supertype
(if (None() === optionalValue) ... would not be allowed. The lint requires a subtype or supertype, the === only allows a subtype.)
Using the static type of the first operand makes equality more asymmetric. It matters whether you write SomEnum.value === someObject or the opposite way.
(It always could matter, but you should write your == so it doesn't.)

I think an

bool eq<T>(T v1, T v2) => v1==v2;

could work as the =<T>= operation (almost as short too, and easier to parse).

I don't see myself using that either, ever.

@natebosch
Copy link
Member

I like the idea of improving operator == more than adding operator ===. It's a breaking change, but one that would only have visible effect in very non-idiomatic code.

@lrhn
Copy link
Member

lrhn commented Nov 26, 2024

Declaring Object.operator== to be bool operator ==(covariant Object other) => identical(this, other); would be non-breaking since all existing implementations have a parameter type of Object or above.

Changing the implementations could be breaking if it throws, but if they can be changed to class Foo { bool operator==(covariant? Foo other) => other != null && state == other.state; (#4177) then it would work.
(One can already have a covariant Foo other interface an an (Object other) => other is Foo && ... implementation, the real problems are with classes that are both interface and implementation, like Iterable itselft, which is also its own skeleton implementation. Those need the covariant?/covariant to separate the interface from the implementation.
(There are three concepts in play here: The interface parameter type, the implementation parameter type and the local variable type. Those can all be allowed to be different. A parameter like covariant? Foo x could have interface type Foo to restrict direct calls, implementation type Object? to allow upcasting, and local variable type Foo? to handle the Object? arguments.)

@eernstg
Copy link
Member

eernstg commented Dec 16, 2024

Note that the equality types proposal soundly allows for operator == to have the equality type as its parameter type. There is no need for covariant anywhere. A typical example could be the following:

class A {
  operator ==(A other) at A => ...;
}

This is sound because the proposed semantics of e1 == e2 will only invoke the operator == of e1 when the equality type of the value of e1 is the same as the equality type of the value of e2 (and this is always guaranteed to have a subtype of the equality type). You could say that this is a special case of multiple dispatch which by construction is immune to the ambiguity error.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants