Alternate Primary Constructors proposal: Dissociate "Header" from "Constructor"
Edit 1:
Added an example using this.field to manually initialize a specific field. See 'Exclude some fields from ...'
Current primary constructor proposals consider the "class header" (cf class Person(int age, String name)) as a constructor definition.
At the same time, proposals don't offer a mean for "class headers" to define asserts/super constructor/initializers/....
This leads to the creation of yet another kind of constructors: "in-body constructors" (cf class Person { this(int age); }).
IMO this over complexify the feature and leads to tough discussions around syntax for defining fields. Cf https://github.com/dart-lang/language/issues/4396
In this issue, I'd like to propose an alternative:
- Implement "class header", but not consider them as constructors.
- Add a syntax to inject all fields defined in the "class header" as parameters in a constructor:
class Example(int a) { Example(...); // < '...' adds all fields from the class header as parameter in the constructor } - Change the default synthetic constructor that all classes have to also include the class header
This take on headers enable adding asserts or initializers or "non-field parameters" or super constructors, without any impact on class headers.
Table of content:
(links don't work because of Github, but it helps visualize the proposal! :smile:)
- TL;DR syntax usage
- Class with just a header
- Class with a header and a constructor
- Constructors with non-field parameters: Using
...to refer to the header inside constructors - Adding asserts, initializers and super constructors to classes with headers
- Adding
constto a header class.
- Going further:
- Mutable fields
- Optional header parameters
- What if a class has a header, but a constructor has no
...? - Support
;instead of{}as class body - Exclude some fields from
...
- Key takeaways
TL;DR syntax usage
Class with just a header
class Person(String name, {int age}) {}
// ✅ Good usages
final john = Person('John', age: 42);
// ❌ Bad usages
final john = Person('John');
// ^ 'age' is required but missing
final john = Person('John', age: 42);
john.age = 42;
// ^ Fields defined through the header are 'final' by default
[!NOTE]
The header does not define a constructor. ThePerson('', age: 42)constructor wasn't defined by the header, but instead the "default synthetic constructor" that Dart adds when a class has no constructor.class Example(int a) {}is strictly identical to:
class Example(int a) { Example(...); // < Inject the class header inside the constructor }
Class with a header and a constructor
class UserList(List<User> users) {
const UserList.empty()
: users = const <User>[];
}
// ✅ Good usages
const list = UserList.empty();
// ❌ Bad usages
final list = UserList([]);
// ^ No `UserList()` is present on the class.
// Since we defined one constructor by hand, then the "synthetic constructor"
// that Dart adds when no constructor is present is no-longer added.
Notice how adding UserList.empty() removed the default UserList(), even though we still specifying a "class header".
Constructors with non-field parameters: Using ... to refer to the header inside constructors
Constructors can use ... to "inject" all fields from the class header onto the constructor.
If fields are "named", then a required named parameter will be used. If fields are positional, then a positional parameter will be used.
Parameters can be defined on a constructor before or after the .... This will impact whether new positional parameters are added before or after those from the header.
class Example(int b) {
Example(this.a, ..., this.c)
final int a;
final int c;
}
final example = Example(1,2,3);
print(example.a); // 1
print(example.b); // 2
print(example.c); // 3
Adding asserts, initializers and super constructors to classes with headers
Since headers are not constructors, if we want to add asserts & co, we should define a constructor.
class Example(int a) {
// We can use asserts
Example(...): assert(a > 0);
}
Example(1); // OK
Example(-1); // KO, not positive
The same logic applies to initializers:
class Example(int a) {
// We can use asserts
Example(...)
: now = DateTime.now();
final DateTime now;
}
Example(42);
And same goes with super constructors:
abstract class Base {
Based.named(int value);
}
class Example(int a) extends Base {
// We can use Inheritance and specify `super(...)`
Example(...): super.named(a):
}
Example(42);
Adding const to a header class.
By default, writing class Example(int a) {} does not produce a const constructor.
This is consistent with how class Example {} does not support const Example()
To add const to such class, simply define a const constructor alongside ...:
class Example(int a) {
const Example(...);
}
[!NOTE]
If we want to go further, we could supportconst class Example(int a) {}If we do so, I would expect that the following should work too:
const class Example {} const example = Example();
Going further:
Mutable fields
Mutability support in the header could be done with an extra keyword:
class Person(var String name, {var int age}) {}
final person = Person('', age: 42);
person.age++;
person.name = 'John';
Optional header parameters
By default, header parameters are required. This matches Records ; which only possess required values.
To customize this, we can either add ? before the name of the variable (not the type, the name)
class Person({int ?age}) {}
// ^ Defines a `int? age' field, where the parameter is optional
Alternatively, we can specify a default value:
class Person({int age = 0}) {}
What if a class has a header, but a constructor has no ...?
Using ... inside constructors is not mandatory.
When a class specify a header, the associated field must always be initialized. But this doesn't have to be done through ...
We can use this.field, or initializers:
class Example(int a, int b) {
Example(this.a)
: b = 42
}
Support ; instead of {} as class body
For convenience, we could support replacing {} with ;:
class Example(int a);
// ^ This is identical to:
class Example(int a) {}
Although this is purely stylistic and not critical.
Exclude some fields from ...
Sometimes, a constructor may want to purposefully not include a parameter that is defined in its "header".
To do so, we could rely on initializers:
class Person(String name, int age) {
Person(...)
: age = 42;
// ^ 'age' is manually initialized, and therefore `...` does not import the `age` parameter
}
Person('john'); // OK
Person('john', 42); // KO, only one parameter can be passed
This could work too by relying on this.field in constructors if we wished.
The following snippet defined age as positional, but converts it to a named parameter:
class Person(String name, int age) {
Person(..., {required this.age});
}
Key takeaways
The benefit of this approach is:
-
We don't define a new kind of constructors. There's no such thing as "in-body constructor" here. We keep using the old constructor syntax everyone is used to, just with the added
...features + headers -
We don't need to differentiate between "field parameter" vs "non-field parameter". The header always defines fields. And constructors always define nothing but parameters.
-
Transitioning from a one-liner class with just a header to a complex class with asserts is simple. We go from:
class Person(String name, {int age}) {}
To:
class Person(String name, {int age}) {
Person(...):
assert(age >= 0);
}
The transition is simple to do.
We don't need to change how fields are defined. We don't remove the header either We just add a plain old constructor, with asserts/initializers/...
Interesting! This would certainly fit well together with some kind of parameter/argument list abstraction.
- We don't define a new kind of constructors. There's no such thing as "in-body constructor" here. We keep using the old constructor syntax everyone is used to, just with the added
...features + headers
It doesn't define a new kind of constructor, but it still defines a new kind of parameter list that can appear in the class header and a new ... syntax that can appear in a constructor parameter list. That doesn't seem like less total complexity to me.
- We don't need to differentiate between "field parameter" vs "non-field parameter". The header always defines fields. And constructors always define nothing but parameters.
- Transitioning from a one-liner class with just a header to a complex class with asserts is simple.
Sure, adding assert() is an easy transition. But those aren't very common. What if you have a class like:
class Foo(int a, int b, int c, int d, int e, int f) {
Foo(...) : assert(a < f);
}
Later, you realize that you don't want to store c directly as a field. If I understand your proposal, since c is not the last positional parameter, you are forced to change the class to:
class Foo() {
final int a;
final int b;
final String c;
final int d;
final int e;
final int f;
Foo(this.a, this.b, int c, this.d, this.e, this.f) : assert(a < f), c = c.toString();
}
That seems like a pretty rough cliff to make users climb. In contrast, with an in-body primary constructor, you start with:
class Foo {
this(
final int a,
final int b,
final int c,
final int d,
final int e,
final int f,
) : assert(a < f);
}
And then you change it to:
class Foo {
final String c;
this(
final int a,
final int b,
int c,
final int d,
final int e,
final int f,
) : assert(a < f),
c = c.toString();
}
What if you have a class like:
class Foo(int a, int b, int c, int d, int e, int f) { Foo(...) : assert(a < f); }Later, you realize that you don't want to store
cdirectly as a field. If I understand your proposal, sincecis not the last positional parameter, you are forced to change the class to:class Foo() { final int a; final int b; final String c; final int d; final int e; final int f; Foo(this.a, this.b, int c, this.d, this.e, this.f) : assert(a < f), c = c.toString(); }
You can do better. You'd have:
class Foo(int a, int b, String c, int d, int e, int f) {
+ Foo(this.a, this.b, int c, ...) : assert(a < f), c = c.toString();
}
This isn't too bad.
And to be fair, this issue only happens because you're using a large number positional parameters. I highly doubt that your example will be common. Having 5+ positional parameter is really bad practice I'd say. And it's even rarer to have an initializer in this scenario.
I'm not sure how you usually gather usage statistics on Github. But I'd expect your example to almost never come up.
Even when a constructor has positional parameters, it's usually just 1 or 2 params, followed by named parameters. Like Text('Hello world', key: Key(...), style: ...). And this would be easy to transition.
A more realistic example is:
class Foo({int a, int b, int c, int d, int e, int f}) {}
And then we have:
class Foo({int a, int b, String c, int d, int e, int f}) {
Foo({..., required int c}): c = c.toString();
}
That's significantly better I'd say.
I would like this proposal better if all parameters were also updated to final by default. It's a breaking change, but the behavior would be consistent across headers, parameters, and records.