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

Wildcard type bounds #54108

Closed
lukehutch opened this issue Nov 21, 2023 · 6 comments
Closed

Wildcard type bounds #54108

lukehutch opened this issue Nov 21, 2023 · 6 comments

Comments

@lukehutch
Copy link

lukehutch commented Nov 21, 2023

Java has wildcard type bounds, e.g. ? extends SomeClass. Dart does not.

This can cause runtime type errors in Dart for people like me who have a hard time reasoning through covariance...

For example

class Table {}

class TableSubclass extends Table {}

class Inner<T extends Table> {
  final bool Function(T) test;
  Inner(this.test);
}

class Outer {
  final Inner<Table> inner;
  Outer(this.inner);
}

void main() {
  final inner = Inner<TableSubclass>((t) => true);
  print(inner.test(TableSubclass()));
  final outer = Outer(inner);
  print(outer.inner.test(TableSubclass()));
}

This prints

true
Uncaught Error: TypeError: Closure 'main_closure': type '(TableSubclass) => bool' is not a subtype of type '(Table) => bool'

Whereas if you change Outer to

class Outer<T extends Table> {
  final Inner<T> inner;
  Outer(this.inner);
}

the output is

true
true

This seems to indicate that you should be able to do

class Outer {
  final Inner<? extends Table> inner;
  Outer(this.inner);
}

but wildcard type bounds are not supported in Dart.

This becomes more important when inner is not a single value, but a list: List<Inner<Table>> inners -- since with a list of arbitrary length, you can't just impose a fixed number type bounds on the surrounding class. You should be able to do List<Inner<? extends Table>> inners in this case.

@lukehutch
Copy link
Author

I can solve the issue by adding this function to Inner:

  bool testAdapter(Table t) => test(t as T);

but it would be nice to have type bounds so that this is not required.

This is probably a special case of #52826.

@lrhn
Copy link
Member

lrhn commented Nov 21, 2023

This is use-site variance.
The covariant Inner<? extends Table> allows the same values as Dart's current Inner<Table>.
The difference is that Java, being invariant by default, doesn't allow you to access any member where T of Inner occurs contravariantly through the ? extends Table type. That's entirely a restriction on what you can do with a value that is locally typed as Inner<? extends Table>.

It's a way to ensure soundness, that Dart's unsound covariance doesn't protect against.

@lukehutch
Copy link
Author

Maybe I misunderstand what Java would do in this case then, because my assumption (untested) was that Java would handle this just fine, but Dart doesn't. So how then is Java more restrictive than Dart?

@mraleph
Copy link
Member

mraleph commented Nov 21, 2023

Duplicate of dart-lang/language#753

@mraleph mraleph marked this as a duplicate of dart-lang/language#753 Nov 21, 2023
@mraleph mraleph closed this as completed Nov 21, 2023
@mraleph
Copy link
Member

mraleph commented Nov 21, 2023

I am closing the issue because it is a duplicate to the whole web of issues on the language repo, but feel free to continue discussion here.

So how then is Java more restrictive than Dart?

@lrhn is simply expanding on the topic of wildcards e.g. explains how they work and what kind of restrictions they introduce. "Restrictive" here applies specifically to the things you can do with values of types containing a wildcard.

@eernstg
Copy link
Member

eernstg commented Nov 21, 2023

Here are a couple of references to related discussions and proposals:

We do have a proposal for a simplified kind of use-site variance (namely use-site invariance) in dart-lang/language#229.

The simplified feature is a rather good match to Dart, because Dart may well be enhanced with statically checked declaration-site variance, dart-lang/language#524, which will handle most cases in a less verbose and more convenient manner.

We have also considered a fully general approach to use-site variance, dart-lang/language#753, but that's perhaps too much mechanism to add if we already have declaration-site variance.


Here is the example using declaration-site variance (--enable-experiment=variance):

class Table {}

class TableSubclass extends Table {}

class Inner<in T extends Table> { // <-- This line was modified.
  final bool Function(T) test;
  Inner(this.test);
}

class Outer {
  final Inner<Table> inner;
  Outer(this.inner);
}

void main() {
  final inner = Inner<TableSubclass>((t) => true);
  print(inner.test(TableSubclass()));
  final outer = Outer(inner); // Compile-time error!
  print(outer.inner.test(TableSubclass()));
}

With this approach, we've changed the class Inner such that it is statically safe, based on the in modifier that turns T into a contravariant type parameter. Without that modifier (that is, considering the original version of Inner that you wrote), the member test is inherently unsafe, and any reference to that member is potentially going to throw. In particular, outer.inner.test will throw if inner is an Inner<TableSubclass>—you don't even get to call it, because it will throw as soon as you even access outer.inner.test. Declaration-site variance allows you to turn that run-time error into a compile-time error.

You can read more about "non-covariant members" in dart-lang/language#296 and dart-lang/language#297. If you wish to support the detection of this kind of member you can vote for a lint to flag these members here: dart-lang/linter#4111.

So this is a particular trade-off: The class Inner is now statically safe, but you won't be able to have an Outer whose inner holds an Inner<TableSubclass>. That should be an Inner<Table> and Inner<TableSubclass> is simply not a subtype of Inner<Table>.

So how then is Java more restrictive than Dart?

You can do something in Java which is essentially the same thing as the example above using declaration-site variance, which has the same trade-off. However, let's consider another approach as well, where we preserve the ability of Outer to hold an Inner whose type argument is allowed to be more special than the statically known bound:

In order to express a property of a class which is similar to test in Inner, you'd need to use the type Function and then you'd need to emulate the built-in covariance of Dart in the declaration of inner. You would get something along these lines:

class Table {}

class TableSubclass extends Table {}

class Inner<T extends Table> {
  final Function<T, Boolean> test;
  Inner(Function<T, Boolean> test) {
    this.test = test;
  }
}

class Outer {
  final Inner<? extends Table> inner;
  Outer(Inner<? extends Table> inner) {
    this.inner = inner;
  }
}

public class n001 {
  public static void main(String[] args) {
    final Inner<TableSubclass> inner = new Inner<TableSubclass>(t -> true);
    System.out.println(inner.test(new TableSubclass()));
    final Outer outer = new Outer(inner);
    System.out.println(outer.inner.test(new TableSubclass()));
  }
}

This produces an error saying that it cannot find the symbol test:

n001.java:29: error: cannot find symbol
    System.out.println(inner.test(new TableSubclass()));
                            ^
  symbol:   method test(TableSubclass)
  location: variable inner of type Inner<TableSubclass>
n001.java:31: error: cannot find symbol
    System.out.println(outer.inner.test(new TableSubclass()));
                                  ^
  symbol:   method test(TableSubclass)
  location: variable inner of type Inner<? extends Table>
2 errors

What happens here is that Java simply pretends that the unsafe members do not exist, in this case: An Inner doesn't have a test member.

This is safe because it prevents any usage of the unsafe member, so we aren't going to get any run-time type errors because of such usages. Of course, it won't do the job because you can't really use the Inner if it doesn't have a test.

Dart could have done a similar thing, of course (Dart was created several years after wildcards were added to Java, and the whole set of issues was very well-known at the time), but Dart chose to allow the unsafe operations at compile time and check them at run time.

This is usually working quite fine, but these "non-covariant members" (like Inner.test) are particularly unsafe, and I'd very much like to introduce a statically checked alternative in Dart, namely primarily dart-lang/language#524.


As an aside, I should mention that it is possible to handle the situation where an object o has a member test which is a function whose parameter type co-varies with the type of o: Virtual classes and virtual types would can do that, safely, and one rather widely used language that has this concept is Scala (where it's known as 'path dependent types'). The feature you get is a kind of dependent type (types that depend on run-time values), and they allow us to say that a particular argument arg must have a type myReceiver.MyType in order to be allowed as an actual argument as in myReceiver.myMethod(arg). It can be a non-trivial exercise to express the program in such a way that we can know this kind of thing. Also, path dependent types is a large and complex feature; we aren't going to have that in Dart.

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