-
Notifications
You must be signed in to change notification settings - Fork 214
Finalize details of initialization based promotion. #946
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
Comments
For what it's worth, I think my preferred way to think about this is that the If I start from that, I get the following answers to the above:
In summary then, my initial proposal is:
Thoughts? |
Very good thoughts. I fear it's a little more than we can get away with, though. So, a declaration of the form On assignment, a variable with no type considers all types as being of interest and promotes to the assigned type. It now has that type. That's its minimal known type along that branch, there is nothing above that type that it can be demoted to. (So no to 3., the variable cannot be demoted). So, question 1: I want to make it an error to read the variable while it's still not definitely assigned, but I don't think it's possible. It will likely be a problem for loosely written script files which are used to the variable just being var x;
if (something) x = "option";
return foo(x: x); I don't think we can make that an error now. I wish that we could, but I fear it's too breaking. (For a final variable, I would make it an error to read the variable uninitialized. The ability to assign to a final variable is completely new, so we don't need to worry about legacy code). So, in practice: Yes to error if definitely unassigned, no to error if potentially assigned. That brings us to 2: If it's not an error to read That is, any unassigned path joined with an assigned path provides "make the type nullable" as its contribution. Since So, what is the type after joining, say, branches with types
I think that "same type" is too strict, and we always get into problems when defining which types are the "same type". Rather not go there. The "maximum type" doesn't work well in practice (it's not associative because it's a partial function: MAX(MAX(num, int), double) is num, MAX(num, MAX(int, double)) is undefined). So, trying to reinvent LUB needs something better. The LUB has issues, but it's also what users are used to. It's what you get from Take a simple example: var x;
if (useDoubles) {
x = 0.0;
} else {
x = 0;
} Users may assume this infers So, I think LUB/UP is the correct choice, even with all its inherent issues. I hope they are fewer now that we don't have implicit downcast. So, in summary:
It's an error to read the variable if it is definitely unassigned or it's
It has type For final variables, 1) is an error, so no type is needed until definitely assigned along disjoint branches.
No demotion above its first assigned type. That is its most general known type, there is no type of interest above that to demote to. This ensures that the uninitialized branch doesn't get silently ignored. The type becomes nullable, which is immediately obvious to someone who tries to use the variable. If it's OK that it's nullable, then the code just works.
No. That would probably be too cumbersome in practice, and it breaks the
Probably no. I wouldn't mind, but we have style guides telling people not to write |
Note that we don't use LUB in promotion/demotion anywhere else, so this is both new work, and also somewhat inconsistent: var x;
dynamic y;
if (useDoubles) {
x = 0.0;
if (y is! double) {
y = 0.0;
} // y promoted to double
} else {
x = 0;
if (y is! int) {
y = 0;
} // y promoted to int
}
// x is "demoted" to num
// y is "demoted" to dynamic There are also a lot of questions raised about when to apply LUB. class A {}; class B extends A {}; class C extends B {};
var x;
if (something) {
x = B(); // promote to B
} else {
x = A(); // promote to A
if (x is! C) {
x = C(); // promote to C
}
}
// is x demoted to LUB(B, A) or LUB(B, C)? I suspect that you can also make examples with switches for which the order that you apply the LUB matters, by forcing a different path through the multiple inheritance graph. To summarize what I'm hearing so far: Definite assignment errors
Demoted type
Does that cover it? |
I think that covers it well, yes. It's "too breaking", not "bad idea" in both cases. I wouldn't mind switching to any un-initialized variable being, well, definitly uninitialized. |
We discussed this in the language meeting today, some notes for further discussion follow. The discussion points were the following:
There is a general desire to have as much uniformity of behavior as possible - changing a variable from explicitly typed to implicitly typed, var to final, etc should preferably not deeply change the behavior. In so far as the behavior is different, there is a desire to have the difference be clear in the static analysis, rather than invisible (i.e. falling over to dynamic). There is a general readability concern.
|
We should also think about how these proposed rules affect the migration tool. One of the goals of the migration tool is to produce code that doesn't have compile-time errors, so we need to make sure the tool makes whatever changes are necessary to avoid triggering these new errors. My current thinking is that the migration tool should probably change any local Regarding the idea about making it an error to use a variable declared |
I spent a bunch of time working through all of the different combinations of declaration style ( Here is a proposal: Let the initializing assignments of a local variable be all of the assignments to it that occur in positions where the variable is definitely unassigned. So, in: test() {
var f;
if (c) {
f = 1;
} else {
f = 2;
f = 3;
}
f = 4;
} The initializing assignments are test() {
var f = 1;
f = 2;
} The initializing assignment is just the one on the declaration, Given that, the static type of an unannotated local variable is the LUB of the types of all of its initializing assignments. Taking the LUB of a bunch of expressions scattered through the function sounds spooky, but I hope that the fact that we only include definitely unassigned ones limits the potential for cycles. So in the above examples, test() {
var f;
if (c) {
f = 1;
} else {
f = 2.3;
}
} We infer The rule for which operations are allowed (in addition to the existing rules about assigning to Is that sufficient? Walking through the original examples: void test1And2(bool b) {
var x; // Infer int from initializing assignment (A).
if (b) {
var l = x; // Error: Accessing non-nullable when not definitely assigned.
x = 3; // (A). No promotion here, x is already int.
}
var a = x; // Error: Accessing non-nullable when not definitely assigned.
}
void test3(bool b) {
var x; // Infer int from initializing assignment (A).
if (b) {
x = 3; // (A). No promotion here, x is already int.
}
// No demotion, still int.
var y; // Infer int from initializing assignment (B).
y = 3; // (B). No promotion, y is already int.
y = "hello"; // Error: Can't assign string to int.
}
void test4(bool b) {
var x; // Infer Object from LUB of int (A) and String (B).
if (b) {
x = 3; // (A). Promote Object to int.
x.isEven; // OK, x is promoted to int.
} else {
x = "hello"; // (B). Promote Object to String.
x.length; // OK, x is promoted to String.
}
// Demote x back to Object.
var l = x; // Infer Object for l from x.
x.arglbargle; // Error: No arglbargle on Object.
}
void test5(bool b) {
String? x; // Use declared type.
if (b) {
print(x); // Not error because x is nullable. Initialized with null.
x = "hello"; // Promote to String.
}
// Demote back to String?
print(x); // No error.
} Thoughts? |
@munificent What would you propose that the types of void test1And2(bool b) {
var x;
if (b) {
var l = x;
x = l;
} else {
x = null;
}
} |
I guess Bob's approach requires that reading a definitely unassigned variable is an error, so you can't do Bob's approach;
That means allows: String? z = ...;
var x;
if (something) {
// x definitely unassigned.
x = z; // Only initializing assignment to x.
// x definitely assigned
}
// x potentially assigned/unassigned.
print(x); // x has type String?, so it's implicitly intialized to null, and this access is OK. How about: Iterable<String?> zs = ...;
var x;
while (z in zs) {
// x potentially assigned/unassigned.
x = z; // Not an initializing assignment to x.
// x definitely assigned
}
// x potentially assigned/unassigned.
print(x); What is the type of var x;
do {
// x is potentially assigned.
if (someTest) {
x = 42;
} else {
x = 37;
}
} while (someOtherTest);
print(x); // x is definitely assigned. Here It's convoluted code. It does suggest a variant on Bob's approach where we use potentially unassigned assignments: Not Bob's approach;
This would disallow: String? z = ...;
var x;
if (something) {
// x definitely unassigned.
x = z; // Only initializing assignment to x.
// x definitely assigned
}
// x potentially assigned/unassigned.
print(x); // x is inferred as String?, but it's potentially unassigned so it can't be read. because it's reading It would allow var x;
do {
// x is potentially assigned.
if (someTest) {
x = 42;
} else {
x = 37;
}
} while (someOtherTest);
print(x); // x is definitely assigned. because both assignments are at potentially unassigned positions, and so we infer (For a final variable, there is no difference between the two approaches because you can't assign to a potentially assigned final variable anyway). |
Going back to my initial reason for this feature, it was to avoid having to write types on variables even if they are initialized a little later. The original inspiration was: StreamSubscripton<int> subscription;
subscription = streamOfInt.listen(... something something subscription.cancel() ...); That code is no longer valid, not even with the declared type. The cases that do make sense is where you declare a variable prior to initializing it because you will definitely assign something to it, but you will do so inside a nested statement (likely an The canonical version of that is:
I use two variables to preclude using a conditional expression as the initializer. The values still have the same type, which is also not necessarily general. Widget widget;
if (horizontal) {
state.direction = Direction.horizontal;
widget = HorizontalListWidget(data);
} else {
state.direction = Direction.vertical;
widget = VerticalListWidget(data);
} In these cases, I'd like to avoid writing the type. It's generally situations where you can almost rewrite it to (So, if we had tuples and statement/block expressions, we'd probably be fine, then you could compute all the variable initializations on each branch and join them at the end). I want rules to match this. That means to use LUB as the join because that's what a conditional expression does. So, which assignments count and what happens on branches where there is no assignment? var x;
x = e;
// Should work exactly as: var x = e;`
var x;
if (test) {
x = e1;
} else {
x = e2;
}
// Should work exactly as `var x = test ? e1 : e2;`.
var x;
if (test) {
x = e;
}
if (x == null) ...
// Doesn't have to work. You can add an `else { x = null; }` if you want to. I think I'd like So, proposal: Assignment based type inference applies to non- An initializing expression is an assignment to the variable at a point where there has potentially been no assignment to the variable since its declaration. (Aka. potentially unassigned if you ignore the If the variable has no The type of the variable is inferred as the LUB if the static types of the initializing expressions, plus An initializing assignment will always promote to its assigned type. So, Bob's examples are unchanged. void test1And2(bool b) {
var x; // No initializer.
if (b) {
var l = x; // Reading while potentially unassigned -> error.
x = l;
} else {
x = null;
}
} If you write it with an void test1And2(bool b) {
var x = null; // infer Null as LUB(Null (here), Null (A), Null (B)).
if (b) {
var l = x; // x promoted to Null, l type Null
x = l; // (A) Initializing x to Null.
} else {
x = null; // (B) Initializing x to Null
}
// x has type Null.
} We could also allow you to write It can be used for direct initialzation too: var? x = "ab"; // x has type String?. Really helps if you currently need to do: StreamSubscription<Map<String, List<String>>>? subscription = stream.listen(...);
// would become:
var? subscription = stream.listen(...); I think that's actually a useful feature by itself, but it can interact with assignment based initialization too. |
Realistically, all of these (Bob and not-Bob) are non-starters given our current approaches. They all require a fix-point computation over the AST, since in order to figure out the type of a variable var x;
var y;
var z;
if (b) {
x = 3; // Initializing assignment at int
y = x; // Initializing assignment at the type eventually inferred for x
} else {
if (otherb) {
z = 3; // Initializing assignment at int
y = z; // Initializing assignment at the type eventually inferred for z
} else {
y = 3; // Initializing assignment at int
z = y; // Initializing assignment at the type eventually inferred for y
}
x = z; // Initializing assignment at the type eventually inferred for z
} |
The problem with this, as I mention in the previous comment, is that the type of the initializer is not well-defined independently of the type of
So just to clarify, you intend this code to be legal? final x = null;
if (b) {
x =3;
} |
I have had that exact syntax idea in mind of for a while too. I'm not sure if I like it yet, but the fact that it occurred to both of us a positive sign.
You're right. I was hoping that my proposal would dodge the need for a fixed-point computation by only using the definitely unassigned positions as initializers. I wanted to statically prevent cycles from occurring at all. But as you note, definite assignment tracking isn't sufficient. I think fields generally provide the rules and guarantees users want:
Fields enforce those rules using constructor initialization lists. Fields can be annoying to work with because the rules are draconian. (That's basically why we have factory constructors.) So the question is can we give users those same guarantees with locals? The simple answer is "yes":
Done! I think that's basically what the original NNBD behavior required. The question here then is are there any simple extensions we can come up with that provide the same guarantees we get from fields but aren't that strict? The two rules I proposed don't work. Making them work using some kind of fixpoint computation certainly fails "simple". One option is to take my proposal and add a third rule:
This would make Leaf's example an error: void test1And2(bool b) {
var x;
if (b) {
var l = x;
x = l; // <- Error: initializing assignment "= l" depends on x.
} else {
x = null;
}
} What does "depends on" mean? I'm not precisely sure, but we already have it in the language. From the spec:
I've never seen a definition for "depends on" in that sentence, but it seems to be meaningful enough for us to use it for many years. There is also this rule:
This is a nice textually precise way of making it an error for a local variable to refer to itself in its own initializer. Conceptually, that's the same goal we're trying to achieve while allowing a local variable to be initialized in multiple places. Personally, my feeling is that if we can't come up with simple extensions to let users do
|
Do you feel that my initial proposal isn't simple? One of the things that I liked about it was that it was, in fact, very simple.
Some principles that are leading me to the above:
As a consequence of the above, I think it should be an error to use a final variable before it is definitely assigned, and an error to assign to a final variable if it is potentially assigned. The one point of difference seems to be that there seems to be a general desire to allow initialization at different types, and to compute the LUB from there. The above is easily adjusted to allow for this: Option 1
This satisfies your 1, 2, and 3 properties of fields.
If we feel that your property 4 is important, we modify as follows: Option 2
Or, slightly more aggressive on the inference: Option 3Same as Option 2, except that: Examples: void test1And2(bool b) {
var x;
final y;
if (b) {
var l = x; // `x` is definitely unassigned. Error in all 3 options.
x = 3; // Promotes x to int
y = 3; // Promotes y to int
}
}
void test3(bool b) {
var x;
final y;
if (b) {
x = 3; // Promotes x to int
y = 3;
} // x and y have type int in option 1, int? in option 2 and 3
var a = x; // Error in option 1, ok with type `int?` in options 2 and 3
var b = y; // Error in all three options.
if (otherb) {
x = 3; // Ok in all options
y = 3; // Error in all options
}
x = 3.0; // Error in option 1 and option 2, promotes to num? in option 3.
y = 3.0; // Error in all options
x = "hello"; // Error in all options, x is already definitely assigned, so no LUB demotion
}
void test4(bool b) {
var x;
final y;
if (b) {
x = 3;
y = 3;
x.isEven; // Ok, x is promoted to int.
y.isEven; // Ok, y is promoted to int.
} else {
x = "hello";
y = "hello";
x.length; // Ok, x is promoted to String
y.length; // Ok, y is promoted to String
}
var a = x; // Ok in all options, x has type Object
var a = y; // Ok in all options, y has type Object
x = 3.0; // Ok in all options, x has type Object
y = 3.0 // Error in all options, y is final
x = null; // Error in all options, x has type Object
}
|
Sorry, I got a little lost in the weeds and forgot the initial proposal. (It's been one of those weeks.) Yes, I do like your proposal and I do think it is simple.
I agree with those principles too. They line up well with the rules I listed for fields.
I don't know if I would say that I desire using LUB, just that it was an option I wanted to consider. On further thought, I think I like your original proposal of it being an error for the types to be different. Your proposal is a little unclear on how annotated variables are handled, and I couldn't find anything in the flow analysis spec about them. Is this an error? test1() {
String s; // Is this allowed?
if (c) {
s = "a";
} else {
s = "b";
}
print(s); // OK?
}
test2()
String s; // Never initialized, never used. OK?
}
test3()
String? s; // Annotated nullable, used without initialization.
print(s); // OK?
} I think these questions are important because if we make it an error to initialize to different types, then the solution should be to annotate the local variable. Doing that should still give us behavior we like. In particular, even if you annotate with a non-nullable type, I think you should be able to still use the deferred initialization. If you annotate with a nullable type, I think it's somewhat nice to default initialize to Let me try to fill in some additional corners of your initial proposal. These rules should cover yours and more:
|
I'll admit to being torn on this.
Yes, this issue was filed about promotion of unannotated variables, but I think it's clear we can't think about them in isolation. We need a consistent package. My current thinking is:
I don't know what the right answer is yet for non-final nullable variables. There is one approach which makes them consistent with final variables:
There's another approach which seems more in line with how nullable variables are used:
The latter is your suggestion. It's less consistent, but the desire for consistency here may just be a foolish hobgoblin.
I think test1 and test2 should be ok. We could outlaw test2, but that feels unnecessary to me. The analyzer will hint, but if you really want it, knock yourself out. I'm on the fence about test3.
This all seems reasonable to me. The asymmetry between final and mutable is like a trick mirror - it looks good or bad depending on the angle I look from... :)
All seems reasonable to me.
This all seems good to me.
This seems reasonable. The examples that give me a small pause are ones like the following: void firstTry() {
var x;
if (cond) {
x = 3;
}
use(x); // Error
}
void secondTry() {
var x;
if (cond) {
x = 3;
} else {
x = null;
}
use(x); // Still error
}
void thirdTry() {
int? x;
if (cond) {
x = 3;
}
use(x); // Ok
} I can see a user being a bit frustrated going through those steps. It feels "reasonable" to not assign to |
It's consistent with fields, though, which is the main reason I suggested it. I think people do generally like the implicit
Agreed. The really odd one is: test() {
final x;
// Never used.
final int x;
// Also never used.
} You'll get unused variable warnings, so it's probably OK for this to be allowed, but it feels weird to me to have a final variable that never gets initialized. Looking at:
That is an interesting one. This is a case where doing LUB of the initializers would do what the user wants. You'd infer Maybe it's safest to just not special case this and try to have good error messages to guide people towards the |
We never want So let's say that the new We already prevent reading potentially unassigned variables unless they are If all assignments promoted, we wouldn't be considering which assignments would count as initializing assignments. That wouldn't be the issue then, only what the initial type of the variable would be, and how to join two branches. So let's just worry about that for a moment. Cases that should work include: var x;
if (test) {
x = 0;
} else {
x = 1;
}
x.toRadixString(10); and var x;
if (test) {
x = 0;
} else {
x = "no";
}
print(x); That is, initializing to the same type definitely preserves the type on join. Definitely initializing at least promotes to Then we can get into clever cases like ( var x;
if (test) {
x = a;
if (x is! C) x = C.fromA(x);
} else {
x = b;
if (x is! C) x = C.fromB(x);
} Here the type of So, let's just say that a Any assignment to a variable with an empty type chain promotes to the assigned type (and the assigned type is considered a type of interest, if that matters). How do we join two such chains? The plain intersection would make my second example above give an empty chain, which means that the variable can't be used even if it has definitely been assigned. So, let's define the join of two type chains, c1 = t1::r1 and c2 = t2::r2, as follows:
For a variable with a declared or inferred (from initializer) type, the top of the chain (t1, t2) will always be that type, so it's just plain intersection of chains. A case like the diamond So, in short:
An instance variable with no type or initializer inherits its type from its superinterfaces, if any, and if there are no declarations with that name in the superinterfaces, it just has type All existing rules for assignment to finals still apply: No assigning unless the variable is definitely unassigned. No reading unless it's definitely assigned. (Unless it's Normal promotion can apply to an empty chain, the variable is treated as having type late var x;
do {
if (something) x = y;
} while (itsNotInitialized);
// x has empty type chain. Treated as Object?, also for promotion.
if (x is Foo) {
// x is Foo!
} The answer to the five questions would then be:
It's a normal definitely unassigned variable. You're not allowed to read it until it's definitely assigned, or if it's potentially assigned and
Depends on what is meant by "demoted". For assignment, no. For join points, yes.
No. They will join to their LUB at join points.
No. I'd say yes, but we'd have to re-migrate everything with a declaration like It accepts |
It doesn't immediately seem too hard to support The appealing thing about supporting this is that it means that:
|
If you mean switching between It breaks my definitely assigned story. For a I think I'd rather add |
I actually think that I mean
It makes sense to me. Reading a definitely unassigned variable is stupid code - just replace it with the
This is tempting, but I'm worried about it. We have no mechanism in place for exposing the same API at different types, which means that a non-local variable or field |
Not the way I understand the idea: var x;
if (test) x = "a";
print(x); Here you only read x when it's not definitely unassigned, and you only assign It can work, and it might be convenient, but it still feels a little ad-hoc to me. It will help people writing code the way they are used to. As for the meaning of |
@munificent suggested an alternative which is to just say that a non-local declaration We could in addition also consider just disallowing |
Ok, I'm going to put together a concrete proposal based on this for us to discuss, and hopefully move forward on. Initial stake in the ground looks like this. The set of errors is as follows: Read Errors:
Write Errors:
Further:
|
(I guess the result of that LUB would again count as an initialization inferred type). Have to be careful here. For (assume C <: A): foo(A a, C c) {
var x;
if (something) {
x = a;
if (a is! C) {
x = C.fromA(a);
}
} else {
x = c;
}
// Joining chains A::C and C.
} I do want to get C out of this, but that means intersecting an initialization inferred type (IFT) with a non-IFT type. So, how are the two or more chains with IFTs at the top joined.
The second option would not get C in the above example. Checking, it turns out that some of these are not associative. If we try joining the chains Object'::num::, int':: and double':: (where the ' means an IFT) as both we get: 1A. (Object'::num:: ⊕ int'::) ⊕ double':: ↦ Object' ⊕ double':: ↦ Object' 2A. (Object'::num:: ⊕ int'::) ⊕ double':: ↦ Object' ⊕ double':: ↦ Object' 3A. (Object'::num:: ⊕ int'::) ⊕ double':: ↦ Object' ⊕ double':: ↦ Object' So, none of the options are associative. I actually thought one of then would be. I think the result for 3 is probably what I want for B, but I want that for A as well then. What can we do instead? Not use LUB, obviously. I'd like to use LUB for some situations, but maybe it's just not tenable. (When we intersect, would |
Option 2 is associative (iff LUB is associative, which I'm not sure it is). |
Initial proposal here. |
I would just make these errors. If we have a principle of "non-nullable by default" where you have to opt in to anything accepting null, that would imply that the language shouldn't silently give you |
My proposal does not address the former, I would prefer to address any changes to non-local variables in a follow up. For
This basically means you can never read a For non- |
I think that's the correct way to handle late variables: Treat them as if they have been assigned at the point where they are read, even though we cannot statically determine that it's true. As such, the type that it has is one of the types that were assigned. |
An alternative proposal eliminating the use of upper bounds is here. |
Ok, here's where I think we are. I have two proposals up (see previous comments). The primary difference between the two is whether initializations of the same variable at different types result in an error (and hence require a type annotation), or result in an upper bound computation. Widget build(BuildContext context) {
var child;
if (something) {
child = Text(...);
} else {
child = Padding(...);
}
return Something(..., child : child);
} In the upper bound proposal, code like the above is valid. The type of The main objections to the upper bound proposal boil down (I think) to the following:
The main objection to the simpler proposal is that code like the above seems like it ought to work. @munificent any thoughts on this? |
A variant of the simple proposal which allows nulls to be assigned explicitly and in some cases implicitly is here: #1009 |
@stereotype441 @munificent and I discussed these proposal further today. Some notes from the discussion. As a general principle, we are all on board with the goal that every variable can be thought of as having a single declared type, which is being "discovered" via the inference process. This specifically means that we prefer to specify this such it is an error to assign conflicting types even if the paths on which they are assigned don't converge: var x; // Error, no type can be inferred
if (b) {
x = 3;
return;
} else {
x = "hello";
return;
} // No valid type on the join, even though neither path reaches here We were all generally on board with making the presence or absence of inference errors independent of whether the variable was used in a bad state or not. In other words, the previous example should be an error, even though the variable in question is never actually used after the join. The biggest remaining question is how to deal with mutable variables (
Examples: var x;
if (b) {
x = 3;
} // x has type `int?` in option 1, error in 2 and 3 var x;
if (b) {
x = 3;
} else {
x = null;
} // x has type int? in all three proposals. var x = null;
if (b) {
x = 3; // Error in option 1 and 2, ok in 3
} // x has type int? in option 3 var x;
if (b) {
x = null;
x = 3; // Error in option 1 and 2, ok in 3
} // x has type int? in option 3 My latest draft of the nullable definite assignment proposal here currently takes option 1, and can easily be made to take option 2 instead. An informal proposal from @munificent is intended to get to option 3, but requires further work to see if it bears out. A key issue to work out for option 3 is how to safely handle back edges in loops, given that we do not iterate to a fixed point during flow analysis. Example:
|
I think I like option 3 as well. It's explicit, and it makes About var x = null;
while (b) {
Null y = x; // Is this valid? If so, we must guarantee that x is Null on the back edge
x = 3; // This assignment must either be disallowed, or the back edge assumption must safely approximate it
} I'd make that invalid. The read of |
@leafpetersen wrote:
Indeed, strongly agreeing on that. When we have two non-joining control flow paths where the variable has (very) different types, this still implies that it is a source of serious confusion for the reader of the code that this variable must be understood to have those distinct types at different locations in the code. To me, the ability to read the code is crucial. The focus on readability comes up with the stated models 1-3 as well: I'm worried about model 3: It breaks the otherwise consistent rule that an initializer on a local I'd be really happy about model 2, where the inferred type needs to be documented: both Model 1 would also work, because we as a community have a lot of experience with the rule that a But model 1 is still a somewhat quirky rule. In order to distinguish "initialized to null, gets nullable version of the declared type implied by init. assignments" from "gets its declared type from init. assignments", the developer would need to have an overview of all control flow paths and the init. assignments to this variable on those paths: if one or more of them are missing then we go with the "nullable" type. So that's what the developer needs to understand further down in the code when the (unexpectedly) nullable type causes errors. @lrhn wrote:
Yet another exception for an initializer of the form |
Right, so my preference is that option 3 is best if we can make it work in a relatively straightforward way. I just spent literally twenty seconds poking around the Flutter codebase and found: Future<HttpClientRequest> open(
String method, String host, int port, String path) {
// ...
String query = null;
if (queryStart < fragmentStart) {
query = path.substring(queryStart + 1, fragmentStart);
path = path.substring(0, queryStart);
}
// ...
} Option 3 would let you use To handle back edges, how about we simply make it an error if a variable does not have a fully inferred type by the point that it reaches the top of a loop? We don't want to implement an iterative solution and we sure as heck don't want users to have a run a fixpoint algorithm in their heads to figure out the type of a local variable. :) I look at all of these options as basically dealing with the fact that If option 3 doesn't pan out, option 2 is a reasonable fallback. It may make some code more tedious with either duplicate else branches or type annotations, but is otherwise pretty close. It's a little more verbose, but it's equally as safe as option 3. I actively dislike option 1 because it undermines our clear story that you always have to explicitly opt in to nullability. I take the "by default" part of NNBD seriously, and I think it's what most of our users want. Option 1 really bothers me because deleting a line of code that initializes a variable in some branch could silently turn that variable into a nullable one without you realizing. In many cases, that will cause an error downstream alerting you to the change, but that's not always true and I don't like relying on that. @eernstg is right that not doing option 1 is a significant change in idiomatic Dart since we've been telling users for years to rely on the implicitly initialization to Before Dart 2.0, we told people not to use |
SGTM. The one tweak I might suggest is: it's an error if a variable does not have a fully inferred type by the point that it reaches the top of a loop that assigns to it. We already pre-compute which variables any given loop assigns to for other flow-analysis-related reasons so I don't think this would be too much of a stretch, and it would allow us to accept more programs that are trivial to understand.
+1 |
+1! |
This is inconsistent on the face of it. The whole point of Option 3 (at least as proposed by @munificent ) is that
I'm not sure if you're envisioning doing something different with loops than from conditionals (and if so what?), or if you are proposing an Option 3a different from the proposal from @munificent in which reading the variable before it has been potentially assigned something typed is an error? |
I'll have to admit that I did not see that If var x = null;
var f = () => x; // Static type Null Function().
x = 2; // Demotion from Null to int? (a supertype).
Null y = f(); // Looks valid to me. So, if this is allowed, it's unsafe. We should not be allowed to promote to (If that is true, we might be in the lucky situation that any code which prevents the promotion to So, could the rules be stated as:
(It's an error to read a variable with no type. It is an error to assign to a variable with no type unless it's an initializing assingment. IIT Types at merges must be T, T? or Then: var x = null;
if (b) x = Foo();
// x has IIT and type Foo?. |
Update on this. We've decided to back off of the initialization based promotion due to complexity concerns. Remaining changes are only around definite assignment, specified here |
Final design landed here. |
Closing this out, the implementation issues are tracked. |
The flow analysis specification, based on discussion here includes support for promotion based on initialization assignments. Specifically:
T
subject to the usual restrictions around capture etc.In discussion, the language team still feels that this is a valuable feature. In discussion, several related questions were raised.
Examples:
cc @lrhn @eernstg @munificent @scheglov @stereotype441 @johnniwinther
The text was updated successfully, but these errors were encountered: