dart-lang / sdk

The Dart SDK, including the VM, dart2js, core libraries, and more.
https://dart.dev
BSD 3-Clause "New" or "Revised" License
9.85k stars 1.52k forks source link

Type inference for abstract classes with recurring generics can't be inferred #55647

Closed FMorschel closed 1 week ago

FMorschel commented 1 week ago

I have in my project a GoRouter-like implementation where, on new pages, I don't instantiate the widget but already have the default constructor for it being called.

Sometimes when I'd like to use generics with a variable passed by page arguments MoudalRoute.of(context)?.settings.arguments I can't because type inference for abstract classes with recurring generics can't be inferred.

The following example is only to show the error, in my actual use-case I have the widget being called in another place and it would be tough to pass a generic argument all the way there.

E.g.:

import 'package:flutter/material.dart';

void main() => runApp(
      const MaterialApp(
        home: Scaffold(
          body: W(),
        ),
      ),
    );

abstract class C<T extends C<T>> {}

class D extends C<D> {}

class E extends C<E> {}

class W<T extends C<T>> extends StatefulWidget {
  const W({super.key});

  @override
  State<W<T>> createState() => _WState<T>();
}

class _WState<T extends C<T>> extends State<W<T>> {
  late T variable;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    variable = ModalRoute.of(context)?.settings.arguments as T;
  }

  @override
  Widget build(BuildContext context) {
    return const Placeholder();
  }
}

In my use cases, I'm currently not using generics, but the actual base class C is the type for my variable, but this is not ideal.

FMorschel commented 1 week ago

Also, this would be a problem if I had an implementation such as:

class W<T extends C<T>> extends StatefulWidget {
  const W({
    required T this.variable,
    super.key,
  });

  const W.n({super.key}) : variable = null;

  final T? variable;

  @override
  State<W<T>> createState() => _WState<T>();
}

The call to the W default constructor would be fine but when calling the W.n constructor that would be an error.

eernstg commented 1 week ago

There is no way an automated process (such as type inference) could make a reasonable choice of a suitable type argument for W() in main: Both W<D>(...) and W<E>(...) would satisfy the given F-bound, and there is nothing in the type system that would allow us to prefer one over the other.

Also, there could be other declarations like class F extends C<F> {} in some other library (not written by you). How would you be able to make a choice, even as a human being who is reading the code and trying to write something?

I think you'll have to look for a way to pass that information along, such that you don't end up requiring a miracle. ;-)

eernstg commented 1 week ago

You could pass a WFactory<Object?> around (if you insist on not knowing the actual type argument, otherwise it could have static type W<T> for some T where T is actually a subtype of C<T>):

class WFactory<X extends C<X>> {
  W<X> create() => W();
}

You can now use myWFactory.create() and obtain an instance of W<T>, rather than trying to use W() where the actual type argument to W is not known.

FMorschel commented 1 week ago

I could not understand your suggestion for creating a factory. I get the same result with your code but for WFactory now.

My point here is that I believe there should be a warning on the analyzer at the creation of the generics and not only when calling. But since this is solvable, I'm not sure how that could be done. But in cases where the variable is null, it doesn't matter what type is inferred, at least in my case, maybe trying to understand this case better could help here.

This would also happen if C was replaced by Comparable. In this case, we could simply remove <T> from the Comparable and that would work. But in my case, that is not valid because we get:

Type parameter bound types must be instantiated.
eernstg commented 1 week ago

I could not understand your suggestion for creating a factory.

I'm assuming that somewhere in the code it is known which type argument T should be passed to the invocation of the W constructor. At that location you create a WFactory<T> and start passing it around (somehow --- as a method parameter, stored in some widely accessible object). Then, when you'd currently invoke W() you would instead invoke myWFactory.create(), which will return a W<T>.

It is just a very basic device that allows one part of the program to communicate with another part, and it is based on the assumption that the problem of selecting the right type argument can be solved somewhere. You could say that it is a type-argument-only variant of dependency injection.

in cases where the variable is null, it doesn't matter what type is inferred

You could then use any solution, or even a specialized class DummyC extends C<DummyC> ().

FMorschel commented 1 week ago

I see what you mean now, thanks a lot. I've solved all my cases now.

My main concern is for cases where the variable is intended to be nullable. It could be confusing for others how to tackle that. If you believe that this is not something that the language/SDK will take a look at, then by all means you can close this issue. If you believe there is even a chance to create a lint or something regarding this case, and you want to rename this issue or similar, it would be alright for me as well.

Thanks a lot for answering!

eernstg commented 1 week ago

I've solved all my cases now.

That's great!

I wanted to comment on an earlier remark, too:

My point here is that I believe there should be a warning on the analyzer at the creation of the generics and not only when calling.

This sounds like you consider F-bounded type variables to be an error in the first place.

I don't think so. They may well be somewhat cumbersome to work with, but they are certainly able to do things that we can't express otherwise (except if we have some even more esoteric and fancy type system features).

The basic idea is that F-bounded type parameters can be used to express structural correspondence without subtype relationships. The standard example is that we wish to define some relationships between the members of a small family of classes, and we do that by introducing one type variable per member:

abstract class Operator<O extends Operator<O, V>, V extends Vehicle<O, V>> {
  void operate(V vehicle);
}

abstract class Vehicle<O extends Operator<O, V>, V extends Vehicle<O, V>> {}

class Driver extends Operator<Driver, Car> {
  void operate(Car car) {
    car.startEngine();
    // ...
  }
}

class Car extends Vehicle<Driver, Car> {
  void startEngine() => print('Vrmmmmmm!');
}

class Cyclist extends Operator<Cyclist, Bicycle> {
  void operate(Bicycle bicycle) {
    print('Putting on my ${bicycle.helmet}!');
    // ...
  }
}

class Bicycle extends Vehicle<Cyclist, Bicycle> {
  final helmet = Helmet();
}

class Helmet {}

void go<O extends Operator<O, V>, V extends Vehicle<O, V>>(O o, V v) {
  o.operate(v);
}

void main() {
  // Safe.
  go(Driver(), Car());
  go(Cyclist(), Bicycle());

  // Each unsafe combination is an error. No actual type
  // arguments will satisfy the constraints.
  //   go(Driver(), Bicycle());
  //   go(Cyclist(), Car());
}

The point is that we can express some shared relationships (in this case: an operator can operate a vehicle) and then we can create variants of the basic family (Operator and Vehicle), yielding derived class families (Driver and Car, plus Cyclist and Bicycle) where the same relationship exists.

Those derived class families have their own interdependencies (a Driver expects to be able to startEngine on a Car, and a Cyclist expects to find a helmet on a Bicycle). This means that we can combine instances from the same family, but we shouldn't put objects together that belong to different families.

F-bounded type parameters will do this just fine: A Driver refers to a Car and a Cyclist refers to a Bicycle, and it's simply a type error to try to combine a Driver and a Bicycle (OK, we'll ignore the fact that some people may be both drivers and cyclists, in our model they'll have to make a choice and stick to that ;-).

In contrast, assume that we use subtyping (subclassing, actually, but that makes no difference) to enable the desired shared structure. In this case we will end up with a situation where Driver is a subtype of Operator and so is Cyclist, and similarly for the remaining classes.

This means that (1) we must use covariant parameters in order to be able to specify that we expect a Driver to cooperate with a Car, not a Bicycle, and vice versa for the Cyclist.

As a consequence, the following example will throw at run time because the given classes have some subtype relationships that are detrimental to type safety (namely Driver <: Operator and Cyclist <: Operator, and similarly for the vehicle side):

abstract class Operator {
  void operate(Vehicle vehicle);
}

abstract class Vehicle {}

class Driver extends Operator {
  void operate(covariant Car car) {
    car.startEngine();
    // ...
  }
}

class Car extends Vehicle {
  void startEngine() => print('Vrmmmmmm!');
}

class Cyclist extends Operator {
  void operate(covariant Bicycle bicycle) {
    print('Putting on my ${bicycle.helmet}!');
    // ...
  }
}

class Bicycle extends Vehicle {
  final helmet = Helmet();
}

class Helmet {}

void go(Operator o, Vehicle v) {
  o.operate(v);
}

void main() {
  // Safe.
  go(Driver(), Car());
  go(Cyclist(), Bicycle());

  // Each unsafe combination is allowed as well.
  go(Driver(), Bicycle());
  go(Cyclist(), Car());
}
eernstg commented 1 week ago

My main concern is for cases where the variable is intended to be nullable

I don't quite understand what the issue is here. Perhaps create a new issue to explain it in more detail?

FMorschel commented 1 week ago

I sure can. Will do that later today. Just to make it clear. The issue would be if for example:

const W.n({super.key}) : variable = null;

Appeared on a package that has no testing and this was not necessarily ever instantiated with this constructor. Then the package user would see a problem and probably would not be sure how to solve it (what to inform in the generics so the code doesn't break for the intended use case).

This is why there could be a lint there or something to warn the developer to solve that edge case.

FMorschel commented 1 week ago

Created, thanks for your help.