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

[Static extensions] How does inference work for constructors defined in static extensions #4053

Open
leafpetersen opened this issue Aug 23, 2024 · 11 comments
Labels
static-extensions Issues about the static-extensions feature

Comments

@leafpetersen
Copy link
Member

Bubbling up this discussion from comments in #3835 , how do we expect type inference to work for constructors defined in static extensions. Ignoring the question of whether we re-use the existing extension syntax or use a new static extension syntax, we have the general problem of going from a constructor call of the form C.name(args) or C<T1, ...., Tn>.name(args) which is defined on an extension E which has type arguments X1.... Xk and on type T_on to a specific choice of type arguments for X1....Xk. As a concrete example to work from, consider:

extension E<S, T> on Map<int, T> {
  factory Map.bar(S x, T y) => {3 : y};
}

How should inference work on calls to this constructor? That is, given

test() {
  var x1 = Map.bar("hello", 3);
  var x2 = Map<int, num>.bar("hello", 3);
  Map<Object, Object> x3 = Map.bar("hello, 3);
  Map<Object, Object> x4 = Map<int, num>.bar("hello, 3);
}

how do we produce the type arguments to turn each implicit invocation into an explicit invocation of the form E<T1, T2>.Map.bar("hello", 3); for some T1 and T2?

My initial thinking is that we can draw intuition by considering the original definition as essentially defining a generic static method equivalent to the following:

Map<int, T> E_Map_bar<S, T>(S x, T y) => E<S, T>.Map.bar(x, y);

This gives an obvious intuition as to how to infer calls to Map.bar without explicit type arguments: we view an invocation of Map.bar(args) in downwards context K as essentially equivalent to an invocation of E_Map_bar(args) in downwards context K. For the above examples with no explicit type arguments, this then results in the following inferred explicit invocations:

test() {
  var x1 = Map.bar("hello", 3); // E<String, int>.Map.bar("hello", 3);
  var x2 = Map<int, num>.bar("hello", 3);
  Map<Object, Object> x3 = Map.bar("hello, 3);  // E<String, Object>.Map.bar("hello", 3);
  Map<Object, Object> x4 = Map<int, num>.bar("hello, 3);
}

Note that in the case of x3, downwards inference pins the type argument T to Object when the subtype match Map<int, T> <# Map<Object, Object> is performed, but leaves S unconstrained. S is then filled in via upwards inference based on the first argument to the constructor.

How then should we treat the cases with explicit type arguments (x2 and x4)? My initial intuition is that we get the desired result by treating the explicit instantiation exactly as above, except replacing the downwards context K by the explicitly instantiated type on which the constructor is called. Note that in the explicit instantiation case, K adds nothing to the inference process. So here, we would treat var x2 = Map<int, num>.bar("hello", 3); the same as if we had the invocationE_Map_bar("hello", 3) in downwards context Map<int, num>, and likewise for Map<Object, Object> x4 = Map<int, num>.bar("hello, 3);. The result would be the following inferred explicit invocations:

test() {
  var x1 = Map.bar("hello", 3); // E<String, int>.Map.bar("hello", 3);
  var x2 = Map<int, num>.bar("hello", 3); // E<String, num>.Map.bar("hello", 3);
  Map<Object, Object> x3 = Map.bar("hello, 3);  // E<String, Object>.Map.bar("hello", 3);
  Map<Object, Object> x4 = Map<int, num>.bar("hello, 3); // E<String, num>.Map.bar("hello", 3);
}

In each case, the downwards context (if any) is replaced by Map<int, num> which in turn fixes T to be num via the subtype match of Map<int, T> <# Map<int, num> solving for T and S is inferred via upwards inference from the first argument to the constructor.

The intuition for replacing the downwards context is essentially that by writing Map<int, num>.bar(...) the user has expressed the requirement that bar produces a Map<int, num>.

Note that I'm not proposing we specify this via the desugaring to a static method E_Map_bar as written above: this just provides the underlying semantic model which motivates my sketch of how inference should work above. The specification itself is essentially just a use of the already existing generic type argument inference process, with a particular choice of downwards context, type arguments, arguments, and type parameters to solve for.

@eernstg @stereotype441 @chloestefantsova @johnniwinther WDYT ? Does this make any sense? Do you see problems with my rough sketch above? Alternative proposals?

cc @dart-lang/language-team

@leafpetersen leafpetersen added the static-extensions Issues about the static-extensions feature label Aug 23, 2024
@chloestefantsova
Copy link
Contributor

I like it. This inference behavior is clear and non-surprising.

I'm interested in the case of multiple extensions on Map with slightly different type arguments in the on-clause of the extensions. For example, we may have the following declaration, in addition to E:

extension E2<S, T> on Map<bool, T> {
  factory Map.bar(S x, T y) => {false : y};
}

How do we know which bar should be applied and how should we construct the replacement context in that case?

@lrhn
Copy link
Member

lrhn commented Aug 23, 2024

I wrote in a different issue that we probably want to treat "raw" constructor invocations specially, carrying the raw-ness through the applicability check, and then infer type arguments on the way back.
Just being able to do:

static extension NumList<T extends num> on List<T> {
   List.singleton(T value) : this.filled(1, value);
   factory List.banana() { print("Banana"); return this.empty(); }
}
void main() {
  var list = List.singleton(4);
  List<int> list2 = List.banana();
}

and having it inferring List<int> in both cases is such a fundamental way of using constructors that it will be incredibly confusing if it doesn't work like that.

In both cases List is a raw type clause (an identifier which denotes a generic declaration).
What we want is that the invocations work exactly like they would on the extension:

void main() {
  var list = NumList.singleton(4);
  List<int> list2 = NumList.banana();
}

that the List.xyz term denotes the constructor NumList.xyz, and type inference continues from there. (Where we have type inference defined for direct invocations of the members too.)
If they apply. That's the catch, we have to figure out first whether they apply, which may depend on inferred type arguments, depending on how we define applicability.

A List<String>.singleton("a") shouldn't be allowed to run the static extension constructor. That requires binding the type parameter <T extends num> to String and then executing code with that binding. That's a no-go.

There are two possible ways to do make that fail:

  • Consider the constructor extension applicable, apply it, then complain that the instantiation is invalid.
  • Consider the constructor extension not applicable to List<String>.singleton("a") at all.

The latter allows another static extension member with the same name to apply instead.

The easy way out is to say that a static extension is applicable if the static namespace of the extension's uninstantiated on type is the same as the static namespace of the target type clause.
(Maybe only constructors are applicable to T<...>.m references, and only non-constructors to T.m= or T.m<...> references.)

Any two static extensions with the same name on the same base class will conflict.
That's always going to be the case for static members, but not necessarily for constructors which can differ on instantiation of a generic receiver type.

Another approach is then to try to vet the instantiated receiver type against the on type of the extension.
That requires an instantiated receiver type.
A call of List<int>.singleton(2) is clearly instantiated, we can check that NumList<T>'s on type List<T> can be solved against List<int> to an instantiation T = int. Then we check if the instantiated on type List<int> is a good match for the instantiated receiver type List<int> (it is!), and the extension is applicable.
(And an on type of List<num> would not be applicable for a constructor with receiver type List<int>.)

We would want a raw receiver type with a context type to do downwards inference then, so:

List<String> l = List.singleton("a");

would infer List<String>.singleton(...) for the NumList constructor, from the context, and then consider that a non-raw
receiver type that the extension does not apply to.

So yes, downwards inference where possible, then applicability check on the resulting non-raw type. But also preserving a raw type with no context type so it can be inferred from the arguments.

For the

extension E2<S, T> on Map<bool, T> {
  factory Map.bar(S x, T y) => {false : y};
}
Map<bool, String> m = Map.bar(42, "a");

example a context type can only help infer the value type.
We have a potential constructor on Map, so we try to do downwards inference on the Map as a constructor, and we can infer T = String, but nothing for S. No contradiction, so the extension applies.
Then, if it's the only one that applies, continue inferring types for E2<S, T>.bar(42, "a") with T=String, which may solve for S=int.

Or something.

@leafpetersen
Copy link
Member Author

@lrhn

static extension NumList<T> on List<T extends num> {

I don't know what the T extends num is doing above, do you mean to have that in the binding?

I wrote in a different issue that we probably want to treat "raw" constructor invocations specially, carrying the raw-ness through the applicability check, and then infer type arguments on the way back.

I don't know what this means, can you elaborate?

and having it inferring List<int> in both cases is such a fundamental way of using constructors that it will be incredibly confusing if it doesn't work like that.

I believe that my proposal will infer List<int> for both of your examples.

@leafpetersen
Copy link
Member Author

@chloestefantsova

How do we know which bar should be applied and how should we construct the replacement context in that case?

My take is that adding constructor overloading via extensions is probably an anti-goal. My initial starting pitch would be that:

  • If there is a constructor/static member of the appropriate name on the underlying class-like thing, it wins
  • Otherwise, if there is a single constructor static member of the appropriate name on an in scope extension on the underlying type it we pick it
  • Otherwise it's an error

If we really want to do overloading... I don't know. That should probably be a separate issue/discussion.

@lrhn
Copy link
Member

lrhn commented Aug 23, 2024

do you mean to have that in the binding?

I do. Fixed.

@eernstg
Copy link
Member

eernstg commented Aug 23, 2024

@leafpetersen wrote:

how do we expect type inference to work for constructors defined in static extensions

In an earlier version of the proposal, in this section, I proposed the use of a generic function to decide whether or not any given extension would be applicable for a given instance creation expression. Your idea shares some elements with that approach. For the current version, in this PR, I used a declarative approach, just specifying the requirements that must be satisfied by the type inference step.

I'll try to explore the relationship between that approach and the one described in this issue. Here is the example extension:

extension E<S, T> on Map<int, T> {
  factory Map.bar(S x, T y) => {3 : y};
}

The rules I've proposed take a number of steps in order to decide that an expression like Map.bar("hello", 3) must be an invocation of the constructor declared by E. In particular, we check that bar isn't a static member declared by Map and that there are no accessible extensions with on-class Map that declare such a static member. It's an error if we find both a static member and a constructor.

So let's say that the only possible resolution of the instance creation is the constructor E.Map.bar.

How should inference work on calls to this constructor? That is, given

test() {
  var x1 = Map.bar("hello", 3);
  var x2 = Map<int, num>.bar("hello", 3);
  Map<Object, Object> x3 = Map.bar("hello, 3);
  Map<Object, Object> x4 = Map<int, num>.bar("hello, 3);
}

We now decided that Map.bar("hello", 3) is treated as E.Map.bar("hello", 3). E accepts two type arguments and they will determine the type of the expression as a whole as well as the parameter types. The type parameters of E must have suitable bounds such that every choice of actual type arguments to E that satisfies the bounds will also yield an instantiated constructor return type that satisfies the bounds. So we can rely on inference selecting type arguments S0, T0 to E, and then just insert them into the constructor return type Map<int, T>, yielding Map<int, T0>, which is then the type of the instance creation as a whole. (In the example, the type parameters of E do not actually have any bounds.)

This matches very well with the use of inference based on E_Map_bar:

how do we produce the type arguments to turn each implicit invocation into an explicit invocation of the form E<T1, T2>.Map.bar("hello", 3); for some T1 and T2?

My initial thinking is that we can draw intuition by considering the original definition as essentially defining a generic static method equivalent to the following:

Map<int, T> E_Map_bar<S, T>(S x, T y) => E<S, T>.Map.bar(x, y);

The proposal uses an even more verbose form to describe the explicitly resolved invocation of E.Map.bar, namely E<S, T>.Map<int, T>.bar(x, y). This is simply a notation that shows all the types explicitly, which ensures that it is trivial to check consistency (substitute the actual type arguments of E<S, T> into the constructor return type, that must yield the type Map<int, T>, which is then also the type of the expression as a whole).

For Map<int, num>.bar("hello", 3), which is treated as E.Map<int, num>.bar("hello", 3), we need to infer type arguments to E such that the instantiated constructor return type is Map<int, num>. This is the point where I'm proposing that we require Map<int, num>, not just a subtype or a supertype.

(The reason is that I do not think it's acceptable to use an approach to inference whereby the static type of Map<int, num>.bar("hello", 3) ends up being Map<int, Object> or any other proper supertype, and also not Map<int, int> or any other proper subtype.)

I'd want this step to yield a constraint that T must be equal to num, not just a subtype that by sheer luck ends up being num, or a supertype likewise. I wouldn't expect to be able to get this effect by specifying a function like E_Map_bar above and perform inference on an invocation of that function.

But if we assume that we have #3963 then we could express it as follows: First use type equality constraints to find the exact value of zero or more type arguments passed to E. This yields the constraint that the 2nd type argument (passed to T) must be num. Next, infer:

  var x2 = E_Map_bar<_, num>("hello", 3);

Normal inference will now succeed and find that the first type argument should be String, and this allows us to transform E.Map<int, num>.bar("hello", 3) to E<String, num>.Map<int, num>.bar("hello", 3).

For Map<Object, Object> x3 = Map.bar("hello", 3) we resolve Map.bar as E.Map.bar as usual, and infer:

Map<Object, Object> x3 = E_Map_bar("hello", 3); // Result `E_Map_bar<String, Object>`.

Finally, with Map<Object, Object> x4 = Map<int, num>.bar("hello, 3);, we resolve as usual and infer by equality constraints that the 2nd type argument to E must be num and then infer

Map<Object, Object> x4 = E_Map_bar<_, num>("hello", 3); // Result `E_Map_bar<String, num>`.

Here is an example where it makes a difference whether we insist on equality constraints to connect E and Map or we are just using standard inference on an invocation of F_Map_bar:

// Let's say this is the only accessible extension.
extension F<S extends num, T> on Map<S, T> {
  factory Map.bar(S x, T y) => {x : y};
}

// Used to emulate inference.
Map<S, T> F_Map_bar<S extends num, T>(S x, T y) => F<S, T>.Map<S, T>.bar(x, y);

void main() {
  Map<num, Object> x1 = Map<double, String>.bar(1.5, 'Hello!');

  // Corresponding inference based on F_Map_bar:
  Map<num, Object> x2 = F_Map_Bar(1.5, 'Hello!'); // Result `F_Map_bar<num, Object>`
}

If we rely on standard inference of the invocation of F_Map_bar in the given context then we will (by downward inference) choose the actual type arguments num and Object. I can't easily see a way to take the type arguments which are passed explicitly to Map into account, and we end up creating a Map<num, Object>. I think this is not acceptable when the syntax says Map<double, String>.

What I'm proposing is that we have the step where Map<double, String>.bar(1.5, 'Hello!') is resolved as F.Map<double, String>.bar(1.5, 'Hello!'), equality inference is then used to make it F<double, String>.Map<double, String>.bar(1.5, 'Hello!'), and that is then the final result. We will then create a Map<double, String> as requested by the developer, and it is checked that Map<double, String> is assignable to Map<num, Object> as usual.

Note that I'm not proposing we specify this via the desugaring to a static method E_Map_bar as written above: this just provides the underlying semantic model which motivates my sketch of how inference should work above.

Certainly.

@eernstg
Copy link
Member

eernstg commented Aug 26, 2024

A couple of things to keep in mind, regarding the expressive power that we may include or exclude based on our choices about how to perform type inference:

Constructors in static extensions can emulate generic constructors in some cases. Assume that we want the following:

class A {
  final int i;
  A(this.i);
  A.computed<X>(X x, int Function(X) fun): this(fun(x)); // A generic constructor.
}

void main() {
  A a = A(42);
  a = A.computed('Hello!', (s) => s.length);
}

Here's the emulation, using static extensions:

class A {
  final int i;
  A(this.i);
}

static extension AExtension<X> on A {
  A.computed(X x, int Function(X) fun): this(fun(x));
}

// `main` is unchanged.

We can't provide an actual type argument as suggested in the generic constructor issue (that would be A.computed<String>(...)), we'll have to use AExtension<String>.A.computed(...), but it does look the same as long as all type arguments can be inferred.

Another difference is that a a static extension can only add a generative constructor to a class if it is redirecting, so if we want a generative non-redirecting generic constructor then it can't be expressed as a declaration in a static extension. Still, this approach does allow us to emulate a generic constructor in a lot of situations.

Constructors in static extensions can also emulate conditional constructors. Assume that we want the following:

// Let's just pretend that `List` has this extra, conditional constructor.
class List<E> {
  ...
  if <E extends Comparable<E>>
  List.sorted(Iterable<E> iter) {...}
}

class A {
  final int i;
  A(this.i);
}

void main() {
  var xs = List.sorted(['b', 'a']); // OK.
  var ys = List.sorted([A('b'), A('a')]); // Compile-time error.
}

Emulation using extensions:

class List<E> ... // Doesn't declare the constructor `List.sorted`.

static extension ListSortedConstructor<E extends Comparable<E>> on List<E> {
  List.sorted(Iterable<E> iter): this.of(iter.sort((a, b) => a.compareTo(b)));
}

// Class `A` and `main` are unchanged.

@lrhn
Copy link
Member

lrhn commented Sep 3, 2024

I think the inference rules here do work. The static "constructor function" is likely the function you would get by doing a List.bar tear-off, similarly to how we get functions with other type parameters when tearing constructors off through a type alias. Fx:

typedef MM<K1, K2, V> = Map<K1, Map<K2, V>>;
var mmf = MM.filled; // Static type `Map<K1, Map<K2, V>> Function<K1, K2, V>(int, Map<K2, V>)

The examples do not have extra bounds, which is where I have some problems.
(It may assume that a constructor is applicable if it has the correct name and base type, independent of type parameters. Or it may just not hit any case where it matters.)

Factory constructors are usually easier, because they can be used covariantly - they can return a subtype of the constructed type. Generative constructors must be able to initialize an object of exactly the constructed type.
(Or we can disallow using a generative extension constructor as a super-constructor in a non-redirecting generative constructor, and avoid that complication.)

Consider:

class O<T1, T2 extends num> {
  O.rn(T v1, List<T2> v2);
}
extension E<S1, S2 extends int> on O<S1, S2> {
  O.ex(S1 v1, String v2) : this.rn(v1, <S2>[if (0 is S2) v2.length as S2]);
  factory O.re(S1 v1) = P<S1, S2>.re;
}
class P<R1, R2 extends int> extends O<R, R2> {
  P.re(S1 v1) : super.ex(v1, "$v1");
}

If I do O<String, num>.ex("a", "b") then it must be a compile-time error.
The type parameter S2 has bound int, so no code can be allowed to run with it bound to num.

If we try to solve it as above, the downards inference constraints are S1 == String and S2 == num. Since S2 == num is not valid for S2 extends int, the invocation is invalid. If we can't solve validly, it's an error. That's reasonable.

We also cannot allow a class like:

class Q<R1, R2 extends num> extends O<R, R2> {
  Q.re(S1 v1) : super.ex(v1, "$v1");
}

Here the only reference to the extension E is the super.ex invocation.
That invocation needs to do the same type inference with target type O<R, R2>, and R2 extends num is not a valid value for S2 extends int.
The super-constructor invocation is invalid, because super.ex construtor that otherwise behaves as if it was on O is not valid for all instantiations of O, like any constructor actually on O must be.
(Ok, let's not allow using static extension constructors as super-constructors. They're only for creating instances.)

Should we just not use the type parameters of the extension for constructors. What if we did:

extension E<S1, S2 extends int> on O<S1, S2> {
  O<X1, X2 extends num>.ex(X1 v1, String v2) : this.rn(...);
}

instead, requiring that any extension constructor on O must provide its own type parameters that accept all valid type arguments to O.?
It's extra verbose when the type parameters match up. We could allow you to use the type parameter of the extension if they match the type parameters of the on-class, and if not, all extension constructors have to add their own type parameters that do match.
(Or we could allow all constructors to have more restricted type parameters than the class itself.)

@munificent
Copy link
Member

I'm coming at this a little cold and forgive me if this is too tangential:

extension E<S, T> on Map<int, T> {
  factory Map.bar(S x, T y) => {3 : y};
}

Given that, if I were to do:

Map<String, bool>.bar('s', true);

Then I am calling a constructor declared in an extension whose on type is Map<int, T>. But the constructor is returning an object which is not a Map<int, T> for any T. Doesn't that seem... weird? You've got a constructor that doesn't return the type of its surrounding thing (the extension and its on type here), which violates the assumption of constructors. And you've got an extension that's a constructor but that returns an object wouldn't match the on type of the thing you just called. It almost seems like the constructor shouldn't be applicable, or this should be some kind of error.

I understand that static extensions make this weird because the extension is resolved on something more like the on declaration than the on type. But something feels very fishy here to me.

Generative constructors must be able to initialize an object of exactly the constructed type.
(Or we can disallow using a generative extension constructor as a super-constructor in a non-redirecting generative constructor, and avoid that complication.)

I would disallow generative extension constructors entirely. I didn't even know we were considering those.

@lrhn
Copy link
Member

lrhn commented Oct 10, 2024

extension E<S, T> on Map<int, T> {
 factory Map.bar(S x, T y) => {3 : y};
}

Given that, if I were to do:

Map<String, bool>.bar('s', true);

Can you do that at all? That's part of the question here. (And the answer is probably "no".)

If you write the explicit static extension member invocation, E<String, bool>.bar('s', true), or as the proposal syntax says it, E<String, bool>.Map.bar('s', true), then you are effectively calling a constructor on Map<int, bool>, not on Map<String, bool>. That's the Map type that E<String, bool> refers to. (Just as if E has been typedef E<S, T> = Map<int, T>; and bar had been declared on Map, somehow, while still referring to S.)

When you write Map<String, bool>.bar('s', true), we need to find an available and applicable static extension base-named bar on the type Map<String, bool>, and then E.Map.bar does not apply, because its on type is Map<int, T> and there is no solve for S and T that will make Map<int, T> be Map<String, bool>. (Or even just "a subtype of", since this is a factory constructor, but it's not a given that we won't require the instantiated static extension on type to be equal to the constructor target type, at least up to mutual subtyping. It's definitely needed if we allow generative constructors.)

So the error here would be either:

Map<int, T> does not have a constructor named bar

if we just ignore inapplicable constructors entirely, or some text describing that no instantiation of E<S, T> can be found that makes Map<int, T> be Map<String, bool> as requested.

If we disallow generative extension constructors entirely, that means disallowing redirecting generative constructors, which we could support. Non-redirecting ones were never going to be possible.
That reduces the problem, but does not remove it.

We are still in the position where

Map<String, bool> foo = Map.bar('a', true);

needs to resolve bar at a point where it doesn't if it's a static member access or a constructor access.

It then checks which static extensions are available that has a static member or constructor with base name bar.
It findsE.
Here we can say that if it finds more than one, there is a conflict, and we give up. Or we can take one step further and check if extension is applicable, which only makes sense for constructors. Static members are always applicable, and have no access to type parameters.

Checking for type-based applicability means looking at the receiver type. Here Map is a raw constructor receiver for a potential constructor invocation, so do type inference based on the context type to make it Map<String, bool>.bar(...).
Then check if E<S, T> on Map<int, T> is applicable to Map<String, bool>. It isn't, no possible way.
(If there was any possible solution for S and T where the on-type was related to Map<String, bool>, we could choose to solve for either == or <: relation between the instantiated on type and the specified type it applies to. Even for factory constructors, I'd go with ==.)

Since the extension was not applicable to Map<String, bool>, we can then remove E from consideration again and let another static extension member, or just say that there is an error.

Is this confusing? Yes!

Maybe we should only allow static constructors if the type parameters are the same as on type's.
If the static extension type parameters are not the same as those of the generic class (up to alpha-equivalence), or are not forwarded directly (no swapping positions), so anything but extension Name<A, B> on Map<A, B>, then you can't declare an extension constructor at all. Or allow swapping positions, and just require the type parameters in the in type are used lineraly and has the same bound as the corresponding parameter in the on type class.
(So extension E<A, B extends int, C> on Map<C, A>... is OK. Solving backwards is still trivial and always possible.)

If you do the on Map<int, T> thing, it's not declaring any constructors on Map<int, T>. You can still declare static members on Map and ignore the type parameters, but to declare constructors on Map<A, B>, we must be able to see that we can solve for A and B for any instantiation of Map. It must match all Map types to be allowed a constructor.


The next obvious questions could be:

  • Could we ignore the type parameters in the on type, so Map<S, T> directly means E<S, T>. No. Because we're lucky that E has two type parameters here. For extension F<X extends String> on Map<X, X> { factory Map.baz(X value) => {value: value}; }, writing Map<int, int>.baz(1); is meaningless. The F.Map.baz constructor refers to X which has a bound, we can't just ignore that. We have to solve for the type parameters of the extension somehow, and the on type is the only thing we have that constrains them.
  • Could we let factory constructors declare their own type parameters. Heck yes. factory Map<X, Y>.baz(...) {...}. Then the constructor is just a static extension on the static namespace of the on type, it's not on the instantiated on type itself. That constructor cannot reference the type parameters of the extension at all. (Then we can allow that syntax in classes too, so you can do factory List<T extends Comparable<T>>.sorted(Iterable<T> values) => .... Allow constructors to restrict type arguments to class. #1899. I want this.)

@eernstg
Copy link
Member

eernstg commented Oct 15, 2024

extension E<S, T> on Map<int, T> {
  factory Map.bar(S x, T y) => {3 : y};
}
```dart

Given that, if I were to do:

Map<String, bool>.bar('s', true);

Then I am calling a constructor declared in an extension whose on type is Map<int, T>. But the constructor is returning an object which is not a Map<int, T> for any T

Here's how the proposals here would respond.

Map<String, bool>.bar('s', true) is a compile-time error. In order to not be a compile-time error, it must be possible to infer two actual type arguments, bind them to S and T, respectively, and thus yield the instantiated on type Map<String, bool>. But there's no way we can bind S and T to anything such that Map<int, T> becomes Map<String, bool>. Hence, type inference must fail.

The idea is that an invocation of a constructor like Map<T1, T2>.bar(arg1, arg2) must have static type Map<T1, T2>, such that a reader can understand what we're creating without knowing whether the constructor is declared by Map itself, or it is added to Map by a static extension.

A constructor which is added by a static extension has a small amount of extra expressive power: it doesn't have to repeat the declaration of the type parameters of the on class, it can declare any number of actual type arguments and use them in whatever way it wants in the on type, as long as every possible binding of the type variables will yield an on type whose type arguments satisfy the bounds on the on class.

For a class like Map whose type parameters do not have a bound, we can use any type arguments we want in the on type. With E we're using Map<int, T>, which illustrates that we can use ground types as well as type variables in any combination we might desire.

So we can subset the applicable type arguments with a constructor in a static extension by writing an on type and some type parameters that can't express all the type argument lists that the on class supports (in the example: we insist that Map.bar can only create maps whose key type is int). Moreover, we can decompose the type arguments, as shown below.

If you want to specify the type arguments of the static extension directly then you can use a different syntax: E<String, bool>.Map.bar(e1, e2). This has exactly the same meaning as E<String, bool>.Map<int, bool>.bar(e1, e2), which also implies that e1 must have a static type which is assignable to String, and e2 must be assignable to bool.

With E<String, bool>.Map.bar('s', true) we can't see the type of the result, but it is fully determined because E<String, bool> binds S to String and T to bool, which implies that the instantiated on type is Map<int, bool>. I think it's crucial (for code comprehensibility) that Map<int, bool>.bar(...) has static type Map<int, bool> and not a subtype or supertype thereof. The idea is that we must preserve the property that the type of a constructor invocation is "obvious".

When it comes to inference, the context type can play a role as usual:

Map<int, T> mapBar<S, T>(S s, T t) => E<S, T>.Map.bar(s, t);

void main() {
  Map<Object, Object> map = Map.bar('s', true);
  // .. is treated like:
  Map<Object, Object> map1 = mapBar('s', true); // Inferred as `mapBar<String, Object>...`.
}

The new map has static (and dynamic) type Map<int, Object>.

We can use the ability to specify the type arguments of the constructor indirectly to decompose the actual type arguments (assuming --enable-experiment=inference-using-bounds):

static extension E<X extends Iterable<Y>, Y> on Map<Y, X> {
  factory Map.baz(X x) => {x.first: x};
}

// Just used to illustrate inference.
Map<Y, X> mapBaz<X extends Iterable<Y>, Y>(X x) => {x.first: x};

void main() {
  var map = Map.baz([1]); // Inferred as `mapBaz<List<int>, int>(<int>[1])`.
  print(map.runtimeType); // '_Map<int, List<int>>'.
}

This is again something that we can't do using a constructor which is declared in Map.

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

5 participants