language icon indicating copy to clipboard operation
language copied to clipboard

Can generative constructors declared in static extensions be used in super calls of other generative constructors?

Open leafpetersen opened this issue 1 month ago • 2 comments

The current spec allows static extensions to declare generative redirecting constructors, but forbids their use as a super constructor in other generative constructors. As discussed here, this means the only reason to use them is to allow for a const constructor. Moreover, replacing a normal constructor with one declared in an extension becomes more breaking than it otherwise need be.

This restriction is proposed because a generative redirecting constructor can implicitly pass more specific generic arguments to the super constructor call than what is expected from the class structure. Consider these examples:

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

class B extends A<int> {
  B() : super.named() {
    x.isEven;
  }
}

class C extends A<Object> {
  C() : super.named() {
    x = "hello";
  }
}

extension on A<num> {
  A.named() : this(3.5);
}

Both classes B and C extend A with type arguments that are different (more specific and less specific, respectively) than those actually provided to the call to the A() constructor in the redirection in the extension.

If we assume that those type arguments are respected, and that the instances created by calls to the B() or C() constructors are also instances of A<num> then we immediately encounter issues.

First, the B() constructor creates an object whose static type is known to be a subtype of A<int>, but whose runtime type is not compatible with this. The read of x in the body of the B constructor demonstrates this unsoundness, calling isEven on a double.

Second, the C() constructor creates an object where even within the scope of the instance members, where we would otherwise be guaranteed that we have a precise type for this, we have a static type for this which is more general than the runtime type. The write to this.x here must be guarded by a covariance check. While Dart already has support for runtime checked covariance, this change would invalidate a useful invariant that is often relied upon both by compilers and programmers (that the type for this is known to be exact).

In this scenario, it seems to me that the only reasonable approach to this would be to try to specify invariance here: that is, to specify that a super invocation must have exactly the type of the super class (for some definition of "exactly the type", e.g. mutual subtype). Assuming that redirection can never introduce subsumption (something that should be validated), this is likely sufficient? [Edit] This is not true, unless we separately enforce it. See this comment for example.

It is possible that there is an alternative approach in which the redirection is essentially "inlined" into the super invocation with type arguments provided exactly as given in the class header. Combined with a requirement that the static type of the super invocation be a subtype of the superclass type, this might be sufficient to guarantee coherence.

cc @dart-lang/language-team

leafpetersen avatar Nov 05 '25 02:11 leafpetersen

Maybe we can detect variance and make it a compile-time error. I don't think we can avoid unsoundness if we allow it. We may be able to detect it at runtime (like a covariant parameter type check), but that's more fragility than I'd prefer in a place where people don't usually consider that kind of problems. (And where they are likely to miss subtle details that are only visible in the extension declaration.)

So, as you say, require the on type of the extension to be exactly the type being created (or suitably equivalent), otherwise the super-invocation or generative redirection is not allowed.

For the example:

  • C should be a compile-time error. The super.named() call must be on A<Object>, and we cannot find an instantiation of the extension (because it's not generic, so we easily checked) that makes its on type be A<Object>. Being a subtype is not sufficient.
  • B should be a compile-time error. The super.named() call must be on A<int>, and we cannot find an instantiation of the extension that makes its on type be A<int>. Being a supertype is not sufficient.
  • A.named is fine, its on type is A<num>, so it will only ever be called to initialize an object which is exactly an A<num>. (If invoked directly as new A<Object>.named(), the new will create an A<num> and the constructor will initialize that, by forwarding to the object's own A.new constructor.)

Generics makes everything more ... interesting.

class A<T1, T2> {}

extension E1<X1 extends num, X2 extends X1> on A<X1, X2> {
  new named1() : this();
}

extension E2<Y1> on A<Y1, Y1> {
  new named2() : this();
}

class B extends A<int, int> {
  B.e1() : super.named1(); // OK, E1<int, int> has on type A<int, int>
  B.e2() : super.named2(); // OK, E2<int> has on type A<int, int>
}

class C<T> extends A<T, T> {
  // ERROR, T is not valid type argument to E1.
  // No valid type arguments will make the on type be A<T, T>.
  C.e1() : super.named1(); 
  C.e2() : super.named2(); // OK, E2<T> has on type A<T, T>
}

extension E3<X1 extends int, X2 extends X1> on A<X1, X2> {
  new named31() : this.named1(); // OK, E1<X1, X2> has on type A<X1, X2>.
  new named32() : this.named2(); // ERROR, no argument to E2 can give on type A<X1, X2> (for all X1, X2)
}

I think enforcing invariance in this/super-constructor calls will be enough to be sound. If the on type, which is also the this type, of the extension constructor is always the same as the instance being created, and the extension constructor can only forward to an actual constructor on the object's class, or another constructor with the same this/on type, then transitively they will all have the same type.

The question is whether we can efficiently solve for the type arguments needed for the extension in a way that gives an invariant solution.

(Is it a problem that we have no way to make an explicit extension constructor invocation in super-constructor invocations?)

Taking B.e2, we need to find a type for Y1 so that A<Y1, Y1> is A<int, int>. It sounds rather trivial when written like that. It's probably not that simple.

Go through the type structure of the uninstantiated on type (A<Y, Y>) and the class type of the superclass the constructor is called on (A<int, int>, superclass og B). As long as the structure is the same and the component types are recursively the same, it's OK. If they differ, and the on type is a type variable of the extension, then remember the other type as a type for that type variable. Otherwise the types don't match. At the end, if all the remembered types for a type variable are not the same type, the types don't match, otherwise that type is the type argument for that type parameter.

That means the types must have the same structure. We could say that type sub-clauses that do not contain a type variable just need to describe mutual subtypes. Then an extension on Map<String, Object?> constructor can be used to create a Map<String, dynamic>. That's probably preferable.

We can't just do that for types containing type variables until we have fund the binding, and we need to traverse the structures in lockstep to find the "same place" in the other type. So we probably require same structure above any type variable. (Any better ideas? Is this a generalization of the subtype algorithm, where a set of designated type variables are unbound and only used to record constraints, and since we want subtype in both directions, the constraints must be satisfied, and satisfiable, invariantly. And if we get Object? <: Y <: dynamic, we'll need a way to pick one.)

lrhn avatar Nov 05 '25 10:11 lrhn

I don't think inlining/short-circuiting redirecting constructors will work. I think it may make sense for the initial Something.name() invocation, but those can be covariant anyway.

For a constructor called as a super-constructor, the object to initialize already exists.

Also, bypassing a declaration is not a good idea. The code that calls the constructor is defined relative to the public API of the constructor it calls, which means its function type and whether it's const or not, and factory or generative. Whether that call is valid should only depend on the public API. Which is why we should make it an error when a use may not match that public API.

lrhn avatar Nov 05 '25 12:11 lrhn