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

Class methods - static methods with access to generic type parameters. #3781

Open
lrhn opened this issue May 6, 2024 · 13 comments
Open

Class methods - static methods with access to generic type parameters. #3781

lrhn opened this issue May 6, 2024 · 13 comments
Labels
feature Proposed language feature that solves one or more problems

Comments

@lrhn
Copy link
Member

lrhn commented May 6, 2024

Static methods cannot access type variables of the surrounding class/mixin/enum/extension type (or extension).

I propose introducing class members which can.

Proposal

Syntax

Allow prefixing a member declaration with class instead of static to make it a class member.

class C<T> {
  class List<T> single(T value) => <T>[value];
}
var list = C<int>.single(42);

Variable declarations cannot be class members.
Extension declarations are not allowed to be class members.
A class, enum, mixin or extension type member is.
(Maybe we can allow extension members too.)

Class members are scoped like static members, and conflict in the same ways. The only difference is that you can invoke a class member like you invoke a constructor, with type arguments on the type.

That is C<T>.single(42) is valid syntax. It already is, it can just now be used for other declarations than constructors.

Class methods can be generic functions.

class C<K> {
  class Map<K, V> mapIt<V>(K key, V value) => {key: value};
}

If a declaration could be static, and it's not a variable declaration, it can be class instead.

Static semantics

The body of a class member can reference the type parameters of the surrounding class (or similar).

Invoking C.mapIt(a, b), with a raw receiver, infers type arguments to C if possible. It is treated as if C<_>.mapIt is a single method declaration with two type parameters, and those type parameters are inferred as we normally would for a single method.
If no context or arguments are available to infer the type parameters from, they are instantiated to bounds.

We can also infer type arguments to the class for static setters and getters, which may be new, since those have so far not been able to be generic.

An unqualified identifier which resolves to a class member is inferred in the same way as a raw identifier,
except that if there is no context to infer type arguments to, and the invocation is from an instance or class member,
it uses the current binding of the type parameters instead of instantiating to bounds.
If invoking from a static method, it works just as if invoking with an explicit raw type.

This is consistent with how other unqualified identifiers reference members of "the same" surrounding construct (fx extension methods).

Invoking a class member through a type alias works just as invoking a static member (only works when the alias directly denotes a type declaration), except that the class member can access type arguments to the type.

A class method tear-off is a constant expression if the type arguments to the class are constant, whether explicit or inferred. (The tear-off will close over those type arguments.)

A class member of a non-generic class is indistinguishable from a static member.

Semantics

The scope that the class member is evaluated in contains type variables corresponding to the surrounding class,
just like for a constructor.
(Unlike an instance member, the type variables are not read from a this instance, and there is no this in scope.)

Class methods can be torn off. If they are, they close over the type variables of the class they're torn off from.
The runtime type of the torn-off function is the function type of the member that was torn off.
It's not possible to abstract over the instantiation of the class, tearing a method off a "raw type".

Considerations

The C<int>.single(42) looks like a constructor call, but isn't.
But then C.single(42) looks like a constructor call too, and that can be just a plain static function on a non-generic class.
We've relied on naming to distinguish constructors and static functions (and allowed static factory functions to use constructor-like names when it makes sense), so I don't see any new issue here.

There should be no ambiguity with syntax. The reserved word class cannot be used inside class-like declaration bodies, so it's new. It's placed in the same place as static.

Invocation syntax is not new, it's already used by constructors. We can now do C<int>.foo = 42, which is new, but not grammatically challenging. Since it's only static-like declarations, there are no operators.

If we ever plan to have nested classes then we may have a problem. I'm ready to consider a different name for class member, or wait until it becomes necessary.

If we get extension static methods (#723), then we should probably also allow extension class methods.
(Will have to consider whether access to type variables is ambiguous - how do we go from the type variable binding of the target class to the type variable binding of the extension? Probably the same way we always do.)

@lrhn lrhn added the feature Proposed language feature that solves one or more problems label May 6, 2024
@tatumizer
Copy link

I'm ready to consider a different name for class

class C<T> {
  static List<inherited T> single(T value) => <T>[value];
}

The idea is that once the method is static, it's better to be spelled out like a static method. It's a treatment of T that's different.

@abitofevrything
Copy link

Just curious as to why we can't extend static methods to have access to the classes type parameters? If there is a conflict, the classes parameters would be shadowed by the type parameters on the static method itself, so references to existing type parameters would be unchanged.

@lrhn
Copy link
Member Author

lrhn commented May 7, 2024

We could just change all static methods to be able to refer to the type parameters.

It can't be allowed to work for variables, but that's an easy restriction. Just don't put the type variables in scope.
After all, the type variable is already in the lexical scope, it's just an error to refer to them, so it wouldn't change any identifier resolution.

It would mean that a static method may or may not refer to the type variables.
A caller will have to know whether it's enough to write Foo.foo() or if they need Foo<int>.foo().
I guess you can see that from the function signature. If the signature doesn't mention the type parameter, then the type parameter is probably not used by the function.

If the function doesn't use the type parameter, then it doesn't need to close over it. But we don't want to rely on that.
A tear-off it's only constant of the type arguments are constant, whether they are used or not.

Could work. Is simpler.
I'm still worrying that it might get confusing, but I can't find an immediate problem.

@tatumizer
Copy link

One (potential) problem is about restrictions on type parameters. Example: somebody declared the class like this:

class Foo<T extends Widget> {
  static List<T> bar(T value) => <T>[value];  
}

T in the definition of "bar" has no restrictions, so this T has nothing to do with T in the class definition. Why is it called T then? Probably because people habitually write T for the type parameter.

Now suppose we allow a new invocation syntax. Can we write Foo<int>.bar(1)? This doesn't make sense: int cannot be a type parameter of Foo. The compiler must complain. However, if we call bar via Foo<Widget>.bar(myWidget) it must work.
There's something off about this phenomenon, but probably not a big deal.

The second problem is related to the first one. Consider a variant:

class Foo<T extends Widget> {
  static List<E extends Widget> bar(E value) => <E>[value];  
}

Before the proposed change, it didn't matter whether we write a type parameter of bar as E or T or anything. But now, it begins to matter: if it's E, we cannot call bar via Foo<Widget>.bar(), but if it's T - we can. Isn't it strange?

@lrhn
Copy link
Member Author

lrhn commented May 7, 2024

If this is using the syntax above, without the inherited, then I wouldn't use that syntax.
(I'm also not completely sure I understand the examples, or the problems they should have.)

If we had a syntax where the static method declares the parameter that can be applied along with the class name, it'd probably be on the static, not on the return type. That would make it:

class Foo<T extends Widget> {
  static<T> List<T> bar(T value) =><T>[value];
  //    ^^^ declaration
  //             ^      ^           ^ references
}

then there is no question that T is different from the type argument to Widget (there is closer declaration of the same name), and is really a type parameter of bar.
We can allow you to write Foo<int>.bar(42), but it's really more like Foo.<int>bar(42).
And nothing would prevent having a different number of parameters either:

class Foo<T> {
  static<K, V> Map<K, Foo<V>> cache() => <K, Foo<V>>{};
}

That can be confusing.

I would just not do that, precisely because it's confusing. Either use the class type parameters, or use a generic function (or both), but this is not trying to introduce a third kind of type parameters.

The idea is precisely to give access to the type parameters of the surrounding declaration, which means it would just be:

class Foo<T extends Widget> {
  //      ^  declaration
  static List<T> bar(T value) =><T>[value];
  //          ^      ^           ^ references
}

and the T's refer to the T of Foo<T extends Widget>. It's supplied by writing Foo<SomeWidget>.bar(SomeWidget()), and has to satisfy the bounds of Foo.
If you want bar to have a different type parameter, with a different bound, you have to make bar generic.

The underlying problem that I'm trying to solve is to reduce the API distinction between constructors and static functions.

Today you can do:

List<int>.filled(42, 42)

but you cannot do:

List<int>.fromStream(someIntegerStream)

and have it return a Future<List<int>>. You have to use a static function which is not a constructor, and pass the type argument to the function.

List.fromStream<int>(someIntegerStream)

which is OK, but the difference is slightly grating.

By allowing List<int>.fromStream to call a static function and supply a T that is intended as a list element type, we remove one of the two API distinctions between factory constructors and static methods.
(The other being constructors not being able to be generic, which we should just fix at the same time.)

@tatumizer
Copy link

tatumizer commented May 7, 2024

Sorry, my mistake. Currently, if you write

class Foo<T extends Widget> {
   static List<T> bar(T value) =><T>[value];
}

you will get an error "static members can't reference type parameters of the class".
Your idea is to remove this limitation, right?
Just to illustrate:
Today, if you needed a generic static method, you have to write it as

class Foo<T extends Widget> {
   static List<T> bar<T>(T value) =><T>[value];
}

but T in bar<T> has no relation to T from the class definition -e.g. it can be called as Foo.bar(1)
With the proposed change, bar1 will be different from bar in the following example, right?

class Foo<T extends Widget> {
   static List<T> bar<T>(T value) =><T>[value]; // T can be whatever, e.g. int
   static<T> List<T> bar1(T value) =><T>[value]; // T references an "inherited" T, which MUST extend Widget 

}

The difference is that you will now be able to pass the type parameter to bar1 by attaching it to Foo, but won't be able to do the same with bar. And for bar1, there will be a limitation: we will not be able to call Foo<int>.bar1(1), but can call Foo<SimpleWidget>.bar1(mySimpleWidget). As for the original bar, everything remains the same as it is today (there's no other choice: after all, its type parameter has nothing to do with the class parameter, except the fact that both of them by accident happen to use the same letter T).
Correct? If not (probably not), could you illustrate the difference between "before the change" and "after the change" in examples?

(Edited, replacing one sort of confusion with another :-)
IMO, moving <T> around inside the method definition and attaching it here rather than there, still doesn't convey the meaning of the parameter being "inherited" from the class. Or maybe it does, but not clearly enough :-)

@lrhn
Copy link
Member Author

lrhn commented May 7, 2024

right?

Right! And that's why I don't like the static<T> either.

So instead, if we go with static instead of class, you can write:

class C<T extends num> {
  static T foo1<T>(T value) => value;
  static T foo2(T value) => value;
  T foo3<T>(T value) => value;
  T foo4(T value) => value;
}

Here foo2 and foo4 refer to the type argument of the surrounding class.
The foo4 accesses the type arguments from this, and foo2 gets them implicitly from the "receiver class" when called.
The foo1 and foo3 have their own type parameters which just happen to have the same name as the class type parameter (which is bad style, but not illegal). That name shadowing prevents them from referring to the class type parameter.

There is nothing fundamentally new here, not much at least. We just allow static members to access the type parameters just like constructors already can, if they want to and haven't shadowed the name. No existing code is hurt, we don't change identifier resolution, we just allow something that was previously forbidden. (And give it a meaning.)

And reading whether a function depends on the type parameter is no more or less difficult than it is for instance members already.

@tatumizer
Copy link

tatumizer commented May 7, 2024

Oh, I see! By omitting the <T> next to foo2 and foo4, we leave T bound to the class parameter, avoiding the shadowing.
Please check the following table:

C<int>.foo2(1); // correct: T propagates from C<int>
C<int>.foo1(1); // error! 
C.foo2<int>(1); // error or not? 
C.foo1<int>(1); // correct
C.foo2(1); // error or not? does inference work? Does it work in extensions?

(Please fix a small typo: "The foo2 foo1 and foo3 have their own type parameters...")

There's one (theoretical) case we haven't yet discussed: foo5 might have some type parameters bound to the class, but not all. How to declare such a method? We still need the syntax for that, like E foo5<inherited T, E> => ... or maybe
E foo5<*T, E> => ... or something?

@abitofevrything
Copy link

I don't think that's a problem, it would be declared like this:

class C<T> {
  static (T, E) foo5<E>(T a, E b) => (a, b);
}

No need for a new syntax, just don't shadow the class type parameters.

Invocations would then look like this: C<int>.foo5<String>(1, 'foo'). A bit weird, but consistent with current behaviour, clearly shows the difference between class and method type parameters, and will most likely be inferred anyway.

@tatumizer
Copy link

tatumizer commented May 8, 2024

And what if class C has more type parameters?

class C<T, V> { // V is not used by foo5
  static (T, E) foo5<E>(T a, E b) => (a, b);
}

Then the invocation will look like C<int, _>.foo5<String>(1, 'foo'), right? (I know, parameters can be inferred, but we are discussing complete formal syntax). Another option is to use Never: C<int, Never>.foo5<String>(1, 'foo')

@abitofevrything
Copy link

This is already analogous to the situation today where a function declares type parameters it does not use:

T bar<T, E>(T value) => value;

This needs to be invoked as bar<int, Never>(5) (or any other type, doesn't have to be Never).

As you say though, hopefully this won't arise too often thanks to type inference.

@tatumizer
Copy link

Suppose in the body of the extension method I want to call another static method from the same or different extension.
Will this work?

extension <T extends num> on List<T> {
  static foo(T t) {
    // I want to call another static method from the same or different extension
    List<T>.bar(t); // will it work?
  }
  static bar(T t) {
    //...
  }
}

@lrhn
Copy link
Member Author

lrhn commented May 8, 2024

What is suggest:

// Valid. T of C bound to int, used in foo2
C<int>.foo2(1);
// Valid. Doesn't use T of C. 
// T of foo1 inferred as int.
C<int>.foo1(1);
// Error, foo2 is not generic
C.foo2<int>(1);
// Valid. Just like today. 
// C instantiated to bound, never used
C.foo1<int>(1);
// Valid. C.foo2 infers as C<int>.foo2(1)
C.foo2(1);

About extensions: it will work if we get extension static methods, otherwise the static is only on the extension itself.
Haven't decided if we should allow instantiated extensions as receivers to, after all they are not types.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature Proposed language feature that solves one or more problems
Projects
None yet
Development

No branches or pull requests

3 participants