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

Primary constructor on classes #2364

Open
Tracked by #2360
leafpetersen opened this issue Jul 29, 2022 · 115 comments
Open
Tracked by #2360

Primary constructor on classes #2364

leafpetersen opened this issue Jul 29, 2022 · 115 comments
Assignees
Labels
brevity A feature whose purpose is to enable concise syntax, typically expressible already in a longer form data-classes extension-types feature Proposed language feature that solves one or more problems inline-classes Cf. language/accepted/future-releases/inline-classes/feature-specification.md primary-constructors structs

Comments

@leafpetersen
Copy link
Member

leafpetersen commented Jul 29, 2022

[Update: There is considerable interest on the team in adding primary constructors as a general feature. This original discussion issue has been repurposed as the tracking issue for the general feature request.]

Introduction

Primary constructors is a feature that allows for specifying one constructor
and a set of instance variables, with a concise and crisp syntax. Consider this
Point class defined using the current class syntax for the constructor and fields:

class Point {
  int x;
  int y;
  Point(this.x, this.y);
}

With the primary constructor feature, this class can defined with this much shorter syntax:

class Point(int x, int y);

Discussion

[original issue content below]

In the proposal for structs and extension structs, I propose to add primary constructors to structs. Briefly, the class name (or the type parameter list if any) may/must be followed by a parenthesized list of variable declarations as such:

  struct MyStruct(int x, int y) {
      // members here
  }

In the struct proposal, these are always final by default, and are restricted in various ways (i.e. they may not be late, they may not be const). They are allowed to declare initializers, which are used to generate default initialization values.

This issue is to discuss the possibility of splitting this out, and making it a general feature for classes as well.

Initial points in favor of this include:

  • It would be consistent, and nice to have this available for classes
  • It makes structs less different from classes

Initial points against include:

  • Resolving the tension between the desire to have final by default for structs, and the existing mutable by default behavior in classes.
  • Classes support richer superclass structure that may make specifying how the generated constructor works more complicated
  • Dealing with const constructors here seems more complicated than in the data class case.
@lrhn
Copy link
Member

lrhn commented Jul 30, 2022

I'd say "yes".

If it works, it feels odd to not allow it. I can't see a reason it shouldn't work.
It's a little weird that it only works with final instance variables, but I think that's acceptable.

But, that depends a lot on what model/capabilities we end up with for the fields.

The current model uses the primary constructor to declare fields, and only secondarily as a template for a default constructor. That is, it's not necessarily a "constructor".
You can also write other constructors, and initialize fields directly in those.

We could consider a different model where the "primary constructor" directly defines the unnamed constructor.
That means changing the (...) to a parameter list instead of a list of field declarations, and then deriving the field declarations from the parameters, instead of the other way around. (And then you can perhaps even extend another non-abstract struct, and forward parameters using super.foo syntax.)
Otherwise people will need to write a constructor anyway, when they want it to have named parameters. I think that'd be a lost opportunity for a shorthand syntax for classes, where you don't need to write a constructor directly.

With that, I'd also say that any other generative constructor must be redirecting (eventually) to the primary constructor.
Since the primary constructor initializes all fields.
(Or, we can allow adding a name, class Foo._(args) {}, to make the primary constructor be a named, possibly private, constructor.)

If we do allow the syntax for classes too, I'd also have those classes get the default == and hashCode implementations (if they don't inherit or declare an implementation of either other than the one from Object). That makes sense because the primary fields are known. (Not sure that works if you extend another kind of class, though, because you can't just delegate to super.==).

A class declaration with a primary constructor can add further instance variables, but they must be self-initializing (nullable or having an initializer). Those other variables would also not be part of the == or hashCode implementation.
(If you need that, you must declare the ==/hashCode yourself.)

@eernstg
Copy link
Member

eernstg commented Aug 1, 2022

Yes, please! ;-)

We could use the following rule: The primary constructor of a struct declares non-late instance variables that are final by default. The primary constructor of a class declares non-late instance variables that are non-final by default, but may have the keyword final.

Classes can still declare additional instance variables, of any kind, as before. Structs might be able to do this as well—what's the harm in allowing a struct to have final int now = DateTime.now().millisecondsSinceEpoch;?

The primary constructor syntax gives rise to a constructor declaration and a set of instance variable declarations. Every constructor is checked statically relative to the result of this desugaring step.

In other words, there is no reason to require that all constructors are redirected to the generated primary constructor, they just need to satisfy the normal constraints that we have today (e.g., that a non-late final instance variable must be initialized before any code with access to this runs).

About this:

changing the (...) to a parameter list instead of a list of field declarations, and
then deriving the field declarations from the parameters, instead of the other way around

I think that's a very interesting idea to explore.

@leafpetersen
Copy link
Member Author

With that, I'd also say that any other generative constructor must be redirecting (eventually) to the primary constructor.
Since the primary constructor initializes all fields.

Not sure I follow this. I was proposing to allow other generative constructors, they just must also initialize all of the fields as usual.

(Or, we can allow adding a name, class Foo._(args) {}, to make the primary constructor be a named, possibly private, constructor.)

On reflection, I was thinking of modifying the proposal to say that if there is a "primary constructor", then there is always a Foo._ constructor generated, and if there is no explicit default constructor, then one is generated that redirects to ._.

In other words, there is no reason to require that all constructors are redirected to the generated primary constructor, they just need to satisfy the normal constraints that we have today (e.g., that a non-late final instance variable must be initialized before any code with access to this runs).

This was the model I had in mind.

@mraleph
Copy link
Member

mraleph commented Aug 2, 2022

I totally agree that this syntax should be applicable to classes as well. You can make an argument that it is good enough if it only supports simple cases, e.g.

class X (var x, int y, final String z, {super.key}) extends Y {
  final int w = x + y;
}

// equivalent to 

class X extends Y {
  var x;
  int y;
  final String z;
  final int w;
  X(this.x, this.y, this.z, {super.key}) : w = x + y;
}

and for anything else people can resort to traditional constructor syntax.

You can probably make const work as well if you say that class with a primary constructor of form (final f, ..., final z) (all fields final) automatically gets const constructor.

We can maybe even make something like:

class X (var x, int y, final String z, {key}) extends Y(x, key: key) {
  final int w = x + y;
}

work.

The weakest point of this is going from shorthand syntax to long syntax once you realise that you need constructor body, but maybe this rarely happens.

@munificent
Copy link
Member

I 1000% want any primary constructor syntax to be generalized to classes. In fact, I personally care more about that than I care about the entire views proposal. :) Most user-types do not have value semantics (==, hashCode) but do have simple enough constructors that they could use this syntax.

To validate that, I scraped a big corpus of code from itsallwidgets.com, open source Flutter apps, and pub packages (18+MLOC) to determine which classes with at least one generative constructor could not use a proposed primary constructor syntax. The reasons a class might not be able to use a primary constructor sugar that I considered are:

  • "Multiple generative ctors": There are multiple generative constructors. In practice, many of these classes could still probably use a primary constructor for one of them, but these were rare enough that I didn't bother trying to distinguish them. So consider the results below a slight undercount.
  • "Non-empty body": The constructor body isn't empty.
  • "Non-forwarded superclass ctor param": The constructor has a super() constructor initializer that passes an argument that isn't simply a forward from a constructor argument. (In other words, there's a superclass constructor argument that couldn't use super. instead.)
  • "Non-forwarded field initializer": The constructor has a field initializer that isn't simply a forward of a constructor parameter. (In other words, there's a constructor initializer that couldn't use this. instead.)

The results are:

-- Could use primary (109448 total) --
  82826 ( 75.676%): Yes
   9254 (  8.455%): No: Non-forwarded superclass ctor param
   7172 (  6.553%): No: Non-empty body
   4568 (  4.174%): No: Multiple generative ctors
   3859 (  3.526%): No: Non-forwarded field initializer
    935 (  0.854%): No: Non-empty body, Non-forwarded field initializer
    442 (  0.404%): No: Non-empty body, Non-forwarded superclass ctor param
    338 (  0.309%): No: Non-forwarded field initializer, Non-forwarded superclass ctor param
     54 (  0.049%): No: Non-empty body, Non-forwarded field initializer, Non-forwarded superclass ctor param

So a little more than 3/4 of all existing class declarations could use something close to the proposed primary constructor syntax. Note that I'm assuming here that a primary constructor syntax would support users controlling which parameters are positional, named, and/or optional and would allow private names. (In other words, I did not treat those as failures.)

I think the biggest design challenges are:

  1. Whether fields should default to final or not. It's pretty obviously the right default for struct, but I think would be surprising for class. (Ideally, we would have always defaulted to immutable for fields and parameters, but that ship has sailed.)

  2. How to make the syntax readable when there are many fields, doc comments, extends clauses, implements, with, etc. It can get pretty hairy to pack all of that into the header of a class.

A while back, I worked on a primary constructor strawman syntax that looked like:

class Rect new (
  final int x,
  final int y,
  final int width,
  final int height,
);

So instead of a parameter list right after the class name, there is a new keyword first. Having a keyword there allows a few things:

You can put it after the other header clauses. Since the field list is likely longer than the extends clause, type parameters, etc. I think it looks best last right before the class body, as in:

class ArgumentSublist extends Rule<Expression> implements FormatSpan new (
  /// The full argument list from the AST.
  final List<Expression> _allArguments,

  /// The positional arguments, in order.
  final List<Expression> _positional,

  /// The named arguments, in order.
  final List<Expression> _named,
) {
  /// The number of leading block arguments, excluding functions.
  ///
  /// If all arguments are blocks, this counts them.
  final int _leadingBlocks;

  /// The number of trailing blocks arguments.
  ///
  /// If all arguments are blocks, this is zero.
  final int _trailingBlocks;

  void visit(SourceVisitor visitor) { ... }
}

Compare that to what you'd get using the current proposal:

class ArgumentSublist(
  /// The full argument list from the AST.
  final List<Expression> _allArguments,

  /// The positional arguments, in order.
  final List<Expression> _positional,

  /// The named arguments, in order.
  final List<Expression> _named,
) extends Rule<Expression> implements FormatSpan {
  /// The number of leading block arguments, excluding functions.
  ///
  /// If all arguments are blocks, this counts them.
  final int _leadingBlocks;

  /// The number of trailing blocks arguments.
  ///
  /// If all arguments are blocks, this is zero.
  final int _trailingBlocks;

  void visit(SourceVisitor visitor) { ... }
}

Note how the extends and implements clauses are buried in the middle.

You can use different keywords. In my strawman, you could use const instead of new to make the primary constructor a const constructor. We could also allow you to use final to default to making all fields final. So the first example becomes:

class Rect final (
  int x,
  int y,
  int width,
  int height,
);

We could then do the same thing for struct which would allow you to define value types with mutable fields. (Which are, admittedly, dubious, but a thing users do in practice.)

In other words, this means the only thing writing struct instead of class does is give you default implementations of ==, hashCode, etc.

You can provide a constructor name. Having a keyword before the parameter list instead of the class name also provides a natural place to insert a constructor name if you want the primary constructor to be named:

class NestingLevel extends FastHash new.empty(
  /// The nesting level surrounding this one, or `null` if this is represents
  /// top level code in a block.
  final NestingLevel? parent,

  /// The number of characters that this nesting level is indented relative to
  /// the containing level.
  ///
  /// Normally, this is [Indent.expression], but cascades use [Indent.cascade].
  final int indent,
) {
  /// The total number of characters of indentation from this level and all of
  /// its parents, after determining which nesting levels are actually used.
  ///
  /// This is only valid during line splitting.
  int get totalUsedIndent => _totalUsedIndent!;
  int? _totalUsedIndent;
}

The downside, of course, is that this is a bit more verbose and a little different coming from other languages whose primary constructor is right after the class name. In cases where there isn't much else in the type header, there are few fields, and they aren't documented, I think the classic primary constructor syntax looks better. But once the type scales up (and in particular, once you document your fields, which I think is generally a good idea), it gets kind of hard to read.

@leafpetersen
Copy link
Member Author

Compare that to what you'd get using the current proposal:

This is assuming no changes to documentation conventions, which I think is not realistic. From a brief look at some kotlin code, the equivalent might look more like:

  /// @param _allArguments The full argument list from the AST.
  /// @param _positiional The positional arguments, in order.
  /// @param _named The named arguments, in order.
class ArgumentSublist(
  final List<Expression> _allArguments,
  final List<Expression> _positional,
  final List<Expression> _named,
) extends Rule<Expression> implements FormatSpan {
  /// The number of leading block arguments, excluding functions.
  ///
  /// If all arguments are blocks, this counts them.
  final int _leadingBlocks;

  /// The number of trailing blocks arguments.
  ///
  /// If all arguments are blocks, this is zero.
  final int _trailingBlocks;

  void visit(SourceVisitor visitor) { ... }
}

Which looks fine to me (nit, I don't understand how the extra fields work in this class, since they're not initialized in the constructor?)

One way of looking at this is that intuitively, we write Foo<X, Y> for generic classes, and the intuition is basically that the type parameters are "parameters" to the class. And at the invocation site, you write them in the same place: Foo<int, int>(...arguments). The same intuition seems to me to carry over naturally: the constructor parameters are parameters to the class, and in an invocation, you put the arguments immediately after the generic parameters (or the class name if none). So using the "parameter" syntax immediately after the classname/generics seems very intuitive to me.

You can use different keywords. In my strawman, you could use const instead of new to make the primary constructor a const constructor.

Is there any reason not say that every primary constructor is a const constructor (at least if the superclass has a const constructor)?

We could also allow you to use final to default to making all fields final. So the first example becomes:

class Rect final (
  int x,
  int y,
  int width,
  int height,
);

We could then do the same thing for struct which would allow you to define value types with mutable fields. (Which are, admittedly, dubious, but a thing users do in practice.)

We could. It looks pretty weird to me though.

You can provide a constructor name.

This really feels a bit over-generalized to me. If you want a named constructor, just write the constructor.

The downside, of course, is that this is a bit more verbose

This is really the rub. My sense is that the more we generalize this, the more we lose the actual benefits. Your data scraping suggests that a huge majority of classes don't need the generality. So every bit of generality that we add that makes that majority more verbose has a massive incremental cost in aggregate, and only benefits a few niche cases.

@lrhn
Copy link
Member

lrhn commented Aug 3, 2022

Is there any reason not say that every primary constructor is a const constructor (at least if the superclass has a const constructor)?

We'd need to give you a way to opt out of being a const constructor if you don't want it. You may not want it if you plan to add further, non-final, fields to the class in the future. That will be a breaking change if the constructor is implicitly made const without you asking for it.
In general, locking people into a constraint by default is dangerous. Even more to people who don't know about it. Those who do can usually come up with a workaround.

... every bit of generality that we add that makes that majority more verbose has a massive incremental cost in aggregate, and only benefits a few niche cases.

That's a very good point. The only counter-point is that ever feature we make default and automatic causes an extra step if you ever need to migrate away from the shorthand. If we make a primary constructor implicitly const, you need to remember to write const when you migrate off using primary constructors. (That's probably the smallest such issue, so not really an argument for not making it default to const. Not having an opt-out other than migrating away from the primary constructor is a bigger issue to me).

@mraleph
Copy link
Member

mraleph commented Aug 3, 2022

We'd need to give you a way to opt out of being a const constructor if you don't want it.

FWIW I think that majority of Dart developers don't concern themselves with such matters because they are not writing reusable code.

So I think we should not optimise defaults towards the minority that does.

@leafpetersen
Copy link
Member Author

@ lrhn

We'd need to give you a way to opt out of being a const constructor if you don't want it. You may not want it if you plan to add further, non-final, fields to the class in the future. That will be a breaking change if the constructor is implicitly made const without you asking for it.
In general, locking people into a constraint by default is dangerous. Even more to people who don't know about it. Those who do can usually come up with a workaround.

To be slightly provocative, maybe the answer is to say "if you don't want it to be const, don't use a primary constructor". As @mraleph says, I think there is a lot of value for a feature like this that you don't have to use in optimizing strongly for the common case.

To be slightly less provocative, we could at least say that getting an implicit const constructor is part of the deal with structs/data classes. That is, if you say data class, you are opting in to implicitly final fields and implicit const constructor.

Not having an opt-out other than migrating away from the primary constructor is a bigger issue to me

I hear this, but I also think that there is an inherent cliff here. If you want a constructor body, you have to migrate away. If you want to delegate, you have to migrate away. If you want to initialize some fields in the initializer list, you have to migrate away. So saying that if you want non-const you have to migrate away doesn't feel that bad to me.

@munificent
Copy link
Member

munificent commented Aug 4, 2022

This is assuming no changes to documentation conventions, which I think is not realistic.

That's a good point. Hoisting all the field docs alleviates much of my readability concerns.

One way of looking at this is that intuitively, we write Foo<X, Y> for generic classes, and the intuition is basically that the type parameters are "parameters" to the class. And at the invocation site, you write them in the same place: Foo<int, int>(...arguments). The same intuition seems to me to carry over naturally: the constructor parameters are parameters to the class, and in an invocation, you put the arguments immediately after the generic parameters (or the class name if none). So using the "parameter" syntax immediately after the classname/generics seems very intuitive to me.

Yeah, I agree it is 100% intuitive to have the parameters right there. I just think it looks funny when you end up having the extends/implements/with clauses jammed between the primary constructor and the class body. But... I'm convinced that it's the least bad approach.

Is there any reason not say that every primary constructor is a const constructor (at least if the superclass has a const constructor)?

No, good point.

This is really the rub. My sense is that the more we generalize this, the more we lose the actual benefits. Your data scraping suggests that a huge majority of classes don't need the generality. So every bit of generality that we add that makes that majority more verbose has a massive incremental cost in aggregate, and only benefits a few niche cases.

Yes, I think I'm sold. I've poked around a bunch of Kotlin code and it does look weird to me to have the superclasses and superinterfaces wedged between the primary constructor and class body. But in practice, it seems like most classes with complex inheritance hierarchies don't use primary constructors. For those that do... it looks a little weird (and people seem to format them in a variety of creative ways), but not intolerable.

OK, so what I'd suggest then is:

  • A primary constructor is a parameter list that appears directly after the struct or class name. It can have positional, optional, named, and required parameters as the user wants.

  • Each parameter in the list (that isn't a super. parameter) becomes a field on the type initialized by that parameter. The field is implicitly final in a struct and final if the parameter is marked final in a class.

  • It can contain super. parameters which implicitly get forwarded to the superclass constructor the way they do in a normal constructor declaration.

  • The primary constructor is implicitly const.

  • The class may define other constructors (generative, redirecting, or factory) as long as those constructors meet all of the normal obligations of initializing final fields, etc.

  • The class may also define other fields as long as it doesn't cause problems that the primary constructor doesn't initialize them: they are either initialized at their declaration, late, or nullable.

  • A class can omit its {} body and use ; instead if empty.

  • It's probably reasonable to do what @lrhn suggests and allow a constructor name before the parameter list too:

    class Foo.name(int x);

There's the weird wrinkle around private named fields as named parameters in the primary constructor. I think I'd be OK with saying that you just can't do that.

@lrhn
Copy link
Member

lrhn commented Aug 8, 2022

What @munificent says.

Parameter list occurs after class name — and after type parameters if any.

I'm actually, uncharacteristically, fine with allowing the field names in the parameter list to be private, and automatically make them public in the implicitly added constructor. It's reasonable to want private fields, and unreasonable to have private parameter names. Something needs to be tweaked. (I'd even be willing to contemplate making the name of the parameter of the common this._foo be foo, but that's potentially breaking if it's referenced as _foo later in the parameter list.)

I'm now OK with making the constructor const if possible (superclass constructor is const, any further fields in a class declaration are non-late and final - and therefore necessarily initialized with a constant.)

Biggest issue: Do we need a way to specify a super-constructor other than the unnamed one?

If we allow Foo.name(int x) for a primary constructor, we'd also want to be able to call that from a subclass primary constructor.
Maybe we can heuristically say that the primary constructor calls the superclass constructor:

  • which is a primary constructor, if the superclass has one
  • otherwise the one with the same name (empty/new name if unnamed), if it exists,
  • otherwise use the unnamed constructor, if it exists.

Most people will just use the unnamed primary constructor for everything, and that'll just work.


On second thought, there is one problem with implicitly inferring const for the constructor.
(Other than getting people locked into it without them knowing it.)
If the primary constructor is const when:

  • The superclass constructor is const, and
  • Default values of primary constructor parameters are const, and
  • Any fields added to the class supports being const (not late, final, and is nullable or has an initializer which is constant, even though its context isn't constant).

then whether the constructor actually is const will depend on very fragile and accidental choices.

Adding a field like final int x = 42; will preserve const-ness. Changing it to static final int _defaultX = 42; final int x = _defaultX; will make the constructor non-constant. (If _defaultX had been constant, it would work.)
There is no warning, unless you check that the constructor can be used as const, which you might not care about (since you just broke it without noticing).

I think that's generally going to be too fragile. I'd recommend you having to write const to get a const constructor, say:

const class Foo(int x, int y);

That's an explicit opt-in to the primary constructor being constant. It makes it easy to give errors if some other part of the class doesn't support being const, rather than just silently not being constant.
If you don't think about const-ness, you won't accidentally promise that the class can be used for constants.

Yes, it's one more word, and it'll likely be used a lot, but as long as we don't have const-by-default everywhere else, and an opt-out word for non-const-ness, I think we have to stick to the rule that const is not implicit, because it's a big promise you make in your API.

@leafpetersen
Copy link
Member Author

I think that's generally going to be too fragile. I'd recommend you having to write const to get a const constructor, say:

const class Foo(int x, int y);

That's an explicit opt-in to the primary constructor being constant. It makes it easy to give errors if some other part of the class doesn't support being const, rather than just silently not being constant. If you don't think about const-ness, you won't accidentally promise that the class can be used for constants.

Yeah, I think I agree that it's too fragile, and I'd be fine with this choice (to put const before the class). For structs/data classes (if we do them) perhaps it would be reasonable to say that data class means both immutable and const though?

@lrhn
Copy link
Member

lrhn commented Aug 10, 2022

Data classes/structs can be constant if their superclass is constant (and the superclass must be an abstract struct or the Object (or Struct, if we have it) class, so that should hold inductively), and if their primary constructor initializer/default-value expressions are constant (or at least potentially constant).

The current proposal allows non-potentially-constant initializer expressions.
That means that

struct Foo({List<int> indices = [0]});

cannot have a constant constructor.

Again it becomes fragile to infer const for the constructor, because a slip of the hand, like writing = [] instead of = const[], will turn the struct from constant to non-constant without any real warning.

If initializer expressions have to be constant for structs, then we make all structs const, but I think that'll be too restrictive. There will be uses for structs that have mutable default values.

I mentioned earlier that we could have separate syntaxes for constant default values and non-constant initializers, say:

struct Foo({int x = 0, List<int> l ??= <int>[0]});

That would allow a struct with only = default values to be implicitly const, and using ??= being the way to opt out.
You still don't have a way to opt out of providing a const constructor other than introducing a ??= initializer.

I'd still prefer to go with const struct Foo(int x, int y) to make the primary constructor const, rather than making it implicit.

@mit-mit mit-mit changed the title Should primary constructors be generalized to classes? Primary constructors on classes May 2, 2023
@mit-mit mit-mit changed the title Primary constructors on classes Primary constructor on classes May 2, 2023
@eernstg
Copy link
Member

eernstg commented May 12, 2023

Cf. #3023, a concrete proposal about this feature.

@cedvdb
Copy link

cedvdb commented Dec 1, 2024

according to your theory, you are losing the cohesion

It is not according to my theory, it is by definition.

Rereading your previous comment, It is indeed annoying to rewrite the parameters for different constructors (not 100% sure that's where you are getting at), but I'd prefer:

class A {
  final int z;
  constructor(final int x, { required final int y }): z = x * 2;    // or "this" or "primary", I don't mind this
  A.another( { int y = 3 }) ; // passthrough because not the primary
}

where

  • the primary constructor acts as described in the design doc
  • parameters are added to a passthrough constructor as they are defined in the primary constructor unless
  • unless they are overwritten
  • The pass through constructor has to be valid: if there are named parameters in the primary constructor, you cannot use positional parameters in the passthrough constructor, etc.
  • the passthrough constructor ultimately redirects to the primary constructor (executing the initializers). Or put another way, the initializer of the primary is also added to the passthrough constructor
  • (maybe) a constructor is either primary or passthrough (so the, ugly, keyword "passthrough" does not exist in code as it is unnecessary)

This makes a lot more sense to me because what's in the primary constructor is the full thing and not potentially partial. What do you think ?

@tatumizer
Copy link

tatumizer commented Dec 2, 2024

I still think the declarations should be kept separate. Maybe the explicit "passthrough" keyword is unnecessary: the compiler can automatically add all passthrough parameters to every generative constructor (normally, there's only one), or some shorter syntax like Foo(int z, ...) could be supported to the same effect.
The feature is not supposed to cover all possible scenarios, just the most common ones.

@tatumizer
Copy link

Consider an example:

class A {
  final int a;
  A(int a): a =  a==0? 42: a;
}

If we rewrite it using this(...) syntax, we get

class A {
  this(final int a): a==0? 42: a;
}
  1. Will it compile?
  2. Will it work?

(Asking for a friend 😄)

@eernstg
Copy link
Member

eernstg commented Dec 4, 2024

(Friends are great! 😄)

Here's the example again:

class A {
  this(final int a): a == 0 ? 42 : a;
}

Wouldn't that be something like this.a = a == 0 ? 42 : a? I don't think anybody has proposed that the initializer list can contain plain expressions, and I'm not sure what it means. I'll assume that it is a normal initializer list entry.

Anyway, the cool thing in this example would be that we avoid the duplication of the variable name, and yet we compute the value rather than just using the actual argument directly.

I think this can be modeled rather naturally as follows, assuming the proposal here:

class A {
  this._(final int a);
  A(final int a) : this._(a == 0 ? 42 : a);
}

However, if we don't have specific plans to use this 2-step separation of the object initialization (e.g., by having several different redirecting constructors) then we might just as well use a completely normal constructor:

class A {
  final int a;
  A(int a) : a = a == 0 ? 42 : a;
}

We could try to come up with a generalization of the initializing formal mechanism by introducing a computation into the parameter declaration. For example, we could associate a function with the initializing formal:

class A {
  final int a;
  A((this.a: (it) => it == 0 ? 42 : it);
}

This would specify that this.a is initialized by obtaining an actual argument, arg, invoking the given function on arg to get an object v, and storing v in the slot for the given instance variable. We could allow this computation to be specified using an existing function:

int foo(it) => it == 0 ? 42 : it;

class A {
  final int a;
  A((this.a: foo);
}

This could be carried over to primary constructors:

// Using a function literal.

class A(final int a: (it) => it == 0 ? 42 : it);

// Using an existing function.

int foo(it) => it == 0 ? 42 : it;

class A(final int a: foo);

The basic idea is simply that we're considering the meaning of initializing formals (basically, it's an implicitly induced x = x in the initializer list) and then generalizing it (to have x = e in the initializer list, where e is an expression that may contain x).

It's a crowded syntactic space. For example, an optional parameter could become something like final int b: ((it) => longExpression) = defaultValue, which is definitely not very readable. In any case, I think it's useful to be aware of the potential for generalizations on top of the initializing formals mechanism (and, by extension, elements of primary constructors with an initializing-formal-ish semantics).

@tatumizer
Copy link

tatumizer commented Dec 4, 2024

You see what happens when you start from a Schrödinger definition that superimposes field declarations over parameter declarations? Everything becomes a problem requiring more and more of exotic syntax and conventions, but WHY?
What's wrong with keeping the fields separate from the parameters?
If you are so in love with this(...) syntax - fine, let's use it while declaring "passthrough" fields - now you can write either

class A(final int a, final int b) { // declaring class name AND passthrough fields
    //,,,
} 

or (interchangeably - like in your proposal)

class A {
   // declaration of passthrough fields
   this(
    final int a, 
    final int b
   ); // NO CODE! This is just a declaration
}

BUT, if you are not happy with the automatically generated passthrough constructor, you can write it explicitly:

class A(final int a, final int b) {
    A(a, ...): a = a==0?42:a; /// ... is a placeholder for other passthrough parameters
} 

Note that in the above notation, there's no way to use the parameter b: it's not even there in a parameter list, so you can't confuse it with the field. But when you want to intercept a passthrough parameter a, you do it explicitly, by including it into a parameter list. And now you have a parameter a, AND a field a, and everything works the way it used to work before.
There are no new concepts or formats except the (informal) term "passthrough", plus a new syntax token ....
Please think about it for 5 minutes - you will see the advantages (basically, by keeping the fields separate from parameters, you are expanding your "design space").

Some examples of what is possible to do with "passthrough":

class A extends B {
  this( // passthrough fields -NAMED ONLY
    final int x;
    super.a,
    super.b,
   // to include *all* named parameters of super constructor nnn, we can write
   ... super.nnn // include B.nnn constructor parameters
  );
  // autogenerated if there's no explicitly defined generative constructor
  A(...): super(...);
  // what if our constructor has a positional parameter? Then we have to write
  final int something;
  A(this.something, ...): super(...); 
  // what if super has a positional parameter? Then we pass it by hand
  A(int z, ...): super(z, ...); 
}

Is there anything missing?
EDIT: maybe the positional vs named parameters issue must be handled more carefully. Leaving it as TBD for now.

@eernstg
Copy link
Member

eernstg commented Dec 5, 2024

You see what happens when you start from a Schrödinger definition that superimposes field declarations over parameter declarations?

I was trying to respond to your idea about having an initializing formal (or a primary constructor parameter whose meaning in most proposals is an initializing formal plus an instance variable declaration), and then inject some sort of computation that may use the actual argument and yields the initial value of the instance variable. At least, that's how I understood this(final int a): this.a = a == 0 ? 42 : a;.

It's a simple idea:

// Initializing formal.
class C {
  final int i;
  C(int i): this.i = i; // Can be written as `C(this.i);`
}

// Generalized initializing formal.
class D {
  final int i;
  D(int i): this.i = expressionUsing(i); // Maybe `C(this.i: expressionUsing)`.
}

However, this is just a brief thought experiment, I don't think this idea is likely to become a language mechanism. The syntax is too esoteric, as you say, and it is not obvious to me how it could be made much more readable without destroying the brevity which is the main purpose. (It's still a fun idea, though ;-).

For the passthrough mechanisms, I think they are more related to enhanced default constructors because the starting point is the variable declaration rather than the parameter declaration, and also because it includes steps whereby the parameter list of a constructor is modified (here: by implicitly inducing a named formal parameter declaration for each passthrough variable).

The enhanced default constructors were criticized for their readability properties: There is (arguably) too much complexity in the process whereby a reader of a constructor declaration can determine how to call it (if the declaration exists at all in the source code). This is also the main reason why I'm focusing on mechanisms where variable declarations are derived from parameter declarations and not the other way around.

@tatumizer
Copy link

tatumizer commented Dec 5, 2024

The difference is that "enhanced default constructors" proposal (EDC for brevity) does NOT introduce the notion of "passthrough fields", which is a cornerstone of the feature. With the passthrough (PT) fields declared in a special section, the format of the invocation exactly matches the structure of the field declarations. In EDC, every field is included in the generated constructor, but this is not the case in PT. I'm not sure how to name this declaration section: I don't like "this", others don't like "passthrough", but I found another word in EDC proposal: "forward" - maybe not an ideal word, but arguably less controversial:

class A extends B {
  int foo; // not included in a constructor
  int bar;
  forward(
     final int a = 0,
     final int b = 1,
     String super.x, // the type, if specified, must match the type of x in super
     double super.y = _,  // _ says that there's a default value, but we don't care what it is
    //etc.
  );
}

It's clear from the definitions which parameter is optional and which is required. The only problem is positional parameters.
They are relatively rare (in Flutter, we can find many constructors with 20 named parameters and at most one positional parameter), so the straightforward way of passing them would be by manually adding an extra parameter explicitly:

A(int z, ...): super(z, ...);

but I've not given it much thought yet - maybe there's a better option like

forward(
  positional final int foo,
  positional super.z,
  int super.bar
)

With the introduction of a special declaration block like forward, we have an option of adding some specific syntax there, which would be difficult with the 3 competing proposals (EDC, "primary", "declaring").

Do you still see any "readability issues" with the proposed format?

EDIT: among other things, the proposed "forward" feature (quite expectedly) solves the (highly in-demand) problem of forwarding:

class A extends B {
  forward (
     String? super.xxx
  )
}

If the parameter is omitted while calling the constructor A(), and not observed (through interception) it won't be passed into B at all (as opposed to passing null).

BONUS: You don't have to write "required" while declaring the forward fields.


IMO, dart's insistence on having two colors of parameters is a major design mistake. There's no logical reason for this. I can't find a single instance of dart's API where positional parameters have meaningless names to the extent they cannot be used publicly. At least, this never happens in constructors. It's OK if a tear-off loses the names, but here. we are are not talking about tear-offs, or scratch functions, or closures - it's all about constructors.

@tatumizer
Copy link

tatumizer commented Dec 16, 2024

I had a fruitful brainstorming session with ChatGPT (after seeing a youtube video from a physics professor debating an unresolved problem with it and being impressed).
FWIW, ChatGPT suggests the syntax like this:

class A {
  final int a;
  final String b;

  A({this.*});
}

See the entire response inside.

ChatGPT response To address the problem of redundant field declarations and constructor parameter definitions in Dart, a more concise syntax could be designed that allows for a simpler way to declare fields and initialize them in the constructor without repeating the names.

Here’s an idea for the new syntax:

Suggested Syntax:

class A {
  final int a;
  final String b;

  A({this.a, this.b});
}

This is essentially the same as your original code, but it's still verbose since the field names and the constructor parameters are repeated. We can simplify this.

Proposal: Implicit Initialization from Constructor

We could introduce a feature where the fields are automatically initialized from the constructor parameters without needing to repeat their names. The idea would be something like this:

class A {
  final int a;
  final String b;

  A(this.a, this.b);
}

This syntax avoids repeating the field names, as the constructor automatically initializes fields with the same name as the parameters.

Further Simplification: Using constructor Block

If we want to reduce verbosity even more, a Dart-specific feature like a constructor block could be introduced, where the fields are implicitly initialized. The constructor would automatically pick up any field names that match its parameters.

class A {
  final int a;
  final String b;

  A({this.*});
}

The this.* pattern would indicate that all constructor parameters should automatically be used to initialize matching fields.

In this proposal, you don’t even have to specify each field individually. Dart would implicitly link constructor parameters with the fields having matching names. This would be very similar to Python's dataclasses or Kotlin's data classes, which allow automatic property initialization.

Final Thoughts:

  1. Simplifying the Syntax: The idea behind this proposal is to minimize the redundancy of writing field names twice, while still keeping it clear and intuitive. Dart could use an implicit constructor initializer if the parameter names match the field names.

  2. Flexibility and Clarity: The choice to either explicitly define field names or to have implicit initialization would be left to the developer. More complex constructors with transformations or custom logic could still manually assign field values, while simple classes could take advantage of the new syntax.

  3. Backward Compatibility: This change should be designed to work with existing Dart code, ensuring backward compatibility. A new feature like this could be introduced as an opt-in option, allowing developers to opt for the new syntax when needed.

This approach will save developers from repeating field names, improving the readability and maintainability of the code.

When asked what to do if we want to leave some fields out of the constructor initialization, it suggested marking the relevant fields with @constructor annotation, and still use the above syntax. For the super parameters, it suggested the syntax like

class A extends B {
  final int a;
  final String b;
  A({inherit super.*, this.*});
}

I find the answers quite reasonable. Rather than using an annotation, I'd still prefer to place the fields in a kind of block, but I'm not sure about the syntax. Let's try "this":

class A {
  this {
    final int a;
    final String b;
  };
  Foo? _foo;
  A({this.*});
}

@eernstg
Copy link
Member

eernstg commented Dec 16, 2024

@tatumizer wrote:

enhanced default constructors .. does NOT introduce the notion of "passthrough fields",

Aside: I'd prefer to say 'passthrough instance variables' in order to avoid introducing a special word like 'fields' just for this kind of variable—if anything, the non-standard variables are either all non-local variables (because they have a getter and possibly a setter), or all local variables (because they don't). The fact that the getter/setter for the instance variable has late binding is not significant for the variable, that's just a property that all instance getters and setters have. I think we could say 'passthrough variable' (omitting 'instance') because all of them are instance variables.

Anyway, a passthrough variable would be an instance variable at the semantic level, and it's introduced syntactically in your proposal, IIUC, as a formal parameter-ish declaration in a specialized constructor declaration (it occurs in the place where we'd expect a formal parameter, but the declaration syntax itself is the same as for a variable declaration).

The special power which is bestowed upon a passthrough variable is that every constructor (IIUC) will have an implicitly induced initializing formal parameter for each of those parameters (I'm not quite sure how the "unless they are overwritten" mechanism would work).

All in all, this is a mechanism that uses a variable declaration (e.g., in a forward(...) declaration) and generates a set of parameter declarations, which is the same direction of cause and effect as EDCs.

It is arguably more powerful than primary/declaring constructors because it can introduce an unlimited number of parameter declarations based on a single variable declaration.

However, this also means that it has the same readability issues as EDCs: You need to perform a non-trivial amount of computation on the actual constructor declarations (taking other declarations into account as well) in order to understand what it does and how to call it.

ChatGPT suggests the syntax like this: ... [using {this.*}] ...

This would be one step more explicit because the variables will still give rise to a number of constructor parameter declarations (I don't know if they are still called 'passthrough'), and you can't see the actual names.

@tatumizer
Copy link

@eernstg: I don't understand where the "readability" issue comes from.
Here's an example from zig:

const Vec3 = struct { x: f32, y: f32, z: f32 };
const my_vector = Vec3{
        .x = 0,
        .y = 100,
        .z = 50,
    };

Here, the field declarations are "primary", and the "constructor" syntax is ... I woudn't even say "derived" - it's trivial.
Fields in zig can have default values, too - those can be omitted in the initializer. The same is true for many other languages, so we can treat "fields first" approach as a baseline.
What makes dart different?

Maybe it makes sense to go back to the basics then? The original proposal by @leafpetersen is rather simple. Here the quote from the earlier comment:

My sense is that the more we generalize this, the more we lose the actual benefits. Your data scraping suggests that a huge majority of classes don't need the generality. So every bit of generality that we add that makes that majority more verbose has a massive incremental cost in aggregate, and only benefits a few niche cases.

Then the question is: what extra features can be added without inadvertently adding too much complexity?
My thought was that adding a "passthrough/forward/this/something" {...}` section is an acceptable dose of complexity, but then one thing leads to another, and things get more complicated. Maybe it's not worth it then.

@tatumizer
Copy link

tatumizer commented Dec 17, 2024

After staring into the expression {this.*} for a couple of hours, I managed to grok its meaning!
this.* includes the initializers of the form this.name, but only for "public" names! The language doesn't allow us to write {this._name} in a parameter list anyway, and we can profit from this convention immensely!

class A extends B {
   final int a = 0;
   final String? b;
   final Foo? _foo; // NOT included into this.* - has to be handled separately
   A({Foo? foo, super.*, this.*}): _foo = foo {
      // constructor body
   } 
}

The idea is that we can define all normal (passthrough) fields as public - and then they will be included into this.*, but if we don't want a field to have such a standard initializer, we declare it private (_name), and then we can add custom parameters into the constructor.
E.g., we can introduce a positional parameter for foo:

   A(Foo foo, {super.*, this.*}): _foo = foo {
      // constructor body
   } 

Too fancy?

@n7trd
Copy link

n7trd commented Feb 3, 2025

I had exactly the same language improvement in mind when I found your comment.
"Too fancy?" Definitely not!

Just a reminder, this is a common way to code things in flutter:

class Text extends StatelessWidget {
  const Text(
    String this.data, {
    super.key,
    this.style,
    this.strutStyle,
    this.textAlign,
    // ... many more parameters
  });

  const Text.rich(
    InlineSpan this.textSpan, {
    super.key,
    this.style,
    this.strutStyle,
    this.textAlign,
    // ... many more parameters
  });

  final String? data;
  final InlineSpan? textSpan;
  final TextStyle? style;
  final StrutStyle? strutStyle;
  final TextAlign? textAlign;
  final TextDirection? textDirection;
  final Locale? locale;
  final bool? softWrap;
  final TextOverflow? overflow;
  final double? textScaleFactor;
  final TextScaler? textScaler;
  final int? maxLines;
  final String? semanticsLabel;
  final TextWidthBasis? textWidthBasis;
  final ui.TextHeightBehavior? textHeightBehavior;
  final Color? selectionColor;
}

If i could write this instead:

class Text extends StatelessWidget {
  const Text(
    String this.data, {
    super.key,
    this.*,  // Include all public class fields automatically
  });

  const Text.rich(
    InlineSpan this.textSpan, {
    super.key,
    this.*,  // Include all public class fields automatically
  });

  final String? data;
  final InlineSpan? textSpan;
  final TextStyle? style;
  final StrutStyle? strutStyle;
  final TextAlign? textAlign;
  final TextDirection? textDirection;
  final Locale? locale;
  final bool? softWrap;
  final TextOverflow? overflow;
  final double? textScaleFactor;
  final TextScaler? textScaler;
  final int? maxLines;
  final String? semanticsLabel;
  final TextWidthBasis? textWidthBasis;
  final ui.TextHeightBehavior? textHeightBehavior;
  final Color? selectionColor;
}

This would be a HUGE reduction in boilerplate code!

By the way, removing all the fields from the parameter list has actually
an additional advantage because I would finally see at first glance all the required fields and how the constructors differs.
A thing, that is very difficult to see now:

Image

@rrousselGit
Copy link

I personally don't like this.* because we can't define both named and positional fields.

The language doesn't allow us to write {this._name} in a parameter list anyway, and we can profit from this convention immensely!

Maybe that could change. An issue was created recently for this: #4251

So I'd prefer the older proposal about:

class Foo  {
  this(final int a, {final String? b});
}

And add support for private named parameters alongside it.

@cedvdb
Copy link

cedvdb commented Feb 3, 2025

I personally don't like this.* because we can't define both named and positional fields.

Agreed. This would not be an issue if it was the call site that decided whether a parameter is named or not, that is you could use both at all times instead:

class Foo  {
   this(final int a, final String? b);
   Foo.other(this.*);
}

void main() {
  final foo1 = Foo(3, '3');
  final foo2 = Foo(a: 3, b: '3');
  final foo3 = Foo.other(a: 3, b: '3');
  final foo4 = Foo.other(b: '3', a: 3);
}

Just a thought.

@FMorschel
Copy link

FMorschel commented Feb 3, 2025

@cedvdb I think maybe you're looking for #831

@n7trd
Copy link

n7trd commented Feb 3, 2025

I personally don't like this.* because we can't define both named and positional fields.

@rrousselGit That's true.
However, if this.* were allowed just for optional parameters, it would be a huge improvement as well. I don't think it's an either/or situation, but perhaps it could be a separate proposal. Is there a proposal somewhere for this?

@rrousselGit
Copy link

rrousselGit commented Feb 3, 2025

I just don't see any form of this.* give the same flexibility that a constructor offers.
It feels backward to define fields, then infer the constructor. I think Freezed's approach makes more sense: Define a constructor, then infer fields.

And we already have a constructor-first approach in the language: Extension types

extension type Foo(int myField) {
}

This is a primary constructor, just not for classes.
It's more limited than what we'd want for classes, but I think consistency is important.

So I'd go for:

class Foo(final int value);
class const Bar(final int value);
class Baz({int value = 0}) extends Base
    : assert(value > 0),
      super(...);
class Quaz(this.value) { // Using this. and super. don't define a field, but refer to an existing one
  final num value;
}
class Qux(keyword int x):  _value = x  { // Add a keyword for parameters that don't have a matching field
  final Object? _value;
}

It might also be worth considering making fields final by default. That'd be a good opportunity to do so. But that's a different topic

@n7trd
Copy link

n7trd commented Feb 3, 2025

I'm having difficulty seeing how this feature addresses the common use case I described earlier.
While the syntax aims to reduce code duplication by eliminating the need to retype class fields, it appears to fall short in handling more practical scenarios.

class Home extends StatefulWidget {
  const Home({
    super.key,
    required this.useLightMode,
    required this.useMaterial3,
    required this.colorSelected,
    required this.handleBrightnessChange,
    required this.handleMaterialVersionChange,
    required this.handleColorSelect,
    required this.handleImageSelect,
    required this.colorSelectionMethod,
    required this.imageSelected,
  });

  final bool useLightMode;
  final bool useMaterial3;
  final ColorSeed colorSelected;
  final ColorImageProvider imageSelected;
  final ColorSelectionMethod colorSelectionMethod;

  final void Function(bool useLightMode) handleBrightnessChange;
  final void Function() handleMaterialVersionChange;
  final void Function(int value) handleColorSelect;
  final void Function(int value) handleImageSelect;

  @override
  State<Home> createState() => _HomeState();
}

I really have no idea how to rewrite it with the syntax above.
What happens if I want to add a named constructor? Rewrite it?
I can go on with examples and still don't know what benefit it gives.

VS going to a codebase and make this.* :

class Home extends StatefulWidget {
  const Home({
    super.key,
    required this.*
  });

  final bool useLightMode;
  final bool useMaterial3;
  final ColorSeed colorSelected;
  final ColorImageProvider imageSelected;
  final ColorSelectionMethod colorSelectionMethod;

  final void Function(bool useLightMode) handleBrightnessChange;
  final void Function() handleMaterialVersionChange;
  final void Function(int value) handleColorSelect;
  final void Function(int value) handleImageSelect;

  @override
  State<Home> createState() => _HomeState();
}

We really need some examples beyond foo/bar.
I'm really concerned what the code would look like in
a Flutter setting. And while it's true that I can stick
to the "default" class syntax that doesn't save me and others
from reading the new. It also helps not much that it aligns with the extension
syntax which is nice for the use case it has but also very niche.

@rrousselGit
Copy link

rrousselGit commented Feb 3, 2025

Here's your Home updated with the same syntax that extension types use:

class Home({
  super.key,
  required final bool useLightMode;
  required final bool useMaterial3;
  required final ColorSeed colorSelected;
  required final ColorImageProvider imageSelected;
  required final ColorSelectionMethod colorSelectionMethod;

  required final void Function(bool useLightMode) handleBrightnessChange;
  required final void Function() handleMaterialVersionChange;
  required final void Function(int value) handleColorSelect;
  required final void Function(int value) handleImageSelect;
}) extends StatefulWidget {

  @override
  State<Home> createState() => _HomeState();
}

And here's Flutter'sContainer:

class Container({
  super.key,
  /// Align the [child] within the container.
  /// [...]
  final AlignmentGeometry? alignment;
  final EdgeInsetsGeometry? padding;
  final Color? color;
  final Decoration? decoration;
  final Decoration? foregroundDecoration;
  keyword double? width,
  keyword double? height,
  keyword BoxConstraints? constraints,
  final EdgeInsetsGeometry? margin;
  final Matrix4? transform;
  final AlignmentGeometry? transformAlignment;
  final Widget? child;
  final Clip clipBehavior= Clip.none,
})
  extends StatelessWidget
  : assert(decoration != null || clipBehavior == Clip.none),
    constraints = <...> {
  final BoxConstraints? constraints;
}
For reference, here's the current `Container` source:
class Container extends StatelessWidget {
  Container({
    super.key,
    this.alignment,
    this.padding,
    this.color,
    this.decoration,
    this.foregroundDecoration,
    double? width,
    double? height,
    BoxConstraints? constraints,
    this.margin,
    this.transform,
    this.transformAlignment,
    this.child,
    this.clipBehavior = Clip.none,
  }) : assert(decoration != null || clipBehavior == Clip.none),
       constraints = <...>;

  final Widget? child;
  /// Align the [child] within the container.
  /// [...]
  final AlignmentGeometry? alignment;
  final EdgeInsetsGeometry? padding;
  final Color? color;
  final Decoration? decoration;
  final Decoration? foregroundDecoration;
  final BoxConstraints? constraints;
  final EdgeInsetsGeometry? margin;
  final Matrix4? transform;
  final AlignmentGeometry? transformAlignment;
  final Clip clipBehavior;
}

(I'm not super sure about the formatting tbh)

@n7trd
Copy link

n7trd commented Feb 3, 2025

Thanks! That helps a lot.
It would be nice if the 'required' keyword could be removed one day.. I recall seeing a good proposal about this somewhere. Additionally, removing 'final' would be even better 🙂

I have two questions, if you don't mind:

  1. What exactly is keyword?
  2. Is it possible to include named constructors in this context, or is that not feasible?

@rrousselGit
Copy link

rrousselGit commented Feb 3, 2025

What exactly is keyword?

A way to define a parameter without defining a field alongside it.
I'm not sure what the name should be though

Is it possible to include named constructors in this context, or is that not feasible?

Sure is

class MyClass.name(final int count);

void main() {
  MyClass.name(42);
}

All of these have been discussed way higher in the thread. I'm just rehashing already suggested ideas ; with some small twists

@cedvdb
Copy link

cedvdb commented Feb 4, 2025

The primary appeal of this.* to me is for secondary constructors to avoid duplication there. In that case primary constructors and this.* are not mutually exclusive.

class Foo  {
   this(final int a, final String? b);
   Foo.other(this.*);
}

Consider a flutter widget with a lot of properties where you override only a few in a secondary constructor

@eernstg
Copy link
Member

eernstg commented Feb 4, 2025

@n7trd mentioned {this.*} in this comment along with class Text as an example where it would be very effective.

I'm a little bit worried about mechanisms that quantify over groups like "all public instance variables declared by this class/enum/..." because they are quite inflexible. It may seem powerful and convenient in a given example, but if your use case doesn't match this lucky case 100% then you may suddenly have to write everything in full.

One way to find a similar mechanism which is somewhat more flexible is to tie it up to the subsequent invocation (similar to mechanisms that we'd use with forwarding).

We would abstract over all parameters of the forwardee, e.g., by referring to "something with asterisk", based on the syntactic approach taken with {this.*} (it makes sense to me, apparently also to others). However, we'd need to introduce this sequence of parameters and also use it in another position.

So let's say that * in the positional section of a formal parameter declaration list denotes all positional parameter declarations of the forwardee from the position where it occurs. Similarly, it denotes all the named parameter declarations whose name hasn't otherwise been declared when it occurs in {}.

For the invocation where these parameters are passed on, we could use * for the positional parameters and *: * for named parameters.

For example, (*) => print(*) is the same as (Object? arg) => print(arg);. For named parameters, we already have a good example in Text:

class const Text._(
  String? data,
  InlineSpan? textSpan, {
  super.key,
  TextStyle? style,
  StrutStyle? strutStyle,
  TextAlign? textAlign,
  TextDirection? textDirection,
  Locale? locale,
  bool? softWrap,
  TextOverflow? overflow,
  double? textScaleFactor,
  TextScaler? textScaler,
  int? maxLines,
  String? semanticsLabel,
  TextWidthBasis? textWidthBasis,
  ui.TextHeightBehavior? textHeightBehavior,
  Color? selectionColor
}) extends StatelessWidget {

  const Text(String data, {*}): this._(data, null, *:*);
  const Text.rich(InlineSpan textSpan, {*}): this._(null, textSpan, *:*);
  ...
}

The DartDoc page for each constructor should probably show both the expansion where every parameter declaration is written explicitly and the abbreviated form which helps the reader understand that all those named parameters are exactly the same as the ones in Text._ (and Text and Text.rich share those named parameters, so when we've gotten familiar with one of them we already know that we know the other one as well).

This would allow us to get the brevity which has been a main characteristic of primary constructors all the time: The constructor Text._ declares the parameters and the instance variables in a single step, not in two steps where the name of each parameter (and more) is duplicated. Moreover, *:* allows us to avoid the duplication of names two more times, in the two other constructors.

Note that this approach allows us to have additional instance variables whose initialization is handled in a different way (e.g., in an initializer list), and it also allows us to include instance variables whose name is private (#2509, also discussed in earlier comments in this issue).

@rrousselGit wrote:

I personally don't like this.* because we can't define both named and positional fields.

Right, that was also one of the things I was worried about.

But if we're abstracting over callee parameters rather than instance variables then we can do it.

class const A._(int a, {String? b}) {
  A(*, {*}): this._(*, *:*);

  // Of course, if we're just forwarding everything then we could also redirect.
  factory A = A._; // #4135
}

It feels backward to define fields, then infer the constructor. I think Freezed's approach makes more sense: Define a constructor, then infer fields.

Agreed! The language team has had discussions about both approaches, but I also prefer going from the parameters to the instance variables.

So I'd go for:

class Foo(final int value);
class const Bar(final int value);
class Baz({int value = 0}) extends Base
    : assert(value > 0),
      super(...);
class Quaz(this.value) { // Using this. and super. don't define a field, but refer to an existing one
  final num value;
}
class Qux(keyword int x):  _value = x  { // Add a keyword for parameters that don't have a matching field
  final Object? _value;
}

This proposal is already getting somewhat close to that:

class Foo(final int value);
class const Bar(int value);
class Baz({int value = 0}): assert(value > 0), super(...) extends Base;
class Quaz(this.value) {
  final num value;
}
class Qux(this._value) {
  final Object? _value;
}

The last one is easy because this._value works just fine when the parameter is positional. When the parameter is named, we'd need to include something like this feature such that we can have a named parameter whose name for callers is public, but it is at the same time an initializing formal for a privately named instance variable.

class A({int _x = 0}) {
  void foo() => print(_x); // OK, we do have an instance variable `int _x`.
}

void main() {
  A(x: 10); // OK, the name for callers is the corresponding public name for `_x`.
}

@n7trd wrote:

I'm having difficulty seeing how this feature addresses the common use case I described earlier.

Let's try. Here is the original:

class Home extends StatefulWidget {
  const Home({
    super.key,
    required this.useLightMode,
    required this.useMaterial3,
    required this.colorSelected,
    required this.handleBrightnessChange,
    required this.handleMaterialVersionChange,
    required this.handleColorSelect,
    required this.handleImageSelect,
    required this.colorSelectionMethod,
    required this.imageSelected,
  });

  final bool useLightMode;
  final bool useMaterial3;
  final ColorSeed colorSelected;
  final ColorImageProvider imageSelected;
  final ColorSelectionMethod colorSelectionMethod;

  final void Function(bool useLightMode) handleBrightnessChange;
  final void Function() handleMaterialVersionChange;
  final void Function(int value) handleColorSelect;
  final void Function(int value) handleImageSelect;

  @override
  State<Home> createState() => _HomeState();
}

And the abbreviated version, relying on nothing but this proposal:

class const Home({
  super.key,
  bool useLightMode,
  bool useMaterial3,
  ColorSeed colorSelected,
  ColorImageProvider imageSelected,
  ColorSelectionMethod colorSelectionMethod,
  void Function(bool useLightMode) handleBrightnessChange,
  void Function() handleMaterialVersionChange,
  void Function(int value) handleColorSelect,
  void Function(int value) handleImageSelect,
}) extends StatefulWidget {
  @override
  State<Home> createState() => _HomeState();
}

Note that final is inferred because of the modifier const on the primary constructor, and required is inferred because each of the formal parameters has a non-nullable type (potentially non-nullable is enough, by the way), and no default value.

This is very similar to @rrousselGit's Home example, except that it relies on inference to get the modifiers final and required implicitly (in those cases where it would be a compile-time error to leave them out).

The bulky class header with the extends clause pretty far down has been frowned upon many times. But it does get the job done, and a think I could get used to this style of declaration. It is certainly an abbreviation.

What happens if I want to add a named constructor? Rewrite it?

The named constructor should presumably do something which differs from the behavior of Home?

Anyway, let's make the primary one private, and add a few variants:

class const Home._({
  super.key,
  bool useLightMode,
  bool useMaterial3,
  ColorSeed colorSelected,
  ColorImageProvider imageSelected,
  ColorSelectionMethod colorSelectionMethod,
  void Function(bool useLightMode) handleBrightnessChange,
  void Function() handleMaterialVersionChange,
  void Function(int value) handleColorSelect,
  void Function(int value) handleImageSelect,
}) extends StatefulWidget {
  const factory Home = Home._; // #4135
  factory Home.named1({*}) => Home._(*:*);
  const Home.named2({bool useLightMode = true, *}):
      this._(useLightMode: useLightMode, *:*); // Change a parameter to optional.

  @override
  State<Home> createState() => _HomeState();
}

@n7trd
Copy link

n7trd commented Feb 5, 2025

Primary constructors are firmly in the syntactic sugar group. They do not allow you to do anything you couldn't without them, they just provide a shorter, less repetitive, more up-front syntax which is specialized for a particular use case: Small data-driven classes with little abstraction. (irhn 2023)

Given how this proposal evolved, it goes far beyond the original idea. In its current state, it's more of a replacement for the existing class/constructor syntax. It seems to cover everything and more, becoming even more valuable as the number of parameters, fields, and named constructors grows.

There's a reason why I brought all the Flutter examples into the discussion. There's a significant need to eliminate the enormous amount of field repetition due to the way its API was designed. With that in mind, primary constructors fit (unintentionally?) very nicely. Considering this, I don't think they remain limited to specialized use cases anymore.


What I really hope, given all of this, is that it could be introduced more as an "upgrade" to the current class/constructor syntax. How? By sticking to the primary body constructor version and dropping the header variant.

It requires slightly more typing, but at least the class declaration & additions (extends, implements, and so on) doesn't get pushed somewhere between the lines when there are more constructor parameters. AND it deviates less from the current class/constructor in general.

Putting it all together, we get something like this:

class Text extends StatelessWidget {
  
  const Text._({
    String? data,
    InlineSpan? textSpan, {
    super.key,
    TextStyle? style,
    StrutStyle? strutStyle,
    TextAlign? textAlign,
    TextDirection? textDirection,
    Locale? locale,
    bool? softWrap,
    TextOverflow? overflow,
    double? textScaleFactor,
    TextScaler? textScaler,
    int? maxLines,
    String? semanticsLabel,
    TextWidthBasis? textWidthBasis,
    ui.TextHeightBehavior? textHeightBehavior,
    Color? selectionColor
  });

  const Text(String data, {*}): this._(data, null, *:*);
  const Text.rich(InlineSpan textSpan, {*}): this._(null, textSpan, *:*);

}
// edited it. hope it "compiles". Not sure how a private constructor and the proposal fits together  ;-)

looks nice i think 🤔

@tatumizer
Copy link

In dart, lexical scope always wins. Try this out:

class A {
  int x=10;
}
int x=-1; // comment it out and run again
class B extends A {
  foo()=> print(x);
}
void main() {
  B().foo();
}

This program prints -1. If you comment out the declaration int x=-1;, it prints 10.
That makes the idea of this(...) constructor... not sure how to put it... a bit controversial:

int a = -1;
class A {
  this(int a);
  method()=>print(a); // prints -1 regardless of the instance `a`.
}

The idea requires tweaking the scoping rules for no apparent reason.

@n7trd
Copy link

n7trd commented Feb 5, 2025

@tatumizer This wasn't intended as a new idea - I was just trying to convert the code above to use a primary constructor body syntax, but I made a mistake while doing ;-)

@eernstg
Copy link
Member

eernstg commented Feb 7, 2025

What I really hope, given all of this, is that it could be introduced more as an "upgrade" to the current class/constructor syntax. How? By sticking to the primary body constructor version and dropping the header variant.

I'd prefer to have both. I don't really understand why we'd prevent

class const TinyDataThing(int i, String s);

just because

class const Home._({
  super.key,
  bool useLightMode,
  bool useMaterial3,
  ColorSeed colorSelected,
  ColorImageProvider imageSelected,
  ColorSelectionMethod colorSelectionMethod,
  void Function(bool useLightMode) handleBrightnessChange,
  void Function() handleMaterialVersionChange,
  void Function(int value) handleColorSelect,
  void Function(int value) handleImageSelect,
}) extends StatefulWidget {
  const factory Home = Home._; // #4135
  factory Home.named1({*}) => Home._(*:*);
  const Home.named2({bool useLightMode = true, *}):
      this._(useLightMode: useLightMode, *:*); // Change a parameter to optional.

  @override
  State<Home> createState() => _HomeState();
}

may have a header which is unusually verbose and not very readable. In that case, just use the in-body form with Home!

About the scope rules: The intention is that a primary constructor has the special ability to declare formal parameters and at the same time introduce instance variables with the same name (OK, with {int _x = 0} the variable is named _x and the parameter is named x, as proposed in #2509). This means that the instance variables have exactly the same placement in the scoping structure when you write them manually and when you get them from a primary constructor (in the header or in the body, makes no difference). They are completely normal instance variables that are added to the class implicitly.

With Text, we'd get something like the following:

class Text extends StatelessWidget {
  
  const this._({
    String? data,
    InlineSpan? textSpan, {
    super.key,
    TextStyle? style,
    StrutStyle? strutStyle,
    TextAlign? textAlign,
    TextDirection? textDirection,
    Locale? locale,
    bool? softWrap,
    TextOverflow? overflow,
    double? textScaleFactor,
    TextScaler? textScaler,
    int? maxLines,
    String? semanticsLabel,
    TextWidthBasis? textWidthBasis,
    ui.TextHeightBehavior? textHeightBehavior,
    Color? selectionColor
  });

  const Text(String data, {*}): this._(data, null, *:*);
  const Text.rich(InlineSpan textSpan, {*}): this._(null, textSpan, *:*);
}

We do need to mark the in-body primary constructor syntactically because the plain declaration const Text._({...}); already has a meaning (which is: ignore all the parameters and don't declare any instance variables). The proposal currently uses the syntax where the class name is replaced by this, so that's what I've used above.

I do recognize that the this syntax is controversial. Here's a way to think about it that might help: A primary constructor in the body is marked by this because every parameter p that it declares should be understood as this.p. It is also true that it causes p to be declared in the first place, but that's probably easy to remember if you have already been thinking about this.p.

@tatumizer
Copy link

@eernstg: you are addressing just half of the argument: namely, whether it's easy or not to remember that the syntax this(int x) leads to the emergence of an extra field in the class. But it's not a matter of remembering. While the field int x can certainly pop into existence, it cannot pop into the lexical scope. And because it's not in the lexical scope, we cannot refer to it as x in the methods of the class, but should use this.x instead.

final x=0;
class C {
   this(int x=1); // the lexical scope of inner x ends here
   method() {
      print(x); // 0 - b/c it's the outer "final int x=0", not this.x
      print(this.x); // can be anything 
   }
} 

What you are proposing is not just a silent declaration of an extra field, but also the sudden emergence of x in the lexical scope.
Well, you can certainly double down on that by saying x pops into the lexical scope somehow. But then, it's not clear why super.y never pops into my lexical scope.

Also (though it's a minor point compared with the above), the syntax this(x) used in the initializer like

class C {
  int x;
  A(this.x);
  A.named(int x=0): this(x); // HERE it is. `this(x)` in the initializer
}

that this(x) refers to a regular constructor of A, not a special constructor possibly declared as this(int x).
(The syntax this(x) was cited as a justification for the new declaration syntax this(parameters) - but this argument is weak and the similarity might even be confusing).
Again, it's a relatively minor point.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
brevity A feature whose purpose is to enable concise syntax, typically expressible already in a longer form data-classes extension-types feature Proposed language feature that solves one or more problems inline-classes Cf. language/accepted/future-releases/inline-classes/feature-specification.md primary-constructors structs
Projects
Status: Being spec'ed
Development

No branches or pull requests