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

Soft covariance modifier #4177

Open
munificent opened this issue Nov 26, 2024 · 2 comments
Open

Soft covariance modifier #4177

munificent opened this issue Nov 26, 2024 · 2 comments
Labels
feature Proposed language feature that solves one or more problems

Comments

@munificent
Copy link
Member

munificent commented Nov 26, 2024

We're working on supporting enum-like shorthands (#357) to eliminate some verbosity when accessing a static member on a type where the surrounding context happens to be that same type.

Unfortunately, the very first example in that issue and the first comment are places where using the context type doesn't actually help: ==, .contains(), and .containsAll(). In all of those APIs, the context type is either Object or List<Object> and not a more precise type that the user might expect.

These looser types have been a source of user bugs for years which is why we added lints for unrelated_type_equality_checks, iterable_contains_unrelated_type, and collection_methods_unrelated_type.

These APIs are designed to accept looser types deliberately. Accepting Object on the right-hand side of == means that when an instance of that type is used in a heterogeneous map or set, it won't throw exceptions when compared to unrelated types. Likewise, allowing looser types in the collection methods means the methods still work when collections are used in a covariant way:

var strings = <String>["hello", "there"];
List<Object> objects = strings;
print(objects.contains(123));

This program prints "false" instead of throwing an exception, which is what would happen if the parameter type of contains() was E.

Is there a way to get the runtime flexibility around heterogeneous collections and when using collections in covariant ways while getting having a less error-prone API with stricter static typing?

Proposal

I propose we add a "soft covariant" modifier, covariant?. It behaves similarly to the existing covariant modifier. It allows a parameter in a method override to have a more precise type than the parameter it overrides.

But unlike covariant, instead of immediately throwing at runtime if the argument doesn't match the parameter type, it simply passes null. This means that the parameter's type must be a nullable type.

For example:

abstract class Base {
  void method(num n);
}

class Concrete implements Base {
  @override
  void method(covariant? int? n) {
    print('Received $n');
  }
}

main() {
  Concrete c = Concrete();
  c.method(12.34); // Static error, parameter type expects int.

  // Upcast.
  Base b = c;
  b.method(1234); // No static error, at runtime prints "Received 1234".
  b.method(12.34); // No static error, at runtime prints "Received null".
}

When a class implements ==, we recommend that they use covariant? like so:

class Point {
  final num x, y;

  Point(this.x, this.y);

  bool operator ==(covariant? Point? other) =>
      other != null &&
      x == other.x &&
      y == other.y;

  // hashCode, etc.
}

main() {
  List<Point> points = [Point(1, 2), Point(3, 4)];
  points.contains('not a point'); // Static error, parameter type expects Point.

  // Upcast.
  List<Object> objects = points;
  print(points.contains('not a point')); // No static error, prints "false".
}

Likewise, we change the signatures of the collection methods to:

class Iterable<E> {
  bool contains(covariant? E? element);
}

class List<E> {
  bool remove(covariant? E? element);
}

class Map<K, V> {
  bool containsKey(covariant? K? key);
  bool containsValue(covariant? V? value);
  bool removeAll(covariant? K? key);
  V? operator[](covariant? K? key);
}

This way, when calling these methods, they are statically type checked at the expected element's type without any need for a lint. Also, when the shorthand syntax is supported, it becomes available in the arguments to these methods.

Unfortunately, this doesn't help with containsAll() and removeAll(). For those, we probably don't want the method to immediately reject an argument collection whose reified type argument isn't precise enough. What actually matters is the elements inside the collection.

If we can live without that, then this feature might give us a more general-purpose language mechanism that lets object-oriented APIs be more precisely typed while playing nicely with covariance (at least, as much as it's possible to play nice with such a weird feature).

@munificent munificent added the feature Proposed language feature that solves one or more problems label Nov 26, 2024
@lrhn
Copy link
Member

lrhn commented Nov 26, 2024

This feels related to the safe cast e as? T, which converts a non-T value to null instead of throwing.
Where covariant T does as T on the argument, covariant? T does as? T, otherwise they are the same.

There have been requests for a covariant-like functionality in non-instance functions, accepting not than necessity just to satisfy a type requirement. One suggested syntax for that was

 void foo(Object? arg as int){...}

which accepts any argument, but throws if the value is not a Foo.
(With current type promotion, you can just start the function body with arg as int;.) Using as? in the same way feels consistent.

I would allow a non-null or potentially non -nullparameter type:

class Foo {
  void and(covariant? Foo other) => other != null && ...

The parameter is non-null, but the local variable it introduces is implicitly nullable. That makes it an error to invoke the and function above directly with a non-Foo value, including null, and the internal implementation can safely assume that a null value is due to covariance.
(Having a difference between the public API and the implementation of what covariant does. This just uses an internal variable type that differs from the public API type too.)
Whether you use covariant? int or covariant int as parameter declaration is an implementation choice, it doesn't change the signature. The signature is still just covariant-by-declaration. Subclasses do not inherit the ?.

We would change collection methods to, fx,

  bool contains(covariant T value);

Since every implementation will still accept Object?, they won't break.
Our own implementations would then use covariant? T as implemention.

(Would this feature be moot with variance annotations? I think it won't, because declaration-site variance isn't powerful enough to handle these cases without making the type invariant. That's too breaking without use-site variance.)

@munificent
Copy link
Member Author

This feels related to the safe cast e as? T, which converts a non-T value to null instead of throwing.

Yes, that's exactly what led me to use covariant?. I'd also like an as? operator (though I find that with if-case, I miss it less often than I used to).

I would allow a non-null or potentially non -nullparameter type:

class Foo {
  void and(covariant? Foo other) => other != null && ...

The parameter is non-null, but the local variable it introduces is implicitly nullable. That makes it an error to invoke the and function above directly with a non-Foo value, including null, and the internal implementation can safely assume that a null value is due to covariance.

I considered that briefly, but I worry that this would be really confusing:

class Foo {
  void method(covariant? String s) {
    print(s.length); // Compile error, can't access length on nullable "s".
  }
}

Would this feature be moot with variance annotations?

That's something I've been wondering about for a long time. I would love to be in a world where we don't need the weird hacky covariant and runtime unsound covariance at all.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature Proposed language feature that solves one or more problems
Projects
None yet
Development

No branches or pull requests

2 participants