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

Viability of extension constructor tear-offs. #3876

Closed
lrhn opened this issue Jun 4, 2024 · 3 comments
Closed

Viability of extension constructor tear-offs. #3876

lrhn opened this issue Jun 4, 2024 · 3 comments
Labels
static-extensions Issues about the static-extensions feature

Comments

@lrhn
Copy link
Member

lrhn commented Jun 4, 2024

Assume we can define extension constructors like:

class C<X1 extends A1, ..., Xn extends An> {
  C(args) {}
}
extension E<Y1 extends B1, ..., Ym extends Bm> on C<S1, ..., Sn> {
  factory E.ext(params) { ... }
}

Then we can presumably have extensions on subtypes like:

extension ComparableList<T extends Comparable<T>> on List<T> {
  factory List.ordered(Iterable<T> values) => [...values].sort(compareComparable<T>);
}

Invoking such constructors as C.ext(args) or C<T1,...,Tn>.ext(args) is probably possible, if we can solve C<S1, ..., Sn> <: C<T1, ...,Tn> for Y1,...,Ym. If we can't, the constructor is not applicable at that type, and it'll likely be an error, like List<Symbol>.ordered([#a, #b]). No solution for T extends Comparable<T>, so List.ordered is not applicable at this type. If it was, it would work.

The problem comes when we get to constructor tear-offs, like:

var c1 = C.ext;
var c2 = C<T1,...,Tn>.ext;

Tearing off from C is easy, it's just like tearing of an "equivalent function" like

  static C<S1, ..., Sn> _ext$tearOff<Y1 extends B1, ..., Ym extends Bm>(params) => E<Y1, ..., Ym>.ext(params);

and it's constant and canonicalized, and if instantiated, it's constant if the type arguments are constant.

Tearing off from E puts us in a position where we abstract over something we want to solve for.

The obvious definition of C.ext would be a function equivalent to:

  C<X1, ..., Xn> _tearOff<X1 extends A1, ..., Xn extends An>(params) => C<X1, ..., Xn>.ext(params);

The problem with that we check whether an extension constructor is applicable based on the type arguments, and here we don't know the type arguments yet, since we are abstracting over them.

What we need is at least to allow lower bounds on the type parameters of _tearOff.
That is, we should find a set of "safe bounds", D1, ..., Dn such that E is always applicable to C<D1, ..., Dn>, then use those for the tear-off function:

  C<X1, ..., Xn> _tearOff<X1 extends A1, ..., Xn extends An>(params) => C<X1, ..., Xn>.ext(params);

(Can we maybe start with [B1/Y1,...,Bn/Yn]S1, ..., [B1/Y1,...,Bn/Yn]S1, then remove all remaining occurrences of Y1,...,Ym by replacing them with Never/Object? depending on variance? If that becomes super-bounded, it might still be valid. If it becomes effectively Never, the result won't be useful, but maybe it never was if that's the best solution.)

Assume we can find such limits, then we still have to do something for C<X1, ...,Xn>.ext(params), where we try to solve for C<S1,...,Sn> <: C<X1, ..., Xn>. There are so many ways that can fail to solve when X1,...,Xn are type variables. The only types that are subtypes of a type variable are type variables that are bounded by it, and bottom types.

The cases where it just works are precisely those where each Si is a type parameter of E (or the occasional bottom type).
Any other relation between extension type parameter and on-type type argument makes it impossible to abstract over type arguments to extension constructors.

(Even a constant type won't work, extension E2 on List<num>, but it probably rarely will anyway if we don't have contravariance, since List<int>.ctrFromE2(arg) probably won't be able to create an actual List<int> without access to the type parameter.)

Questions

Should we just say that an extension cannot declare a constructor unless all type arguments to the on type (the denoted type declaration that the statics will be on) are type arguments (or subtypes of type arguments) of the extension, put constant types?

Or should we make the type arguments to uninstantiated constructor tear-offs be those of the extension.
It's surprising if Map.self has type Map<X, X> Function<X>(X value) with only one type argument, but it will correspond to an extension SelfMap<X> on Map<X, X> { SelfMap.self(X value) => {value: value}; } which has only one type parameter.

Or something else.

(Which may be why @eernstg's proposal uses C.name as the declaration, if that can be used to give it the same type parameters as C, somehow. Not entirely sure, must read again.)

@lrhn lrhn added the static-extensions Issues about the static-extensions feature label Jun 4, 2024
@lrhn
Copy link
Member Author

lrhn commented Jun 4, 2024

Or maybe the solution is to add a more general feature, and then let extension constructors use it: Constructors with restricted type parameters (#1899).

Basically a constructor can add type arguments to the class name:

class Map<K, V> {
  factory Map<K extends Comparable<K>, V>.ordered() => SplayTreeMap<K, V>(compareComparable<K>);
}

The type parameters in the constructor must have the same number of parameters as the class, and its bounds must be subtypes of the corresponding type parameter bounds of the class.

Then you are only allowed to invoke the constructor with type arguments that matches its bounds:

  var map1 = Map<num, String>.ordered(); // OK
  var map2 = Map<Symbol, String>.ordered(); // Error: Symbol <!: Comparable<Symbol>.

With that, an extension could declare a constructor of their target class as:

class Point<P extends num> {
  final P x, P y;
  Point(this.x, this.y);
}
extension IntPoint on Point<int> {
  Point<C extends int>.diagonalInt(C xy) : this(xy, xy);
}

and you an do Point<int>.diagnoalInt(42). (Already suggest in #1899.)

In practice, I think you'd more often want to have a fixed type, like JsonMap<String, Object?>, and this feature doesn't allow that. Can't do Point<int>.diagonalInt because it's parsing precisely the same as Point<X>.diagonalInt, so if one was intended as a type and the other as a type variable, we need distinguishing syntax.

Possibly: Map<const String, const Object?>.fromJsonString(String value) => jsonDecode(value) as Map<String, Object?>;, where each parameter can be either const SomeType or a normal type parameter X. Not sure how I'd call Map<const String, X>.foo(), with an explicit String parameter or not?

@leafpetersen
Copy link
Member

@lrhn I got pretty lost in the above, maybe because of the level of abstraction. I don't really understand what problem you're worried about, so maybe a concrete example would help?

Tearing off from E puts us in a position where we abstract over something we want to solve for.

I don't know what this means.

@lrhn
Copy link
Member Author

lrhn commented Aug 24, 2024

I got pretty lost in the above, maybe because of the level of abstraction.

Me too, on re-reading.
There was definitely something that worried me. The amount of words here suggests that I couldn't put my finger on exactly what. Let me try again.

This issue assumes that whether a static extension with a constructor applies to a specific generic type can depend on the actual type arguments. Which can be inferred.
If we ignore type arguments in whether an extension constructor is applicable, then this issue is (probably) moot. We'll just have more conflicts.

If we do use the instantiated type for applicability, a static extension like

static extension NumList<T extends num> on List<T> {
  List.singleton(T value) : this.filled(1, value);
}

would not be applicable to List<String>.singleton("a"), because List<String> is not compatible with List<T> with T extends num.
The alternative is letting the extension be applicable based only on the base type List and the the name singleton, and then, if there are no conflicts, giving an error that NumList<String>.singleton is not a valid instantiation.

We still have to "solve for T" to go from List<String> to a type argument for NumList.
We may decide that List<String>.singleton denotes the constructor NumList<...>.singleton, but we still need to find the actual type arguments for NumList. It's easy here, but it can be more complicated.

Consider a more complicated example:

extension ComparableList<T extends Comparable<T>> on List<T> {
  factory List.ordered(Iterable<T> values) => [...values].sort(compareComparable<T>);
}

Here List<num>.ordered(<int>[1, 2]) would work, and List.ordered(<num>[1, 2]) should also work, but the latter requires deciding that the constructor is applicable before knowing the actual argument types - because deciding which extension to use is done during type inference, before doing type inference on arguments, to be able to actually have type contexts for the arguments. (Which may just be a strike against using the type instantiation of the target type to decide applicability of extension constructors.)

Then there are constructor tear-offs.

var f = List.ordered;

With a raw target type and no context type, the extension constructor is going to be applicable, there is no reason it wouldn't be.
We then have to infer a type argument to ComparableList before we can tear off ComparableList<T>.ordered.
That means solving List<T> <: List<_> with T <: Comparable<T> for T.
Which can probably fail, but that's not new, it's just solving type relations involving F-bounded types.
I don't think it's worse than class C<T extends Comparable<T>> { C(); } var f = C.new;.

If we had a context type List<num> Function(num) f = List.ordered; we'd have to solve List<T> Function(T) against that (the type of the List.ordered tear-off) as part of downwards inference.

Then something like

static extension Permutation<T> on Map<T, T> {
  factory Map.singleton(T value) => {value: value};
}

where we have a non-linear relation, and we want to solve something like:

Map<num, int> Function(int) = Map.singleton;

That means solving Map<T, T> Function(T) <: Map<num, int> Function(int) for T (with no constraints on T this time).
Maybe we can solve that, maybe we can't, but if deciding whether the extension is applicable requires doing so, we need to decide up-front.
But this type check involves the parameter types of the constructor, so if we use that for applicability, we may introduce overloading. (We should probably just look at the return types only, solving only Map<num, int> <: Map<T, T> to avoid that issue.)

I don't see why I was so worried. If we can't solve, it doesn't apply. If we can solve, we have an instantiation for T which may or may not satisfy bounds, and we can check if it does.
And If there is no context type, tear-offs shouldn't be able to fail other than if instantiate-to-bounds doesn't provide a well-bounded type, but that's also not new.

Or we can say it's always applicable, and then give an error later if we have no conflict and then fail to solve, but then we can include the parameters in the type inference.

So, I think my worry above was assuming that we wanted to infer the type parameters to the extension before we could decide whether it was applicable, but that solving for that sometimes required looking at the arguments too, which we shouldn't do before having decided which extension to apply (because we don't want to infer types for arguments more than once).
And a worry that doing inference on constructor tear-offs would lead to two levels of inferring type arguments, but I think that's misguided. Doing ... f = List.ordered; would not try to infer a type argument to List and then a type argument to ComparableList so it's on type matched the first list. It should just say that List.ordered denotes a raw ComparableList.ordered, and then perform type inference directly on that.

I've had more ideas since then, which could possibly bypass that worry (distinguish raw and instantiated constructor invocations, and for raw ones infer type arguments from a context type as downwards inference, then decide applicability if the type is now instantiated, and keep the raw receiver type if not, and consider the extension constructor applicable. Then if it gets chosen, solve for the actual type arguments during upwards inference.)

Or we should just keep it simple and not allow overloading names. It's easier. Cleaner. And we can always loosen it up later, because that would only introduce fewer errors.

I'll close this as ... probably ... unfounded worry about a hypothetical approach that we'll very probably not take.

@lrhn lrhn closed this as completed Aug 24, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
static-extensions Issues about the static-extensions feature
Projects
None yet
Development

No branches or pull requests

2 participants