Skip to content

Equality Types #4198

Open
Open
@eernstg

Description

@eernstg

As a follow-up on Typed Equality Operator, this issue describes a take on this topic area that includes some non-breaking changes to the run-time semantics.

Existing equality

The current typing of operator == is perfectly suitable for the case where we wish to decide the equality of two arbitrary objects. In this situation the "universe of discourse" is the universe of all objects whatsoever. This case occurs when two objects with static type Object? are compared, but a case like 1 == true is also included. The crucial characteristic is that the given equality comparison is intended to occur in said universe.

Equality should be an equivalence relation, and it is a non-trivial task to write implementations of operator == such that the behavior models a relation which is both reflexive, symmetric, and transitive. This requires carefully written implementations of operator ==, but it is in principle doable. So we already have a pretty good approach to equality when the universe of discourse is 'all objects'.

We may also want to use a narrower universe of discourse. We could check statically that e1 == e2 will compare two objects whose equality is plausible because they are both known to have a certain common type. This means that the universe of discourse is only the objects that have this type. This helps us avoiding equality comparisons whose outcome is false in every case because the operands were written incorrectly by accident (that is, in the cases where that incorrect expression has an "unreasonably" different type).

A well-known way to take a step in this direction is to implement operator == such that it tests the run-time type of the operand:

class A {
  ...
  bool operator ==(Object other) {
    if (other is! A) return false;
    ... // Compare states.
  }
}

class B implements A {
  ...
  bool operator ==(Object other) {
    if (other is! B) return false;
    ... // Compare states.
  }
}

This is dangerous because it is asymmetric: myA == myB may be true, but myB == myA will be false, where myA has run-time type A and myB has run-time type B.

A symmetric approach can be achieved by testing the type more strictly: if (other.runtimeType != runtimeType) return false;.

This is dangerous in a couple of ways: Theoretically, any user-written class can implement runtimeType to return whatever it wants, in which case it gets tricky to maintain a well-understood model of equality. More realistically, it is a very restrictive approach, and it breaks encapsulation, because it prevents substitutable subtypes: If other is a subtype of the enclosing class/enum/etc then it cannot possibly be == to this object. For example:

import 'dart:math' as math;

abstract class Point {
  double get x;
  double get y;
  operator ==(other) => other is Point && x == other.x && y == other.y;
}

class CartesianPoint implements Point {
  final double x, y;
  CartesianPoint(this.x, this.y);
  get hashCode => ...;
}

class PolarPoint implements Point {
  final double magnitude;
  final double angle;
  PolarPoint(this.magnitude, this.angle);
  get hashCode => ...;
  get x => magnitude * math.cos(angle);
  get y => magnitude * math.sin(angle);
}

void main() {
  Point p1 = CartesianPoint(0, 0), p2 = PolarPoint(0, 0);
  print(p1 == p2);
}

In this case the CartesianPoint and PolarPoint classes are specializations and implementations of the same concept Point, and it would make sense to decide on equality at the type Point, such that it is possible for a CartesianPoint to be equal to a PolarPoint.

The scenario could also use private subtypes to play the role of PolarPoint and CartesianPoint, but I'd claim that both the private and the public scenario can be legitimate.

It's OK for equality to apply for objects of different types, and hence it's too strict if it is enforced that equality among such objects is guaranteed to be false. Hence, I'd claim that if (other.runtimeType != runtimeType) return false; should not be used in general. It is only acceptable in the case where we can actually justify that no substitutable subtypes should (and do!) exist.

The equality type of a type

Because of the difficulties around symmetry, we introduce the notion of the equality type of a type.

The equality type of all types that are expressible today is Object. This preserves the semantics of current code.

The equality type of a type which is introduced by a class/enum/mixin/mixin-class declaration can be specified explicitly as a clause on the declaration of operator ==:

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

It is a compile-time error if this does not have the specified equality type.

The semantics of e1 == e2 is now modified such that (1) null is treated the same as today, (2) if the value v1 of e1 and the value v2 of e2 have different equality types then the result is false, otherwise (3) operator == is invoked with v1 as the receiver and v2 as the argument and the result is the return value from this invocation.

Note that this means that it is statically sound to declare operator == to have the equality type as its parameter type. Also note that there is no need to worry about typing properties of other: We already know that it is some subtype of the equality type, and we also known that other has explicitly declared that it agrees on this type as the basis for the comparison. In particular, we don't need anything like if (other is! MyType) return false; any more.

Next, we could introduce an explicitly typed equality like e1 =<T>= e2 which would require that the static type of e1 and the static type of e2 is a subtype of T?. We could then interpret e1 == e2 to mean e1 =<T>= e2 where T is the the equality type of the non-null type corresponding to the static type of e1. For example p1 == p2 would be a compile-time error if the static type of p1 is CartesianPoint, and the static type of p2 is not a subtype of Point?. If you insist then you can compare p1 and 1 by specifying the type explicitly: p1 =<Object>= 1.

Performance implications

It is possible to implicitly generate the type test as the first statement in the body of operator == (low-level pseudo code: if (class.equalityType != other.class.equalityType) return false;), but in the cases where it is guaranteed that the equality type is different from Object then we could generate code to perform the check inlined at the call site. In this case we would invoke a "raw" version of the operator == which doesn't have this check.

It might then be beneficial to declare a special equality type for null:

class Null {
  ...
  bool operator ==(Object other) at Null => true;
}

No other non-bottom class is a subtype of Null, which implies that the current handling of null at an expression of the form e1 == e2 can be replaced by a comparison of their equality types: If exactly one of the operands is null then they will have different equality types and the result is immediately false. If both are non-null then we may immediately know that the result is false if they have different equality types, and otherwise we'll call operator == as usual. Finally, if both operands are null then we invoke Null.== and get the result true.

All in all, this means that we are replacing the null-handling logic of today by a test whether the two operands have the same equality type or not.

That might be faster than the approach we're using today, in particular because the implementation of operator == will no longer need to do anything like if (other is! MyType) then return false; and because we will avoid the method invocation entirely in the case where the result is false because the equality types are different.

Finally, data structures like sets could rely on equality types in order to speed up lookups and other operations: If a given set is partitioned into subsets with the same equality type then it is already known for a lookup that we only need to look into the subset with the same equality type as the object which is being looked up, if any.

Metadata

Metadata

Assignees

No one assigned

    Labels

    featureProposed language feature that solves one or more problems

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions