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 · 72 comments
Open
Tracked by #2360

Primary constructor on classes #2364

leafpetersen opened this issue Jul 29, 2022 · 72 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
@Cat-sushi
Copy link

Cat-sushi commented Jul 5, 2023

I don't have a strong opinion that all kinds of classes should have primary constructors.
But, I think that verbose primary constructor like syntaxes are very nonsense.
I think also that, when data classes introduce primary constructors, then there is no additional cognitive load for primary constructors on other classes.

Do you @Hixie want to reject primary constructors on data classes, as well?

@Cat-sushi
Copy link

Records already have a primary constructor like syntax, anyway.

@Cat-sushi
Copy link

Cat-sushi commented Jul 6, 2023

Summary of my opinion.

  • I don't stick to primary constructors, but
  • Simplicity of primary constructors is extremely important, because primary constructors is a syntax sugar.
  • The cognitive load of primary constructors is quite limited, because they have a syntax similar to the existing syntax of parameter definition of function.
  • Also because, type definition of records already have a syntax similar to that of primary constructors.
  • Also because, data classes is introducing primary constructors.
  • A simple and smarter alternative of typedef is a practical use case of implicit primary constructors on inline classes.
  • More complex constructors should use the traditional syntax, and I'm willing to limit the expressiveness of primary constructors.

@Hixie
Copy link

Hixie commented Jul 6, 2023

I am not familiar with a data classes proposal so I have no opinion to offer regarding this proposal's application to that one.

@Cat-sushi
Copy link

Sorry, I've understood the syntax of data classes is not fixed, and I opened new issue #3198.

@lrhn
Copy link
Member

lrhn commented Jul 6, 2023

There are (at least) three different kinds of language features:

  • Those which allow you to do or express new things you couldn't before.
  • Including those that allow you to prevent things you couldn't prevent before.
  • Those that allow you a shorter, more specialized, syntax to do for common cases, which you could do more verbosely before using a more general syntax.

The least one is what we usually call "syntactic sugar", but there's a wonky line beltween "what you cannot do" and "what you cannot reasonably do". It's all Turing equivalent anyway, and we include the lambda calculus, so you can do any computation. You just can't do it inside the language framework we provide (classes, interfaces, well typed functions, etc.)

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.
You can use the syntax for other classes too, but then the benefit may not be as great, or it may even be negative.

In comparison, class modifiers is in the second group: Allowing you to prevent things that you couldn't prevent before. Those are "easy" to implement, they just reject some programs, and everything else works the same as before (hopefully with some more chances for optimization, now that the compiler has more information).

Null safety is in both of the first two groups. It allows you to express information that couldn't be expressed before (this value can be null, that value cannot), and prevent null-unsafe programs that couldn't be prevented before.

Extension methods are technically syntactic sugar for static functions, but within the language framework, they allow you to express things idiomatically that couldn't be expressed that way before.
(We can always argue whether there are design flaws, and whether it's a good thing that extension methods are sometimes better than non-extension methods, even when declared in the same library, but extension methods are being used, successfully, in many domains. And again, specialized synctic sugar doesn't have to be for every case, as long as it works better than what you had before, for the cases where it is intended.)

Patterns are also pure syntactic sugar too, but so concentrated sugar that it allows you to cleanly express something in one line today, that could take ten lines before, and with more code comes more risk of making a mistake.
Patterns come with a high complexity cost (learning that new syntax), but we do hope the productivity benefit will outweigh that.

You can argue against individual features, and will probably be correct in several cases, but in every case it was introduced based on the knownledge available at the time, from the requests of developers who wanted the feature, comparison to other possible features solving the same problems, and the available resources. (Rewriting the language from scratch, with everything we know today, may give the best resulting language, but what will we do in the ten years it takes to get there?)

Every feature should be weighted against the cost of designing and implementing it, including opportunity cost and loss of free syntax space, the complexity it introduces for both implementors and users, and the benefit it eventually brings to users.

Low cost, low complexity, high benefit is awesome, but there are only so many low-hanging fruits.

On the other hand, saying "all the good features are taken" and doing nothing is the way to stagnation.

That means that we are often taking on more costly features now than we have before, because there is less syntax space left to use, more existing features to interact with, and few good low-hanging fruits as alternatives.
But it should still be features that will be useful to some users.
Either because they enable better optimization or better software engineering guarantees by adding restrictions, by providing shorter, or safer, syntax for commonly used programming patterns, or by allowing you to do things you never could before (which usually ends up being changes to the type system, because there's not much you can't do today if you can convince the type system that it's OK.)

@Hixie
Copy link

Hixie commented Jul 31, 2023

This comparison of JetPack Compose, Swift UI, and Flutter is interesting:
https://www.jetpackcompose.app/compare-declarative-frameworks/JetpackCompose-vs-SwiftUI-vs-Flutter
A huge amount of boilerplate would be removed by primary constructors syntactic sugar (the rest would largely be removed by having a macros for disposables, and one of the latter examples suggests the need for a macro for inherited widgets, which is not something I think I'd considered before but in retrospect is low hanging fruit).

I would still prefer to see this syntax be introduced in a manner that builds on macros, and failing at least a syntax that avoids the cliff when you switch from primary constructors to anything else, but I understand the value of the proposal.

@Hixie
Copy link

Hixie commented Aug 1, 2023

With the above in mind I've been trying to think about how I'd use this feature in code (I'm mostly writing sample code these days so it's the kind of code I think would be particularly likely to benefit from this feature).

One thing I've noticed is that it is quite common for constructors to have initializer asserts. I see the current proposal does not support that. It would be unfortunate to have to specify all the fields explicitly just to be able to add asserts to document the class contract. I expect if we don't support initializer asserts in the syntactic sugar for constructors we may have a chilling effect on the user of such asserts, which would be unfortunate given how valuable they have been so far.

@eernstg
Copy link
Member

eernstg commented Aug 7, 2023

The cliff issue and the support for assertions could be handled by adopting this proposal, which is mentioned in the primary constructor proposal in the discussion section.

The basic idea is that a primary constructor has a magic power (that is, the ability to implicitly induce instance variable declarations), and that specific power could just as well be given to a distinguished non-primary constructor. In that case there can't be a primary constructor, and there can't be more than one of those distinguished non-primary constructors. The distinguished non-primary constructor would have an explicit syntactic marker (in the proposal it's var). We could call it a var constructor.

Except for the fact that some parameters can (and must) induce a variable declaration, the var constructor offers all the normal constructor affordances, including initializer lists that may contain assertions.

The syntax would of course need to be discussed; for example, const var or var const would be the natural syntax at the beginning of a constant var constructor declaration, and this is probably going to give rise to protests. Anyway, the underlying idea can be combined with many different syntactic forms, and we could discuss those two things separately.

leafpetersen referenced this issue in munificent/gallery-constructors Aug 29, 2023
- A "this." parameter can implicitly introduce a new field if there
  isn't already a field with its name. If so, the field's type is the
  same as the parameter's.

- The constructor doesn't have to repeat the class name and can instead
  be just "const" or "new".
@n7trd
Copy link

n7trd commented Oct 8, 2023

What's wrong with this? I think it is short enough...

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

Other languages have a lot more surrounding noise around.

Sure, class Point(int x, int y) is shorter, but it's a new syntax for the same thing.
I don't know if creating classes faster than ever before is such an important use case for most Dart/Flutter devs 👋 .

Also, how should this be documented reasonably if the list of parameters (ehh fields) grows?
Things can quickly get out of hand on the Flutter side. I don't want to blame initializing formal parameters (if that was the name) for the following, but it surely helped.

Bildschirmfoto 2023-10-09 um 00 40 30

@jodinathan
Copy link

I don't think ThemeData is a use case for this feature

@eernstg
Copy link
Member

eernstg commented Oct 9, 2023

Right, cf. this remark.

@n7trd
Copy link

n7trd commented Oct 11, 2023

I don't think ThemeData is a use case for this feature

You are correct; ThemeData is not an appropriate use case for this feature. I used ThemeData as an example to illustrate how some Flutter API documentation appears from a user's perspective. It's evident that this issue needs to be addressed.

However, this feature appears to worsen the problem by introducing additional functionality, now declarations inside the constructors. My concern is that this might make addressing the issue more challenging.

If the primary purpose and constrain of this feature is to create classes with a limited number of required fields for initialization, then this feature could be beneficial.

@eernstg
Copy link
Member

eernstg commented Oct 11, 2023

We have discussed various readability issues that may come up in the case where a primary constructor contains a large number of elements. The answer is always "then don't use a primary constructor". With that in mind, it should not be a problem that this mechanism is optimized for cases where it is small and simple. It is purely a piece of syntactic sugar, and nobody needs to use it if they don't like it in a particular case.

@cedvdb
Copy link

cedvdb commented Dec 1, 2023

I'd rather:

class Point {
   Point(int this.x, int this.y);
   Point.onXAxis(this.x) : y = 0;
}

@pedromassango
Copy link

Improvements on the OP, replace:

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

To

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

@jodinathan
Copy link

We have discussed various readability issues that may come up in the case where a primary constructor contains a large number of elements. The answer is always "then don't use a primary constructor". With that in mind, it should not be a problem that this mechanism is optimized for cases where it is small and simple. It is purely a piece of syntactic sugar, and nobody needs to use it if they don't like it in a particular case.

I think we do need something that screams that a class is exposing implicit fields, however, maybe we could just add a flag to the class:

// works
primary class Foo {
  Foo(field int x);
}
// exception: you can't have primary constructor stuff without flagging the class as primary
class Foo {
  Foo(field int x);
}
// lint: primary flag not needed
primary class Foo {
  Foo();
}

@cedvdb
Copy link

cedvdb commented Feb 2, 2024

@jodinathan there doesn't need to be two keywords there, imo this is fine

class Foo {
  Foo(field int x);
}

but I prefer

class Foo {
  Foo(int this.x);
}

as it is more familiar

@jodinathan
Copy link

yeah, I do like

class Foo {
  Foo(int this.x);
}

but I am not sure if the class declaration should warn that it has magical constructors or not

I had some hard time with TypeScript because of that

@lrhn
Copy link
Member

lrhn commented Feb 2, 2024

Since

  Foo(int this.x);

is already valid and useful syntax, we can't use that.
However, if we prefix it with var or final, then it's, IIRC, not currently valid, and if it is, or won't be missed if we take it for implicit field declarations.
So

class Foo { 
  Foo(final int this.x); 
}

would declare a (final) instance variable final int x.

If we don't want it in an initializing formal, it can probably go in the initializer list too

class Foo { 
  Foo(int x): final int x = x; 
}

Not sure how I feel about the readability.
Definitely don't want to allow spreading such field declarations across multiple constructors.

@rrousselGit
Copy link

rrousselGit commented Mar 3, 2024

I think we're straying away from the initial goal of primary constructors, which is to find a syntax as succinct as possible for the 95% use-case

I hope that we'll one day have nested class definition, for the sake of sealed classes. And primary constructors would play a large role in the usability here

IMO there's a big difference between:

sealed class Entity {
  class City(final String name, {required final int population});
  class Person(final String name, {required final int age});
}

vs:

sealed class Entity {
  class City {
    City(String name, {required int population})
          : final String name,
            final int age;
  }

  class Person {
     Person(final String name, {required int age})
          : final String name,
            final int age;
  }
}

I'd personally prefer a syntax that's limited but effective. And suggest users to refactor to normal constructors if they need something more specific.

I assume a good IDE refactor would go a long way here.

@cedvdb
Copy link

cedvdb commented Mar 3, 2024

I'd personally prefer a syntax that's limited but effective. And suggest users to refactor to normal constructors if they need something more specific.

Agreed, this one seems unnecessary:

class Foo { 
  Foo(int x): final int x = x; 
}

Hopefully it can also support private members:

class Foo { 
  Foo({ final int this._x }); 
}

final foo = Foo(x: 3);

@rrousselGit
Copy link

rrousselGit commented Mar 3, 2024

I'd stick to the syntax used by extension types personally.

extension type Example(/* primary constructor*/) {
   // Optionally define extra constructors if we wish to
   Example.name(/* named constructor */ ): this(...);
}

Just replace extension type with class, and we're done :D

I'd find it more confusing if classes deviated from extension types in that regard.
If extension types already have primary constructors, to me, all primary constructors should match that syntax.
Be it for classes, extension types, or even enums.

In that sense, I personally really dislike how pattern matching introduced switch () { case value: vs switch () { value =>. I get why. But it trips people up. I've seen lots of beginners confused by the difference.

@mit-mit mit-mit added the feature Proposed language feature that solves one or more problems label Apr 18, 2024
@mit-mit mit-mit moved this to Being spec'ed in Language funnel May 27, 2024
@eernstg eernstg added the brevity A feature whose purpose is to enable concise syntax, typically expressible already in a longer form label Oct 30, 2024
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