language icon indicating copy to clipboard operation
language copied to clipboard

Can generative constructors declared in static extensions be used as targets of redirecting 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 the target of a redirection. 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.

The motivation for this restriction is, I believe, largely based on the issue illustrated in this example:

class S<T> {
  T x;
  S(this.x);
  S.redirecting() : this.named();
}

extension on S<int> {
  S.named() : this(3);
}

The S.named constructor declared in an extension always returns a specific instance of S, which makes it unsafe/invalid to use as the target of redirection in the generic S.redirecting constructor.

This seems easily remedied by adding an text specifying that it is an error if the target of a redirecting constructor has a static type which is not a subtype of the type of the enclosing class. Is this sufficient? Are there other soundness issues lurking here?

There is now an inference problem to solve at the redirection point, but this seems likely to be easily dealt with in the same manner as with redirecting factory constructors.

cc @dart-lang/language-team

leafpetersen avatar Nov 05 '25 01:11 leafpetersen

Just an additional comment: allowing this does in fact mean that a generative constructor can return an instance of a more specific type than the type of the class through which it is invoked. Example:

class A<T> {
  A();
}

extension on A<num> {
   A.n1() : this.n2();
}

extension on A<int> {
  A.n2() : this();
}

Here, invoking A.n1() will produce something of static type A<num> but the instance will be an A<int>.

leafpetersen avatar Nov 05 '25 06:11 leafpetersen

I think the instance will be an A<num>. (Doesn't mean that there is no problem. There definitely is.)

If you do new A.n1(), the new operator allocates the object, not the constructor. The constructor just initializes the object. (If you had a class B extends A<num> { B(): super.n1(); }, new B() creates the B object, invoking the B() constructor to initialize it, which chains to A<num>.n1() to initialize the A-part of the object.)

Type inference will fill in the A<...>.n1() type parameter. That should be A<num>.n1(), since A<num> is the return type of the A.n1 constructor('s tear-off/function type).

The object created by A<num>.n1() should have the type A<num> (the on type that the constructor is called on, not necessarily the same as what is written, even if it is here. We allow covariance in the new operator invocation, so A<Object>.n1() should create an A<num> too.)

Then A<num>.n1() wants to forward the initialization of that A<num> to A.n2() of an extension with on type A<int>. The type of this of A.n2 is the instantiated on type. That's where the type error is, a generative constructor with an incorrect type for this, not matching the actual object being initialized. That can be turned into a soundness issue very easily, whether it is co- or contra-variant.

If that gets allowed, it forwards to A() of A<num>. I'll assume that the actual class constructor takes the type parameter from the object, where it's already stored, and not as an argument, so it see A<num>.

(Constructors can be seen as instance methods, they have a this and access to type variables, there are just parts of the constructor where they are not allow to access that this other than to initialize fields. Object creation creates an object, then calls a generative constructor on the object, which recursively calls super. constructors until reaching Object.new, and when that returns, the object is initialized and this can be used.)

If we swap the types around and have the code do something with values of those types:

class A<T>{
  A(T value);
}

extension E1 on A<int> {
   A.n1() : this.n2(42); // Invokes a constructor with known signature `A<Num> Function(num)`!
}

extension E2 on A<num> {
  A.n2(num n) : this(n * 3.14); // Invokes a constructor *assumed* to have signature `A<num> Function(num)`
}

then new A<Object>.n1() would create an A<int> object (the return type of E1.n1), and pass it to E1.n1(42) with a this type of A<int>. That would forward the A<int> to E2.n2(42) with a this/on type of A<num> (which is bad, m'kay!), then pass that to A.new(42 * 3.14), which A.n2 assumes has a T of num. Since A.new is run on an A<int>, a double argument is a type error.

(I don't know if a covariant parameter type check would be enough to handle that issue. I'm assuming not, but I'd prefer to just disallow E1.n1 forwarding to E2.n2 entirely.)

lrhn avatar Nov 05 '25 10:11 lrhn