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

How do constructor tearoffs of generic classes resolve through type aliases? #1620

Closed
leafpetersen opened this issue May 7, 2021 · 5 comments

Comments

@leafpetersen
Copy link
Member

In discussions between @eernstg and myself this morning, the following question arose. Consider the following code:

class New<S, T> {
  New();
  void show() {print("$S, $T");}
  static int staticMethod() => 3;
}
typedef Old<S, T> = New<S, T>;
typedef Old1<S> = New<S, int>;
typedef Old2 = New<String, int>;

Given the above, the following is valid code, and prints 3 three times:

void main() {
  print(Old.staticMethod());
  print(Old1.staticMethod());
  print(Old2.staticMethod());
}

The interpretation of each of the static method calls through the type alias is that we resolve the type alias to a class, and call the static method on the underlying class (regardless of the generic arguments or lack thereof).

Now consider the following program:

void main() {
  Old().show();
  Old1().show();
  Old2().show();
}

This code prints the following:

dynamic, dynamic
dynamic, int
String, int

The interpretation of the use of Old etc as a constructor is that inference (in this case via instantiation to bound) is done to fill in the missing type arguments, and the constructor is then invoked.

In an upcoming release, we propose to allow constructors to be torn off, and we propose that tearing off a constructor from a generic class can either be done providing type arguments (in which case a monomorphic function is the result) or without providing type arguments (in which case a generic function is the result). Example:

void main() {
  print((New.new).runtimeType);
  print(New<String, int>.new).runtimeType;
}

This program is expected (under the current proposal, to print:

New<S, T> Function<S, T>()
New<String, int> Function()

Now consider the following program:

void main() {
  print((Old.new).runtimeType); // 1
  print((Old1.new).runtimeType); // 2
  print((Old2.new).runtimeType); // 3
}

What do we expect this to print?

A couple of points:

  • First, if we expect renaming via a typedef to remain seamless, then we must expect line 1 to print New<S, T> Function<S, T>()
    • This is also internally consistent: Old() and (Old.new)() will, in general, behave consistently with respect to inference.
  • Second, it seem to me that it would be highly surprising if line 3 also printed New<S, T> Function<S, T>()
    • This would be internally inconsistent: Old2() produces a New<String, int>, but (Old2.new)() would produce a New<dynamic, dynamic>.
  • Third, this suggests that we should choose an interpretation of line 3 which prints New<String, int> Function().
    • This is internally consistent: Old2() produces a New<String, int> and (Old2.new)() also produces a New<String, int>.

So it seems clear to me that there is a strong argument for resolving constructor tearoffs through type aliases differently from static methods: we should choose the interpretations from my First and Third points above (even though this is arguably inconsistent with how we resolve static methods).

However, there is an oddity here from a specification perspective in that for Old, we are expected to ignore the type arguments to New on the RHS of the typedef, but for Old2 we are expected to respect them. This oddity becomes clear when we consider Old1. What do we expect line 2 to print? Options:

  • Line 2 is a static error
  • Line 2 prints: New<dynamic, int> Function()
    • This is internally inconsistent: New<String, int> n = Old1() produces a New<String, int>, but New<String, int> n = (Old1.new)() would be a static error.
  • Line 2 prints: New<S, int> Function<S>().
    • This is internally consistent: I believe in all contexts, Old1() and (Old1.new)() should produce the same result.
    • This has some potentially worrying implementation implications, since we must now associate a set of generic constructor tearoffs with each partially applying typedef (with associated identity questions).
  • Line 2 prints: New<S, T> Function<S, T>().
    • This is surprising for all of the same reasons described above.

As a bonus exercise, consider:

typedef Old3<S, T> = New<List<S>, List<T>>;

Some possible interpretations we could take.

First, we could choose to interpret each typedef with a RHS which expands to a class to induce a set of (possibly generic) constructor tearoffs. That is, the declarations:

typedef Old<S, T> = New<S, T>;
typedef Old1<S> = New<S, int>;
typedef Old2 = New<String, int>;

are essentially considered to induce the declarations:

Old<S, T> Old.new<S, T>() => New.new<S, T>()
Old1<S, int> Old1.new<S>() => New.new<S, int>()
Old2<String, int> Old2.new() => New.new<String, int>()

Second, we could choose to specify directly an interpretation which gives us the result we want. Something along the (very rough) lines of:

  • Given a constructor tear-off of the form C.name, where C is a typedef with type parameters Xi, expand the RHS of C fully.
  • If the result is a non-generic class, tearoff name
  • If the result is a generic class with no arguments passed, instantiate to bounds and tearoff name
  • If the result is a generic class with arguments passed, and the arguments are closed with respect to the Xi, tearoff name from the instantiated class.
  • If the result is a generic class with arguments passed, and the arguments are exactly the Xi, tearoff name as a generic method.
  • Otherwise static error

This seems tremendously hacky to me, but maybe it can be made cleaner?

Third, we could back off of tearing off constructors as generic methods.

cc @eernstg @lrhn @munificent @natebosch @jakemac53 @stereotype441 @johnniwinther @stefantsov @rakudrama

Thoughts? Other suggestions? Things I'm missing/have mis-stated?

@lrhn
Copy link
Member

lrhn commented May 7, 2021

The current proposal deals explicitly with constructor tear-offs through type aliases.

Now consider the following program:

void main() {
  print((Old.new).runtimeType); // 1
  print((Old1.new).runtimeType); // 2
  print((Old2.new).runtimeType); // 3
}

What do we expect this to print?

The current proposal would give you:

  1. New<S, T> Function<S, T>()
  2. New<S, int> Function<S>()
  3. New<String, int> Function()

That is, is generates a function which specializes the generic type alias, not the class. A not-completely instantiated type alias is not a class, it's a generic function from type to class (same as a generic class is not a class, it's a function from types to a class), so the result will be a generic function for the alias function as well, like it would for the generic class.

Similarly if we have:

typedef Old3<S extends num, T extends num> = New<S, T>;
typedef Old4<S, T> = New<T, S>;
typedef Old5<S, T, U, V> = New<S, T>;

we'd get:

print(Old3.new.runtimeType); // New<S,T> Function<S extends num, T extends Num>()
print(Old4.new.runtimeType); // New<T, S> Function<S, T>()
print(Old5.new.runtimeType); // New<S, T> Function<S, T, U, V>()

The real question is whether any of these are identical with a tear-off of New.new.

In the current proposal, the Old2.new tear-off is identical to New<String, int>.new. It's a non-generic type alias, so it's completely equivalent to its aliased type.
The remaining ones are not.

The proposal does not try to recognize that Old passes type arguments straight through, and can therefore be seen as equivalent to New. I just don't consider it worth specifying ("If it has the same number of type parameters, with the same bounds at each position, and it passes the type parameters through to the class definition in the same order, then it's an true alias, and we replace it with the RHS. If not, we treat it differently.") Easier to just treat all uninstantiated generic type aliases the same.

So, the current algorithm for tearing off constructors of type aliases is:

  • If the alias is not generic, use the aliased type directly.
  • If the alias is generic and instantiated (explicitly or implicitly by inference), use the aliased type directly.
  • If the alias is generic and not instantiated, create a generic function with the type parameters of the alias and the parameters and body you'd get by tearing off the constructor of the aliased type instantiated with those type parameters.

So, Old1.new becomes <S>() => Old1<S>.new() which expands (now that Old1 is fully applied) to <S>() => New<S, int>.new().

@Levi-Lesches
Copy link

Levi-Lesches commented May 7, 2021

So Old1.new becomes <S>() => Old1<S>.new() which expands (now that Old1 is fully applied):
<S>() => New<S, int>.new()

Won't this conflict with allowing constructors to have generics (as in #647)? This would allow having generics before and after .new. Consider the example:

class Original<K, V> {
  Original.new<E>(K key, V value, List<E> list) { }
}

typedef Alias<S> = Original<S, int>;

Some examples of using generic constructors with generic types. Original and Alias should have identical behavior:

Original<String, int>.new  // Original<String, int> Function<E>(String, int, List<E>)
Original<String, int>.new<bool>  // Original<String, int> Function<bool>(String, int, List<bool>)

Alias<String>.new  // Original<String, int> Function<E>(String, int, List<E>)
Alias<String>.new<bool>  // Original<String, int> Function<bool>(String, int, List<bool>)

// These two should be the same, but would not be under the above rules
Original.new;  // Original<dynamic, dynamic> Function<E>(dynamic, dynamic, List<E>)
Alias.new;  // Original<K, int> Function<K> (K, int, List)

To reconcile, we should continue to require type arguments for the class to go between the type and .new, and type arguments for the constructor to go after the .new. If both are omitted, the class's type instantiates to bounds while the tearoff keeps the generics of the constructor. That is, exactly like how Original.new would resolve.

@lrhn
Copy link
Member

lrhn commented May 10, 2021

@Levi-Lesches
Tearing off generic class constructors as generic functions does interact with potentially adding generic constructors, with or without considering aliases.

The current thought is that if it becomes an issue, we can solve it by concatenating the generics:

class C<T> {
  C.foo<E>() ...
}
var f = C.foo;  // <T, E>() => C<T>.foo<E>();

With that, I'd expect: Alias.new to have type Original<K, int> Function<K, E>(K, int, List<E>) and work like <K, E>(K $1, int $2, List<E> $3) => Original<K, int>.new<E>($1, $2, $3).
There is no reason to say that it should be the same as Original.new since Alias is not the same type (or type-function) as Original.

@Levi-Lesches
Copy link

There is no reason to say that it should be the same as Original.new since Alias is not the same type (or type-function) as Original.

I guess my intuition is that it's just an alias, so everything should be the same save for the name.

Is there precedent for combining generics? I feel that will be misleading, since it "hides" the true signature of Original, even though it's just an alias. One of the big selling points of aliases is that it's just a name replacement, but now migration becomes a bit harder if devs have to change more. Consider, instead of Original and Alias, a situation where someone renames a class from Bad to Good:

class Good<T> {
  Good.new<E>(T value, List<E> list);
}
typedef Bad<T> = Good<T>;

var beforeMigration = Bad.new;  // <T, E>(T, List<E>) => Good<T>
var afterMigration = Good.new;  // <E>(T, List<E>) => Good<dynamic>

Can we instead expect the class generics be separate from the constructor generics? Like so

var beforeMigration1 = Bad.new;               // <E>(dynamic, List<E>) => Good<dynamic>
var beforeMigration2 = Bad<int>.new;          // <E>(int, List<E>) => Good<int>
var beforeMigration3 = Bad.new<String>;       // (dynamic, List<String>) => Good<dynamic>
var beforeMigration4 = Bad<int>.new<String>;  // (int, List<String>) => Good<int>

var afterMigration1 = Good.new;               // <E>(dynamic, List<E>) => Good<dynamic>
var afterMigration2 = Good<int>.new;          // <E>(int, List<E>) => Good<int>
var afterMigration3 = Good.new<String>;       // (dynamic, List<String>) => Good<dynamic>
var afterMigration4 = Good<int>.new<String>;  // (int, List<String>) => Good<int>

This way, the only true difference between aliases is just the name.

@eernstg
Copy link
Member

eernstg commented Sep 3, 2021

@leafpetersen, I think we can close this issue: Proper renames have been specified, and implementations are working or being fixed via other issues.

@eernstg eernstg closed this as completed Sep 3, 2021
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