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

Generic and conditional constructors #4213

Open
eernstg opened this issue Dec 20, 2024 · 4 comments
Open

Generic and conditional constructors #4213

eernstg opened this issue Dec 20, 2024 · 4 comments
Labels
feature Proposed language feature that solves one or more problems

Comments

@eernstg
Copy link
Member

eernstg commented Dec 20, 2024

This is a proposal about a reinterpretation of constructors in Dart that allows them to have higher expressive power, similar to the additional power of constructors in the proposal about conditional instance members and constructors.

First note that the proposal has no effect on constructors of non-generic classes (and mixin classes, enums, extension types, etc, but I'll just say 'class' here for brevity).

In contrast, the interpretation of constructors of generic classes changes. The core idea is that a constructor does not have access to the type parameters of the enclosing class. Instead, the current notation is just an abbreviated form. When the underlying (non-abbreviated, full) form is used, every constructor must declare all the type parameters they need.

class A<X1 extends B1 .. Xk extends Bk> ... {
  // Current notation, using an arbitrary example signature.
  A(X1 x, Map<X2, X3> map) {
    .../*May use X1 .. Xk*/...
  }

  // Full notation.
  A<Y1 .. Yk>.new<Y1 extends BB1 .. Yk extends BBk>(Y1 x, Map<Y2, Y3> map) {
    .../*May use Y1 .. Yk*/...
  }
}

In the full notation, the name of the constructor must be of the form <typeIdentifier> '.' <identifierOrNew>. We can use this kind of name without changing the meaning of the declaration by using .new when the original constructor name was of the form <typeIdentifier>.

In the full notation, in this case where it corresponds to the current form, the constructor declares the same type parameters with the same bounds as the class, up to alpha equivalence (that is, we can freely rename the type parameters as long as it is a consistent renaming). I've used new names in the example above in order to emphasize that the type parameters X1 .. Xk are not available in the constructor which uses the full notation, but the new type parameters play exactly the same role as the type parameters of the class do in the current notation. When it is result of this kind of desugaring, the full notation has the same semantics as the current notation.

However, the full notation allows for more expressive power, because it is possible to specify that a given constructor can only create some generic instantiations of the given class. For example, we might have a very strictly confined constructor that only supports a fixed type:

class A1<X> {
  final X x;
  A1(this.x);
  A1<int>.name([int this.x = 0]);
}

void main() {
  A1<num> a = A1(10); // This will be an `A1<num>`.
  a = A1.name(); // This will be an `A1<int>`.
  A1<String> aa = A1.name(); // Compile-time error.
}

The constructor A1.name can make the parameter optional, because it's guaranteed that it will never create an instance of A1<T> for any other T than int. This is safer than having a constructor that tries to use an int as a default value for x, but actually can't statically make it an error to try to create an A1<String>.

We could also have a constructor that imposes an extra constraint on a type argument:

class A2<X, Y> {
  final X x;
  final Y y;
  A2(this.x, this.y);
  A2<X, List<X>>.name<X>(X x): this(x, [x]);
}

void main() {
  A2.name(1); // Has type `A2<int, List<int>>`.
}

This means that we can use the constructor A2.name to conveniently create a special kind of A2 object where the y is a list that contains the x (initially, at least), and we can declare the constructor such that this relationship between the type parameters and the instance variable values is enforced by declaring it explicitly.

A typical example in the same vein would be a class where a constructor argument can be made optional in the case where the type arguments provide a guarantee:

class A3<X> {
  final X x;
  final int Function(X, X) _compare;
  A3(this.x, this._compare);
  A3<X>.name<X extends Comparable<X>>(X x): this(x, (x1, x2) => x1.compareTo(x2));
}

void main() {
  A3.name(1); // OK, creates an `A3<num>` because `num <: Comparable<num>`.
  A3.name<num>(1); // It is also OK to pass the type arguments explicitly.
}

For the static analysis, it is an error if it does not follow from the declarations of type parameters of a constructor that the resulting type arguments of the class will satisfy the bounds. For example:

class A4<X extends num> {
  A4<Y>.name1<Y extends int>(); // OK, `Y <: num` is guaranteed.
  A4<Z>.name2<Z extends Comparable<Z>>(); // Error: Can't assume that `Z <: num`.
}

The mechanism would be applicable to factory constructors as well as generative constructors, and it would be applicable to constructors that are redirecting as well as the ones that aren't.

Type inference on invocations of generic constructors would proceed as if they were generic static methods.

class A5<X> {
  A5<Y>.name1<Y extends int>();
  A5<Z>.name2<Z extends Comparable<Z>>();

  // Inferred as if they were
  static A5<Y> name1<Y extends int>() => throw "Not relevant";
  static A5<Z> name2<Z extends Comparable<Z>>() => throw "Not relevant";
}

There is one question where the answer isn't obvious: Should it be allowed at the call site to provide the type arguments to the class, such that the invocation is similar to current constructor invocations?

class A6<X> {
  A6<Y>.new<Y extends String>();  // Restricted.
  A6.name(); // We can actually create an `A6<S>` for any `S`.
}

void main() {
  A6<Object>(); // Creates an `A6<String>`. Too weird! Error?
}

I'd recommend that we are very careful when it comes to constructor invocations where the type arguments are passed to the class (as in the constructor invocations that we have today). In particular, it seems highly confusing that a generative constructor named A6 would work in such a way that A6<Object>() evaluates to an instance of A6<String>.

We could simply make it an error for an invocation of a generic constructor to receive the type arguments at the class (which is treated as a context type for an invocation of the corresponding static method). Alternatively, we could recommend that the analyzer emits a warning. The developer could then choose to make it A6<String>() in order to get rid of the warning.

In any case, these considerations are relevant for explicitly provide type arguments, not for inferred ones.

For all those cases where no extra expressive power is needed, the current notation is available, with the same semantics. In particular, they will definitely continue to support the provision of type arguments to the class. We could (and probably should) treat constructors declared with the full notation the same if they are the result of a desugaring of the current notation (that is: declares same type parameters as the class, up to alpha equivalence, and passes all of them, and nothing else, to the class, in the same order).

@dart-lang/language-team, WDYT? This would allow us to obtain the expressive power that we've had in some proposals about static extensions, but using a mechanism that works in regular classes as well as static extensions (and perhaps even more places).

@eernstg eernstg added the feature Proposed language feature that solves one or more problems label Dec 20, 2024
@gryznar
Copy link

gryznar commented Dec 20, 2024

I like this idea :) This can go even further in the future with support for conditional conformance for methods. In such case is this syntax flecmxible enough to support that without need to reinvent that?

@lrhn
Copy link
Member

lrhn commented Dec 21, 2024

(Summary derived at after having written the below: I think this can work. I just want more, but I think it'll be compatible.)

I like the idea, and I'm pretty sure I've suggested something similar before, members that only exist in some instantiations, and constructors with limited return types. (Found them: #1899, #3181).

I do find the syntax a little repetitive and unintuitive.

So, again, I'll suggest "type patterns". Rather than having to specify type variables in one place, just to be able to infer them and use them in another place, you can specify a type variable inside something that today has to be a type term, and thereby achieving type destructuring.

I've suggested

Llist<final T> value = expr;

as a syntax, but it doesn't work as well as I'd like. So my new suggestion is to let an unprefixed <X> be a type pattern inside a decision pattern, matching any type and binding it to X.
The tour can be constrained as <X extends T>

That would make it List<<X>> value = ….

With that in place, we can use type patterns fx as the types of extensions

extension Foo on List<<X>> {
  X get center => this[length ~/ 2];
}

That's not much different from today, but it suggests what to do with limited constructors and members: a pattern on the static type/this:

class A2<X, Y> {
  final X x;
  final Y y;
  A2(this.x, this.y);
  A2<<X>, List<X>>.name(X x): this(x, [x]);

  S get A<<S extends String>, <_>>.stringName => x;
}

void main() {
  A2.name(1); // Has type `A2<int, List<int>>`.
  A2.("Foo", 42).stringName;
}

These work as both static type matchers, only allowing you to invoke the constructor or member of the receiver type matchers the type pattern, and as your destructors for type parameters, maybe function type parts, extracting the actual runtime type parameter's type as the runtime binding of the introduced your variable.

(It also works in actual patterns, only for type arguments, case <<E extends num>>[var first, ..., var last]]: matches a list of numbers with two or more elements, and extracts the type argument from the list object.
(It's existential open, basically.)

I do want type patterns, and if we get those, they would fit in will with this feature, so I'd be wary of introducing a second and incompatible syntax for the same thing.

However, it might not be incompatible.
A type pattern can contain literals too, List<<X extends num>> and List<num> are both patterns that match the same (sub-)types, one just has a binding, just like num x and num() work for values.

If you can put a type pattern before methods and constructors, the pattern can refer to type variables introduces by the method or (generic) constructor itself, as type values. That, plus generic corticoid, gives this proposal, and then we can extend that with your patterns latter, for an inline/pattern-based variable declaration rather than a separate variable declaration.

(The same way I want to allow (int x = 42) as an inline expression, rather than having to declare a variable separately as a statement.)

@eernstg
Copy link
Member Author

eernstg commented Dec 27, 2024

@lrhn wrote:

(Found them: #1899, #3181).

Right, and we also have #2313, and the basic idea has been brought up in the language team several years earlier.

I'll suggest "type patterns".

The type patterns proposal in #170 is quite broad, it is basically an outline of a whole family of mechanisms with something that relates to the phrase type patterns at the core.

However, type patterns won't quite fit as a drop-in replacement of regular type parameters.

First, all of the type pattern mechanisms that I can recall included the property that the pattern-declared type parameters couldn't be passed explicitly. For example, with an existential-open mechanism based on type patterns, we'd introduce the type parameter in the right-hand operand of a type test, and it would be bound to a value as part of the subtype check:

void main() {
  List<num> xs = <int>[1]; // We forgot the precise type argument.
  num n = 1; // Precise type forgotten.

  // Existential open: Allow us to re-discover the types.
  if (xs is Iterable<final T>) { // Yields true and binds `T` to `int`.
    if (n is T) xs.add(n); // Safe.
  }
}

In this case it certainly doesn't make sense to pass the value of T explicitly anywhere because the whole point is that T is looked up by inspecting the run-time type of xs.

Another example where type patterns came up is this comment in this issue. Here is a declaration that embodies the core of that issue:

// The type `dynamic` comes from the original example, shouldn't matter here.
class BlocBuilder<TBloc extends Bloc<dynamic, var TState>> {...}

In this case the point is that we declare BlocBuilder as a generic class with a single type parameter, but then we use a type pattern (written as var TState in this case) in order to decompose the given actual type argument such that we can use TState in the body of the class. We're basically given the power to take the given type TBloc apart, and refer to the parts by name.

It is again crucial that TState is computed (based on static information for a poor man's version, or on the run-time value of TBloc for a more powerful mechanism). The whole point is that we don't want to pass TState as a separate type argument at call sites, we just want to pass TBloc and then get TState for free.

This differs from just declaring the type parameter separately:

class BlocBuilder<TBloc extends Bloc<dynamic, TState>, TState> {...}

We can do this today, and with the new 'inference-using-bounds' feature in Dart 3.7.0 we can also get a good and tight inferred value for TState (based on compile-time information only), but this means that we must infer all the type arguments. In other words, we can get TState for free if we can get all type arguments for free, but if we must pass an actual type argument for TBloc then we must also pass an actual type argument for TState.

So that's my first point: Type patterns aren't a good match for this task because they are inherently intended to be computed from other type arguments, they can't be passed explicitly.

In particular, we can't do simple things like this:

class A<X, Y> {
  A<<X>, <Y>>(...); // Declare two type parameters using patterns.
  ...
}

void main() {
  A<int, String>(); // Error!
  A.new<int, String>(); // Error!
}

The errors arise because the constructor doesn't accept any actual type arguments. This means that generic constructors can't be invoked with explicit type arguments, unless we somehow introduce some normal type parameters as well. Even with normal type parameters as well, we can't pass the pattern-declared ones explicitly, unless we come up with some mechanism to do so (perhaps those type parameters could be optional and named?).

This implies that we certainly don't have a straightforward way to re-interpret all constructor declarations as generic constructors. Even the simplest cases where we "don't do anything" with the type parameters can't be expressed using type patterns, we'd have to support a way to introduce regular type parameters as well. They could be the type parameters of the class, but in that case we can't express the case where the constructor takes additional type parameters (like Z below).

The next difficulty is that we can't control the ordering of type parameters that are introduced by type patterns, and we can't express type parameters that aren't used in other types.

class A<X, Y> {
  // Using this proposal.
  A<X, Y>.new<Y, X extends Y, Z>(Z z);

  // Using type patterns.
  A<<X extends Y>, <Y>>(Z z); // `Z` is undefined?!
}

Even if we assume that we can pass those type parameters explicitly, we don't have a way to specify any particular ordering. We could have a convention that says "the pattern-declared type parameters are passed in declaration order". We would then pass X first, followed by Y, because <X extends Y> occurs before <Y> in the declaration. But we might need to use the opposite order (say, because we must be compatible with an earlier version of the same library), and we have no way to do that.

Next, pattern-declared type parameters are bound by decomposing other type arguments, which is different from finding actual type arguments by inference. In particular, we must choose a particular direction for the dependencies.

class A<X, Y> {
  // Using this proposal.
  A<X, Y>.new<X, Y, Z>(Z z, List<Z> zs);
}

void main() {
  A<String, double> a = A(1, <num>[]); // X:String, Y:double, Z:num.
  a = A(1, []); // X:String, Y:double, Z:int, `[]` is `<int>[]`.
}

Type inference binds Z to num in the first case because we derive the constraints that Z must be a supertype of int and a supertype of num. In the second case Z is bound to int and [] is inferred as <int>[]. This illustrates that information can flow in multiple directions: From the list, and from the number. It could also flow down from the context type (like the values of X and Y).

For a type pattern based approach, we could try to introduce a notion of pattern-declared type parameters in the value parameter types:

class A<X, Y> {
  // Using type  patterns? Is this readable.
  A<<X>, <Y>>.name1(<Z> z, List<Z> zs);
  A<<X>, <Y>>.name2(Z z, List<<Z>> zs);
}

void main() {
  A<String, double> a = A.name2(1, <num>[]); // X:String, Y:double, Z:num.
  a = A.name1(1, []); // X:String, Y:double, Z:int, `[]` is `<int>[]`.
}

With <Z>, do we bind Z to the run-time type of the first value argument z, or the static type? Similarly for List<<Z>>.

Note that we must call A.name2 in the first case and A.name1 in the second case, because each of them will only support the flow of information in a single direction. Again, type patterns are different than regular type parameters. More powerful in some ways (especially if based on run-time types), less powerful in other ways.

All in all, I think type patterns are very powerful and I expect them to be very useful in many situations. But I don't think they'd work very well as drop-in replacements for regular type parameters.

In fact, I think we should have generic constructors (a la this proposal, or whatever) using perfectly normal type parameters, and we should also have some of the features where type patterns are basically the perfect way to proceed (existential open, decomposition of structured types).

I view this proposal as a way to explain what constructors of generic classes are doing, generalizing it to handle type arguments of the constructor itself as well as type arguments of the class: They are essentially just a different notation for generic static methods. This makes the current proposal very simple (and low risk), and it does support the extra expressive power that led to the use of words like 'conditional' and 'generic' together.

@lrhn
Copy link
Member

lrhn commented Dec 27, 2024

It is again crucial that TState is computed (based on static information for a poor man's version, or on the run-time value of TBloc for a more powerful mechanism). The whole point is that we don't want to pass TState as a separate type argument at call sites, we just want to pass TBloc and then get TState for free.

That makes it a question of what the pattern destructures. There are actually two kinds of destructurings: Runtime type destructuring and type argument destructuring.

It means that:

void withBloc<T extends Bloc<var X>>(T value) { ... }

and

void withBlock(Block<var X> value) { ... }

behave differently.
The former destructures the type argument, so if you do withBlock<MyBlock<Super>>(MyBlock<Sub>());, the X will be bound to Super.
The latter destructures the runtime type, so it does existential open. If you do withBlock(MyBlock<Sub>()), X will be bound to Sub, and you can't explicitly bind it to something else.

I think both type destructurings are useful, with the former likely being slightly easier to implement than the latter. It doesn't require the same kind of existential open - not on runtime types, only on type argument types, which usually means that it's a statically known type. Or at least a type that could have been denoted at the call point.

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