dart-lang / dart-lang/language
Generalized generative constructor initializer code.
- Dominant language
- TeX
- Stars
- 2.9k
- Forks
- 239
- Avg merge
- 2d 18h
- Merged PRs (30d)
- 14
Description
This proposes a generalization of object initialization, which allows more powerful and expressive computations during initialization, while still maintaining a separation between code running before an object has been fully initialized (no access to `this`) and after (the current constructor body).
## Motivation
The current syntax for initializing instance variables in non-redirecting generative constructors, the "initializer list", is very restricted in what it can express.
The only allowance is assigning the result of a single expression to one instance variable. If two fields need to share a value in any way, say one containing a stream-controller, and another a value depending on the stream of that controller, it cannot be expressed in a single constructor.
The immediate workaround is to use a factory constructor which does all computation, and the calls a private generative constructor which just initializes fields with pre-computed values.
If a public generative constructor is needed, another workaround is to use a forwarding generative constructor which creates the shared object, and pass it to another constructor, which can then refer to it through the parameter, like a kind of "let" constructor using constructor chaining.
## Proposal
Allow initialization of instance fields to happen inside the constructor body, as well as in an initializer list.
To do that, the super-constructor invocation is allowed to be moved into the constructor body as well.
The grammar is changed such that a non-redirecting generative constructor with:
* No initializer list, or
* an initializer list with no `super` constructor invocation
can be followed by a constructor body block which can contain *at most* one `super` constructor invocation as a top-level "statement" in the block.
If such a constructor contains *zero* `super` constructor invocations, one is inserted automatically at the latest possible place where it would be allowed in the body block.
A `super constructor invocation statement has the form super(_args_); or super._name_(_args_);. That is, the same syntax as the entry in the initializer list, followed by a semicolon.
_All existing syntax remains valid. A constructor with no body block, just a `;`, will still get its `super` constructor invocation appended to the initializer list. A constructor with a `super` constructor invocation in the initializer list will work exactly like today._
The behavior of such a constructor is that:
* All code in the body block prior to the `super` constructor invocation is *initialization code*.
* Initialization code can be statements, but access to `this` is restricted as follows:
* Only `this.x` is allowed, where `x` is an instance variable of the current class.
Any other use of `this` is a compile-time error.
* No other access to the instance or instance members otherwise. That includes through `super.foo()` invocations.
* Unqualified variables, `x`, resolving to instance variables are allowed, as equivalent to `this.x`. Those will be
shadowed by parameters or other local variables as normal (unlike initializer lists which allow `x = x` instead
of `this.x = x`).
* Every instance variable of the current class is temporarily given a "definitely/possibly assigned" property,
similar to local variables.
* Every variable already initialized by an initializer expression, initializing formal or initializer list entry is definitely assigned.
* Every other instance variable is definitely unassigned.
* While executing the initialization code, such variables may become definitely assigned if they're, well,
definitely assigned to, using the same rules as for definite assignment of local variables.
* Non-`final`, definitely uninitialized `final`, and potentially unassigned `late final` variables may be assigned to.
* Definitely assigned, or potentially assigned and `late`, variables may be *read*. _(This is new!)_
* At the `super` constructor invocation, all non-nullable instance variables must be definitely assigned.
* The `super` constructor invocation then chains to the superclass object initialization as normal.
* When it returns, the object is fully initialized and the following code may reference `this` freely.
* All instance variables revert to being just instance variables, with no "potentially/definitely assigned" properties.
Because they're all either definitely assigned or `late`.
(Obviously an implementation can remember information for optimizations,
like knowing that a `late` variable is definitely assigned.)
* Local variables defined prior to the `super` constructor invocation are still in scope and can be accessed.
If neither the initializer list, nor the constructor body block, contains a `super` constructor invocation,
an invocation of `super()` is inserted *as late as possible*.
If there is no constructor body block, it's inserted at the end of the initializer list as normal.
If there is a constructor body block, it's inserted at the latest possible point in that block, which means just before the first statement of the block which references `this` or `super` in a way that is not allowed in the initialization code. If there is no such statement, the `super()` constructor invocation is inserted at the end of the constructor body block.
_That is, the only change in behavior occurs when the constructor body block contains a `super` constructor invocation, which is entirely new syntax, or the constructor does not contain any `super` constructor invocation at all. In the latter case, the `super` constructor invocation may be moved to later in the body, so some local computation may now happen before the super-constructor invocation, but it's only about computation ordering, the computations should not affect each other, unless they do so through global state._
A `const` constructor must still not have a body, which restricts them to the existing initializer list and no statement control flow.
## Consequences
With this change, you never need more than one constructor to construct an object.
You can still have multiple constructors, doing different things, but you never *need* to add an extra private constructor just to do more complicated computation before initialization.
### Closures
I conspicuously avoided mentioning closures.
If one creates a closure in the initialization code of a constructor body block, which references an instance variable, and then calls the closures after the `super` constructor invocation, what happens?
*Preferably* it should just work as if the closure had always referenced the same instance variable. But it's not unreasonable that the object doesn't yet exist during initialization, and the `this.x` variables are really place-holder local variables that are being initialized, and only stored into an object later, when it's been allocated.
Maybe that issue solves itself, if creating a closure containing an instance variable will always treat the variable as potentially, but not definitely, unassigned, so any attempt to read it will fail. An attempt to write might be valid, though, if the variable isn't `final`.
The most direct solution is to say that it "just works", but that may force a specific implementation approach onto back-ends, where the object is always allocated first, and variables during initialization are backed by the object instance's memory slots.
In most cases, it just won't matter, because capturing *instance* variables during initialization is incredibly rare, and reusing the closure afterwards is even rarer. And if the compiler can optimize the remaining constructors, a few de-optimized cases won't be a problem.
(But maybe accidentally capturing becomes a bigger issue if we start allowing more kinds of code, like doing `someList.any((x) => x.name == inputName)` where `inputName` is an already initialized non-`final` instance variable.
We can't directly see that this closure *won't* escape to be called after object initialization, so we may need treat the `inputName` field less efficiently during instantiation. But if `inputName` is `final`, we can choose to just close over the value, not the variable, which must be definitely assigned already for the code to even be valid.)
I'd suggest that when accessing instance fields during initialization is allowed, we should also allow closing over them, and then take whatever hit it costs us if someone does that.
An alternative is to not allow reading initialized variables, only allow writing to them. It's slightly less ergonomic, but it's what we do today, and it isn't *too* bad.
## Variants and extensions
### Don't allow reading initialized instance fields
Instead of allowing you to read `this.x` in initializer code if it's already definitely assigned, we just don't allow that.
The only valid use of `this.x` is to assign to it.
That also means that capturing an instance variable is less likely to happen. You have to capture a *write*, `this.x = v`, which only makes sense if the variable is non-`final` or `late` (because otherwise the closure itself forces `this.x` to be potentially assigned, in case the closure is called more than once).
It's still possible to refer to local variables with the same value, it just requires changing:
```dart
Foo(args) : _controller = StreamController() {
_stream = _controller.stream;
}
```
to
```dart
Foo(args) {
var controller = StreamController();
_controller = controller;
_stream = _controller.stream;
}
```
Which isn't bad.
(Or go all-in on brevity and do:
```dart
Foo(args) {
_stream = (_controller = StreamController()).stream;;
}
```
)
### "Factory" generative constructors.
Sometimes you don't want to expose a public generative constructor, because you don't want people to subclass your class through that constructor.
With Dart 3, you can make the class `final` or `interface` to prevent that entirely, but if you want to push subclassing to use a specific constructor, and still expose another constructor for creating instances, you'd have to make the other constructor a factory constructor.
We could allow you to write `factory` on a generative constructor:
```dart
Foo(args) factory : initializerList {body}
Foo(args) factory {body}
Foo(args) factory : initializerList;
Foo(args) factory;
```
The `factory` modifier is put after the constructor parameters, because putting it in front will make the second line above conflict with the existing factory syntax of `factory Foo(args) { body-returning-value }`.
The effect would be that this particular constructor cannot be used as a super-constructor by subclasses (maybe only "outside of the same library", like other access modifiers).
### Initializer list blocks
Rather than, or in addition to, moving initialization into the constructor body, we could allow code blocks inside the initializer list.
```dart
Foo(args) : this.z = z, {
initializer code
}, this.w = w {
body code
}
```
Each initializer list block will be treated the same as the initializer code inside the body proper. It can initialize instance variables, and access ones already initialized earlier in the initializer list.
At the end of it, some instance variables will have been definitely or potentially assigned, and that carries forward to the rest of the initializer list, and the body initializer code, if any.
Local variables in initializer list blocks are *not* visible in later initializers.
Unless we want them to be.
The syntax is a little hard to read, e.g., `Foo(args): {initblock}, {initblock} {bodyblock}`. The separation between initializer block and body block is hard to read. This readability issue is the primary reason why the proposal doesn't try to split initialization code into its own block.
Contributor guide
Research direction
No implementation files, tests, or entry points are named. Start by reviewing the proposal's grammar, initialization semantics, closure behavior, and listed variants, then determine whether the language design has reached an agreed direction. Done would require a settled proposal and corresponding specification work.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- dart
- Domain
- compilers
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Stale
- Clarity
- Needs clarification
- Newbie friendliness
- 25/100