language icon indicating copy to clipboard operation
language copied to clipboard

How do primary constructors and augmentations interact?

Open munificent opened this issue 1 month ago • 14 comments

We need to figure out all of the various ways that augmentations and primary constructors can play nicely or poorly together:

  1. Does an augmentation of a class with a primary constructor have to repeat the primary constructor in its header? If yes, it seems like it's a redundant member declaration, which doesn't make much sense. We don't require you to repeat any other members and in fact explicitly forbid it. If no, then the primary constructor parameters are in lexical scope in the augmentation's class body but their declarations are not textually visible. With type parameters, we specifically require the augmentation to repeat them to avoid that syntactic confusion.

  2. I'm assuming in 1 that the primary constructor parameters are in scope inside the body of the augmentation. But that's a choice too. Should they be?

  3. Can an augmentation augment a primary constructor with a body by writing a generative constructor declaration with the same name? Conversely, can an augmentation augment a generative constructor with a ; in the introductory declaration by having a primary constructor with the same name in the augmentation?

  4. Can an augmentation of a class with a primary constructor add a this block if the introductory declaration doesn't have one? Or if it does?

  5. What else am I not thinking of?

I haven't thought about this much, but here's one proposal:

We treat primary constructors including the scoping of their parameters as syntactic sugar effectively applied before augmentation. So a primary constructor is treated like an in-body constructor declaration. Any instance field initialized at its declaration that refers to a primary constructor parameter is treated like an explicit constructor initializer. Then augmentations apply to the result of that.

So, if you write:

class C(final int x, int y) {
  int z = y;
  this : assert(x != y);
}

As far as augmentation is concerned, it's as if you wrote:

class C {
  final int x;
  int z;
  new (this.x, int y) : z = y, assert(x != y);
}

And then an augmentation can modify that according to the existing rules.

munificent avatar Nov 11 '25 00:11 munificent

EDIT: I thought initializing formals were handled during biding-actuals-to-formals, but they aren't today, so what I suggest is actually exactly what we do today, just allowing instance variable initializers to be spread over multiple fragments.

We treat primary constructors including the scoping of their parameters as syntactic sugar effectively applied before augmentation. So a primary constructor is treated like an in-body constructor declaration

Except that we don't have in-body declaring constructors (any more), so it cannot be an in-body constructor declaration.

The questions I’d first want to answer are …

When is a primary declaration an implementing declaration, and when is it only a signature declaration?

An initializing constructor declaration is definitely implementing if it has a super parameter, initializing formal, initializer list entry or block body. For a primary constructor, it’s also implementing if it has a a field parameter.

Having a this; does not make it an implementation, that’s just a hook to hang metadata on, it’s an (implicit or explicit) part of a signature declaration too.

(That field parameter is also implementing for the instance variable it declares. If it is not introducing, should the parameter have an augment modifier? It probably should.)

What about instance variable initializer expressions which refer to constructor parameters?

Two options:

  • You can only have such initializers in the class declaration containing an implementing constructor declaration. (Since we don’t mark implementing declarations, that means having an instance variable initializer that refers to primary constructor parameters makes the constructor implementing.) This is consistent with viewing such initializer expressions as desugared into initializer list entries, and initializer list entries imply an implementing declaration. Or …

  • You can have such initializers in any class with a primary constructor signature. That has a number of consequences:

    • The eventual constructor implementation must be a primary constructor. No implementing a primary signature with a non-primary implementation. (Or maybe …!)
    • The parameter reference doesn’t know if the parameter variable is final or not. That usually only matters to implementation, so it’s not part of the signature. Also suggesting that there is something off about this approach.

I still think we want to use the second approach. Examples:

class V(int x, int y);

augment class V(x, y) {
  final double magnitude = sqrt(x * x + y * y);
}

We should allow this class in either case. It may imply that the class V(x, y) is the implementing constructor.

And then we should also allow:

class V(int x, int y);

augment class V(x, y) {
  final double arg = atan2(y, x);
}

It may then confuse users if we don’t allow both augmentations on the same class.

The field declarations looks like they are independent, and independent of the constructor, just like other field declarations.

class V(final int x, final int y) {
  static int _idCounter = 0;
}

augment class V(x, y) {
  final _id = ++_idCounter;
}

augment class V(x, y) {
  final double magnitude = sqrt(x * x + y * y);
}

A user would likely be surprised that the first augmentation is valid, and the second gets disallowed for having a field initializer that refers to the parameters that are clearly in scope.

I really think allowing instance variable initializer expressions to refer to primary parameters in any class is valuable, let’s call it “initializers everywhere”. But it comes with consequences.

Are primary constructor signatures special, or are they just signatures?

Can you do:

class P(int x, int y); // An initializing generative constructor signature, with no implementation.

augment class P {
  final int x, y;
  augment P(this.x, this.y);
}

That is, does it matter that a signature is given as a primary constructor, or does it only matter for the implementing declaration?

With “initializers everywhere” it matters. Looking at the introducing declaration, someone would believe they can write;

augment class P(int x , int y) {
  final double arg = atan2(y, x);
}

So we shouldn’t allow implementing a primary constructor signature with a non-primary implementation. (We could, but whether someone uses a constructor parameter in a field initializer anywhere in any class declaration determines whether you can have a second initializing constructor. That’s fragile.)

Can you do:

class P {}
augment class P(final int x, final int y) {
  final double arg = atan2(y, x);
}

In this case, looking at the introducing declaration of P, it’s still clear that you cannot declare another initializing constructor. That’s not necessarily an issue. Whether you can introduce a new declaration always depends on all existing declarations that may conflict, if nothing else than by name. And in this case, it’s enough to look at the introducing declaration of the P.new constructor. We may want to allow this.

Can you do:

class P {
  P(int x, int y);
  double get arg;
}
augment class P(final int x, final int y) {
  augment final double arg = atan2(y, x);
}

That is, augment an indetermined in-body generative constructor signature with a primary constructor implementation? Seems reasonable. Can other augmenting classes use P as primary? Probably not! They should look to the introducing declaration for the constructor to see whether it’s primary.

Proposal

  • A constructor’s signature is a primary constructor if its introducing declaration is a primary constructor declaration.

  • It’s a compile-time error to augment a primary constructor signature with a non-primary constructor declaration.

  • It’s a compile-time error to augment a non-primary constructor signature with a non-implementing primary constructor declaration.

    • An implementing primary constructor may augment a non-primary constructor signature. That class declaration, and only that, can refer to constructor parameters in instance variable initializers. This does not change that the constructor’s signature is non-primary for all other augmenting declarations of the class.
    • Since we don't mark implementing constructors, being an augmenting primary constructor for a non-primary constructor signature implies that the constructor is implementing, even if it shows no other signs of that.
  • Instance variable initializer expressions can only refer to constructor parameters if the class header contains a primary constructor signature. The constructor parameter variables in scope for such initializer expressions are all unassignable, as if they were final variables. Only the implementing declaration can declare a parameter variable to be mutable, and other augmenting classes don’t, and shouldn’t, know. We could allow the implementing declaration’s class declaration to know whether variables are assignable or not, but we don’t mark implementing declarations, so instead we’d make an assignment to a constructor parameter imply that that class declaration’s constructor is implementing. That may be a little too surprising.

    Example:

    class C(int x, int y);
    augment class C(int x, int y) {
      final int xBefore = x++; // VALID ALONE, INVALID TOGETHER
    }
    augment class C(int x, int y) {
      final int yBefore = y++; // VALID ALONE, INVALID TOGETHER
    }
    

    The x++ changes, so assigns to, a parameter, making that class’s primary constructor be implementing. So does y++, so you have two implementations. I think that’s too indirect to be understandable. Instead all references in initializing expressions should be immutable. That also makes it less important which order they’re executed in. I think mutating constructor parameters in the initializer list is rare. And then you can still do it in the initializer list.

  • If after applying all augmentations, no definite constructor implementation declaration has been found, the introducing declaration is defined to be implementing. Which means it gets an implicit : super(); super-constructor invocation on its implicit or explicit this, just like an indefinite in-body constructor would get a : super(); initializer list.

  • Executing an initializing constructor to initialize an object behaves as follows:

    • Bind actuals to formals. (Finds default values for missing arguments and builds scopes, does not execut initializing formals yet.)

    • Create instance variable initialization scope (same as existing initializer list scope, except that all variables are considered final).

    • Evaluate every instance variable initializer in the class’s declarations in source order, and initialize the variable to the result.

      • If the expression is inside a class declaration with no primary constructor declaration, use the class scope.
      • If the expression is inside a class declaration with a primary constructor declaration, use the instance variable initializer scope.
    • Execute initializing formals in source order.

      • This order is needed to not be (potentially) breaking. An initializing formal can overwrite the value of an instance variable initializer expression, but that expression could have side effects, so it should be run. Alternatively, do apply initializing formals during binding-actuals-to-formals, then don’t assign the value of an initializer expression to its variable if the variable is already initialized. Or (personal preference, but technically breaking): Don’t evaluate the initializer expressions of any variable which is initialized by the constructor. The value will be overwritten anyway, and it really shouldn’t have side effects.
    • Create initializer list scope. Like today, plain constructor parameters are mutable.

    • Execute initializer list in the initializer list scope.

    • … and then the super-call as normal, and come back to execute the body in the parameter scope …

  • Modify the primary constructor feature to make constructor parameter variables unassignable in instance variable initializer expressions, in preparation for this. But also because it makes evaluation order less important. Could be annoying if it prevents something likeclass Countdown(int count) { final _elements = [for (;count > 0;) —count]; }, but probably worth it.

With that, my answers are:

  1. Optional. If you repeat the constructor signature, it must agree on const, name and parameter signature. It can omit parameter types as normal. (If we allow omitting positional names by using _, then that’s OK here too, that name will just not be in scope then). You don't need a second augment if you only have the header signature, the one on the class is enough.
  2. If you don't write the signature, parameter variables are not in scope. Matters if you try to use them, which you only can in a constructor implementation or an instance variable initializer. An implementation without access to parameters is unlikely - unless there are no parameters. And it matters if you want an instance variable initializer expression to refer to something of the same name outside of the constructor. It can, if the parameters are not in scope.
  3. No. You augment a primary constructor's body section by writing augment this. If you follow it with an initializer list or body, this is the one implementing declaration. If you don't, it can only hold metadata or comments. It does not require you to repeat the header signature, but it cannot refer to parameters if you don't.
  4. Yes, subject to "only one implementing declaration". (Does the this need an augment if it's the first this block, but not the introductory constructor declaration? I'd say "yes", you are augmenting the constructor, which has been introduced.)
  5. See above, and below.

Examples:

int x = 0, y = 0; // Most recent V coordinates.
void _setXY(int newX, int newY) { // Allows setting even when shadowed.
  x = newX;
  y = newY;
}
class V;
augment class V(int x, int y); // Constructor introduction, is primary.
augment class V {
  /// Creates a new `V` with the given coordinates.
  augment this; // Augment in-body part with comment. Use `augment` since *constructor* exits.
}
augment class V {
  static int _prevId = 0;
  final int id = ++_prevId;
}
augment class V(int x, int y) {
  final double arg = atan2(y, x); // Reference to in-scope parameters.
}
augment class V { // No parameters declared or in scope.
  // Declaration is valid without a primary constructor, doesn't refer to parameters.
  final ({int x, int y}) prevCoordiantes = (x: x, y: y); // References top-level variables.
}
augment class V(int x, int y) { // Constructor implementation.
  final ({int x, int y}) coordinates = (x: x, y: y);
  augment this {
    _setXY(x, y); // Really convoluted, I know!
  }
}
augment class V(x, y) { // Can omit types.
  final double magnitude = sqrt(y * y + x * x); // Reference to in-scope parameters.
}
class P {
  int get x;
  int get y;
}
augment class P(augment final int x, int y) { // `augment` required for same reason as for `y`.
  augment final int y = y; // `augment` required because augmenting (and implementing) `int get y` above.
}

More questions:

Can you write this; without a primary constructor in the same class declaration?

class Foo {
  /// Creates a foo.
  this;
}
augment Foo(final int id);

Probably not. If you have a this before (in augmentation order) any primary constructor declarations, then it is introducing a primary constructor, but doesn't have a constructor signature. That's not valid.

After an introducing declaration, it needs to be augment this;, and there is no ambiguity about what that means.

Can a class declaration have an instance variable initializer expression that refers to a constructor parameter before the introductory declaration of the constructor?

No! Because it needs to have a primary constructor header to refer to a variable, and then it would be the introducing declaration for that constructor. (Phew!)

Is source order important?

As usual, an introducing declaration must be first.

Instance variable initializer expressions are executed in source order. They always were, but they still are. They can now refer to constructor parameters. But they cannot modify those parameters' variables, and they are executed before the initializer list which can modify variables.

What if instance variable initializers capture variables.

The variables of the instance variable initializer scope and the initalizer list scope are still the same variables. It's bascially the same scope today, it's just that instance variable initializers are not allowed to assign to the variable. (Could probably be defined in a different way, as using the same scope plus "not being allowed to assign to constructor parameters".)

lrhn avatar Nov 11 '25 08:11 lrhn

This is all well-though-out and expressive. But I gotta say, it's a lot of complexity. That comment is about 1/3 of the length of the entire augmentations proposal. I don't think the corner case of these two features interacting warrants that much mechanism. I do generally agree with the spirit of maximum expressiveness and compositionality, but sometimes the cost is too high.

When it comes to primary constructors and augmentations, what actually matters? Consider the simplest possible proposal:

Proposal 0: A class with a primary constructor can't be augmented, and an augmentation can't define a primary constructor.

Simple to specify, simple to implement, simple for users to understand. Why is this proposal not sufficient?

The main use case I can imagine is that users will want to use primary constructors on their classes because they are so nice and terse, but will also want to use code generation to fill in some stuff on the class. It would be a drag to tell users that if they use any code gen, they can't use primary constructors at all.

I don't see it being a particularly compelling use case that augmentations should be able to use primary constructors. Most augmentations will be code generated anyway, and a robot doesn't care whether you ask it to output syntactic sugar or something more explicit.

Given that, how about:

Proposal 1: Only an introductory declaration of a class may have a primary constructor.

  • Primary constructor parameters are only in scope in the introductory declaration where the primary constructor appears. Augmentations defining or implementing constructors must always use in-body forms. This sidesteps all of the questions around whether primary constructor parameters are in scope or not, have to be redeclared, are immutable, etc.

  • We extend the notion of incomplete to say that a constructor is also complete if it has any declaring parameters or any of a primary constructor's parameter's are used in field declaration initializers. Any of these uses will only ever appear in the introductory declaration, so it's a fixed property of the introductory declaration alone as to whether the primary constructor is complete or not.

    I considered saying that a primary constructor is always complete so that users don't have to reason about the brittleness of whether any of the parameters are used by field initializers. We could do that, but I think it's reasonable to want to use a "pure signature" primary constructor as a nice way to define a data-like/serialized/proxy/whatever class whose body (including a constructor) is filled in by codegen:

    @data
    class Point(int x, int y);
    

    Supporting incomplete primary constructors enables that. But the brittleness is confined to the introductory declaration.

  • An augmentation can augment a primary constructor using an in-body augmenting constructor declaration with the same name:

    class C(int x, int y);
    
    augment class C {
      new(x, y) { print('hi!'); }
    }
    

    All of the usual restrictions apply around matching const-ness, parameter lists, etc. From the augmentation's point of view, it's as if the introductory declaration's constructor was a regular in-body constructor.

That seems pretty simple and workable to me. All you give up is the ability to use one specific flavor of syntactic sugar inside code which is most likely generated anyway. Can we live with that?

munificent avatar Nov 17 '25 23:11 munificent

I still think we want to use the second approach. Examples:

class V(int x, int y);

augment class V(x, y) { // line 3
  final double magnitude = sqrt(x * x + y * y);
}

I'd say line 3 should be an error. I guess the constructor declaration in the augmentation is to add a constructor (see example 1 below). In the code above, the class is adding a constructor and the augmentation is adding another constructor. Duplicating the constructor declaration in the class and in the augmentation only adds boilerplate, in my opinion.

Example 1:

class C;
augment class C(var int x, var int y) {}

main() {
  final c = C(1, 2);
  print(c.y); // 2
}

If the augmentation can add more parameters or default values, then it looks useful. Does interpreting the example 2 code below as "class Point has a constructor Point Function(int, int); augment class Point adds 1 parameter int z with a default value; the final constructor is Point Function(int, int, [int])." make sense?

Example 2:

class Point(var int x, var int y);
augment class Point([var int z = 0]);

main() {
  final point = Point(1, 2);
  print(point.z); // 0
}

Wdestroier avatar Nov 18 '25 12:11 Wdestroier

The "Proposal 1" could work.

Quick question: Can I do:

class C(int x, int y) {}
augment class C {
  final int x, y;
  new(this.x, this.y);
  new.swapped(this.y, this.x);
}

That is: Can I have multiple initializing constructors if the class doesn't have any implementing primary constructor declaration?

My suggestion: YES. Whether you can have multiple initializing constructors is decided by whether you have a primary constructor implementation.

Long text below, but it comes down to which use-cases we want to support, and what they are intended to mean.

  • Do we want users to use primary constructor syntax to specify a constructor signature, which will then be filled in by code generation? (Probably. Some argue that "primary constructor" should be the first, and sometimes only, constructor people need to learn.)
  • Do we want that to lock the class into only having one initializing constructor? (Probably not, because if it does, you will sometimes have to not write a primary constructor singature, and write a completely equivalent in-body constructor signature instead, because your code generator asks you to not block it from adding more constructors. That's a bad experience.)

The design basically has two modes:

  • Incomplete: The primary constructor doesn't do anything that forces it to be complete. It's a constructor signature written in the header (for documentation purposes), but it's not actually a primary constructor.
  • Complete: It does initialization, using parameters, which means that it is both the introducing and the implementing declaration for that constructor.

In the latter case, the class clearly has a primary constructor implementation, so it cannot have any other initializing constructors. In the former case, the class does not have a primary constructor, it only has a signature which won't be implemented using a primary constructor anyway, so nothing should prevent if from having another initializing constructor too.

Well, other than that if there is no augmenting implementing declaration for the constructor, then the declaring constructor was a Schrödinger's implementation all along.

We could add;

  • If a class/mixin/enum has any primary constructor declaration in any of its declarations, whether the primary constructor complete or incomplete, it's a compile-time error for the class to also declare another initializing constructor.

It needs to be said, either that, or that it is allowed. Making a primary constructor signature count the same as an implementation feels like a foot-gun.

Let's take another step back.

The rules that you cannot have another initializing constructor if you have a primary constructor ... are mainly rooted in syntax.

Nothing technical prevents you from having another initializing constructor, as long as it initializes all the fields, and it's defined to not evaluate the field initializer expressions that depend on primary constructor parameters. Which is fair, they're really initializer list entires of the primary constructor that are written in a different place.

Having field initializers that look like they should be evaluated, but aren't, doesn't work well syntactically in the same class declaration, so we disallow it.

If we have more than one class declaration, using augmentations, nothing technical prevents us from allowing another augmenting class declaration from declaring an initializing constructor which initializes every instance variable in the class. It satisfies the actual signature of the class, and doesn't care how the other augmenting declarations are written. And then, nothing should prevent allowing each augmenting class declaration to have its own primary constructor declaration. As long as they all initialize all instance variables that need initialization.

(Except one sad issue: We can't tell whether instance variable initializers that are not referring to constructor parameters should be real initializers or shorthand initializer list entries. Shucks!)

So maybe the rules about "no other initializing constructors" should be per syntactic class declaration, not per semantic class. (But then you can write things in two adjacent augmenting declarations that you cannot write in one declaration by itself. That's a design smell.)

We could go further in the other direction and say that a primary constructor is always a complete/implementing constructor. Even if it doesn't have any code that actually does something, it's still implementing.

It means that you can't use a primary constructor to introduce a signature, it's never incomplete. But that also means that you can't have an incomplete primary constructor forcing an implementation to use a primary constructor (and no other constructors).

With that, we could allow a primary constructor to be augmenting. It's just another way of writing an implementing initializing constructor, and it doesn't matter that it's a primary constructor anywhere outside of that sole syntactic class-ish declaration. It's an implementation detail (as it should be, which also emphasizes why it's an implementing declaration).

But that implementation detail implies that you cannot have other implementing constructors, so it has a global side-effect.

I feel like I'm going in circles between non-optimal choices here. <meme id="simon_pegg_no_no"/>.

lrhn avatar Nov 18 '25 14:11 lrhn

Oh, this is a really good comment. I forgot about the restriction on not having other generative constructors if you have a primary constructor. Assuming we're building on proposal 1 where only the introductory declaration can have a primary constructor, then I think you lay out four options:

  • Proposal 1A: A primary constructor is always considered complete. And because it's complete, it is a real generative primary constructor. Thus if the introductory declaration has a primary constructor, no augmenting declaration can add any generative constructors.

    This means you can't use a primary constructor in the introductory declaration just as a nice syntax to write a signature which is filled in by an augmentation.

  • Proposal 1B: A primary constructor is complete if has a declaring parameter, initializing formal, super parameter, this block initializer, this block body, or another instance field declaration's initializer refers to a primary constructor parameter. Otherwise it's incomplete. Either way, it counts as a primary constructor, and the class can't define any other generative constructors.

    class C(int a) {
      C.other(); // Error.
    }
    

    If it's incomplete, it's a signature for an incomplete primary constructor. An augmentation can complete it with an in-body generative constructor declaration of the same name.

    class C(int a) {
    }
    
    augment class C {
      C(int a) { print(a); } // OK.
    }
    
  • Proposal 1C: A primary constructor is considered complete or incomplete as in 1B. Only if it's complete does it count as a primary constructor. If it's incomplete, it's just a signature for an incomplete non-primary generative constructor. An augmentation can complete it with an in-body generative constructor declaration. The augmentation may have other generative constructors:

    class C(int a) {}
    
    augment class C(int a) {
      C.other() {}
    }
    

    But you can't do the same thing in one syntactic class declaration. This is an error:

    class C(int a) {
      C.other() {}
    }
    

    This would violate the general principle that anything you could do split into multiple augmentations you could also do in one syntactic declaration.

  • Proposal 1D: A primary constructor is considered complete or incomplete as in 1B. Either way, it's a primary constructor. But the rule for whether a primary constructor prevents other generative constructors is restricted to only primary constructors where there are fields whose initializers refer to primary constructor parameters. So this is OK:

    class C(int a) {
      int x = 0;
      C.other(): x = 1 {}
    }
    

    But this is an error:

    class C(int a) {
      int x = a;
      C.other(): x = 1 {}
    }
    

In short, there's a few levers:

  1. Is a constructor primary or not?
  2. Is a primary constructor complete or not?
  3. Are other generative constructors allowed or not?

Then:

  • Proposal 1A says primary constructors are always complete and thus always primary and thus you can never have other generative constructors.

  • Proposal 1B says primary constructors are always primary but may be incomplete. An augmentation can complete it but you can never have other generative constructors.

  • Proposal 1C says a constructor is only primary if it is also complete and thus you can have other generative constructors if you have an incomplete primary constructor.

  • Proposal 1D says even if a constructor is primary (complete or not), you can have other generative constructors as long as fields don't use the primary constructor parameters.

I think 1D is too subtle and will make primary constructors harder to use in general, unrelated to augmentations. Limiting that to augmentations as in 1C avoids making primary constructors more brittle everywhere but at the expensive of adding an irregularity where there's something you can express across augmentations that you can't express in one declaration. That's a design smell to me. I think 1A is workable but means users can't use nice primary constructor syntax for code generated constructor bodies.

1B feels like the Goldilocks point in the design space to me. It lets users use primary constructor syntax while having an augmentation fill in the body, but it keeps the boundary about when a class can or can't have other generative constructors simple and easy to reason about.

We already have a general principle that whatever a user knows to be true about a class from reading the introductory declaration should still be true after the augmentations are applied. If they see a syntactic primary constructor in the class, they know it has a primary constructor and because of that they know it can't have other generative constructors. It seems reasonable to me that an augmentation wouldn't be able to change that.

Proposal 1, more clearly

What I propose is still proposal 1 and then we clarify that if the introductory declaration has a primary constructor (incomplete or not) then the class "has a primary constructor" for purposes of disallowing other generative constructors.

An augmentation can fill in a body for that primary constructor if incomplete, but it must do so using in-body generative constructor syntax. That doesn't count as "another" generative constructor. It's just completing the primary one.

How does that sound?

munificent avatar Nov 20 '25 01:11 munificent

Good summary of the options.

For 1D, it presumes that you can have field initializers referring to primary constructor parameters of an incomplete declaration, without making it a complete declaration, otherwise it would just be 1B again. That's a bold, but I like it.

(1B doesn't say that the initializing expression referring to constructor parameters has to be in the same syntactic class declaration, but it has to mean that. If it was in another class declaration, and could refer to the parameters, the other declaration also has a primary constructor declaration, and as written both would be complete.)

I worry that we have a perception-inconsistency betweeen how primary constructors are intended (and how users are encouraged to think about them) and how they are implemented and thought about by us (read: me). It doesn't show in a single-declaration class, because we fine-tuned the design to make the two match up in that case, but when we move to agumenting declarations, we move away from that overlapping center.

Users see:

class Point(final double x, final double y) {
  final double arg = atan2(y, x);
  final double magnitude = sqrt(x * x + y * y);
}

and think that the field initializers belong to the field.

In the language design and underlying desugaring, the field initializers belong to the constructor, because only a constructor invocation has access to its parameter/initializer-list scope. It's a repositioned initializer list entry, basically, and can be desugared into one.

It used to be that every initializing constructor would evaluate all the instance field initializer expressions. It still is, because with a primary constructor, there is only one initializing constructor. But that changed the meaning of final double arg = ...; from having an expression that belongs to the field, to an expression that belongs to the constructor. And we never say whether a field initializer that doesn't use the initializer-list scope belongs to the primary constructor or not. Which also means that we need to specify the execution in a way that works in both cases.

The user perspective would imply that

class Point(final double x, final double y) {}
augment class Point(x, y) {
  final double arg = atan2(y, x);
  final double magnitude = sqrt(x * x + y * y);
}

makes sense. Field initializers are not part of the constructor implementation, so why not write them in a different augmenting class than the completing constructor declaration?

We are restating the names of the primary constructor parameters, so that we can refer to them, just like we do with type parameters and augmenting functions, but the field initializers are not considered to be "part of the constructor".

I think we may need to support this, because otherwise we'll be confusing users more than we help them. They may end up losing the understanding they have of primary constructors. (And if when can refer to constructor parameters from their parameter list signature, that reference cannot assume that it's mutable. So it's good that it can't even before augmentations.)

Similarly, we may need to support specifying a constructor signature as a primary constructor.

@builtValue
class Point(int x, int y);

because that's how the user thinks they are supposed to write constructors.

Then code-gen adds:

augment class Point {
  final int x;
  final int y;
  Point(this.x, this.y);
  int get hashCode => ..
  bool operator ==(...) => ...
  String toString() => ...
  Point copyWith({...}) => ...
}  

Or even

augment class Point(final int x, final int y) {
  // ....
}

Both can be made to work.

Augmentations don't have to be only code-generated, and I don't want to prevent a user from using one primary constructor in their class, and use primary constructor syntax for it everywhere. Again to not confuse the user who thinks primary constructors are, well, the primary way to write constructors. Which is the story that is being encouraged.

Because of that, I'm gravitating towards 1B plus "initalizing expression everywhere", but minus "must be declard in the introducing class declaration".

As you say, 1A is probably too restrictive and 1C is inconsistent. 1D is also inconsistent, breaks with "primary constructor is only initializing constructor" by saying that some primary constructor declarations are not actually so.

A class having a primary constructor is a design choice with consequences (like not having other initializing constructors, and being able to refer to constructor parameters in instance initializers). Because of that, I think we should make being a primary constructor part of the constructor contract, just as much as being const, so it has to be stated by the introducing declaration.

But I'd make it the introducing declaration of the constructor, not the introducing declaration of the class. You can't see everything you need to know about a class from its introducing declaration anyway. It can be class C;. That doesn't tell you all you need to know about the members of C, those can be added later.

You can't add a new constructor C.foo if there is any existing C.foo constructor declaration anywhere. And you can't add a new initializing constructor C.qux if there is any existing primary constructor declaration anywhere.

So maybe:

1E (aka 1B-II).

  • A constructor is a primary constructor if its introducing declaration is a primary constructor declaration.
  • It's a compile-time error to have a primary constructor and any other initializing constructor.
  • A primary constructor declaration is complete if it has any declaring, super or initializing formal parameter, or if it has a this declaration with an initializer list (: ...) or body {...}. But is not impled by having instance variables with initializer expressions referring to primary constructor parameters.
    • If there is no augmenting constructor satsifying this, then the introducing declaration is complete.
  • An augmenting constructor declaration for a primary constructor can either:
    • repeat the primary constructor in the header. It must repeat any const and/or name and have a matching parameter list signature.

      • Instance variable initializers can refer to those parameter names.
      • It can have an augment this; in the body. The augment is required.
      • No extra augment is required in the header, apart from the one that's already before the class(-ish). It's more like repeating type parameters than writing a constructor.

      An augmenting declaration must acknowledge that the constructor is primary before it can refer to it as such, even if it's not completing.

    • or write the constructor signature in the body, prefixed by augment, repeating const or adding new, repeating the name if any, and have a matching parameter list signature. Code generators don't have to use primary constructor syntax, they can use the same approach for any constructor they augment.

  • An augmenting declaration can complete a primary constructor by:
    • having a primary augmenting constructor for the same constructor that is complete.
    • having an in-body augmenting and completing non-redirecting constructor declaration for the same constructor.
  • It's a compile-time error to have a primary constructor signature or implementation for a constructor whose introducing declaration was not primary.

With that:

  1. Is a constructor primary or not?
  2. Is a primary constructor complete or not?
  3. Are other generative constructors allowed or not?

are answerd by:

  1. If and only if the introducing declaration is primary. It's a class design choice that a constructor is primary.
  2. If it has a non-normal parameter or anything after this. Or if there is no other definitely-complete constructor, then the introducing constructor is complete with the implicit this: super(); implementation.
  3. No.

At most one constructor can be primary. If there is one, no other constructor may be initializing, and you can refer to the constructor parameters in field declarations. You can augment a primary constructor with a primary or in-body constructor declaration. You cannot augment a non-primary constructor with a primary declaration. If you use that the constructor is primary in a later augmentation, you write it as a primary declaration, including repeating the parameters, and then you have access to those parameters in field initializers. If not, you don't have access to parameters.

When executing a primary constructor implementation to initialize an instance:

  • Bind actuals to formals (needed to get the values, and possibly default values, from the argument list), but delay performing initialization for initializing formals/declaring parameters.
  • For each instance variable in the (fully augmented) class, in source order, which has an initializing expression
    • evaluate the expression and initialize the instance variable to that value.
      • If the surrounding declaration has a primary constructor, the expression's scope is the initializer list scope (with parameters not being allowed to be modified),
      • If not, it's the class scope.
  • For each initializing formal/declaring parameter, assign the parameter value to the instance field.
  • Execute the initializer list.
  • Execute the super-constructor invocation to initialize the instance.
  • Execute the body.

That goes for both signatures and implementation, it's still the introducing declaration that defines whether the constructor is primary or not.

Examples:

class Foo {}

augment class Foo(int bananas, int apples); // Valid, constructor introduction not in class introduction.

augment class Foo(int bananas, int apples) { // Valid primary signature.
  final int fruits = bananas + apples;
}

augment class Foo {
  final int bananas, apples;
  new (this.bananas, this.apples); // Valid - non-primary implementation.
}
// Or
// augment class Foo(final int bananas, final int applies); // Valid - primary implementation.

augment class Foo {
  /// It's salad, basically.
  new (bananas, apples); // Valid - non-primary signature.
}

(Offtopic: Do you put space between new and the parameter list in the shorthand syntax?)

lrhn avatar Nov 20 '25 11:11 lrhn

But [being complete] is not implied by having instance variables with initializer expressions referring to primary constructor parameters.

This part is pretty spooky to me. It requires adding another phase to instance creation which seems like a lot of mechanism for a corner case that likely matters to very few users. I'd rather say that when an instance field refers to a primary constructor parameter, that makes the constructor complete.

That way, we can still think of instance initializers that refer to primary constructor parameters as being sugar for moving the initializer to the initializer list. I think it's good to keep that, because if we ever re-add in-body declaring constructors or do some other feature around constructors, the less machinery we have in the way, the better.

Otherwise, this sounds fairly reasonable to me. I think if we're going to allow an augmentation to use primary constructor syntax to augment a primary constructor, then we should require it to do so. I don't see much value in giving augmentations two ways to say the same thing. (Especially since we no longer even have in-body declaring constructors.)

Also, if an augmentation can use primary constructor syntax, we may as well let it introduce a primary constructor too.

So how about this proposal:

Proposal 1F:

  • A constructor is a primary constructor if it's declared in the header of a class. That can be in the introductory declaration or in an augmenting declaration.

  • A primary constructor declaration is complete if it has any of:

    • A declaring field parameter,
    • An initializing formal,
    • A super parameter,

    Or if the surrounding class has any of:

    • A this declaration with an initializer list or body,
    • An instance field declaration whose initializer refers to a primary constructor parameter.
  • An augmentation of a primary constructor must also be a primary constructor. As with other augmentations, it must repeat the same parameter signature with respect to arity and names. Type annotations can be inherited from the augmented declaration. An augmentation may choose to make some parameters initializing formals, declaring parameters, or super parameters. Doing any of that makes that a complete declaration, which means that only one of the syntactic declarations of a primary constructor can introduce any field parameters, initializing formals, etc.

  • As before, it's a compile-time error if a class has a primary constructor and any other generative constructors.

  • As before, a constructor can only be completed by one declaration. So an augmentation of a complete primary constructor can only add metadata or doc comments. But an augmentation of an incomplete primary constructor can add a body, declaring parameters, etc.

  • An augmentation can augment a primary constructor by adding a this declaration subject to the usual restrictions around complete/incomplete.

  • An augmentation with a primary constructor can introduce instance fields whose initializers refer to parameters in the primary constructor. In that case, the augmentation's declaration of the primary constructor is complete.

I think this is probably the simplest but most direct way to adapt primary constructors to augmentations. It lets you use them freely with the same syntax in an introductory or augmenting declaration. And it interprets "complete" in the straightforward way where if the declaration implies any kind of working happening, it's complete.

Thoughts?

munificent avatar Nov 20 '25 22:11 munificent

This part is pretty spooky to me. It requires adding another phase to instance creation

It's not so much a new phase, as it's reordering things that already happen. And we need to do that no matter what. And if we want to have the same initialization order for non-primary initializing constructors, we need to do something new here. I'll file a separate issue.

If we say that it's an error to have an initializer expression for an instance variable that is also initialized by an initializing formal or initializer list entry when you have a primary constructor, then we don't need to worry about the order of initializing formals and initializer expressions.

I think we should make that an error. If there is no other initializing constructor, the initializer expression will always be overwritten by the initializing formal. (In classes with multiple initializing constructors, you can have an instance variable with a "default" intializing expression which gets overwritten only by some constructors. I also think we should make those overwriting constructors not actually evaluate the initializer expression, but that's a separate concern.)

If you can't initialize the same variable twice anyway, we can make the initialization order simpler:

  • bind actuals to formals, now including initializing formals as normal,
  • evaluere initializer expressions,
  • execute initializer list,
  • super constructor invocation,
  • body

The only difference from today is that initializer expressions go after binding actuals to formals, instead of before. That's unavoidable if expressions can refer to the variables. If we stop double-initializing, we can have the same order for non-primary constructors.

And I think it will confuse at least some users that they can't write:

class C(final int x, final int y) {
  static int ctr = 0;
  int a = ++ctr;
  int z = x + y;
}
augment class C(int x, int y) {
  int b = ++ctr;
  int c = x - y; // this is an error
}

They can't write the x - y in the augmentation, even though the variables appear to be in scope, but they can refer to ctr which isn't obviously in scope.

I don't think there is any technical issue with allowing it. When the class has a primary constructor, the class can contain field initializer expressions that refer to that primary constructor's parameters.

(Especially since we no longer even have in-body declaring constructors.)

That does mean that an in-body implementation can't be declaring. I still think it could make things easier for code generators if they don't need to generate primary constructor syntax.

Shorthands are for humans. Forcing them on code generators isn't necessarily helping anyone.

lrhn avatar Nov 20 '25 22:11 lrhn

And we need to do that no matter what. And if we want to have the same initialization order for non-primary initializing constructors, we need to do something new here. I'll file a separate issue.

You're right, we need to specify the order that field initializers run when they may be spread across multiple augmentations. Filed: https://github.com/dart-lang/language/issues/4573.

If we say that it's an error to have an initializer expression for an instance variable that is also initialized by an initializing formal or initializer list entry when you have a primary constructor, then we don't need to worry about the order of initializing formals and initializer expressions.

Huh, I thought it was already an error to initialize a field in the initializer list if it has an initializer at the declaration too, but I guess not. TIL.

Either way, I'd like to keep this issue focused on augmentations.

And I think it will confuse at least some users that they can't write:

class C(final int x, final int y) {
  static int ctr = 0;
  int a = ++ctr;
  int z = x + y;
}
augment class C(int x, int y) {
  int b = ++ctr;
  int c = x - y; // this is an error
}

They can't write the x - y in the augmentation, even though the variables appear to be in scope, but they can refer to ctr which isn't obviously in scope.

I agree, that is confusing. But they also can't write:

class C {
  C(int x, int y) : assert(x > y) {}
}

augment class C {
  int x;
  int y;

  augment C(this.x, this.y);
}

This is also potentially confusing because it may not be obvious that an initializing formal makes the constructor complete, so now you are trying to augment a complete member with another complete member.

I don't know if your example is any more confusing than this one. Augmenting stuff is just kind of complex and subtle and you have to know what you're doing.

I don't think there is any technical issue with allowing it.

I agree we could technically make it work to let you augment a primary constructor with an in-body constructor. It's just weird. This would be introducing a new way to express something in an augmentation that you can't use outside of an augmentation. The constructor is a primary constructor. That property is locked in by the introductory declaration and augmentations in general can't change the existing properties of declarations (const-ness, factory-ness, interface-ness, etc.).

"Primary-ness" is an important property of a constructor because it determines whether the class can have other generative constructors or not. The syntax for giving a constructor the "primary-ness" property is by specifying it in the class header and the syntax for giving a constructor the non-primary-ness property is by putting it in the body. So it seems unnecessarily confusing to me to allow that in-body syntax in an augmentation to still mean "primary" just because the introductory declaration already assigned that property.

If we did do that, is this allowed:

class C(int x) {}

augment class C {
  @someMetadata
  augment C(x);

  augment this {
    print('body');
  }
}

I can see arguments in either direction but I would much rather not have to pick a side by just saying the only way to do primary constructor stuff is using primary constructor syntax.

munificent avatar Nov 22 '25 00:11 munificent

You're right, we need to specify the order that field initializers run when they may be spread across multiple augmentations. Filed: https://github.com/dart-lang/language/issues/4573.

That's not so much what worries me. If that was all, then "generalized source order" is the obvious answer. It's just not enough to preserve backwards compatibility.

the currently specified behavior is:

class C {
  static int ctr = 0;
  int x = ++ct;
  int y = ++ctr,
  int z = ++ctr;
  C(this.y) : this.z = ++ctr;
}
void main() {
  var c = C(42);
  print("${c.x}, ${c.y}, ${c.z}"); // Prints "1, 42, 4"
}

Then what is the behavior of:

class C(this.y, [int nul = 0]) {
  static int ctr = 0;
  int x = ++ctr + nul,
  int y = ++ctr;
  int z = ++ctr;
  this: this.z = ++ctr;
}
void main() {
  var c = C(42);
  print("${c.x}, ${c.y}, ${c.z}"); // Prints ??
}

My problem^H^H^H^H^H^H^H^H^H^H Among my problems is that instance variable initializers are run when you invoke in initializing constructor, but they are not declared as part of the constructor declaration. Today that's not considered weird, it's just how it is.

With primary constructors some of them are linked to the constructor declaration, those that rely on the constructor paramter list. They have to be evaluated after processing the parameter list, otherwise they can't refer to the default value.

So we need to do something. And if we want to preserve the current behavior, we have to do something carefully. And if we don't want to have completely different semantics for primary constructors, we even need to be clever.

"Primary-ness" is an important property of a constructor because it determines whether the class can have other generative constructors or not.

It's an important property of the class that a constructor is primary, it's not a particularly important property for the constructor itself. After desugaring, there is no difference. (That could be an argument for putting the primary constructor declaration on the introducing class declaration, but I think that could be an annoying restriction in practice.) The only reason to prevent other initializing constructors is that it's syntactically confusing. It's not because being a primary constructor means something special itself.

If we allow other fragments to refer to the primary corrector's parameters, then it becomes important that that particular constructor is primary, but it's not something that would prevent a non-primary implementation declaration.

(And I still think we should, because otherwise we'll have to explain the difference between initializers that refer to constructor parameters and initializers that don't.

class C(int x, int y) {
  static int a = 0;
  final int v1 = a + b;
  final int v2 = x + y;
}
augment class C(int x, int y) {
  static int b = 0;
  final int v3 = a + b;
  final int v4 = x + y;
}

Only one instance variable initializer here is an error. I'd rather make it zero instead of having to explain that one.)

lrhn avatar Nov 23 '25 19:11 lrhn

This discussion is a looong cat! ;-)

I agree with a lot of things and disagree with a few things. I'd like to recommend some changes in the following.

The most important point that there is no need to insist that there is exactly one implementing declaration. What we need is a rule that each implementation element is specified at most once. The other important point is that primary constructors must be introduced with the enclosing declaration, that is, it must always be shown up front, if present at all. Finally, I've chosen to make it an error for a primary constructor to augment a non-primary constructor, and vice versa.

With each kind of implementation element, the situation where no such element has been specified at all in a given augmentation chain gives rise to a kind specific handling. For example, if no default value has been specified for a parameter that requires a default value, null is used if the parameter type is nullable, and otherwise a compile-time error occurs.

This is the most consistent approach, considering that we already do the same thing for formal parameter default values and augmentations:

An optional formal parameter has the default value d if exactly one declaration of that formal parameter in the augmentation chain specifies a default value, and it is d. An optional formal parameter does not have an explicitly specified default value if none of its declarations in the augmentation chain specifies a default value. The default value is introduced implicitly with the value null in the case where the parameter has a nullable declared type, and no default values for that parameter are specified in the augmentation chain.

I'd recommend that we use the same kind of rule to determine the meaning of declarations where different parts of the implementation of a primary constructor are specified in different declarations in the augmentation chain:

Terminology

A membered, type introducing declaration is a declaration of a class, a mixin class, an extension type, or an enumerated type.

The entity which is introduced by such a declaration is a membered type introducer.

Primary constructors

A primary constructor changes the rules about a class substantially. In particular, if a class has a primary constructor then every other declaration of a non-redirecting generative constructor is an error. Also, the scope rules are different. Hence, it must be specified up front that the class has a primary constructor, and otherwise it is known that it doesn't have a primary constructor.

A compile-time error occurs if a primary constructor is specified in any element of an augmentation chain defining a membered type introducer, and no primary constructor is specified in the introductory declaration.

Subsequent elements in the augmentation chain may specify a primary constructor or omit it, and the resulting class will have a primary constructor if and only if the introductory declaration has one.

The introductory declaration of a primary constructor determines the names, types, and kind of formal parameters (named/positional, required/not, etc) in the same way as with other formal parameter lists. The ability for a parameter to be declaring is new, and is specified below. As with other constructor declarations, a compile-time error occurs if an augmenting declaration of a primary constructor specifies any signature element differently (that is, by having a different type annotation, a different status as named/positional, etc, for a formal parameter; or by having const on the introductory declaration of the primary constructor and not on another, or vice versa; or by declaring primary constructors with different names; etc).

An augmenting declaration of a membered type introducer that specifies a primary constructor is also an augmenting declaration of said primary constructor. In particular, it is not required, and not allowed, to use the augment modifier on the primary constructor itself, as in class C(final int i); and then augment class augment C(i);.

The formal parameters of a primary constructor

A formal parameter of a primary constructor has a name, optionally a type annotation, a named/positional status, a required/optional status when named, an optional default value when optional, and a declaring/not-declaring status. Moreover, the parameter can be an initializing formal (this.p) or a super parameter (super.p).

The default value and the declaring/not-declaring status are implementation elements. The status of being an initializing formal or a super parameter is also an implementation element. For each parameter, implementation elements can be specified at most once in an augmentation chain.

The situation where a given element is never specified in the augmentation chain is handled in the same way as with other formal parameters. One new rule is needed: If no element in the augmentation chain specifies that a parameter is declaring then it is not declaring.

The other elements are signature elements. The type annotation is inferred if not specified in the introductory declaration, all other elements are inherently specified by every declaration of the given parameter anywhere in the augmentation chain.

A compile-time error occurs if a signature element is specified differently in different elements of the augmentation chain. For example, it is an error if a parameter is specified as optional positional in one declaration and non-optional positional in another.

The body part of a primary constructor

The body part of a primary constructor is derived from <primaryConstructorBodySignature> (<functionBody> | ';').

The part this is signature and must be specified in every declaration of such a body part. If no function body is specified then this and the optional initializer list is followed by ;, which does not specify a body.

The initializer list is implementation, and so is the function body. As such, each of those must be specified at most once in the augmentation chain. But there is no requirement that both must be specified together.

If an augmentation chain on a non-constant primary constructor body part does not specify a body in any element then the resulting constructor body part has the empty body, {}.

A body is always a compile-time error if it is specified in a declaration of a constant constructor, and this is also true for the body part of a constant primary constructor.

Dependencies among the two parts of a primary constructor

A compile-time error occurs if the augmentation chain for a membered type introducer has a primary constructor body part, but no primary constructor is declared in the header in the introductory declaration of said enclosing declaration.

Hence, the primary constructor header part must be present if there is a body part, but they don't have to occur together in each element of the augmentation chain.

Access to the formal parameters of a primary constructor

The formal parameters of a primary constructor are in scope in every element in an augmentation chain for a membered type introducer where the introductory declaration has a primary constructor declaration in the header.

The scoping is the same as the scoping which is specified for a single declaration of these kinds with a primary constructor. That is, the formal parameters are in scope in the initializing expressions of non-late instance variable declarations, and in the initializer list of a primary constructor body part. The parameters which are not initializing formals, super parameters, nor declaring parameters are in scope in the function body of the body part of the primary constructor.

A compile-time error occurs if any parameter of a primary constructor is accessed and the current element in the augmentation chain does not have a primary constructor declaration in the header.

The underlying rationale is that the primary constructor parameters are present in every element in the augmentation chain of the enclosing membered type introducer, but it is only allowed to refer to them in the elements of the augmentation where they are explicitly mentioned. These declarations are present in every element of the augmentation chain because this is consistent with the treatment of the top-level scope and the instance members of an augmentation chain, but they are marked as "do not touch" in the declarations where there is no primary constructor declaration, because it's too weird if they can be used in a context where there is no hint at all about their existence.

Answering the questions

Here are the answers to the questions posed in the OP of this issue:

  • Does an augmentation of a class with a primary constructor have to repeat the primary constructor in its header? If yes, it seems like it's a redundant member declaration, which doesn't make much sense. We don't require you to repeat any other members and in fact explicitly forbid it. If no, then the primary constructor parameters are in lexical scope in the augmentation's class body but their declarations are not textually visible. With type parameters, we specifically require the augmentation to repeat them to avoid that syntactic confusion.

My answer is no, it can be omitted, but in that case it is an error to access the formal parameters of the primary constructor in initializing expressions of non-late instance variables and in the initializer list. The point is that it is too confusing if we can use names that are not visible and yet they are handled as being present in the enclosing syntax, and it is also too confusing if we were to allow imported or top-level names to be accessed in an augmenting declaration that doesn't have the primary constructor declaration in the header. This would imply that the code has a completely different meaning if we move it to a different augmenting declaration, which is not the case for the instance members and for the top-level declarations.

  • I'm assuming in 1 that the primary constructor parameters are in scope inside the body of the augmentation. But that's a choice too. Should they be?

Yes, they are in scope, but it is only allowed to use them if the primary constructor is specified in the current augmenting declaration.

  • Can an augmentation augment a primary constructor with a body by writing a generative constructor declaration with the same name? Conversely, can an augmentation augment a generative constructor with a ; in the introductory declaration by having a primary constructor with the same name in the augmentation?

No, we should keep "primary-ness" explicit because it matters.

  • Can an augmentation of a class with a primary constructor add a this block if the introductory declaration doesn't have one? Or if it does?

Yes, a body part can be added to a primary constructor. It is preceded by augment unless it occurs in the introductory declaration of the enclosing membered type introducer.

Examples

class A(final int x);

augment class A {
  @metadata
  augment final x; // OK.
}

The implicitly induced instance variable final int x; can be augmented, but only in ways that aren't an error for other reasons (for example, we can't add an initializing expression because it is already initialized by the actual argument passed to the declaring parameter x).

String x = 'Top-level declaration';

class A(final int x);

augment class A {
  final int y = x; // Error, no access to primary constructor parameters.
  final int z;
}

augment class A(int x) {
  augment final z = x; // OK.
  this: assert(x >= 0) { print(x); } // OK.
}

This illustrates a topic where my proposal differs from several previous ones in this section: The formal parameters of the primary constructor are in scope in every augmenting declaration of the membered type introducer, but it is an error to access them if the current augmenting declaration does not have a primary constructor declaration.

Here are some examples that have been discussed earlier in this issue:

class V(int x, int y);

augment class V(x, y) {
  final double magnitude = sqrt(x * x + y * y); // OK.
}

This is OK because x and y are in scope in every declaration of V, and it is not an error to access them in the augmenting declaration of V because it has a primary constructor declaration (which is necessarily augmenting because the declaration is augmenting, and the introductory primary constructor must be in the introductory class declaration).

class V(int x, int y);

augment class V(x, y) {
  final double arg = atan2(y, x); // OK.
}

Nothing new here.

class V(final int x, final int y) {
  static int _idCounter = 0;
}

augment class V(x, y) { // `augment class V {` would have been OK, too.
  final _id = ++_idCounter; // OK.
}

augment class V(x, y) {
  final double magnitude = sqrt(x * x + y * y); // OK.
}

Again, nothing new.

class P(int x, int y); // A primary constructor.

augment class P {
  final int x, y;
  augment P(this.x, this.y); // Error, cannot augment a primary with a non-primary.
}
// Assuming `class P(int x, int y);`, I think.

augment class P(int x , int y) {
  final double arg = atan2(y, x); // OK.
}
class C(int x, int y);

augment class C(int x, int y) {
  final int theX = x; // OK.
  final int xBefore = x++; // Error because it modifies a primary parameter here.
}

augment class C(int x, int y) {
  final int theY = y; // OK.
  augment this { y++; } // OK, primary parameters can be modified in the constructor body.
}

There is no requirement that one particular declaration must be appointed as being the implementation. We only require that each implementation part is specified at most once. In particular, it's OK to add multiple instance variables in multiple augmenting declarations, and it's OK to access the primary parameters in each of them.

int x = 0, y = 0; // Most recent V coordinates.

void _setXY(int newX, int newY) { // Allows setting even when shadowed.
  x = newX;
  y = newY;
}

class V0;
augment class V0(int x, int y); // Error, can't introduce a primary constructor in an augmenting declaration.

class V(int x, int y); // OK.

augment class V {
  /// Creates a new `V` with the given coordinates.
  augment this; // Augment in-body part with comment.
}

augment class V {
  static int _prevId = 0;
  final int id = ++_prevId; // OK.
}

augment class V(int x, int y) {
  final double arg = atan2(y, x); // OK, refers to primary parameters.
}

augment class V { // No parameters declared; they are in scope, but not accessible.
  final ({int x, int y}) prevCoordiantes = (x: x, y: y); // Error, no access to primary parameters.
}

augment class V(int x, int y) {
  final ({int x, int y}) coordinates = (x: x, y: y); // OK.
  augment this {
    _setXY(x, y); // Nothing special here, `x` and `y` are primary parameters.
  }
}

augment class V(x, y) { // Can omit types.
  final double magnitude = sqrt(y * y + x * x); // Reference to in-scope parameters.
}

The main difference compared with earlier proposals is that the primary parameter names are always in scope in the relevant initializers, but they cannot be used unless the current enclosing declaration includes the primary constructor.

class P {
  int get x;
  int get y;
}

augment class P(augment final int x, int y) { // Error, primary constructor must be in introductory declaration.
  augment final int y = y; // Irrelevant
}

// But we can do this.

class Q {
  int get x;
  int get y;
}

augment class Q {
  final int x;
  final int y;
  new (int this.x, int y): y = y;
}

There is also no notion of having an augment final int x parameter. If you wish to augment the implicitly induced instance variable then go ahead and augment it. It does not matter that it is not shown. (You also can't see a getter or setter declared with the keywords get and set when you declare an instance variable, and it is still OK to augment the getter and setter).

eernstg avatar Nov 25 '25 11:11 eernstg

I like and agree with almost all of this. My only quip is that I would prefer to primary constructor to not need to be introduced in the introducing declaration of the type introducing declaration, but just in the introducing declaration of the constructor.

Adding a class C {} at the top and augmenting it with every member later becomes harder if you have to mention the primary constructor up-front. Obviously you can, because you are editing the library (code generators can adapt to anything, I don't worry about those) , but you lose the separation of concerns that made you split the declaration into parts to begin with. Or you feel forced to use a non-primary constructor, even though you really want a primary constructor. I don't think that's the incentives we want to push.

lrhn avatar Nov 25 '25 16:11 lrhn

The reason why I'm recommending that primary constructors can only be introduced by the introductory declaration of the type introducer (that is, the class, mixin class, extension type, or enum declaration) is that we have otherwise maintained a meta-rule stating that "earlier declarations in an augmentation chain should not imply that something is possible, if it is made impossible by a later declaration in the augmentation chain".

For example, we don't allow an augmenting declaration D2 to change a class from being a class to being a mixin class, because this would eliminate the ability to have an extends clause in any of the elements of the augmentation chain that occur before D2. Similarly, we don't allow a formal parameter to omit a type annotation, and then specifying that type annotation in some later augmenting declaration; the type annotation is determined by the introductory declaration, and augmenting declarations can only re-state it or omit it, it can't change the type annotation.

This meta-rule doesn't apply to name clashes—we can always incur a compile-time error by adding an instance member or a static member whose name is n in a situation where a subsequent augmenting declaration already declares such a member.

However, in this case it is not a name clash: When a type introducer has a primary constructor, it is an error to declare another non-redirecting generative constructor in that type introducer, no matter which name this constructor gets.

I also think it is important to have the primary constructor in the introductory declaration of the enclosing type introducer because it changes the semantics of the class body. In particular, if a non-late instance variable declaration has an initializing expression that refers to x then it may resolve as a reference to a top-level or a static declaration from enclosing scopes when the class does not have a primary constructor with a formal parameter whose name is x, but it will be a reference to that parameter if the class does have such a primary constructor.

I think we should strive for a consistent treatment of the scope structure, and the fact that both the top-level scope and the class body scope are combined before lexical lookups are performed imply, for me, that we should treat the primary parameter initializer scope similarly: The primary parameters are in scope in every declaration in the augmentation chain.

I do recognize, though, that it may be confusing if those names are usable in an augmenting declaration where the declaration of the primary constructor has been omitted. Hence, I'm recommending that such accesses should be an error (so the developer who actually wants to use those parameters can just add the primary constructor signature to the enclosing type introducer declaration).

eernstg avatar Nov 28 '25 11:11 eernstg

earlier declarations in an augmentation chain should not imply that something is possible, if it is made impossible by a later declaration in the augmentation chain

I only consider that a rule at the API level. If a user looks at the first declaration of something, they should know how they can use it.

I don't consider that a rule at the implementation level. A member that isn't introduced in the introductory type declaration can affect everything else. For example you can't assume that you can add an instance member named foo just because the class has no foo member in the introductory declaration, it might have a statistic foo member introduced somewhere.

To a user, it doesn't matter whether a declaration is primary.

To the implementor it matters throughout the class. Just like adding a member does.

I don't think a rule like this should mean that you have to introduce a primate primary consumers constructor in the introductory type declaration any more than you'd have to introduce any other constructor.

we don't allow an augmenting declaration D2 to change a class from being a class to being a mixin class, because this would eliminate the ability to have an extends clause in any of the elements of the augmentation chain that occur before

That's not the main reason I see for disallowing it. My main reason is that your can't see whether you can mix the class in just by looking at the first declaration. And that is a property of the class, not just of its implantation. Primary constructors are pure implantation.

The main reason there would be for the first type declaration to represent all constraints on a class would be that code generators could run in parallel and not introduce incompatible constraints. That isn't true anyway, member conflicts can occur anyway:

  • name conflicts as usual. Static vs constructor, static vs instance, method vs setter.
  • const constructor vs non-final field, or vs. field with non-const initializer expression.
  • a redirecting generative constructor ending up without a target or with a factor target.

You can't look at a class declaration like class C; and say anything about its members. Not even that it will have an unnamed constructor.

Being able to introduce a primary constructor that conflicts with another declaration isn't special, avoiding it isn't worth making you have to declare the constructor before you need it.

lrhn avatar Nov 28 '25 23:11 lrhn