Skip to content

Commit

Permalink
Update primary constructor proposal based on email thread (#4161)
Browse files Browse the repository at this point in the history
Update the primary constructors feature spec to support something like the features mentioned by Lasse in email on Nov 8th: Allow initializer lists to have `this.x = e` and `super.name(...)` elements, not just assertions, and specify that a variable `x` which is used in an initializing element (of the form `this.v = ...x...` or `v = ...x...`) or in a superinitializer (`super(...x...)`) does not introduce an instance variable in the class.
  • Loading branch information
eernstg authored Nov 12, 2024
1 parent 48d3cbd commit a530cd6
Showing 1 changed file with 142 additions and 126 deletions.
268 changes: 142 additions & 126 deletions working/2364 - primary constructors/feature-specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Author: Erik Ernst

Status: Draft

Version: 1.3
Version: 1.4

Experiment flag: primary-constructors

Expand Down Expand Up @@ -81,16 +81,15 @@ The basic idea is that a parameter list that occurs just after the class
name specifies both a constructor declaration and a declaration of one
instance variable for each formal parameter in said parameter list.

A primary constructor cannot have a body, and it cannot have an normal
initializer list (and hence, it cannot have a superinitializer, e.g.,
`super.name(...)`). However, it can have assertions, it can have
initializing formals (`this.p`) and it can have super parameters
(`super.p`).
A primary constructor cannot have a body. However, it can have assertions,
it can have initializing formals (`this.p`), it can have super parameters
(`super.p`), and it can have an initializer list.

The motivation for these restrictions is that a primary constructor is
intended to be small and easy to read at a glance. If more machinery is
needed then it is always possible to express the same thing as a body
constructor (i.e., any constructor which isn't a primary constructor).
The motivation for the missing support for a body is that a primary
constructor is intended to be small and easy to read at a glance. If more
machinery is needed then it is always possible to express the same thing as
a body constructor (i.e., any constructor which isn't a primary
constructor).

The parameter list uses the same syntax as constructors and other functions
(specified in the grammar by the non-terminal `<formalParameterList>`).
Expand Down Expand Up @@ -211,7 +210,7 @@ class const Point(int x, int y);
enum E(String s) { one('a'), two('b') }
```

Finally, an extension type declaration is specified to use a
Note that an extension type declaration is specified to use a
primary constructor (in that case there is no other choice,
it is in the grammar rules):

Expand Down Expand Up @@ -276,7 +275,7 @@ class const D<TypeVariable extends Bound>.named(
]) extends A with M implements B, C;
```

Finally, it is possible to specify assertions on a primary constructor,
It is possible to specify assertions on a primary constructor,
just like the ones that we can specify in the initializer list of a
regular (not primary) constructor:

Expand All @@ -292,6 +291,48 @@ class Point {
class Point(int x, int y): assert(0 <= x && x <= y * y);
```

Finally, it is possible to use an initializer list in order to
invoke a superconstructor and/or initialize some explicitly
declared instance variables with a computed value.

```dart
// Current syntax.
class A {
final int x;
const A.someName(this.x);
}
class B extends A {
final String s1;
final String s2;
const B(int x, int y, {required this.s2})
: s1 = y.toString(), super.someName(x + 1);
}
// Using primary constructors.
class const A.someName(int x);
class const B(int x, int y, {required String s2})
: s1 = y.toString(), assert(s2.isNotEmpty), super.someName(x + 1)
extends A {
final String s1;
}
```

A formal parameter of a primary constructor which is used in a variable
initialization or in a superinitializer does not implicitly induce an
instance variable.

In particular, `int x` does not give rise to an instance variable in the
class `B` because `x` is used in the superinitializer. Similarly, `int y`
does not give rise to an instance variable because it is used in the
initializer list element `s1 = y.toString()`. However, `s2` _does_ give
rise to an instance variable (it is used in the assertion, but that does
not prevent the instance variable from being induced).

## Specification

### Syntax
Expand All @@ -309,19 +350,16 @@ constructors as well.
<primaryConstructorNoConst> ::= // New rule.
<typeIdentifier> <typeParameters>?
('.' <identifierOrNew>)? <formalParameterList>
<assertions>?
<assertions> ::= // New rule.
':' <assertion> (',' <assertion>)*
<initializers>?
<classNamePartNoConst> ::= // New rule.
<primaryConstructorNoConst>
| <typeWithParameters>;
<classNamePart> ::= // New rule.
'const'? <primaryConstructorNoConst>
| <typeWithParameters>;
<typeWithParameters> ::= <typeIdentifier> <typeParameters>?
<classBody> ::= // New rule.
Expand Down Expand Up @@ -378,8 +416,11 @@ and `final`. *A final instance variable cannot be covariant, because being
covariant is a property of the setter.*

Conversely, it is not an error for the modifier `covariant` to occur on
other formal parameters of a primary constructor (this extends the
existing allowlist of places where `covariant` can occur).
another formal parameter _p_ of a primary constructor (this extends the
existing allowlist of places where `covariant` can occur), unless _p_
occurs in an initializer element of the primary constructor which is not
an assertion. *For example, `class C(covariant int p): super(p + 1);` is an
error.*

The desugaring consists of the following steps, where _D_ is the class,
extension type, or enum declaration in the program that includes a primary
Expand All @@ -393,56 +434,28 @@ Where no processing is mentioned below, _D2_ is identical to _D_. Changes
occur as follows:

Assume that `p` is an optional formal parameter in _D_ which is not an
initializing formal and not a super parameter. Assume that `p` does not
have a declared type, but it does have a default value whose static type in
the empty context is a type (not a type schema) `T` which is not `Null`. In
that case `p` is considered to have the declared type `T`. When `T` is
`Null`, `p` is considered to have the declared type `Object?`. If `p`
does not have a declared type nor a default value then `p` is considered
to have the declared type `Object?`.
initializing formal, and not a super parameter. Assume that `p` does not
occur in the initializer list of _D_, except possibly in some assertions.
Assume that `p` does not have a declared type, but it does have a default
value whose static type in the empty context is a type (not a type schema)
`T` which is not `Null`. In that case `p` is considered to have the
declared type `T`. When `T` is `Null`, `p` is considered to have the
declared type `Object?`. If `p` does not have a declared type nor a default
value then `p` is considered to have the declared type `Object?`.

*Dart has traditionally assumed the type `dynamic` in such situations. We
have chosen the more strictly checked type `Object?` instead, in order to
avoid introducing run-time type checking implicitly.*

The current scope of the formal parameter list of the primary constructor
in _D_ is the type parameter scope of the enclosing class, if it exists,
and otherwise the enclosing library scope *(in other words, the default
values cannot see declarations in the class body)*.

*Note that every occurrence of a type variable of _D_ in a default value is
an error, because no constant expression contains a type variable. Hence,
we can proceed under the assumption that there are no such occurrences.*
The current scope of the formal parameter list and initializer list (if
any) of the primary constructor in _D_ is the body scope of the class.

*We need to ensure that the meaning of default value expressions is
well-defined, taking into account that the primary constructor is actually
located in a different scope than normal non-primary constructors. One way
to specify this is to use a syntactic transformation:*

Every default value in the primary constructor of _D_ is replaced by a
fresh private name `_n`, and a constant variable named `_n` is added to the
top-level of the current library, with an initializing expression which is
said default value.

*This means that we can move the parameter declarations including the
default value without changing its meaning. Implementations are free to
use this particular desugaring based technique, or any other technique
which has the same observable behavior. In particular, it should not be
possible for such a default value to obtain a new meaning because an
identifier in the default value resolves to a declaration in the class body
when it occurs in _k_ after the transformation, but it used to resolve to
a top-level or imported declaration before the transformation.*

For each of these constant variable declarations, the declared type is the
formal parameter type of the corresponding formal parameter, except: In the
case where the corresponding formal parameter has a type `T` where one or
more type variables declared by _D_ occur, the declared type of the
constant variable is the least closure of `T` with respect to the type
parameters of the class.

*For example, if the default value is `const []` and the parameter type is
`List<X>`, the top-level constant will be `const List<Never> _n = [];` for
some fresh name `_n`.*
well-defined, taking into account that the primary constructor is
physically located in a different scope than normal non-primary
constructors. We do this by specifying the current scope explicitly as the
body scope, in spite of the fact that the primary constructor is actually
placed outside the braces that delimit the class body.*

Next, _k_ has the modifier `const` iff the keyword `const` occurs just
before the name of _D_, or _D_ is an `enum` declaration.
Expand All @@ -461,23 +474,25 @@ type parameter list, if any, and `.id`, if any.
The formal parameter list _L2_ of _k_ is identical to _L_, except that each
formal parameter is processed as follows.

In particular, the formal parameters in _L_ and _L2_ occur in the same
order, and mandatory positional parameters remain mandatory, and named
parameters preserve the name and the modifier `required`, if any. An
optional positional or named parameter remains optional; if it has a
default value `d` in _L_ then it has the transformed default value `_n` in
_L2_, where `_n` is the name of the constant variable created for that
default value.
The formal parameters in _L_ and _L2_ occur in the same order, and
mandatory positional parameters remain mandatory, and named parameters
preserve the name and the modifier `required`, if any. An optional
positional or named parameter remains optional; if it has a default value
`d` in _L_ then it has the default value `d` in _L2_ as well.

- An initializing formal parameter *(e.g., `this.x`)* is copied from _L_ to
_L2_, using said transformed default value, if any, and otherwise
unchanged.
- A super parameter is copied from _L_ to _L2_ using said transformed
default value, if any, and is otherwise unchanged.
- A formal parameter (named or positional) of the form `T p` or `final T p`
where `T` is a type and `p` is an identifier is replaced in _L2_ by
`this.p`. A parameter of the same form but with a default value uses said
transformed default value.
_L2_, along with the default value, if any, and is otherwise unchanged.
- A super parameter is copied from _L_ to _L2_ along with the default
value, if any, and is otherwise unchanged.
- Assume that _p_ is a formal parameter (named or positional) of the form
`T p` or `final T p` where `T` is a type and `p` is an identifier.
Assume that _p_ occurs in the initializer list of _D_, in an element
which is not an assertion. In this case, _p_ occurs without changes in
_L2_. *Note that the parameter cannot be covariant in this case, that is
an error.*
- Otherwise, a formal parameter (named or positional) of the form `T p` or
`final T p` where `T` is a type and `p` is an identifier is replaced in
_L2_ by `this.p`, along with its default value, if any.
Next, an instance variable declaration of the form `T p;` or `final T p;`
is added to _D2_. The instance variable has the modifier `final` if the
parameter in _L_ is `final`, or _D_ is an `extension type` declaration,
Expand All @@ -487,48 +502,45 @@ default value.
removed from the parameter in _L2_, and it is added to the instance
variable declaration named `p`.

If there are any assertions following the formal parameter list _L_ then
_k_ has an initializer list with the same assertions in the same order.

The current scope of the assertions in _D_ is the formal parameter
initializer scope of the formal parameter list *(that is, they can see the
parameters including any initializing formals, the type parameters, and
everything in the library scope that isn't shadowed by the scopes in
between)*.
If there is an initializer list following the formal parameter list _L_ then
_k_ has an initializer list with the same elements in the same order.

The expressions in the assertions are subject to a transformation that
preserves the resolution of every identifier in said expressions when they
occur as part of the initializer list of _k_. *(In particular, an
identifier in an assertion expression cannot resolve to a declaration in
the class body)*.
*The current scope of the initializer list in _D_ is the body scope
of the enclosing declaration, which means that they preserve their
semantics when moved into the body.*

Finally, _k_ is added to _D2_, and _D_ is replaced by _D2_.

### Discussion

It could be argued that primary constructors should support arbitrary
superinvocations using the specified superclass:
It could be argued that primary constructors should not support
superinitializers because the resulting declaration is too complex to be
conveniently readable, and developers could just write a regular primary
constructor instead.

We expect that primary constructors will in practice be small and simple,
but they may use different subsets of the expressive power of the
mechanism. For example,


```dart
class B extends A { // OK.
B(int a): super(a);
}
// Use super parameters.
class B(int a) extends A(a); // Could be supported, but isn't!
```
class const Point2D(int x, int y);
class const Point3D(super.x, super.y, int z) extends Point2D;
// Use a named constructor and a computed super argument.
There are several reasons why this is not supported. First, primary
constructors should be small and easy to read. Next, it is not obvious how
the superconstructor arguments would fit into a mixin application (e.g.,
when the superclass is `A with M1, M2`), or how readable it would be if the
superconstructor is named (`class B(int a) extends A with M1, M2.name(a);`).
For instance, would it be obvious to all readers that the superclass is `A`
and not `A.name`, and that all other constructors than the primary
constructor will ignore the implied superinitialization `super.name(a)` and
do their own thing (which might be implicit)?
class A._(int x);
In short, if you need to write a complex superinitialization like
`super.name(e1, otherName: e2)` then you need to use a body constructor.
class B(int y): assert(y > 2), super._(y - 1)
extends A with Mixin1, Mixin2;
```

Like many other language mechanisms, primary constructors need developers
to use their human judgment to create declarations that are both readable,
useful, and maintainable.

There was a [proposal from Bob][] that the primary constructor should be
expressed at the end of the class header, in order to avoid readability
Expand Down Expand Up @@ -598,17 +610,15 @@ class Point {
class final Point(int x, int y); // Not supported!
```

Most likely, there is an easy workaround: Make the constructor `const`. It
is very often possible to make the constructor `const`, even in the case
where the class isn't necessarily intended to be used in constant
expressions: There is no initializer list, no superinitialization, no
body. The only way it can be an error to use `const` on a primary
constructor is if the superclass doesn't have a constant constructor, or if
the class has a mutable or late instance variable, or it has some
non-constant expressions in instance variable declarations. (Those issues
can only be created by instance variables that are declared explicitly in
the class body whereas the ones that are created by primary constructor
parameters will necessarily satisfy the `const` requirements).
There is an easy partial workaround: Make the constructor `const`. It is
very often possible to make the constructor `const`, even in the case where
the class isn't necessarily intended to be used in constant expressions:
There is no body. The only ways it can be an error to use `const` on a
primary constructor is if the superclass doesn't have a constant
constructor, or if the class has a mutable or late instance variable, or it
has some non-constant expressions in instance variable declarations or in
the initializer list. Using `const` is not a complete solution, but
probably OK in practice.

Finally, we could allow a primary constructor to be declared in the body of
a class or similar declaration, possibly using a modifier like `primary`,
Expand All @@ -631,11 +641,10 @@ class D<TypeVariable extends Bound> extends A with M implements B, C {
```

This approach offers more flexibility in that a primary constructor in the
body of the declaration can have initializers and a body, just like other
constructors. In other words, `primary` on a constructor has one effect
only, which is to introduce instance variables for formal parameters in the
same way as a primary constructor in the header of the declaration. For
example:
body of the declaration can have a body, just like other constructors. In
other words, `primary` on a constructor has one effect only, which is to
introduce instance variables for formal parameters in the same way as a
primary constructor in the header of the declaration. For example:

```dart
// Current syntax.
Expand Down Expand Up @@ -698,13 +707,20 @@ class E extends A {
```

We may get rid of all those occurrences of `required` in the situation
where it is a compile-time error to not have them, but that is a
where it is a compile-time error to not have them, but that is a
[separate proposal][inferred-required].

[inferred-required]: https://github.com/dart-lang/language/blob/main/working/0015-infer-required/feature-specification.md

### Changelog

1.4 - November 12, 2024

* Add support for a full initializer list (which adds elements of the form
`x = e` and `super(...)` or `super.name(...)`). Add the rule that a
parameter introduces an instance variable except when used in the
initializer list.

1.3 - July 12, 2024

* Add support for assertions in the primary constructor. Add support for
Expand Down

0 comments on commit a530cd6

Please sign in to comment.