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

Constructor tear-offs: how to treat tear offs from generic classes #1586

Closed
leafpetersen opened this issue Apr 14, 2021 · 6 comments
Closed

Comments

@leafpetersen
Copy link
Member

leafpetersen commented Apr 14, 2021

The current proposal specifies that when tearing of a constructor from a generic class, the tearoff is always a monomorphic function which has been instantiated, either implicitly via the context type, or via instantiate to bounds. There is an optional extension to allow explicit instantiation via syntax, but no option to tear it off uninstantiated. Example:

class A<T> {
  A.named();
}

void test() {
  var a = A.named;  // a has type A<dynamic> Function()
  A<int> Function() b = A.named; // b has type A<int> Function()
  // Possible extension
  var c = A<String>.named; // c has type A<String> Function()
}

It's surprising to me that the choice would be to always instantiate here. If we choose to only support fully instantiated generics, then I think the "possible extension" is almost a must have, since otherwise it's very non-intuitive how to control the torn off type. Having to assign to a context is surprising.

More generally, the fact that A.named with no context implicitly instantiates to bounds seems very surprising.

I would propose the following alternative: instead, we by default tear off a constructor from a generic class as a generic method.

class A<T> {
  A.named();
}

void test() {
  var a = A.named;  // a has type A<T> Function<T>()
  A<int> Function() b = A.named; // b has type A<int> Function() via implicit instantiation 
  // Possible extension
  var c = A<String>.named; // c has type A<String> Function() via explicit instantiation
}

A concern was raised about how to integrate this with a possible extension to allow generic constructors. This is slightly awkward, but not terrible. Specifically, I would suggest the following:

class A<T> {
  A.named<S>(S x);
}

void test() {
  var a = A.named;  // a has type A<T> Function<T, S>(S)
  A<int> Function() b = A.named; // b has type A<int> Function(dynamic) via implicit instantiation, or error 
  // Possible extension
  var c = A<String>.named; // c has type A<String> Function<S>(S) via explicit instantiation, or error
}

It is slightly surprising that the type parameters get "uncurried" in this case, but this would be by far a very esoteric corner of the language (if it even comes to exist) and it feels odd to make a more common part of the language (tearoffs of constructors from generic classes) less useful in order to make this hypothetical esoteric corner slightly less awkward.

cc @lrhn @eernstg @munificent @natebosch @jakemac53 @stereotype441

@lrhn
Copy link
Member

lrhn commented Apr 14, 2021

I'm positive towards this approach. I think it would be completely uncontroversial if it wasn't for the prospect of generic constructors.

I'm slightly worried about users conflating class generics and method generics and being confused why (Foo.bar)<T>(x) works, but Foo.bar<T>(x) doesn't. If we allow (Foo<T>.bar) as an explicitly instantiated tear-off, then both Foo<T>.bar(x) and (Foo<T>.bar)(x) will also work. I'll wager that we'll see at least one request to allow Foo.bar<T>(x) as alternative syntax for Foo<T>.bar(x) because it's "more consistent". That's the primary reason I've shied away from generic tear-offs until now.

If we treat constructor tear-off as equivalent to tearing off a static helper function, like:

  static List<E> List$filled$tearoff<E>(int count, E fill) => List<E>.filled(count, fill);

then we get instantiated tear-off for free from what we do for normal function tear-off. (And it defines when we do canonicaization).

I'd still also want to allow explicit instantiation, var f = List<int>.filled;, because it saves a lot of typing over

  List<int> Function(int, int) f = List.filled;

There is no shorthand for the function type, even when all you want is the return type. If you write anything in the variable type, you must write it all because you turn off type inference for the entire variable type.

The choice of how to instantiate is driven by context type. That's something we decided not to use to distinguish a type literal from an unnamed constructor tear-off — but not only because it uses the context type, also because it's unwieldy and error-prone compared to callable object tear-offs.
It's exactly what we do for instantiated tear-offs of functions, so we have some precedence.

The only real issue is what happens if we add generic constructors. Which won't matter until we do that, so as long as we believe it can be solved, we don't necessarily need to worry about the complexity of the solution yet.

Uncurrying the type parameters is also the only solution I could find that made sense.

It might be breaking if we introduce type arguments to existing constructors, because then var x = Map.fromIterable; would change from Map<K, V> Function<K, V>(Iterable, K Function(dynamic), V Function(dynamic)) to
Map<K, V> Function<K, V, T extends dynamic>(Iterable<T>, K Function(T), V Function(T)).
I'm willing to do it anyway 😈 .

For a generic class and constructor, it's less clear that we can just defer it to a normal function tear-off of a static helper function, because we need to do a partial instantiation. With:

   static A<T> named$tearoff<T, S>(S x) => A<T>.named<S>(x);

we don't have an existing definition of how to specialize only T (or only S). So, there would be more work there (but again not until we actually have generic constructors).

For:

class A<T> {
  A.named<S>(S x);
}

would it be possible to instantiate S and not T?

That is, are all of the following valid:

var x = A.named;  // A<T> Function<T, S>(S)
A<T> Function<T, S>(S) y = A.named;
A<int> Function<S>(S) z = A.named;
A<T> Function<T>(int) w = A.named;  // <-- specialize only `S`.
A<int> Function(String) v = A.named;

We will have an issue deciding which type parameter is being specialized if we allow both. If we only allow the class parameter to be specialized by itself, then it's clear from the number of type arguments in the result type what happens. Might be safer that way, but it sets a precedent for partial type instantiation to take the first parameter (if that is ever something we'd want to do in general for normal generic functions).

There are tricky cases where S would depend on T, like:

class B<T> {
  B.named<S extends T>(S x);
}

where

B<T> Function<T>(int x) p = B.named;

would not be valid, but that's a type issue because we can't instantiate S to something making it valid.
I don't think it's an issue in practice.

Alternatively, we do not provide partial instantiation. The constructor tear-off of A.named takes two type arguments, and is treated in every way as a two-type-parameter function. If you want to partially instantiate it, you need to use A<int>.named to get a one-type-parameter function. That might be safer.
(Again, all choices to make when we get generic constructors, until then I think it will just work, and there are solutions here that I think will work for generic constructors too).

@eernstg
Copy link
Member

eernstg commented Apr 14, 2021

In order to get the enhanced expressive power, I think it would be useful to let a constructor tearoff be as generic as the class and the constructor admits. Generic function instantiation could then be applied implicitly to the torn-off function, using the same rules as the ones that we have for generic functions/methods already. That amounts to the same thing as @leafpetersen's proposal at

I would propose the following alternative ...

However, I think we should take the opportunity to enable explicit instantiation of tearoffs (for functions, methods, constructors). It is an inconvenient anomaly that we can obtain the instantiation via a context type, but we can't express it directly.

A possible syntax for this could use .call for methods and .new or the qualified name for constructors:

class C<X> {
  C(int i);
  C.named(String s);
  C.generic<Y>(Y y, X Function(Y) f); // If we get those.
  void m<Z, W>(Z z, W w) {}
}

List<X> f<X, Y>(X x, Y y) => [x];

void main() {
  f; // Type `List<X> Function<X, Y>(X, Y)`, as today.
  f<int>.call; // Type `List<int> Function<Y>(int, Y)`.
  f<int, String>.call; // Type `List<int> Function(int, String)`.

  var c = C<int>();
  c.m; // Type `void Function<Z, W>(Z, W)`, as today.
  c.m<bool>.call; // Type `void Function<W>(bool, W)`.
  c.m<bool, bool>.call; // Type `void Function(bool, bool)`.

  C.new; // Type `C<X> Function<X>(int)`.
  C.named; // Type `C<X> Function<X>(String)`.
  C<String>.new; // Type `C<String> Function(int)`.
  C<double>.named; // Type `C<double> Function(String)`.
  C.generic; // Type `C<X> Function<X, Y>(Y, X Function(Y))`.
  C<int>.generic; // Type `C<int> Function<Y>(Y, int Function(Y))`.
  C.generic<bool>.call; // Type `C<X> Function<X>(bool, X Function(bool))`.
  C<int>.generic<bool>.call: // Type `C<int> Function(bool, int Function(bool))`.
}

If we consider the support for currying (by passing fewer type arguments than the declared formals) too error prone then we can always make it an error instead.

The point is that <constructorDesignation> allows for Name<TypeArguments>.name today, and it is always an error unless Name is a class declaring a constructor named Name.name.

So if we start using name<TypeArguments>.call for a new purpose then it is a non-breaking change (except for the case there is a constructor whose name is name.call, which is not very likely because class names generally start with a capital letter).

I think it's a nice property that we may think of the tearoff C<int>.generic as just another function, which makes it natural that we can proceed and instantiate it as C<int>.generic<bool>.call.

We would use the same rule as today where an ordinary method invocation (like x.foo<int>(42)) is parsed as such (not as an evaluation of x.foo or x.foo<int> followed by application(s) of actual parameter list(s)).

void main() {
  f(1, true); // Normal function call, inferring `<int, bool>`.
  f<int>.call(1, true); // Would tear off a function object, doing `(f<int>.call)<bool>(1, true)`.
  f<int, bool>.call(1, true); // Would tear off, but could also be desugared as `f.call<int, bool>(1, true)`.

  C.new(2); // Allowed in the proposal already, inferred as `C<dynamic>(2)`.
  C.named('bar'); // Inferred as `C<dynamic>.named('bar')`.
  C<String>.new(2); // Again in the proposal, `C<String>(2)`.
  C<double>.named('bar'); // Possible today.
  C.generic(3, (i) => i.isEven); // Ideally, inferred as `C<bool>.generic<int>(...)`.
  C<int>.generic(true, (b) => 3); // Ideally, inferred as `C<int>.generic<bool>(...)`.
  C.generic<bool>.call(true, (b) => 3); // Ideally, inferred as `C<int>.generic<bool>(...)`.
  C<int>.generic<bool>.call(true, (b) => 3): // No inference.
}

We have an anomaly in that the invocations that are possible today must make a choice for any omitted type arguments (so we get an instance of C<dynamic> in a few cases, because we don't have the notion of a type function that would receive the actual argument corresponding to X and return an instance of C<X>).

Other than that, the interactions between the torn off function objects and the forms where the function is invoked directly seem to be rather natural.

@lrhn
Copy link
Member

lrhn commented Apr 14, 2021

If we want to enable explicit instantiation of function tear-offs, then we can also consider just allowing foo<int, String> as an expression. It's not currently valid, but it could be made so. That would also allow List<int> as an instantiated type literal.
See #123.

@eernstg
Copy link
Member

eernstg commented Apr 14, 2021

The reason why I tried to come up with something other than <typeName> <typeArguments> is that I suspect it will introduce a number of parsing ambiguities. It might also lower the general readability of the code if that syntax can't be taken as a hint that "this is syntactically a type".

I think forms like C<int>.named and C<int>.new are reasonably consistent with f<int>.call, and we could of course use List<int>.type to denote the reified Type, leaving int.type as a special case that we don't really need, but also a way ahead to using .type in general for reified types.

But if #123 will just work without any difficulties then we could certainly use the same syntax for tearoffs.

@natebosch
Copy link
Member

My preference is that a tearoff of a constructor on a generic class results in a generic function (other than where inference fills it in) as in Leaf's alternative. This feels the least surprising to me, and it buys some power than I think is unlikely to be confusing for users, even if it doesn't see much use.

If it turns out to be a large burden to implementations to tear off as a generic function that could convince me otherwise.

I also do think it would be value to implement #123 and allow explicitly specifying generic types on the tearoff. I agree that if we don't implement the tearoffs as resulting in generic functions, that not being able to specify it without using the context type is a large drawback.

@lrhn
Copy link
Member

lrhn commented Apr 14, 2021

If the "recommended implementation strategy" is to have a static helper function:

 static List<T> List$filled$tearoff<T>(int count, T value) => List<T>.filled(count, value);

and tear off that instead of the constructor (which falls back on existing behavior and gives us precisely the constant/canonicalization behavior we want), then var f = List.filled; providing a generic function comes for free, we'd have to force instantiation otherwise.

I still want #123, also for the List<int> type literals.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

4 participants