Description
This issue captures some in-person discussion @lrhn and I had yesterday and today. A number of proposals were floated, and I'd like to hear others' opinions. Lasse, please let me know if I get some details wrong.
Proposal: promotions are recorded as a chain.
It would be nice to be able to write the following code:
// Example 1
void f(Object x) {
if (x is! num) throw ...; // Promotes `x` to `num`
if (x is int) { // Promotes `x` to `int`
...
} else {
if (x is! double) throw ...; // Promotes `x` to `double`.
}
x + 1; // Ok because `x` is known to be `num`.
}
(Note that all these examples assume the code has been "opted in", so that types are non-nullable by default, and enhanced type promotion and definite assignment analysis are enabled).
Consider what happens when type promotion analysis reaches the end of the second if
statement. It has to join a control flow path in which x
is promoted to int
with a control flow path in which x
is promoted to double
. We would like it to remember the promotion to num
.
The proposed way to do this is for the promotion process to record each promotion as a chain of types, where each type in the chain is a subtype of the next link in the chain. So if (x is! num) throw ...;
promotes x
from Object
to num <: Object
; if (x is int)
promotes to int <: num <: Object
; and if (x is! double) throw ...;
promotes to double <: num <: Object
. Joining two control flow paths is now a simple* matter of rewinding mismatched parts of the two chains until a matching type is found.
(*Ok, maybe it's not actually that simple. How do we join, for example, int <: Object
with int <: num <: Object
? What if the two chains form a diamond, e.g. List<int> <: List<num> <: Iterable<num>
and List<int> <: Iterable<int> <: Iterable<num>
?)
Proposal: some assignments promote.
It would be nice for type promotion to account for assignments, so e.g. one can do something like this:
// Example 2
void f(Object o) {
if (o is! String) o = o.toString();
o.length; // Ok because `o` has promoted type `String`
}
However, not every assignment should promote. Sometimes the user wants to explicitly declare a variable to have a more general type in order to avoid accidentally making use of more specific methods, and we want to preserve that ability. For example:
// Example 3
void f() {
Iterable<int> x = ...;
x = <int>[1, 2, 3];
x[0] = 4; // Compile-time error: `Iterable<int>` does not support `operator[]`.
}
The proposed rule is that part of the state carried by the type promotion engine is a set* of "types of interest" for each variable, and an assignment will only promote if the type of the RHS is a subtype of one of the types of interest. So in example 1, if (o is! String)
causes o
to be promoted to String
in the "else" branch, and to be "interested in" String
in the "then" branch.
Then o = o.toString();
successfully promotes it, since it the type String
is of interest. Then, at the end of the if
statement, when the two control flow paths are joined, the promotion is preserved because it occurs along both control flow paths. However, in example 2, x = <int>[1, 2, 3];
causes no promotion because there are no types of interest to promote to.
(*Representing the types of interest as a set carries the disadvantage that if the RHS is a subtype of multiple types of interest it might not be clear which type to promote to; perhaps we should use a chain here too.)
Proposal: allow uninitialized non-nullable final variables.
In the area of definite assignment, it would be nice to be able to write final
without an initializer, provided that definite assignment analysis can prove that the variable is definitely assigned before it is used. For example:
// Example 4
void f() {
final int x;
if (...) {
x = 1;
} else {
x = 2;
}
print(x); // Ok because `x` is definitely assigned before it's used.
}
The current NNBD spec says that this works if final
is dropped (it's ok to not initialize a potentially non-nullable variable provided that it is also marked late
, or definite assignment analysis can prove that it is definitely assigned before it is read). Intuitively, the proposal is to have that the same rule apply for final variables.
However, the analysis is somewhat more complex because we need to verify that final variables aren't assigned twice. So this would be an error:
// Example 5
void f() {
final int x;
if (...) {
x = 1;
}
x = 2; // Compile-time error: `x` might be assigned more than once.
}
So the definite assignment algorithm would need to track three possible states for each final variable: definitely not assigned, possibly assigned, and definitely assigned. Note that the "possibly assigned" state is a little weird for final variables because it renders them useles: you can neither assign the variable nor use it once it gets in this state.
A further complication is that as the spec is currently written, it is permissible (though useless) to have a final variable with no initializer whose type is nullable. (Such a variable receives the value null
and cannot be changed). If we want to permit the pattern in example 4, it seems like we should prohibit the "useless null final" pattern, e.g.:
// Example 6
void f() {
final int? x;
print(x); // Compile-time error: `x` not assigned
}
Proposal: assignments to definitely unassigned final variables trigger promotion
The two previous proposals above have an unfortunate interaction: if you drop the type of the final variable, it is not promoted. For example:
// Example 7
void f() {
final x; // Type is implicitly dynamic
if (...) {
x = 1; // Promotes to `int`
} else {
x = 2; // Promotes to `int`
}
x.length; // Compile-time error: `int` does not support `.length`.
}
It would be nice for type promotion to catch the bug in this case, and the previous argument that "we should only promote to types of interest" doesn't really seem as strong in this case, because the user didn't specify an explicit type.
So a further proposal is to waive the "types of interest" requirement when assigning to a definitely unassigned final variable with an implicit type. So in the above example, x.length
would be caught as a compile-time error.
Proposal: assignments to definitely unassigned non-final variables also trigger promotion
It would be nice to have the invariant that changing "final" to "var" doesn't reduce the strength of type promotion. So for example, if we accept the proposal above, it would be nice for this to work too:
// Example 8
void f() {
var x; // No longer `final`
if (...) {
x = 1; // Promotes to `int`
} else {
x = 2; // Promotes to `int`
}
x.length; // Compile-time error: `int` does not support `.length`.
}
When we discussed this today, we thought this would force us to have a new kind of state to represent the state of x
after var x;
. But on further reflection, we don't; this is the "definitely not assigned" state.
Proposal: use definite assignment analysis to flag compile time errors for obviously wrong code using "late"
The late
keyword silences most of the errors we've been discussing here, causing runtime checks to occur instead. The idea is that the user doesn't want to be hindered when they know their code will work at runtime, but the compiler can't prove it. But we could consider still flagging it as an error if the compiler can prove that the code won't work at runtime. For example:
// Example 9
void f() {
late final x;
x = 1;
x = 2; // Compile-time error: `x` already definitely assigned
}
void g() {
late final x;
print(x); // Compile-time error: `x` definitely not assigned
}
After some discussion, we agreed that this probably isn't a good idea, for two reasons:
- It's a lot easier to explain the behavior of
late
as "enforces finality and definite assignment at runtime rather than compile time" rather than "enforces finality and definite assignment at runtime rather than compile time, unless definite assignment analysis can prove that a runtime error would occur in a certain code path" - We don't want the user to get into the habit of seeing a compile-time error with a variable, adding
late
as an experiment to see if the error goes away, and taking that as a signal that the error wasn't legitimate.