Skip to content

Problem with type definition using generics #51152

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

Closed
bytes7bytes7 opened this issue Jan 28, 2023 · 8 comments
Closed

Problem with type definition using generics #51152

bytes7bytes7 opened this issue Jan 28, 2023 · 8 comments

Comments

@bytes7bytes7
Copy link

I think that there is a serious problem with generic types.
On the 1st screenshot you can read whole code.
On the 2nd screenshot you can see that the type of wrapper is Wrapper<int> (as expected)
On the 3rd screenshot you can see that the type of result is Future<dynamic> (as NOT expected, I expect Future<int>)

  • Dart SDK Version 3.7.0
  • Windows 10 x64

common
type1
type2

@asashour
Copy link
Contributor

A text version is also helpful, as one can not copy from a screenshot. 3.7.0 is the Flutter version, which includes Dart 2.19.0. The issue also happens with the dev channel.

class Wrapper<T> {
  const Wrapper(this.value);

  final T value;
}

Future<T> send<W extends Wrapper<T>, T>(W wrapper) async {
  throw Exception();
}

void main() {
  final wrapper = Wrapper(1);
  final result = send(wrapper);
  print(result);
}

@vsmenon
Copy link
Member

vsmenon commented Jan 29, 2023

It looks like a mathematically valid solution AFAICT. It's not clear what you're trying to accomplish with the declaration of send - it's very under-constrained as written. @eernstg - if I missed something.

If you want the Future and the Wrapper to have the same generic type, this would enforce that:

Future<T> send<T>(Wrapper<T> wrapper) async { ... }

@eernstg
Copy link
Member

eernstg commented Jan 29, 2023

The inferred value of a type variable is often the least possible type. So if we have a constraint like LowerBound <: X <: UpperBound the value chosen by type inference is typically X == LowerBound. This makes sense because the return value of an invocation of a generic function gets the most informative type (that is, the "smallest" type wrt the subtype relationship) in the typical case where the inferred type parameters occur in covariant positions in the return type.

However, in the case where a type parameter has no constraints (that is, the constraint is _ <: X <: _ where _ is the unknown type), the upper bound is used as the ultimate backup.

I think the section Grounded constraint solution for a type variable in the feature specification of type inference contains the most useful information. Basically, the inferred value of a type variable like T in the example (which has no bound and no constraints, so the merge of the constraint set is _ <: T <: _) is Object?.

So we should expect the unconstrained type parameter to be inferred to be a top type.

However, I'm not sure why it is inferred as dynamic rather than Object?. @stereotype441, do you agree on the analysis? Do you think there's a need to adjust inference.md? I suspect that type inference will infer dynamic in a number of situations where inference.md seems to require Object?.

@bytes7bytes7
Copy link
Author

bytes7bytes7 commented Jan 30, 2023

A text version is also helpful, as one can not copy from a screenshot. 3.7.0 is the Flutter version, which includes Dart 2.19.0. The issue also happens with the dev channel.

class Wrapper<T> {
  const Wrapper(this.value);

  final T value;
}

Future<T> send<W extends Wrapper<T>, T>(W wrapper) async {
  throw Exception();
}

void main() {
  final wrapper = Wrapper(1);
  final result = send(wrapper);
  print(result);
}

Sorry, I forgot to paste a text version of code😢
And I was wrong with Dart sdk version. Of course, I mean Dart 2.19.0

@bytes7bytes7
Copy link
Author

bytes7bytes7 commented Jan 30, 2023

The inferred value of a type variable is often the least possible type. So if we have a constraint like LowerBound <: X <: UpperBound the value chosen by type inference is typically X == LowerBound. This makes sense because the return value of an invocation of a generic function gets the most informative type (that is, the "smallest" type wrt the subtype relationship) in the typical case where the inferred type parameters occur in covariant positions in the return type.

However, in the case where a type parameter has no constraints (that is, the constraint is _ <: X <: _ where _ is the unknown type), the upper bound is used as the ultimate backup.

I think the section Grounded constraint solution for a type variable in the feature specification of type inference contains the most useful information. Basically, the inferred value of a type variable like T in the example (which has no bound and no constraints, so the merge of the constraint set is _ <: T <: _) is Object?.

So we should expect the unconstrained type parameter to be inferred to be a top type.

However, I'm not sure why it is inferred as dynamic rather than Object?. @stereotype441, do you agree on the analysis? Do you think there's a need to adjust inference.md? I suspect that type inference will infer dynamic in a number of situations where inference.md seems to require Object?.

So, does it mean that there is no way to get int from send in this example?

abstract class Request<R extends Object?> {
  const Request(this.value);

  final R value;
}

class SomeRequest extends Request<int> {
  const SomeRequest(super.value);
}

abstract class RequestHandler<R extends Object?, T extends Request<R>> {
  const RequestHandler();

  Future<R> call(T request);
}

Future<R> send<R extends Object?, T extends Request<R>>(
  T request,
) async {
  final handler = _getRequestHandlerFor<T>();

  throw Exception();
}

RequestHandler? _getRequestHandlerFor<T extends Request>() => throw Exception();

Future<void> main() async {
  const request = SomeRequest(1);
  final result = await send(request);
}

Type of result is Object?

@eernstg
Copy link
Member

eernstg commented Jan 30, 2023

Depends on what you mean by 'no way'. ;-)

As @vsmenon mentioned, you could just simplify the situation by having fewer type parameters (and hence fewer degrees of freedom during inference):

abstract class Request<R extends Object?> {
  const Request(this.value);
  final R value;
}

class SomeRequest extends Request<int> {
  const SomeRequest(super.value);
}

abstract class RequestHandler<R extends Object?> {
  const RequestHandler();
  Future<R> call(Request<R> request);
}

Future<R> send<R extends Object?>(Request<R> request) async {
  final handler = _getRequestHandlerFor<Request<R>>();
  throw Exception();
}

RequestHandler? _getRequestHandlerFor<T extends Request>() => throw Exception();

Future<void> main() async {
  const request = SomeRequest(1);
  final result = await send(request); // `result` has type `int`.
}

I suppose you're using the bound Object? (rather than no bound) in several locations because this allows raw types (like Request as opposed to Request<S> for some S) to have a meaning which does not contain the type dynamic.

(I usually prefer enhancing the support for getting diagnostic notifications about any location where the type dynamic exists, after type inference, instantiation to bounds, generic function instantiation etc., but using Object? as a bound will eliminate some of those occurrences of dynamic, too, so I just left them unchanged.)

However, I eliminated the type variable T extends Request<R> in a few places, and simply replaced every occurrence of T in the body by Request<R>. This should not reduce the affordances offered by these declarations at all, because T was never used in a position where a client could detect the difference. This could be in terms of having a different return type for some methods, or having methods accepting a callback accepting a parameter of type T—in short, having T in a covariant position in the API somewhere. I'd recommend that you use this kind of simplification whenever possible.

That said, there are of course some situations where the elimination of type parameters like this will cause an actual loss of useful typing information:

abstract class A<X, Y extends Iterable<X>> {
  final X x;
  A(this.x);
  Y get foo;
}

class B1 extends A<X, List<X>> {
  B1(super.x);
  List<X> get foo => [x];
}

// Assume that `A` has some other subtypes `B2`, `B3` etc.

With these declarations, you would be able to have a variable a of type A<...> (so you avoid being dependent on the specific choice of B1, B2 etc.) and you would still be able to express the fact that a.foo yields a List and not just any Iterable (the type of a might be A<S, List<S>> for some S). If you simplify that one to be abstract class A<X> { .. Iterable<X> get foo; } then you won't be able to express that.

In those cases you might have to write the type arguments explicitly.

@eernstg
Copy link
Member

eernstg commented Jan 30, 2023

I'll close this issue because there's considerable overlap between the not-yet-resolved parts of this issue and an existing issue, cf. dart-lang/language#620 (comment).

@eernstg eernstg closed this as completed Jan 30, 2023
@bytes7bytes7
Copy link
Author

bytes7bytes7 commented Jan 31, 2023

Depends on what you mean by 'no way'. ;-)

As @vsmenon mentioned, you could just simplify the situation by having fewer type parameters (and hence fewer degrees of freedom during inference):

abstract class Request<R extends Object?> {
  const Request(this.value);
  final R value;
}

class SomeRequest extends Request<int> {
  const SomeRequest(super.value);
}

abstract class RequestHandler<R extends Object?> {
  const RequestHandler();
  Future<R> call(Request<R> request);
}

Future<R> send<R extends Object?>(Request<R> request) async {
  final handler = _getRequestHandlerFor<Request<R>>();
  throw Exception();
}

RequestHandler? _getRequestHandlerFor<T extends Request>() => throw Exception();

Future<void> main() async {
  const request = SomeRequest(1);
  final result = await send(request); // `result` has type `int`.
}

I suppose you're using the bound Object? (rather than no bound) in several locations because this allows raw types (like Request as opposed to Request<S> for some S) to have a meaning which does not contain the type dynamic.

(I usually prefer enhancing the support for getting diagnostic notifications about any location where the type dynamic exists, after type inference, instantiation to bounds, generic function instantiation etc., but using Object? as a bound will eliminate some of those occurrences of dynamic, too, so I just left them unchanged.)

However, I eliminated the type variable T extends Request<R> in a few places, and simply replaced every occurrence of T in the body by Request<R>. This should not reduce the affordances offered by these declarations at all, because T was never used in a position where a client could detect the difference. This could be in terms of having a different return type for some methods, or having methods accepting a callback accepting a parameter of type T—in short, having T in a covariant position in the API somewhere. I'd recommend that you use this kind of simplification whenever possible.

That said, there are of course some situations where the elimination of type parameters like this will cause an actual loss of useful typing information:

abstract class A<X, Y extends Iterable<X>> {
  final X x;
  A(this.x);
  Y get foo;
}

class B1 extends A<X, List<X>> {
  B1(super.x);
  List<X> get foo => [x];
}

// Assume that `A` has some other subtypes `B2`, `B3` etc.

With these declarations, you would be able to have a variable a of type A<...> (so you avoid being dependent on the specific choice of B1, B2 etc.) and you would still be able to express the fact that a.foo yields a List and not just any Iterable (the type of a might be A<S, List<S>> for some S). If you simplify that one to be abstract class A<X> { .. Iterable<X> get foo; } then you won't be able to express that.

In those cases you might have to write the type arguments explicitly.

Thank you! Great explanation👍

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