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

Infer most specific generic type for methods #1557

Open
renggli opened this issue Apr 1, 2021 · 10 comments
Open

Infer most specific generic type for methods #1557

renggli opened this issue Apr 1, 2021 · 10 comments
Labels
request Requests to resolve a particular developer problem

Comments

@renggli
Copy link

renggli commented Apr 1, 2021

The following example works as expected:

class Clazz<T> {}

Clazz<T> join<T>(Clazz<T> a, Clazz<T> b) => Clazz<T>();

void main() {
  final a = Clazz<int>();
  final b = Clazz<double>();
  print(join(a, b));  // 'Clazz<num>' 👍
}

The problem I am facing is that I cannot figure out a way to write an (operator) method within Clazz that works and that doesn't yield Clazz<dynamic> as a result.

I would have expected that the following static extension method is very similar to the function above, yet the compiler is unable to infer R as num in this case and yields The argument type 'Clazz<double>' can't be assigned to the parameter type 'Clazz<int>':

class Clazz<T> {}

extension ClazzExt<R> on Clazz<R> {
  Clazz<R> join(Clazz<R> other) => Clazz<R>();
}

void main() {
  final a = Clazz<int>();
  final b = Clazz<double>();
  print(a.join(b));  // The argument type 'Clazz<double>' can't be assigned to the 
                     // parameter type 'Clazz<int>_ 👎 
}

Context: https://stackoverflow.com/questions/66851159/getting-a-more-specific-generic-type-than-dynamic

@renggli renggli added the request Requests to resolve a particular developer problem label Apr 1, 2021
@mateusfccp
Copy link
Contributor

This works for me:

class Clazz<T> {}

extension ClazzExt<R> on Clazz<R> {
  Clazz<R> join<S>(Clazz<S> other) => Clazz<R>();
}

void main() {
  final a = Clazz<int>();
  final b = Clazz<double>();
  print(a.join(b));
}

@renggli
Copy link
Author

renggli commented Apr 1, 2021

This ignores the type of the argument and returns the type of the receiver: It prints Clazz<int> instead of the shared superclass Clazz<num> like my first example.

@mateusfccp
Copy link
Contributor

mateusfccp commented Apr 1, 2021

Yes, this is how it works. In the first case, the compiler will try to find a common supertype between the two arguments, as they both use T as a generic type. In the second case, however, as the method uses the class generic type as return type, it is the same as calling join<int>(a, b) in the first case, which won't work.

If you try something like:

extension ClazzExt<T, R extends T, S extends T> on Clazz<R> {
  Clazz<T> join<S>(Clazz<S> other) => Clazz<T>();
}

The compiler will infer dynamic as the common supertype between both. Maybe this is the improvement that could be proposed?

Why the compiler will infer the more generic supertype (dynamic) between S and R instead of the most specific num one?

@renggli
Copy link
Author

renggli commented Apr 1, 2021

Yeah, I tried that too, but I need the most specific type. I can work around it by writing join(a, b) or [a, b].join(), but both yield an awkward API. Thus I am asking how I could get more useful types for a.join(b)?

@leafpetersen
Copy link
Member

@renggli I haven't thought comprehensibly through this, but I suspect that there isn't a clean way to do this. There's an unexpected asymmetry underlying this: I like to be able to think about a.foo(b) as being somewhat isomorphic to foo(a, b), but it really isn't. The reason is that in the former case, getting a type schema for foo depends on inferring a first, whereas in the latter case, foo is the thing that you need to infer first. This asymmetry shows up in a number of places in this style of inference, and the example you give is one of them.

@mateusfccp
Copy link
Contributor

mateusfccp commented Apr 1, 2021

@leafpetersen

In the example code I provided:

extension ClazzExt<T, R extends T, S extends T> on Clazz<R> {
  Clazz<T> join(Clazz<S> other) => Clazz<T>();
}

The compiler knows that the returned type T must be a supertype of both R and S. Why does it choose dynamic instead of num? Is there a reason for it to chose the most generic type instead of the most specific one? Or was it an arbitrary design decision? Also, even if it chooses the most generic one, why not Object? instead of dynamic?

@leafpetersen
Copy link
Member

@mateusfccp I think there's some binding scope confusion there in your code. Note that you have two different type variables named S with different binding sites. Renaming those to avoid confusion gives this:

extension ClazzExt<T, R extends T, S extends T> on Clazz<R> {
  Clazz<T> join<P>(Clazz<P> other) => Clazz<T>();
}

If I now do new Clazz<int>().join(new Clazz<double>()) two things happen:

First, extension method resolution matches Clazz<int> <: Clazz<R>, which yields the constraint int <: R. There are no further constraints from extension method resolution, so we choose int for R, and let S and T default to dynamic. You can imagine doing something different with bounds, but there a lot of tradeoffs here. (And in any case, if we did choose to propagate int to S and T here, then it still wouldn't do what you want, since you'd end up with R, S, and T being int, and P being double.)

Second, after extension method resolution is done, inference for the generic method kicks in, and matches Clazz<double> <: Clazz<P> solving for P, which gives double <: P as the only constraint, so we choose double for P.

@mateusfccp
Copy link
Contributor

mateusfccp commented Apr 2, 2021

@leafpetersen I fixed what I meant with my code by removing <S> from join.

This is what I meant:

extension ClazzExt<T, R extends T, S extends T> on Clazz<R> {
  Clazz<T> join(Clazz<S> other) => Clazz<T>();
}

What I was thinking was something like this:

  • We have T, R <: T and S <: T.
  • When asking for Clazz<T> from a Clazz<R>, the returned type should be a supertype, as specified by R extends T.
  • The given other parameter should be a subtype of Clazz<T>, as specified by S extends T.

Consider Clazz<int>().join(Clazz<double>()). In this case, R will be bind to int and S will be bind to double, as was explicitly stated in the type parameters. This way, the returned type T should be a supertype of both int and double (because R <: T and S <: T).

Valid matches are dynamic, Object, and num. The compiler will always match dynamic, but it would be nice for it to infer the most specific of them, i. e. num.

@leafpetersen
Copy link
Member

@mateusfccp I think then for your example there's really no need for the extra type parameters. The key point is that for what you want to work, extension method resolution needs to consider not just the receiver when resolving the generic arguments to the extension, but also the arguments to the method call. This is not what is currently done: instead, extension method resolution is done WRT the receiver only, which fixes the generic parameters to the extension, and then resolution is done on the method itself.

If we did consider the arguments to the method as part of resolution, then

extension ClazzExt<T,> on Clazz<T> {
  Clazz<T> join(Clazz<T> other) => Clazz<T>();
}

would work just fine, since at the point of extension method resolution, we would consider both the receiver of join and the argument.

To be honest - I don't remember the design discussion for why we went this way and what the tradeoffs were. I know that I raised this question in the design discussion, but I don't off the top of my head remember why we went the other way, and I can't quickly find a discussion issue for it. It may have had to do with the desire @lrhn had to model this around implicit wrapper classes? Not sure. @lrhn may have a better recollection.

renggli added a commit to petitparser/dart-petitparser that referenced this issue Apr 4, 2021
renggli added a commit to petitparser/dart-petitparser that referenced this issue Apr 4, 2021
@lrhn
Copy link
Member

lrhn commented Apr 6, 2021

There were several reasons, with "not introducing method overloading" being the strongest.

Dart does not have method overloading. Each class can only have one method with each name.

If we allowed extension methods to be chosen depending on the method arguments, you would effectively get overloading for extension methods (we'd have to ignore a two-parameter extension method when you're calling with three arguments and vice versa), which might entice people to write their APIs entirely in extension methods instead of normal method.
Allowing that was something we actively wanted to prevent. If we actually want to add overloading, we will support it for plain instance methods too, right from the start.

So, we went for a design where extension methods mirrors instance methods as far as possible.

For this example, if it had been an instance method, you'd have gotten the same errors:

class Clazz<T> {
  Clazz<T> join(Clazz<T> other) => Clazz<T>();
}

void main() {
  final a = Clazz<int>();
  final b = Clazz<double>();
  print(a.join(b));  // The argument type 'Clazz<double>' can't be assigned to the 
                     // parameter type 'Clazz<int>_ 👎 
}

There is nothing new and special about doing this with extensions, they mirror instance methods precisely here too.

What you really-really want is:

 Clazz<R> join<R super T>(Clazz<R> other) => ...;

but Dart sadly does not have super-bounded type variables.

just95 added a commit to just95/toml.dart that referenced this issue Apr 18, 2021
Due to dart-lang/language#1557 the parser
constructed by `ChoiceParserExtension.or` is not typed correctly.
Runtime-type errors are avoided by construction `ChoiceParser`s
directly.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
request Requests to resolve a particular developer problem
Projects
None yet
Development

No branches or pull requests

4 participants