dart-lang / dart-lang/language

Symmetric equality

Open
#2,900 3 comments 2 reactions 0 assignees View on GitHub
feature
Dominant language
TeX
Stars
2.9k
Forks
239
Avg merge
2d 18h
Merged PRs (30d)
14

Description

Now that the records and patterns flag flip CL has landed, I decided to reward myself by writing up a strawman (more like a wild harebrained idea) that would help with #2895.

## Background

One of the main uses of `runtimeType` in Flutter and elsewhere is this pattern:

```dart
class Point {
final int x, y;
Point(this.x, this.y);

bool operator ==(Object other) =>
other is Point &&
other.runtimeType == Point && // <--
x == other.x &&
y == other.y;
}
```

That pattern is common because object-oriented programming clashes poorly with binary operators. In particular, users expect `==` to uphold [a contract](https://dev.to/kylec32/effective-java-tuesday-obey-the-equals-contract-4df4), part of which is that `==` should be symmetric. It should never be the case that:

```dart
(a == b) != (b == a)
```

But with inheritance and single dispatch, it's very easy to accidentally violate that:

```dart
class Point {
final int x, y;
Point(this.x, this.y);

bool operator ==(Object other) =>
other is Point && // <-- No runtimeType check.
x == other.x &&
y == other.y;
}

class ColorPoint extends Point {
final int r, g, b;
Point(super.x, super.y, this.r, this.g, this.b);

bool operator ==(Object other) =>
other is ColorPoint && // <-- No runtimeType check.
super == other &&
r == other.r &&
g == other.g &&
b == other.b;
}
```

Now if you do:

```dart
var point = Point(1, 2);
var redPoint = ColorPoint(1, 2, 3, 4, 5);
print(point == redPoint); // true
print(redPoint == point); // false
```

ColorPoints know they aren't non-ColorPoint Points, but Points don't know they aren't ColorPoints.

Checking the `runtimeType` for explicit equality to a single concrete type fixes this glitch because it prohibits subtypes from being equal to supertypes. But relying on a potentially-slow reflective API for a very common operation (especially in Flutter, which relies heavily on equality during tree diffing) is a gross hack.

## Proposal

This wart in single-dispatch languages [has bugged me for a long time](http://journal.stuffwithstuff.com/2010/12/31/rethinking-user-defined-operators/). (It's one of the reasons I've long been interested in multimethods.) But we're stuck with single dispatch in Dart. However, I think we could make `==` smarter and avoid the need to create runtime `Type` objects without adding multimethods to Dart.

Here's another way to reformulate the `Point` and `ColorPoint` problem. The core issue is that the two classes have different *protocols for determining equality*. Each has its own implementation of `==` and those implementations are *different*. Given single dispatch, that implies that `==` likely won't be symmetric.

So what if we said that two objects can *only* be equal if they each have the exact *same* equality protocol? The naive way to do that would be prohibit overriding `==`. That way all objects have the same method. But that obviously doesn't work since Object already defines it. User-defined `==` methods *are* useful in practice.

Instead, we can solve this the same way computer scientists solve most problems: by introducing a layer of indirection. We could specify that an `a == b` expression desugars to `areEqual(a, b)`, which is defined like:

```dart
bool areEqual(Object? a, Object? b) {
var aEquater = a.equater;
var bEquater = b.equater;

if (!identical(aEquater, bEquater)) return false;

// OK, have same protocol, so use it:
return aEquater(a, b);
}
```

The `equater` getter returns a function:

```dart
typedef Equater = bool Function(Object? a, Object? b);
```

(We could make this a single-method interface, but there's no value in doing so and potentially a cost in terms of another virtual call.)

The magic is that `identical()` call. Before we let the objects decide if they are equal, we first check if they have agreed on an equality protocol. If not, we early out and say they aren't equal.

We define a default equater on Object that preserves the current behavior:

```dart
bool identityEquater(Object? a, Object? b) => identical(a, b);

class Object {
Equater get equater => identityEquater;
}
```

Types that want to support user-defined equality do so by delegating to a custom equater:

```dart
class Point {
final int x, y;
Point(this.x, this.y);

Equater get equater => equatePoints;
}

bool equatePoints(Object? a, Object? b) {
return a is Point &&
b is Point &&
a.x == b.x &&
a.y == b.y;
}

class ColorPoint extends Point {
final int r, g, b;
Point(super.x, super.y, this.r, this.g, this.b);

Equater get equater => equateColorPoints;
}

bool equateColorPoints(Object? a, Object? b) {
return a is ColorPoint &&
b is ColorPoint &&
equatePoints(a, b) &&
r == other.r &&
g == other.g &&
b == other.b;
}
```

We've eliminated the runtimeType checks, but we haven't reintroduced the problem. A `ColorPoint` won't be equal to a `Point` regardless of which order the operands appear. Because they each return different equater functions, the equater isn't even run.

In fact, this is a *more* flexible solution than using `runtimeType` because, if you want, you can have different classes that *do* share an equater:

```dart
class DebugLoggedPoint extends Point {
final int x, y;
DebugLoggedPoint(super.x, super.y);
}
```

This class does want to be considered equal to other instances of `Point`. Since it doesn't override `equater` (or it could but could still return a reference to the same function), it uses the same equality protocol and is thus able to be equal to other instances of `Point` or `DebugLoggedPoint` (but not `ColorPoint`).

One way to think about the `runtimeType` pattern is that we don't actually care about the runtime type at all. We're just using it as a unique token to identify the equals protocol the objects are using. If they have the same runtime type, they have the same `==` method body and thus the same protocol.

The proposal here uses an explicitly defined function (which has identity) as that protocol token instead.

## Migration

Unfortunately, Dart already has an `==` operator with prescribed semantics. And adding new instance getters to `Object` is likely prohibitively hard. I don't know if this is possible.

One way we could approach it is by making it optional. Instead of putting `equater` on `Object`, we'd define the `areEqual()` function that `==` desugars to more like:

```dart
bool areEqual(Object? a, Object? b) {
if (a is Equatable) {
if (b is Equatable) {
// Both support the new equater protocol, so use it.
var aEquater = a.equater;
var bEquater = b.equater;

if (!identical(aEquater, bEquater)) return false;

// OK, have same protocol, so use it:
return aEquater.areEqual(a, b);
} else {
// An object that supports the equater protocol can't be equal to one
// that doesn't.
return false;
}
} else {
if (b is Equatable) {
// An object that supports the equater protocol can't be equal to one
// that doesn't.
return false;
} else {
// Fallback to the current semantics.
if (identical(a, null) && identical(b, null)) return true;
if (identical(a, null) || identical(b, null)) return false;
return a.==(b);
}
}
}
```

Then `Equatable` is defined like:

```dart
abstract class Equatable {
Equater get equater;
}
```

We could potentially do some of those conditional checks at compile time. When compiling an `==` expression, we look at the static types of the two operands. If both of them implement `Equatable`, then we desugar to calling that protocol. If only one does, then we short-circuit and return `false`. Otherwise we fall back to the current behavior.

We could even make `Equatable` generic:

```dart
abstract class Equatable {
Equater get equater;
}

typedef Equater = bool Function(L, R);
```

Then we'd say that an `a == b` expression compiles to calling the equater protocols if the static types of `a` and `b` both implement `Equatable`. This would also eliminate the `is` checks in the bodies of the `equater` functions.

## Evaluation

Is this a good idea? I don't know. Probably not. It means an `==` expression desugars to a fairly large blob of code with some control flow and two instance dispatches. One would hope that a compiler can optimize most of it away in common cases, but that's always a gamble.

It means defining custom equality on a class is more complicated than just overriding a method. (But, for what it's worth, making a class iterable also involves exactly this level of indirection where you define an `iterator` getter that returns a separate `Iterator` class.)

But it's *an* idea, and it's fun to throw stuff out there sometimes. It would eliminate, as far as I know, the main legitimate use of `runtimeType` in code today.

Contributor guide

Open the contributing guide

Research direction

Start by reading the linked context in #2895, then review the proposal's equality, migration, and evaluation sections. There are no files or tests named in the issue; done would require a resolved language-design decision about whether and how symmetric equality should be supported.

Written by the indexing model from the issue text.

Assessment

Tech stack
dart
Domain
compilers
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Stale
Clarity
Needs clarification
Newbie friendliness
20/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.